colosseum-microcontroller 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. akros/__init__.py +5 -0
  2. akros/api.py +7 -0
  3. colosseum/__init__.py +5 -0
  4. colosseum/__main__.py +7 -0
  5. colosseum/_core/__init__.py +1 -0
  6. colosseum/_core/mergdet.py +27 -0
  7. colosseum/_core/mergdet_m.py +191 -0
  8. colosseum/_core/plate_dataset.py +354 -0
  9. colosseum/_core/plate_loss.py +647 -0
  10. colosseum/_core/plate_metrics.py +182 -0
  11. colosseum/_core/platedet_u.py +429 -0
  12. colosseum/_core/train_platedet.py +538 -0
  13. colosseum/_core/training_state.py +96 -0
  14. colosseum/api.py +159 -0
  15. colosseum/cli.py +22 -0
  16. colosseum/evaluation.py +68 -0
  17. colosseum/pack_checkpoint.py +50 -0
  18. colosseum/prediction.py +54 -0
  19. colosseum/training.py +170 -0
  20. colosseum/weights/amlpr.pt +0 -0
  21. colosseum/weights/ccpd.pt +0 -0
  22. colosseum/weights/drone.pt +0 -0
  23. colosseum/weights/gtsdb.pt +0 -0
  24. colosseum/weights/manifest.json +152 -0
  25. colosseum/weights/seeds/amlpr_seed1.pt +0 -0
  26. colosseum/weights/seeds/amlpr_seed2.pt +0 -0
  27. colosseum/weights/seeds/ccpd_seed1.pt +0 -0
  28. colosseum/weights/seeds/ccpd_seed2.pt +0 -0
  29. colosseum/weights/seeds/drone_seed1.pt +0 -0
  30. colosseum/weights/seeds/drone_seed2.pt +0 -0
  31. colosseum/weights/seeds/gtsdb_seed1.pt +0 -0
  32. colosseum/weights/seeds/gtsdb_seed2.pt +0 -0
  33. colosseum/weights/seeds/shwd_seed1.pt +0 -0
  34. colosseum/weights/seeds/shwd_seed2.pt +0 -0
  35. colosseum/weights/seeds/uc3m_seed1.pt +0 -0
  36. colosseum/weights/seeds/uc3m_seed2.pt +0 -0
  37. colosseum/weights/shwd.pt +0 -0
  38. colosseum/weights/uc3m.pt +0 -0
  39. colosseum_microcontroller-0.1.3.dist-info/METADATA +54 -0
  40. colosseum_microcontroller-0.1.3.dist-info/RECORD +44 -0
  41. colosseum_microcontroller-0.1.3.dist-info/WHEEL +5 -0
  42. colosseum_microcontroller-0.1.3.dist-info/entry_points.txt +2 -0
  43. colosseum_microcontroller-0.1.3.dist-info/licenses/LICENSE +21 -0
  44. colosseum_microcontroller-0.1.3.dist-info/top_level.txt +2 -0
@@ -0,0 +1,647 @@
1
+ """Target assignment and loss for the MERG-Det class-implicit grid head."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+ from torch import Tensor
9
+ from torch.nn import functional as F
10
+
11
+ from .mergdet import GeometryCodec
12
+
13
+
14
+ def logit_distillation_loss(
15
+ student: Tensor,
16
+ teacher: Tensor,
17
+ *,
18
+ geometry_weight: float = 5.0,
19
+ min_score: float = 0.05,
20
+ ) -> dict[str, Tensor]:
21
+ """Distil a larger detector into an output-compatible student.
22
+
23
+ Objectness is matched densely. Geometry is matched only at the teacher's
24
+ 3x3 local maxima, weighted by teacher confidence, so low-confidence
25
+ background geometry cannot dominate the signal. The teacher graph is
26
+ always detached here as a second line of defence after its no-grad forward.
27
+ """
28
+
29
+ if student.ndim != 4 or student.shape[1] != 5:
30
+ raise ValueError("student logits must have shape [B, 5, H, W]")
31
+ if teacher.shape != student.shape:
32
+ raise ValueError("teacher and student logits must have identical shapes")
33
+ if not math.isfinite(geometry_weight) or geometry_weight < 0:
34
+ raise ValueError("geometry_weight must be finite and nonnegative")
35
+ if not math.isfinite(min_score) or not 0 <= min_score <= 1:
36
+ raise ValueError("min_score must lie in [0, 1]")
37
+
38
+ teacher = teacher.detach().to(device=student.device, dtype=student.dtype)
39
+ teacher_score = teacher[:, 0].sigmoid()
40
+ student_score = student[:, 0].sigmoid()
41
+ objectness = F.mse_loss(student_score, teacher_score)
42
+
43
+ pooled = F.max_pool2d(teacher_score[:, None], 3, stride=1, padding=1)[:, 0]
44
+ peaks = teacher_score.eq(pooled) & teacher_score.ge(min_score)
45
+ student_geometry = torch.cat(
46
+ (student[:, 1:3].sigmoid(), student[:, 3:5].clamp(-2, 2)), dim=1
47
+ )
48
+ teacher_geometry = torch.cat(
49
+ (teacher[:, 1:3].sigmoid(), teacher[:, 3:5].clamp(-2, 2)), dim=1
50
+ )
51
+ per_cell = F.smooth_l1_loss(
52
+ student_geometry, teacher_geometry, reduction="none"
53
+ ).sum(dim=1)
54
+ weights = teacher_score * peaks.to(teacher_score.dtype)
55
+ if peaks.any():
56
+ geometry = (per_cell * weights).sum() / weights.sum().clamp_min(1e-8)
57
+ else:
58
+ geometry = student.sum() * 0
59
+ return {
60
+ "loss": objectness + geometry_weight * geometry,
61
+ "objectness": objectness,
62
+ "geometry": geometry,
63
+ "peaks": peaks.sum(),
64
+ }
65
+
66
+
67
+ def _focal_objectness(logits: Tensor, targets: Tensor) -> Tensor:
68
+ """Class-balanced focal loss whose positives are not diluted by grid size."""
69
+
70
+ probabilities = logits.sigmoid()
71
+ positive = targets.eq(1)
72
+ negative = ~positive
73
+ positive_terms = 0.75 * (1 - probabilities).pow(2) * F.softplus(-logits)
74
+ negative_terms = 0.25 * probabilities.pow(2) * F.softplus(logits)
75
+ positive_loss = (
76
+ positive_terms[positive].mean() if positive.any() else logits.sum() * 0
77
+ )
78
+ negative_loss = (
79
+ negative_terms[negative].mean() if negative.any() else logits.sum() * 0
80
+ )
81
+ return positive_loss + negative_loss
82
+
83
+
84
+ def _gaussian_radius(height: Tensor, width: Tensor, min_overlap: float = 0.5) -> Tensor:
85
+ """CornerNet/CenterNet radius: smallest shift that still keeps IoU>=min_overlap."""
86
+
87
+ a1 = 1.0
88
+ b1 = height + width
89
+ c1 = width * height * (1 - min_overlap) / (1 + min_overlap)
90
+ r1 = (b1 - torch.sqrt((b1 * b1 - 4 * a1 * c1).clamp_min(0))) / (2 * a1)
91
+
92
+ a2 = 4.0
93
+ b2 = 2 * (height + width)
94
+ c2 = (1 - min_overlap) * width * height
95
+ r2 = (b2 - torch.sqrt((b2 * b2 - 4 * a2 * c2).clamp_min(0))) / (2 * a2)
96
+
97
+ a3 = 4 * min_overlap
98
+ b3 = -2 * min_overlap * (height + width)
99
+ c3 = (min_overlap - 1) * width * height
100
+ r3 = (b3 + torch.sqrt((b3 * b3 - 4 * a3 * c3).clamp_min(0))) / (2 * a3)
101
+ return torch.minimum(torch.minimum(r1, r2), r3).clamp_min(0.0)
102
+
103
+
104
+ def _peak_normalized_focal(
105
+ logits: Tensor,
106
+ soft_target: Tensor,
107
+ positive: Tensor,
108
+ positive_target: Tensor | None = None,
109
+ ) -> Tensor:
110
+ """Penalty-reduced focal loss normalized by the positive count only.
111
+
112
+ Both terms share the 1/N_pos normalization. That is what makes a constant
113
+ objectness field cost O(N_neg / N_pos); per-class mean normalization instead
114
+ makes a constant field cost a fixed amount that is independent of grid size,
115
+ which turns "predict the same score everywhere" into the cheapest solution.
116
+
117
+ ``positive_target`` optionally replaces the hard target 1 at positive cells
118
+ with a soft value in [0, 1] (one entry per positive cell, in ``nonzero``
119
+ order). The positive term then becomes the quality focal loss
120
+ ``|t - p|^2 * BCE(logit, t)``, which reduces exactly to the original
121
+ ``(1 - p)^2 * softplus(-logit)`` when ``t == 1``.
122
+ """
123
+
124
+ probabilities = logits.sigmoid().clamp(1e-6, 1 - 1e-6)
125
+ positive_count = positive.sum().clamp_min(1).to(logits.dtype)
126
+ if positive_target is None:
127
+ positive_terms = (1 - probabilities).pow(2) * F.softplus(-logits)
128
+ positive_loss = positive_terms[positive].sum()
129
+ else:
130
+ positive_logits = logits[positive]
131
+ positive_probabilities = probabilities[positive]
132
+ target = positive_target.detach().clamp(0, 1).to(logits.dtype)
133
+ cross_entropy = target * F.softplus(-positive_logits) + (1 - target) * F.softplus(
134
+ positive_logits
135
+ )
136
+ positive_loss = ((target - positive_probabilities).pow(2) * cross_entropy).sum()
137
+ negative_terms = (
138
+ (1 - soft_target).pow(4) * probabilities.pow(2) * F.softplus(logits)
139
+ )
140
+ negative_loss = negative_terms[~positive].sum()
141
+ return (positive_loss + negative_loss) / positive_count
142
+
143
+
144
+ def _objectness_ranking(logits: Tensor, positive: Tensor) -> Tensor:
145
+ """Rank positive cells above the hardest background cells per image."""
146
+
147
+ image_losses: list[Tensor] = []
148
+ for image_logits, image_positive in zip(logits, positive):
149
+ positive_logits = image_logits[image_positive]
150
+ negative_logits = image_logits[~image_positive]
151
+ if not positive_logits.numel() or not negative_logits.numel():
152
+ continue
153
+ hard_count = min(negative_logits.numel(), 16 * positive_logits.numel())
154
+ hard_negative = torch.topk(negative_logits, hard_count).values
155
+ pairwise_margin = hard_negative[:, None] - positive_logits[None, :] + 0.5
156
+ image_losses.append(F.softplus(pairwise_margin).mean())
157
+ return torch.stack(image_losses).mean() if image_losses else logits.sum() * 0
158
+
159
+
160
+ def _aligned_geometry_iou(
161
+ geometry_prediction: Tensor,
162
+ geometry_target: Tensor,
163
+ positive: Tensor,
164
+ ) -> Tensor:
165
+ """Return aligned IoU for predicted and target geometry at positive cells."""
166
+
167
+ batch, rows, columns = positive.nonzero(as_tuple=True)
168
+ if not batch.numel():
169
+ return geometry_prediction.new_empty(0)
170
+ grid_h, grid_w = positive.shape[-2:]
171
+ predicted = geometry_prediction[batch, :, rows, columns]
172
+ target = geometry_target[batch, :, rows, columns]
173
+ predicted_center_x = (columns + predicted[:, 0]) / grid_w
174
+ predicted_center_y = (rows + predicted[:, 1]) / grid_h
175
+ target_center_x = (columns + target[:, 0]) / grid_w
176
+ target_center_y = (rows + target[:, 1]) / grid_h
177
+
178
+ predicted_x1 = predicted_center_x - predicted[:, 2] / 2
179
+ predicted_y1 = predicted_center_y - predicted[:, 3] / 2
180
+ predicted_x2 = predicted_center_x + predicted[:, 2] / 2
181
+ predicted_y2 = predicted_center_y + predicted[:, 3] / 2
182
+ target_x1 = target_center_x - target[:, 2] / 2
183
+ target_y1 = target_center_y - target[:, 3] / 2
184
+ target_x2 = target_center_x + target[:, 2] / 2
185
+ target_y2 = target_center_y + target[:, 3] / 2
186
+
187
+ intersection_width = (
188
+ torch.minimum(predicted_x2, target_x2)
189
+ - torch.maximum(predicted_x1, target_x1)
190
+ ).clamp_min(0)
191
+ intersection_height = (
192
+ torch.minimum(predicted_y2, target_y2)
193
+ - torch.maximum(predicted_y1, target_y1)
194
+ ).clamp_min(0)
195
+ intersection = intersection_width * intersection_height
196
+ predicted_area = predicted[:, 2] * predicted[:, 3]
197
+ target_area = target[:, 2] * target[:, 3]
198
+ union = predicted_area + target_area - intersection
199
+ return (intersection / union.clamp_min(1e-8)).clamp(0, 1)
200
+
201
+
202
+ def _window_box_iou(
203
+ decoded_geometry: Tensor,
204
+ batch_index: int,
205
+ box: Tensor,
206
+ window: tuple[int, int],
207
+ ) -> tuple[Tensor, int, int]:
208
+ """IoU between each window cell's decoded box and one object.
209
+
210
+ Returns the IoU patch (graph attached) and the window origin (row, col).
211
+ """
212
+
213
+ _, _, grid_h, grid_w = decoded_geometry.shape
214
+ _, center_x, center_y, width, height = box
215
+ column = int(center_x.clamp(0, 1 - 1e-6) * grid_w)
216
+ row = int(center_y.clamp(0, 1 - 1e-6) * grid_h)
217
+ half_r, half_c = window[0] // 2, window[1] // 2
218
+ r0, r1 = max(0, row - half_r), min(grid_h, row + half_r + 1)
219
+ c0, c1 = max(0, column - half_c), min(grid_w, column + half_c + 1)
220
+ patch = decoded_geometry[batch_index, :, r0:r1, c0:c1]
221
+ rows = torch.arange(r0, r1, device=patch.device, dtype=patch.dtype)[:, None]
222
+ columns = torch.arange(c0, c1, device=patch.device, dtype=patch.dtype)[None, :]
223
+ cx = (columns + patch[0]) / grid_w
224
+ cy = (rows + patch[1]) / grid_h
225
+ px1, py1, px2, py2 = cx - patch[2] / 2, cy - patch[3] / 2, cx + patch[2] / 2, cy + patch[3] / 2
226
+ tx1, ty1, tx2, ty2 = center_x - width / 2, center_y - height / 2, center_x + width / 2, center_y + height / 2
227
+ inter = (torch.minimum(px2, tx2) - torch.maximum(px1, tx1)).clamp_min(0) * (
228
+ torch.minimum(py2, ty2) - torch.maximum(py1, ty1)
229
+ ).clamp_min(0)
230
+ iou = (inter / (patch[2] * patch[3] + width * height - inter).clamp_min(1e-8)).clamp(0, 1)
231
+ return iou, r0, c0
232
+
233
+
234
+ def _local_window_ranking(
235
+ logits: Tensor,
236
+ decoded_geometry: Tensor,
237
+ labels: list[Tensor],
238
+ window: tuple[int, int],
239
+ margin: float,
240
+ gap: float,
241
+ anchor: str,
242
+ min_anchor_iou: float,
243
+ ) -> tuple[Tensor, Tensor]:
244
+ """Decoder-consistent local ranking.
245
+
246
+ The NMS-free decoder keeps a cell only if it is the score maximum of its
247
+ ``window``. For every object, the anchor cell (``'best_iou'``: the window cell
248
+ whose current decoded box overlaps the object most; ``'center'``: the
249
+ assigned centre cell) must out-score every other window cell whose IoU is
250
+ lower by more than ``gap``: ``softplus(logit_other - logit_anchor + margin)``
251
+ weighted by the detached IoU gap. No new positives are created and the
252
+ anchor geometry is not supervised here. Objects whose anchor IoU is below
253
+ ``min_anchor_iou`` are skipped (no trustworthy anchor). Returns the mean
254
+ pair loss (zero when no pairs) and the number of pairs.
255
+ """
256
+
257
+ pair_losses: list[Tensor] = []
258
+ for batch_index, boxes in enumerate(labels):
259
+ for box in boxes.to(decoded_geometry.device):
260
+ iou, r0, c0 = _window_box_iou(decoded_geometry, batch_index, box, window)
261
+ iou = iou.detach()
262
+ rows, cols = iou.shape
263
+ if anchor == "best_iou":
264
+ flat_anchor = int(iou.argmax())
265
+ else:
266
+ _, grid_h, grid_w = logits.shape
267
+ column = int(box[1].clamp(0, 1 - 1e-6) * grid_w) - c0
268
+ row = int(box[2].clamp(0, 1 - 1e-6) * grid_h) - r0
269
+ flat_anchor = row * cols + column
270
+ anchor_iou = iou.flatten()[flat_anchor]
271
+ if float(anchor_iou) < min_anchor_iou:
272
+ continue
273
+ window_logits = logits[batch_index, r0 : r0 + rows, c0 : c0 + cols].flatten()
274
+ anchor_logit = window_logits[flat_anchor]
275
+ weights = (anchor_iou - iou.flatten()).clamp_min(0)
276
+ weights[flat_anchor] = 0
277
+ selected = weights > gap
278
+ if not selected.any():
279
+ continue
280
+ penalty = F.softplus(window_logits[selected] - anchor_logit + margin)
281
+ pair_losses.append(weights[selected] * penalty)
282
+ if not pair_losses:
283
+ zero = logits.sum() * 0
284
+ return zero, zero.detach()
285
+ pairs = torch.cat(pair_losses)
286
+ return pairs.mean(), torch.tensor(float(pairs.numel()), device=logits.device)
287
+
288
+
289
+ def _window_auxiliary_positives(
290
+ decoded_geometry: Tensor,
291
+ labels: list[Tensor],
292
+ positive: Tensor,
293
+ window: tuple[int, int],
294
+ threshold: float,
295
+ ) -> tuple[Tensor, Tensor]:
296
+ """Cells near a target whose *current* decoded box already overlaps it.
297
+
298
+ For every object, the cells inside a ``window`` (rows, cols) centred on the
299
+ assigned cell are scored by the IoU between their own decoded box and the
300
+ object. Cells reaching ``threshold`` that are not the assigned positive
301
+ become auxiliary positives. Their dx/dy cannot represent the object centre
302
+ (offsets live inside the cell), so they get no Smooth-L1 target; they are
303
+ trained through IoU only. Returns a boolean mask and the per-cell IoU
304
+ (graph attached) with the best-overlapping object, zero elsewhere.
305
+ """
306
+
307
+ batch_size, _, grid_h, grid_w = decoded_geometry.shape
308
+ aux_iou = decoded_geometry.new_zeros((batch_size, grid_h, grid_w))
309
+ for batch_index, boxes in enumerate(labels):
310
+ for box in boxes.to(decoded_geometry.device):
311
+ iou, r0, c0 = _window_box_iou(decoded_geometry, batch_index, box, window)
312
+ rows, cols = iou.shape
313
+ current = aux_iou[batch_index, r0 : r0 + rows, c0 : c0 + cols]
314
+ aux_iou[batch_index, r0 : r0 + rows, c0 : c0 + cols] = torch.where(iou > current, iou, current)
315
+ aux_mask = (aux_iou.detach() >= threshold) & ~positive
316
+ return aux_mask, aux_iou
317
+
318
+
319
+ def _teacher_soft_field(
320
+ prediction: Tensor, teacher: list[Tensor], weight: float
321
+ ) -> Tensor:
322
+ """Turn teacher detections into a penalty-reduction field.
323
+
324
+ The Gaussian around a ground-truth centre says "do not punish this cell as
325
+ pure background, it is next to an object". A teacher detection says the same
326
+ thing for a different reason: a much stronger model finds plate-like
327
+ structure here. Both feed the same ``(1 - soft) ** 4`` factor, so a cell the
328
+ teacher is confident about is no longer driven all the way to zero.
329
+
330
+ ``weight`` scales how much of the teacher's confidence is admitted, and zero
331
+ reproduces the behaviour without any teacher.
332
+ """
333
+
334
+ batch_size, _, grid_h, grid_w = prediction.shape
335
+ field = prediction.new_zeros((batch_size, grid_h, grid_w))
336
+ if weight <= 0.0:
337
+ return field
338
+ for batch_index, boxes in enumerate(teacher):
339
+ if not boxes.numel():
340
+ continue
341
+ for box in boxes.to(prediction.device):
342
+ confidence, center_x, center_y = box[0], box[1], box[2]
343
+ column = int(center_x.clamp(0, 1 - 1e-6) * grid_w)
344
+ row = int(center_y.clamp(0, 1 - 1e-6) * grid_h)
345
+ value = (confidence * weight).clamp(0.0, 1.0)
346
+ field[batch_index, row, column] = torch.maximum(
347
+ field[batch_index, row, column], value
348
+ )
349
+ return field
350
+
351
+
352
+ def _build_targets(
353
+ prediction: Tensor,
354
+ labels: list[Tensor],
355
+ soft_target_mode: str,
356
+ ) -> tuple[Tensor, Tensor, Tensor]:
357
+ """Assign one positive cell per object and build the objectness field.
358
+
359
+ ``soft_target_mode='hard'`` reproduces the original one-hot field.
360
+ ``'gaussian'`` splats a penalty-reduced Gaussian whose radius follows the
361
+ box size in cell units, so cells next to the peak are not punished as if
362
+ they were pure background.
363
+ """
364
+
365
+ batch_size, _, grid_h, grid_w = prediction.shape
366
+ object_target = prediction.new_zeros((batch_size, grid_h, grid_w))
367
+ geometry_target = prediction.new_zeros((batch_size, 4, grid_h, grid_w))
368
+ positive = torch.zeros(
369
+ (batch_size, grid_h, grid_w), dtype=torch.bool, device=prediction.device
370
+ )
371
+ rows = torch.arange(grid_h, device=prediction.device, dtype=prediction.dtype)
372
+ columns = torch.arange(grid_w, device=prediction.device, dtype=prediction.dtype)
373
+
374
+ for batch_index, boxes in enumerate(labels):
375
+ for box in boxes.to(prediction.device):
376
+ _, center_x, center_y, width, height = box
377
+ center_x = center_x.clamp(0, 1 - 1e-6)
378
+ center_y = center_y.clamp(0, 1 - 1e-6)
379
+ grid_x = center_x * grid_w
380
+ grid_y = center_y * grid_h
381
+ column = int(grid_x)
382
+ row = int(grid_y)
383
+
384
+ if soft_target_mode == "gaussian":
385
+ radius = _gaussian_radius(height * grid_h, width * grid_w).clamp_min(
386
+ 0.5
387
+ )
388
+ sigma = (2 * radius + 1) / 6
389
+ splat = torch.exp(
390
+ -((columns - grid_x) ** 2)[None, :] / (2 * sigma**2)
391
+ - ((rows - grid_y) ** 2)[:, None] / (2 * sigma**2)
392
+ )
393
+ object_target[batch_index] = torch.maximum(
394
+ object_target[batch_index], splat
395
+ )
396
+
397
+ # If two plates share a cell, retain the larger target. This collision
398
+ # rate must be reported and controlled through input/stride selection.
399
+ area = width * height
400
+ old_area = geometry_target[batch_index, 2, row, column] * geometry_target[
401
+ batch_index, 3, row, column
402
+ ]
403
+ if positive[batch_index, row, column] and area <= old_area:
404
+ continue
405
+ object_target[batch_index, row, column] = 1
406
+ positive[batch_index, row, column] = True
407
+ geometry_target[batch_index, :, row, column] = torch.stack(
408
+ (grid_x - column, grid_y - row, width, height)
409
+ )
410
+ return object_target, geometry_target, positive
411
+
412
+
413
+ def plate_loss(
414
+ prediction: Tensor,
415
+ labels: list[Tensor],
416
+ ranking_weight: float = 0.0,
417
+ quality_weight: float = 0.0,
418
+ objectness_mode: str = "balanced",
419
+ codec: GeometryCodec | None = None,
420
+ geometry_weight: float = 5.0,
421
+ teacher: list[Tensor] | None = None,
422
+ distillation_weight: float = 0.0,
423
+ iou_weight: float = 0.0,
424
+ size_loss_multiplier: float = 1.0,
425
+ center_loss_multiplier: float = 1.0,
426
+ tiny_center_loss_multiplier: float = 1.0,
427
+ tiny_center_height_threshold: float = 0.0,
428
+ objectness_target: str = "hard",
429
+ auxiliary_window: tuple[int, int] | None = None,
430
+ auxiliary_iou_threshold: float = 0.3,
431
+ local_rank_weight: float = 0.0,
432
+ local_rank_window: tuple[int, int] = (3, 3),
433
+ local_rank_margin: float = 0.5,
434
+ local_rank_gap: float = 0.05,
435
+ local_rank_anchor: str = "best_iou",
436
+ local_rank_min_anchor_iou: float = 0.3,
437
+ ) -> dict[str, Tensor]:
438
+ """Compute loss from normalized YOLO labels [class, cx, cy, width, height].
439
+
440
+ ``objectness_mode='balanced'`` is the original per-class mean focal loss and
441
+ is kept so earlier runs stay reproducible. ``'peak'`` normalizes both terms
442
+ by the positive count, which is what restores the grid-size-dependent
443
+ pressure against a constant objectness field.
444
+
445
+ ``objectness_target='hard'`` keeps target 1 at positive cells.
446
+ ``'iou'`` (peak mode only) replaces it with the detached IoU between the
447
+ decoded and target box at that cell, in the style of the quality focal
448
+ loss, so a cell whose box barely overlaps cannot earn a high score.
449
+
450
+ ``auxiliary_window=(rows, cols)`` (requires ``objectness_target='iou'``)
451
+ additionally treats window cells whose current decoded box reaches
452
+ ``auxiliary_iou_threshold`` IoU with the object as positives for the score
453
+ (target = their IoU) and for the IoU loss, never for Smooth-L1. This lets the
454
+ score peak follow the best-geometry cell instead of the centre cell only.
455
+
456
+ ``local_rank_weight>0`` adds decoder-consistent local ranking: inside
457
+ ``local_rank_window`` around each object, the anchor cell (``'best_iou'`` or
458
+ ``'center'``) must out-score cells whose decoded IoU is lower by more than
459
+ ``local_rank_gap``, with ``local_rank_margin`` in logit space. It never
460
+ creates positives and leaves the inference graph untouched.
461
+ """
462
+
463
+ if prediction.ndim != 4 or prediction.shape[1] != 5:
464
+ raise ValueError("prediction must have shape [batch, 5, height, width]")
465
+ if len(labels) != prediction.shape[0]:
466
+ raise ValueError("one label tensor is required for every batch item")
467
+ if objectness_mode not in ("balanced", "peak"):
468
+ raise ValueError("objectness_mode must be 'balanced' or 'peak'")
469
+ if not 0 <= iou_weight < float("inf"):
470
+ raise ValueError("iou_weight must be finite and nonnegative")
471
+ if not 1 <= size_loss_multiplier < float("inf"):
472
+ raise ValueError("size_loss_multiplier must be finite and at least 1")
473
+ if not 1 <= center_loss_multiplier < float("inf"):
474
+ raise ValueError("center_loss_multiplier must be finite and at least 1")
475
+ if not 1 <= tiny_center_loss_multiplier < float("inf"):
476
+ raise ValueError("tiny_center_loss_multiplier must be finite and at least 1")
477
+ if tiny_center_loss_multiplier != 1 and not 0 < tiny_center_height_threshold <= 1:
478
+ raise ValueError("tiny_center_height_threshold must lie in (0, 1]")
479
+ if objectness_target not in ("hard", "iou"):
480
+ raise ValueError("objectness_target must be 'hard' or 'iou'")
481
+ if objectness_target == "iou" and objectness_mode != "peak":
482
+ raise ValueError("objectness_target='iou' requires objectness_mode='peak'")
483
+ if auxiliary_window is not None:
484
+ if objectness_target != "iou":
485
+ raise ValueError("auxiliary_window requires objectness_target='iou'")
486
+ if len(auxiliary_window) != 2 or any(int(v) < 1 or int(v) % 2 == 0 for v in auxiliary_window):
487
+ raise ValueError("auxiliary_window must be two odd positive sizes (rows, cols)")
488
+ if not 0 < auxiliary_iou_threshold <= 1:
489
+ raise ValueError("auxiliary_iou_threshold must lie in (0, 1]")
490
+ if not 0 <= local_rank_weight < float("inf"):
491
+ raise ValueError("local_rank_weight must be finite and nonnegative")
492
+ if local_rank_weight:
493
+ if len(local_rank_window) != 2 or any(int(v) < 1 or int(v) % 2 == 0 for v in local_rank_window):
494
+ raise ValueError("local_rank_window must be two odd positive sizes (rows, cols)")
495
+ if local_rank_anchor not in ("best_iou", "center"):
496
+ raise ValueError("local_rank_anchor must be 'best_iou' or 'center'")
497
+ if not (0 <= local_rank_gap < 1 and 0 <= local_rank_margin and 0 < local_rank_min_anchor_iou <= 1):
498
+ raise ValueError("invalid local ranking hyperparameters")
499
+ codec = codec or GeometryCodec()
500
+
501
+ object_target, geometry_target, positive = _build_targets(
502
+ prediction,
503
+ labels,
504
+ soft_target_mode="gaussian" if objectness_mode == "peak" else "hard",
505
+ )
506
+
507
+ if teacher is not None and distillation_weight > 0.0:
508
+ if len(teacher) != prediction.shape[0]:
509
+ raise ValueError("one teacher tensor is required for every batch item")
510
+ if objectness_mode != "peak":
511
+ raise ValueError("distillation requires objectness_mode='peak'")
512
+ teacher_field = _teacher_soft_field(prediction, teacher, distillation_weight)
513
+ # Ground-truth positives keep their hard target of 1.
514
+ object_target = torch.maximum(object_target, teacher_field)
515
+ object_target = torch.where(positive, torch.ones_like(object_target), object_target)
516
+
517
+ # Decoded geometry lives in normalized image units and feeds IoU/quality.
518
+ decoded_geometry = torch.cat(
519
+ (prediction[:, 1:3].sigmoid(), codec.decode_size(prediction[:, 3:5])), dim=1
520
+ )
521
+ aligned_iou = _aligned_geometry_iou(decoded_geometry, geometry_target, positive)
522
+
523
+ if auxiliary_window is not None:
524
+ aux_mask, aux_iou = _window_auxiliary_positives(
525
+ decoded_geometry, labels, positive,
526
+ (int(auxiliary_window[0]), int(auxiliary_window[1])), auxiliary_iou_threshold,
527
+ )
528
+ aux_values = aux_iou[aux_mask]
529
+ else:
530
+ aux_mask = torch.zeros_like(positive)
531
+ aux_values = aligned_iou.new_empty(0)
532
+
533
+ if objectness_mode == "peak":
534
+ if auxiliary_window is None:
535
+ objectness = _peak_normalized_focal(
536
+ prediction[:, 0],
537
+ object_target,
538
+ positive,
539
+ aligned_iou.detach() if objectness_target == "iou" else None,
540
+ )
541
+ else:
542
+ # Score targets for the union of assigned and auxiliary cells, in
543
+ # row-major order to match boolean indexing inside the focal loss.
544
+ score_target = prediction.new_zeros(positive.shape)
545
+ score_target[positive] = aligned_iou.detach()
546
+ score_target[aux_mask] = aux_values.detach()
547
+ union = positive | aux_mask
548
+ objectness = _peak_normalized_focal(
549
+ prediction[:, 0], object_target, union, score_target[union]
550
+ )
551
+ else:
552
+ objectness = _focal_objectness(prediction[:, 0], object_target)
553
+ ranking = _objectness_ranking(prediction[:, 0], positive)
554
+ if local_rank_weight:
555
+ local_rank, local_rank_pairs = _local_window_ranking(
556
+ prediction[:, 0], decoded_geometry, labels,
557
+ (int(local_rank_window[0]), int(local_rank_window[1])),
558
+ local_rank_margin, local_rank_gap, local_rank_anchor, local_rank_min_anchor_iou,
559
+ )
560
+ else:
561
+ local_rank = prediction.sum() * 0
562
+ local_rank_pairs = local_rank.detach()
563
+ if aligned_iou.numel():
564
+ positive_logits = prediction[:, 0][positive]
565
+ quality_target = aligned_iou.detach()
566
+ quality = F.binary_cross_entropy_with_logits(
567
+ positive_logits, quality_target, reduction="mean"
568
+ )
569
+ else:
570
+ quality = prediction.sum() * 0
571
+
572
+ # Regression happens in the encoded space so the gradient does not inherit
573
+ # the sigmoid tail when the target box is only a few pixels tall.
574
+ encoded_prediction = torch.cat(
575
+ (prediction[:, 1:3].sigmoid(), codec.encode_prediction(prediction[:, 3:5])),
576
+ dim=1,
577
+ )
578
+ encoded_target = torch.cat(
579
+ (geometry_target[:, 0:2], codec.encode_size(geometry_target[:, 2:4])), dim=1
580
+ )
581
+ positive_geometry = positive[:, None].expand_as(encoded_prediction)
582
+ if positive_geometry.any():
583
+ geometry = F.smooth_l1_loss(
584
+ encoded_prediction[positive_geometry],
585
+ encoded_target[positive_geometry],
586
+ reduction="mean",
587
+ )
588
+ else:
589
+ geometry = prediction.sum() * 0
590
+ # The original mean covers four channels. Increase only the two size
591
+ # channels, leaving the coefficient on center offsets exactly unchanged.
592
+ if size_loss_multiplier != 1 and positive.any():
593
+ size_mask = positive[:, None].expand_as(encoded_prediction[:, 2:4])
594
+ size_loss = F.smooth_l1_loss(
595
+ encoded_prediction[:, 2:4][size_mask],
596
+ encoded_target[:, 2:4][size_mask], reduction="mean")
597
+ geometry = geometry + .5 * (size_loss_multiplier - 1) * size_loss
598
+ # Symmetric opt-in control for the two centre-offset channels. The default
599
+ # is exactly the historical four-channel mean, preserving old runs.
600
+ if center_loss_multiplier != 1 and positive.any():
601
+ center_mask = positive[:, None].expand_as(encoded_prediction[:, 0:2])
602
+ center_loss = F.smooth_l1_loss(
603
+ encoded_prediction[:, 0:2][center_mask],
604
+ encoded_target[:, 0:2][center_mask], reduction="mean")
605
+ geometry = geometry + .5 * (center_loss_multiplier - 1) * center_loss
606
+ # Increase centre supervision only for targets below a normalized height
607
+ # threshold. Normalize the extra term by every positive, not only the tiny
608
+ # subset, so each selected centre channel is multiplied exactly as requested
609
+ # without changing the relative contribution when a batch contains fewer
610
+ # or more tiny objects.
611
+ if tiny_center_loss_multiplier != 1 and positive.any():
612
+ tiny = positive & (geometry_target[:, 3] < tiny_center_height_threshold)
613
+ if tiny.any():
614
+ tiny_center_mask = tiny[:, None].expand_as(encoded_prediction[:, 0:2])
615
+ tiny_center_sum = F.smooth_l1_loss(
616
+ encoded_prediction[:, 0:2][tiny_center_mask],
617
+ encoded_target[:, 0:2][tiny_center_mask], reduction="sum")
618
+ geometry = geometry + (
619
+ (tiny_center_loss_multiplier - 1)
620
+ * tiny_center_sum / positive_geometry.sum()
621
+ )
622
+ total = (
623
+ objectness
624
+ + geometry_weight * geometry
625
+ + ranking_weight * ranking
626
+ + quality_weight * quality
627
+ )
628
+ all_iou = torch.cat((aligned_iou, aux_values))
629
+ iou_loss = (1 - all_iou).mean() if all_iou.numel() else prediction.sum() * 0
630
+ # Keep the zero-weight execution path identical to historical training.
631
+ # Smooth-L1 remains active to provide gradients for non-overlapping boxes.
632
+ if iou_weight:
633
+ total = total + iou_weight * iou_loss
634
+ if local_rank_weight:
635
+ total = total + local_rank_weight * local_rank
636
+ return {
637
+ "loss": total,
638
+ "objectness": objectness,
639
+ "geometry": geometry,
640
+ "ranking": ranking,
641
+ "quality": quality,
642
+ "iou": iou_loss,
643
+ "positive_iou": aligned_iou.mean() if aligned_iou.numel() else quality,
644
+ "auxiliary_count": aux_mask.sum(),
645
+ "local_rank": local_rank,
646
+ "local_rank_pairs": local_rank_pairs,
647
+ }