Spaces:
Running on Zero
Running on Zero
| """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 | |
| 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 | |
| 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) | |