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
akros/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Compatibility imports for earlier public checkpoints and scripts."""
2
+
3
+ from .api import AKROS, Colosseum, Detection
4
+
5
+ __all__ = ["AKROS", "Colosseum", "Detection"]
akros/api.py ADDED
@@ -0,0 +1,7 @@
1
+ """Compatibility interface for the earlier AKROS package name."""
2
+
3
+ from colosseum.api import Colosseum, Detection
4
+
5
+ AKROS = Colosseum
6
+
7
+ __all__ = ["AKROS", "Colosseum", "Detection"]
colosseum/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Public Colosseum inference interface."""
2
+
3
+ from .api import Colosseum, Detection
4
+
5
+ __all__ = ["Colosseum", "Detection"]
colosseum/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m colosseum`` as an alternative to the console script."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1 @@
1
+ """Colosseum architecture and training components."""
@@ -0,0 +1,27 @@
1
+ """Canonical public interface for the shared MERG-Det architecture."""
2
+
3
+ from .platedet_u import (
4
+ AsymmetricResidualBlock,
5
+ ConvBNAct,
6
+ Detection,
7
+ EdgeMicroStem,
8
+ GeometryCodec,
9
+ GeometryPrior,
10
+ MergDet,
11
+ decode,
12
+ filter_by_geometry,
13
+ parameter_count,
14
+ )
15
+
16
+ __all__ = [
17
+ "AsymmetricResidualBlock",
18
+ "ConvBNAct",
19
+ "Detection",
20
+ "EdgeMicroStem",
21
+ "GeometryCodec",
22
+ "GeometryPrior",
23
+ "MergDet",
24
+ "decode",
25
+ "filter_by_geometry",
26
+ "parameter_count",
27
+ ]
@@ -0,0 +1,191 @@
1
+ """MERG-Det-M: scaled MERG-Det family (S/M/L) with a three-scale readout.
2
+
3
+ Same input path, anisotropic 2x8 output grid, five-channel head layout,
4
+ codec, loss and decoder as MERG-Det, so every existing training and
5
+ evaluation tool applies unchanged. Differences are capacity only: wider
6
+ stages, repeated blocks, a fourth downsampling stage, a top-down fusion of
7
+ three scales onto the 2x8 grid, and a decoupled two-layer head. Operators stay
8
+ conv / BN / ReLU6 / add / nearest upsample. This is an engineering variant,
9
+ not a novelty claim; the 11k MERG-Det stays a separate result.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+
16
+ import torch
17
+ from torch import Tensor, nn
18
+ from torch.nn import functional as F
19
+
20
+ from .platedet_u import AsymmetricResidualBlock, ConvBNAct, EdgeMicroStem
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class MergDetMConfig:
25
+ name: str
26
+ channels: tuple[int, int, int, int, int] # stem, stage1, stage2, stage3, stage4
27
+ repeats: tuple[int, int, int, int] # extra non-strided blocks per stage
28
+ neck: int # lateral / fused channels on every scale
29
+ head: int # objectness branch channels
30
+ head_depth: int = 2
31
+ geometry_head: int | None = None # defaults to head for historical configs
32
+ geometry_head_depth: int | None = None # defaults to head_depth
33
+
34
+
35
+ # Budget ladder bracketing the Stage B candidates at 144x192 (single class):
36
+ # S 104,005 params / 54.4M Conv MAC (~ESPDet-Pico / 3.5)
37
+ # M 368,813 params / 129.4M Conv MAC (~ESPDet-Pico 361k, 2.7x its MAC)
38
+ # L 927,661 params / 226.0M Conv MAC (~YOLOX-Nano 897k, 2.5x its MAC)
39
+ # MACs stay above the candidates' at equal parameters because the 2x8 grid
40
+ # keeps 72 rows; this is reported, not hidden.
41
+ CONFIGS: dict[str, MergDetMConfig] = {
42
+ # V4 high-resolution narrow candidates. They retain V3's 192x256 canvas,
43
+ # repeats and 2x8 readout while reducing channel traffic enough to target
44
+ # the ESPDet/Tinyissimo latency range. V4T is the SRAM-first arm; V4S is
45
+ # the less aggressive accuracy-first arm.
46
+ "V4T": MergDetMConfig("V4T", (8, 12, 24, 56, 112), (1, 1, 2, 3), 16, 16),
47
+ "V4S": MergDetMConfig("V4S", (8, 16, 24, 48, 72), (1, 1, 2, 3), 32, 32),
48
+ # Accuracy-first rebalance after V4T's frozen pilot: spend compute at the
49
+ # input/detail path and use one wider head block; recover budget in stage3.
50
+ "V4A": MergDetMConfig("V4A", (10, 12, 24, 40, 128), (1, 1, 2, 3), 16, 24, 1),
51
+ # V4B static-screen arms: retain V4A's backbone and spend only on box
52
+ # regression, since V4A's localization diagnostic found size/vertical
53
+ # errors rather than an AP50/objectness bottleneck.
54
+ "V4B1": MergDetMConfig("V4B1", (10, 12, 24, 40, 128), (1, 1, 2, 3), 16, 16, 1, 24, 2),
55
+ "V4B2": MergDetMConfig("V4B2", (10, 12, 24, 40, 128), (1, 1, 2, 3), 16, 16, 1, 32, 2),
56
+ "V4B3": MergDetMConfig("V4B3", (10, 12, 24, 40, 128), (1, 1, 2, 3), 24, 16, 1, 32, 2),
57
+ # V4C restores V4A's objectness width after V4B1 failed its epoch-50 AP50
58
+ # gate, while retaining the geometry-only depth intervention.
59
+ "V4C1": MergDetMConfig("V4C1", (10, 12, 24, 40, 128), (1, 1, 2, 3), 16, 24, 1, 24, 2),
60
+ "V4C2": MergDetMConfig("V4C2", (10, 12, 24, 40, 128), (1, 1, 2, 3), 16, 24, 1, 32, 2),
61
+ # V5 accuracy-first/hardware-bounded arm. Restore richer multi-scale
62
+ # fusion while keeping the V4A detail path and a shallow deployable head.
63
+ "V5A": MergDetMConfig("V5A", (10, 12, 24, 40, 128), (1, 1, 2, 3), 32, 24, 1),
64
+ "S": MergDetMConfig("S", (16, 24, 32, 72, 112), (1, 1, 2, 3), 48, 48),
65
+ "M": MergDetMConfig("M", (24, 32, 48, 112, 224), (1, 2, 3, 4), 64, 64),
66
+ "L": MergDetMConfig("L", (24, 32, 64, 160, 320), (1, 2, 4, 6), 64, 64),
67
+ }
68
+
69
+ STRIDES = ((1, 2), (1, 2), (2, 2), (2, 2)) # after the stride-2 stem: grid 2x8, 4x16, 8x32
70
+
71
+ # Grid ablation (docs/GRID_ABLATION_PROTOCOL.md): the output grid is set by the
72
+ # strides of stages 1-2 only; stages 3-4 keep (2, 2) and the neck upsamples by
73
+ # size. Parameters are grid-independent by construction; MAC is not.
74
+ GRID_STRIDES: dict[tuple[int, int], tuple[tuple[int, int], tuple[int, int]]] = {
75
+ (2, 8): ((1, 2), (1, 2)), # anisotropic default: 72x24 cells at 144x192
76
+ (4, 4): ((1, 1), (2, 2)), # isotropic at equal cell count: 36x48
77
+ (8, 2): ((2, 1), (2, 1)), # wrong-direction control: 18x96
78
+ (8, 8): ((2, 2), (2, 2)), # conventional coarse grid: 18x24
79
+ }
80
+
81
+
82
+ class RegularBottleneckBlock(nn.Module):
83
+ """Hardware-oriented bottleneck using one regular 3x3 spatial convolution."""
84
+
85
+ def __init__(self, in_channels: int, out_channels: int, stride: int | tuple[int, int] = 1) -> None:
86
+ super().__init__()
87
+ stride_y, stride_x = (stride, stride) if isinstance(stride, int) else stride
88
+ self.stride_y, self.stride_x = stride_y, stride_x
89
+ hidden = max(4, min(in_channels, out_channels) // 4)
90
+ self.reduce = ConvBNAct(in_channels, hidden, 1)
91
+ self.spatial = ConvBNAct(hidden, hidden, 3, stride=(stride_y, stride_x), padding=1)
92
+ self.expand = ConvBNAct(hidden, out_channels, 1, activation=False)
93
+ if stride_y == 1 and stride_x == 1 and in_channels == out_channels:
94
+ self.skip: nn.Module = nn.Identity()
95
+ else:
96
+ self.skip = ConvBNAct(in_channels, out_channels, 1, stride=(stride_y, stride_x), activation=False)
97
+ self.activation = nn.ReLU6(inplace=True)
98
+
99
+ def forward(self, source: Tensor) -> Tensor:
100
+ residual = self.skip(source)
101
+ features = self.expand(self.spatial(self.reduce(source)))
102
+ return self.activation(features + residual)
103
+
104
+
105
+ def _stage(in_channels: int, out_channels: int, stride: tuple[int, int], repeats: int,
106
+ block_type: type[nn.Module]) -> nn.Sequential:
107
+ blocks = [block_type(in_channels, out_channels, stride=stride)]
108
+ blocks += [block_type(out_channels, out_channels) for _ in range(repeats)]
109
+ return nn.Sequential(*blocks)
110
+
111
+
112
+ class MergDetM(nn.Module):
113
+ def __init__(
114
+ self,
115
+ size: str = "M",
116
+ objectness_prior: float | None = None,
117
+ output_stride: tuple[int, int] = (2, 8),
118
+ block_kind: str = "asymmetric",
119
+ gradient_mode: str = "fixed",
120
+ fusion_mode: str = "multiscale",
121
+ ) -> None:
122
+ super().__init__()
123
+ config = CONFIGS[size]
124
+ self.config = config
125
+ output_stride = (int(output_stride[0]), int(output_stride[1]))
126
+ if output_stride not in GRID_STRIDES:
127
+ raise ValueError(f"output_stride must be one of {sorted(GRID_STRIDES)}, got {output_stride}")
128
+ self.output_stride = output_stride
129
+ if block_kind not in ("asymmetric", "regular"):
130
+ raise ValueError(f"block_kind must be 'asymmetric' or 'regular', got {block_kind!r}")
131
+ if fusion_mode not in ("multiscale", "detail_only"):
132
+ raise ValueError(f"fusion_mode must be 'multiscale' or 'detail_only', got {fusion_mode!r}")
133
+ self.block_kind = block_kind
134
+ self.gradient_mode = gradient_mode
135
+ self.fusion_mode = fusion_mode
136
+ block_type = AsymmetricResidualBlock if block_kind == "asymmetric" else RegularBottleneckBlock
137
+ stride1, stride2 = GRID_STRIDES[output_stride]
138
+ c0, c1, c2, c3, c4 = config.channels
139
+ r1, r2, r3, r4 = config.repeats
140
+ self.stem = EdgeMicroStem(c0, gradient_mode=gradient_mode)
141
+ self.stage1 = _stage(c0, c1, stride1, r1, block_type)
142
+ self.stage2 = _stage(c1, c2, stride2, r2, block_type) # output grid (detail)
143
+ self.stage3 = _stage(c2, c3, STRIDES[2], r3, block_type) # 4x16
144
+ self.stage4 = _stage(c3, c4, STRIDES[3], r4, block_type) # 8x32
145
+ neck = config.neck
146
+ self.lateral2 = ConvBNAct(c2, neck, 1, activation=False)
147
+ self.lateral3 = ConvBNAct(c3, neck, 1, activation=False)
148
+ self.lateral4 = ConvBNAct(c4, neck, 1, activation=False)
149
+ self.smooth3 = block_type(neck, neck)
150
+ self.smooth2 = block_type(neck, neck)
151
+ self.activation = nn.ReLU6(inplace=True)
152
+ geometry_head = config.geometry_head if config.geometry_head is not None else config.head
153
+ geometry_depth = (
154
+ config.geometry_head_depth
155
+ if config.geometry_head_depth is not None
156
+ else config.head_depth
157
+ )
158
+ self.objectness_branch = self._branch(neck, config.head, config.head_depth, block_type)
159
+ self.geometry_branch = self._branch(neck, geometry_head, geometry_depth, block_type)
160
+ self.objectness_head = nn.Conv2d(config.head, 1, 1)
161
+ self.geometry_head = nn.Conv2d(geometry_head, 4, 1)
162
+ if objectness_prior is not None:
163
+ if not 0.0 < objectness_prior < 1.0:
164
+ raise ValueError("objectness_prior must lie in (0, 1)")
165
+ with torch.no_grad():
166
+ self.objectness_head.bias.fill_(-math.log((1.0 - objectness_prior) / objectness_prior))
167
+
168
+ @staticmethod
169
+ def _branch(in_channels: int, channels: int, depth: int,
170
+ block_type: type[nn.Module] = AsymmetricResidualBlock) -> nn.Sequential:
171
+ # The 2x8 grid has 1728 cells at 144x192, so a dense 3x3 head would
172
+ # dominate the MAC budget; keep the head asymmetric-depthwise like the
173
+ # stages and spend parameters in the coarse stages instead.
174
+ layers = [block_type(in_channels, channels)]
175
+ layers += [block_type(channels, channels) for _ in range(depth - 1)]
176
+ return nn.Sequential(*layers)
177
+
178
+ def forward(self, image: Tensor) -> Tensor:
179
+ s2 = self.stage2(self.stage1(self.stem(image)))
180
+ if self.fusion_mode == "multiscale":
181
+ s3 = self.stage3(s2)
182
+ s4 = self.stage4(s3)
183
+ p4 = self.lateral4(s4)
184
+ p3 = self.smooth3(self.activation(self.lateral3(s3) + F.interpolate(p4, size=s3.shape[-2:], mode="nearest")))
185
+ p2 = self.smooth2(self.activation(self.lateral2(s2) + F.interpolate(p3, size=s2.shape[-2:], mode="nearest")))
186
+ else:
187
+ p2 = self.smooth2(self.activation(self.lateral2(s2)))
188
+ # objectness, dx, dy, normalized width, normalized height — MERG-Det layout
189
+ return torch.cat(
190
+ (self.objectness_head(self.objectness_branch(p2)), self.geometry_head(self.geometry_branch(p2))), dim=1
191
+ )
@@ -0,0 +1,354 @@
1
+ """Minimal YOLO-format, single-class dataset loader for MERG-Det."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import torch
9
+ from PIL import Image, ImageOps
10
+ from torch import Tensor
11
+ from torch.nn import functional as F
12
+ from torch.utils.data import Dataset
13
+
14
+
15
+ IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AugmentationConfig:
20
+ """Training-time augmentation for a letterboxed single-class grid detector.
21
+
22
+ Every transform is applied to the already-letterboxed tensor rather than to
23
+ the source image, so it composes with the preprocessing cache. That costs a
24
+ little fidelity on the zoom-in path, and buys the ability to augment without
25
+ decoding JPEG again on every epoch.
26
+
27
+ Horizontal flip is safe here because the task is plate *detection*. It would
28
+ not be safe once character recognition is in scope.
29
+ """
30
+
31
+ flip_probability: float = 0.5
32
+ zoom_range: tuple[float, float] = (0.7, 1.5)
33
+ translate_fraction: float = 0.08
34
+ brightness_jitter: float = 0.25
35
+ contrast_jitter: float = 0.25
36
+ noise_std: float = 0.02
37
+ min_visible_fraction: float = 0.35
38
+ fill_value: float = 114.0 / 255.0
39
+
40
+ def __post_init__(self) -> None:
41
+ if not 0.0 <= self.flip_probability <= 1.0:
42
+ raise ValueError("flip_probability must lie in [0, 1]")
43
+ if self.zoom_range[0] <= 0 or self.zoom_range[0] > self.zoom_range[1]:
44
+ raise ValueError("zoom_range must be positive and ordered")
45
+ if not 0.0 < self.min_visible_fraction <= 1.0:
46
+ raise ValueError("min_visible_fraction must lie in (0, 1]")
47
+
48
+
49
+ def _apply_zoom_translate(
50
+ image: Tensor, zoom: float, shift_x: float, shift_y: float, fill: float
51
+ ) -> Tensor:
52
+ """Resample the image so a point at u moves to u * zoom + (1 - zoom) / 2 + s."""
53
+
54
+ # affine_grid maps output coordinates back to input coordinates, so the
55
+ # matrix here is the inverse of the transform applied to the boxes.
56
+ theta = image.new_tensor(
57
+ [
58
+ [1.0 / zoom, 0.0, -2.0 * shift_x / zoom],
59
+ [0.0, 1.0 / zoom, -2.0 * shift_y / zoom],
60
+ ]
61
+ ).unsqueeze(0)
62
+ grid = F.affine_grid(theta, (1,) + tuple(image.shape), align_corners=False)
63
+ # Sampling the image minus its fill value, then adding it back, makes every
64
+ # out-of-bounds pixel exactly the letterbox grey instead of black.
65
+ sampled = F.grid_sample(
66
+ (image - fill).unsqueeze(0),
67
+ grid,
68
+ mode="bilinear",
69
+ padding_mode="zeros",
70
+ align_corners=False,
71
+ )
72
+ return sampled.squeeze(0) + fill
73
+
74
+
75
+ def _transform_labels(
76
+ labels: Tensor, zoom: float, shift_x: float, shift_y: float, minimum: float
77
+ ) -> Tensor:
78
+ """Move boxes under the same transform, then clip and drop what is left."""
79
+
80
+ if not labels.numel():
81
+ return labels
82
+ moved = labels.clone()
83
+ offset = (1.0 - zoom) / 2.0
84
+ moved[:, 1] = labels[:, 1] * zoom + offset + shift_x
85
+ moved[:, 2] = labels[:, 2] * zoom + offset + shift_y
86
+ moved[:, 3] = labels[:, 3] * zoom
87
+ moved[:, 4] = labels[:, 4] * zoom
88
+
89
+ x1 = (moved[:, 1] - moved[:, 3] / 2).clamp(0.0, 1.0)
90
+ y1 = (moved[:, 2] - moved[:, 4] / 2).clamp(0.0, 1.0)
91
+ x2 = (moved[:, 1] + moved[:, 3] / 2).clamp(0.0, 1.0)
92
+ y2 = (moved[:, 2] + moved[:, 4] / 2).clamp(0.0, 1.0)
93
+ clipped_width = (x2 - x1).clamp_min(0.0)
94
+ clipped_height = (y2 - y1).clamp_min(0.0)
95
+
96
+ original_area = (moved[:, 3] * moved[:, 4]).clamp_min(1e-12)
97
+ visible = (clipped_width * clipped_height) / original_area
98
+ keep = visible >= minimum
99
+
100
+ moved[:, 1] = (x1 + x2) / 2
101
+ moved[:, 2] = (y1 + y2) / 2
102
+ moved[:, 3] = clipped_width
103
+ moved[:, 4] = clipped_height
104
+ return moved[keep]
105
+
106
+
107
+ def augment(
108
+ image: Tensor,
109
+ labels: Tensor,
110
+ config: AugmentationConfig,
111
+ generator: torch.Generator | None = None,
112
+ extra_boxes: Tensor | None = None,
113
+ ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, Tensor]:
114
+ """Apply one random augmentation to a letterboxed image and its boxes.
115
+
116
+ ``extra_boxes`` carries a second set of boxes through the identical geometric
117
+ transform, which is how teacher detections stay aligned with the image they
118
+ supervise. Both tensors use columns 1..4 for ``cx, cy, w, h``; column 0 is
119
+ the class for labels and the confidence for teacher boxes, and is untouched.
120
+ """
121
+
122
+ def uniform(low: float, high: float) -> float:
123
+ if low == high:
124
+ return low
125
+ return float(
126
+ torch.empty(1).uniform_(low, high, generator=generator).item()
127
+ )
128
+
129
+ if extra_boxes is None:
130
+ extra_boxes = torch.zeros((0, labels.shape[1] if labels.ndim == 2 else 5))
131
+ return_extra = False
132
+ else:
133
+ return_extra = True
134
+
135
+ if uniform(0.0, 1.0) < config.flip_probability:
136
+ image = torch.flip(image, dims=(-1,))
137
+ if labels.numel():
138
+ labels = labels.clone()
139
+ labels[:, 1] = 1.0 - labels[:, 1]
140
+ if extra_boxes.numel():
141
+ extra_boxes = extra_boxes.clone()
142
+ extra_boxes[:, 1] = 1.0 - extra_boxes[:, 1]
143
+
144
+ zoom = uniform(*config.zoom_range)
145
+ shift_x = uniform(-config.translate_fraction, config.translate_fraction)
146
+ shift_y = uniform(-config.translate_fraction, config.translate_fraction)
147
+ if zoom != 1.0 or shift_x != 0.0 or shift_y != 0.0:
148
+ image = _apply_zoom_translate(
149
+ image, zoom, shift_x, shift_y, config.fill_value
150
+ )
151
+ labels = _transform_labels(
152
+ labels, zoom, shift_x, shift_y, config.min_visible_fraction
153
+ )
154
+ extra_boxes = _transform_labels(
155
+ extra_boxes, zoom, shift_x, shift_y, config.min_visible_fraction
156
+ )
157
+
158
+ if config.contrast_jitter:
159
+ scale = uniform(1.0 - config.contrast_jitter, 1.0 + config.contrast_jitter)
160
+ image = (image - image.mean()) * scale + image.mean()
161
+ if config.brightness_jitter:
162
+ image = image + uniform(-config.brightness_jitter, config.brightness_jitter)
163
+ if config.noise_std:
164
+ image = image + torch.randn(
165
+ image.shape, generator=generator, dtype=image.dtype
166
+ ) * config.noise_std
167
+ image = image.clamp(0.0, 1.0)
168
+ if return_extra:
169
+ return image, labels, extra_boxes
170
+ return image, labels
171
+
172
+
173
+
174
+ class YoloPlateDataset(Dataset[tuple[Tensor, Tensor]]):
175
+ def __init__(
176
+ self,
177
+ image_directory: str | Path,
178
+ label_directory: str | Path,
179
+ input_height: int = 112,
180
+ input_width: int = 192,
181
+ source_class: int = 0,
182
+ cache_images: bool = False,
183
+ augmentation: AugmentationConfig | None = None,
184
+ teacher_targets: str | Path | None = None,
185
+ ) -> None:
186
+ self.image_directory = Path(image_directory)
187
+ self.label_directory = Path(label_directory)
188
+ self.input_height = input_height
189
+ self.input_width = input_width
190
+ self.source_class = source_class
191
+ # Augmentation is applied on read, never baked into the cache, so the
192
+ # cached tensors stay a faithful copy of the preprocessed dataset.
193
+ self.augmentation = augmentation
194
+ self.images = sorted(
195
+ path
196
+ for path in self.image_directory.rglob("*")
197
+ if path.suffix.lower() in IMAGE_SUFFIXES
198
+ )
199
+ if not self.images:
200
+ raise ValueError(f"no images found in {self.image_directory}")
201
+ self.teacher: list[Tensor] | None = (
202
+ self._load_teacher_targets(Path(teacher_targets))
203
+ if teacher_targets is not None
204
+ else None
205
+ )
206
+ self.cache: list[tuple[Tensor, Tensor]] | None = None
207
+ if cache_images:
208
+ cache_directory = self.label_directory.parents[1] / "cache"
209
+ cache_file = cache_directory / (
210
+ f"{self.image_directory.name}_{input_width}x{input_height}"
211
+ f"_class{source_class}.pt"
212
+ )
213
+ if cache_file.exists():
214
+ cached = torch.load(cache_file, map_location="cpu", weights_only=False, mmap=True)
215
+ if len(cached) != len(self.images):
216
+ raise ValueError(f"stale dataset cache: {cache_file}")
217
+ self.cache = cached
218
+ print(f"loaded_image_cache={cache_file}", flush=True)
219
+ else:
220
+ self.cache = [self._load_item(index) for index in range(len(self.images))]
221
+ cache_directory.mkdir(parents=True, exist_ok=True)
222
+ torch.save(self.cache, cache_file)
223
+ print(f"saved_image_cache={cache_file}", flush=True)
224
+
225
+ def _load_teacher_targets(self, path: Path) -> list[Tensor]:
226
+ """Load teacher detections and put them in this dataset's letterbox frame.
227
+
228
+ The file stores boxes normalized to the *source* image. The student sees
229
+ a letterboxed crop, so the boxes need exactly the transform that
230
+ :meth:`_load_item` applies to the labels, or the supervision would point
231
+ at the wrong pixels.
232
+
233
+ Only image dimensions are read here, not pixels, so this stays cheap.
234
+ """
235
+
236
+ payload = torch.load(path, map_location="cpu", weights_only=False)
237
+ if payload.get("format") != "mergdet_teacher_targets_v1":
238
+ raise ValueError(f"unrecognized teacher target format in {path}")
239
+ predictions = payload["predictions"]
240
+
241
+ targets: list[Tensor] = []
242
+ missing = 0
243
+ for image_path in self.images:
244
+ key = str(image_path.relative_to(self.image_directory)).replace("\\", "/")
245
+ detections = predictions.get(key)
246
+ if detections is None:
247
+ missing += 1
248
+ targets.append(torch.zeros((0, 5), dtype=torch.float32))
249
+ continue
250
+ with Image.open(image_path) as handle:
251
+ source_width, source_height = ImageOps.exif_transpose(handle).size
252
+ scale = min(
253
+ self.input_width / source_width, self.input_height / source_height
254
+ )
255
+ pad_x = (self.input_width - max(1, round(source_width * scale))) // 2
256
+ pad_y = (self.input_height - max(1, round(source_height * scale))) // 2
257
+ boxes = torch.zeros((detections.shape[0], 5), dtype=torch.float32)
258
+ # Column 0 becomes confidence so columns 1..4 line up with labels and
259
+ # the augmentation transform can be shared between the two.
260
+ boxes[:, 0] = detections[:, 4]
261
+ boxes[:, 1] = (
262
+ detections[:, 0] * source_width * scale + pad_x
263
+ ) / self.input_width
264
+ boxes[:, 2] = (
265
+ detections[:, 1] * source_height * scale + pad_y
266
+ ) / self.input_height
267
+ boxes[:, 3] = detections[:, 2] * source_width * scale / self.input_width
268
+ boxes[:, 4] = detections[:, 3] * source_height * scale / self.input_height
269
+ targets.append(boxes)
270
+ if missing:
271
+ raise ValueError(
272
+ f"{missing} of {len(self.images)} images have no teacher entry in "
273
+ f"{path}; the target file does not match this split"
274
+ )
275
+ total = sum(int(item.shape[0]) for item in targets)
276
+ print(f"loaded_teacher_targets={path} detections={total}", flush=True)
277
+ return targets
278
+
279
+ def __len__(self) -> int:
280
+ return len(self.images)
281
+
282
+ def _read_labels(self, image_path: Path) -> Tensor:
283
+ relative = image_path.relative_to(self.image_directory).with_suffix(".txt")
284
+ label_path = self.label_directory / relative
285
+ rows: list[list[float]] = []
286
+ if label_path.exists():
287
+ for line in label_path.read_text(encoding="utf-8").splitlines():
288
+ values = [float(value) for value in line.split()]
289
+ if len(values) != 5:
290
+ raise ValueError(f"invalid YOLO label in {label_path}: {line}")
291
+ if int(values[0]) == self.source_class:
292
+ rows.append([0.0, *values[1:]])
293
+ return torch.tensor(rows, dtype=torch.float32).reshape(-1, 5)
294
+
295
+ def _load_item(self, index: int) -> tuple[Tensor, Tensor]:
296
+ image_path = self.images[index]
297
+ labels = self._read_labels(image_path)
298
+ image = ImageOps.exif_transpose(Image.open(image_path)).convert("L")
299
+ source_width, source_height = image.size
300
+ scale = min(self.input_width / source_width, self.input_height / source_height)
301
+ resized_width = max(1, round(source_width * scale))
302
+ resized_height = max(1, round(source_height * scale))
303
+ resized = image.resize((resized_width, resized_height), Image.Resampling.BILINEAR)
304
+ pad_x = (self.input_width - resized_width) // 2
305
+ pad_y = (self.input_height - resized_height) // 2
306
+ canvas = Image.new("L", (self.input_width, self.input_height), 114)
307
+ canvas.paste(resized, (pad_x, pad_y))
308
+
309
+ if labels.numel():
310
+ labels[:, 1] = (labels[:, 1] * source_width * scale + pad_x) / self.input_width
311
+ labels[:, 2] = (labels[:, 2] * source_height * scale + pad_y) / self.input_height
312
+ labels[:, 3] = labels[:, 3] * source_width * scale / self.input_width
313
+ labels[:, 4] = labels[:, 4] * source_height * scale / self.input_height
314
+
315
+ pixels = torch.frombuffer(bytearray(canvas.tobytes()), dtype=torch.uint8)
316
+ tensor = pixels.reshape(self.input_height, self.input_width).float() / 255.0
317
+ return tensor.unsqueeze(0), labels
318
+
319
+ def __getitem__(
320
+ self, index: int
321
+ ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, Tensor]:
322
+ image, labels = (
323
+ self.cache[index] if self.cache is not None else self._load_item(index)
324
+ )
325
+ teacher = self.teacher[index] if self.teacher is not None else None
326
+ if self.augmentation is None:
327
+ if teacher is None:
328
+ return image, labels
329
+ return image, labels, teacher
330
+ # Clone first: the cached tensors are shared across epochs and workers.
331
+ return augment(
332
+ image.clone(),
333
+ labels.clone(),
334
+ self.augmentation,
335
+ extra_boxes=None if teacher is None else teacher.clone(),
336
+ )
337
+
338
+
339
+ def collate_plates(
340
+ batch: list[tuple[Tensor, ...]],
341
+ ) -> tuple[Tensor, list[Tensor]] | tuple[Tensor, list[Tensor], list[Tensor]]:
342
+ """Stack a batch, keeping teacher boxes only when the dataset supplies them.
343
+
344
+ Validation never carries teacher targets, so it keeps the two-item shape and
345
+ every existing consumer of this function keeps working unchanged.
346
+ """
347
+
348
+ columns = zip(*batch)
349
+ images = torch.stack(next(columns))
350
+ labels = list(next(columns))
351
+ teacher = next(columns, None)
352
+ if teacher is None:
353
+ return images, labels
354
+ return images, labels, list(teacher)