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.
- akros/__init__.py +5 -0
- akros/api.py +7 -0
- colosseum/__init__.py +5 -0
- colosseum/__main__.py +7 -0
- colosseum/_core/__init__.py +1 -0
- colosseum/_core/mergdet.py +27 -0
- colosseum/_core/mergdet_m.py +191 -0
- colosseum/_core/plate_dataset.py +354 -0
- colosseum/_core/plate_loss.py +647 -0
- colosseum/_core/plate_metrics.py +182 -0
- colosseum/_core/platedet_u.py +429 -0
- colosseum/_core/train_platedet.py +538 -0
- colosseum/_core/training_state.py +96 -0
- colosseum/api.py +159 -0
- colosseum/cli.py +22 -0
- colosseum/evaluation.py +68 -0
- colosseum/pack_checkpoint.py +50 -0
- colosseum/prediction.py +54 -0
- colosseum/training.py +170 -0
- colosseum/weights/amlpr.pt +0 -0
- colosseum/weights/ccpd.pt +0 -0
- colosseum/weights/drone.pt +0 -0
- colosseum/weights/gtsdb.pt +0 -0
- colosseum/weights/manifest.json +152 -0
- colosseum/weights/seeds/amlpr_seed1.pt +0 -0
- colosseum/weights/seeds/amlpr_seed2.pt +0 -0
- colosseum/weights/seeds/ccpd_seed1.pt +0 -0
- colosseum/weights/seeds/ccpd_seed2.pt +0 -0
- colosseum/weights/seeds/drone_seed1.pt +0 -0
- colosseum/weights/seeds/drone_seed2.pt +0 -0
- colosseum/weights/seeds/gtsdb_seed1.pt +0 -0
- colosseum/weights/seeds/gtsdb_seed2.pt +0 -0
- colosseum/weights/seeds/shwd_seed1.pt +0 -0
- colosseum/weights/seeds/shwd_seed2.pt +0 -0
- colosseum/weights/seeds/uc3m_seed1.pt +0 -0
- colosseum/weights/seeds/uc3m_seed2.pt +0 -0
- colosseum/weights/shwd.pt +0 -0
- colosseum/weights/uc3m.pt +0 -0
- colosseum_microcontroller-0.1.3.dist-info/METADATA +54 -0
- colosseum_microcontroller-0.1.3.dist-info/RECORD +44 -0
- colosseum_microcontroller-0.1.3.dist-info/WHEEL +5 -0
- colosseum_microcontroller-0.1.3.dist-info/entry_points.txt +2 -0
- colosseum_microcontroller-0.1.3.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|