TobiasLogic commited on
Commit
9b92c75
·
verified ·
1 Parent(s): 606aa90

Upload ObjectModel-v1: code, README, assets (v1 checkpoint)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/examples/family.jpg filter=lfs diff=lfs merge=lfs -text
37
+ assets/examples/food_tray.jpg filter=lfs diff=lfs merge=lfs -text
38
+ assets/examples/rodeo.jpg filter=lfs diff=lfs merge=lfs -text
39
+ assets/examples/street_market.jpg filter=lfs diff=lfs merge=lfs -text
40
+ assets/tracking_demo.gif filter=lfs diff=lfs merge=lfs -text
41
+ assets/tracking_demo.mp4 filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Bench Labs
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - object-detection
5
+ - pytorch
6
+ - computer-vision
7
+ - research
8
+ ---
9
+
10
+ # ObjectModel-v1
11
+
12
+ ObjectModel-v1 is a clean-room, compact, end-to-end object detector from Bench Labs. It is a
13
+ research implementation, not a benchmark claim. The model tests
14
+ whether global semantic reasoning can be compressed into a small fixed latent memory while
15
+ precise geometry is recovered by query-conditioned sampling from full-resolution pyramid
16
+ features.
17
+
18
+ The model is NMS-free. It predicts a fixed set of objects and is trained with Hungarian
19
+ bipartite matching.
20
+
21
+ This is v1: a single from-scratch training run with no pretraining, no hyperparameter search
22
+ across seeds, and none of the ablations `RESEARCH.md` calls for. Treat everything below as a
23
+ first checkpoint in an ongoing process, not a finished result. v2 is expected to do meaningfully
24
+ better, whether that comes from more training compute, an architecture change informed by v1's
25
+ ablations, or both.
26
+
27
+ ## Status
28
+
29
+ - Architecture, COCO data path, losses, training, evaluation, profiling, and ONNX export are implemented.
30
+ - Synthetic forward/loss/backward and data tests are included.
31
+ - Full COCO2017 training is complete: 100 epochs on a single RTX 5090, peak val AP 0.358 at epoch 95.
32
+ See [Training Progress](#training-progress) below for the full curve.
33
+ - No "state of the art" or "beats YOLO/DETR" claim is made. Final AP (0.358) is below the 40-50
34
+ range `RESEARCH.md` set as a competitiveness bar against 20-40M-parameter real-time detectors.
35
+ This is a real, single-seed, un-pretrained result, not a benchmark claim. See the
36
+ [Minimum Validation Protocol](#minimum-validation-protocol) for what a competitive claim actually
37
+ requires: controlled baselines, three seeds, and ablations, none of which have been run yet.
38
+
39
+ ## Training Progress
40
+
41
+ <img src="assets/ap_progress.svg" alt="Validation AP by epoch across the full 100-epoch run, rising from near-zero to a peak of 0.358 at epoch 95" width="900">
42
+
43
+ | | |
44
+ |---|---|
45
+ | Parameters | 40.8M |
46
+ | Epochs | 100 / 100 (complete) |
47
+ | Peak val AP (IoU 0.50:0.95) | 0.358 (epoch 95) |
48
+ | Final val AP (epoch 100) | 0.356 |
49
+ | Val AP50 / AP75 | 0.542 / 0.377 |
50
+ | Val AP small / medium / large | 0.184 / 0.385 / 0.493 |
51
+ | Val AR@100 | 0.573 |
52
+ | Train loss | 4.11 (from 15.77 at epoch 1) |
53
+ | Throughput | ~90-110 img/s, batch 32, RTX 5090 |
54
+ | Single-frame inference | 30.7 ms / 32.5 FPS (batch 1, eager, RTX 5090) |
55
+
56
+ Peak AP came at epoch 95 (0.3578), not the final epoch. That is normal late-training fluctuation,
57
+ and `best.pt` correctly holds the epoch-95 weights rather than epoch 100's. Against the 40-50 AP
58
+ range `RESEARCH.md` set as the competitiveness bar, this run falls short. It is a real result from
59
+ a genuine from-scratch run, on the low end of what the project's own research memo was aiming for.
60
+ The gap is exactly what the [Minimum Validation Protocol](#minimum-validation-protocol) exists to
61
+ characterize properly: whether more decoder layers, more latents, longer training, or pretraining
62
+ would close it is unknown without actually running those ablations.
63
+
64
+ ## Examples
65
+
66
+ Six detections from the final EMA checkpoint (epoch 95, peak AP) on COCO val2017 images,
67
+ confidence &ge; 0.35. Picked for variety, not cherry-picked for perfection.
68
+
69
+ | | |
70
+ |---|---|
71
+ | ![A tray of Japanese desserts and a sake bottle, each item individually boxed](assets/examples/food_tray.jpg) | ![A child on a skateboard with a parent, skateboard detected at high confidence](assets/examples/skatepark.jpg) |
72
+ | Dessert tray, bottle, bowl, and a dozen individually boxed items | Skate park, parent and child, skateboard at 0.93 confidence |
73
+ | ![A street market with a person at 0.94 confidence and umbrella awning detected](assets/examples/street_market.jpg) | ![A rodeo scene with multiple people correctly boxed in a crowd](assets/examples/rodeo.jpg) |
74
+ | Street market, person at 0.94, market umbrella | Rodeo, crowd of people, one animal still mislabeled |
75
+ | ![A parent holding a baby at a table, with cup, chair, and bottle detected](assets/examples/family.jpg) | ![A rainbow kite in flight above a beach with people and a distant boat](assets/examples/kite_beach.jpg) |
76
+ | Family at a table, cups, chair, bottle, two people | Beach, kite at 0.80, people along the shore, a distant boat |
77
+
78
+ ## Live Tracking Demo
79
+
80
+ <img src="assets/tracking_demo.gif" alt="ObjectModel-v1 detections chained through a SORT tracker on real pedestrian footage, boxes and ids tracking people across frames" width="420">
81
+
82
+ [Full-length video (20s, MP4)](assets/tracking_demo.mp4)
83
+
84
+ ObjectModel-v1 itself has no temporal component. Every frame is detected independently. The clip
85
+ above chains detections through a from-scratch SORT-style tracker (`src/objectmodel_v1/tracking.py`:
86
+ constant-velocity Kalman motion model plus IoU/Hungarian frame-to-frame association) to give boxes
87
+ a persistent id and a short motion trail. Source footage is `vtest.avi`, OpenCV's standard
88
+ pedestrian test clip (BSD-3, ships with OpenCV), genuine video the model never trained on.
89
+
90
+ This is the same clip run at three points in training, tracker unchanged throughout. Only the
91
+ detector's checkpoint improved:
92
+
93
+ | Checkpoint | Ids issued over 20s | Longest-lived ids |
94
+ |---|---|---|
95
+ | Epoch 13 (AP 0.227) | ~89 | none survive past a few seconds |
96
+ | Epoch 26 (AP 0.288) | ~84 | 3 ids survive nearly the full clip |
97
+ | Epoch 95 (AP 0.358, final) | 92 | 4 ids survive nearly the full clip |
98
+
99
+ Track count issued does not fall much, since new people keep entering frame throughout the clip
100
+ and each one earns a new id, which is correct behavior. Track persistence for people already in
101
+ frame improved consistently instead. A separate test on a genuinely different scene, an eye-level
102
+ warehouse clip not shown here, also surfaced a real and distinct limitation: the detector still
103
+ occasionally hallucinates objects on plain background surfaces, reading a support pillar as
104
+ "refrigerator", even at this final checkpoint. Tracking quality rides on detection quality, and
105
+ detection quality on out-of-domain footage (camera angles, lighting, and compression that COCO's
106
+ photos do not really cover) is visibly weaker than on COCO's own validation images.
107
+
108
+ ## Architecture
109
+
110
+ ```text
111
+ image
112
+ -> compact convolutional backbone (strides 8/16/32)
113
+ -> top-down pyramid fusion
114
+ -> pooled multi-scale tokens
115
+ -> fixed latent memory (global semantics)
116
+ -> learned object queries
117
+ -> query self-attention
118
+ -> cross-attention to latent memory
119
+ -> local sampling around the current query box
120
+ -> iterative class and box prediction
121
+ -> object set (no anchors, no NMS)
122
+ ```
123
+
124
+ The local sampling radius scales with each query's current width and height. Early decoder
125
+ layers can search broadly; later layers focus naturally as boxes are refined. During training,
126
+ an optional dense auxiliary head adds one-to-many spatial supervision. It is discarded for
127
+ inference and must be evaluated as an ablation, not assumed to help.
128
+
129
+ ## Installation
130
+
131
+ Use Python 3.11 or another PyTorch-supported Python version:
132
+
133
+ ```bash
134
+ python3.11 -m venv .venv
135
+ .venv/bin/pip install --upgrade pip
136
+ .venv/bin/pip install -e '.[coco,export,dev]'
137
+ ```
138
+
139
+ For a specific CUDA build, install the matching PyTorch wheel first using the command from
140
+ <https://pytorch.org/get-started/locally/>, then install ObjectModel-v1.
141
+
142
+ ## Data
143
+
144
+ The default configuration expects COCO 2017:
145
+
146
+ ```text
147
+ /path/to/coco/
148
+ annotations/instances_train2017.json
149
+ annotations/instances_val2017.json
150
+ train2017/*.jpg
151
+ val2017/*.jpg
152
+ ```
153
+
154
+ Category IDs are mapped to contiguous training labels and converted back during evaluation.
155
+ Images without target objects are supported.
156
+
157
+ ## Commands
158
+
159
+ Profile the model before allocating training compute:
160
+
161
+ ```bash
162
+ objectmodel-profile --config configs/objectmodel_v1.yaml --device cuda
163
+ ```
164
+
165
+ Overfit a small dataset first. A full single-GPU command is:
166
+
167
+ ```bash
168
+ objectmodel-train \
169
+ --config configs/objectmodel_v1.yaml \
170
+ --data-root /path/to/coco \
171
+ --output outputs/objectmodel_v1
172
+ ```
173
+
174
+ Distributed training:
175
+
176
+ ```bash
177
+ torchrun --standalone --nproc_per_node=8 -m objectmodel_v1.train \
178
+ --config configs/objectmodel_v1.yaml \
179
+ --data-root /path/to/coco \
180
+ --output outputs/objectmodel_v1
181
+ ```
182
+
183
+ Resume and override configuration values:
184
+
185
+ ```bash
186
+ objectmodel-train \
187
+ --config outputs/objectmodel_v1/config.yaml \
188
+ --data-root /path/to/coco \
189
+ --output outputs/objectmodel_v1 \
190
+ --resume outputs/objectmodel_v1/last.pt \
191
+ --set train.batch_size=8
192
+ ```
193
+
194
+ Evaluate the EMA checkpoint with canonical `pycocotools` metrics:
195
+
196
+ ```bash
197
+ objectmodel-eval \
198
+ --config outputs/objectmodel_v1/config.yaml \
199
+ --checkpoint outputs/objectmodel_v1/best.pt \
200
+ --data-root /path/to/coco
201
+ ```
202
+
203
+ Export raw logits and normalized `cxcywh` boxes to ONNX:
204
+
205
+ ```bash
206
+ objectmodel-export \
207
+ --config outputs/objectmodel_v1/config.yaml \
208
+ --checkpoint outputs/objectmodel_v1/best.pt \
209
+ --output outputs/objectmodel_v1/objectmodel-v1.onnx
210
+ ```
211
+
212
+ Track detections across video frames (see [Live Tracking Demo](#live-tracking-demo); this is a
213
+ post-processing layer over independent per-frame detections, not a model capability):
214
+
215
+ ```python
216
+ from objectmodel_v1.tracking import SortTracker
217
+
218
+ tracker = SortTracker(iou_threshold=0.3, max_age=5, min_hits=2)
219
+ for frame in video_frames:
220
+ boxes, labels, scores = detect(frame) # your decode_predictions() call
221
+ for t in tracker.update(boxes, labels, scores):
222
+ print(t.id, t.box, t.label, t.score)
223
+ ```
224
+
225
+ ## Minimum Validation Protocol
226
+
227
+ Before describing ObjectModel-v1 as competitive, run all models on the same COCO train2017 and
228
+ val2017 data, image resolution, augmentation budget, training epochs, and hardware. Report:
229
+
230
+ - COCO AP, AP50, AP75, APS, APM, and APL.
231
+ - Parameters, FLOPs/MACs, FP32/FP16/INT8 artifact sizes.
232
+ - End-to-end batch-1 median and p95 latency, including preprocessing and decoding.
233
+ - Peak training and inference memory, GPU-hours, epochs, and images seen.
234
+ - Three seeds for the principal result, with mean and standard deviation.
235
+ - Results both from random initialization and with the same permitted pretraining.
236
+
237
+ Required ablations:
238
+
239
+ | Experiment | Question |
240
+ |---|---|
241
+ | latent memory vs flattened feature attention | Does compression preserve useful global context? |
242
+ | local sampler disabled | Does high-resolution geometric evidence improve localization? |
243
+ | fixed vs box-scaled offsets | Does coarse-to-fine sampling matter? |
244
+ | dense auxiliary head disabled | Does added supervision improve convergence? |
245
+ | 1/2/3 latent layers | Where is the accuracy/latency optimum? |
246
+ | 32/64/96 latents | How aggressively can global context be compressed? |
247
+ | 3/4/6 decoder layers | What is the anytime speed/accuracy curve? |
248
+
249
+ Suggested external baselines are RT-DETR-R18, D-FINE-N/S, LW-DETR-T/S, and YOLOX-S. Use
250
+ their official implementations and report their license and measurement setup separately.
251
+
252
+ ## Research Basis
253
+
254
+ ObjectModel-v1 builds on published, independently attributable ideas:
255
+
256
+ - DETR: set prediction and Hungarian matching.
257
+ - Conditional and Deformable DETR: spatially conditioned/local sparse attention.
258
+ - RT-DETR: efficient separation of multi-scale encoding and query decoding.
259
+ - D-FINE: evidence that fine-grained iterative localization is valuable.
260
+ - DEIM: evidence that one-to-one matching benefits from denser training supervision.
261
+ - LW-DETR: evidence that compact transformer detectors can compete with real-time CNNs.
262
+
263
+ ObjectModel-v1's specific hypothesis is the combination of a **fixed compressed global memory** and
264
+ **box-scaled local pyramid sampling**. Publication novelty requires a broader prior-art search
265
+ and empirical ablations; this repository does not claim that the combination is patent-new.
266
+
267
+ ## License
268
+
269
+ Apache License 2.0. Dataset images, annotations, pretrained weights, and external baselines
270
+ retain their own licenses and are not included.
RESEARCH.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ObjectModel-v1 Research Decision
2
+
3
+ ## Decision
4
+
5
+ Use a compact NMS-free set detector and target roughly 25-45M parameters, despite the stated
6
+ 150-200M ceiling. Current real-time detectors already demonstrate strong COCO results in the
7
+ 20-40M range. More parameters would weaken the deployment story without establishing novelty.
8
+
9
+ ## Evidence Reviewed
10
+
11
+ | Family | Relevant result reported by its authors | Lesson for ObjectModel-v1 |
12
+ |---|---|---|
13
+ | RT-DETR | R18: 20M parameters and 46.5 COCO AP; R50: 42M and 53.1 AP | Efficient hybrid encoding plus query decoding is a strong baseline. |
14
+ | D-FINE | L: 31M, 54.0 AP, 91 GFLOPs; FDR + GO-LSD adds 1.0 AP in its roadmap ablation | Localization representation and supervision remain high-value. |
15
+ | DEIM | Dense O2O + matchability-aware loss improves convergence; D-FINE-L reaches 54.7 AP in the paper | Sparse one-to-one supervision is a training bottleneck. |
16
+ | LW-DETR | Plain compact ViT plus shallow decoder is competitive in reported real-time comparisons | A detector need not use a large decoder or full multi-scale attention. |
17
+ | YOLOX | Anchor-free decoupled detector with Apache-2.0 implementation | Strong permissive NMS-based baseline. |
18
+
19
+ These are paper/repository claims under their stated training and latency setups. They are not
20
+ ObjectModel-v1 results and should not be compared across papers as if hardware, data, and software
21
+ were controlled.
22
+
23
+ ## Hypothesis
24
+
25
+ Most detector features do not need expensive global interaction at native pyramid resolution.
26
+ A fixed set of learned latents can carry global semantics, while each object query can recover
27
+ precise local evidence from the original pyramid near its evolving box. This should make global
28
+ reasoning cost independent of image token count after pooling, while preserving a direct path
29
+ to small-object and boundary evidence.
30
+
31
+ The hypothesis fails if any of the following occur:
32
+
33
+ 1. Flattened multi-scale attention at matched compute materially outperforms latent memory.
34
+ 2. Local sampling does not improve AP75 or small-object AP.
35
+ 3. Latent compression creates an accuracy drop larger than its measured latency benefit.
36
+ 4. Dense auxiliary supervision increases training complexity without improving convergence.
37
+
38
+ ## Claim Gate
39
+
40
+ Do not use "revolutionary," "state of the art," or "beats YOLO/DETR" in public materials until:
41
+
42
+ 1. The controlled baseline and ablation matrix in `README.md` is complete.
43
+ 2. At least three seeds support the principal comparison.
44
+ 3. Latency is measured end to end on disclosed hardware and exported runtimes.
45
+ 4. Training data and pretraining are identical or differences are prominently disclosed.
46
+ 5. Failure cases and per-scale metrics are published.
47
+
48
+ ## Primary Sources
49
+
50
+ - RT-DETR: <https://arxiv.org/abs/2304.08069>
51
+ - D-FINE: <https://arxiv.org/abs/2410.13842>
52
+ - DEIM: <https://arxiv.org/abs/2412.04234>
53
+ - LW-DETR: <https://arxiv.org/abs/2406.03459>
54
+ - DETR implementation: <https://github.com/facebookresearch/detr>
55
+ - YOLOX implementation: <https://github.com/Megvii-BaseDetection/YOLOX>
56
+ - COCO API: <https://github.com/cocodataset/cocoapi>
assets/ap_progress.svg ADDED
assets/examples/family.jpg ADDED

Git LFS Details

  • SHA256: 3852b841e7174c35826cc8e233d6a98e866fe7fbc23b7ceddb0b6b9907abe730
  • Pointer size: 131 Bytes
  • Size of remote file: 114 kB
assets/examples/food_tray.jpg ADDED

Git LFS Details

  • SHA256: b37ee90d2ca0bee80648de4773dca8fb5e3d8fbc1c5dcd69792bb2cb9e4b4d16
  • Pointer size: 131 Bytes
  • Size of remote file: 105 kB
assets/examples/kite_beach.jpg ADDED
assets/examples/rodeo.jpg ADDED

Git LFS Details

  • SHA256: 316c808061221eeb0fa4ba750cd45bd40171667fdb593e1ffc72038d3b10b2e8
  • Pointer size: 131 Bytes
  • Size of remote file: 141 kB
assets/examples/skatepark.jpg ADDED
assets/examples/street_market.jpg ADDED

Git LFS Details

  • SHA256: 0755f68b09b403b3f2dbd3702fd41a1681274dbc28e692dcbe9d994e05a2f7cf
  • Pointer size: 131 Bytes
  • Size of remote file: 119 kB
assets/tracking_demo.gif ADDED

Git LFS Details

  • SHA256: 933034a46a356bc0783ec165bbb8504b4593b95882bd1d73da1f9c6f664baf62
  • Pointer size: 132 Bytes
  • Size of remote file: 3.82 MB
assets/tracking_demo.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9385404ce1b0d53f9433a5666f314d9c4dc87de3628dd11496ffd39199bd2fdd
3
+ size 2812004
configs/objectmodel_v1.yaml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ num_classes: 80
3
+ input_size: 640
4
+ stem_channels: 64
5
+ backbone_channels: [96, 192, 384, 512]
6
+ backbone_depths: [2, 3, 6, 3]
7
+ hidden_dim: 384
8
+ fpn_depth: 2
9
+ latent_count: 64
10
+ latent_pool_sizes: [12, 6, 3]
11
+ latent_layers: 2
12
+ decoder_layers: 8
13
+ num_queries: 300
14
+ num_heads: 8
15
+ local_points: 4
16
+ dropout: 0.0
17
+ dense_aux: true
18
+
19
+ loss:
20
+ cost_class: 2.0
21
+ cost_bbox: 5.0
22
+ cost_giou: 2.0
23
+ weight_class: 2.0
24
+ weight_bbox: 5.0
25
+ weight_giou: 2.0
26
+ weight_dense: 1.0
27
+ focal_alpha: 0.25
28
+ focal_gamma: 2.0
29
+ aux_weight: 1.0
30
+ dense_topk: 5
31
+
32
+ train:
33
+ epochs: 150
34
+ batch_size: 16
35
+ eval_batch_size: 8
36
+ workers: 8
37
+ prefetch_factor: 4
38
+ lr: 0.0002
39
+ backbone_lr: 0.0001
40
+ min_lr_ratio: 0.05
41
+ weight_decay: 0.05
42
+ warmup_steps: 1500
43
+ clip_grad_norm: 0.1
44
+ amp: true
45
+ amp_dtype: bfloat16
46
+ channels_last: false
47
+ compile: false
48
+ ema_decay: 0.9998
49
+ seed: 42
50
+ eval_every: 1
51
+ print_freq: 50
52
+
53
+ data:
54
+ train_image_dir: train2017
55
+ train_annotations: annotations/instances_train2017.json
56
+ val_image_dir: val2017
57
+ val_annotations: annotations/instances_val2017.json
58
+ hflip_prob: 0.5
59
+ scale_range: [0.65, 1.0]
60
+ mean: [0.485, 0.456, 0.406]
61
+ std: [0.229, 0.224, 0.225]
configs/objectmodel_v1_tiny.yaml ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ num_classes: 80
3
+ input_size: 384
4
+ stem_channels: 32
5
+ backbone_channels: [48, 96, 192, 256]
6
+ backbone_depths: [1, 2, 3, 2]
7
+ hidden_dim: 192
8
+ fpn_depth: 1
9
+ latent_count: 32
10
+ latent_pool_sizes: [8, 4, 2]
11
+ latent_layers: 1
12
+ decoder_layers: 3
13
+ num_queries: 100
14
+ num_heads: 6
15
+ local_points: 4
16
+ dropout: 0.0
17
+ dense_aux: true
18
+
19
+ loss:
20
+ cost_class: 2.0
21
+ cost_bbox: 5.0
22
+ cost_giou: 2.0
23
+ weight_class: 2.0
24
+ weight_bbox: 5.0
25
+ weight_giou: 2.0
26
+ weight_dense: 1.0
27
+ focal_alpha: 0.25
28
+ focal_gamma: 2.0
29
+ aux_weight: 1.0
30
+ dense_topk: 3
31
+
32
+ train:
33
+ epochs: 20
34
+ batch_size: 8
35
+ workers: 4
36
+ lr: 0.0002
37
+ backbone_lr: 0.0001
38
+ min_lr_ratio: 0.05
39
+ weight_decay: 0.05
40
+ warmup_steps: 200
41
+ clip_grad_norm: 0.1
42
+ amp: true
43
+ ema_decay: 0.9998
44
+ seed: 42
45
+ eval_every: 1
46
+ print_freq: 20
47
+
48
+ data:
49
+ train_image_dir: train2017
50
+ train_annotations: annotations/instances_train2017.json
51
+ val_image_dir: val2017
52
+ val_annotations: annotations/instances_val2017.json
53
+ hflip_prob: 0.5
54
+ scale_range: [0.7, 1.0]
55
+ mean: [0.485, 0.456, 0.406]
56
+ std: [0.229, 0.224, 0.225]
pyproject.toml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "objectmodel-v1"
7
+ version = "0.1.0"
8
+ description = "ObjectModel-v1: compact end-to-end object detection research"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ dependencies = [
13
+ "numpy>=1.26",
14
+ "Pillow>=10.0",
15
+ "PyYAML>=6.0",
16
+ "scipy>=1.11",
17
+ "torch>=2.4",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ coco = ["pycocotools>=2.0.7"]
22
+ export = ["onnx>=1.16", "onnxscript>=0.1"]
23
+ dev = ["pytest>=8.0", "ruff>=0.6"]
24
+
25
+ [project.scripts]
26
+ objectmodel-train = "objectmodel_v1.train:main"
27
+ objectmodel-eval = "objectmodel_v1.evaluate:main"
28
+ objectmodel-export = "objectmodel_v1.export:main"
29
+ objectmodel-profile = "objectmodel_v1.profile:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ addopts = "-q"
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py310"
src/objectmodel_v1.egg-info/PKG-INFO ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: objectmodel-v1
3
+ Version: 0.1.0
4
+ Summary: ObjectModel-v1: compact end-to-end object detection research
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: Pillow>=10.0
11
+ Requires-Dist: PyYAML>=6.0
12
+ Requires-Dist: scipy>=1.11
13
+ Requires-Dist: torch>=2.4
14
+ Provides-Extra: coco
15
+ Requires-Dist: pycocotools>=2.0.7; extra == "coco"
16
+ Provides-Extra: export
17
+ Requires-Dist: onnx>=1.16; extra == "export"
18
+ Requires-Dist: onnxscript>=0.1; extra == "export"
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == "dev"
21
+ Requires-Dist: ruff>=0.6; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # ObjectModel-v1
25
+
26
+ ObjectModel-v1 is a clean-room, compact, end-to-end object detector from Bench Labs. It is a
27
+ research implementation, not a benchmark claim. The model tests
28
+ whether global semantic reasoning can be compressed into a small fixed latent memory while
29
+ precise geometry is recovered by query-conditioned sampling from full-resolution pyramid
30
+ features.
31
+
32
+ The model is NMS-free. It predicts a fixed set of objects and is trained with Hungarian
33
+ bipartite matching.
34
+
35
+ ## Status
36
+
37
+ - Architecture, COCO data path, losses, training, evaluation, profiling, and ONNX export are implemented.
38
+ - Synthetic forward/loss/backward and data tests are included.
39
+ - No COCO training has been run in this repository yet.
40
+ - No accuracy, speed, or "state of the art" claim is made before controlled benchmarks.
41
+
42
+ ## Architecture
43
+
44
+ ```text
45
+ image
46
+ -> compact convolutional backbone (strides 8/16/32)
47
+ -> top-down pyramid fusion
48
+ -> pooled multi-scale tokens
49
+ -> fixed latent memory (global semantics)
50
+ -> learned object queries
51
+ -> query self-attention
52
+ -> cross-attention to latent memory
53
+ -> local sampling around the current query box
54
+ -> iterative class and box prediction
55
+ -> object set (no anchors, no NMS)
56
+ ```
57
+
58
+ The local sampling radius scales with each query's current width and height. Early decoder
59
+ layers can search broadly; later layers focus naturally as boxes are refined. During training,
60
+ an optional dense auxiliary head adds one-to-many spatial supervision. It is discarded for
61
+ inference and must be evaluated as an ablation, not assumed to help.
62
+
63
+ ## Installation
64
+
65
+ Use Python 3.11 or another PyTorch-supported Python version:
66
+
67
+ ```bash
68
+ python3.11 -m venv .venv
69
+ .venv/bin/pip install --upgrade pip
70
+ .venv/bin/pip install -e '.[coco,export,dev]'
71
+ ```
72
+
73
+ For a specific CUDA build, install the matching PyTorch wheel first using the command from
74
+ <https://pytorch.org/get-started/locally/>, then install ObjectModel-v1.
75
+
76
+ ## Data
77
+
78
+ The default configuration expects COCO 2017:
79
+
80
+ ```text
81
+ /path/to/coco/
82
+ annotations/instances_train2017.json
83
+ annotations/instances_val2017.json
84
+ train2017/*.jpg
85
+ val2017/*.jpg
86
+ ```
87
+
88
+ Category IDs are mapped to contiguous training labels and converted back during evaluation.
89
+ Images without target objects are supported.
90
+
91
+ ## Commands
92
+
93
+ Profile the model before allocating training compute:
94
+
95
+ ```bash
96
+ objectmodel-profile --config configs/objectmodel_v1.yaml --device cuda
97
+ ```
98
+
99
+ Overfit a small dataset first. A full single-GPU command is:
100
+
101
+ ```bash
102
+ objectmodel-train \
103
+ --config configs/objectmodel_v1.yaml \
104
+ --data-root /path/to/coco \
105
+ --output outputs/objectmodel_v1
106
+ ```
107
+
108
+ Distributed training:
109
+
110
+ ```bash
111
+ torchrun --standalone --nproc_per_node=8 -m objectmodel_v1.train \
112
+ --config configs/objectmodel_v1.yaml \
113
+ --data-root /path/to/coco \
114
+ --output outputs/objectmodel_v1
115
+ ```
116
+
117
+ Resume and override configuration values:
118
+
119
+ ```bash
120
+ objectmodel-train \
121
+ --config outputs/objectmodel_v1/config.yaml \
122
+ --data-root /path/to/coco \
123
+ --output outputs/objectmodel_v1 \
124
+ --resume outputs/objectmodel_v1/last.pt \
125
+ --set train.batch_size=8
126
+ ```
127
+
128
+ Evaluate the EMA checkpoint with canonical `pycocotools` metrics:
129
+
130
+ ```bash
131
+ objectmodel-eval \
132
+ --config outputs/objectmodel_v1/config.yaml \
133
+ --checkpoint outputs/objectmodel_v1/best.pt \
134
+ --data-root /path/to/coco
135
+ ```
136
+
137
+ Export raw logits and normalized `cxcywh` boxes to ONNX:
138
+
139
+ ```bash
140
+ objectmodel-export \
141
+ --config outputs/objectmodel_v1/config.yaml \
142
+ --checkpoint outputs/objectmodel_v1/best.pt \
143
+ --output outputs/objectmodel_v1/objectmodel-v1.onnx
144
+ ```
145
+
146
+ ## Minimum Validation Protocol
147
+
148
+ Before describing ObjectModel-v1 as competitive, run all models on the same COCO train2017 and
149
+ val2017 data, image resolution, augmentation budget, training epochs, and hardware. Report:
150
+
151
+ - COCO AP, AP50, AP75, APS, APM, and APL.
152
+ - Parameters, FLOPs/MACs, FP32/FP16/INT8 artifact sizes.
153
+ - End-to-end batch-1 median and p95 latency, including preprocessing and decoding.
154
+ - Peak training and inference memory, GPU-hours, epochs, and images seen.
155
+ - Three seeds for the principal result, with mean and standard deviation.
156
+ - Results both from random initialization and with the same permitted pretraining.
157
+
158
+ Required ablations:
159
+
160
+ | Experiment | Question |
161
+ |---|---|
162
+ | latent memory vs flattened feature attention | Does compression preserve useful global context? |
163
+ | local sampler disabled | Does high-resolution geometric evidence improve localization? |
164
+ | fixed vs box-scaled offsets | Does coarse-to-fine sampling matter? |
165
+ | dense auxiliary head disabled | Does added supervision improve convergence? |
166
+ | 1/2/3 latent layers | Where is the accuracy/latency optimum? |
167
+ | 32/64/96 latents | How aggressively can global context be compressed? |
168
+ | 3/4/6 decoder layers | What is the anytime speed/accuracy curve? |
169
+
170
+ Suggested external baselines are RT-DETR-R18, D-FINE-N/S, LW-DETR-T/S, and YOLOX-S. Use
171
+ their official implementations and report their license and measurement setup separately.
172
+
173
+ ## Research Basis
174
+
175
+ ObjectModel-v1 builds on published, independently attributable ideas:
176
+
177
+ - DETR: set prediction and Hungarian matching.
178
+ - Conditional and Deformable DETR: spatially conditioned/local sparse attention.
179
+ - RT-DETR: efficient separation of multi-scale encoding and query decoding.
180
+ - D-FINE: evidence that fine-grained iterative localization is valuable.
181
+ - DEIM: evidence that one-to-one matching benefits from denser training supervision.
182
+ - LW-DETR: evidence that compact transformer detectors can compete with real-time CNNs.
183
+
184
+ ObjectModel-v1's specific hypothesis is the combination of a **fixed compressed global memory** and
185
+ **box-scaled local pyramid sampling**. Publication novelty requires a broader prior-art search
186
+ and empirical ablations; this repository does not claim that the combination is patent-new.
187
+
188
+ ## License
189
+
190
+ Apache License 2.0. Dataset images, annotations, pretrained weights, and external baselines
191
+ retain their own licenses and are not included.
src/objectmodel_v1.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/objectmodel_v1/__init__.py
5
+ src/objectmodel_v1/boxes.py
6
+ src/objectmodel_v1/config.py
7
+ src/objectmodel_v1/data.py
8
+ src/objectmodel_v1/evaluate.py
9
+ src/objectmodel_v1/export.py
10
+ src/objectmodel_v1/losses.py
11
+ src/objectmodel_v1/matching.py
12
+ src/objectmodel_v1/model.py
13
+ src/objectmodel_v1/postprocess.py
14
+ src/objectmodel_v1/profile.py
15
+ src/objectmodel_v1/train.py
16
+ src/objectmodel_v1/utils.py
17
+ src/objectmodel_v1.egg-info/PKG-INFO
18
+ src/objectmodel_v1.egg-info/SOURCES.txt
19
+ src/objectmodel_v1.egg-info/dependency_links.txt
20
+ src/objectmodel_v1.egg-info/entry_points.txt
21
+ src/objectmodel_v1.egg-info/requires.txt
22
+ src/objectmodel_v1.egg-info/top_level.txt
23
+ tests/test_boxes.py
24
+ tests/test_data.py
25
+ tests/test_model.py
src/objectmodel_v1.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/objectmodel_v1.egg-info/entry_points.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [console_scripts]
2
+ objectmodel-eval = objectmodel_v1.evaluate:main
3
+ objectmodel-export = objectmodel_v1.export:main
4
+ objectmodel-profile = objectmodel_v1.profile:main
5
+ objectmodel-train = objectmodel_v1.train:main
src/objectmodel_v1.egg-info/requires.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ numpy>=1.26
2
+ Pillow>=10.0
3
+ PyYAML>=6.0
4
+ scipy>=1.11
5
+ torch>=2.4
6
+
7
+ [coco]
8
+ pycocotools>=2.0.7
9
+
10
+ [dev]
11
+ pytest>=8.0
12
+ ruff>=0.6
13
+
14
+ [export]
15
+ onnx>=1.16
16
+ onnxscript>=0.1
src/objectmodel_v1.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ objectmodel_v1
src/objectmodel_v1/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """ObjectModel-v1 compact object detection research package."""
2
+
3
+ from .model import ObjectModelV1, ObjectModelV1Spec, build_model
4
+
5
+ __all__ = ["ObjectModelV1", "ObjectModelV1Spec", "build_model"]
6
+ __version__ = "0.1.0"
src/objectmodel_v1/boxes.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+
7
+ def box_cxcywh_to_xyxy(boxes: Tensor) -> Tensor:
8
+ cx, cy, width, height = boxes.unbind(-1)
9
+ return torch.stack(
10
+ (cx - 0.5 * width, cy - 0.5 * height, cx + 0.5 * width, cy + 0.5 * height),
11
+ dim=-1,
12
+ )
13
+
14
+
15
+ def box_xyxy_to_cxcywh(boxes: Tensor) -> Tensor:
16
+ x0, y0, x1, y1 = boxes.unbind(-1)
17
+ return torch.stack(
18
+ ((x0 + x1) * 0.5, (y0 + y1) * 0.5, x1 - x0, y1 - y0), dim=-1
19
+ )
20
+
21
+
22
+ def box_area(boxes: Tensor) -> Tensor:
23
+ return (boxes[..., 2] - boxes[..., 0]).clamp(min=0) * (
24
+ boxes[..., 3] - boxes[..., 1]
25
+ ).clamp(min=0)
26
+
27
+
28
+ def box_iou(boxes1: Tensor, boxes2: Tensor) -> tuple[Tensor, Tensor]:
29
+ area1 = box_area(boxes1)
30
+ area2 = box_area(boxes2)
31
+ top_left = torch.maximum(boxes1[:, None, :2], boxes2[:, :2])
32
+ bottom_right = torch.minimum(boxes1[:, None, 2:], boxes2[:, 2:])
33
+ intersection = (bottom_right - top_left).clamp(min=0).prod(dim=-1)
34
+ union = area1[:, None] + area2 - intersection
35
+ return intersection / union.clamp(min=1e-7), union
36
+
37
+
38
+ def generalized_box_iou(boxes1: Tensor, boxes2: Tensor) -> Tensor:
39
+ """Pairwise generalized IoU for boxes in x0, y0, x1, y1 format."""
40
+ iou, union = box_iou(boxes1, boxes2)
41
+ top_left = torch.minimum(boxes1[:, None, :2], boxes2[:, :2])
42
+ bottom_right = torch.maximum(boxes1[:, None, 2:], boxes2[:, 2:])
43
+ enclosing = (bottom_right - top_left).clamp(min=0).prod(dim=-1)
44
+ return iou - (enclosing - union) / enclosing.clamp(min=1e-7)
45
+
46
+
47
+ def generalized_box_iou_batched(boxes1: Tensor, boxes2: Tensor) -> Tensor:
48
+ """Batched pairwise generalized IoU, boxes in x0, y0, x1, y1 format.
49
+
50
+ boxes1: [B, N, 4], boxes2: [B, M, 4] -> [B, N, M]
51
+ """
52
+ area1 = box_area(boxes1)
53
+ area2 = box_area(boxes2)
54
+ top_left = torch.maximum(boxes1[:, :, None, :2], boxes2[:, None, :, :2])
55
+ bottom_right = torch.minimum(boxes1[:, :, None, 2:], boxes2[:, None, :, 2:])
56
+ intersection = (bottom_right - top_left).clamp(min=0).prod(dim=-1)
57
+ union = area1[:, :, None] + area2[:, None, :] - intersection
58
+ iou = intersection / union.clamp(min=1e-7)
59
+ enc_top_left = torch.minimum(boxes1[:, :, None, :2], boxes2[:, None, :, :2])
60
+ enc_bottom_right = torch.maximum(boxes1[:, :, None, 2:], boxes2[:, None, :, 2:])
61
+ enclosing = (enc_bottom_right - enc_top_left).clamp(min=0).prod(dim=-1)
62
+ return iou - (enclosing - union) / enclosing.clamp(min=1e-7)
63
+
64
+
65
+ def inverse_sigmoid(value: Tensor, eps: float = 1e-5) -> Tensor:
66
+ value = value.clamp(min=0.0, max=1.0)
67
+ return torch.log(value.clamp(min=eps) / (1.0 - value).clamp(min=eps))
src/objectmodel_v1/config.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ def load_config(path: str | Path) -> dict[str, Any]:
11
+ with Path(path).open("r", encoding="utf-8") as handle:
12
+ config = yaml.safe_load(handle)
13
+ if not isinstance(config, dict):
14
+ raise ValueError(f"Configuration must be a mapping: {path}")
15
+ return config
16
+
17
+
18
+ def apply_overrides(config: dict[str, Any], overrides: list[str]) -> dict[str, Any]:
19
+ result = deepcopy(config)
20
+ for item in overrides:
21
+ if "=" not in item:
22
+ raise ValueError(f"Override must have key=value form: {item}")
23
+ dotted_key, raw_value = item.split("=", 1)
24
+ keys = dotted_key.split(".")
25
+ node = result
26
+ for key in keys[:-1]:
27
+ if key not in node or not isinstance(node[key], dict):
28
+ raise KeyError(f"Unknown configuration path: {dotted_key}")
29
+ node = node[key]
30
+ if keys[-1] not in node:
31
+ raise KeyError(f"Unknown configuration key: {dotted_key}")
32
+ node[keys[-1]] = yaml.safe_load(raw_value)
33
+ return result
34
+
35
+
36
+ def save_config(config: dict[str, Any], path: str | Path) -> None:
37
+ with Path(path).open("w", encoding="utf-8") as handle:
38
+ yaml.safe_dump(config, handle, sort_keys=False)
src/objectmodel_v1/data.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from PIL import Image, ImageEnhance
11
+ import torch
12
+ from torch import Tensor
13
+ from torch.utils.data import Dataset
14
+
15
+ from .boxes import box_xyxy_to_cxcywh
16
+
17
+
18
+ class CocoDetectionDataset(Dataset):
19
+ """Minimal COCO detection loader with deterministic category remapping."""
20
+
21
+ def __init__(
22
+ self,
23
+ image_dir: str | Path,
24
+ annotation_file: str | Path,
25
+ input_size: int,
26
+ training: bool,
27
+ hflip_prob: float = 0.5,
28
+ scale_range: tuple[float, float] = (0.65, 1.0),
29
+ mean: tuple[float, float, float] = (0.485, 0.456, 0.406),
30
+ std: tuple[float, float, float] = (0.229, 0.224, 0.225),
31
+ ) -> None:
32
+ self.image_dir = Path(image_dir)
33
+ self.annotation_file = Path(annotation_file)
34
+ self.input_size = input_size
35
+ self.training = training
36
+ self.hflip_prob = hflip_prob
37
+ self.scale_range = scale_range
38
+ self.mean = torch.tensor(mean, dtype=torch.float32)[:, None, None]
39
+ self.std = torch.tensor(std, dtype=torch.float32)[:, None, None]
40
+
41
+ with self.annotation_file.open("r", encoding="utf-8") as handle:
42
+ data = json.load(handle)
43
+ self.images = sorted(data["images"], key=lambda item: item["id"])
44
+ annotations: dict[int, list[dict[str, Any]]] = defaultdict(list)
45
+ for annotation in data["annotations"]:
46
+ if annotation.get("iscrowd", 0) == 0 and annotation["bbox"][2] > 0 and annotation["bbox"][3] > 0:
47
+ annotations[annotation["image_id"]].append(annotation)
48
+ self.annotations = annotations
49
+ category_ids = sorted(category["id"] for category in data["categories"])
50
+ self.category_to_label = {category_id: label for label, category_id in enumerate(category_ids)}
51
+ self.label_to_category = {label: category_id for category_id, label in self.category_to_label.items()}
52
+ self.categories = sorted(data["categories"], key=lambda item: self.category_to_label[item["id"]])
53
+
54
+ def __len__(self) -> int:
55
+ return len(self.images)
56
+
57
+ def _letterbox(
58
+ self, image: Image.Image, boxes: Tensor
59
+ ) -> tuple[Image.Image, Tensor, tuple[float, int, int]]:
60
+ width, height = image.size
61
+ scale_jitter = random.uniform(*self.scale_range) if self.training else 1.0
62
+ ratio = min(self.input_size / width, self.input_size / height) * scale_jitter
63
+ resized_width = max(1, round(width * ratio))
64
+ resized_height = max(1, round(height * ratio))
65
+ image = image.resize((resized_width, resized_height), Image.Resampling.BILINEAR)
66
+ max_x = self.input_size - resized_width
67
+ max_y = self.input_size - resized_height
68
+ if self.training:
69
+ offset_x = random.randint(0, max_x) if max_x else 0
70
+ offset_y = random.randint(0, max_y) if max_y else 0
71
+ else:
72
+ offset_x, offset_y = max_x // 2, max_y // 2
73
+ canvas = Image.new("RGB", (self.input_size, self.input_size), (114, 114, 114))
74
+ canvas.paste(image, (offset_x, offset_y))
75
+ if boxes.numel():
76
+ boxes = boxes * ratio
77
+ boxes[:, [0, 2]] += offset_x
78
+ boxes[:, [1, 3]] += offset_y
79
+ return canvas, boxes, (ratio, offset_x, offset_y)
80
+
81
+ def __getitem__(self, index: int) -> tuple[Tensor, dict[str, Tensor]]:
82
+ image_info = self.images[index]
83
+ image = Image.open(self.image_dir / image_info["file_name"]).convert("RGB")
84
+ original_width, original_height = image.size
85
+ records = self.annotations.get(image_info["id"], [])
86
+ boxes = []
87
+ labels = []
88
+ for record in records:
89
+ x, y, width, height = record["bbox"]
90
+ boxes.append((x, y, x + width, y + height))
91
+ labels.append(self.category_to_label[record["category_id"]])
92
+ box_tensor = torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4)
93
+ label_tensor = torch.tensor(labels, dtype=torch.int64)
94
+
95
+ if self.training and random.random() < self.hflip_prob:
96
+ image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
97
+ if box_tensor.numel():
98
+ old_x0 = box_tensor[:, 0].clone()
99
+ box_tensor[:, 0] = original_width - box_tensor[:, 2]
100
+ box_tensor[:, 2] = original_width - old_x0
101
+ if self.training:
102
+ image = ImageEnhance.Color(image).enhance(random.uniform(0.8, 1.2))
103
+ image = ImageEnhance.Contrast(image).enhance(random.uniform(0.8, 1.2))
104
+ image, box_tensor, (ratio, offset_x, offset_y) = self._letterbox(image, box_tensor)
105
+ if box_tensor.numel():
106
+ box_tensor = box_xyxy_to_cxcywh(box_tensor) / self.input_size
107
+ valid = (box_tensor[:, 2] > 1e-4) & (box_tensor[:, 3] > 1e-4)
108
+ box_tensor = box_tensor[valid].clamp(0.0, 1.0)
109
+ label_tensor = label_tensor[valid]
110
+
111
+ image_array = np.asarray(image, dtype=np.float32).copy() / 255.0
112
+ image_tensor = torch.from_numpy(image_array).permute(2, 0, 1)
113
+ image_tensor = (image_tensor - self.mean) / self.std
114
+ target = {
115
+ "boxes": box_tensor,
116
+ "labels": label_tensor,
117
+ "image_id": torch.tensor(image_info["id"], dtype=torch.int64),
118
+ "original_size": torch.tensor([original_height, original_width], dtype=torch.int64),
119
+ "transform": torch.tensor([ratio, offset_x, offset_y], dtype=torch.float32),
120
+ }
121
+ return image_tensor, target
122
+
123
+
124
+ def detection_collate(batch):
125
+ images, targets = zip(*batch, strict=True)
126
+ return torch.stack(images), list(targets)
127
+
128
+
129
+ def build_dataset(config: dict, root: str | Path, split: str) -> CocoDetectionDataset:
130
+ data = config["data"]
131
+ root = Path(root)
132
+ training = split == "train"
133
+ return CocoDetectionDataset(
134
+ root / data[f"{split}_image_dir"],
135
+ root / data[f"{split}_annotations"],
136
+ input_size=int(config["model"]["input_size"]),
137
+ training=training,
138
+ hflip_prob=float(data.get("hflip_prob", 0.5)),
139
+ scale_range=tuple(data.get("scale_range", (0.65, 1.0))) if training else (1.0, 1.0),
140
+ mean=tuple(data.get("mean", (0.485, 0.456, 0.406))),
141
+ std=tuple(data.get("std", (0.229, 0.224, 0.225))),
142
+ )
src/objectmodel_v1/evaluate.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import torch
8
+ from torch.utils.data import DataLoader
9
+
10
+ from .boxes import box_cxcywh_to_xyxy
11
+ from .config import apply_overrides, load_config
12
+ from .data import build_dataset, detection_collate
13
+ from .model import build_model
14
+
15
+
16
+ @torch.inference_mode()
17
+ def evaluate_coco(
18
+ model,
19
+ data_loader,
20
+ device: torch.device,
21
+ output_file: str | Path,
22
+ confidence: float = 0.001,
23
+ max_detections: int = 300,
24
+ ) -> dict[str, float]:
25
+ try:
26
+ from pycocotools.coco import COCO
27
+ from pycocotools.cocoeval import COCOeval
28
+ except ImportError as error:
29
+ raise RuntimeError("COCO evaluation requires: pip install -e '.[coco]'") from error
30
+
31
+ model.eval()
32
+ input_size = model.spec.input_size
33
+ predictions = []
34
+ label_to_category = data_loader.dataset.label_to_category
35
+ for images, targets in data_loader:
36
+ images = images.to(device, non_blocking=True)
37
+ outputs = model(images)
38
+ probabilities = outputs["pred_logits"].sigmoid()
39
+ normalized_boxes = box_cxcywh_to_xyxy(outputs["pred_boxes"]).clamp(0.0, 1.0)
40
+ for batch_index, target in enumerate(targets):
41
+ scores, labels = probabilities[batch_index].max(dim=-1)
42
+ count = min(max_detections, scores.numel())
43
+ scores, indices = scores.topk(count)
44
+ keep = scores >= confidence
45
+ scores, indices = scores[keep], indices[keep]
46
+ labels = labels[indices]
47
+ boxes = normalized_boxes[batch_index, indices] * input_size
48
+ ratio, offset_x, offset_y = target["transform"].tolist()
49
+ original_height, original_width = target["original_size"].tolist()
50
+ boxes[:, [0, 2]] = (boxes[:, [0, 2]] - offset_x) / ratio
51
+ boxes[:, [1, 3]] = (boxes[:, [1, 3]] - offset_y) / ratio
52
+ boxes[:, [0, 2]].clamp_(0, original_width)
53
+ boxes[:, [1, 3]].clamp_(0, original_height)
54
+ boxes[:, 2:] -= boxes[:, :2]
55
+ for box, score, label in zip(boxes.cpu(), scores.cpu(), labels.cpu(), strict=True):
56
+ predictions.append(
57
+ {
58
+ "image_id": int(target["image_id"]),
59
+ "category_id": int(label_to_category[int(label)]),
60
+ "bbox": [round(float(value), 3) for value in box],
61
+ "score": float(score),
62
+ }
63
+ )
64
+
65
+ output_file = Path(output_file)
66
+ output_file.parent.mkdir(parents=True, exist_ok=True)
67
+ with output_file.open("w", encoding="utf-8") as handle:
68
+ json.dump(predictions, handle)
69
+ ground_truth = COCO(str(data_loader.dataset.annotation_file))
70
+ if not predictions:
71
+ return {"AP": 0.0, "AP50": 0.0, "AP75": 0.0, "APS": 0.0, "APM": 0.0, "APL": 0.0}
72
+ detections = ground_truth.loadRes(str(output_file))
73
+ evaluator = COCOeval(ground_truth, detections, "bbox")
74
+ evaluator.params.imgIds = [int(item["id"]) for item in data_loader.dataset.images]
75
+ evaluator.evaluate()
76
+ evaluator.accumulate()
77
+ evaluator.summarize()
78
+ names = ("AP", "AP50", "AP75", "APS", "APM", "APL")
79
+ return {name: float(evaluator.stats[index]) for index, name in enumerate(names)}
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser(description="Evaluate ObjectModel-v1 on COCO")
84
+ parser.add_argument("--config", default="configs/objectmodel_v1.yaml")
85
+ parser.add_argument("--checkpoint", required=True)
86
+ parser.add_argument("--data-root", required=True)
87
+ parser.add_argument("--output", default="outputs/predictions.json")
88
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
89
+ parser.add_argument("--batch-size", type=int, default=8)
90
+ parser.add_argument("--workers", type=int, default=4)
91
+ parser.add_argument("--set", action="append", default=[])
92
+ args = parser.parse_args()
93
+ config = apply_overrides(load_config(args.config), args.set)
94
+ model = build_model(config)
95
+ checkpoint = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
96
+ model.load_state_dict(checkpoint.get("ema", checkpoint.get("model", checkpoint)))
97
+ device = torch.device(args.device)
98
+ model.to(device)
99
+ dataset = build_dataset(config, args.data_root, "val")
100
+ loader = DataLoader(
101
+ dataset,
102
+ batch_size=args.batch_size,
103
+ shuffle=False,
104
+ num_workers=args.workers,
105
+ pin_memory=device.type == "cuda",
106
+ collate_fn=detection_collate,
107
+ )
108
+ print(json.dumps(evaluate_coco(model, loader, device, args.output), indent=2))
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
src/objectmodel_v1/export.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import torch
7
+ from torch import nn
8
+
9
+ from .config import apply_overrides, load_config
10
+ from .model import build_model
11
+
12
+
13
+ class ExportModel(nn.Module):
14
+ def __init__(self, model: nn.Module) -> None:
15
+ super().__init__()
16
+ self.model = model
17
+
18
+ def forward(self, images: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
19
+ outputs = self.model(images)
20
+ return outputs["pred_logits"], outputs["pred_boxes"]
21
+
22
+
23
+ def main() -> None:
24
+ parser = argparse.ArgumentParser(description="Export ObjectModel-v1 to ONNX")
25
+ parser.add_argument("--config", default="configs/objectmodel_v1.yaml")
26
+ parser.add_argument("--checkpoint", required=True)
27
+ parser.add_argument("--output", default="objectmodel-v1.onnx")
28
+ parser.add_argument("--opset", type=int, default=20)
29
+ parser.add_argument("--set", action="append", default=[])
30
+ args = parser.parse_args()
31
+ config = apply_overrides(load_config(args.config), args.set)
32
+ model = build_model(config)
33
+ checkpoint = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
34
+ model.load_state_dict(checkpoint.get("ema", checkpoint.get("model", checkpoint)))
35
+ model.eval()
36
+ wrapper = ExportModel(model)
37
+ size = model.spec.input_size
38
+ sample = torch.randn(1, 3, size, size)
39
+ output = Path(args.output)
40
+ output.parent.mkdir(parents=True, exist_ok=True)
41
+ torch.onnx.export(
42
+ wrapper,
43
+ (sample,),
44
+ output,
45
+ input_names=["images"],
46
+ output_names=["logits", "boxes"],
47
+ dynamic_axes={
48
+ "images": {0: "batch"},
49
+ "logits": {0: "batch"},
50
+ "boxes": {0: "batch"},
51
+ },
52
+ opset_version=args.opset,
53
+ dynamo=False,
54
+ )
55
+ print(f"Exported {output} ({output.stat().st_size / 1024**2:.2f} MiB)")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
src/objectmodel_v1/losses.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from torch import Tensor, nn
8
+
9
+ from .boxes import box_cxcywh_to_xyxy, generalized_box_iou
10
+ from .matching import hungarian_match, hungarian_match_layers
11
+
12
+
13
+ def sigmoid_focal_loss(
14
+ logits: Tensor, targets: Tensor, alpha: float = 0.25, gamma: float = 2.0
15
+ ) -> Tensor:
16
+ probabilities = logits.sigmoid()
17
+ ce = F.binary_cross_entropy_with_logits(logits, targets, reduction="none")
18
+ p_t = probabilities * targets + (1.0 - probabilities) * (1.0 - targets)
19
+ loss = ce * (1.0 - p_t).pow(gamma)
20
+ if alpha >= 0:
21
+ alpha_t = alpha * targets + (1.0 - alpha) * (1.0 - targets)
22
+ loss = alpha_t * loss
23
+ return loss
24
+
25
+
26
+ class ObjectModelCriterion(nn.Module):
27
+ def __init__(self, config: dict) -> None:
28
+ super().__init__()
29
+ loss_config = config.get("loss", config)
30
+ self.cost_class = float(loss_config.get("cost_class", 2.0))
31
+ self.cost_bbox = float(loss_config.get("cost_bbox", 5.0))
32
+ self.cost_giou = float(loss_config.get("cost_giou", 2.0))
33
+ self.weight_class = float(loss_config.get("weight_class", 2.0))
34
+ self.weight_bbox = float(loss_config.get("weight_bbox", 5.0))
35
+ self.weight_giou = float(loss_config.get("weight_giou", 2.0))
36
+ self.weight_dense = float(loss_config.get("weight_dense", 1.0))
37
+ self.aux_weight = float(loss_config.get("aux_weight", 1.0))
38
+ self.dense_topk = int(loss_config.get("dense_topk", 5))
39
+ self.alpha = float(loss_config.get("focal_alpha", 0.25))
40
+ self.gamma = float(loss_config.get("focal_gamma", 2.0))
41
+
42
+ def _dense_targets(
43
+ self,
44
+ logits: Tensor,
45
+ targets: Sequence[dict[str, Tensor]],
46
+ level_index: int,
47
+ ) -> tuple[Tensor, Tensor, Tensor]:
48
+ batch, _, height, width = logits.shape
49
+ device = logits.device
50
+ target_logits = torch.zeros_like(logits)
51
+ target_boxes_hwc = torch.zeros(batch, height, width, 4, dtype=torch.float32, device=device)
52
+ positive = torch.zeros(batch, height, width, dtype=torch.float32, device=device)
53
+ offsets = torch.tensor(
54
+ [
55
+ (-1, -1),
56
+ (0, -1),
57
+ (1, -1),
58
+ (-1, 0),
59
+ (0, 0),
60
+ (1, 0),
61
+ (-1, 1),
62
+ (0, 1),
63
+ (1, 1),
64
+ ],
65
+ dtype=torch.int64,
66
+ device=device,
67
+ )
68
+ distances = offsets.square().sum(dim=1)
69
+ candidate_count = min(self.dense_topk, len(offsets))
70
+
71
+ nonempty = [(i, t) for i, t in enumerate(targets) if t["labels"].numel() > 0]
72
+ if not nonempty:
73
+ return target_logits, target_boxes_hwc.permute(0, 3, 1, 2), positive
74
+ all_boxes = torch.cat([t["boxes"] for _, t in nonempty])
75
+ all_labels = torch.cat([t["labels"] for _, t in nonempty])
76
+ all_batch = torch.cat(
77
+ [
78
+ torch.full((t["labels"].numel(),), i, dtype=torch.int64, device=device)
79
+ for i, t in nonempty
80
+ ]
81
+ )
82
+
83
+ areas = all_boxes[:, 2] * all_boxes[:, 3]
84
+ target_levels = torch.where(areas < 0.02, 0, torch.where(areas < 0.15, 1, 2))
85
+ level_mask = target_levels == level_index
86
+ if not bool(level_mask.any()):
87
+ return target_logits, target_boxes_hwc.permute(0, 3, 1, 2), positive
88
+
89
+ boxes = all_boxes[level_mask]
90
+ labels = all_labels[level_mask]
91
+ sel_batch = all_batch[level_mask]
92
+
93
+ grid = (boxes[:, :2] * boxes.new_tensor([width, height])).long()
94
+ grid[:, 0].clamp_(0, width - 1)
95
+ grid[:, 1].clamp_(0, height - 1)
96
+ x = (grid[:, None, 0] + offsets[None, :, 0]).clamp(0, width - 1)
97
+ y = (grid[:, None, 1] + offsets[None, :, 1]).clamp(0, height - 1)
98
+
99
+ sort_key = distances[None] * ((width + 1) * (height + 1))
100
+ sort_key = sort_key + x * (height + 1) + y
101
+ order = sort_key.argsort(dim=1, stable=True)[:, :candidate_count]
102
+ x = x.gather(1, order)
103
+ y = y.gather(1, order)
104
+
105
+ expanded_labels = labels[:, None].expand_as(x)
106
+ expanded_batch = sel_batch[:, None].expand_as(x)
107
+ target_logits[expanded_batch, expanded_labels, y, x] = 1.0
108
+
109
+ flat_cells = (expanded_batch * (height * width) + y * width + x).reshape(-1)
110
+ owners = torch.full((batch * height * width,), -1, dtype=torch.int64, device=device)
111
+ source_owners = torch.arange(boxes.shape[0], device=device)[:, None].expand_as(x).reshape(-1)
112
+ owners.scatter_reduce_(0, flat_cells, source_owners, reduce="amax", include_self=True)
113
+ occupied = owners >= 0
114
+ positive.view(-1)[occupied] = 1.0
115
+ target_boxes_hwc.view(-1, 4)[occupied] = boxes[owners[occupied]]
116
+ return target_logits, target_boxes_hwc.permute(0, 3, 1, 2), positive
117
+
118
+ def _set_loss(
119
+ self, outputs: dict[str, Tensor], targets: Sequence[dict[str, Tensor]], matches=None
120
+ ) -> dict[str, Tensor]:
121
+ logits = outputs["pred_logits"]
122
+ boxes = outputs["pred_boxes"]
123
+ if matches is None:
124
+ matches = hungarian_match(
125
+ outputs,
126
+ targets,
127
+ self.cost_class,
128
+ self.cost_bbox,
129
+ self.cost_giou,
130
+ )
131
+ device = logits.device
132
+ target_classes = torch.zeros_like(logits)
133
+ normalizer = max(sum(len(target["labels"]) for target in targets), 1)
134
+
135
+ nonempty = [
136
+ (batch_index, prediction_indices, target_indices)
137
+ for batch_index, (prediction_indices, target_indices) in enumerate(matches)
138
+ if prediction_indices.numel() > 0
139
+ ]
140
+ if nonempty:
141
+ batch_ids = torch.cat(
142
+ [torch.full_like(pred_idx, batch_index) for batch_index, pred_idx, _ in nonempty]
143
+ )
144
+ pred_idx_t = torch.cat([pred_idx for _, pred_idx, _ in nonempty])
145
+ tgt_idx_t = torch.cat([tgt_idx for _, _, tgt_idx in nonempty])
146
+
147
+ counts = torch.tensor([len(target["labels"]) for target in targets], device=device)
148
+ offsets = torch.cat([counts.new_zeros(1), counts.cumsum(0)[:-1]])
149
+ global_target_idx = tgt_idx_t + offsets[batch_ids]
150
+
151
+ all_target_boxes = torch.cat([target["boxes"] for target in targets])
152
+ all_target_labels = torch.cat([target["labels"] for target in targets])
153
+
154
+ labels = all_target_labels[global_target_idx]
155
+ target_classes[batch_ids, pred_idx_t, labels] = 1.0
156
+ predicted = boxes[batch_ids, pred_idx_t]
157
+ expected = all_target_boxes[global_target_idx]
158
+ else:
159
+ predicted = None
160
+ expected = None
161
+
162
+ class_loss = sigmoid_focal_loss(logits, target_classes, self.alpha, self.gamma).sum()
163
+ class_loss = class_loss / normalizer
164
+ if predicted is not None:
165
+ bbox_loss = F.l1_loss(predicted, expected, reduction="sum") / normalizer
166
+ giou = generalized_box_iou(box_cxcywh_to_xyxy(predicted), box_cxcywh_to_xyxy(expected))
167
+ giou_loss = (1.0 - giou.diag()).sum() / normalizer
168
+ else:
169
+ bbox_loss = boxes.sum() * 0.0
170
+ giou_loss = boxes.sum() * 0.0
171
+ return {
172
+ "loss_class": class_loss * self.weight_class,
173
+ "loss_bbox": bbox_loss * self.weight_bbox,
174
+ "loss_giou": giou_loss * self.weight_giou,
175
+ }
176
+
177
+ def _dense_loss(
178
+ self, outputs: list[dict[str, Tensor]], targets: Sequence[dict[str, Tensor]]
179
+ ) -> Tensor:
180
+ total = outputs[0]["logits"].sum() * 0.0
181
+ normalizer = max(sum(len(target["labels"]) for target in targets), 1)
182
+ for level_index, level_output in enumerate(outputs):
183
+ logits = level_output["logits"]
184
+ boxes = level_output["distances"].sigmoid()
185
+ target_logits, target_boxes, positive = self._dense_targets(
186
+ logits, targets, level_index
187
+ )
188
+ cls_loss = sigmoid_focal_loss(logits, target_logits, self.alpha, self.gamma)
189
+ cls_loss = cls_loss.sum() / normalizer
190
+ positive_mask = positive[:, None].expand_as(boxes)
191
+ box_loss = (F.l1_loss(boxes, target_boxes, reduction="none") * positive_mask).sum()
192
+ total = total + cls_loss + box_loss / normalizer
193
+ return total / len(outputs)
194
+
195
+ def forward(
196
+ self, outputs: dict[str, Tensor], targets: Sequence[dict[str, Tensor]]
197
+ ) -> dict[str, Tensor]:
198
+ layer_outputs = [outputs, *outputs.get("aux_outputs", [])]
199
+ layer_matches = hungarian_match_layers(
200
+ layer_outputs,
201
+ targets,
202
+ self.cost_class,
203
+ self.cost_bbox,
204
+ self.cost_giou,
205
+ )
206
+ primary = self._set_loss(outputs, targets, layer_matches[0])
207
+ total = sum(primary.values())
208
+ for auxiliary, matches in zip(
209
+ outputs.get("aux_outputs", []), layer_matches[1:], strict=True
210
+ ):
211
+ auxiliary_losses = self._set_loss(auxiliary, targets, matches)
212
+ total = total + self.aux_weight * sum(auxiliary_losses.values()) / max(
213
+ len(outputs["aux_outputs"]), 1
214
+ )
215
+ if "dense_outputs" in outputs:
216
+ dense = self._dense_loss(outputs["dense_outputs"], targets)
217
+ primary["loss_dense"] = dense * self.weight_dense
218
+ total = total + primary["loss_dense"]
219
+ primary["loss_total"] = total
220
+ return primary
src/objectmodel_v1/matching.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import numpy as np
6
+ import torch
7
+ from scipy.optimize import linear_sum_assignment
8
+ from torch import Tensor
9
+
10
+ from .boxes import box_cxcywh_to_xyxy, generalized_box_iou_batched
11
+
12
+
13
+ def hungarian_match(
14
+ outputs: dict[str, Tensor],
15
+ targets: Sequence[dict[str, Tensor]],
16
+ class_cost: float = 2.0,
17
+ bbox_cost: float = 5.0,
18
+ giou_cost: float = 2.0,
19
+ ) -> list[tuple[Tensor, Tensor]]:
20
+ """Match predictions to targets with a detached CPU Hungarian solve."""
21
+ return hungarian_match_layers([outputs], targets, class_cost, bbox_cost, giou_cost)[0]
22
+
23
+
24
+ def hungarian_match_layers(
25
+ outputs: Sequence[dict[str, Tensor]],
26
+ targets: Sequence[dict[str, Tensor]],
27
+ class_cost: float = 2.0,
28
+ bbox_cost: float = 5.0,
29
+ giou_cost: float = 2.0,
30
+ ) -> list[list[tuple[Tensor, Tensor]]]:
31
+ """Match all decoder layers with one GPU-to-CPU synchronization."""
32
+ if not outputs:
33
+ return []
34
+
35
+ device = outputs[0]["pred_boxes"].device
36
+ target_sizes = [len(target["labels"]) for target in targets]
37
+ max_targets = max(target_sizes, default=0)
38
+ if max_targets == 0:
39
+ empty = torch.empty(0, dtype=torch.int64, device=device)
40
+ return [[(empty, empty) for _ in targets] for _ in outputs]
41
+
42
+ batch_size = len(targets)
43
+ padded_boxes = torch.zeros(batch_size, max_targets, 4, dtype=torch.float32, device=device)
44
+ padded_labels = torch.zeros(batch_size, max_targets, dtype=torch.int64, device=device)
45
+ for batch_index, target in enumerate(targets):
46
+ count = target_sizes[batch_index]
47
+ if count > 0:
48
+ padded_boxes[batch_index, :count] = target["boxes"]
49
+ padded_labels[batch_index, :count] = target["labels"]
50
+
51
+ padded_costs = []
52
+ for layer_output in outputs:
53
+ probabilities = layer_output["pred_logits"].sigmoid().detach()
54
+ boxes = layer_output["pred_boxes"].detach()
55
+ num_queries = boxes.shape[1]
56
+ gather_index = padded_labels[:, None, :].expand(-1, num_queries, -1)
57
+ class_term = -probabilities.gather(2, gather_index)
58
+ bbox_term = torch.cdist(boxes, padded_boxes, p=1)
59
+ giou_term = -generalized_box_iou_batched(
60
+ box_cxcywh_to_xyxy(boxes), box_cxcywh_to_xyxy(padded_boxes)
61
+ )
62
+ cost = (class_cost * class_term + bbox_cost * bbox_term + giou_cost * giou_term).float()
63
+ for batch_index in range(batch_size):
64
+ padded_costs.append(cost[batch_index])
65
+
66
+ costs = torch.stack(padded_costs).cpu().numpy()
67
+
68
+ cpu_matches: list[tuple[np.ndarray, np.ndarray]] = []
69
+ for cost, target_size in zip(costs, target_sizes * len(outputs), strict=True):
70
+ if target_size == 0:
71
+ empty = np.empty(0, dtype=np.int64)
72
+ cpu_matches.append((empty, empty))
73
+ else:
74
+ cpu_matches.append(linear_sum_assignment(cost[:, :target_size]))
75
+
76
+ lengths = [len(rows) for rows, _ in cpu_matches]
77
+ all_rows = torch.as_tensor(
78
+ np.concatenate([rows for rows, _ in cpu_matches]),
79
+ dtype=torch.int64,
80
+ device=device,
81
+ )
82
+ all_cols = torch.as_tensor(
83
+ np.concatenate([cols for _, cols in cpu_matches]),
84
+ dtype=torch.int64,
85
+ device=device,
86
+ )
87
+ flat_matches = []
88
+ offset = 0
89
+ for length in lengths:
90
+ flat_matches.append(
91
+ (all_rows[offset : offset + length], all_cols[offset : offset + length])
92
+ )
93
+ offset += length
94
+
95
+ batch_size = len(targets)
96
+ return [
97
+ flat_matches[start : start + batch_size]
98
+ for start in range(0, len(flat_matches), batch_size)
99
+ ]
src/objectmodel_v1/model.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import torch
8
+ from torch import Tensor, nn
9
+ import torch.nn.functional as F
10
+
11
+ from .boxes import inverse_sigmoid
12
+
13
+
14
+ class ConvNormAct(nn.Sequential):
15
+ def __init__(
16
+ self,
17
+ in_channels: int,
18
+ out_channels: int,
19
+ kernel_size: int = 1,
20
+ stride: int = 1,
21
+ groups: int = 1,
22
+ activation: bool = True,
23
+ ) -> None:
24
+ padding = kernel_size // 2
25
+ layers: list[nn.Module] = [
26
+ nn.Conv2d(
27
+ in_channels,
28
+ out_channels,
29
+ kernel_size,
30
+ stride,
31
+ padding,
32
+ groups=groups,
33
+ bias=False,
34
+ ),
35
+ nn.BatchNorm2d(out_channels),
36
+ ]
37
+ if activation:
38
+ layers.append(nn.SiLU(inplace=True))
39
+ super().__init__(*layers)
40
+
41
+
42
+ class GatedConvBlock(nn.Module):
43
+ """Inverted residual block with a cheap learned residual gate."""
44
+
45
+ def __init__(self, channels: int, expansion: float = 2.0) -> None:
46
+ super().__init__()
47
+ hidden = int(channels * expansion)
48
+ self.expand = ConvNormAct(channels, hidden)
49
+ self.depthwise = ConvNormAct(hidden, hidden, 3, groups=hidden)
50
+ self.project = ConvNormAct(hidden, channels, activation=False)
51
+ self.gate = nn.Parameter(torch.zeros(1))
52
+
53
+ def forward(self, inputs: Tensor) -> Tensor:
54
+ return inputs + torch.tanh(self.gate) * self.project(self.depthwise(self.expand(inputs)))
55
+
56
+
57
+ class BackboneStage(nn.Sequential):
58
+ def __init__(self, in_channels: int, out_channels: int, depth: int, stride: int) -> None:
59
+ super().__init__(
60
+ ConvNormAct(in_channels, out_channels, 3, stride=stride),
61
+ *(GatedConvBlock(out_channels) for _ in range(depth)),
62
+ )
63
+
64
+
65
+ class CompactBackbone(nn.Module):
66
+ def __init__(
67
+ self, stem_channels: int, channels: list[int], depths: list[int]
68
+ ) -> None:
69
+ super().__init__()
70
+ if len(channels) != 4 or len(depths) != 4:
71
+ raise ValueError("Backbone requires four channel and depth values")
72
+ self.stem = nn.Sequential(
73
+ ConvNormAct(3, stem_channels, 3, stride=2),
74
+ ConvNormAct(stem_channels, stem_channels, 3, stride=2),
75
+ )
76
+ stages: list[nn.Module] = []
77
+ in_channels = stem_channels
78
+ for index, (out_channels, depth) in enumerate(zip(channels, depths, strict=True)):
79
+ stages.append(
80
+ BackboneStage(in_channels, out_channels, depth, stride=1 if index == 0 else 2)
81
+ )
82
+ in_channels = out_channels
83
+ self.stages = nn.ModuleList(stages)
84
+ self.out_channels = channels[1:]
85
+
86
+ def forward(self, images: Tensor) -> list[Tensor]:
87
+ features = self.stem(images)
88
+ outputs = []
89
+ for index, stage in enumerate(self.stages):
90
+ features = stage(features)
91
+ if index > 0:
92
+ outputs.append(features)
93
+ return outputs
94
+
95
+
96
+ class PyramidFusion(nn.Module):
97
+ def __init__(self, in_channels: list[int], hidden_dim: int, depth: int) -> None:
98
+ super().__init__()
99
+ self.lateral = nn.ModuleList(ConvNormAct(c, hidden_dim) for c in in_channels)
100
+ self.refine = nn.ModuleList(
101
+ nn.Sequential(*(GatedConvBlock(hidden_dim, expansion=1.5) for _ in range(depth)))
102
+ for _ in in_channels
103
+ )
104
+
105
+ def forward(self, inputs: list[Tensor]) -> list[Tensor]:
106
+ projected = [layer(x) for layer, x in zip(self.lateral, inputs, strict=True)]
107
+ outputs = list(projected)
108
+ for index in range(len(outputs) - 2, -1, -1):
109
+ outputs[index] = outputs[index] + F.interpolate(
110
+ outputs[index + 1], size=outputs[index].shape[-2:], mode="nearest"
111
+ )
112
+ return [block(x) for block, x in zip(self.refine, outputs, strict=True)]
113
+
114
+
115
+ def sine_position_encoding(
116
+ height: int, width: int, dim: int, device: torch.device, dtype: torch.dtype
117
+ ) -> Tensor:
118
+ if dim % 4 != 0:
119
+ raise ValueError("Position encoding dimension must be divisible by four")
120
+ y, x = torch.meshgrid(
121
+ torch.linspace(0, 1, height, device=device, dtype=dtype),
122
+ torch.linspace(0, 1, width, device=device, dtype=dtype),
123
+ indexing="ij",
124
+ )
125
+ frequencies = torch.arange(dim // 4, device=device, dtype=dtype)
126
+ frequencies = 2.0 * torch.pi * (10000.0 ** (-frequencies / max(dim // 4, 1)))
127
+ x = x.flatten()[:, None] * frequencies[None]
128
+ y = y.flatten()[:, None] * frequencies[None]
129
+ return torch.cat((x.sin(), x.cos(), y.sin(), y.cos()), dim=-1)
130
+
131
+
132
+ class FeedForward(nn.Sequential):
133
+ def __init__(self, dim: int, expansion: int = 4, dropout: float = 0.0) -> None:
134
+ super().__init__(
135
+ nn.Linear(dim, dim * expansion),
136
+ nn.GELU(),
137
+ nn.Dropout(dropout),
138
+ nn.Linear(dim * expansion, dim),
139
+ nn.Dropout(dropout),
140
+ )
141
+
142
+
143
+ class LatentLayer(nn.Module):
144
+ def __init__(self, dim: int, num_heads: int, dropout: float) -> None:
145
+ super().__init__()
146
+ self.norm1 = nn.LayerNorm(dim)
147
+ self.attention = nn.MultiheadAttention(dim, num_heads, dropout, batch_first=True)
148
+ self.norm2 = nn.LayerNorm(dim)
149
+ self.ffn = FeedForward(dim, dropout=dropout)
150
+
151
+ def forward(self, inputs: Tensor) -> Tensor:
152
+ normalized = self.norm1(inputs)
153
+ inputs = inputs + self.attention(normalized, normalized, normalized, need_weights=False)[0]
154
+ return inputs + self.ffn(self.norm2(inputs))
155
+
156
+
157
+ class LatentMemory(nn.Module):
158
+ """Compresses multi-scale maps into a fixed-size global reasoning memory."""
159
+
160
+ def __init__(
161
+ self,
162
+ dim: int,
163
+ latent_count: int,
164
+ pool_sizes: list[int],
165
+ layers: int,
166
+ num_heads: int,
167
+ dropout: float,
168
+ ) -> None:
169
+ super().__init__()
170
+ if len(pool_sizes) != 3:
171
+ raise ValueError("One latent pool size is required for each pyramid level")
172
+ self.pool_sizes = pool_sizes
173
+ self.latents = nn.Parameter(torch.empty(latent_count, dim))
174
+ self.level_embedding = nn.Parameter(torch.empty(len(pool_sizes), dim))
175
+ self.query_norm = nn.LayerNorm(dim)
176
+ self.token_norm = nn.LayerNorm(dim)
177
+ self.compress = nn.MultiheadAttention(dim, num_heads, dropout, batch_first=True)
178
+ self.layers = nn.ModuleList(LatentLayer(dim, num_heads, dropout) for _ in range(layers))
179
+ nn.init.normal_(self.latents, std=0.02)
180
+ nn.init.normal_(self.level_embedding, std=0.02)
181
+
182
+ def forward(self, features: list[Tensor]) -> Tensor:
183
+ tokens = []
184
+ for level, (feature, size) in enumerate(zip(features, self.pool_sizes, strict=True)):
185
+ pooled = F.adaptive_avg_pool2d(feature, (size, size)).flatten(2).transpose(1, 2)
186
+ position = sine_position_encoding(
187
+ size, size, feature.shape[1], feature.device, feature.dtype
188
+ )
189
+ tokens.append(pooled + position[None] + self.level_embedding[level][None, None])
190
+ token_memory = self.token_norm(torch.cat(tokens, dim=1))
191
+ latents = self.latents[None].expand(features[0].shape[0], -1, -1)
192
+ latents = latents + self.compress(
193
+ self.query_norm(latents), token_memory, token_memory, need_weights=False
194
+ )[0]
195
+ for layer in self.layers:
196
+ latents = layer(latents)
197
+ return latents
198
+
199
+
200
+ class QueryLocalSampler(nn.Module):
201
+ """Samples high-resolution pyramid evidence around each evolving query box."""
202
+
203
+ def __init__(self, dim: int, num_levels: int, points: int) -> None:
204
+ super().__init__()
205
+ self.num_levels = num_levels
206
+ self.points = points
207
+ self.offsets = nn.Linear(dim, num_levels * points * 2)
208
+ self.weights = nn.Linear(dim, num_levels * points)
209
+ self.output = nn.Linear(dim, dim)
210
+ nn.init.zeros_(self.offsets.weight)
211
+ nn.init.zeros_(self.offsets.bias)
212
+ nn.init.zeros_(self.weights.weight)
213
+ nn.init.zeros_(self.weights.bias)
214
+
215
+ def forward(self, queries: Tensor, boxes: Tensor, features: list[Tensor]) -> Tensor:
216
+ batch, query_count, _ = queries.shape
217
+ offsets = self.offsets(queries).view(
218
+ batch, query_count, self.num_levels, self.points, 2
219
+ )
220
+ offsets = offsets.tanh() * boxes[..., None, None, 2:] * 0.5
221
+ centers = boxes[..., None, None, :2]
222
+ sample_points = (centers + offsets).clamp(0.0, 1.0)
223
+ weights = self.weights(queries).view(
224
+ batch, query_count, self.num_levels * self.points
225
+ )
226
+ weights = weights.softmax(dim=-1).view(
227
+ batch, query_count, self.num_levels, self.points
228
+ )
229
+
230
+ sampled_levels = []
231
+ for level, feature in enumerate(features):
232
+ grid = sample_points[:, :, level] * 2.0 - 1.0
233
+ sampled = F.grid_sample(
234
+ feature,
235
+ grid,
236
+ mode="bilinear",
237
+ padding_mode="zeros",
238
+ align_corners=False,
239
+ )
240
+ sampled = sampled.permute(0, 2, 3, 1)
241
+ sampled_levels.append(sampled)
242
+ sampled_features = torch.stack(sampled_levels, dim=2)
243
+ fused = (sampled_features * weights[..., None]).sum(dim=(2, 3))
244
+ return self.output(fused)
245
+
246
+
247
+ class DecoderLayer(nn.Module):
248
+ def __init__(
249
+ self, dim: int, num_heads: int, num_levels: int, local_points: int, dropout: float
250
+ ) -> None:
251
+ super().__init__()
252
+ self.norm1 = nn.LayerNorm(dim)
253
+ self.self_attention = nn.MultiheadAttention(dim, num_heads, dropout, batch_first=True)
254
+ self.norm2 = nn.LayerNorm(dim)
255
+ self.global_attention = nn.MultiheadAttention(dim, num_heads, dropout, batch_first=True)
256
+ self.norm3 = nn.LayerNorm(dim)
257
+ self.local_sampler = QueryLocalSampler(dim, num_levels, local_points)
258
+ self.norm4 = nn.LayerNorm(dim)
259
+ self.ffn = FeedForward(dim, dropout=dropout)
260
+
261
+ def forward(
262
+ self, queries: Tensor, memory: Tensor, boxes: Tensor, features: list[Tensor]
263
+ ) -> Tensor:
264
+ normalized = self.norm1(queries)
265
+ queries = queries + self.self_attention(
266
+ normalized, normalized, normalized, need_weights=False
267
+ )[0]
268
+ queries = queries + self.global_attention(
269
+ self.norm2(queries), memory, memory, need_weights=False
270
+ )[0]
271
+ queries = queries + self.local_sampler(self.norm3(queries), boxes, features)
272
+ return queries + self.ffn(self.norm4(queries))
273
+
274
+
275
+ class MLP(nn.Sequential):
276
+ def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, layers: int) -> None:
277
+ modules: list[nn.Module] = []
278
+ for index in range(layers):
279
+ in_dim = input_dim if index == 0 else hidden_dim
280
+ out_dim = output_dim if index == layers - 1 else hidden_dim
281
+ modules.append(nn.Linear(in_dim, out_dim))
282
+ if index < layers - 1:
283
+ modules.append(nn.ReLU(inplace=True))
284
+ super().__init__(*modules)
285
+
286
+
287
+ class DenseAuxiliaryHead(nn.Module):
288
+ def __init__(self, dim: int, num_classes: int) -> None:
289
+ super().__init__()
290
+ self.shared = nn.ModuleList(
291
+ nn.Sequential(ConvNormAct(dim, dim, 3, groups=dim), ConvNormAct(dim, dim))
292
+ for _ in range(3)
293
+ )
294
+ self.classification = nn.Conv2d(dim, num_classes, 1)
295
+ self.regression = nn.Conv2d(dim, 4, 1)
296
+
297
+ def forward(self, features: list[Tensor]) -> list[dict[str, Tensor]]:
298
+ outputs = []
299
+ for feature, tower in zip(features, self.shared, strict=True):
300
+ hidden = tower(feature)
301
+ outputs.append(
302
+ {
303
+ "logits": self.classification(hidden),
304
+ "distances": F.softplus(self.regression(hidden)),
305
+ }
306
+ )
307
+ return outputs
308
+
309
+
310
+ @dataclass(frozen=True)
311
+ class ObjectModelV1Spec:
312
+ num_classes: int = 80
313
+ input_size: int = 640
314
+ stem_channels: int = 48
315
+ backbone_channels: tuple[int, int, int, int] = (64, 128, 256, 384)
316
+ backbone_depths: tuple[int, int, int, int] = (2, 3, 6, 3)
317
+ hidden_dim: int = 256
318
+ fpn_depth: int = 2
319
+ latent_count: int = 64
320
+ latent_pool_sizes: tuple[int, int, int] = (12, 6, 3)
321
+ latent_layers: int = 2
322
+ decoder_layers: int = 6
323
+ num_queries: int = 300
324
+ num_heads: int = 8
325
+ local_points: int = 4
326
+ dropout: float = 0.0
327
+ dense_aux: bool = True
328
+
329
+
330
+ class ObjectModelV1(nn.Module):
331
+ """NMS-free detector with compressed global memory and local geometric sampling."""
332
+
333
+ def __init__(self, spec: ObjectModelV1Spec) -> None:
334
+ super().__init__()
335
+ self.spec = spec
336
+ self.backbone = CompactBackbone(
337
+ spec.stem_channels, list(spec.backbone_channels), list(spec.backbone_depths)
338
+ )
339
+ self.neck = PyramidFusion(self.backbone.out_channels, spec.hidden_dim, spec.fpn_depth)
340
+ self.memory = LatentMemory(
341
+ spec.hidden_dim,
342
+ spec.latent_count,
343
+ list(spec.latent_pool_sizes),
344
+ spec.latent_layers,
345
+ spec.num_heads,
346
+ spec.dropout,
347
+ )
348
+ decoder_template = DecoderLayer(
349
+ spec.hidden_dim, spec.num_heads, 3, spec.local_points, spec.dropout
350
+ )
351
+ self.decoder = nn.ModuleList(deepcopy(decoder_template) for _ in range(spec.decoder_layers))
352
+ self.query_embedding = nn.Embedding(spec.num_queries, spec.hidden_dim)
353
+ self.reference_points = nn.Embedding(spec.num_queries, 4)
354
+ self.class_heads = nn.ModuleList(
355
+ nn.Linear(spec.hidden_dim, spec.num_classes) for _ in range(spec.decoder_layers)
356
+ )
357
+ self.box_heads = nn.ModuleList(
358
+ MLP(spec.hidden_dim, spec.hidden_dim, 4, 3) for _ in range(spec.decoder_layers)
359
+ )
360
+ self.dense_head = (
361
+ DenseAuxiliaryHead(spec.hidden_dim, spec.num_classes) if spec.dense_aux else None
362
+ )
363
+ self._reset_parameters()
364
+
365
+ def _reset_parameters(self) -> None:
366
+ prior_probability = 0.01
367
+ class_bias = -torch.log(torch.tensor((1.0 - prior_probability) / prior_probability))
368
+ for head in self.class_heads:
369
+ nn.init.constant_(head.bias, class_bias)
370
+ nn.init.zeros_(self.reference_points.weight)
371
+ with torch.no_grad():
372
+ self.reference_points.weight[:, 2:] = -2.0
373
+ for head in self.box_heads:
374
+ nn.init.zeros_(head[-1].weight)
375
+ nn.init.zeros_(head[-1].bias)
376
+ if self.dense_head is not None:
377
+ nn.init.constant_(self.dense_head.classification.bias, class_bias)
378
+ nn.init.zeros_(self.dense_head.regression.weight)
379
+ nn.init.constant_(self.dense_head.regression.bias, 1.0)
380
+
381
+ def forward(self, images: Tensor) -> dict[str, Any]:
382
+ features = self.neck(self.backbone(images))
383
+ memory = self.memory(features)
384
+ batch = images.shape[0]
385
+ queries = self.query_embedding.weight[None].expand(batch, -1, -1)
386
+ boxes = self.reference_points.weight.sigmoid()[None].expand(batch, -1, -1)
387
+ layer_outputs: list[dict[str, Tensor]] = []
388
+ for layer, class_head, box_head in zip(
389
+ self.decoder, self.class_heads, self.box_heads, strict=True
390
+ ):
391
+ queries = layer(queries, memory, boxes, features)
392
+ boxes = (inverse_sigmoid(boxes) + box_head(queries)).sigmoid()
393
+ layer_outputs.append({"pred_logits": class_head(queries), "pred_boxes": boxes})
394
+ boxes = boxes.detach() if self.training else boxes
395
+
396
+ output: dict[str, Any] = dict(layer_outputs[-1])
397
+ output["aux_outputs"] = layer_outputs[:-1]
398
+ if self.training and self.dense_head is not None:
399
+ output["dense_outputs"] = self.dense_head(features)
400
+ return output
401
+
402
+
403
+ def build_model(config: dict[str, Any]) -> ObjectModelV1:
404
+ model_config = config.get("model", config)
405
+ fields = ObjectModelV1Spec.__dataclass_fields__
406
+ unknown = set(model_config) - set(fields)
407
+ if unknown:
408
+ raise ValueError(f"Unknown model configuration keys: {sorted(unknown)}")
409
+ values = dict(model_config)
410
+ for key in ("backbone_channels", "backbone_depths", "latent_pool_sizes"):
411
+ if key in values:
412
+ values[key] = tuple(values[key])
413
+ return ObjectModelV1(ObjectModelV1Spec(**values))
src/objectmodel_v1/postprocess.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from .boxes import box_cxcywh_to_xyxy
7
+
8
+
9
+ @torch.no_grad()
10
+ def decode_predictions(
11
+ outputs: dict[str, Tensor],
12
+ image_sizes: list[tuple[int, int]],
13
+ confidence: float = 0.25,
14
+ top_k: int = 300,
15
+ ) -> list[dict[str, Tensor]]:
16
+ logits = outputs["pred_logits"].sigmoid()
17
+ boxes = box_cxcywh_to_xyxy(outputs["pred_boxes"]).clamp(0.0, 1.0)
18
+ results = []
19
+ for index, (height, width) in enumerate(image_sizes):
20
+ scores, labels = logits[index].max(dim=-1)
21
+ keep = scores >= confidence
22
+ if keep.sum() > top_k:
23
+ selected = scores.masked_fill(~keep, -1).topk(top_k).indices
24
+ else:
25
+ selected = torch.where(keep)[0]
26
+ selected_boxes = boxes[index, selected].clone()
27
+ selected_boxes[:, [0, 2]] *= width
28
+ selected_boxes[:, [1, 3]] *= height
29
+ results.append(
30
+ {"scores": scores[selected], "labels": labels[selected], "boxes": selected_boxes}
31
+ )
32
+ return results
src/objectmodel_v1/profile.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import time
6
+
7
+ import torch
8
+
9
+ from .config import apply_overrides, load_config
10
+ from .model import build_model
11
+ from .utils import trainable_parameter_count
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(description="Profile ObjectModel-v1 parameters and latency")
16
+ parser.add_argument("--config", default="configs/objectmodel_v1.yaml")
17
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
18
+ parser.add_argument("--warmup", type=int, default=10)
19
+ parser.add_argument("--runs", type=int, default=50)
20
+ parser.add_argument("--set", action="append", default=[])
21
+ args = parser.parse_args()
22
+ config = apply_overrides(load_config(args.config), args.set)
23
+ device = torch.device(args.device)
24
+ model = build_model(config).eval().to(device)
25
+ size = model.spec.input_size
26
+ sample = torch.randn(1, 3, size, size, device=device)
27
+ with torch.inference_mode():
28
+ for _ in range(args.warmup):
29
+ model(sample)
30
+ if device.type == "cuda":
31
+ torch.cuda.synchronize()
32
+ durations = []
33
+ for _ in range(args.runs):
34
+ start = time.perf_counter()
35
+ model(sample)
36
+ if device.type == "cuda":
37
+ torch.cuda.synchronize()
38
+ durations.append((time.perf_counter() - start) * 1000)
39
+ durations.sort()
40
+ parameters = trainable_parameter_count(model)
41
+ report = {
42
+ "parameters": parameters,
43
+ "parameters_millions": round(parameters / 1e6, 3),
44
+ "fp32_weight_megabytes": round(parameters * 4 / 1024**2, 2),
45
+ "input_size": size,
46
+ "device": str(device),
47
+ "latency_ms_median": round(durations[len(durations) // 2], 3),
48
+ "latency_ms_p95": round(durations[min(int(len(durations) * 0.95), len(durations) - 1)], 3),
49
+ }
50
+ print(json.dumps(report, indent=2))
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
src/objectmodel_v1/tracking.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ from scipy.optimize import linear_sum_assignment
7
+
8
+
9
+ def _box_to_state(box: np.ndarray) -> np.ndarray:
10
+ x0, y0, x1, y1 = box
11
+ width, height = x1 - x0, y1 - y0
12
+ cx, cy = x0 + width / 2.0, y0 + height / 2.0
13
+ scale = width * height
14
+ aspect = width / max(height, 1e-6)
15
+ return np.array([cx, cy, scale, aspect], dtype=np.float64)
16
+
17
+
18
+ def _state_to_box(state: np.ndarray) -> np.ndarray:
19
+ cx, cy, scale, aspect = state[:4]
20
+ scale = max(scale, 1e-6)
21
+ width = np.sqrt(scale * aspect)
22
+ height = scale / max(width, 1e-6)
23
+ return np.array(
24
+ [cx - width / 2.0, cy - height / 2.0, cx + width / 2.0, cy + height / 2.0],
25
+ dtype=np.float64,
26
+ )
27
+
28
+
29
+ def iou_matrix(boxes_a: np.ndarray, boxes_b: np.ndarray) -> np.ndarray:
30
+ if boxes_a.shape[0] == 0 or boxes_b.shape[0] == 0:
31
+ return np.zeros((boxes_a.shape[0], boxes_b.shape[0]), dtype=np.float64)
32
+ area_a = (boxes_a[:, 2] - boxes_a[:, 0]) * (boxes_a[:, 3] - boxes_a[:, 1])
33
+ area_b = (boxes_b[:, 2] - boxes_b[:, 0]) * (boxes_b[:, 3] - boxes_b[:, 1])
34
+ top_left = np.maximum(boxes_a[:, None, :2], boxes_b[None, :, :2])
35
+ bottom_right = np.minimum(boxes_a[:, None, 2:], boxes_b[None, :, 2:])
36
+ width_height = np.clip(bottom_right - top_left, 0, None)
37
+ intersection = width_height[..., 0] * width_height[..., 1]
38
+ union = area_a[:, None] + area_b[None, :] - intersection
39
+ return intersection / np.clip(union, 1e-9, None)
40
+
41
+
42
+ class _KalmanBoxTracker:
43
+ """Constant-velocity Kalman filter over (cx, cy, scale, aspect) — the classic SORT state.
44
+
45
+ Aspect ratio is treated as constant (no velocity term for it), matching Bewley et al. 2016.
46
+ """
47
+
48
+ _next_id = 1
49
+
50
+ def __init__(self, box: np.ndarray, label: int, score: float) -> None:
51
+ self.state = np.zeros(7, dtype=np.float64)
52
+ self.state[:4] = _box_to_state(box)
53
+ self.covariance = np.eye(7) * 10.0
54
+ self.covariance[4:, 4:] *= 1000.0
55
+
56
+ self._transition = np.eye(7)
57
+ for i in range(3):
58
+ self._transition[i, i + 4] = 1.0
59
+ self._observation = np.zeros((4, 7))
60
+ self._observation[:4, :4] = np.eye(4)
61
+
62
+ self._process_noise = np.eye(7) * 1.0
63
+ self._process_noise[4:, 4:] *= 0.01
64
+ self._measurement_noise = np.eye(4) * 1.0
65
+
66
+ self.id = _KalmanBoxTracker._next_id
67
+ _KalmanBoxTracker._next_id += 1
68
+ self.label = label
69
+ self.score = score
70
+ self.hits = 1
71
+ self.age = 0
72
+ self.time_since_update = 0
73
+
74
+ def predict(self) -> np.ndarray:
75
+ self.state = self._transition @ self.state
76
+ self.covariance = self._transition @ self.covariance @ self._transition.T + self._process_noise
77
+ self.age += 1
78
+ self.time_since_update += 1
79
+ state = self.state.copy()
80
+ state[2] = max(state[2], 1e-6)
81
+ return _state_to_box(state)
82
+
83
+ def update(self, box: np.ndarray, label: int, score: float) -> None:
84
+ measurement = _box_to_state(box)
85
+ innovation = measurement - self._observation @ self.state
86
+ innovation_cov = self._observation @ self.covariance @ self._observation.T + self._measurement_noise
87
+ kalman_gain = self.covariance @ self._observation.T @ np.linalg.inv(innovation_cov)
88
+ self.state = self.state + kalman_gain @ innovation
89
+ self.covariance = (np.eye(7) - kalman_gain @ self._observation) @ self.covariance
90
+ self.label = label
91
+ self.score = score
92
+ self.hits += 1
93
+ self.time_since_update = 0
94
+
95
+ def current_box(self) -> np.ndarray:
96
+ return _state_to_box(self.state)
97
+
98
+
99
+ @dataclass
100
+ class Track:
101
+ id: int
102
+ box: tuple[float, float, float, float]
103
+ label: int
104
+ score: float
105
+ hits: int
106
+ age: int
107
+
108
+
109
+ class SortTracker:
110
+ """Minimal SORT-style tracker: Kalman motion prediction + IoU/Hungarian association.
111
+
112
+ This sits *outside* the model as a post-processing layer over independent
113
+ per-frame detections — ObjectModel-v1 itself has no temporal component.
114
+ Gives detections a persistent id and lets a track survive a few frames of
115
+ missed detection (occlusion, a confidence dip) via `max_age`.
116
+ """
117
+
118
+ def __init__(self, iou_threshold: float = 0.3, max_age: int = 5, min_hits: int = 3) -> None:
119
+ self.iou_threshold = iou_threshold
120
+ self.max_age = max_age
121
+ self.min_hits = min_hits
122
+ self._trackers: list[_KalmanBoxTracker] = []
123
+
124
+ def update(self, boxes: np.ndarray, labels: np.ndarray, scores: np.ndarray) -> list[Track]:
125
+ """Advance one frame. `boxes` is [N, 4] xyxy, `labels`/`scores` are [N]."""
126
+ predicted = (
127
+ np.array([tracker.predict() for tracker in self._trackers])
128
+ if self._trackers
129
+ else np.zeros((0, 4))
130
+ )
131
+
132
+ matches, unmatched_detections, _ = self._associate(predicted, boxes)
133
+
134
+ for det_idx, trk_idx in matches:
135
+ self._trackers[trk_idx].update(boxes[det_idx], int(labels[det_idx]), float(scores[det_idx]))
136
+
137
+ for det_idx in unmatched_detections:
138
+ self._trackers.append(
139
+ _KalmanBoxTracker(boxes[det_idx], int(labels[det_idx]), float(scores[det_idx]))
140
+ )
141
+
142
+ self._trackers = [t for t in self._trackers if t.time_since_update <= self.max_age]
143
+
144
+ results = []
145
+ for tracker in self._trackers:
146
+ confirmed = tracker.hits >= self.min_hits or tracker.age <= self.min_hits
147
+ if tracker.time_since_update == 0 and confirmed:
148
+ x0, y0, x1, y1 = tracker.current_box()
149
+ results.append(
150
+ Track(
151
+ id=tracker.id,
152
+ box=(x0, y0, x1, y1),
153
+ label=tracker.label,
154
+ score=tracker.score,
155
+ hits=tracker.hits,
156
+ age=tracker.age,
157
+ )
158
+ )
159
+ return results
160
+
161
+ def _associate(
162
+ self, predicted: np.ndarray, detections: np.ndarray
163
+ ) -> tuple[list[tuple[int, int]], list[int], list[int]]:
164
+ if predicted.shape[0] == 0 or detections.shape[0] == 0:
165
+ return [], list(range(detections.shape[0])), list(range(predicted.shape[0]))
166
+
167
+ iou = iou_matrix(detections, predicted)
168
+ row_idx, col_idx = linear_sum_assignment(1.0 - iou)
169
+
170
+ matches: list[tuple[int, int]] = []
171
+ matched_detections: set[int] = set()
172
+ matched_trackers: set[int] = set()
173
+ for row, col in zip(row_idx, col_idx, strict=True):
174
+ if iou[row, col] >= self.iou_threshold:
175
+ matches.append((row, col))
176
+ matched_detections.add(row)
177
+ matched_trackers.add(col)
178
+
179
+ unmatched_detections = [i for i in range(detections.shape[0]) if i not in matched_detections]
180
+ unmatched_trackers = [i for i in range(predicted.shape[0]) if i not in matched_trackers]
181
+ return matches, unmatched_detections, unmatched_trackers
src/objectmodel_v1/train.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ import torch.distributed as dist
11
+ from torch.nn.parallel import DistributedDataParallel
12
+ from torch.optim import AdamW
13
+ from torch.optim.lr_scheduler import LambdaLR
14
+ from torch.utils.data import DataLoader, DistributedSampler
15
+
16
+ from .config import apply_overrides, load_config, save_config
17
+ from .data import build_dataset, detection_collate
18
+ from .evaluate import evaluate_coco
19
+ from .losses import ObjectModelCriterion
20
+ from .model import build_model
21
+ from .utils import (
22
+ ModelEMA,
23
+ learning_rate_factor,
24
+ move_targets,
25
+ save_checkpoint,
26
+ seed_everything,
27
+ trainable_parameter_count,
28
+ )
29
+
30
+
31
+ def distributed_context() -> tuple[int, int, int]:
32
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
33
+ rank = int(os.environ.get("RANK", "0"))
34
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
35
+ if world_size > 1:
36
+ if not torch.cuda.is_available():
37
+ raise RuntimeError("Distributed training currently requires CUDA")
38
+ torch.cuda.set_device(local_rank)
39
+ dist.init_process_group(backend="nccl")
40
+ return rank, world_size, local_rank
41
+
42
+
43
+ def build_optimizer(model, config: dict) -> AdamW:
44
+ train = config["train"]
45
+ backbone, other = [], []
46
+ for name, parameter in model.named_parameters():
47
+ if not parameter.requires_grad:
48
+ continue
49
+ (backbone if name.startswith("backbone.") else other).append(parameter)
50
+ return AdamW(
51
+ [
52
+ {"params": other, "lr": float(train["lr"])},
53
+ {"params": backbone, "lr": float(train.get("backbone_lr", train["lr"]))},
54
+ ],
55
+ weight_decay=float(train["weight_decay"]),
56
+ )
57
+
58
+
59
+ def reduce_losses(losses: dict[str, torch.Tensor], world_size: int) -> dict[str, float]:
60
+ values = torch.stack([value.detach() for value in losses.values()])
61
+ if world_size > 1:
62
+ dist.all_reduce(values)
63
+ values /= world_size
64
+ return {name: float(value) for name, value in zip(losses, values, strict=True)}
65
+
66
+
67
+ def main() -> None:
68
+ parser = argparse.ArgumentParser(description="Train ObjectModel-v1")
69
+ parser.add_argument("--config", default="configs/objectmodel_v1.yaml")
70
+ parser.add_argument("--data-root", required=True)
71
+ parser.add_argument("--output", default="outputs/objectmodel_v1")
72
+ parser.add_argument("--resume")
73
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
74
+ parser.add_argument("--set", action="append", default=[])
75
+ args = parser.parse_args()
76
+
77
+ rank, world_size, local_rank = distributed_context()
78
+ config = apply_overrides(load_config(args.config), args.set)
79
+ train_config = config["train"]
80
+ seed_everything(int(train_config["seed"]) + rank)
81
+ device = torch.device(f"cuda:{local_rank}" if world_size > 1 else args.device)
82
+ if device.type == "cuda":
83
+ torch.backends.cudnn.benchmark = True
84
+ torch.backends.cuda.matmul.allow_tf32 = True
85
+ torch.backends.cudnn.allow_tf32 = True
86
+ torch.set_float32_matmul_precision("high")
87
+ channels_last = bool(train_config.get("channels_last", False))
88
+ compile_model = bool(train_config.get("compile", False))
89
+ output_dir = Path(args.output)
90
+ if rank == 0:
91
+ output_dir.mkdir(parents=True, exist_ok=True)
92
+ save_config(config, output_dir / "config.yaml")
93
+
94
+ train_dataset = build_dataset(config, args.data_root, "train")
95
+ train_sampler = DistributedSampler(train_dataset, shuffle=True) if world_size > 1 else None
96
+ train_loader = DataLoader(
97
+ train_dataset,
98
+ batch_size=int(train_config["batch_size"]),
99
+ shuffle=train_sampler is None,
100
+ sampler=train_sampler,
101
+ num_workers=int(train_config["workers"]),
102
+ pin_memory=device.type == "cuda",
103
+ drop_last=True,
104
+ persistent_workers=int(train_config["workers"]) > 0,
105
+ prefetch_factor=int(train_config.get("prefetch_factor", 4))
106
+ if int(train_config["workers"]) > 0
107
+ else None,
108
+ collate_fn=detection_collate,
109
+ )
110
+ model = build_model(config).to(device)
111
+ if channels_last:
112
+ model = model.to(memory_format=torch.channels_last)
113
+ criterion = ObjectModelCriterion(config).to(device)
114
+ optimizer = build_optimizer(model, config)
115
+ total_steps = int(train_config["epochs"]) * len(train_loader)
116
+ scheduler = LambdaLR(
117
+ optimizer,
118
+ lambda step: learning_rate_factor(
119
+ step,
120
+ total_steps,
121
+ int(train_config["warmup_steps"]),
122
+ float(train_config["min_lr_ratio"]),
123
+ ),
124
+ )
125
+ ema = ModelEMA(model, float(train_config["ema_decay"])) if rank == 0 else None
126
+ start_epoch, global_step, best_ap = 0, 0, -1.0
127
+ if args.resume:
128
+ checkpoint = torch.load(args.resume, map_location="cpu", weights_only=False)
129
+ model.load_state_dict(checkpoint["model"])
130
+ optimizer.load_state_dict(checkpoint["optimizer"])
131
+ scheduler.load_state_dict(checkpoint["scheduler"])
132
+ start_epoch = int(checkpoint["epoch"]) + 1
133
+ global_step = int(checkpoint.get("global_step", start_epoch * len(train_loader)))
134
+ best_ap = float(checkpoint.get("best_ap", -1.0))
135
+ if ema is not None and "ema" in checkpoint:
136
+ ema.model.load_state_dict(checkpoint["ema"])
137
+ if rank == 0:
138
+ print(
139
+ json.dumps(
140
+ {
141
+ "parameters": trainable_parameter_count(model),
142
+ "world_size": world_size,
143
+ "device": str(device),
144
+ "steps_per_epoch": len(train_loader),
145
+ },
146
+ indent=2,
147
+ )
148
+ )
149
+
150
+ training_model = (
151
+ DistributedDataParallel(model, device_ids=[local_rank], find_unused_parameters=False)
152
+ if world_size > 1
153
+ else model
154
+ )
155
+ if compile_model:
156
+ training_model = torch.compile(training_model, dynamic=False, mode="reduce-overhead")
157
+ use_amp = bool(train_config.get("amp", True)) and device.type == "cuda"
158
+ amp_dtype = (
159
+ torch.bfloat16 if train_config.get("amp_dtype", "float16") == "bfloat16" else torch.float16
160
+ )
161
+ scaler = torch.amp.GradScaler("cuda", enabled=use_amp and amp_dtype == torch.float16)
162
+ history_path = output_dir / "metrics.jsonl"
163
+ for epoch in range(start_epoch, int(train_config["epochs"])):
164
+ if train_sampler is not None:
165
+ train_sampler.set_epoch(epoch)
166
+ training_model.train()
167
+ epoch_start = time.perf_counter()
168
+ log_start = epoch_start
169
+ running = torch.zeros((), device=device)
170
+ for batch_index, (images, targets) in enumerate(train_loader):
171
+ images = images.to(
172
+ device,
173
+ non_blocking=True,
174
+ memory_format=torch.channels_last if channels_last else torch.preserve_format,
175
+ )
176
+ targets = move_targets(targets, device)
177
+ optimizer.zero_grad(set_to_none=True)
178
+ with torch.autocast(device_type=device.type, dtype=amp_dtype, enabled=use_amp):
179
+ outputs = training_model(images)
180
+ losses = criterion(outputs, targets)
181
+ scaler.scale(losses["loss_total"]).backward()
182
+ scaler.unscale_(optimizer)
183
+ torch.nn.utils.clip_grad_norm_(
184
+ training_model.parameters(), float(train_config["clip_grad_norm"])
185
+ )
186
+ scaler.step(optimizer)
187
+ scaler.update()
188
+ scheduler.step()
189
+ global_step += 1
190
+ if ema is not None:
191
+ ema.update(model)
192
+ running += losses["loss_total"].detach()
193
+ if rank == 0 and (batch_index + 1) % int(train_config["print_freq"]) == 0:
194
+ now = time.perf_counter()
195
+ log_steps = int(train_config["print_freq"])
196
+ avg_loss = running / (batch_index + 1)
197
+ if world_size > 1:
198
+ dist.all_reduce(avg_loss)
199
+ avg_loss = avg_loss / world_size
200
+ print(
201
+ f"epoch={epoch + 1} step={batch_index + 1}/{len(train_loader)} "
202
+ f"loss={avg_loss.item():.4f} lr={scheduler.get_last_lr()[0]:.3e} "
203
+ f"step_seconds={(now - log_start) / log_steps:.3f} "
204
+ f"images_per_second={log_steps * len(images) / (now - log_start):.2f}",
205
+ flush=True,
206
+ )
207
+ log_start = now
208
+
209
+ epoch_avg_loss = running / max(len(train_loader), 1)
210
+ if world_size > 1:
211
+ dist.all_reduce(epoch_avg_loss)
212
+ epoch_avg_loss = epoch_avg_loss / world_size
213
+ metrics: dict[str, float] = {
214
+ "epoch": epoch + 1,
215
+ "train_loss": epoch_avg_loss.item(),
216
+ "epoch_seconds": time.perf_counter() - epoch_start,
217
+ }
218
+ if world_size > 1:
219
+ dist.barrier()
220
+ should_evaluate = (epoch + 1) % int(train_config["eval_every"]) == 0
221
+ if rank == 0 and should_evaluate:
222
+ val_dataset = build_dataset(config, args.data_root, "val")
223
+ val_loader = DataLoader(
224
+ val_dataset,
225
+ batch_size=int(train_config.get("eval_batch_size", train_config["batch_size"])),
226
+ shuffle=False,
227
+ num_workers=int(train_config["workers"]),
228
+ pin_memory=device.type == "cuda",
229
+ collate_fn=detection_collate,
230
+ )
231
+ metrics.update(
232
+ evaluate_coco(
233
+ ema.model if ema is not None else model,
234
+ val_loader,
235
+ device,
236
+ output_dir / f"predictions_epoch_{epoch + 1:03d}.json",
237
+ )
238
+ )
239
+ if rank == 0:
240
+ state = {
241
+ "epoch": epoch,
242
+ "global_step": global_step,
243
+ "best_ap": max(best_ap, metrics.get("AP", -1.0)),
244
+ "model": model.state_dict(),
245
+ "ema": ema.model.state_dict() if ema is not None else model.state_dict(),
246
+ "optimizer": optimizer.state_dict(),
247
+ "scheduler": scheduler.state_dict(),
248
+ "config": config,
249
+ }
250
+ save_checkpoint(output_dir / "last.pt", **state)
251
+ if metrics.get("AP", -1.0) > best_ap:
252
+ best_ap = metrics["AP"]
253
+ state["best_ap"] = best_ap
254
+ save_checkpoint(output_dir / "best.pt", **state)
255
+ with history_path.open("a", encoding="utf-8") as handle:
256
+ handle.write(json.dumps(metrics) + "\n")
257
+ print(json.dumps(metrics))
258
+ if world_size > 1:
259
+ dist.barrier()
260
+ if world_size > 1:
261
+ dist.destroy_process_group()
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()
src/objectmodel_v1/utils.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import random
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import torch
11
+ from torch import Tensor, nn
12
+
13
+
14
+ def seed_everything(seed: int) -> None:
15
+ random.seed(seed)
16
+ np.random.seed(seed)
17
+ torch.manual_seed(seed)
18
+ torch.cuda.manual_seed_all(seed)
19
+
20
+
21
+ def move_targets(targets: list[dict[str, Tensor]], device: torch.device):
22
+ return [
23
+ {key: value.to(device, non_blocking=True) for key, value in target.items()}
24
+ for target in targets
25
+ ]
26
+
27
+
28
+ class ModelEMA:
29
+ def __init__(self, model: nn.Module, decay: float = 0.9998) -> None:
30
+ self.model = deepcopy(model).eval()
31
+ self.decay = decay
32
+ for parameter in self.model.parameters():
33
+ parameter.requires_grad_(False)
34
+ target = dict(self.model.state_dict())
35
+ self._float_names = [name for name, value in target.items() if value.is_floating_point()]
36
+ self._float_targets = [target[name] for name in self._float_names]
37
+ self._other = [(name, target[name]) for name in target if not target[name].is_floating_point()]
38
+ self._float_sources: list[Tensor] | None = None
39
+ self._other_sources: list[Tensor] | None = None
40
+
41
+ @torch.no_grad()
42
+ def update(self, model: nn.Module) -> None:
43
+ if self._float_sources is None:
44
+ source = dict(model.state_dict())
45
+ self._float_sources = [source[name].detach() for name in self._float_names]
46
+ self._other_sources = [source[name].detach() for name, _ in self._other]
47
+ torch._foreach_mul_(self._float_targets, self.decay)
48
+ torch._foreach_add_(self._float_targets, self._float_sources, alpha=1.0 - self.decay)
49
+ for (_, target_value), source_value in zip(self._other, self._other_sources):
50
+ target_value.copy_(source_value)
51
+
52
+
53
+ def learning_rate_factor(step: int, total_steps: int, warmup_steps: int, min_ratio: float) -> float:
54
+ if warmup_steps > 0 and step < warmup_steps:
55
+ return max(step + 1, 1) / warmup_steps
56
+ progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1)
57
+ cosine = 0.5 * (1.0 + math.cos(math.pi * min(max(progress, 0.0), 1.0)))
58
+ return min_ratio + (1.0 - min_ratio) * cosine
59
+
60
+
61
+ def save_checkpoint(path: str | Path, **state) -> None:
62
+ path = Path(path)
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ temporary = path.with_suffix(path.suffix + ".tmp")
65
+ torch.save(state, temporary)
66
+ temporary.replace(path)
67
+
68
+
69
+ def write_json(path: str | Path, value) -> None:
70
+ with Path(path).open("w", encoding="utf-8") as handle:
71
+ json.dump(value, handle, indent=2)
72
+
73
+
74
+ def trainable_parameter_count(model: nn.Module) -> int:
75
+ return sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
tests/test_boxes.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ from objectmodel_v1.boxes import (
4
+ box_cxcywh_to_xyxy,
5
+ box_xyxy_to_cxcywh,
6
+ generalized_box_iou,
7
+ )
8
+
9
+
10
+ def test_box_conversion_roundtrip():
11
+ boxes = torch.tensor([[0.5, 0.4, 0.2, 0.6], [0.2, 0.3, 0.1, 0.1]])
12
+ restored = box_xyxy_to_cxcywh(box_cxcywh_to_xyxy(boxes))
13
+ torch.testing.assert_close(restored, boxes)
14
+
15
+
16
+ def test_generalized_iou_identity_and_separation():
17
+ boxes = torch.tensor([[0.1, 0.1, 0.4, 0.4], [0.6, 0.6, 0.9, 0.9]])
18
+ giou = generalized_box_iou(boxes, boxes)
19
+ torch.testing.assert_close(giou.diag(), torch.ones(2))
20
+ assert giou[0, 1] < 0
tests/test_data.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from PIL import Image
4
+ import torch
5
+
6
+ from objectmodel_v1.data import CocoDetectionDataset, detection_collate
7
+
8
+
9
+ def test_coco_dataset_and_empty_image(tmp_path):
10
+ image_dir = tmp_path / "images"
11
+ image_dir.mkdir()
12
+ Image.new("RGB", (100, 50), "white").save(image_dir / "one.jpg")
13
+ Image.new("RGB", (80, 80), "black").save(image_dir / "two.jpg")
14
+ annotations = {
15
+ "images": [
16
+ {"id": 1, "file_name": "one.jpg", "width": 100, "height": 50},
17
+ {"id": 2, "file_name": "two.jpg", "width": 80, "height": 80},
18
+ ],
19
+ "annotations": [
20
+ {"id": 1, "image_id": 1, "category_id": 7, "bbox": [10, 5, 20, 10], "iscrowd": 0}
21
+ ],
22
+ "categories": [{"id": 7, "name": "object"}],
23
+ }
24
+ annotation_file = tmp_path / "annotations.json"
25
+ annotation_file.write_text(json.dumps(annotations), encoding="utf-8")
26
+ dataset = CocoDetectionDataset(image_dir, annotation_file, 64, training=False)
27
+ image, target = dataset[0]
28
+ empty_image, empty_target = dataset[1]
29
+ assert image.shape == (3, 64, 64)
30
+ assert target["boxes"].shape == (1, 4)
31
+ assert target["labels"].tolist() == [0]
32
+ assert empty_target["boxes"].shape == (0, 4)
33
+ batch, targets = detection_collate([(image, target), (empty_image, empty_target)])
34
+ assert batch.shape == (2, 3, 64, 64)
35
+ assert len(targets) == 2
36
+ assert torch.isfinite(batch).all()
tests/test_losses.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from test_model import tiny_config
3
+
4
+ from objectmodel_v1.losses import ObjectModelCriterion
5
+
6
+
7
+ def reference_dense_targets(criterion, logits, targets, level_index):
8
+ batch, _, height, width = logits.shape
9
+ target_logits = torch.zeros_like(logits)
10
+ target_boxes = torch.zeros(batch, 4, height, width, device=logits.device)
11
+ positive = torch.zeros(batch, height, width, device=logits.device)
12
+ for batch_index, target in enumerate(targets):
13
+ if target["labels"].numel() == 0:
14
+ continue
15
+ centers = target["boxes"][:, :2]
16
+ grid = (centers * torch.tensor([width, height], device=centers.device)).long()
17
+ grid[:, 0].clamp_(0, width - 1)
18
+ grid[:, 1].clamp_(0, height - 1)
19
+ areas = target["boxes"][:, 2] * target["boxes"][:, 3]
20
+ target_levels = torch.where(areas < 0.02, 0, torch.where(areas < 0.15, 1, 2))
21
+ for target_index in torch.where(target_levels == level_index)[0]:
22
+ center_x, center_y = grid[target_index]
23
+ label = target["labels"][target_index]
24
+ candidates = []
25
+ for dy in (-1, 0, 1):
26
+ for dx in (-1, 0, 1):
27
+ x = int((center_x + dx).clamp(0, width - 1))
28
+ y = int((center_y + dy).clamp(0, height - 1))
29
+ candidates.append((dx * dx + dy * dy, x, y))
30
+ for _, x, y in sorted(candidates)[: criterion.dense_topk]:
31
+ target_logits[batch_index, label, y, x] = 1.0
32
+ target_boxes[batch_index, :, y, x] = target["boxes"][target_index]
33
+ positive[batch_index, y, x] = 1.0
34
+ return target_logits, target_boxes, positive
35
+
36
+
37
+ def test_dense_targets_match_reference_with_boundaries_and_collisions():
38
+ criterion = ObjectModelCriterion(tiny_config())
39
+ logits = torch.randn(2, 5, 8, 8)
40
+ targets = [
41
+ {
42
+ "boxes": torch.tensor(
43
+ [
44
+ [0.01, 0.01, 0.10, 0.10],
45
+ [0.02, 0.02, 0.11, 0.11],
46
+ [0.99, 0.99, 0.50, 0.50],
47
+ ]
48
+ ),
49
+ "labels": torch.tensor([1, 2, 3]),
50
+ },
51
+ {"boxes": torch.empty(0, 4), "labels": torch.empty(0, dtype=torch.long)},
52
+ ]
53
+
54
+ for level_index in range(3):
55
+ expected = reference_dense_targets(criterion, logits, targets, level_index)
56
+ actual = criterion._dense_targets(logits, targets, level_index)
57
+ for expected_tensor, actual_tensor in zip(expected, actual, strict=True):
58
+ assert torch.equal(expected_tensor, actual_tensor)
59
+
60
+
61
+ def test_dense_targets_match_reference_for_production_topk():
62
+ config = tiny_config()
63
+ config["loss"]["dense_topk"] = 5
64
+ criterion = ObjectModelCriterion(config)
65
+ logits = torch.randn(2, 5, 12, 10)
66
+ targets = [
67
+ {"boxes": torch.rand(9, 4), "labels": torch.randint(0, 5, (9,))},
68
+ {"boxes": torch.rand(4, 4), "labels": torch.randint(0, 5, (4,))},
69
+ ]
70
+
71
+ for level_index in range(3):
72
+ expected = reference_dense_targets(criterion, logits, targets, level_index)
73
+ actual = criterion._dense_targets(logits, targets, level_index)
74
+ for expected_tensor, actual_tensor in zip(expected, actual, strict=True):
75
+ assert torch.equal(expected_tensor, actual_tensor)
tests/test_matching.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ from objectmodel_v1.matching import hungarian_match, hungarian_match_layers
4
+
5
+
6
+ def test_layer_matcher_matches_individual_calls():
7
+ torch.manual_seed(7)
8
+ outputs = [
9
+ {
10
+ "pred_logits": torch.randn(3, 12, 5),
11
+ "pred_boxes": torch.rand(3, 12, 4),
12
+ }
13
+ for _ in range(3)
14
+ ]
15
+ targets = [
16
+ {"labels": torch.tensor([1, 3]), "boxes": torch.rand(2, 4)},
17
+ {"labels": torch.empty(0, dtype=torch.long), "boxes": torch.empty(0, 4)},
18
+ {"labels": torch.tensor([0, 2, 4]), "boxes": torch.rand(3, 4)},
19
+ ]
20
+
21
+ expected = [hungarian_match(output, targets) for output in outputs]
22
+ actual = hungarian_match_layers(outputs, targets)
23
+
24
+ for expected_layer, actual_layer in zip(expected, actual, strict=True):
25
+ for expected_match, actual_match in zip(expected_layer, actual_layer, strict=True):
26
+ assert torch.equal(expected_match[0], actual_match[0])
27
+ assert torch.equal(expected_match[1], actual_match[1])
28
+
29
+
30
+ def test_layer_matcher_handles_all_empty_targets():
31
+ outputs = [{"pred_logits": torch.randn(2, 4, 3), "pred_boxes": torch.rand(2, 4, 4)}]
32
+ targets = [
33
+ {"labels": torch.empty(0, dtype=torch.long), "boxes": torch.empty(0, 4)},
34
+ {"labels": torch.empty(0, dtype=torch.long), "boxes": torch.empty(0, 4)},
35
+ ]
36
+
37
+ matches = hungarian_match_layers(outputs, targets)
38
+
39
+ assert len(matches) == 1
40
+ assert all(rows.numel() == 0 and cols.numel() == 0 for rows, cols in matches[0])
tests/test_model.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ from objectmodel_v1.losses import ObjectModelCriterion
4
+ from objectmodel_v1.model import build_model
5
+ from objectmodel_v1.postprocess import decode_predictions
6
+
7
+
8
+ def tiny_config(dense_aux=True):
9
+ return {
10
+ "model": {
11
+ "num_classes": 5,
12
+ "input_size": 128,
13
+ "stem_channels": 16,
14
+ "backbone_channels": [24, 32, 48, 64],
15
+ "backbone_depths": [1, 1, 1, 1],
16
+ "hidden_dim": 48,
17
+ "fpn_depth": 1,
18
+ "latent_count": 8,
19
+ "latent_pool_sizes": [4, 2, 1],
20
+ "latent_layers": 1,
21
+ "decoder_layers": 2,
22
+ "num_queries": 12,
23
+ "num_heads": 4,
24
+ "local_points": 2,
25
+ "dropout": 0.0,
26
+ "dense_aux": dense_aux,
27
+ },
28
+ "loss": {
29
+ "cost_class": 2.0,
30
+ "cost_bbox": 5.0,
31
+ "cost_giou": 2.0,
32
+ "weight_class": 2.0,
33
+ "weight_bbox": 5.0,
34
+ "weight_giou": 2.0,
35
+ "weight_dense": 1.0,
36
+ "focal_alpha": 0.25,
37
+ "focal_gamma": 2.0,
38
+ "aux_weight": 1.0,
39
+ "dense_topk": 3,
40
+ },
41
+ }
42
+
43
+
44
+ def test_forward_shapes_and_ranges():
45
+ model = build_model(tiny_config()).eval()
46
+ with torch.no_grad():
47
+ output = model(torch.randn(2, 3, 128, 128))
48
+ assert output["pred_logits"].shape == (2, 12, 5)
49
+ assert output["pred_boxes"].shape == (2, 12, 4)
50
+ assert len(output["aux_outputs"]) == 1
51
+ assert "dense_outputs" not in output
52
+ assert torch.all((output["pred_boxes"] >= 0) & (output["pred_boxes"] <= 1))
53
+
54
+
55
+ def test_loss_backward_with_empty_target():
56
+ config = tiny_config()
57
+ model = build_model(config).train()
58
+ criterion = ObjectModelCriterion(config)
59
+ output = model(torch.randn(2, 3, 128, 128))
60
+ targets = [
61
+ {
62
+ "boxes": torch.tensor([[0.5, 0.5, 0.25, 0.3], [0.2, 0.2, 0.1, 0.1]]),
63
+ "labels": torch.tensor([1, 3]),
64
+ },
65
+ {"boxes": torch.empty(0, 4), "labels": torch.empty(0, dtype=torch.long)},
66
+ ]
67
+ losses = criterion(output, targets)
68
+ assert all(torch.isfinite(value) for value in losses.values())
69
+ losses["loss_total"].backward()
70
+ gradients = [parameter.grad for parameter in model.parameters() if parameter.grad is not None]
71
+ assert gradients
72
+ assert all(torch.isfinite(gradient).all() for gradient in gradients)
73
+
74
+
75
+ def test_decode_is_nms_free_top_k_filter():
76
+ model = build_model(tiny_config(dense_aux=False)).eval()
77
+ with torch.no_grad():
78
+ output = model(torch.randn(1, 3, 128, 128))
79
+ result = decode_predictions(output, [(240, 320)], confidence=0.0, top_k=4)[0]
80
+ assert result["boxes"].shape == (4, 4)
81
+ assert result["scores"].shape == (4,)
82
+ assert torch.all(result["boxes"][:, [0, 2]] <= 320)
83
+ assert torch.all(result["boxes"][:, [1, 3]] <= 240)