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.
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 | verdict |
|---|---|---|---|---|
defocus |
0.993 | 0.854 | 0.703 | use the model |
noise_drift |
0.988 | 0.862 | 0.692 | use the model |
hot_pixels |
0.976 | 0.743 | 0.702 | use the model |
occluded |
0.994 | 0.982 | 0.719 | use the model β corrected, see below |
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
condensationhead 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
import cv2, numpy as np, onnxruntime as ort
LABELS = ["occluded", "defocus", "noise_drift", "hot_pixels"]
MODEL = {"defocus", "noise_drift", "hot_pixels"} # see the table: use a threshold for occlusion
so = ort.SessionOptions()
so.intra_op_num_threads = 1
so.add_session_config_entry("session.intra_op.allow_spinning", "0")
sess = ort.InferenceSession("camera_health_v2.onnx", sess_options=so,
providers=["CPUExecutionProvider"])
img = cv2.imread("frame.png", cv2.IMREAD_GRAYSCALE)
p = cv2.resize(img, (64, 64), interpolation=cv2.INTER_AREA).astype(np.float32) # WHOLE frame
p = (p - p.mean()) / (p.std() + 1e-8) # per-frame standardise, required
probs = 1 / (1 + np.exp(-sess.run(None, {"image": p[None, None]})[0][0]))
print({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.
Provenance
COCO val2017 (public) plus frames from the author's own rig. No personal data is included in the release.