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
akros/__init__.py
ADDED
akros/api.py
ADDED
colosseum/__init__.py
ADDED
colosseum/__main__.py
ADDED
|
@@ -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)
|