← Aung Kaung Myat/WalkthroughsRepository ↗
CPU-only robot-learning track · block 1 · repo mujoco-cube-pose-cnn

Sub-Millimetre Cube Pose

Given one 128×128 photograph of a cube on a table, where is it and which way is it turned? A small convolutional network answers to 0.59 mm and 0.21°. The point of the project is not that number — it is the four measurements around it that say how much of that win was really the network, and how much was arithmetic anyone could have done by hand.

PyTorch (CPU)MuJoCo 3.12OpenCV ONNX RuntimeNumPyno CUDA anywhere
Best model
27 k parameters
Position error
0.59 mm median
Latency
0.23 ms · 1 thread
Training time
≈4 min per run
Dataset
12 000 rendered images
Hardware
laptop CPU, no GPU
01

In plain words

The problem in one picture: a rendered cube, and a network that has to say where it sits and how it is turned.

A robot that is going to pick something up needs to know exactly where that thing is and how it is rotated. This project builds the smallest honest version of that problem: a single red-ish cube on a flat table, one camera looking straight down, and the question "give me x, y and the rotation angle".

There are two obvious ways to answer it.

  1. Write the rule yourself. The cube is a coloured blob on a plain table. Threshold the image on colour, find the blob, take its centre, fit the smallest rectangle around it and read off the angle. Thirty lines of OpenCV, no training, runs in 0.05 ms.
  2. Train a network. Render thousands of labelled images, feed them to a small convolutional network, and let it learn the mapping.

Almost every tutorial does option 2 and declares victory. This project does option 1 first, and then does it a second time, better, before the network is allowed to compete. That ordering is the whole experiment, and it changes the answer: the first hand-written baseline loses to the network by 4.5×, and after one calibration constant is fitted to it, more than half of that gap disappears.

The result the project exists to produce

Building the strong baseline after the model would have inflated the network's apparent advantage by roughly 2×. What the network actually wins is not the typical case — it is the tail: the classical method fails rarely and badly, the network fails never and mildly.

Why simulation

Labelling real photographs by hand is slow, and a human-placed label of "the cube centre is at x = 4.2 cm" is not accurate to a millimetre. In MuJoCo the numbers go the other way: the position and angle are typed in, and then the image is rendered from them. The label is not an annotation, it is the input. Twelve thousand perfectly labelled images take about twelve seconds to generate.

Terms used on this page

MuJoCoThe physics and rendering engine. Here it is used only to draw labelled images, not to simulate motion.
posePosition plus orientation. For this cube that is x, y, z and a yaw angle.
yaw foldA cube looks identical every 90 degrees, so yaw can only be defined modulo 90 — the metric has to fold before comparing.
spatial soft-argmaxA layer that converts a heatmap into an (x, y) coordinate while staying differentiable, so gradients still flow.
baselineA simpler method the network has to beat. Without one, an error number means nothing.
MAEMean absolute error — the average size of the mistake, in millimetres or degrees.
val splitData held back from training and looked at once, at the very end, so the number is not tuned against.
ONNXA portable model format. The exported file runs without PyTorch, which is what deployment needs.
02

The task, defined precisely

What pose means here, what the network outputs, and the symmetry that makes yaw awkward.

Position

The cube centre, in metres, in the table frame, sampled uniformly in ±0.075 m. The camera is directly overhead and its image plane is parallel to the table, which means the mapping from pixels to metres is exactly affine — no perspective correction, no distortion model. At 128 px the scale is 367.9 px/m, i.e. one pixel is 2.72 mm.

Rotation, and the trap in it

A cube seen from directly above shows a square. A square looks identical at 5° and at 95°. So the rotation is only observable modulo 90°, and asking a network for "the angle in degrees" is asking it for something that does not exist.

Worse, regressing a raw angle puts a discontinuity in the middle of the label range: 89° and 1° are nearly the same pose but numerically far apart, so the network hedges toward the middle and is systematically wrong near the boundary. The fix used here — and it is standard practice in pose estimation — is to predict a point on a circle instead of an angle:

target = (sin 4θ, cos 4θ)          # YAW_FOLD = 4

Multiplying by four folds a full turn onto four identical quarters, so θ and θ+90° map to exactly the same target and there is no seam anywhere. Decoding runs it backwards: θ = atan2(s, c) / 4, wrapped into [0°, 90°).

Crucially, OpenCV's minAreaRect has exactly the same ambiguity. Both methods are scored under the same 90° fold, by the same function, so the comparison is fair by construction rather than by assertion.

Raw angle target — a cliff at the fold 90° same pose, opposite ends of the range (sin 4θ, cos 4θ) — continuous, no seam θ = 5° θ = 95° land on the same point
Left: what a raw-degree target does to the loss surface. Right: the encoding actually used.

Two appearance regimes

RegimeWhat variesWhat it tests
easyNothing. Fixed cube colour, fixed light position and intensity, fixed table shade.Whether a hand-written rule is simply the better engineering choice when the world is controlled.
hardCube hue uniform over the whole colour wheel, saturation and value randomised; table shade randomised; light position and intensity randomised.Whether a fixed prior survives appearance change — the miniature version of the sim-to-real question.

Note what is not randomised: geometry, camera pose, object size. Only appearance moves. That keeps the comparison to a single axis, so a result can be attributed.

03

The pipeline

The order the programs run in, from scene file to exported model.

step 1gen_dataset.py Render scenes in MuJoCo, write labels straight out of simulator state.
verifyview_dataset.py Draw the labels back onto the images. If markers miss the cubes, everything downstream is meaningless.
step 2baseline_cv.py Two hand-written OpenCV rules. No learning, no training data.
step 3model.py + train.py One conv trunk, two competing heads. 40 epochs on 8 CPU threads.
step 4baseline_v2.py Refit the classical method with one scalar learned on train — the fair fight.
step 5export_onnx.py Export, then prove the export reproduces both the tensors and the task metrics.
Sequence matters here

Steps 2 and 4 are the same classical method. It appears twice on purpose — once before the network so the network has a target, and once after so the network's win can be attributed. Step 4 is the one that changed the conclusion.

04

Every file, and what it is for

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

FileLinesWhat it does
common.py116 The single source of truth. Scene constants that must match the MJCF; the yaw fold and its inverse; the pixel↔world projection; the dataset loader; and — most importantly — pose_metrics(), the one scoring function used by every method. If the classical baseline and the network were scored by two implementations they would drift apart, and the comparison the whole project exists to make would be worthless.
scene.xml24 The MuJoCo scene. A plane at z = 0, one light, one overhead camera at 0.45 m with a 45° vertical field of view and axes aligned to the world, and a box of half-size 0.03 m on a free joint. Shadows are switched off (shadowsize="0") because shadow mapping is the expensive part of software rendering.
gen_dataset.py132 Renders the dataset. Samples x, y and yaw, writes them into qpos, mutates the cube/table colours and the light according to the regime, calls mj_forward, renders, and stores the image into a memory-mapped .npy array. Writes a meta.json recording image size, px/m, and the label column order. Train and validation splits use different seed streams and are never mixed.
view_dataset.py52 Label verification. Projects each ground-truth label back into pixel space through the analytic overhead projection and draws a marker. If the markers do not land on the cubes, the label convention is wrong — and every number produced afterwards would be consistently, silently wrong.
baseline_cv.py101 Classical baseline, two variants. --method red is a fixed hue window — the naive prior "the cube is red". --method sat keeps anything sufficiently saturated — the weaker, more honest prior "the cube is the most colourful thing on a near-grey table". Both then run: morphological open → largest contour → area floor → moments centroid + minAreaRect angle → convert to world units.
baseline_v2.py60 The fair baseline. Runs the same detector over the train split, fits one scalar radial correction by least squares, applies it unchanged to validation. One learned parameter against the network's 27 000.
model.py86 Two heads on one convolutional trunk: a generic flatten head and a spatial soft-argmax keypoint head. Described in detail below.
train.py145 The training loop. Normalises the four targets onto a common scale, uses SmoothL1 loss, AdamW at 2e-3 with a cosine schedule, batch 64, 40 epochs, no augmentation. Prints per-epoch throughput and flags a sustained >20 % drop, because this machine exposes no CPU temperature sensor. Reports the final epoch and selects on nothing.
export_onnx.py100 Exports to ONNX and then proves the export three ways: max absolute tensor difference on a real batch, the full task metrics recomputed through ONNX Runtime, and batch-1 latency at both 1 and 8 threads.
test_common.py67 Tier-1 tests. The yaw fold, the projection and the metric checked against values derived on paper. Needs no dataset and no weights — it runs from a fresh clone in seconds.
verify.sh55 Reproduces the README's claims from a clean checkout, cheapest tier first. Pins MUJOCO_GL and the thread count because both change the numbers, and skips the data tiers with instructions rather than failing when data/ is absent.
requirements.txt9 Explicit CPU wheels: torch==2.14.0+cpu. Pinned because every number in the README was measured against them.
runs/ · out/ · data/ Three run directories with tracked final.pt checkpoints and metrics.json; qualitative PNG sheets; and the generated dataset, which is gitignored at ~1.3 GB and regenerates in about 30 seconds.
05

Making the data

How the training images are generated in MuJoCo, and what is varied between them.

Where the label comes from

The generator does not run physics. It writes the pose directly into the simulator's state vector and calls mj_forward, which recomputes all the derived quantities without advancing time:

data.qpos[:3]  = [x, y, CUBE_HALF]
data.qpos[3:7] = [cos(yaw/2), 0, 0, sin(yaw/2)]     # quaternion about z
mujoco.mj_forward(model, data)
renderer.update_scene(data, camera="top")
imgs[i] = renderer.render()
labels[i] = [x, y, sin(4·yaw), cos(4·yaw), yaw]

A quaternion about the z axis is (cos(θ/2), 0, 0, sin(θ/2)) — the halved angle is the standard quaternion convention and is the second most common place to introduce a silent factor-of-two error in this kind of code. The label array keeps the raw yaw in a fifth column for debugging only; nothing trains on it.

Randomising appearance

On hard, four things move per image:

cube hue
Uniform over the full wheel, converted through a hand-written HSV→RGB (six-case piecewise function, six lines). Saturation 0.6–1.0, value 0.5–1.0 — vivid, but arbitrary in colour.
table shade
Grey level 0.25–0.85, with a slight independent tint per channel so it is near-grey rather than exactly grey.
light position
x and y in ±0.5 m, height 0.9–1.4 m. This moves the shading direction on the cube's side faces, which is what a global brightness change cannot imitate.
light intensity
Diffuse 0.4–1.0.

These are written directly into model.geom_rgba, model.light_pos and model.light_diffuse — fields of the compiled model, mutable at run time, so one XML covers every appearance.

Memory-mapped storage

Twelve thousand 128×128 RGB images is about 590 MB. The generator opens the array with np.lib.format.open_memmap and writes into it row by row, so the whole set never has to fit in RAM at once. Training loads it the same way and sorts each batch's indices before slicing, which turns random access into sequential reads from the file — a small change that matters when the data is on disk rather than in memory.

Cost, measured

900–1080 images per second at 128 px, so 12 000 images takes about twelve seconds. Rendering is not the bottleneck in this project, which is worth stating because it usually is: on this machine OpenGL is software-rendered (llvmpipe under WSL) and shadows are disabled precisely to keep it that way.

06

The baselines — and why there are two of them

The classical-vision baseline, the bias found in it, and the one-line correction that removed it.

Version 1: two hand-written priors

Both variants share the same pipeline and differ only in the mask:

hsv = cvtColor(img, RGB2HSV)
m   = MASKS[method](hsv)                       # 'red' or 'sat'
m   = morphologyEx(m, MORPH_OPEN, ones(3,3))   # kill speckle
c   = max(findContours(m), key=contourArea)    # largest blob
if contourArea(c) < 30: return None            # detection failed
(u,v),(w,h),ang = minAreaRect(c)
x, y = pixel_to_world(u, v, size)
yaw  = mod(radians(-ang), pi/2)                # image v grows down → sign flip

The two priors behave very differently once appearance moves:

PriorDetection rate on hardWhat happened
red — fixed hue window10.9 % Missed 89 % of the set, because the cube is no longer red. Its accuracy on the 11 % it did find is unchanged, so its own error metric looks perfectly healthy.
sat — most saturated object100 % Fully robust to the appearance shift, with the same error as on easy. No network involved.
The failure mode this exposes

A wrong prior does not raise an error. It returns fewer answers, and the answers it does return are fine. Detection rate and accuracy have to be reported together or the failure is invisible — which is exactly what happens when a paper reports only mean error.

Version 2: one scalar, fitted honestly

Version 1 has a systematic +2.98 mm radial bias — every prediction sits slightly too far from the table centre. The cause is geometric, not statistical: an overhead camera does not only see the cube's top face, it also sees the outward-facing side walls. The silhouette is therefore wider on the outward side, and its centroid sits outside the true projected centre.

table, z = 0 camera at table centre this wall is visible from here true centre silhouette centroid, +2.98 mm outward
Not noise — a repeatable geometric offset that grows with distance from the optical axis. A hand-written centroid cannot know about it. A network trained on labels absorbs it for free.

That is not a fair fight. The network saw 12 000 labelled images; the baseline saw none. So the baseline is given the same privilege and no more: fit exactly one number, a radial scale correction, on the train split, and apply it unchanged to validation.

k = argmin ‖ k·r_measured − r_true ‖     # closed form, one line
  = (r_meas · r_true).sum() / (r_meas²).sum()
  = 0.951                                  # a −4.9 % radial correction

The bias goes from +2.98 mm to +0.01 mm, and the median error falls from 3.41 mm to 1.91 mm — 53 % of the whole gap to the network, closed by a single scalar and no training loop.

07

The network — two heads, one question

Two output heads compared on identical data — flatten against spatial soft-argmax.

Why the usual head cannot be used

Nearly every image classifier ends with global average pooling: average the feature map over space, then classify. That works because "is this a cat" does not depend on where the cat is — translation invariance is a feature.

A pose regressor needs the opposite. The answer is the location. Average over space and you have thrown away the thing you were asked for. So the head has to preserve spatial information, and there are two ways to do that.

HeadTrunkMechanismParams
flat4 stride-2 blocks, 128×128 → 8×8×128 Flatten the whole feature map to 8192 values and put a single linear layer on it. No prior at all — the network has to discover the geometry of a raster from scratch.130 k
softargmax3 stride-2 blocks, 128×128 → 16×16×64 A 1×1 convolution produces 16 keypoint channels; each is turned into a probability map by a spatial softmax; the expected (u, v) of that map is computed. The layer's output is a coordinate.27 k

Both then produce four numbers: normalised x, normalised y, sin 4θ, cos 4θ.

How spatial soft-argmax works

Given one feature channel of shape (H, W):

  1. Divide by a learned temperature and take a softmax over all H×W positions. The channel is now a probability distribution over pixels — "where in this image is my feature".
  2. Build two coordinate grids, u from −1 to 1 left-to-right and v from −1 to 1 top-to-bottom.
  3. Take the expectation: E[u] = Σ p·u, E[v] = Σ p·v.

The result is a differentiable, sub-pixel coordinate. The temperature is a learnable parameter stored as its logarithm, so the exponential keeps it strictly positive without a constraint — a low temperature gives a sharp, argmax-like peak; a high one averages broadly.

This is the keypoint front end used by visuomotor policies — the deep-spatial-autoencoder line of work — and the reason to implement it here rather than read about it is that the project measures what the prior is worth: 5× fewer parameters, 2.6× faster, and more accurate, with a better tail.

Training details that are load-bearing

target scaling
Position is divided by XY_RANGE so it lands in [−1, 1], matching the sine and cosine which already do. Without this, 0.075 m of position error and 1.0 of sine error would be weighted a thousand to one and the model would learn nothing but yaw.
SmoothL1(β=0.1)
Quadratic near zero, linear far from it. Less sensitive to outliers than mean squared error while still having a well-behaved gradient at the minimum.
no augmentation
Deliberately absent. This run measures what the plain architecture does on the plain data, so that a later augmentation result has something honest to be compared against.
final epoch reported
Not the best epoch. Selecting the lowest-validation epoch and then reporting that epoch's validation number is a small but real leak — the split you choose on cannot also be the split you report on. With a cosine schedule the last epoch is where the weights settle anyway.
throughput logging
WSL exposes no CPU temperature sensor, so the only available throttling signal is throughput decay. The loop prints images per second every epoch and prints a warning when the rate falls more than 20 % below epoch 1. Measured: ~1750 img/s early, decaying to ~1300 in the second half of a 40-epoch run.
A correction the author left in the README rather than deleting

The first version of train.py kept the lowest-validation epoch and reported it. Two of the three runs had already settled on the final epoch, so their numbers were unchanged; the third was retrained and moved from 0.47 mm to 0.48 mm. The leak cost nothing measurable — which is the point of checking rather than assuming.

08

Export, and what counts as proving it

ONNX export, the numerical tolerance it is checked against, and what a passing check does not prove.

ONNX is a portable graph format: train in PyTorch, run anywhere — including a C++ node on an embedded board, with no Python and no PyTorch installed. The file appearing on disk is not the finish line, though, because a graph can be numerically close and still have a permuted output order, a wrong normalisation, or a silently dropped layer.

So export_onnx.py checks three things:

  1. Tensor agreement. A real 256-image batch through both engines. Max absolute difference: 6.6e-7.
  2. Task-level agreement. The full validation split decoded exactly as training decodes it, scored by the same pose_metrics. Every figure identical to two decimals.
  3. Latency at batch 1, at both 1 and 8 threads. Batch 1 is the only batch size a real perception node ever sees, and a ROS 2 node does not get the whole CPU.
Environment note worth knowing

On torch 2.14 torch.onnx.export defaults to the dynamo exporter, which imports onnxscript and fails if it is absent. Passing dynamo=False selects the TorchScript exporter and needs no extra dependency — worth knowing before adding a package to a locked-down machine for no reason.

09

Results

The measured numbers: position error, yaw error, and runtime.

Validation split, n = 2000, independent seed stream, every row scored by the same function. "p95" is the 95th percentile — the tail, not the typical case.

MethodParamsRegimeDetect Median xyp95 xyMedian yaw p95 yawRadial biasLatency
OpenCV, fixed red hue0hard10.9 %3.48 mm6.070.19°1.24+2.96 mm0.06 ms
OpenCV, saturation0easy100 %3.54 mm5.540.18°1.50+3.01 mm0.05 ms
OpenCV, saturation0hard100 %3.41 mm5.570.19°1.66+2.98 mm0.05 ms
v2 · OpenCV + 1 calibrated scalar1easy100 %1.94 mm2.840.18°1.50+0.16 mm0.04 ms
v2 · OpenCV + 1 calibrated scalar1hard100 %1.91 mm2.890.19°1.66+0.01 mm0.06 ms
CNN, flatten head130 khard100 %0.75 mm2.000.28°0.94+0.05 mm1.70 ms
CNN, soft-argmax head27 keasy100 %0.48 mm1.040.16°0.51−0.01 mm0.65 ms
CNN, soft-argmax head27 khard100 %0.59 mm1.320.21°0.66−0.02 mm0.65 ms
same weights, ONNX Runtime, 1 thread27 khard100 %0.59 mm1.320.21°0.66−0.02 mm0.23 ms
same weights, ONNX Runtime, 8 threads27 khard100 %0.59 mm1.320.21°0.66−0.02 mm0.12 ms
Reading the pixel floor correctly

One pixel is 2.72 mm, and an early draft of the README wrote that down as a hard accuracy floor. It is not. It bounds a single-pixel measurement — but the cube covers roughly 480 silhouette pixels, and averaging over them shrinks the quantisation term by about √N, giving an aggregate floor near 0.12 mm. Both the calibrated baseline at 1.91 mm and the network at 0.59 mm legitimately sit below one pixel. Sub-pixel accuracy from a many-pixel object is expected, not a bug; reasoning about a floor from the wrong unit of measurement is how a correct result gets discarded as an error.

10

What the numbers actually say

Reading those results honestly — where the gain came from, and where it did not.

01

A wrong prior fails silently, and its own metric hides it

"The cube is red" misses 89 % of hard while its accuracy on the surviving 11 % looks unchanged. Detection rate and accuracy must always be reported together, or the failure is invisible.

02

Robustness to appearance randomisation was free — and classical

Swapping "red" for "most saturated thing on a near-grey table" gives 100 % detection on hard with the same error as easy. Domain-randomisation robustness is therefore not what justified the network here. Picking a better hand-written feature was cheaper, and it worked.

03

Half the network's apparent win was calibration, not perception

Fitting one scalar on the train split takes the baseline from 3.41 mm to 1.91 mm — 53 % of the gap, closed by a single number. Building the strong baseline second would have inflated the network's result by roughly 2×.

04

What the network genuinely wins is the tail

After calibration the baseline's median yaw error (0.19°) is slightly better than the network's (0.21°) — minAreaRect is near-exact on a clean mask. But its p95 is 1.66° against 0.66°, and p95 position is 2.89 mm against 1.32 mm. The classical method fails rarely and badly; the network fails never and mildly. For anything feeding a controller, the tail is the number that matters.

05

The architectural prior beat the parameter count

Flatten head: 130 k parameters, 1.70 ms, 0.75 mm. Soft-argmax head: 27 k parameters, 0.65 ms, 0.59 mm with a better tail. 5× smaller, 2.6× faster, and more accurate. Handing the network a coordinate beats making it learn what a coordinate is from a flattened grid.

06

The classical method is still 11× faster

0.06 ms against 0.65 ms per image. If 1.9 mm is inside tolerance, the network is the wrong engineering choice regardless of being three times more accurate.

07

The runtime mattered more than the model

Identical weights, identical outputs: PyTorch eager needs 0.65 ms on eight threads; ONNX Runtime needs 0.23 ms on one and 0.12 ms on eight. That is ~2.8× faster on one eighth of the cores, with nothing about the network changed. Before optimising an architecture for edge latency, check whether the framework is the cost. Here it was most of it — and the classical speed advantage shrinks from 11× to 3.8× as a result. A 116 KB ONNX file at 0.23 ms is a real deployment target for a C++ node.

11

Running it

Setup, the commands in order, and the runtimes to expect on a CPU.

git clone https://github.com/AungKaung1928/mujoco-cube-pose-cnn.git
cd mujoco-cube-pose-cnn
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt

python gen_dataset.py --regime easy --n 12000     # ~12 s
python gen_dataset.py --regime hard --n 12000
python view_dataset.py --regime hard              # check the labels first

python baseline_cv.py --regime hard --method red  # the naive prior
python baseline_cv.py --regime hard --method sat  # the honest prior
python baseline_v2.py --regime hard --method sat  # the calibrated one

python train.py --regime hard --head softargmax --epochs 40
python export_onnx.py --regime hard --head softargmax

Or check every claim without retraining

./verify.sh

Three tiers, cheapest first. Tier 1 is test_common.py — the yaw fold, the projection and the metric against paper-derived values, no data needed. Tiers 2 and 3 need data/ (gitignored, ~30 s to regenerate) and re-score the tracked checkpoints through both PyTorch and ONNX Runtime. The script defaults MUJOCO_GL to glfw and pins the thread count, because both change the numbers.

Measured environment

Intel Core Ultra 5 225H, 14 cores, no GPU, WSL2, software OpenGL (llvmpipe). Rendering 900–1080 img/s at 128 px; training ~1750 img/s on 8 threads, decaying 25–35 % in the second half of a run as the CPU hits its sustained power limit.

12

The short version

The whole project compressed into one paragraph.

Estimate the planar pose of a cube from one 128×128 overhead render, and measure when a learned model beats hand-written geometry and when it does not. Twelve thousand images per regime with labels read straight out of MuJoCo. Two hand-written OpenCV baselines, then a third with one scalar calibrated on train — which closes 53 % of the gap and shows the first baseline had been unfairly weak. A 27 k-parameter soft-argmax keypoint head reaches 0.59 mm median and 1.32 mm p95 on randomised appearance, beating a 130 k-parameter flatten head on every axis. Exported to ONNX and verified twice — tensors and task metrics — where the same weights run 2.8× faster on one thread than PyTorch eager on eight. Every claim re-runnable from a clean clone through a tiered verify.sh, and the two corrections made along the way are left in the record rather than edited out.