- camera-health-nano
- The finding that justifies this model existing
- Measured
- Input convention β this matters more than it sounds
- Scope
- Known failure modes
- What happened to the
bandinghead - Usage
- Deployment note: cap the ONNX Runtime thread pool
- Training
- Verification
- What "scalar baseline" means on this card
- Provenance
- The finding that justifies this model existing
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 | 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
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.
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.