depth-nano
47,187 parameters. 189 KB of ONNX. Given one frame from a generated video, estimate how many
generations it is removed from a real anchor image.
When you generate long video by chaining — each chunk conditioned on the last frame of the
previous one — error accumulates. This model reads that accumulation off a single 64×64 frame.
Domain measured / deployment domain tested: measured on LTX-Video-2B and Wan 2.2 generated interior-room frames; deployment domain: no other generator or scene type tested; never seen a real photograph. (Fifth line of the card standard, added 2026-09-02: a number is only as good as the domain it was measured in.)
Scope — read this first
What it is for: monitoring chained video generation. Answers "how degraded is this frame,
in units of generations from ground truth?" so a pipeline can decide when to re-anchor.
What it must not be used for:
- Not an image-quality or aesthetic score. It estimates chaining depth, which correlates
with degradation in this pipeline but is not a general quality measure.
- Not a deepfake, provenance, or AI-generated-content detector. Every training frame is
AI-generated. It has never seen a real photograph and says nothing about whether an image is real.
- Not a measure of how many times an image was compressed, resaved, or edited.
- Not for any decision about a person.
What it predicts
Three ordinal bins over chaining depth:
| class | depth | chunks |
|---|
fresh | 0 — generated directly from a real anchor | 932 |
shallow | 1–5 generations removed | 264 |
deep | 6+ generations removed | 644 |
Ordinal bins, not regression. The depth distribution is bimodal — 932 chunks at depth 0, 547
at 12+, a thin tail between. A regressor scores well on that by learning "zero or large" and never
resolving the middle, and MAE hides the failure. Binning forces the middle class to show its own
recall, which is where the difficulty actually is.
Measured performance
Stratified split by ARM, 34 arms, ~30% held out, 3 seeds. Splitting by arm is mandatory here
(frames in an arm share a seed and a chaining config), and because depth is confounded with arm
identity — the heavily-chained arms are deep throughout — shuffles are retried until both sides
contain all three classes.
| seed | accuracy | majority | lift | ordinal MAE | fresh | shallow | deep |
|---|
| 0 | 0.946 | 0.434 | +0.512 | 0.055 | 0.979 | 0.772 | 0.974 |
| 1 | 0.965 | 0.696 | +0.268 | 0.035 | 0.992 | 0.877 | 0.917 |
| 2 | 0.947 | 0.471 | +0.476 | 0.057 | 0.995 | 0.520 | 0.956 |
| mean | | | +0.419 ± 0.108 | 0.049 ± 0.010 | 0.989 | 0.723 | 0.949 |
Ordinal MAE of 0.049 bins means that when it is wrong it is almost always wrong by one adjacent
bin, never by two. shallow is the hard class and the one that varies (0.520–0.877); the two
extremes are close to solved.
The ablation that makes this believable
+0.42 lift is much higher than anything else in this family, which is exactly when to suspect a
trivial cue. The obvious one: fresh chunks are generated directly from the real anchor image,
so a model could score well by learning "does this look like the anchor photo?" — which would be a
real signal, but not depth estimation.
So I removed fresh entirely and asked only shallow (1–5) vs deep (6+), where no anchor-
similarity cue exists. 908 chunks, 27 arms, 5 seeds:
| seed | accuracy | majority | lift |
|---|
| 0 | 0.919 | 0.555 | +0.365 |
| 1 | 0.953 | 0.560 | +0.393 |
| 2 | 0.902 | 0.624 | +0.278 |
| 3 | 0.957 | 0.574 | +0.383 |
| 4 | 0.952 | 0.733 | +0.218 |
| mean | | | +0.328 ± 0.068 |
It still works. The model is reading accumulated generation damage, not recognising the anchor.
Label-shuffle control: with depth labels permuted across chunks, the same pipeline scores
−0.097 and −0.042 lift. The harness does not manufacture signal.
The crop assumption
Trained on full frames resized to 64×64, no crop. Chaining damage is a global property of the
frame, so unlike most nano models in this family there is no object-to-frame ratio to preserve and
no clutter wall. The corollary: do not feed it a crop — the degradation statistics of a region
are not those of the frame. Whole frames, resized, letterboxed if the aspect differs.
Known failure modes
- Two generators, one scene. LTX-Video-2B and Wan 2.2 TI2V-5B, an interior room with a
person. Chaining artefacts are model-specific; expect poor transfer to other architectures.
shallow recall swings from 0.52 to 0.88 depending on which arms are held out. The middle
of the depth range is genuinely under-sampled (264 chunks vs 932 and 644).
- Depth is a proxy for damage, not damage itself. A well-behaved chain at depth 8 may look
better than a bad one at depth 3. The model predicts the label it was given.
- It cannot exceed the bins. "6+" covers depth 6 and depth 40 identically.
- Frame-level inference, chunk-level labels. Every frame in a chunk inherits the chunk's
depth, which is correct here (depth is constant within a chunk) — but the visual damage is
not constant within a chunk, adding label noise.
Usage
1import cv2, numpy as np, onnxruntime as ort
2
3CLASSES = ["fresh", "shallow", "deep"]
4sess = ort.InferenceSession("depth.onnx", providers=["CPUExecutionProvider"])
5
6img = cv2.imread("frame.jpg") # whole frame, BGR, as cv2.imread returns it
7x = cv2.resize(img, (64, 64), interpolation=cv2.INTER_AREA)
8x = x.astype(np.float32).transpose(2, 0, 1)[None] / 255.0
9
10logits = sess.run(None, {"image": x})[0][0]
11print(CLASSES[int(logits.argmax())])
Input is BGR, 0–1, NCHW — the channel order cv2.imread returns, not RGB. Training used BGR
throughout; feeding RGB swaps two channels silently and degrades output without erroring.
Verification
ONNX vs PyTorch, identical weights, both on CPU, 512 real frames:
- max relative logit difference 3.0e-07 (logits reach ±19.2)
- 100% argmax agreement
Tolerance is relative, not absolute. An absolute 1e-3 check flags a bit-accurate export of this
model as broken — it did, the first time, on the sibling model.
What this replaced
The original plan was a model predicting chunks until collapse. It was killed before training:
collapse onset turns out not to be a well-defined scalar. Only 12 of 34 arms end in an absorbing
failure run, and on 8 of those, two reasonable definitions of onset disagree by more than 2
chunks — in one arm by 44, because a single chunk flickered past the quality checker. The label's
variance was dominated by the checker, not by the process. Chaining depth needs no changepoint and
no quality signal, so it replaced it.
Training
- 1,840 chunks from 34 arms, 6 frames per chunk = 11,040 frames
- 4 conv layers (16→32→48→64), BatchNorm, global average pool, 3-way linear head
- Cross-entropy with inverse-frequency class weights capped at 10×
- Adam 3e-3, 30 epochs, batch 32
- Shipped weights are fit on all 34 arms; every table above is from held-out arm splits
Trained on an NVIDIA Jetson AGX Orin. Both depth.onnx and depth.pt included.
Related
resoajoe/quality-gate-nano — is this chunk bad?
resoajoe/failure-type-nano — why is it bad?
- this — how far gone is it?
Deployment note: cap the ONNX Runtime thread pool
Measured on a Jetson AGX Orin. ONNX Runtime sizes its intra-op thread pool to the CPU core count,
and those workers spin-wait between inferences. Running two 47K-parameter models this way left
~18 threads busy-waiting at ~10.6% of a core each — about 1.9 cores burned continuously to run
inferences that take 0.28 ms. A model this small cannot use intra-op parallelism at all.
1so = ort.SessionOptions()
2so.intra_op_num_threads = 1
3so.inter_op_num_threads = 1
4so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
5so.add_session_config_entry("session.intra_op.allow_spinning", "0")
6sess = ort.InferenceSession("depth.onnx", sess_options=so, providers=["CPUExecutionProvider"])
Measured effect on the same workload: idle CPU 192% → 16.5% of one core, active 231% → 88%,
thread count 45 → 22, throughput unchanged. On edge hardware this is the difference between
"runs alongside everything else" and "saturates the machine".
What "scalar baseline" means on this card
Every margin quoted here is against a stated baseline, because a margin without one is not a
measurement. The baseline is the best single-threshold classifier over ten cheap statistics,
fitted optimistically:
mean · std · lapvar · hf (high-frequency energy ratio) · grad (Sobel magnitude) ·
entropy · centre_edge · radial_slope · row_fft_peak · col_fft_peak
The last four are spatially aware, added after an earlier six-statistic baseline — all global
aggregates — was found to systematically overstate model value on spatially structured tasks. A
baseline that cannot see where anything is loses to a CNN by default. On one test task that flaw
inflated an apparent margin from +0.060 to +0.261.
Two questions are asked with it, and they disagree:
- in-sample — threshold fitted on the data it is scored on. Deliberately generous. Answers
is there structure beyond a low-order statistic?
- transferred — threshold fitted on the training corpus, applied unchanged to the target.
Answers what should I ship? On one task the in-sample figure was 0.954 and the transferred
figure 0.565.
Where this card quotes a single scalar figure without qualification, it is the in-sample one.
Provenance and privacy
Training frames are model-generated video of a synthetic interior scene. No real patients, no real
footage, and no personal data are involved anywhere in this work.