camera-health-nano
46,964 parameters. 188 KB. Four faults, one forward pass. Is this camera working properly?
Multi-label, because a real camera can be simultaneously out of focus, noisy and partly occluded:
occluded, defocus, noise_drift, hot_pixels.
Use camera_health_v2.onnx. v1 is retained for reproducibility only — it shipped a fifth head
(banding) that is broken, and it trained on a different input convention from the one its usage
example showed. Both are fixed below.
Domain measured / deployment domain tested: measured on COCO photographs and real Logitech BRIO frames with SYNTHETIC faults applied; deployment domain: no naturally occurring camera fault tested. (Fifth line of the card standard, added 2026-09-02: a number is only as good as the domain it was measured in.)
The finding that justifies this model existing
Every one of these faults, detected in isolation, is a scalar task. A single threshold on one
cheap statistic matches a neural network exactly. Measured on real camera frames:
| head | alone | with 1 other fault | with 3 others |
|---|
hot_pixels | −0.008 | +0.180 | +0.209 |
defocus | +0.000 | +0.007 | +0.143 |
noise_drift | +0.000 | −0.006 | +0.129 |
occluded | +0.000 | +0.005 | +0.014 |
(margin = model accuracy − best single-threshold scalar, the scalar fitted in-sample and therefore
optimistically. Alone, both reach 1.000.)
The model's value is not detection. It is disentangling mixtures. A gradient threshold measures
blur perfectly until something occludes part of the frame, at which point the statistic is
answering a different question. That is why 47K parameters earn their place in a real deployment,
where faults co-occur, and why they would be waste in a lab where they do not.
If your cameras only ever fail one way at a time, use the thresholds instead — they are free,
interpretable, and cannot overfit a scene.
Measured
Trained on COCO photographs, each resized whole to 64×64, with the four faults sampled
independently at p = 0.30 so mixtures occur naturally.
Real camera frames — 1,800 patches from a Logitech BRIO watching a real office, a different
sensor with its own image pipeline:
| fault | model | best scalar | majority | margin | verdict |
|---|
hot_pixels | 0.975 | 0.743 | 0.702 | +0.232 | use the model |
noise_drift | 0.983 | 0.862 | 0.692 | +0.122 | use the model |
defocus | 0.998 | 0.938 | 0.703 | +0.060 | use the model, but see below |
occluded | 0.991 | 0.982 | 0.719 | +0.008 | use the model — for transfer reasons, see below |
Corrected 2026-08-27. An earlier version of this table gave defocus a scalar of 0.854 and
therefore a margin of +0.144. That baseline contained only global statistics. Against a baseline
that includes a column-profile frequency term the scalar reaches 0.938, and the honest margin
is +0.060 — still positive, and much narrower than previously claimed. hot_pixels and
noise_drift are unaffected; they remain the two heads with a comfortable margin.
Held-out COCO, for reference: 0.977 / 0.971 / 0.969 / 0.960.
Correction (2026-08-26): use the model for occluded, not a threshold
An earlier version of this card told you to detect occlusion with an entropy threshold instead of
this model's head, on the strength of the 0.982 above. That number was fitted in-sample on the
same frames it was scored on, which is fine as a deliberately-optimistic bar for "is there
structure worth modelling" and useless as a guide to what you can actually deploy — because a
threshold you ship has to be a number fixed in advance.
Fitting the threshold on the training corpus and applying it unchanged to real camera frames:
| occlusion on real frames | accuracy |
|---|
| entropy threshold, in-sample (what the old advice used) | 0.982 |
| entropy threshold, fitted on COCO and transferred | 0.931 |
| this model, trained on COCO and transferred | 1.000 |
The old advice cost about 7 points. Thresholds transfer worse than models: a threshold is a single
number tuned to one distribution's location and scale, and a sensor change puts it in the wrong
place with nothing to compensate.
All four heads are now recommended.
Input convention — this matters more than it sounds
Resize the WHOLE frame to 64×64. Do not pass a native crop.
v1 trained on native 64×64 crops while its usage example resized whole frames, and the mismatch was
not harmless: the same weights score 0.997 on whole-frame input and 0.712 on native crops for
defocus, with occluded going the other way. A 64×64 crop of a 1080p frame is a far more
magnified view than a 64×64 crop of a 640×480 photograph, so the spatial statistics differ. v2
trains and deploys on one convention.
Scope
For: monitoring deployed camera fleets — a lens drifted out of focus, sensor read-noise
climbing, stuck pixels, a partially blocked view. Cheap enough to run on every frame on hardware
that can do nothing else.
Not for:
- Not image-quality scoring. It detects specific hardware faults, not whether a photo is good.
- Not tamper-proof. A careful deliberate obstruction is not what it was trained on.
- Not a substitute for camera telemetry where the device already reports focus or gain.
- It sees a 64×64 greyscale frame and cannot represent a person. Not a surveillance tool.
Known failure modes
- Faults are synthetic. Real defocus is optical aberration, not a Gaussian kernel; real
occlusion has soft shadows and partial transparency. The real-camera test above applies
synthetic corruption to real frames — it controls for sensor and scene, not for fault realism.
- Adding more heads degrades the existing ones. Extending to seven faults cost every original
head 0.09–0.17 accuracy, because corruptions that rewrite global statistics (clipping, contrast
crush) destroy the cues the others depend on. Do not assume this architecture extends for free.
- Greyscale only. Colour faults — a failed IR-cut filter, white-balance lock — are invisible.
- Single frame. Frozen-frame and dropout faults need temporal context this does not have.
- Localised soft defects do not work at all. A
condensation head scored 0.655 against a
0.677 scalar and was abandoned; photographs are full of naturally blurred regions, so localised
blur does not move the data outside its own natural variation. Spatial pooling made it worse
(0.611 at 2×2, 0.577 at 4×4).
What happened to the banding head
v1 shipped a fifth head that fired on 95% of real frames, and its card speculated the head might be
partly right — that real footage under mains lighting genuinely contains rolling-shutter banding.
That was tested on real hardware and is false. With six interleaved repeats, the head read
0.998 ± 0.001 when banding was suppressed and 0.927 ± 0.031 when permitted: anticorrelated,
p = 1.7e−17, firing on 100% of frames either way. An exposure sweep from 3.0 to 25.8 ms also found
no mains flicker in that room at all — no dip at the 8.33 ms half-cycle multiples, and a
de-trended periodicity of 10.35 ms at 3% amplitude, which is noise at the wrong period.
The head is removed in v2, and the retraction is recorded here rather than the old text quietly
disappearing.
Usage
1import cv2, numpy as np, onnxruntime as ort
2
3LABELS = ["occluded", "defocus", "noise_drift", "hot_pixels"]
4MODEL = {"defocus", "noise_drift", "hot_pixels"} # see the table: use a threshold for occlusion
5
6so = ort.SessionOptions()
7so.intra_op_num_threads = 1
8so.add_session_config_entry("session.intra_op.allow_spinning", "0")
9sess = ort.InferenceSession("camera_health_v2.onnx", sess_options=so,
10 providers=["CPUExecutionProvider"])
11
12img = cv2.imread("frame.png", cv2.IMREAD_GRAYSCALE)
13p = cv2.resize(img, (64, 64), interpolation=cv2.INTER_AREA).astype(np.float32) # WHOLE frame
14p = (p - p.mean()) / (p.std() + 1e-8) # per-frame standardise, required
15
16probs = 1 / (1 + np.exp(-sess.run(None, {"image": p[None, None]})[0][0]))
17print({l: round(float(v), 3) for l, v in zip(LABELS, probs) if l in MODEL})
Per-frame standardisation is required — the model is deliberately blind to absolute brightness so
it responds to structure rather than exposure.
Deployment note: cap the ONNX Runtime thread pool
ONNX Runtime sizes its intra-op pool to the CPU core count and those workers spin-wait between
inferences. Running two 47K models this way left ~18 threads busy at ~10.6% of a core each —
about 1.9 cores burned to run inferences taking 0.28 ms. With intra_op_num_threads=1 and
allow_spinning=0: idle CPU 192% → 16.5% of one core, active 231% → 88%, threads 45 → 22,
throughput unchanged. On edge hardware that is the difference between running alongside everything
else and saturating the machine.
Training
6,750 train / 2,250 held-out COCO frames, split by source image · 4 conv layers (16→32→48→64) ·
BCE with positive weighting capped at 10× · Adam 3e-3, 26 epochs, batch 64.
Same architecture as resoajoe/camera-motion-nano, resoajoe/bearing-fault-nano,
resoajoe/alarm-nano and resoajoe/depth-nano, unchanged.
Verification
ONNX vs PyTorch, both CPU, 256 inputs: max relative logit difference 2.4e-07, 100%
threshold agreement.
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 (Laplacian variance) · 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, and they were 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 different questions are asked with it:
- in-sample — the threshold is fitted on the same data it is scored on. Deliberately generous.
Answers is there structure here beyond a low-order statistic?
- transferred — the threshold is fitted on the training corpus and applied unchanged to the
target. Answers what should I actually ship?, and they disagree: on one task the in-sample
figure was 0.954 and the transferred figure 0.565.
Provenance
COCO val2017 (public) plus frames from the author's own rig. No personal data is included in the
release.