At a glance要点
Where the error went as each method was added, and what the network costs — before any detail.手法を加えるごとに誤差がどう下がったか、そしてネットワークのコスト。詳細はその後で。
Numbers as a table数値を表で見る
| Method (hard)手法(hard) | Paramsパラメータ数 | Median xyxy 中央値 | p95 xy | Median yawyaw 中央値 | p95 yaw | Latency |
|---|---|---|---|---|---|---|
| OpenCV, saturationOpenCV、彩度 | 0 | 3.41 mm | 5.57 | 0.19° | 1.66 | 0.05 ms |
| v2 · OpenCV + 1 calibrated scalarv2 · OpenCV+calibrationしたスカラー1つ | 1 | 1.91 mm | 2.89 | 0.19° | 1.66 | 0.06 ms |
| CNN, flatten head | 130 k | 0.75 mm | 2.00 | 0.28° | 0.94 | 1.70 ms |
| CNN, soft-argmax head | 27 k | 0.59 mm | 1.32 | 0.21° | 0.66 | 0.65 ms |
| same weights, ONNX Runtime, 1 thread同じ重み、ONNX Runtime、1スレッド | 27 k | 0.59 mm | 1.32 | 0.21° | 0.66 | 0.23 ms |
- 010.59 mm median, 1.32 mm p95, 0.21° yaw. A 27 k-parameter soft-argmax CNN on randomised appearance, trained in ≈4 min per run on a laptop CPU from 12 000 rendered images per regime, no GPU.中央値0.59 mm、p95 1.32 mm、yaw 0.21°。27 kパラメータのsoft-argmax CNNを、見た目をランダム化した条件で評価。学習は条件ごとに12 000枚のレンダリング画像から、ノートPCのCPUだけで1回約4分。GPUなし。
- 02Half the win was one scalar, not learning. A single radial correction (k = 0.951) fitted on train takes the OpenCV baseline from 3.41 mm to 1.91 mm — 53 % of the gap to the network, with no training loop.勝ちの半分は学習ではなくスカラー1つ。trainで求めた半径方向の補正1つ(k = 0.951)で、OpenCV baselineは3.41 mmから1.91 mmに。ネットワークとの差の53 %が学習ループなしで埋まりました。
- 03Classical still wins median yaw and speed. 0.19° vs 0.21° median yaw and 0.06 ms vs 0.65 ms per image; the network's real gain is the tail — p95 yaw 0.66° vs 1.66°, p95 xy 1.32 vs 2.89 mm — so if 1.9 mm is in tolerance the network is the wrong choice.yawの中央値と速度では古典的手法が依然優位。yaw中央値は0.19° vs 0.21°、1枚あたり0.06 ms vs 0.65 ms。ネットワークが本当に勝つのはtail(p95 yaw 0.66° vs 1.66°、p95 xy 1.32 vs 2.89 mm)で、1.9 mmが許容範囲ならネットワークは誤った選択です。
- 04Architectural prior beat parameter count. Soft-argmax head 27 k / 0.65 ms / 0.59 mm against the flatten head's 130 k / 1.70 ms / 0.75 mm — 5× smaller, 2.6× faster, more accurate on every axis.パラメータ数よりアーキテクチャのpriorが効いた。soft-argmax headは27 k / 0.65 ms / 0.59 mm、flatten headは130 k / 1.70 ms / 0.75 mm。5分の1のサイズで2.6×速く、すべての軸でより高精度。
- 05The runtime mattered more than the model. Same weights: PyTorch eager 0.65 ms on 8 threads, ONNX Runtime 0.23 ms on 1 thread and 0.12 ms on 8 (~2.8×); the export is checked on tensors and task metrics, and every claim re-runs from a clean clone through
verify.sh.モデルより実行ランタイムの方が効いた。同じ重みで、PyTorch eagerは8スレッドで0.65 ms、ONNX Runtimeは1スレッドで0.23 ms、8スレッドで0.12 ms(約2.8×)。exportはテンソルとタスク指標の両方で検証し、すべての主張はクリーンなcloneからverify.shで再実行できます。
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.
問題を一言でいえば、レンダリングされた立方体が1つあり、ネットワークがその位置と向きを答える、というものです。
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".
物を掴むロボットは、対象がどこにあり、どれだけ回転しているかを正確に知る必要があります。 このプロジェクトでは、その問題をごまかしのない最小の形にしました。平らなテーブルに赤っぽい立方体が1つ、 真上から見下ろすカメラが1台、そして「x、yと回転角を答えよ」という問いです。
There are two obvious ways to answer it.
答え方は大きく2通りあります。
- 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.
- Train a network. Render thousands of labelled images, feed them to a small convolutional network, and let it learn the mapping.
- ルールを自分で書く。立方体は無地のテーブル上の色の塊です。色で画像を二値化し、塊を見つけて中心を取り、 外接する最小の矩形を当てはめて角度を読み取ります。OpenCVで30行、学習は不要で、0.05 msで動きます。
- ネットワークを学習させる。ラベル付き画像を数千枚レンダリングして小さな畳み込みネットワークに与え、 対応関係を学習させます。
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.
ほとんどのチュートリアルは2つ目の方法だけで勝利を宣言します。このプロジェクトでは、ネットワークと比較する前に、 まず1つ目の方法を先に実装し、さらにもう一度、より良い形で作り直しました。この順序こそが実験の本体であり、 結論を変えます。最初の手書きbaselineはネットワークに4.5×の差で負けますが、calibration定数を1つ当てはめると、 その差の半分以上が消えます。
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.
強いbaselineをモデルの後に作っていたら、ネットワークの見かけの優位は約2×に膨らんでいたはずです。 ネットワークが実際に勝っているのは典型的なケースではなく、分布の裾(tail)です。古典的手法はまれに大きく外し、 ネットワークは大きく外すことがなく、外れても小さく収まります。
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.
実写真に手でラベルを付けるのは時間がかかり、人が付けた「立方体の中心はx = 4.2 cm」というラベルは ミリ単位では正確ではありません。MuJoCoでは逆の順序になります。位置と角度を先に入力し、そこから画像を レンダリングします。ラベルは後付けの注釈ではなく、入力そのものです。完全に正確なラベル付き画像12 000枚が約12秒で生成できます。
Terms used on this pageこのページの用語
The task, defined preciselyタスクの厳密な定義
What pose means here, what the network outputs, and the symmetry that makes yaw awkward.
ここでのposeの意味、ネットワークの出力、そしてyawを扱いにくくしている対称性について。
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.
テーブル座標系における立方体中心の位置(単位はm)で、±0.075 mの範囲から一様にサンプリングします。 カメラは真上にあり、像面はテーブルと平行です。そのためピクセルからメートルへの変換は厳密にaffineで、 透視補正も歪みモデルも不要です。128 pxでのスケールは367.9 px/m、つまり1ピクセルが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.
真上から見た立方体は正方形に見えます。正方形は5°でも95°でも同じ見た目です。したがって回転は 90°を法としてしか観測できず、ネットワークに「角度を度で答えよ」と求めるのは、存在しないものを求めることになります。
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:
さらに悪いことに、角度をそのまま回帰するとラベルの範囲に不連続点が生じます。89°と1°はほぼ同じ姿勢なのに 数値上は大きく離れているため、ネットワークは中間の値に逃げ、境界付近で系統的に誤ります。ここでの対策は、 姿勢推定では標準的な手法ですが、角度の代わりに円周上の点を予測させることです。
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°).
4倍することで1回転が同一の4つの区間に折り畳まれ、θとθ+90°はまったく同じtargetに対応し、継ぎ目はどこにもありません。
復元はその逆で、θ = atan2(s, c) / 4を[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.
重要なのは、OpenCVのminAreaRectにもまったく同じ曖昧さがあることです。両手法とも同じ関数で、
同じ90°の折り返しのもとで評価されます。そのため比較の公平性は、主張ではなく構造によって保証されています。
Two appearance regimes2種類の見た目の条件
| Regime条件 | What varies変化させるもの | What it tests検証すること |
|---|---|---|
easy | Nothing. 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.環境が管理されている場合、手書きのルールの方が単純にエンジニアリング上の正解なのかどうか。 |
hard | Cube 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.固定された事前知識(prior)が見た目の変化に耐えられるか。sim-to-realの問いの縮小版です。 |
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.
ランダム化していないものにも注意してください。形状、カメラ姿勢、物体のサイズは固定で、変わるのは見た目だけです。 比較の軸を1つに絞ることで、結果の原因を特定できるようにしています。
The pipelineパイプライン
The order the programs run in, from scene file to exported model.
シーンファイルからexport済みモデルまで、プログラムを実行する順序です。
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.
ステップ2と4は同じ古典的手法です。意図的に2回登場させています。1回目はネットワークの前で、目標となる基準を与えるため。 2回目はネットワークの後で、その勝ちが何によるものかを切り分けるためです。結論を変えたのはステップ4でした。
Every file, and what it is for全ファイルとその役割
Every source file, how big it is, and the single job it owns.
各ソースファイルの規模と、それぞれが担う1つの役割です。
| Fileファイル | Lines行数 | What it does役割 |
|---|---|---|
| common.py | 116 | 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.唯一の信頼できる情報源。MJCFと一致させる必要のあるシーン定数、yawの折り返しとその逆変換、
ピクセル↔ワールド座標の射影、データセットのローダー、そして最も重要なpose_metrics()を持ちます。
これはすべての手法が使う唯一の評価関数です。古典的baselineとネットワークを別々の実装で評価すると両者は食い違っていき、
このプロジェクトの目的である比較そのものが無意味になります。 |
| scene.xml | 24 | 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.MuJoCoのシーン。z = 0の平面、照明1つ、高さ0.45 mの真上カメラ1台(垂直画角45°、軸はワールド座標に一致)、
free jointに付いた半辺0.03 mの箱で構成されます。ソフトウェアレンダリングで最も重いのはshadow mappingなので、
影は無効にしています(shadowsize="0")。 |
| gen_dataset.py | 132 | 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.データセットをレンダリングします。x、y、yawをサンプリングしてqposに書き込み、条件に応じて立方体・テーブルの色と照明を変更し、
mj_forwardを呼んでからレンダリングし、画像をmemory-mapの.npy配列に保存します。
画像サイズ、px/m、ラベルの列順を記録したmeta.jsonも書き出します。trainとvalidationは別々のseed系列を使い、混ざることはありません。 |
| view_dataset.py | 52 | 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.py | 101 | 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の2つの版。--method redは固定の色相範囲で、「立方体は赤い」という素朴なpriorです。
--method satは十分に彩度の高いものをすべて残します。「グレーに近いテーブル上で最も色鮮やかなものが立方体」という、
より弱く、より正直なpriorです。その後はどちらも、モルフォロジーのopening → 最大輪郭 → 面積の下限 → モーメントによる重心+
minAreaRectの角度 → ワールド単位への変換、と処理します。 |
| baseline_v2.py | 60 | 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.公平なbaseline。同じ検出器をtrainに対して実行し、半径方向の補正スカラー1つを最小二乗で求め、 そのままvalidationに適用します。学習パラメータはネットワークの27 000個に対して1個です。 |
| model.py | 86 | Two heads on one convolutional trunk: a generic flatten head and a spatial soft-argmax keypoint head. Described in detail below.1つの畳み込みtrunkに2種類のhead。汎用的なflatten headと、spatial soft-argmaxのkeypoint headです。詳細は後述します。 |
| train.py | 145 | 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.学習ループ。4つのtargetを共通のスケールに正規化し、SmoothL1 loss、AdamW(2e-3、cosine schedule)、batch 64、40 epoch、 augmentationなしで学習します。このマシンではCPU温度センサーが読めないため、epochごとのthroughputを出力し、 20 %を超える低下が続くと警告します。報告するのは最終epochで、epochの選択は一切行いません。 |
| export_onnx.py | 100 | 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.ONNXにexportし、3つの方法で正しさを確認します。実データのbatchでのテンソルの最大絶対差、 ONNX Runtimeで再計算したタスク指標一式、そして1スレッドと8スレッドでのbatch 1のlatencyです。 |
| test_common.py | 67 | 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.Tier 1のテスト。yawの折り返し、射影、評価指標を、紙の上で導いた値と照合します。データセットも重みも不要で、 cloneした直後から数秒で実行できます。 |
| verify.sh | 55 | 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.クリーンなcheckoutからREADMEの主張を再現します。軽いtierから順に実行します。
MUJOCO_GLとスレッド数はどちらも数値を変えるため固定し、data/がない場合は失敗させずに、
手順を表示してデータが必要なtierをスキップします。 |
| requirements.txt | 9 | Explicit CPU wheels: torch==2.14.0+cpu. Pinned because every number in the README
was measured against them.CPU版wheelを明示しています(torch==2.14.0+cpu)。READMEの数値はすべてこのバージョンで計測したため、固定しています。 |
| 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.final.pt checkpointとmetrics.jsonをgit管理している3つの実行ディレクトリ、
定性確認用のPNG画像、そして生成済みデータセットです。データセットは約1.3 GBあるためgitignoreしており、約30秒で再生成できます。 |
Making the dataデータの生成
How the training images are generated in MuJoCo, and what is varied between them.
学習画像をMuJoCoでどう生成するか、画像ごとに何を変えるか。
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:
生成スクリプトは物理演算を実行しません。姿勢をシミュレータの状態ベクトルに直接書き込み、
mj_forwardを呼びます。これは時間を進めずに、派生する量をすべて再計算します。
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.
z軸まわりのクォータニオンは(cos(θ/2), 0, 0, sin(θ/2))です。角度を半分にするのはクォータニオンの標準的な規約で、
この種のコードで気づかれない2倍の誤差が入り込む場所として2番目に多いところです。ラベル配列の5列目には生のyawを
デバッグ用にだけ残しており、学習には一切使いません。
Randomising appearance見た目のランダム化
On hard, four things move per image:
hardでは、画像ごとに次の4つを変化させます。
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.
これらはmodel.geom_rgba、model.light_pos、model.light_diffuseに直接書き込みます。
いずれもコンパイル済みモデルのフィールドで、実行時に変更できるため、1つのXMLですべての見た目を表現できます。
Memory-mapped storagememory-mapによる保存
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.
128×128のRGB画像12 000枚は約590 MBです。生成スクリプトはnp.lib.format.open_memmapで配列を開き、
1行ずつ書き込むため、データ全体を一度にRAMに載せる必要はありません。学習時も同じ方法で読み込み、スライスする前に
各batchのインデックスをソートします。これでランダムアクセスがファイルの順次読み込みに変わります。
データがメモリではなくディスク上にある場合に効く、小さな工夫です。
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.
128 pxで毎秒900–1080枚なので、12 000枚で約12秒です。このプロジェクトではレンダリングはボトルネックではありません。 普通はボトルネックになるので、あえて明記しておきます。このマシンのOpenGLはソフトウェアレンダリング(WSL上のllvmpipe)であり、 影を無効にしているのはまさにこの速度を保つためです。
The baselines — and why there are two of themBaseline、そしてそれが2つある理由
The classical-vision baseline, the bias found in it, and the one-line correction that removed it.
古典的な画像処理によるbaseline、そこで見つかったbias、そしてそれを取り除いた1行の補正。
Version 1: two hand-written priorsバージョン1:手書きの2つのprior
Both variants share the same pipeline and differ only in the mask:
2つの版は同じ処理の流れを共有し、マスクだけが異なります。
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:
見た目が変わると、2つのpriorの挙動は大きく分かれます。
| Prior | Detection rate on hardhardでの検出率 | What happened結果 |
|---|---|---|
red — fixed hue window固定の色相範囲 | 10.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.立方体がもう赤くないため、89 %を見逃しました。一方で検出できた11 %での精度は変わらないため、この手法自身の誤差指標はまったく正常に見えます。 |
sat — most saturated object最も彩度の高い物体 | 100 % | Fully robust to the appearance shift, with the same error as on easy. No network involved.見た目の変化に完全に頑健で、誤差もeasyと同じです。ネットワークは一切使っていません。 |
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.
誤ったpriorはエラーを出しません。返す答えの数が減るだけで、返した答え自体は正しいのです。 検出率と精度を必ずセットで報告しなければ、この失敗は見えません。論文が平均誤差だけを報告するときに起きているのが、まさにこれです。
Version 2: one scalar, fitted honestlyバージョン2:公正に当てはめたスカラー1つ
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.
バージョン1には半径方向に+2.98 mmの系統的なbiasがあり、どの予測もテーブル中心からわずかに遠すぎる位置になります。 原因は統計ではなく幾何です。真上のカメラには立方体の上面だけでなく、外側を向いた側面も写ります。 そのためシルエットは外側に広がり、その重心は真の投影中心より外側にずれます。
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.
これでは公平な比較になりません。ネットワークはラベル付き画像を12 000枚見ていますが、baselineは1枚も見ていません。 そこでbaselineにも同じ特権を、それ以上は与えずに与えます。trainで半径方向のスケール補正を ちょうど1つだけ求め、そのまま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.
biasは+2.98 mmから+0.01 mmになり、誤差の中央値は3.41 mmから1.91 mmに下がります。 ネットワークとの差全体の53 %が、学習ループなしのスカラー1つで埋まったことになります。
The network — two heads, one questionネットワーク:1つの問いに2つのhead
Two output heads compared on identical data — flatten against spatial soft-argmax.
同一データで2つの出力headを比較します。flattenとspatial soft-argmaxです。
Why the usual head cannot be used一般的なheadが使えない理由
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.
ほぼすべての画像分類器は、最後にglobal average poolingを置きます。特徴マップを空間方向に平均してから分類するのです。 「これは猫か」は猫がどこにいるかに依存しないため、これでうまくいきます。平行移動不変性は利点なのです。
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.
姿勢の回帰では逆が必要です。答えそのものが位置だからです。空間方向に平均すれば、求められているものを捨てることになります。 したがってheadは空間情報を保持しなければならず、その方法は2つあります。
| Head | Trunk | Mechanism仕組み | Paramsパラメータ数 |
|---|---|---|---|
flat | 4 stride-2 blocks, 128×128 → 8×8×128stride 2のブロック×4、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.特徴マップ全体を8192個の値にflattenし、線形層を1つ載せます。priorはまったくなく、 ネットワークは画素格子の幾何をゼロから見つけ出す必要があります。 | 130 k |
softargmax | 3 stride-2 blocks, 128×128 → 16×16×64stride 2のブロック×3、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.1×1畳み込みで16個のkeypointチャンネルを作り、それぞれをspatial softmaxで確率マップに変換し、 そのマップの(u, v)の期待値を計算します。この層の出力は座標そのものです。 | 27 k |
Both then produce four numbers: normalised x, normalised y, sin 4θ, cos 4θ.
どちらも最終的に、正規化したx、正規化したy、sin 4θ、cos 4θの4つの数値を出力します。
How spatial soft-argmax worksspatial soft-argmaxの仕組み
Given one feature channel of shape (H, W):
形状(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".
- Build two coordinate grids, u from −1 to 1 left-to-right and v from −1 to 1 top-to-bottom.
- Take the expectation:
E[u] = Σ p·u,E[v] = Σ p·v.
- 学習可能な温度で割り、H×Wの全位置にわたってsoftmaxを取ります。これでチャンネルはピクセル上の確率分布、 つまり「この特徴は画像のどこにあるか」になります。
- 座標グリッドを2つ作ります。uは左から右へ−1から1、vは上から下へ−1から1です。
- 期待値を取ります:
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.
結果は微分可能で、サブピクセル精度の座標です。温度は対数の形で保持する学習可能パラメータなので、 制約を設けなくても指数関数によって常に正に保たれます。温度が低いとargmaxに近い鋭いピークになり、高いと広く平均されます。
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.
これはvisuomotor policyで使われるkeypointのフロントエンド(deep spatial autoencoderの系譜)です。 読むだけでなくここで実装したのは、このpriorにどれだけの価値があるかを計測するためです。結果は パラメータ数が5分の1、2.6×高速、しかもより高精度で、tailも良好でした。
Training details that are load-bearing結果を左右する学習の詳細
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.XY_RANGEで割って[−1, 1]に収め、もともとその範囲にあるsinとcosに揃えます。
これをしないと、位置の誤差0.075 mとsinの誤差1.0の重みが1000対1になり、モデルはyawしか学習しません。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.
train.pyの最初の版は、validationが最小のepochを保存して報告していました。3回の学習のうち2回は
もともと最終epochが最良だったため、数値は変わりませんでした。残る1回は学習し直し、0.47 mmから0.48 mmになりました。
リークによる影響は計測できるほどではありませんでしたが、思い込まずに確認することにこそ意味があります。
Export, and what counts as proving itExportと、その正しさの証明
ONNX export, the numerical tolerance it is checked against, and what a passing check does not prove.
ONNX export、照合に使う数値の許容誤差、そしてチェックに通っても証明されないこと。
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.
ONNXは移植性のあるグラフ形式です。PyTorchで学習し、どこでも実行できます。PythonもPyTorchも入っていない 組み込みボード上のC++ nodeでも動きます。ただし、ファイルがディスクに出力された時点がゴールではありません。 数値的には近くても、出力の順序が入れ替わっていたり、正規化が間違っていたり、層が黙って抜け落ちていたりすることがあるからです。
So export_onnx.py checks three things:
そこでexport_onnx.pyは次の3点を確認します。
- Tensor agreement. A real 256-image batch through both engines. Max absolute
difference:
6.6e-7. - 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. - 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.
- テンソルの一致。実データ256枚のbatchを両方のエンジンに通します。最大絶対差は
6.6e-7です。 - タスクレベルの一致。validation全体を学習時とまったく同じ方法で復元し、同じ
pose_metricsで評価します。 すべての数値が小数点以下2桁まで一致しました。 - batch 1でのlatencyを、1スレッドと8スレッドの両方で計測します。実際の認識nodeが受け取るのはbatch 1だけであり、 ROS 2 nodeがCPU全体を使えるわけではないからです。
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.
torch 2.14ではtorch.onnx.exportのデフォルトがdynamo exporterで、これはonnxscriptをimportするため、
未インストールだと失敗します。dynamo=Falseを渡すとTorchScript exporterが選ばれ、追加の依存は不要です。
制限のかかったマシンに、不要なパッケージを入れてしまう前に知っておく価値があります。
Results結果
The measured numbers: position error, yaw error, and runtime.
計測した数値:位置誤差、yaw誤差、実行時間。
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.
validation(n = 2000、独立したseed系列)で、すべての行を同じ関数で評価しています。 「p95」は95パーセンタイルで、典型的なケースではなく分布の裾(tail)を表します。
| Method手法 | Paramsパラメータ数 | Regime条件 | Detect検出率 | Median xyxy 中央値 | p95 xy | Median yawyaw 中央値 | p95 yaw | Radial bias半径方向bias | Latency |
|---|---|---|---|---|---|---|---|---|---|
| OpenCV, fixed red hueOpenCV、赤の固定色相 | 0 | hard | 10.9 % | 3.48 mm | 6.07 | 0.19° | 1.24 | +2.96 mm | 0.06 ms |
| OpenCV, saturationOpenCV、彩度 | 0 | easy | 100 % | 3.54 mm | 5.54 | 0.18° | 1.50 | +3.01 mm | 0.05 ms |
| OpenCV, saturationOpenCV、彩度 | 0 | hard | 100 % | 3.41 mm | 5.57 | 0.19° | 1.66 | +2.98 mm | 0.05 ms |
| v2 · OpenCV + 1 calibrated scalarv2 · OpenCV+calibrationしたスカラー1つ | 1 | easy | 100 % | 1.94 mm | 2.84 | 0.18° | 1.50 | +0.16 mm | 0.04 ms |
| v2 · OpenCV + 1 calibrated scalarv2 · OpenCV+calibrationしたスカラー1つ | 1 | hard | 100 % | 1.91 mm | 2.89 | 0.19° | 1.66 | +0.01 mm | 0.06 ms |
| CNN, flatten head | 130 k | hard | 100 % | 0.75 mm | 2.00 | 0.28° | 0.94 | +0.05 mm | 1.70 ms |
| CNN, soft-argmax head | 27 k | easy | 100 % | 0.48 mm | 1.04 | 0.16° | 0.51 | −0.01 mm | 0.65 ms |
| CNN, soft-argmax head | 27 k | hard | 100 % | 0.59 mm | 1.32 | 0.21° | 0.66 | −0.02 mm | 0.65 ms |
| same weights, ONNX Runtime, 1 thread同じ重み、ONNX Runtime、1スレッド | 27 k | hard | 100 % | 0.59 mm | 1.32 | 0.21° | 0.66 | −0.02 mm | 0.23 ms |
| same weights, ONNX Runtime, 8 threads同じ重み、ONNX Runtime、8スレッド | 27 k | hard | 100 % | 0.59 mm | 1.32 | 0.21° | 0.66 | −0.02 mm | 0.12 ms |
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.
1ピクセルは2.72 mmで、READMEの初期の草稿ではこれを越えられない精度の下限として書いていました。これは誤りです。 この値が制限するのは1ピクセル単位の計測です。立方体のシルエットは約480ピクセルを占め、それらを平均すると 量子化誤差の項は約√N分の1に縮み、全体としての下限は0.12 mm程度になります。calibration済みbaselineの1.91 mmも、 ネットワークの0.59 mmも、正当に1ピクセルを下回っています。多数のピクセルからなる物体でサブピクセル精度が出るのは バグではなく当然のことです。誤った測定単位で下限を考えると、正しい結果をエラーとして捨ててしまうことになります。
What the numbers actually say数値が実際に示していること
Reading those results honestly — where the gain came from, and where it did not.
結果を正直に読み解きます。改善がどこから来て、どこから来ていないのか。
A wrong prior fails silently, and its own metric hides it誤ったpriorは黙って失敗し、自身の指標がそれを隠す
"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.
「立方体は赤い」というpriorはhardの89 %を見逃しますが、検出できた11 %での精度は変わらないように見えます。
検出率と精度は常にセットで報告しなければ、失敗が見えません。
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.
「赤い」を「グレーに近いテーブル上で最も彩度の高いもの」に置き換えるだけで、hardで検出率100 %、
誤差もeasyと同じになります。したがって、ここでネットワークを使う根拠はdomain randomisationへの頑健性ではありません。
より良い手書きの特徴を選ぶ方が安上がりで、しかもうまくいきました。
Half the network's apparent win was calibration, not perceptionネットワークの見かけの勝ちの半分は、認識ではなくcalibrationだった
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×.
trainでスカラーを1つ当てはめるだけで、baselineは3.41 mmから1.91 mmになります。差の53 %が、たった1つの数値で埋まりました。 強いbaselineを後回しにしていたら、ネットワークの成果は約2×に水増しされていたはずです。
What the network genuinely wins is the tailネットワークが本当に勝っているのは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.
calibration後のbaselineは、yaw誤差の中央値(0.19°)ではネットワーク(0.21°)よりわずかに優れています。
きれいなマスクに対するminAreaRectはほぼ正確だからです。しかしp95は1.66°対0.66°、位置のp95は2.89 mm対1.32 mmです。
古典的手法はまれに大きく外し、ネットワークは大きく外すことがなく、外れても小さく収まります。制御器に値を渡す用途では、
重要なのはtailの数値です。
The architectural prior beat the parameter countパラメータ数より、アーキテクチャのpriorが効いた
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.
flatten head:130 kパラメータ、1.70 ms、0.75 mm。soft-argmax head:27 kパラメータ、0.65 ms、0.59 mmで、tailも良好です。 5分の1のサイズで、2.6×高速、しかもより高精度。flattenしたグリッドから座標とは何かを学ばせるより、 ネットワークに座標そのものを渡す方が勝ります。
The classical method is still 11× fasterそれでも古典的手法は11×速い
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.
1枚あたり0.06 ms対0.65 msです。1.9 mmが許容範囲内であれば、精度が3倍高くても、ネットワークはエンジニアリング上の誤った選択です。
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.
重みも出力もまったく同じで、PyTorch eagerは8スレッドで0.65 ms、ONNX Runtimeは1スレッドで0.23 ms、8スレッドで0.12 msです。 ネットワークには一切手を加えず、8分の1のコア数で約2.8×速くなりました。edgeでのlatencyのためにアーキテクチャを最適化する前に、 フレームワークがコストになっていないかを確認すべきです。今回はそれがコストの大半で、その結果、古典的手法の速度面の優位は11×から3.8×に縮みました。 0.23 msで動く116 KBのONNXファイルは、C++ nodeへの現実的なデプロイ対象です。
Running it実行方法
Setup, the commands in order, and the runtimes to expect on a CPU.
セットアップ、実行するコマンドの順序、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.
3つのtierを、軽いものから順に実行します。Tier 1はtest_common.pyで、yawの折り返し、射影、評価指標を
紙の上で導いた値と照合します。データは不要です。Tier 2と3はdata/(gitignore対象、再生成に約30秒)が必要で、
git管理されたcheckpointをPyTorchとONNX Runtimeの両方で評価し直します。MUJOCO_GLのデフォルトをglfwにし、
スレッド数も固定しています。どちらも数値を変えるためです。
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.
Intel Core Ultra 5 225H(14コア)、GPUなし、WSL2、ソフトウェアOpenGL(llvmpipe)。レンダリングは128 pxで900–1080 img/s。 学習は8スレッドで約1750 img/sですが、CPUが持続電力の上限に達するため、学習の後半では25–35 %低下します。
The short versionまとめ
The whole project compressed into one paragraph.
プロジェクト全体を1段落に凝縮します。
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.
真上から撮った128×128のレンダリング画像1枚から立方体の平面上の姿勢を推定し、学習したモデルが手書きの幾何計算に
勝つのはどんなときで、勝てないのはどんなときかを計測しました。条件ごとに12 000枚の画像を用意し、ラベルはMuJoCoから直接読み出しています。
手書きのOpenCV baselineを2つ作り、さらにtrainでcalibrationしたスカラー1つを加えた3つ目を作りました。これで差の53 %が埋まり、
最初のbaselineが不当に弱かったことがわかりました。27 kパラメータのsoft-argmax keypoint headは、見た目をランダム化した条件で
中央値0.59 mm、p95 1.32 mmに達し、130 kパラメータのflatten headにすべての軸で勝ちました。ONNXにexportし、テンソルとタスク指標の
2段階で検証したところ、同じ重みが1スレッドで、8スレッドのPyTorch eagerより2.8×速く動きました。すべての主張は、クリーンなcloneから
段階的なverify.shで再実行できます。途中で行った2つの訂正も、消さずに記録として残しています。