| """SCΒ³: Sub-Cell Center Classification for precise bbox localization. |
| |
| Core idea from three cross-domain works: |
| - SimCC (ECCV 2022, pose estimation): coordinate regression β coordinate classification |
| - FIP-GDE (CVPR 2025, tiny obj detection): size-adaptive Gaussian position distribution |
| - D-FINE (arXiv 2024, DETR detection): iterative distribution refinement |
| |
| For each anchor point, predicts a kΓk distribution over sub-cell center positions. |
| Replaces center derivation from (l,t,r,b) edges with explicit classification. |
| Classification is easier to optimize than regression for fine-grained values. |
| Soft-argmax gives sub-bin precision. |
| |
| At k=8, stride=8 (P3): each sub-cell = 1 pixel β 1px center precision. |
| """ |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import numpy as np |
|
|
|
|
| class SC3Head(nn.Module): |
| """Lightweight auxiliary head for sub-cell center classification. |
| |
| Takes FPN features (P3, P4, P5) and predicts a kΓk distribution |
| over sub-cell center positions for each anchor. Total added params < 5%. |
| """ |
|
|
| def __init__(self, in_channels, k=8): |
| """ |
| Args: |
| in_channels: list of ints, input channels per FPN level [C3, C4, C5] |
| k: sub-cell grid size (kΓk bins per anchor cell) |
| """ |
| super().__init__() |
| self.k = k |
| self.nl = len(in_channels) |
|
|
| |
| self.convs = nn.ModuleList() |
| for c_in in in_channels: |
| mid = max(c_in // 4, 32) |
| self.convs.append(nn.Sequential( |
| nn.Conv2d(c_in, mid, 3, padding=1), |
| nn.BatchNorm2d(mid), |
| nn.SiLU(inplace=True), |
| nn.Conv2d(mid, k * k, 1), |
| )) |
|
|
| self._init_weights() |
|
|
| def _init_weights(self): |
| for m in self.modules(): |
| if isinstance(m, nn.Conv2d): |
| nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') |
| if m.bias is not None: |
| |
| nn.init.constant_(m.bias, 0.0) |
|
|
| def forward(self, features): |
| """features: list of [B, C_i, H_i, W_i] FPN features (P3, P4, P5) |
| Returns: list of [B, k*k, H_i, W_i] logits per level |
| """ |
| return [conv(f) for conv, f in zip(self.convs, features)] |
|
|
|
|
| def decode_sc3_center(sc3_logits, anchors, strides, k): |
| """Decode SCΒ³ logits to refined center coordinates. |
| |
| Args: |
| sc3_logits: list of [B, k*k, H, W] per level |
| anchors: list of [N_i, 2] anchor grid points per level |
| strides: list of ints, stride per level |
| k: sub-cell grid size |
| |
| Returns: |
| centers: [N_total, 2] refined center (x, y) in image coords |
| """ |
| centers = [] |
| for i, logits in enumerate(sc3_logits): |
| B, _, H, W = logits.shape |
| |
| logits_2d = logits.view(B, k, k, H, W) |
| prob = F.softmax(logits_2d.reshape(B, k * k, H, W), dim=1) |
| prob = prob.view(B, k, k, H, W) |
|
|
| |
| gy, gx = torch.meshgrid( |
| torch.arange(k, device=logits.device, dtype=torch.float32), |
| torch.arange(k, device=logits.device, dtype=torch.float32), |
| indexing='ij' |
| ) |
| |
| gx_norm = (gx + 0.5) / k |
| gy_norm = (gy + 0.5) / k |
|
|
| |
| dx = (prob * gx_norm[None, :, :, None, None]).sum(dim=(1, 2)) |
| dy = (prob * gy_norm[None, :, :, None, None]).sum(dim=(1, 2)) |
|
|
| |
| anchor = anchors[i] |
| ax = anchor[:, 0].view(H, W) |
| ay = anchor[:, 1].view(H, W) |
|
|
| |
| cx = ax[None] + dx * strides[i] |
| cy = ay[None] + dy * strides[i] |
|
|
| |
| centers.append(torch.stack([cx.reshape(B, -1), cy.reshape(B, -1)], dim=-1)) |
|
|
| return torch.cat(centers, dim=1) |
|
|
|
|
| def make_gaussian_target(cx_sub, cy_sub, k, sigma): |
| """Create 2D Gaussian classification target on kΓk grid. |
| |
| Args: |
| cx_sub, cy_sub: GT center in sub-cell coords [0, k), [N] tensors |
| k: grid size |
| sigma: Gaussian width per sample, [N] tensor |
| |
| Returns: |
| target: [N, k, k] normalized Gaussian distributions |
| """ |
| gy, gx = torch.meshgrid( |
| torch.arange(k, device=cx_sub.device, dtype=torch.float32), |
| torch.arange(k, device=cx_sub.device, dtype=torch.float32), |
| indexing='ij' |
| ) |
| |
| gx_ctr = gx + 0.5 |
| gy_ctr = gy + 0.5 |
|
|
| |
| dx = gx_ctr[None] - cx_sub[:, None, None] |
| dy = gy_ctr[None] - cy_sub[:, None, None] |
| d2 = dx ** 2 + dy ** 2 |
|
|
| |
| sigma = sigma[:, None, None].clamp(min=0.3) |
| gauss = torch.exp(-d2 / (2 * sigma ** 2)) |
|
|
| |
| gauss = gauss / gauss.sum(dim=(1, 2), keepdim=True).clamp(min=1e-8) |
| return gauss |
|
|
|
|
| def compute_sc3_loss(sc3_logits, batch, model, k=8): |
| """Compute SCΒ³ center classification loss. |
| |
| Uses simple center-based assignment: an anchor is responsible for a GT |
| if the GT center falls within the anchor's cell (with 1-cell margin). |
| |
| Args: |
| sc3_logits: list of [B, k*k, H_i, W_i] per FPN level |
| batch: training batch dict with 'bboxes', 'cls', 'batch_idx' |
| model: YOLO model (for stride info) |
| k: sub-cell grid size |
| |
| Returns: |
| scalar loss (weighted CE on assigned anchors) |
| """ |
| device = sc3_logits[0].device |
| dtype = sc3_logits[0].dtype |
|
|
| gt_bboxes = batch['bboxes'] |
| gt_cls = batch['cls'] |
| batch_idx = batch['batch_idx'] |
| bs = sc3_logits[0].shape[0] |
|
|
| if len(gt_bboxes) == 0: |
| return torch.tensor(0.0, device=device, requires_grad=True) |
|
|
| |
| gt_xywh = gt_bboxes.clone() |
| gt_cx = gt_xywh[:, 0] |
| gt_cy = gt_xywh[:, 1] |
| gt_w = gt_xywh[:, 2] |
| gt_h = gt_xywh[:, 3] |
|
|
| total_loss = torch.tensor(0.0, device=device) |
| total_assigned = 0 |
|
|
| detect = model.model.model[-1] |
| strides = detect.stride |
|
|
| for i, logits in enumerate(sc3_logits): |
| B, _, H, W = logits.shape |
| s = strides[i].item() |
| logits_2d = logits.view(B, k, k, H, W) |
|
|
| |
| for b in range(bs): |
| |
| mask = batch_idx == b |
| if mask.sum() == 0: |
| continue |
| img_gt_cx = gt_cx[mask] |
| img_gt_cy = gt_cy[mask] |
| img_gt_w = gt_w[mask] |
| img_gt_h = gt_h[mask] |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| imgsz = 1536 |
|
|
| for j in range(len(img_gt_cx)): |
| |
| gt_cx_px = img_gt_cx[j].item() * imgsz |
| gt_cy_px = img_gt_cy[j].item() * imgsz |
| gt_sz = max(img_gt_w[j].item(), img_gt_h[j].item()) * imgsz / s |
|
|
| |
| cell_x = int(gt_cx_px / s) |
| cell_y = int(gt_cy_px / s) |
|
|
| |
| if 0 <= cell_x < W and 0 <= cell_y < H: |
| |
| sub_x = (gt_cx_px / s - cell_x) * k |
| sub_y = (gt_cy_px / s - cell_y) * k |
|
|
| |
| sub_x = max(0.0, min(float(k) - 1e-6, sub_x)) |
| sub_y = max(0.0, min(float(k) - 1e-6, sub_y)) |
|
|
| |
| sigma = 0.5 + 0.5 * gt_sz |
| sigma = max(0.4, min(3.0, sigma)) |
|
|
| |
| target = make_gaussian_target( |
| torch.tensor([sub_x], device=device), |
| torch.tensor([sub_y], device=device), |
| k, torch.tensor([sigma], device=device) |
| ) |
|
|
| |
| pred_logits = logits_2d[b, :, :, cell_y, cell_x] |
|
|
| |
| loss = -(target.squeeze(0) * F.log_softmax(pred_logits.view(1, k * k), dim=1).view(k, k)).sum() |
|
|
| |
| weight = 1.0 + 2.0 * max(0, 1.0 - gt_sz / 6.0) |
| total_loss += loss * weight |
| total_assigned += 1 |
|
|
| if total_assigned == 0: |
| return torch.tensor(0.0, device=device, requires_grad=True) |
|
|
| return total_loss / max(total_assigned, 1) |
|
|
|
|
| |
|
|
| def inject_sc3(model, k=8, sc3_loss_weight=0.5): |
| """Inject SCΒ³ head into a YOLO model and patch the loss. |
| |
| Uses a forward hook on the Detect module to capture FPN features, |
| then passes them through SC3Head to get sub-cell distributions. |
| Adds SCΒ³ loss to the total training loss. |
| """ |
| detect = model.model.model[-1] |
|
|
| |
| in_channels = [] |
| for i in range(detect.nl): |
| c_in = detect.cv2[i][0].conv.in_channels |
| in_channels.append(c_in) |
|
|
| |
| sc3 = SC3Head(in_channels, k=k).to(model.device) |
|
|
| |
| _features = [] |
|
|
| def hook_fn(module, input, output): |
| _features.clear() |
| _features.append(input[0]) |
|
|
| handle = detect.register_forward_hook(hook_fn) |
|
|
| |
| _orig_loss = model.loss |
|
|
| def loss_with_sc3(batch, preds=None): |
| _features.clear() |
| loss_result = _orig_loss(batch, preds) |
|
|
| if _features: |
| feats = _features[0] |
| sc3_logits = sc3(feats) |
| sc3_loss = compute_sc3_loss(sc3_logits, batch, model, k=k) |
| loss_result[0] = loss_result[0] + sc3_loss * sc3_loss_weight |
| else: |
| sc3_loss = torch.tensor(0.0, device=model.device) |
|
|
| return loss_result |
|
|
| import types |
| model.loss = types.MethodType(loss_with_sc3, model) |
|
|
| |
| model.sc3_head = sc3 |
| model._sc3_handle = handle |
| model._sc3_k = k |
|
|
| print(f'[SCΒ³] Injected: k={k}, in_channels={in_channels}, loss_weight={sc3_loss_weight}') |
| return model |
|
|