In plain words
The task: find every object on a cluttered table, in images where objects hide each other.
Object detection is a harder question than it sounds. It is not "is there a box in this picture" — it is "draw a rectangle around every object, say what each one is, and give me a confidence for each". The number of answers is not fixed, some objects hide behind others, and a detector can be wrong in four different ways at once: miss an object, invent one, put the rectangle in the wrong place, or put the right rectangle on the wrong label.
This project builds that from nothing, in a simulator, on a laptop with no graphics card. Three object
kinds — box, cylinder, sphere — three to six per scene, in a
192×192 image from a camera tilted 39° above the table.
Why the order of the work is the design
The tempting sequence is: train a detector, compute a score, write it down. That sequence cannot tell you whether the score is good, because there is nothing to compare it to and no proof the scorer is correct. So this project runs the reverse:
- Build the dataset and check the labels are right before anything reads them.
- Build the metric and prove it against fifteen cases derived on paper — before there is anything to score.
- Build the strongest classical pipeline you can, including a fitted classifier, and record its number.
- Then train the network, and report the difference.
The previous project in this track found that half a network's apparent advantage was a single calibration constant its baseline had never been given. Building the strong baseline second inflates the model. So here the baseline gets everything a classical pipeline can fairly have — including a fitted classifier and its own tuning done on the training split — before the network is allowed to compete.
Terms used on this page
The five steps and what each one produced
The five build steps, and the concrete artefact each one produced.
Every file, and what it is for
Every source file, how big it is, and the single job it owns.
Core
| File | Lines | What it does |
|---|---|---|
| common.py | 139 | Scene constants, the class list, the yaw fold carried over from the previous project, the
overhead projection, IoU between box sets, mask→box conversion, the HSV→RGB helper, the split
loader, and the label column order. Also two thresholds that decide what counts as a target at
all: MIN_VISIBLE_PX = 12 and MIN_VISIBLE_FRAC = 0.10. |
| scene.xml | 39 | The MuJoCo scene. A 1.5 m table plane, one light, two cameras, and six static object slots with
no joints. Shape, size, colour and pose are all mjModel fields, so one XML covers
every class and every appearance — unused slots are parked 10 m away, outside both fields of view. |
| gen_dataset.py | 239 | Renders the four splits. Rejection-samples object positions at least 0.075 m apart, writes shape
and colour into the model, renders RGB, renders again with segmentation enabled, converts each
instance mask to a tight box, and computes visible_frac with a pruned second pass. |
| view_dataset.py | 69 | Draws the labels back onto sample images (out/labels.png) so a human can confirm
the boxes land where they should before anything trains on them. |
Metric
| File | Lines | What it does |
|---|---|---|
| ap.py | 212 | The AP implementation. Ten IoU thresholds from 0.50 to 0.95, 101 recall levels, greedy matching that cannot cross image boundaries, ignore semantics, per-class averaging with absent classes excluded rather than scored zero. Also size-stratified AP and recall-by-visibility. |
| test_ap.py | 125 | Fifteen unit cases. Every expected value derived on paper — a test that records whatever the code printed last time proves only that the code is deterministic. |
| sanity_ap.py | 116 | End-to-end controls on real data: feed ground truth back as detections (must be exactly 1.0), shuffle the classes, randomise the boxes, keep a known fraction of hits, shift every box by a known pixel count. |
Classical baseline
| File | Lines | What it does |
|---|---|---|
| baseline_cv.py | 383 | Three segmenters (otsu, bgsub, bgsub+ws), a
seed-counting watershed splitter, a nine-feature region descriptor, and a class-balanced
multinomial logistic regression with a fourth background class so the pipeline can
suppress its own false positives. Deliberately linear. |
Learned detector
| File | Lines | What it does |
|---|---|---|
| detector.py | 227 | The architecture and the target encoding. Gaussian radius, Gaussian splatting, encode, focal loss, masked L1 for the regression heads, the encoder–decoder network, and the decode function whose 3×3 max-pool is the entire non-maximum suppression. |
| train_det.py | 273 | The training loop. Holds 1500 images out of train as a tune split so validation is touched exactly once, at the end. Adam + OneCycle, 25 epochs, 34 minutes at 112–128 img/s on 8 threads. Contains the photometric and geometric augmentation implementations. |
| test_detector.py | 106 | Proves encode and decode are exact inverses before any
training. This bug class does not crash — it trains to a low loss and puts the boxes in the wrong
place. Also measures worst-case centre error at 2.00 px, which at stride 4 is exactly the
quantisation the offset head exists to undo. |
| run_ablation.py | 81 | Drives the 2×4 grid: two training regimes × four augmentation settings, one identical budget per cell, every model scored on both validation splits. |
| export_onnx.py | 188 | Exports at fixed batch 1, checks max absolute difference per head, re-runs the full detection
pipeline through ONNX Runtime and scores it with the same ap.py, and times model,
decode, and end-to-end at 1, 4 and 8 threads. |
Inspection and reproduction
| File | What it does |
|---|---|
| view_detections.py · view_cnn.py | Qualitative figures. view_cnn.py pairs each image with the centre heatmap the boxes
were read from — an average cannot show a failure mode, it can only tell you one exists. |
| view_live.py · render_showcase.py | Open the scene in MuJoCo's interactive viewer with both cameras in the dropdown, and render presentation stills with shadows and a textured floor. Shadows cost 6× throughput (~130 img/s against 800+), which is why the dataset renders plain. |
| verify.sh | Five tiers, cheapest first. Tiers 1–2 need no dataset and no weights and run in under a minute; the later tiers explain how to regenerate what they need rather than failing. |
| after_chain.sh · run_rest.sh | Job-chaining wrappers used to queue the long training runs on a shared machine. |
| requirements.txt | Pinned CPU wheels — torch==2.14.0+cpu, mujoco==3.12.0,
onnxruntime==1.23.2. The versions every number was measured on. |
| runs/ · out/ · data/ | Everything the README quotes is tracked: metrics JSON, per-epoch transcripts, both checkpoints,
and detector.onnx. Only data/ (2.9 GB) is gitignored. |
The scene, and where the labels come from
How the MuJoCo scene is arranged, and why the labels are exact rather than drawn by hand.
Why the camera was tilted
The previous project used an overhead camera, which was the right choice there — a top-down image has an exact affine pixel-to-world map, ideal for regressing one pose precisely. But it makes detection degenerate. Objects resting on a flat table can never overlap in a top-down image, so there is no occlusion, no perspective, and no depth-dependent scale. A detector trained on that view would learn none of the three things that make detection hard.
Labels read out of the renderer, not annotated
Each scene is rendered twice. The first pass produces the RGB image. The second runs with
enable_segmentation_rendering(), which returns, per pixel, which geometry won it. A tight
box around one instance mask is therefore already correct under perspective, already correct for the
silhouette of a rotated box, and already correct about what is hidden behind what.
This is the concrete reason robot-learning work starts in simulation. The label is not produced by a human — it is read out.
The occlusion label needs a third pass, and a pruning trick
The segmentation buffer says which object won each pixel. It cannot say how many pixels an object would have had alone — which is exactly what "what fraction of this object is visible" needs. So each object is re-rendered with the others parked outside the frame, and the two counts are divided.
That is N extra renders per scene and it dominates the cost. The optimisation: skip any object whose visible box intersects no other visible box — such an object is provably unoccluded, so the pruning is exact rather than approximate. It cut the second pass from 4.50 solo renders per image to 2.19, removing 51 % of those renders and 35 % of the total generation cost.
Decisions frozen into the scene contract
yaw is NaN for both,
and the loss masks it.mj_step; poses are written directly and
mj_forward recomputes the derived state.What was generated
| Split | Images | Objects | Obj/img | Occluded <0.9 | <0.5 | img/s |
|---|---|---|---|---|---|---|
hard/train | 12 000 | 53 961 | 4.50 | 10.0 % | 0.6 % | 111.1 |
hard/val | 2 000 | 8 992 | 4.50 | 10.2 % | 0.6 % | 115.1 |
easy/train | 12 000 | 53 835 | 4.49 | 10.1 % | 0.5 % | 106.4 |
easy/val | 2 000 | 9 049 | 4.52 | 9.8 % | 0.5 % | 103.6 |
Only 10 % of objects are occluded at all and 0.6 % past half. That is what a 39° elevation and a 0.075 m minimum separation produce. So mAP measured here is not stressed by occlusion, and any claim that the detector "handles occlusion" would be unsupported by this dataset. The honest fix is a lower camera and a regenerated set — not a softer sentence.
The scoring metric, written by hand
COCO mAP implemented from scratch — ten IoU thresholds, 101 recall points, and the matching rule.
What mAP@[.5:.95] actually is
Step by step, because the name hides the mechanism:
- IoU — intersection over union — measures how well a predicted box overlaps a true one. 1.0 is perfect, 0 is no overlap.
- Pick an IoU threshold, say 0.5. Sort all detections by confidence. Walk down the list; each one is a true positive if it overlaps an unclaimed ground-truth box by at least the threshold, otherwise a false positive. Matching is greedy and cannot cross image boundaries.
- That walk traces a precision–recall curve. Average precision is the area under it, sampled at 101 fixed recall levels.
- Repeat at ten thresholds from 0.50 to 0.95 and average. Repeat per class and average again. That is mAP@[.5:.95].
pycocotools is one import away and is deliberately not used. Everything interesting about
detection evaluation lives inside those four steps; calling a library teaches none of it. The semantics
follow pycocotools closely enough to be comparable, and the places they differ are marked in the source.
Three unit cases that are counter-intuitive — and each one catches a real bug
| Case | AP@0.5 | Why |
|---|---|---|
| 1 object, 1 correct hit, then 1 false positive | 1.000 | The false positive arrives after recall reached 1.0, so no recall level was ever achievable at lower precision. It costs nothing. |
| the same false positive placed before the hit | 0.500 | Identical detections, identical recall, half the AP. Ranking is part of the score. |
| 1 of 2 objects found, no false positives | 0.5050 | Not 0.5 — 51 of the 101 recall levels are reachable, so it is 51/101. |
Ignore semantics, and why they get their own test
Stratified AP — "how well does it do on small objects only" — depends entirely on this. Ground truth outside the stratum is marked ignore, not deleted. Deleting it turns every correct detection of an out-of-stratum object into a false positive. The test shows the same data scoring 1.000 with ignore and 0.500 with deletion.
End-to-end controls: known inputs have known outputs
| Input | mAP | Expected |
|---|---|---|
| ground truth fed back as detections | 1.0000 | exactly 1 |
| correct boxes, classes shuffled | 0.1130 | ≈ 1/9 — precision ⅓ × recall ⅓ |
| random boxes, correct classes | 0.0000 | 0 |
| keep 75 % / 50 % / 25 % of hits | 0.7492 / 0.4983 / 0.2541 | ≈ the fraction kept |
Why the range of thresholds matters — a controlled degradation
Shifting every box by d pixels in x gives an IoU of exactly (w−d)/(w+d), so this
test has a predicted answer, not just an observed one.
| Shift | Predicted IoU (w = 28) | mAP | AP50 |
|---|---|---|---|
| 0 px | 1.000 | 1.0000 | 1.0000 |
| 2 px | 0.867 | 0.7309 | 1.0000 |
| 4 px | 0.750 | 0.4639 | 1.0000 |
| 7 px | 0.600 | 0.1415 | 0.6873 |
| 12 px | 0.400 | 0.0057 | 0.0480 |
At a 4 px shift, AP50 is still a perfect 1.0000 while mAP has already lost more than half its value. AP50 alone cannot see localisation quality at all.
Two stratification decisions
Cost: 436 ms for 2000 images × 8992 objects × 3 classes × 10 thresholds. IoU is built once per image and reused across all ten thresholds — the only optimisation the metric needs.
The classical baseline
The classical baseline: watershed segmentation, nine hand-designed shape features, and a linear classifier.
The pipeline
- Estimate the table, per image. Either a heavily downsampled median filter or a morphological opening — both produce "what this image would look like with no objects on it".
- Threshold the residual. What remains after subtracting that background is object.
- Split touching regions with a distance-transform watershed — but only where it is needed (see below).
- Describe each region with nine hand-designed shape features: log area, scale, aspect ratio, extent, circularity, solidity, normalised vertical position, top fill, elongation.
- Classify with a class-balanced multinomial logistic regression over those nine features, with a fourth background class so the pipeline can suppress its own false positives — a hand-built objectness. The confidence is the softmax probability of the chosen class.
A linear model on hand-designed features is what the classical convention is — a linear model on HOG, on SIFT, on shape moments. Making it an MLP would quietly turn the baseline into a small neural network and destroy the comparison it exists for.
Two more rules that keep it honest: nothing is thresholded before AP, because AP integrates over every score cut-off and discarding low-confidence detections in advance only removes recall the metric would have credited; and all tuning was done on train and reported on val, because choosing the watershed's smoothing parameter on the split you then report is exactly how a baseline gets silently inflated.
Results on val, 2000 images
| Method | Regime | mAP | AP50 | AP75 | Agnostic mAP | Det rate | ms/img |
|---|---|---|---|---|---|---|---|
| otsu | easy | 0.4460 | 0.5178 | 0.4488 | 0.5138 | 0.5482 | 0.36 |
| otsu | hard | 0.1430 | 0.2182 | 0.1401 | 0.1699 | 0.2316 | 0.19 |
| bgsub | easy | 0.5002 | 0.5639 | 0.4962 | 0.5307 | 0.5524 | 1.04 |
| bgsub | hard | 0.4114 | 0.4979 | 0.4128 | 0.4761 | 0.5251 | 1.12 |
| bgsub + watershed | easy | 0.5895 | 0.7672 | 0.5853 | 0.6658 | 0.7164 | 1.88 |
| bgsub + watershed | hard | 0.5322 | 0.6979 | 0.5637 | 0.6108 | 0.6847 | 2.02 |
A repair applied to something that is not broken is damage
bgsub alone leaves a bimodal error distribution: 47.3 % of objects recovered at
IoU ≥ 0.9, and 38.6 % below 0.5 because touching objects merge into one region. The obvious fix —
distance-transform watershed — made things worse when applied to every region.
| Variant | Regions/img | Det rate | Agnostic mAP | IoU ≥ 0.9 | IoU < 0.5 |
|---|---|---|---|---|---|
bgsub | 3.40 | 0.5211 | 0.3746 | 47.3 % | 38.6 % |
| watershed on every region | 4.29 | 0.4933 | 0.3004 | 6.3 % | 25.0 % |
| watershed on multi-seed regions only | 4.29 | 0.7067 | 0.5525 | 52.6 % | 14.1 % |
Running the watershed everywhere redraws the boundary of regions that were already correct: the near-perfect tier collapsed from 47.3 % to 6.3 % while the merged tier only fell from 38.6 % to 25.0 %. Counting distance-transform seeds inside each connected region first, and leaving single-seed regions untouched, keeps the good tier and fixes the merges. A parameter sweep would never have found this — the sweep converged towards "split less", which was the wrong axis entirely.
A wrong prior still returns an answer
Global Otsu thresholding on greyscale is a reasonable-looking method: objects and table differ in
brightness, so split the histogram. Under easy it scores 0.446. Under hard,
where table value is uniform(0.25, 0.85) and object value uniform(0.45, 1.0), the two distributions
overlap and the split lands inside the objects — mAP 0.143, a 3.1× collapse,
detection rate 0.232. It never raised an error. It returned a mask every time, and its per-class
numbers read like a weak detector rather than a broken one.
Classification is the bottleneck, not localisation
On hard, class-agnostic AP50 is 0.823 against class-aware AP50
0.698. The boxes are in the right place; the label on them is wrong 15 % of the time.
Per class: sphere 0.695, cylinder 0.510, box 0.391. A sphere's silhouette is a circle
from every direction, so nine shape features describe it completely. A box's silhouette under a tilted
camera changes with yaw, and at 28 px there is not enough of it left for a linear rule. That gap is the
specific thing a learned feature extractor should close — and it is now measured rather than assumed.
Occlusion is where the classical pipeline stops, not degrades
Recall at IoU 0.5 by visible fraction: 0.785 above 0.9 visibility, 0.348 between 0.5 and 0.9, and 0.000 below 0.5. Not a slope — a cliff. A partially hidden object has the wrong silhouette, so every shape feature it produces is wrong at once.
The anchor-free detector
The anchor-free detector: heatmap, size and offset heads, focal loss, and max-pooling used as suppression.
Where the idea comes from
The previous project's winning head was a spatial soft-argmax: reduce a feature map to one expected location. That is exactly why it cannot be used here — there are three to six objects, and one expectation cannot describe them.
The generalisation is to stop reducing the map at all. Keep it at stride 4 (48×48 for a 192 px input), predict a per-class centre heatmap on it, and read every local maximum instead of the mean. Size and offset ride along as two more heads on the same feature map. No anchor boxes, no region proposals, no IoU-based suppression anywhere.
Three heads on one feature map
| Head | Channels | Predicts | Loss |
|---|---|---|---|
hm — heatmap | 3 | One map per class. A peak means "an object centre is here". | Focal loss on a Gaussian-splatted target |
wh — size | 2 | Width and height in cells, read only at the peak. | Masked L1 |
off — offset | 2 | The sub-cell remainder — where inside the 4×4 pixel cell the true centre actually was. | Masked L1 |
The network shape, and why there is a decoder
c1 3→16 stride 2 → 96×96
c2 16→32 stride 2 → 48×48
c3 32→64 stride 2 → 24×24
c4 64→128 stride 2 → 12×12 encoder, down to stride 16
y3 = s3( l3(c3) + upsample(p4(c4)) ) FPN-style decoder
y2 = s2( l2(c2) + upsample(y3) ) back to 48×48, stride 4
hm = 1×1 conv → 3 channels (sigmoid)
wh = 1×1 conv → 2 channels
off = 1×1 conv → 2 channels
The decoder exists because the heads need both halves. Stride 16 has the receptive field to say "object"; stride 4 has the resolution to say "here". Predicting at stride 16 would give a 28 px object a 1.75-cell footprint, and the offset head would have to absorb an 8 px quantisation — larger than the localisation error the classical baseline already achieves. Total: 380 631 parameters.
Three details that are load-bearing rather than decorative
logit(0.01). 99.8 % of the 2304 cells in a target map are zeros. Initialised at
p = 0.5, the focal loss is dominated by pushing background down and the first epochs are spent walking
the bias there — and training can diverge doing it.Encode and decode were verified as exact inverses before any training, the same discipline the metric got. This bug class does not crash — it trains to a low loss and puts the boxes in the wrong place.
Results, hard/val, 2000 images
| Metric | Classical bgsub+ws | Detector | Change |
|---|---|---|---|
| mAP@[.5:.95] | 0.5322 | 0.9107 | +0.379 |
| AP50 | 0.6979 | 0.9899 | +0.292 |
| AP75 | 0.5637 | 0.9896 | +0.426 |
| class-agnostic mAP | 0.6108 | 0.9099 | +0.299 |
| detection rate @ 0.5 | 0.6847 | 0.9433 | +0.259 |
| latency (eager, in-process) | 2.02 ms | 3.87 ms | 1.9× slower |
Training: 10 500 images with 1500 held out of
train to watch for divergence; val was touched exactly once, at the very end.
25 epochs, Adam + OneCycle, 34 min at 112–128 img/s on 8 threads. The latency row was measured
in-process immediately after that 34-minute run — step 5 re-measures it on an idle box and gets a
different answer.
The classification gap closed — which the baseline predicted
Per-class AP: box 0.391 → 0.914, cylinder 0.510 → 0.910, sphere 0.695 → 0.908. The spread across classes collapses from 0.304 to 0.006. The learned extractor does not just score higher, it scores evenly — the class that was hardest for hand-designed features is now indistinguishable from the easiest. That was a stated prediction before training.
The occlusion cliff is gone — with a caveat that matters more than the number
Recall at IoU 0.5 by visible fraction: ≥0.9 → 0.785 becomes 1.000; 0.5–0.9 → 0.348 becomes 0.999; <0.5 → 0.000 becomes 0.910. A centre heatmap has no coupling to the silhouette — the centre of a half-occluded object is still a centre, and the size head regresses the full extent from partial evidence.
The caveat: n = 53. That is 0.6 % of the dataset, and 0.910 on 53 samples carries about ±0.04 at one sigma. The honest claim is narrow — the specific failure mode that stops the classical pipeline does not appear here. It is not evidence of robustness to heavy occlusion in general.
Localisation is essentially exact, and AP75 is where it shows
AP75 0.9896 against AP50 0.9899 — a gap of 0.0003. The baseline lost 0.134 between the same two thresholds. The metric's shift table gives the reading: a uniform 4 px error leaves AP50 at 1.000 while mAP has already fallen to 0.464, so AP50 alone cannot see localisation. The offset head is what buys this; it is measured at 2.00 px of worst-case centre error, which at stride 4 is exactly the quantisation it exists to undo.
The learned detector is slower, not faster
3.87 ms against 2.02 ms, single image, PyTorch eager, decode included. Worth stating plainly because the convenient story would be that the network wins on every axis, and it does not. Step 5 was set up to find out whether that was the architecture or the runtime.
Small objects still cost, in the expected direction
By size tercile: 0.805 (<25.5 px), 0.854 (25.5–31.9 px), 0.884 (>31.9 px). IoU is scale-relative, so a fixed pixel error costs a small box more. Output stride 4 means centres quantise to 4 px cells, which is 16 % of a 25 px object and 9 % of a 43 px one. The offset head removes most of that; the residual 0.079 spread is what is left.
Augmentation against domain randomisation
Photometric augmentation against render-time randomisation, and the asymmetry the comparison exposed.
The question, stated before the runs
Photometric augmentation — randomly changing brightness, contrast, gamma, channel gain during training —
exists because real datasets are captured under one set of lights and deployed under another. But the
hard regime already randomises object hue, table shade, light position and light
intensity at render time. Does augmentation add anything the randomiser does not already cover?
Two regimes × four augmentation settings, one identical budget per cell: 6000 training images, 12 epochs,
same seed, same schedule. Every model scored on both validation splits, so each row has an
in-distribution number and a cross-regime number. The easy→hard column is the
sim-to-real question in miniature — easy is a simulator nobody randomised,
hard is the world it has to survive.
| Trained on | Augmentation | easy val | hard val | Train s |
|---|---|---|---|---|
easy | none | 0.9013 | 0.1465 | 595 |
easy | photometric | 0.8945 | 0.5214 | 783 |
easy | geometric | 0.9093 | 0.1808 | 608 |
easy | both | 0.9060 | 0.5065 | 737 |
hard | none | 0.8683 | 0.8735 | 589 |
hard | photometric | 0.8643 | 0.8742 | 782 |
hard | geometric | 0.8844 | 0.8893 | 648 |
hard | both | 0.8806 | 0.8857 | 839 |
The gap is appearance, and it is a cliff, not a slope
A detector trained on fixed appearance scores 0.90 on what it saw and 0.15 when the table colour and the light move. Detection rate falls to 0.29 — it does not misclassify seven objects in ten, it fails to see them. Nothing about the geometry, the camera, the shapes or the sizes changed. This is the number the whole track is about: a policy trained in an un-randomised simulator meets the real world in exactly this way, and the failure is silent — no error, just boxes missing.
Photometric augmentation recovers a third of the gap and stops
0.147 → 0.521, still 0.35 short of simply training on hard (0.874). The mechanism is
visible in what each one varies. Augmentation perturbs the whole image with one brightness, one
contrast, one gamma, one channel gain — a global nuisance model. The randomiser gives every object its
own hue and moves the light source, which changes the shading direction on every face.
Augmentation is a hand-written model of the nuisance; the randomiser samples the nuisance itself. A
model cannot be augmented towards a variation nobody wrote down.
Geometric augmentation does nothing across the gap
0.147 → 0.181, within the noise of a single seed. Flip and translation add placement diversity, and placement was never the problem — the two regimes share one camera and one placement distribution. An augmentation only buys invariance along the axis it perturbs. Worth stating because the default recipe applies everything at once and then cannot say which part worked.
On top of the randomiser, photometric augmentation buys nothing
hard/none 0.8735 against hard/photo 0.8742: +0.0007, for
33 % more training time. The randomiser already covers the axis photometric augmentation would add.
Every hour spent on an augmentation pipeline for a randomised simulator is an hour spent on nothing.
Geometric augmentation does add +0.016 within hard. Single seed, so suggestive
rather than proven, but the direction is expected for a 6000-image dataset: flip and shift are extra
placements, which the randomiser only samples 6000 times.
The randomised model transfers to the fixed regime for free
hard/none loses 0.005 going to easy.
easy/none loses 0.75 going the other way. That asymmetry is the entire
argument for domain randomisation: the randomised distribution contains the fixed one, so a
model trained on it has already seen the fixed regime as a special case. Nothing was tuned for
easy and nothing needed to be.
More data does not close an appearance gap
The full-budget easy/none model saw 75 % more images and 67 % more epochs and gained
+0.027 on easy — and only +0.033 on hard, from 0.147 to 0.180. Coverage of the
distribution is what transfers, not the number of samples drawn from the wrong one. When a model fails
out of distribution, "collect more data" is only the right answer if the new data comes from somewhere
new.
Export, latency, and a measurement that was thrown away
ONNX export, thread scaling, and a latency measurement that was thrown away and kept in the repository.
Verifying the export end to end
An export that is 3× faster and 2 % wrong is not an optimisation, and a max-absolute-difference on raw
tensors is not a proof: a small logit drift can move a heatmap peak by one cell and change a box entirely.
So the check runs full detections through the ONNX graph and scores them with the same
ap.py.
| Check | Result |
|---|---|
| max |torch − onnx| per head | heatmap 2.4e−6 · size 1.1e−5 · offset 4.8e−6 |
mAP, first 500 hard/val images, PyTorch | 0.9127 |
| mAP, same 500 images, ONNX Runtime | 0.9127 |
| graph | 1.53 MB · 380 631 params · opset 17 · batch fixed at 1 |
Batch is fixed at 1 deliberately: an edge camera delivers one frame at a time, and a graph exported for the shape it will actually see is the graph the runtime can plan for.
Latency, batch 1, 200 images, idle box
Decode — the 3×3 max-pool, the top-k and the gather — is 0.18 ms and does not depend on the runtime. It is added to every row, because a heatmap is not a detection.
| Runtime | Threads | Model ms | End to end ms | vs classical 2.13 ms |
|---|---|---|---|---|
| ONNX Runtime | 8 | 1.01 | 1.20 | 1.8× faster |
| ONNX Runtime | 4 | 1.12 | 1.30 | 1.6× faster |
| PyTorch eager | 4 | 2.54 | 2.72 | 0.78× |
| PyTorch eager | 8 | 2.75 | 2.93 | 0.73× |
| ONNX Runtime | 1 | 3.69 | 3.87 | 0.55× |
| PyTorch eager | 1 | 5.15 | 5.33 | 0.40× |
The runtime is worth 2.7× at eight threads — and 1.4× at one
The previous project found ONNX Runtime on one thread beating PyTorch eager on eight. That does not repeat here. The difference is the model: that network was 27 k parameters with a 16×16 output, so per-op dispatch overhead was most of the time and a runtime that removes overhead removes most of the time. This one is 14× larger with a 48×48×7 output; the convolutions themselves are the cost, and they cost the same arithmetic in either runtime. The refined rule: the runtime dominates while the model is small enough to be overhead-bound; once it is compute-bound, the thread budget matters as much as the engine. The earlier number was true and its generalisation was not.
Four threads is the knee, and eight is worse for eager
ONNX Runtime: 3.69 → 1.12 → 1.01 ms for 1 → 4 → 8 threads. 3.3× from the first four, 10 % from the next four. PyTorch eager on 8 threads (2.75) is slower than on 4 (2.54) — thread oversubscription on a batch-1 forward costs more than the parallelism returns. A node given 4 cores gets essentially everything this model can deliver; a node given 1 core is 1.8× slower than the classical pipeline it was meant to replace. The deployment question is not "how fast is the detector" but "how fast is the detector on the cores it will actually be given" — and that answer can land on either side of the baseline.
Decode is 15 % of the end-to-end latency at the best setting
0.18 ms against 1.01 ms of model, and it does not shrink with the runtime. On a small, fast model the post-processing stops being free — any deployment budget that lists only the forward pass is under-reporting by that much.
The first measurement was discarded, and why is the most reusable finding here
Step 5 first ran unattended immediately after the 96-minute ablation and recorded ONNX-8t 1.225 ms and eager-8t 3.773 ms. Same weights, same code, same box, re-run idle the next morning: 1.014 and 2.750 — 17 % and 27 % faster. The single-threaded classical baseline moved only 5 % in the same comparison.
WSL exposes no temperature sensor, so the cause cannot be read directly. But the box had just finished 1.5 hours at sustained 8-thread load, and the multithreaded numbers moved while the single-threaded one barely did. Three rules adopted for the rest of the track: a latency number is only reported with the conditions it was taken under; nothing timed directly after a training run is trusted; and both measurements stay in the repository, because a number that was silently replaced is worse than a number that was wrong.
What this does not prove
The limits of a synthetic dataset with perfect labels, stated plainly.
- Occlusion robustness. Only 0.6 % of objects are occluded past half. The 0.910 recall in that band rests on 53 samples. Testing it properly needs a lower camera and a smaller separation, and a regenerated dataset.
- Sim-to-real. Both regimes are rendered. The
easy→hardgap is a measured stand-in for the real gap, not the real gap. - Single seed on the ablation. The eight-cell grid was run once. The large effects (0.147 vs 0.874) are far outside any plausible seed noise; the small ones (+0.016 for geometric augmentation) are labelled suggestive rather than proven.
- Three classes, one camera pose, one placement distribution. Nothing here tests generalisation to new object categories or new viewpoints.
- The classical baseline is one person's best attempt. A stronger classical pipeline exists in principle; the comparison is honest about being between these two implementations.
Running it
Setup, the commands in order, and the runtimes to expect on a CPU.
git clone https://github.com/AungKaung1928/mujoco-clutter-detect.git
cd mujoco-clutter-detect
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
python gen_dataset.py --regime hard --n 200 --smoke # smallest useful run
python gen_dataset.py --regime hard --n 12000
python gen_dataset.py --regime easy --n 12000
python view_dataset.py --regime hard --n 12 # -> out/labels.png
nice -n 10 python train_det.py --regime hard --epochs 25 \
--save runs/det_hard_none.json --ckpt runs/det_hard_none.pt # ~35 min
nice -n 10 python run_ablation.py --epochs 12 --fit-n 6000 \
--out runs/ablation.json # ~95 min
nice -n 10 python export_onnx.py --ckpt runs/det_hard_none.pt \
--regime hard --save runs/onnx.json # idle box only
Or check the claims without regenerating anything
./verify.sh
Five tiers. The first two need no dataset and no weights — the fifteen hand-computed AP cases and the
encode/decode inverse check, which are the two places a silent bug would invalidate every number below.
They take under a minute. The later tiers need data/ (2.9 GB, gitignored) and
runs/*.pt, and say how to regenerate them rather than failing.
The script pins MUJOCO_GL=glfw and the thread count, because both change the numbers.
The short version
The whole project compressed into one paragraph.
Multi-object detection on a simulated tabletop, built metric-first. Labels come from MuJoCo's segmentation buffer, so they are exact under perspective and occlusion; the occlusion fraction needs a second pass, which an exact pruning rule cuts by half. A COCO-style AP@[.5:.95] harness is written by hand and proved against fifteen paper-derived cases plus end-to-end controls. A fully fitted classical pipeline — background estimate, seed-counted watershed, nine shape features, class-balanced logistic regression with a background class — scores mAP 0.532, and the four findings under it name exactly where hand-designed features stop. A 381 k-parameter anchor-free heatmap detector then scores 0.911, with the per-class spread collapsing from 0.304 to 0.006 and the classical occlusion cliff absent. An eight-cell ablation shows training on randomised appearance is what transfers: un-randomised collapses to 0.15 under appearance shift, photometric augmentation recovers it only to 0.52, and on top of the randomiser it adds 0.0007. Through ONNX Runtime the detector runs end to end in 1.20 ms against the classical 2.13 ms at identical mAP — and the first latency measurement was discarded for having been taken on a hot box, with both numbers kept in the repository.