File size: 11,962 Bytes
6a5bb7e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | """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)
# Per-level: lightweight conv to predict kΓk logits
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:
# Initialize bias so initial distribution is centered (uniform-ish)
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
# Reshape: [B, k, k, H, W] β softmax over kΓk dims
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)
# Create sub-cell coordinate grid
gy, gx = torch.meshgrid(
torch.arange(k, device=logits.device, dtype=torch.float32),
torch.arange(k, device=logits.device, dtype=torch.float32),
indexing='ij'
)
# Normalize to [0, 1) within the cell
gx_norm = (gx + 0.5) / k
gy_norm = (gy + 0.5) / k
# Soft-argmax: expected sub-cell offset
dx = (prob * gx_norm[None, :, :, None, None]).sum(dim=(1, 2)) # [B, H, W]
dy = (prob * gy_norm[None, :, :, None, None]).sum(dim=(1, 2)) # [B, H, W]
# Add anchor cell position
anchor = anchors[i] # [H*W, 2] in pixel coords
ax = anchor[:, 0].view(H, W)
ay = anchor[:, 1].view(H, W)
# Center in image coords
cx = ax[None] + dx * strides[i]
cy = ay[None] + dy * strides[i]
# Stack as [B, H*W, 2]
centers.append(torch.stack([cx.reshape(B, -1), cy.reshape(B, -1)], dim=-1))
return torch.cat(centers, dim=1) # [B, N_total, 2]
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'
)
# Grid centers at (i + 0.5)
gx_ctr = gx + 0.5
gy_ctr = gy + 0.5
# Distance from GT to each grid point: [N, k, k]
dx = gx_ctr[None] - cx_sub[:, None, None]
dy = gy_ctr[None] - cy_sub[:, None, None]
d2 = dx ** 2 + dy ** 2
# Gaussian: exp(-dΒ² / (2ΟΒ²))
sigma = sigma[:, None, None].clamp(min=0.3)
gauss = torch.exp(-d2 / (2 * sigma ** 2))
# Normalize to sum to 1
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'] # [N_gt, 4] xywh normalized
gt_cls = batch['cls'] # [N_gt, 1]
batch_idx = batch['batch_idx'] # [N_gt]
bs = sc3_logits[0].shape[0]
if len(gt_bboxes) == 0:
return torch.tensor(0.0, device=device, requires_grad=True)
# Convert GT to xyxy
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 each image in batch
for b in range(bs):
# Get GTs for this image
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]
# Anchor centers for this level (pixel coords, normalized)
# Each cell (j, i) corresponds to anchor center at (i*s+s/2, j*s+s/2) pixels
# In normalized coords: ((i+0.5)*s/W, (j+0.5)*s/H)
# We'll work in pixel coords and convert GT to pixel coords
# For simplicity, work in grid cell coords
# GT center in cell coords for this level:
# cell_x = cx_gt * img_w_px / s, cell_y = cy_gt * img_h_px / s
# But we don't know img_w_px here... we can use the model input size
# Use the imgsz from model or just work with normalized coords
# In normalized coords, cell width = s / imgsz
# Sub-cell position within cell at (i,j):
# sub_x = (cx_gt_normalized * imgsz / s - i) = cx_gt_pixels / s - i
# But we only have normalized coords...
# Simplest: work in normalized space
# Cell i in normalized coords: from i*s/imgsz to (i+1)*s/imgsz
# GT center in cell units: cx_gt * imgsz / s
# We'll use the imgsz from the model
imgsz = 1536 # HARDCODED for now, should get from model
for j in range(len(img_gt_cx)):
# GT center in pixel coords (approx, assuming square imgsz)
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 # cells
# Which cell does this GT center fall into?
cell_x = int(gt_cx_px / s)
cell_y = int(gt_cy_px / s)
# Check if within this level's grid
if 0 <= cell_x < W and 0 <= cell_y < H:
# Sub-cell position
sub_x = (gt_cx_px / s - cell_x) * k
sub_y = (gt_cy_px / s - cell_y) * k
# Clip to valid range
sub_x = max(0.0, min(float(k) - 1e-6, sub_x))
sub_y = max(0.0, min(float(k) - 1e-6, sub_y))
# Adaptive sigma: smaller objects β sharper target
sigma = 0.5 + 0.5 * gt_sz # [0.5, ~3.0]
sigma = max(0.4, min(3.0, sigma))
# Create target
target = make_gaussian_target(
torch.tensor([sub_x], device=device),
torch.tensor([sub_y], device=device),
k, torch.tensor([sigma], device=device)
) # [1, k, k]
# Predicted logits at this cell
pred_logits = logits_2d[b, :, :, cell_y, cell_x] # [k, k]
# Cross-entropy loss
loss = -(target.squeeze(0) * F.log_softmax(pred_logits.view(1, k * k), dim=1).view(k, k)).sum()
# Weight: smaller objects get higher loss weight
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)
# ββ Model patching ββββββββββββββββββββββββββββββββββββββββββ
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]
# Get input channels from detect head's first conv
in_channels = []
for i in range(detect.nl):
c_in = detect.cv2[i][0].conv.in_channels
in_channels.append(c_in)
# Create SC3 head
sc3 = SC3Head(in_channels, k=k).to(model.device)
# Store features from Detect forward pass
_features = []
def hook_fn(module, input, output):
_features.clear()
_features.append(input[0]) # input[0] is x (list of FPN features)
handle = detect.register_forward_hook(hook_fn)
# Patch loss
_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)
# Store for inference use
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
|