← Aung Kaung Myat/WalkthroughsRepository ↗
CPU-only robot-learning track · block 2 · repo mujoco-clutter-detect

Tabletop Clutter Detector

Three to six objects of three kinds, jumbled on a table, seen from a tilted camera. Find all of them and say what each one is. A hand-written scoring harness, a fully fitted classical pipeline, and a 381 k-parameter anchor-free network — built in that order so the network's win could be attributed rather than assumed.

PyTorch (CPU)MuJoCo 3.12OpenCV ONNX RuntimeCOCO mAP, hand-written domain randomisationno CUDA anywhere
Classical pipeline
mAP 0.532
Learned detector
mAP 0.911
Parameters
380 631
End-to-end latency
1.20 ms · ONNX, 8 thr
Dataset
28 000 images · 2.9 GB
Metric tests
15, derived on paper
01

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:

  1. Build the dataset and check the labels are right before anything reads them.
  2. Build the metric and prove it against fifteen cases derived on paper — before there is anything to score.
  3. Build the strongest classical pipeline you can, including a fitted classifier, and record its number.
  4. Then train the network, and report the difference.
Why that order, specifically

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

occlusionOne object hiding part of another. The reason a clutter dataset is harder than a tidy one.
IoUIntersection over union — overlap of two boxes divided by their combined area. The standard test for whether a prediction matches a label.
mAP@[.5:.95]Average precision averaged over ten IoU thresholds from 0.50 to 0.95. Strict: a loose box scores at the low thresholds only.
anchor-freePredicts object centres directly on a grid, instead of scoring thousands of pre-set candidate boxes.
stride 4The output grid is one quarter of the input resolution — one cell per 4x4 pixel block.
focal lossA loss that down-weights the easy, empty background cells so they cannot drown out the few cells holding objects.
NMSNon-maximum suppression — discarding duplicate boxes that fire on the same object.
domain randomisationVarying colours, lighting and textures while rendering, so the model cannot learn to depend on any of them.
02

The five steps and what each one produced

The five build steps, and the concrete artefact each one produced.

step 1Scene & dataset Two cameras, exact labels read out of the renderer's segmentation buffer, occlusion measured by a pruned second pass. 28 000 images · 2.9 GB · 108 s per 12 k
step 2aThe AP metric COCO-style AP@[.5:.95] written by hand, not imported. Fifteen paper-derived unit cases plus end-to-end controls. 436 ms for 2000 images
step 2bClassical baseline Background estimate → threshold → seed-counted watershed → nine shape features → multinomial logistic regression. mAP 0.532 · 2.02 ms
step 3Anchor-free detector Centre heatmap + size + offset at stride 4, read by peak-picking. No anchors, no IoU suppression. mAP 0.911 · 381 k params
step 4Augmentation ablation Two regimes × four augmentations, one identical budget, every cell scored on both validation sets. +0.0007 mAP · 96 min
step 5ONNX & latency Export, verify end to end through the same metric, then time on an idle box — and throw away the first measurement. 1.20 ms vs classical 2.13 ms
03

Every file, and what it is for

Every source file, how big it is, and the single job it owns.

Core

FileLinesWhat it does
common.py139 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.xml39 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.py239 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.py69 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

FileLinesWhat it does
ap.py212 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.py125 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.py116 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

FileLinesWhat it does
baseline_cv.py383 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

FileLinesWhat it does
detector.py227 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.py273 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.py106 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.py81 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.py188 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

FileWhat 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.
04

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.

Overhead — no overlap is possible same size regardless of position · flat · separable by blob-finding Tilted 39° — occlusion and perspective return far objects smaller · near objects hide far ones · silhouettes change with yaw
The overhead camera is kept in the scene anyway — it is the only view with an exact pixel↔world map, so it verifies the labels and supplies the world coordinates later blocks need. Keeping both costs nothing and sets up a later experiment: train under one camera pose, evaluate under another.

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

only boxes get a yaw
A sphere has no observable orientation, and a cylinder's rotation about its own axis is unobservable too. Labelling them would train the network to fit noise. yaw is NaN for both, and the loss masks it.
192 px, not 128
Objects are a median 28 px on a side. At 128 px they would be ≈19 px — below COCO's "small object" threshold, and the task would be measuring the renderer rather than the detector.
table half-size 1.5 m
Not 0.5 m. The tilted camera's top frame ray only meets the ground 1.15 m out; a smaller plane leaves a constant black band across the top of every image — dead pixels, and a trivial cue for the network to latch onto.
no physics
Objects are placed, not simulated. No mj_step; poses are written directly and mj_forward recomputes the derived state.
0.075 m minimum separation
Objects can touch but not interpenetrate. This has a consequence used later: no two object centres can land in the same stride-4 cell, which is what makes peak-picking exact.

What was generated

SplitImagesObjectsObj/imgOccluded <0.9<0.5img/s
hard/train12 00053 9614.5010.0 %0.6 %111.1
hard/val2 0008 9924.5010.2 %0.6 %115.1
easy/train12 00053 8354.4910.1 %0.5 %106.4
easy/val2 0009 0494.529.8 %0.5 %103.6
A limitation stated before any detector existed

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.

05

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:

  1. IoU — intersection over union — measures how well a predicted box overlaps a true one. 1.0 is perfect, 0 is no overlap.
  2. 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.
  3. That walk traces a precision–recall curve. Average precision is the area under it, sampled at 101 fixed recall levels.
  4. 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

CaseAP@0.5Why
1 object, 1 correct hit, then 1 false positive1.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 hit0.500 Identical detections, identical recall, half the AP. Ranking is part of the score.
1 of 2 objects found, no false positives0.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

InputmAPExpected
ground truth fed back as detections1.0000exactly 1
correct boxes, classes shuffled0.1130≈ 1/9 — precision ⅓ × recall ⅓
random boxes, correct classes0.00000
keep 75 % / 50 % / 25 % of hits0.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.

ShiftPredicted IoU (w = 28)mAPAP50
0 px1.0001.00001.0000
2 px0.8670.73091.0000
4 px0.7500.46391.0000
7 px0.6000.14150.6873
12 px0.4000.00570.0480
The argument for AP@[.5:.95] in one row

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

size bands
Terciles of this dataset (cut at 25.5 and 31.9 px), not COCO's absolute 32/96 px, which were chosen for ~640 px images. At 192 px every object here is "small" and COCO's stratification would carry no information. The terciles show what a single number hides: a uniform 3 px shift costs mAP 0.517 on the smallest third against 0.701 on the largest, because IoU is scale-relative.
visibility bands report recall, not AP
A visibility band can be applied to ground truth but not to a detection — the detector never says how occluded it thought an object was. So unmatched detections cannot be attributed to a band, precision is undefined, and an "AP for heavily occluded objects" computed this way would be an artefact of where the other bands' false positives landed.

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.

06

The classical baseline

The classical baseline: watershed segmentation, nine hand-designed shape features, and a linear classifier.

The pipeline

  1. 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".
  2. Threshold the residual. What remains after subtracting that background is object.
  3. Split touching regions with a distance-transform watershed — but only where it is needed (see below).
  4. Describe each region with nine hand-designed shape features: log area, scale, aspect ratio, extent, circularity, solidity, normalised vertical position, top fill, elongation.
  5. 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.
Why the classifier is linear on purpose

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

MethodRegimemAPAP50AP75Agnostic mAPDet ratems/img
otsueasy0.44600.51780.44880.51380.54820.36
otsuhard0.14300.21820.14010.16990.23160.19
bgsubeasy0.50020.56390.49620.53070.55241.04
bgsubhard0.41140.49790.41280.47610.52511.12
bgsub + watershedeasy0.58950.76720.58530.66580.71641.88
bgsub + watershedhard0.53220.69790.56370.61080.68472.02
01

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.

VariantRegions/imgDet rateAgnostic mAPIoU ≥ 0.9IoU < 0.5
bgsub3.400.52110.374647.3 %38.6 %
watershed on every region4.290.49330.30046.3 %25.0 %
watershed on multi-seed regions only4.290.70670.552552.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.

02

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.

03

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.

04

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.

07

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

HeadChannelsPredictsLoss
hm — heatmap3One map per class. A peak means "an object centre is here".Focal loss on a Gaussian-splatted target
wh — size2Width and height in cells, read only at the peak.Masked L1
off — offset2The 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

bias starts at −4.6
That is 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.
3×3 max-pool is the NMS
A cell survives only if it is the maximum of its own 3×3 neighbourhood. That removes the ridge of near-peak cells the Gaussian target deliberately creates. It is not an approximation here: the 0.075 m minimum separation guarantees no two object centres land in the same cell, so peak-picking is exact and there is no IoU-based suppression in the pipeline at all.
the larger quadratic root
The Gaussian radius comes from solving a quadratic for the overlap constraint. The CornerNet and CenterNet reference implementations take the larger root, which is not what the algebra calls for. The smaller root was implemented first, and measured: at stride 4 a 28 px object is 7 cells across, and the smaller root gives r = 0.57, which floors to 0 and collapses the soft target back to a single hot pixel — destroying the only thing it exists for. The larger root gives r = 1.91. The discrepancy is recorded in the source rather than quietly papered over.

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

MetricClassical bgsub+wsDetectorChange
mAP@[.5:.95]0.53220.9107+0.379
AP500.69790.9899+0.292
AP750.56370.9896+0.426
class-agnostic mAP0.61080.9099+0.299
detection rate @ 0.50.68470.9433+0.259
latency (eager, in-process)2.02 ms3.87 ms1.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.

01

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.

02

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.

03

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.

04

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.

05

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.

08

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 easyhard column is the sim-to-real question in miniature — easy is a simulator nobody randomised, hard is the world it has to survive.

Trained onAugmentationeasy valhard valTrain s
easynone0.90130.1465595
easyphotometric0.89450.5214783
easygeometric0.90930.1808608
easyboth0.90600.5065737
hardnone0.86830.8735589
hardphotometric0.86430.8742782
hardgeometric0.88440.8893648
hardboth0.88060.8857839
01

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.

02

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.

03

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.

04

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.

05

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.

06

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.

09

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.

CheckResult
max |torch − onnx| per headheatmap 2.4e−6 · size 1.1e−5 · offset 4.8e−6
mAP, first 500 hard/val images, PyTorch0.9127
mAP, same 500 images, ONNX Runtime0.9127
graph1.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.

RuntimeThreadsModel msEnd to end msvs classical 2.13 ms
ONNX Runtime81.011.201.8× faster
ONNX Runtime41.121.301.6× faster
PyTorch eager42.542.720.78×
PyTorch eager82.752.930.73×
ONNX Runtime13.693.870.55×
PyTorch eager15.155.330.40×
01

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.

02

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.

03

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.

04

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.

10

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 easyhard gap 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.
11

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.

12

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.