Upload ObjectModel-v1: code, README, assets (v1 checkpoint)
Browse files- .gitattributes +6 -0
- LICENSE +17 -0
- README.md +270 -0
- RESEARCH.md +56 -0
- assets/ap_progress.svg +16 -0
- assets/examples/family.jpg +3 -0
- assets/examples/food_tray.jpg +3 -0
- assets/examples/kite_beach.jpg +0 -0
- assets/examples/rodeo.jpg +3 -0
- assets/examples/skatepark.jpg +0 -0
- assets/examples/street_market.jpg +3 -0
- assets/tracking_demo.gif +3 -0
- assets/tracking_demo.mp4 +3 -0
- configs/objectmodel_v1.yaml +61 -0
- configs/objectmodel_v1_tiny.yaml +56 -0
- pyproject.toml +40 -0
- src/objectmodel_v1.egg-info/PKG-INFO +191 -0
- src/objectmodel_v1.egg-info/SOURCES.txt +25 -0
- src/objectmodel_v1.egg-info/dependency_links.txt +1 -0
- src/objectmodel_v1.egg-info/entry_points.txt +5 -0
- src/objectmodel_v1.egg-info/requires.txt +16 -0
- src/objectmodel_v1.egg-info/top_level.txt +1 -0
- src/objectmodel_v1/__init__.py +6 -0
- src/objectmodel_v1/boxes.py +67 -0
- src/objectmodel_v1/config.py +38 -0
- src/objectmodel_v1/data.py +142 -0
- src/objectmodel_v1/evaluate.py +112 -0
- src/objectmodel_v1/export.py +59 -0
- src/objectmodel_v1/losses.py +220 -0
- src/objectmodel_v1/matching.py +99 -0
- src/objectmodel_v1/model.py +413 -0
- src/objectmodel_v1/postprocess.py +32 -0
- src/objectmodel_v1/profile.py +54 -0
- src/objectmodel_v1/tracking.py +181 -0
- src/objectmodel_v1/train.py +265 -0
- src/objectmodel_v1/utils.py +75 -0
- tests/test_boxes.py +20 -0
- tests/test_data.py +36 -0
- tests/test_losses.py +75 -0
- tests/test_matching.py +40 -0
- tests/test_model.py +83 -0
.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 ≥ 0.35. Picked for variety, not cherry-picked for perfection.
|
| 68 |
+
|
| 69 |
+
| | |
|
| 70 |
+
|---|---|
|
| 71 |
+
|  |  |
|
| 72 |
+
| Dessert tray, bottle, bowl, and a dozen individually boxed items | Skate park, parent and child, skateboard at 0.93 confidence |
|
| 73 |
+
|  |  |
|
| 74 |
+
| Street market, person at 0.94, market umbrella | Rodeo, crowd of people, one animal still mislabeled |
|
| 75 |
+
|  |  |
|
| 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
|
assets/examples/food_tray.jpg
ADDED
|
Git LFS Details
|
assets/examples/kite_beach.jpg
ADDED
|
assets/examples/rodeo.jpg
ADDED
|
Git LFS Details
|
assets/examples/skatepark.jpg
ADDED
|
assets/examples/street_market.jpg
ADDED
|
Git LFS Details
|
assets/tracking_demo.gif
ADDED
|
|
Git LFS Details
|
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)
|