"""The quality gate. A capture that is too dark, too blurred or too small does not produce a wrong answer — it produces a confident wrong answer, which is worse. The gate runs before the model and decides one of three things: - **pass** — run the model, report the confidence the model gives. - **degrade** — run the model, but the result is capped at `low` confidence and the device is asked for a better frame. Usable evidence, not trustworthy evidence. - **block** — do not run the model at all. Nothing a detector says about a black rectangle is worth storing. Every threshold below came from measuring the committed fixtures and deliberately degraded copies of them. The numbers are in the comments so the next person can re-derive them instead of guessing what "too dark" meant. """ from __future__ import annotations from dataclasses import dataclass import numpy as np from PIL import Image from app.schemas import QualityCheck #: The metric image is downscaled to this before anything is measured. Laplacian #: variance scales with resolution, so a 2,675 px barn photo and a 396 px yard #: photo are otherwise not comparable and the blur threshold means nothing. _METRIC_SIDE = 512 #: Mean luma. Measured: the three fixtures sit at 87–136; the same images at 20% #: brightness sit at 17–27. A frame below this has lost the shadow detail a #: detector needs on dark-coated cattle. MIN_MEAN_LUMA = 35.0 #: Share of pixels at or above 250. Measured: fixtures 0.000–0.011, the same #: images at 3× brightness 0.45–0.49. Mean luma alone does not catch a blown #: frame, because a bright sky plus a dark animal averages out to nothing. MAX_BLOWN_FRACTION = 0.25 #: Variance of the Laplacian, the standard focus proxy. Measured: fixtures #: 584–2,192; the same images under a 6 px Gaussian blur 1.9–20.9. The gap is #: wide enough that this threshold does not need to be precise. MIN_FOCUS_VARIANCE = 80.0 #: Short side in pixels. Below this the detector's 640 px input is upscaling #: more than it is reading, so the result is a soft failure rather than a hard #: one: still worth running, not worth trusting. MIN_SHORT_SIDE = 480 #: Below this there is nothing to detect at any distance, and running the model #: is a waste of a result row. UNUSABLE_SHORT_SIDE = 120 @dataclass(frozen=True) class QualityVerdict: checks: list[QualityCheck] #: No model may run. The capture is still saved; only the interpretation is #: withheld. blocked: bool #: The model may run but the result must not be presented as reliable. degraded: bool @property def first_failure(self) -> QualityCheck | None: return next((c for c in self.checks if not c.passed), None) def _metrics(image: Image.Image) -> tuple[float, float, float]: grey = image.convert("L") grey.thumbnail((_METRIC_SIDE, _METRIC_SIDE), Image.BILINEAR) array = np.asarray(grey, dtype=np.float32) mean_luma = float(array.mean()) blown = float((array >= 250.0).mean()) # 4-neighbour Laplacian by slicing. A convolution library would be a # dependency for four additions. laplacian = ( -4.0 * array[1:-1, 1:-1] + array[:-2, 1:-1] + array[2:, 1:-1] + array[1:-1, :-2] + array[1:-1, 2:] ) return mean_luma, blown, float(laplacian.var()) def assess(image: Image.Image) -> QualityVerdict: """Judge one frame. The `detail` strings are shown to a worker standing in a paddock, so they say what to do differently rather than reporting a metric. """ width, height = image.size short_side = min(width, height) mean_luma, blown, focus = _metrics(image) checks: list[QualityCheck] = [] blocked = False degraded = False if short_side < UNUSABLE_SHORT_SIDE: checks.append(QualityCheck( check="resolution", passed=False, detail=f"The frame is {width}×{height}. There is not enough of it to read.", )) blocked = True elif short_side < MIN_SHORT_SIDE: checks.append(QualityCheck( check="resolution", passed=False, detail=f"The frame is {width}×{height}. Capture at a higher resolution " f"for a result you can rely on.", )) degraded = True else: checks.append(QualityCheck(check="resolution", passed=True)) if mean_luma < MIN_MEAN_LUMA: checks.append(QualityCheck( check="illumination", passed=False, detail="Too dark. Move into better light, or wait for the sun.", )) blocked = True elif blown > MAX_BLOWN_FRACTION: checks.append(QualityCheck( check="illumination", passed=False, detail="Too bright — most of the frame is washed out. Turn away from " "the sun.", )) blocked = True else: checks.append(QualityCheck(check="illumination", passed=True)) if focus < MIN_FOCUS_VARIANCE: checks.append(QualityCheck( check="motion_blur", passed=False, detail="The frame is blurred. Hold still and capture again.", )) blocked = True else: checks.append(QualityCheck(check="motion_blur", passed=True)) return QualityVerdict(checks=checks, blocked=blocked, degraded=degraded)