animap-gpu / app /counting.py
bluman1's picture
Publish services/inference
4b98524 verified
Raw
History Blame Contribute Delete
23.2 kB
"""Counting, and knowing when not to.
This is the layer that turns boxes into a claim. It exists separately from
`detection.py` because the hard part of counting livestock is not detection — it
is being honest about the frames where detection stops working.
Two capabilities share every line of it:
**`cattle_detection`** counts cattle in a paddock. Cattle are large, separated,
and there are tens of them. A COCO detector already has a `cow` class and this
is genuinely the right tool.
**`poultry_count`** counts birds in a frame. For a backyard flock or a yard of
fifty layers, the same detector works. For 12,000 broilers in a shed it does
not, and no threshold tuning will make it: the birds overlap, each one covers a
few hundred pixels, and non-maximum suppression merges the ones that remain. The
answer is a density head, not a better detector (ADR 0014).
So this module measures whether it is still in the regime it was validated for,
and when it is not, **it reports no count at all**. A sample presented as a count
is the failure mode that costs the product its credibility: a farmer shown "18"
for a shed of several hundred does not conclude that the number means something
narrower than they thought.
**How it knows.** It counts the frame three ways — whole, 2x2, 3x3 — and watches
what the count does (`app/tiling.py`). In a frame the detector can read, the
count stops moving, because there was nothing left to find. In a shed it never
stops, because there are always more birds behind the ones in front. That is a
measurement of what the detector is *missing*, which is the thing a saturation
guard has to know and the thing box sizes cannot tell it.
The first version of this file guarded on box size instead, and the evaluation
set caught it: on a broiler house of a thousand birds it found a handful of large
foreground birds, concluded the frame was sparse, and published the handful as a
count. It withheld a number on three of twenty uncountable frames; the tiled test
withholds on twenty of twenty (ADR 0018).
**The thresholds belong to the detector, not to the problem.** They were first
derived on YOLO11m and then inherited unchanged when the shipped model became
YOLOX-m, which cost 22 points of coverage for no gain in safety — the grid a
frame settles at depends on how much the detector's whole-frame pass resolves,
and that is precisely what differs between detectors. Re-deriving them removed a
rule entirely and restored the coverage. If the artefact changes again, re-run
`evaluation/run.py` before trusting a number in this file.
"""
from __future__ import annotations
from dataclasses import dataclass
from statistics import median
from uuid import UUID, uuid4
from app.capabilities import Capability, FORBIDDEN_CLAIMS
from app.detectors import Detection, build
from app.media import MediaRef, MediaStore
from app.providers import ModelArtefact
from app.quality import QualityVerdict, assess
from app.schemas import (
ConfidenceLabel,
InferenceLocation,
InferenceRequest,
InferenceResult,
Observation,
QualityCheck,
)
from app.tiling import Level, converged, pyramid, subject_count
#: How much the count may grow when the frame is cut finer before the frame is
#: called unreadable.
#:
#: **Every number below was re-measured on the shipped YOLOX-m artefact** over
#: the 61-image set (ADR 0018). They previously came from YOLO11m, and carrying
#: them across backends was wrong: the grid at which a frame settles depends on
#: how much the detector's whole-frame pass can resolve, which is exactly what
#: differs between detectors. Re-deriving them moved coverage from 0.71 to 0.935
#: without letting a single dense frame through.
#:
#: **The two classes overlap here, and the honest reading is that this rule does
#: not separate them on its own.** Last-refinement growth runs 0%–75% on frames a
#: human could count and 7%–162% on frames a human could not. At 20% it catches
#: seventeen of the twenty uncountable frames and wrongly withholds two countable
#: ones; the two dense frames that slip past it are caught by
#: `MAX_VALIDATED_COUNT`. Neither rule is sufficient alone, which is why they are
#: OR-ed rather than tuned against each other.
COUNT_GROWTH_TOLERANCE = 0.20
#: Median share of the frame a subject covers, below which no count is reported
#: *even after the count has settled*. A frame can settle simply because every
#: animal in it is a smudge the detector resolves the same way at every grid.
#:
#: **This rule never fires on the evaluation set, so it carries no evidence.**
#: Measured at the grid that ran: the smallest median on a countable frame is
#: 0.00077 (a hillside herd in the Turkish Eğribel pass) and the smallest on an
#: uncountable one is 0.00042 — but every frame it would have caught was already
#: withheld by convergence. 0.0006 sits in a gap two frames wide.
#:
#: It is kept as a backstop for a frame type this set does not contain, and it is
#: labelled unexercised rather than described as if it were doing work. Do not
#: cite it as a reason the guard is safe.
SATURATION_MEDIAN_AREA_FRACTION = 0.0006
#: Median detection score below which the count is reported but capped at `low`
#: confidence and a recapture is asked for. Measured on the fixtures: cattle
#: 0.72, sparse hens 0.49, dense hens 0.47. Sparse and dense are only 0.02
#: apart, so this cannot decide whether to publish a number — it can only decide
#: how much to trust one.
LOW_CONFIDENCE_MEDIAN_SCORE = 0.50
#: Confusable detections per subject, above which the frame is *reported* as
#: class-confused. **This is an observation, not a suppression**, and it used to
#: be the latter.
#:
#: As a suppression rule it was measured net-harmful: on the 61-image set it
#: withheld exactly one count — `cattle_ng_red_bororo`, three Red Bororo cattle,
#: where the detector found all three and also called some of them `horse` — and
#: it caught none of the twenty uncountable frames, because convergence and the
#: count ceiling had already taken all twenty. It cost a correct answer on a
#: Nigerian frame and bought nothing.
#:
#: The signal is still worth recording: it was real when the small backend called
#: seven of thirteen hens `sheep`, and a future artefact may bring it back. So it
#: rides along as `confusable_detections` and a warning, where it informs a
#: reader without silently deleting a number.
CONFUSION_RATIO = 1.0
#: Minimum subjects before that ratio means anything. Below this a single
#: mislabelled animal would trip it.
CONFUSION_MIN_SUBJECTS = 3
#: The largest count that has been checked against ground truth. Above it the
#: service reports no number — not because a larger count is necessarily wrong,
#: but because nobody has ever verified one.
#:
#: **Load-bearing, and tight.** It is what catches the two dense frames whose
#: counts settle anyway: a Swedish free-range yard that stabilises at 32 birds
#: and a Karamoja kraal that stabilises at 30, both holding many times that. The
#: largest correctly published count on the set is 20 animals against a human
#: count of 19, so the threshold sits directly on the edge of the evidence
#: rather than at a comfortable distance from it.
#:
#: Raise it by measuring more frames, not by deciding the detector is probably
#: fine up there.
MAX_VALIDATED_COUNT = 20
HIGH_CONFIDENCE_SCORE = 0.70
MEDIUM_CONFIDENCE_SCORE = 0.50
#: Share of the frame one subject must cover before the per-animal capabilities
#: — weight, body condition, skin — have something they could work with. At 15%
#: of a 1080-line capture the animal is roughly 400 px across, which is the point
#: below which a girth measured off it is noise rather than a measurement.
ISOLATION_MIN_AREA_FRACTION = 0.15
@dataclass(frozen=True)
class CountingProfile:
"""Everything that differs between counting cattle and counting birds."""
capability_key: str
subject_noun: str
count_observation: str
#: COCO classes that are the subject. Only these are counted.
subject_classes: tuple[str, ...]
#: COCO classes the detector reaches for when it can no longer tell what it
#: is looking at. **Never counted** — they are a signal, not a subject.
#: The shipped YOLOX-m does not confuse them on any of the 61 evaluation
#: images, so the signal is unexercised: see `CONFUSION_MIN_SUBJECTS`.
confusable_classes: tuple[str, ...]
unit: str
#: Said on every run, whatever the result. These are the claims the brief
#: forbids, stated before anyone can misread the number.
standing_warning: str
saturation_warning: str
#: Whether a single well-framed subject is what this capability is for.
reports_isolation: bool = False
CATTLE_DETECTION = CountingProfile(
capability_key="cattle_detection",
subject_noun="cattle",
count_observation="cattle_visible",
subject_classes=("cow",),
confusable_classes=("horse", "sheep"),
unit="animals",
standing_warning=(
"A count of the animals visible in this frame. It is not the herd size — "
"animals behind others, behind cover, or out of frame are not in it."
),
saturation_warning=(
"There are more cattle in this frame than can be counted from it. No "
"number is reported. Move closer, or frame a smaller part of the herd."
),
reports_isolation=True,
)
POULTRY_COUNT = CountingProfile(
capability_key="poultry_count",
subject_noun="birds",
count_observation="birds_visible",
subject_classes=("bird",),
confusable_classes=("sheep", "cat", "dog"),
unit="birds",
standing_warning=(
"A count of the birds visible in this frame. It is never the flock "
"population, and it must not be used to reconcile a house."
),
saturation_warning=(
"This flock is denser than a detector can count. No number is reported, "
"because a detector undercounts a crowded shed by an amount nobody can "
"estimate. Frame a smaller section, closer in."
),
)
class DetectionCountRunner:
"""Runs one counting capability against one frame."""
def __init__(self, profile: CountingProfile) -> None:
self.profile = profile
def run(
self,
*,
request: InferenceRequest,
capability: Capability,
artefact: ModelArtefact,
store: MediaStore,
request_id: UUID | None = None,
) -> InferenceResult:
profile = self.profile
request_id = request_id or uuid4()
warnings: list[str] = [profile.standing_warning]
# These capabilities read one frame. Saying so beats silently ignoring
# the rest, and beats pretending a count was aggregated across them.
if len(request.media_ids) > capability.frames_required:
warnings.append(
f"{len(request.media_ids)} frames were supplied; this capability "
f"reads {capability.frames_required}."
)
# `farm_id` and `captured_at` are what let a blob store find the object
# in one request instead of scanning a prefix (`app/media.py`). A local
# store ignores them.
image = store.open_image(MediaRef(
media_id=request.media_ids[0],
farm_id=request.farm_id,
captured_at=request.captured_at,
object_path=request.path_for(request.media_ids[0]),
))
verdict = assess(image)
if verdict.blocked:
return self._blocked(request, capability, artefact, verdict, warnings, request_id)
detector = build(artefact)
levels = pyramid(
detector, image, profile.subject_classes, COUNT_GROWTH_TOLERANCE,
)
final = levels[-1]
settled = len(levels) < 2 or converged(
levels[-2], levels[-1], profile.subject_classes, COUNT_GROWTH_TOLERANCE,
)
subjects = [d for d in final.detections if d.label in profile.subject_classes]
confusable = [d for d in final.detections if d.label in profile.confusable_classes]
checks = list(verdict.checks)
checks.append(self._framing_check(subjects))
checks.append(self._convergence_check(levels, profile, settled))
if profile.reports_isolation:
checks.append(self._isolation_check(subjects))
saturation = self._withhold_reason(subjects, confusable, settled)
observations: list[Observation] = [
# Emitted on every path, including the ones that publish no count,
# because these three are what let a threshold be re-derived later
# from stored results instead of re-run from photographs nobody kept.
Observation(type="counting_grid", value=float(final.grid), confidence=None),
Observation(
type="subjects_detected", value=float(len(subjects)),
unit=profile.unit, confidence=None,
),
]
if subjects:
observations.append(Observation(
type="median_subject_frame_fraction",
value=round(median(d.area_fraction for d in subjects), 5),
unit="fraction", confidence=None,
))
observations.append(Observation(
type="largest_subject_frame_fraction",
value=round(max(d.area_fraction for d in subjects), 5),
unit="fraction", confidence=None,
))
observations.append(Observation(
type="confusable_detections",
value=float(len(confusable)),
confidence=None,
))
if self._is_class_confused(subjects, confusable):
# Reported, never suppressing. Measured net-harmful as a guard; see
# `CONFUSION_RATIO`.
warnings.append(
f"The detector also labelled {len(confusable)} things in this "
f"frame as another animal, which is as many as it called "
f"{profile.subject_noun}. It may be struggling to tell what it "
f"is looking at, so treat the number as a rough indication."
)
if not subjects:
# **Not a count of zero.** "We could not find any birds" and "there
# are no birds" are different claims, and on a packed broiler house
# the detector produces the first while the second would be absurd.
# Emitting no count observation is what keeps the app from rendering
# a zero it would have to defend.
warnings.append(
f"No {profile.subject_noun} were found in this frame. That is not "
f"a count of zero — it means nothing recognisable was detected. "
f"Capture again, closer in and better lit."
)
confidence = ConfidenceLabel.LOW
recapture = True
elif saturation is not None:
warnings.append(profile.saturation_warning)
observations.append(Observation(
type="count_withheld",
value=saturation,
confidence=None,
))
# `subjects_detected` above is already the floor. It is deliberately
# not named as a count anywhere on this path.
confidence = ConfidenceLabel.LOW
recapture = True
else:
mean_score = sum(d.score for d in subjects) / len(subjects)
observations.append(Observation(
type=profile.count_observation,
value=float(len(subjects)),
unit=profile.unit,
confidence=round(mean_score, 3),
))
confidence = _label(mean_score)
recapture = False
if median(d.score for d in subjects) < LOW_CONFIDENCE_MEDIAN_SCORE:
warnings.append(
f"The detector was unsure about most of these {profile.subject_noun}. "
f"Treat the number as a rough indication and capture again closer in."
)
confidence = ConfidenceLabel.LOW
recapture = True
if verdict.degraded:
# A frame the gate flagged cannot produce a high-confidence claim,
# whatever the detector's own scores say about it.
confidence = ConfidenceLabel.LOW
recapture = True
return self._result(
request=request,
capability=capability,
artefact=artefact,
request_id=request_id,
observations=observations,
confidence=confidence,
checks=checks,
warnings=warnings,
recapture=recapture,
)
def _framing_check(self, subjects: list[Detection]) -> QualityCheck:
if subjects:
return QualityCheck(check="framing", passed=True)
return QualityCheck(
check="framing", passed=False,
detail=f"No {self.profile.subject_noun} found in this frame.",
)
def _isolation_check(self, subjects: list[Detection]) -> QualityCheck:
"""Whether the per-animal capabilities could use this frame.
`cattle_detection` runs before weight, body condition and skin, and each
of those needs one animal filling the frame. Reporting that here saves a
second capture attempt later.
"""
large = [d for d in subjects if d.area_fraction >= ISOLATION_MIN_AREA_FRACTION]
if len(large) == 1:
return QualityCheck(check="subject_isolation", passed=True)
return QualityCheck(
check="subject_isolation", passed=False,
detail=(
f"{len(subjects)} animals in frame and {len(large)} close enough to "
f"assess individually. Per-animal capabilities need one animal, "
f"filling the frame."
),
)
def _convergence_check(
self, levels: list[Level], profile: CountingProfile, settled: bool
) -> QualityCheck:
counts = [subject_count(level, profile.subject_classes) for level in levels]
trail = " → ".join(
f"{level.grid}x{level.grid}: {count}" for level, count in zip(levels, counts)
)
if settled:
return QualityCheck(check="count_convergence", passed=True, detail=trail)
return QualityCheck(
check="count_convergence", passed=False,
detail=(
f"The count kept rising as the frame was read more finely "
f"({trail}), so animals are still hidden behind other animals."
),
)
def _is_class_confused(
self, subjects: list[Detection], confusable: list[Detection]
) -> bool:
"""Whether the detector is reaching for neighbouring classes as often as
the right one. A reported signal, not a reason to withhold a count."""
return (
len(subjects) >= CONFUSION_MIN_SUBJECTS
and len(confusable) >= len(subjects) * CONFUSION_RATIO
)
def _withhold_reason(
self,
subjects: list[Detection],
confusable: list[Detection],
settled: bool,
) -> str | None:
"""Whether this frame has left the regime the detector was validated in.
Any one signal is enough. They are OR-ed rather than AND-ed on purpose: a
guard that needs every signal to agree is a guard that goes quiet as soon
as one of them drifts, and going quiet here means publishing a number
that is wrong by an unknown factor.
On the 61-image set the two working signals withhold every one of the
twenty uncountable frames and wrongly withhold two of the thirty-one
countable ones. The last two signals never fire; their comments say so.
"""
if not subjects:
return None
# Seventeen of the twenty uncountable frames stop here.
if not settled:
return "count_did_not_converge"
# The other two. A frame can settle and still be a shed: the detector
# runs out of things it can resolve, so the count stops moving for the
# wrong reason. A count larger than anything ever checked is the signal.
if len(subjects) > MAX_VALIDATED_COUNT:
return "beyond_validated_range"
if median(d.area_fraction for d in subjects) < SATURATION_MEDIAN_AREA_FRACTION:
return "subjects_too_small"
return None
def _blocked(
self, request, capability, artefact, verdict: QualityVerdict, warnings, request_id
) -> InferenceResult:
failure = verdict.first_failure
warnings.append(
failure.detail if failure and failure.detail
else "The capture was not usable."
)
return self._result(
request=request,
capability=capability,
artefact=artefact,
request_id=request_id,
observations=[],
confidence=None,
checks=list(verdict.checks),
warnings=warnings,
recapture=True,
)
def _result(
self, *, request, capability, artefact, request_id, observations,
confidence, checks, warnings, recapture,
) -> InferenceResult:
forbidden = [o.type for o in observations if o.type in FORBIDDEN_CLAIMS]
if forbidden:
# Belt and braces. The registry holds these as data precisely so a
# runner can be stopped by them rather than reviewed against them.
raise ValueError(f"{capability.key} tried to emit a forbidden claim: {forbidden}")
return InferenceResult(
request_id=request_id,
capability_key=capability.key,
model_id=artefact.model_id,
model_version=artefact.version,
inference_location=InferenceLocation.REMOTE,
subject_type=request.subject_type,
subject_id=request.subject_id,
observations=observations,
# A measurement carries no interpretation. A count of animals is a
# fact about the frame; what a farmer should do about it is not
# something this model knows (ADR 0006).
interpretations=[],
observation_confidence=confidence,
interpretation_confidence=None,
quality_checks=checks,
warnings=warnings,
recommended_recapture=recapture,
)
def _label(score: float) -> ConfidenceLabel:
if score >= HIGH_CONFIDENCE_SCORE:
return ConfidenceLabel.HIGH
if score >= MEDIUM_CONFIDENCE_SCORE:
return ConfidenceLabel.MEDIUM
return ConfidenceLabel.LOW
#: Capabilities with an implemented adapter. A capability that has a validated
#: artefact but is absent from here returns 501 rather than a plausible result —
#: see `models/README.md`.
RUNNERS: dict[str, DetectionCountRunner] = {
CATTLE_DETECTION.capability_key: DetectionCountRunner(CATTLE_DETECTION),
POULTRY_COUNT.capability_key: DetectionCountRunner(POULTRY_COUNT),
}