animap-gpu / app /quality.py
bluman1's picture
Publish services/inference
4b98524 verified
Raw
History Blame Contribute Delete
5.35 kB
"""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)