animap-gpu / app /adapters /deterministic.py
bluman1's picture
Publish services/inference
4b98524 verified
Raw
History Blame Contribute Delete
8.77 kB
"""The deterministic methods, as first-class adapters.
Directive §4, and it is the sentence most easily skipped in the whole document:
> Do not use a neural model when deterministic signal processing is better.
So optical flow, FFT periodicity, contour measurement and reference-marker
calibration are registered here alongside SAM and DINOv3 rather than living in a
utilities module. They sit in the same registry, answer the same
`availability()`, and appear in the same listing, because a reader comparing the
stack should see that two of the capabilities with the clearest path forward
need no weights at all.
**These adapters are always available**, which no other adapter in this package
can say. There is no artefact to be absent, no card to be checksummed, no
licence to be refused and no gate to fail — which is most of the argument for
preferring them. `availability()` still exists and still answers, because a
caller should not have to know which kind of adapter it is holding.
The honesty property is not weaker here, it is only located differently. A
neural adapter refuses by having no model; these refuse by measuring whether the
signal was present, in `periodicity`'s two gates and in `geometry`'s refusal to
invent a scale.
"""
from __future__ import annotations
from pathlib import Path
from app.adapters.base import (
Adapter,
AdapterSpec,
Availability,
MeasuredCost,
Measurement,
Modality,
Placement,
Task,
)
from app.adapters.signal.geometry import (
NoReference,
Scale,
measure_region,
scale_from_marker,
)
from app.adapters.signal.respiration import RespirationResult, respiratory_rate
RESPIRATION_SPEC = AdapterSpec(
adapter_id="respiration-flow-fft",
runtime="opencv-numpy",
tasks=(Task.MEASURE,),
modalities=(Modality.VIDEO,),
directive_role=(
"§14 cattle respiratory rate — video, flank region, optical flow, "
"periodicity, FFT, breaths per minute. §4 names optical flow, FFT and "
"periodic motion analysis as OpenCV work rather than model work."
),
requires_artefact=False,
placement=Placement.CPU_SERVICE,
placement_reason=(
"No weights, and the arithmetic is cheap — but the clip is long. Dense "
"flow costs 4.3 ms per frame pair at 320 px, so §14's 30–60 second "
"capture is 4–8 seconds of flow plus decode, measured at 9.7 s median "
"for a 31-second clip. **That is past the inline ceiling**, so this "
"capability needs a queue rather than a bigger box. It is also the "
"strongest on-device candidate in the stack: OpenCV is on the phone "
"already, the video never has to leave it, and ADR 0002's offline-first "
"promise is kept for free."
),
measured=MeasuredCost(
hardware=(
"Apple M-series laptop (NOT the target container). OpenCV 5.0.0 "
"reports 11 threads and ignores setNumThreads(), so a "
"single-threaded figure could not be taken on this build"
),
threads=11,
sample=(
"Cow_crosses_cattle_grid.webm, 925 frames, 30.86 s at 29.97 fps, "
"whole frame, decode plus flow plus spectrum"
),
runs=7,
median_seconds=9.68,
peak_rss_mb=239.0,
measured_on="2026-08-21",
),
notes=(
"**The latency figure is load-sensitive and should be read as a band, "
"not a point.** Seven runs give a 9.68 s median over a 9.29–12.58 s "
"spread, and separate sessions on the same machine and the same clip "
"produced medians of 12.15 s and 14.17 s. The previously recorded "
"9.07 s / 331 MB does not reproduce in any configuration tried: memory "
"is consistently around 239 MB, and no threading setting moves the "
"latency, because this OpenCV build does not honour setNumThreads. "
"What survives all of it is the conclusion — every measurement is past "
"the 8 s inline ceiling, so this capability needs a queue.\n\n"
"**What the metronome validates is the extractor, not this adapter.** "
"On footage whose Commons description states 96 beats per minute, "
"`signal.dominant_rate` returns 96.48, and 48.38 on a crop of the "
"pendulum alone, the swing being half the tick rate — a 0.5% error "
"against a stated rate on real video. But `Metronome.webm` is 11.71 "
"seconds, and `respiration.MIN_CAPTURE_SECONDS` is 20, so "
"`measure()` refuses all three of those regions before any signal "
"processing runs. The only clip with a ground truth cannot reach the "
"code path this adapter exposes, and an earlier version of this note "
"read as though it had. `tests/test_adapters.py` asserts the gap so it "
"cannot be quietly re-closed in prose.\n\n"
"**No cattle rate is validated** — all three real cattle clips are "
"refused, two for being shorter than the capture protocol and one for "
"having no clear rhythm. What is missing is not model work: it is a "
"thirty-second clip of a cow's flank with somebody's counted breath "
"rate beside it."
),
)
GEOMETRY_SPEC = AdapterSpec(
adapter_id="marker-geometry",
runtime="opencv-numpy",
tasks=(Task.MEASURE,),
modalities=(Modality.IMAGE,),
directive_role=(
"§4 geometry, contour measurement and reference-marker calibration; "
"§9's 'approximate visible area: 12–16 cm²' for a wound, and §22's "
"fallback scale when metric depth is unreliable."
),
requires_artefact=False,
placement=Placement.ON_DEVICE,
placement_reason=(
"Marker detection and a contour area are microseconds of arithmetic on "
"a phone. Running it on the device means the farmer learns the card was "
"not in shot while still standing next to the animal, which is the "
"difference between a re-capture and a lost record."
),
notes=(
"**Unmeasured, and unexercised on a real photograph.** No image "
"available to this project contains an Animap reference marker, so the "
"marker-detection half has never run on anything real. The arithmetic "
"either side of it is exercised by unit tests. Do not quote an area "
"from this until somebody has photographed a printed card beside a "
"ruler."
),
)
class DeterministicAdapter(Adapter):
"""Signal processing and geometry. Always available, never guessing."""
def __init__(self, spec: AdapterSpec) -> None:
self.spec = spec
def availability(self) -> Availability:
# OpenCV and NumPy are production dependencies, so there is genuinely
# nothing to check. Importing cv2 here to prove it would make a health
# probe pay for a 60 MB import.
return Availability(True)
def load(self) -> "DeterministicAdapter":
return self
class RespirationAdapter(DeterministicAdapter):
"""§14, end to end."""
def __init__(self) -> None:
super().__init__(RESPIRATION_SPEC)
def measure(
self,
video_path: Path | str,
*,
region: tuple[float, float, float, float] | None = None,
) -> RespirationResult:
return respiratory_rate(video_path, region=region)
class GeometryAdapter(DeterministicAdapter):
"""§9 and §4, once something in the frame has a known size."""
def __init__(self) -> None:
super().__init__(GEOMETRY_SPEC)
def scale(self, image, marker_side_mm: float) -> Scale:
"""Pixels per millimetre from a printed marker.
Propagates `NoReference` rather than returning a default. A frame with
no marker has no scale, and the honest answer is a re-capture prompt.
"""
return scale_from_marker(image, marker_side_mm)
def region_size(self, mask, scale: Scale) -> dict:
return measure_region(mask, scale)
def try_scale(self, image, marker_side_mm: float) -> Measurement:
"""The same thing, as a `Measurement` a runner can put in a result."""
try:
found = self.scale(image, marker_side_mm)
except NoReference as absent:
return Measurement(
kind="scale", value=None, unit="px/mm", usable=False,
detail=str(absent),
)
return Measurement(
kind="scale", value=round(found.pixels_per_mm, 4), unit="px/mm",
usable=True,
support={"relative_error": round(found.relative_error, 4)},
detail=found.source,
)
def deterministic_adapters() -> list[Adapter]:
return [RespirationAdapter(), GeometryAdapter()]