Download model.py from sgsuman/tower-defect-detector: direct link, hf CLI and curl.
- Browser
- Download file 15.8 kB
-
https://huggingface.co/sgsuman/tower-defect-detector/resolve/main/model.py
- Command line
-
hf download hf://sgsuman/tower-defect-detector/model.py
-
curl -L -o model.py https://huggingface.co/sgsuman/tower-defect-detector/resolve/main/model.py
15.8 kB
| """ | |
| Raw Custom Object Detection Neural Network | |
| Built from mathematical first principles in pure PyTorch (torch.nn) | |
| Zero third-party model libraries or frameworks. | |
| """ | |
| import math | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| class ConvBNAct(nn.Module): | |
| """ | |
| Mathematical Convolutional Block: | |
| Z = W * X + b | |
| Z_norm = gamma * (Z - mu) / sqrt(var + eps) + beta | |
| Out = SiLU(Z_norm) = Z_norm * sigmoid(Z_norm) | |
| """ | |
| def __init__(self, in_c, out_c, kernel_size=3, stride=1, padding=1): | |
| super().__init__() | |
| self.conv = nn.Conv2d(in_c, out_c, kernel_size, stride, padding, bias=False) | |
| self.bn = nn.BatchNorm2d(out_c) | |
| self.act = nn.SiLU(inplace=True) | |
| def forward(self, x): | |
| return self.act(self.bn(self.conv(x))) | |
| class SqueezeExcitation(nn.Module): | |
| """ | |
| Mathematical Squeeze-and-Excitation Channel Attention: | |
| Z_se = Sigmoid( W2 * SiLU( W1 * AdaptiveAvgPool2d(X) ) ) | |
| Out = X * Z_se | |
| Dynamically recalibrates channel-wise feature responses to amplify defect-specific | |
| textural frequencies (corrosion roughness, unbolted joint cavities) while suppressing | |
| clean structural background steelwork. | |
| """ | |
| def __init__(self, channels, reduction=16): | |
| super().__init__() | |
| reduced = max(8, channels // reduction) | |
| self.fc = nn.Sequential( | |
| nn.AdaptiveAvgPool2d(1), | |
| nn.Conv2d(channels, reduced, kernel_size=1, bias=True), | |
| nn.SiLU(inplace=True), | |
| nn.Conv2d(reduced, channels, kernel_size=1, bias=True), | |
| nn.Sigmoid() | |
| ) | |
| def forward(self, x): | |
| return x * self.fc(x) | |
| class ResidualBlock(nn.Module): | |
| """ | |
| Residual Skip Connection with Squeeze-and-Excitation Channel Attention: | |
| y = F(x, {W_i}) * SE(F(x)) + x | |
| Mitigates vanishing gradient and enables deep feature representations. | |
| """ | |
| def __init__(self, channels, use_se=True): | |
| super().__init__() | |
| reduced = max(16, channels // 2) | |
| self.c1 = ConvBNAct(channels, reduced, kernel_size=1, stride=1, padding=0) | |
| self.c2 = ConvBNAct(reduced, channels, kernel_size=3, stride=1, padding=1) | |
| self.se = SqueezeExcitation(channels) if use_se else nn.Identity() | |
| def forward(self, x): | |
| return x + self.se(self.c2(self.c1(x))) | |
| class SPPFLayer(nn.Module): | |
| """ | |
| Spatial Pyramid Pooling - Fast (SPPF): | |
| Fuses multi-scale receptive field context via sequential MaxPool2d(5, stride=1, padding=2), | |
| yielding equivalent pooling at 5x5, 9x9, and 13x13 receptive fields. | |
| Dramatically enhances semantic context for large defects (vegetation canopy, | |
| extended corrosion fields) with minimal computational latency. | |
| """ | |
| def __init__(self, in_c, out_c): | |
| super().__init__() | |
| c_mid = in_c // 2 | |
| self.c1 = ConvBNAct(in_c, c_mid, kernel_size=1, stride=1, padding=0) | |
| self.c2 = ConvBNAct(c_mid * 4, out_c, kernel_size=1, stride=1, padding=0) | |
| self.pool = nn.MaxPool2d(kernel_size=5, stride=1, padding=2) | |
| def forward(self, x): | |
| x = self.c1(x) | |
| p1 = self.pool(x) | |
| p2 = self.pool(p1) | |
| p3 = self.pool(p2) | |
| return self.c2(torch.cat([x, p1, p2, p3], dim=1)) | |
| class CustomBackbone(nn.Module): | |
| """ | |
| Deep Convolutional Feature Extractor with Multi-Scale Pyramid Hierarchy: | |
| Input: (B, 3, H, W) | |
| Stem: (B, 32, H/2, W/2) - Stride 2 | |
| Stage1: (B, 64, H/4, W/4) - Stride 4 | |
| Stage2: (B, 128, H/8, W/8) - Stride 8 -> Feature Map P3 (high resolution, small objects: bolts) | |
| Stage3: (B, 256, H/16, W/16) - Stride 16 -> Feature Map P4 (medium objects: nests, insulators) | |
| Stage4: (B, 512, H/32, W/32) - Stride 32 -> Feature Map P5 (global context & large defects: rust/veg) | |
| """ | |
| def __init__(self, in_channels=3): | |
| super().__init__() | |
| # Stem | |
| self.stem = ConvBNAct(in_channels, 32, kernel_size=3, stride=2, padding=1) | |
| # Stage 1: Stride 4 | |
| self.stage1 = nn.Sequential( | |
| ConvBNAct(32, 64, kernel_size=3, stride=2, padding=1), | |
| ResidualBlock(64), | |
| ) | |
| # Stage 2: Stride 8 (P3) | |
| self.stage2 = nn.Sequential( | |
| ConvBNAct(64, 128, kernel_size=3, stride=2, padding=1), | |
| ResidualBlock(128), | |
| ResidualBlock(128), | |
| ) | |
| # Stage 3: Stride 16 (P4) | |
| self.stage3 = nn.Sequential( | |
| ConvBNAct(128, 256, kernel_size=3, stride=2, padding=1), | |
| ResidualBlock(256), | |
| ResidualBlock(256), | |
| ) | |
| # Stage 4: Stride 32 (P5) with SPPF Receptive Field Aggregation | |
| self.stage4 = nn.Sequential( | |
| ConvBNAct(256, 512, kernel_size=3, stride=2, padding=1), | |
| ResidualBlock(512), | |
| ResidualBlock(512), | |
| SPPFLayer(512, 512), | |
| ) | |
| def forward(self, x): | |
| x = self.stem(x) | |
| x = self.stage1(x) | |
| p3 = self.stage2(x) | |
| p4 = self.stage3(p3) | |
| p5 = self.stage4(p4) | |
| return p3, p4, p5 | |
| class FeaturePyramidNeck(nn.Module): | |
| """ | |
| 3-Scale Multi-Scale Feature Aggregator (FPN + PAN dual-path fusion): | |
| Fuses high-level semantic abstractions from P5 and P4 into fine-grained spatial details in P3, | |
| then propagates refined spatial patterns back up to P4 and P5. | |
| """ | |
| def __init__(self, c3=128, c4=256, c5=512, out_c=128): | |
| super().__init__() | |
| # Lateral 1x1 convolutions to project all stages to uniform neck channel dimension | |
| self.lat_p5 = ConvBNAct(c5, out_c, kernel_size=1, stride=1, padding=0) | |
| self.lat_p4 = ConvBNAct(c4, out_c, kernel_size=1, stride=1, padding=0) | |
| self.lat_p3 = ConvBNAct(c3, out_c, kernel_size=1, stride=1, padding=0) | |
| # Top-down FPN smooth convolutions | |
| self.smooth_p4_td = ConvBNAct(out_c * 2, out_c, kernel_size=3, stride=1, padding=1) | |
| self.smooth_p3_td = ConvBNAct(out_c * 2, out_c, kernel_size=3, stride=1, padding=1) | |
| # Bottom-up PAN downsampling and smooth convolutions | |
| self.down_p3 = ConvBNAct(out_c, out_c, kernel_size=3, stride=2, padding=1) | |
| self.smooth_p4_pan = ConvBNAct(out_c * 2, out_c, kernel_size=3, stride=1, padding=1) | |
| self.down_p4 = ConvBNAct(out_c, out_c, kernel_size=3, stride=2, padding=1) | |
| self.smooth_p5_pan = ConvBNAct(out_c * 2, out_c, kernel_size=3, stride=1, padding=1) | |
| def forward(self, p3, p4, p5): | |
| # 1. Lateral projections | |
| p5_lat = self.lat_p5(p5) | |
| p4_lat = self.lat_p4(p4) | |
| p3_lat = self.lat_p3(p3) | |
| # 2. Top-down pathway (FPN) | |
| p5_up = F.interpolate(p5_lat, size=p4.shape[2:], mode="nearest") | |
| p4_td = self.smooth_p4_td(torch.cat([p4_lat, p5_up], dim=1)) | |
| p4_up = F.interpolate(p4_td, size=p3.shape[2:], mode="nearest") | |
| p3_out = self.smooth_p3_td(torch.cat([p3_lat, p4_up], dim=1)) | |
| # 3. Bottom-up pathway (PAN) | |
| p3_down = self.down_p3(p3_out) | |
| p4_out = self.smooth_p4_pan(torch.cat([p4_td, p3_down], dim=1)) | |
| p4_down = self.down_p4(p4_out) | |
| p5_out = self.smooth_p5_pan(torch.cat([p5_lat, p4_down], dim=1)) | |
| return p3_out, p4_out, p5_out | |
| class DecoupledDetectionHead(nn.Module): | |
| """ | |
| Mathematical Decoupled Prediction Head: | |
| Separates Box Regression [tx, ty, tw, th] from Objectness [obj] and Class Probabilities [cls_1..cls_C] | |
| """ | |
| def __init__(self, in_c, num_classes): | |
| super().__init__() | |
| self.num_classes = num_classes | |
| # Coordinate branch (tx, ty, tw, th) | |
| self.box_conv = nn.Sequential( | |
| ConvBNAct(in_c, in_c, kernel_size=3, stride=1, padding=1), | |
| nn.Conv2d(in_c, 4, kernel_size=1) | |
| ) | |
| # Objectness branch | |
| self.obj_conv = nn.Sequential( | |
| ConvBNAct(in_c, in_c // 2, kernel_size=3, stride=1, padding=1), | |
| nn.Conv2d(in_c // 2, 1, kernel_size=1) | |
| ) | |
| # Classification branch | |
| self.cls_conv = nn.Sequential( | |
| ConvBNAct(in_c, in_c, kernel_size=3, stride=1, padding=1), | |
| nn.Conv2d(in_c, num_classes, kernel_size=1) | |
| ) | |
| # Mathematical initialization: initialize objectness bias with negative prior | |
| # sigmoid(-4.59) ~ 0.01 to prevent initial gradient explosion on mostly empty backgrounds | |
| nn.init.constant_(self.obj_conv[-1].bias, -4.59) | |
| def forward(self, x): | |
| # x: (B, C, H_g, W_g) | |
| box = self.box_conv(x).permute(0, 2, 3, 1) # (B, H_g, W_g, 4) | |
| obj = self.obj_conv(x).permute(0, 2, 3, 1) # (B, H_g, W_g, 1) | |
| cls_logits = self.cls_conv(x).permute(0, 2, 3, 1) # (B, H_g, W_g, num_classes) | |
| # Concatenate predictions: [tx, ty, tw, th, obj, c1, ..., cK] | |
| return torch.cat([box, obj, cls_logits], dim=-1) | |
| class RawCustomDetector(nn.Module): | |
| """ | |
| End-to-end Raw Custom Object Detector | |
| Combines: | |
| - Custom Deep Residual Backbone with Squeeze-and-Excitation & SPPF | |
| - 3-Scale Multi-scale Feature Pyramid Neck (FPN + PAN) | |
| - Triple-scale Decoupled Detection Heads: | |
| * Stride 8 (P3): High resolution for tiny defects (missing_bolt, small rust spots) | |
| * Stride 16 (P4): Medium receptive field (bird_nest, damaged_insulator) | |
| * Stride 32 (P5): Global receptive field (extensive rust zones, vegetation canopy) | |
| """ | |
| def __init__(self, num_classes=5, in_channels=3): | |
| super().__init__() | |
| self.num_classes = num_classes | |
| self.strides = [8, 16, 32] | |
| self.backbone = CustomBackbone(in_channels=in_channels) | |
| self.neck = FeaturePyramidNeck(c3=128, c4=256, c5=512, out_c=128) | |
| self.head_p3 = DecoupledDetectionHead(in_c=128, num_classes=num_classes) | |
| self.head_p4 = DecoupledDetectionHead(in_c=128, num_classes=num_classes) | |
| self.head_p5 = DecoupledDetectionHead(in_c=128, num_classes=num_classes) | |
| def forward(self, x): | |
| p3, p4, p5 = self.backbone(x) | |
| p3_fused, p4_fused, p5_fused = self.neck(p3, p4, p5) | |
| pred_p3 = self.head_p3(p3_fused) # (B, H/8, W/8, 5 + num_classes) | |
| pred_p4 = self.head_p4(p4_fused) # (B, H/16, W/16, 5 + num_classes) | |
| pred_p5 = self.head_p5(p5_fused) # (B, H/32, W/32, 5 + num_classes) | |
| return [pred_p3, pred_p4, pred_p5] | |
| def decode_predictions(pred_maps, strides=None, conf_thresh=0.25): | |
| """ | |
| Mathematical coordinate decoding from raw model logits to bounding boxes [x1, y1, x2, y2, score, class_id]. | |
| bx = (grid_x + sigmoid(tx)) * stride | |
| by = (grid_y + sigmoid(ty)) * stride | |
| bw = exp(tw) * stride | |
| bh = exp(th) * stride | |
| """ | |
| if strides is None: | |
| if len(pred_maps) == 3: | |
| strides = [8, 16, 32] | |
| else: | |
| strides = [8, 16] | |
| batch_detections = [] | |
| batch_size = pred_maps[0].shape[0] | |
| for b in range(batch_size): | |
| all_boxes = [] | |
| all_scores = [] | |
| all_classes = [] | |
| for pred_map, stride in zip(pred_maps, strides): | |
| pred = pred_map[b] # (H_g, W_g, 5 + C) | |
| h_g, w_g, _ = pred.shape | |
| device = pred.device | |
| tx = pred[..., 0] | |
| ty = pred[..., 1] | |
| tw = pred[..., 2] | |
| th = pred[..., 3] | |
| obj_logit = pred[..., 4] | |
| cls_logits = pred[..., 5:] | |
| # Grid mesh | |
| gy, gx = torch.meshgrid( | |
| torch.arange(h_g, device=device, dtype=torch.float32), | |
| torch.arange(w_g, device=device, dtype=torch.float32), | |
| indexing="ij" | |
| ) | |
| # Mathematical center & size transformation | |
| bx = (gx + torch.sigmoid(tx)) * stride | |
| by = (gy + torch.sigmoid(ty)) * stride | |
| bw = torch.exp(torch.clamp(tw, max=5.0)) * stride | |
| bh = torch.exp(torch.clamp(th, max=5.0)) * stride | |
| # Convert to [x1, y1, x2, y2] | |
| x1 = bx - bw / 2.0 | |
| y1 = by - bh / 2.0 | |
| x2 = bx + bw / 2.0 | |
| y2 = by + bh / 2.0 | |
| # Probabilities | |
| obj_prob = torch.sigmoid(obj_logit) | |
| cls_prob = torch.softmax(cls_logits, dim=-1) | |
| scores = obj_prob.unsqueeze(-1) * cls_prob # (H_g, W_g, num_classes) | |
| # Flatten [x1, y1, x2, y2, bx, by] (bounding box + exact location point) | |
| boxes = torch.stack([x1, y1, x2, y2, bx, by], dim=-1).view(-1, 6) | |
| scores = scores.view(-1, cls_logits.shape[-1]) | |
| max_scores, class_ids = torch.max(scores, dim=-1) | |
| mask = max_scores >= conf_thresh | |
| if mask.sum() > 0: | |
| all_boxes.append(boxes[mask]) | |
| all_scores.append(max_scores[mask]) | |
| all_classes.append(class_ids[mask]) | |
| if len(all_boxes) > 0: | |
| batch_boxes = torch.cat(all_boxes, dim=0) | |
| batch_scores = torch.cat(all_scores, dim=0) | |
| batch_classes = torch.cat(all_classes, dim=0) | |
| batch_detections.append((batch_boxes, batch_scores, batch_classes)) | |
| else: | |
| batch_detections.append(( | |
| torch.empty((0, 6), device=pred_maps[0].device), | |
| torch.empty((0,), device=pred_maps[0].device), | |
| torch.empty((0,), dtype=torch.long, device=pred_maps[0].device) | |
| )) | |
| return batch_detections | |
| def custom_nms(boxes, scores, classes, iou_thresh=0.45): | |
| """ | |
| Mathematical Non-Maximum Suppression (NMS) | |
| Removes redundant overlapping bounding boxes using Intersection over Union (IoU). | |
| Preserves exact location points [x1, y1, x2, y2, bx, by, score, class_id]. | |
| """ | |
| if boxes.shape[0] == 0: | |
| return torch.empty((0, 8), device=boxes.device) | |
| keep_indices = [] | |
| # Separate NMS per class to avoid suppressing adjacent different defects | |
| unique_classes = torch.unique(classes) | |
| for c in unique_classes: | |
| c_mask = (classes == c) | |
| c_indices = torch.nonzero(c_mask).squeeze(-1) | |
| c_boxes = boxes[c_indices] | |
| c_scores = scores[c_indices] | |
| # Sort descending by score | |
| order = torch.argsort(c_scores, descending=True) | |
| keep = [] | |
| while order.numel() > 0: | |
| curr = order[0].item() | |
| keep.append(c_indices[curr].item()) | |
| if order.numel() == 1: | |
| break | |
| curr_box = c_boxes[order[0], :4].unsqueeze(0) | |
| rest_boxes = c_boxes[order[1:], :4] | |
| # Mathematical IoU | |
| x1 = torch.max(curr_box[:, 0], rest_boxes[:, 0]) | |
| y1 = torch.max(curr_box[:, 1], rest_boxes[:, 1]) | |
| x2 = torch.min(curr_box[:, 2], rest_boxes[:, 2]) | |
| y2 = torch.min(curr_box[:, 3], rest_boxes[:, 3]) | |
| inter_w = torch.clamp(x2 - x1, min=0) | |
| inter_h = torch.clamp(y2 - y1, min=0) | |
| inter_area = inter_w * inter_h | |
| area_curr = (curr_box[:, 2] - curr_box[:, 0]) * (curr_box[:, 3] - curr_box[:, 1]) | |
| area_rest = (rest_boxes[:, 2] - rest_boxes[:, 0]) * (rest_boxes[:, 3] - rest_boxes[:, 1]) | |
| union_area = area_curr + area_rest - inter_area + 1e-7 | |
| iou = inter_area / union_area | |
| remaining = torch.nonzero(iou <= iou_thresh).squeeze(-1) | |
| if remaining.numel() == 0: | |
| break | |
| order = order[remaining + 1] | |
| keep_indices.extend(keep) | |
| if len(keep_indices) == 0: | |
| return torch.empty((0, 8), device=boxes.device) | |
| keep_idx = torch.tensor(keep_indices, device=boxes.device, dtype=torch.long) | |
| final_boxes = boxes[keep_idx] # (N, 6): [x1, y1, x2, y2, bx, by] | |
| final_scores = scores[keep_idx].unsqueeze(-1) | |
| final_classes = classes[keep_idx].unsqueeze(-1).float() | |
| return torch.cat([final_boxes, final_scores, final_classes], dim=-1) | |