From edea11b7cc634ab55b28d91e2dd4ef01bba27655 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 11 Jun 2026 08:52:52 +0200 Subject: [PATCH 1/3] feat(core): add make_forced_detections for caller-supplied bbox --- core/src/temporal_model/core/inference.py | 43 ++++++++++++ core/src/temporal_model/core/tubes.py | 13 ++-- core/tests/test_forced_detections.py | 80 +++++++++++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 core/tests/test_forced_detections.py diff --git a/core/src/temporal_model/core/inference.py b/core/src/temporal_model/core/inference.py index 08a0a84..14705dd 100644 --- a/core/src/temporal_model/core/inference.py +++ b/core/src/temporal_model/core/inference.py @@ -24,6 +24,7 @@ from .tubes import ( build_tubes, merge_colocated_tubes, + validate_roi, ) from .tubes import ( interpolate_gaps as _interpolate_gaps, @@ -34,6 +35,7 @@ "pad_frames_symmetrically", "pad_frames_uniform", "run_yolo_on_frames", + "make_forced_detections", "filter_and_interpolate_tubes", "crop_tube_patches", "score_tubes", @@ -181,6 +183,47 @@ def run_yolo_on_frames( return out +def make_forced_detections( + frames: list[Frame], + *, + bbox_xyxyn: tuple[float, float, float, float], + confidence: float = 1.0, +) -> dict[str, FrameDetections]: + """Build single-detection :class:`FrameDetections` from one fixed bbox. + + Serving-side alternative to :func:`run_yolo_on_frames` for callers that + already localized the smoke upstream: the supplied normalized + ``(x_min, y_min, x_max, y_max)`` box becomes the only detection on every + frame, so tube building yields exactly one full-length tube and the YOLO + detector never runs. Returned keyed by ``frame_id`` to plug into + ``BboxTubeTemporalModel.predict(frame_detections=...)``, which re-stamps + each entry's ``frame_idx``. + + ``confidence`` gates nothing downstream but feeds the calibrator's + mean-confidence feature, so it shifts the calibrated probability. + """ + validate_roi(bbox_xyxyn, name="bbox") + x_min, y_min, x_max, y_max = bbox_xyxyn + return { + f.frame_id: FrameDetections( + frame_idx=idx, + frame_id=f.frame_id, + timestamp=f.timestamp, + detections=[ + Detection( + class_id=0, + cx=(x_min + x_max) / 2.0, + cy=(y_min + y_max) / 2.0, + w=x_max - x_min, + h=y_max - y_min, + confidence=confidence, + ) + ], + ) + for idx, f in enumerate(frames) + } + + def filter_and_interpolate_tubes( tubes: list[Tube], *, diff --git a/core/src/temporal_model/core/tubes.py b/core/src/temporal_model/core/tubes.py index fa20d1a..58f144f 100644 --- a/core/src/temporal_model/core/tubes.py +++ b/core/src/temporal_model/core/tubes.py @@ -552,22 +552,25 @@ def _box_rank(det: Detection) -> tuple[float, float, float]: return (_area(det), det.confidence, det.cx) -def validate_roi(roi: tuple[float, float, float, float]) -> None: - """Validate a normalized ROI rectangle, raising ``ValueError`` if invalid. +def validate_roi( + roi: tuple[float, float, float, float], *, name: str = "roi" +) -> None: + """Validate a normalized xyxyn rectangle, raising ``ValueError`` if invalid. Single source of truth for ROI validity — the API request validator and ``BboxTubeTemporalModel.predict`` both call this, so the rules cannot drift between layers. Valid: 4 values in [0, 1] with x_min < x_max and - y_min < y_max. + y_min < y_max. ``name`` labels the rectangle in error messages for callers + validating other xyxyn boxes (e.g. a forced detection bbox). """ if len(roi) != 4: raise ValueError( - f"invalid roi {roi!r}: expected 4 values (x_min, y_min, x_max, y_max)" + f"invalid {name} {roi!r}: expected 4 values (x_min, y_min, x_max, y_max)" ) x_min, y_min, x_max, y_max = roi if not all(0.0 <= c <= 1.0 for c in roi) or x_min >= x_max or y_min >= y_max: raise ValueError( - f"invalid roi {roi!r}: expected normalized " + f"invalid {name} {roi!r}: expected normalized " "(x_min, y_min, x_max, y_max) with x_min < x_max and y_min < y_max" ) diff --git a/core/tests/test_forced_detections.py b/core/tests/test_forced_detections.py new file mode 100644 index 0000000..da9cb4d --- /dev/null +++ b/core/tests/test_forced_detections.py @@ -0,0 +1,80 @@ +"""Tests for make_forced_detections (caller-supplied bbox, no YOLO).""" + +from unittest.mock import MagicMock + +import pytest + +# Reuse the shared fixtures/helpers from the edge-case suite. +from test_model_edge_cases import ( # type: ignore[import-not-found] + TEST_CONFIG, + red_frames, + tiny_classifier, +) + +from temporal_model.core.inference import make_forced_detections +from temporal_model.core.model import BboxTubeTemporalModel + +__all__ = ["red_frames", "tiny_classifier"] # keep fixtures importable + +BBOX = (0.4, 0.4, 0.6, 0.6) + + +def test_one_single_detection_entry_per_frame(red_frames): + dets = make_forced_detections(red_frames, bbox_xyxyn=BBOX) + + assert set(dets) == {f.frame_id for f in red_frames} + for idx, f in enumerate(red_frames): + fd = dets[f.frame_id] + assert fd.frame_idx == idx + assert fd.frame_id == f.frame_id + assert len(fd.detections) == 1 + + +def test_xyxyn_converted_to_center_format(red_frames): + dets = make_forced_detections( + red_frames, bbox_xyxyn=(0.1, 0.2, 0.5, 0.8), confidence=0.7 + ) + + d = dets[red_frames[0].frame_id].detections[0] + assert d.cx == pytest.approx(0.3) + assert d.cy == pytest.approx(0.5) + assert d.w == pytest.approx(0.4) + assert d.h == pytest.approx(0.6) + assert d.confidence == 0.7 + + +def test_confidence_defaults_to_one(red_frames): + dets = make_forced_detections(red_frames, bbox_xyxyn=BBOX) + assert dets[red_frames[0].frame_id].detections[0].confidence == 1.0 + + +@pytest.mark.parametrize( + "bbox", + [ + (0.6, 0.4, 0.4, 0.6), # x_min >= x_max + (0.4, 0.6, 0.6, 0.4), # y_min >= y_max + (-0.1, 0.0, 0.5, 0.5), # out of [0, 1] + ], +) +def test_invalid_bbox_raises(red_frames, bbox): + with pytest.raises(ValueError, match="invalid bbox"): + make_forced_detections(red_frames, bbox_xyxyn=bbox) + + +def test_predict_with_forced_detections_skips_yolo_and_builds_one_tube( + red_frames, tiny_classifier +): + yolo = MagicMock() + model = BboxTubeTemporalModel( + yolo_model=yolo, classifier=tiny_classifier, config=TEST_CONFIG + ) + + forced = make_forced_detections(red_frames, bbox_xyxyn=BBOX) + out = model.predict(frames=red_frames, frame_detections=forced) + + yolo.predict.assert_not_called() + kept = out.details["tubes"]["kept"] + assert len(kept) == 1 + assert kept[0]["start_frame"] == 0 + assert kept[0]["end_frame"] == len(red_frames) - 1 + assert all(not e["is_gap"] for e in kept[0]["entries"]) From d6f2f972b8c988ddf96fab90f398fca7d7d86f58 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 11 Jun 2026 08:59:03 +0200 Subject: [PATCH 2/3] feat(api): accept bbox_xyxyn to skip YOLO with a caller-supplied detection --- api/src/temporal_model/api/app.py | 7 +- api/src/temporal_model/api/model_runner.py | 37 ++++++++++- api/src/temporal_model/api/schemas.py | 45 ++++++++++++- api/tests/test_app.py | 74 +++++++++++++++++++++- api/tests/test_model_runner.py | 44 +++++++++++++ api/tests/test_schemas.py | 62 ++++++++++++++++++ core/src/temporal_model/core/tubes.py | 4 +- 7 files changed, 264 insertions(+), 9 deletions(-) diff --git a/api/src/temporal_model/api/app.py b/api/src/temporal_model/api/app.py index 1f5d823..fa489a1 100644 --- a/api/src/temporal_model/api/app.py +++ b/api/src/temporal_model/api/app.py @@ -144,7 +144,12 @@ async def predict( ) out = await runner.predict( - paths, roi=body.roi_xyxyn, timer=timer, profile=profile + paths, + roi=body.roi_xyxyn, + bbox=body.bbox_xyxyn, + bbox_confidence=body.bbox_confidence, + timer=timer, + profile=profile, ) profiling = None diff --git a/api/src/temporal_model/api/model_runner.py b/api/src/temporal_model/api/model_runner.py index d2ba875..8a88f87 100644 --- a/api/src/temporal_model/api/model_runner.py +++ b/api/src/temporal_model/api/model_runner.py @@ -122,6 +122,8 @@ async def predict( frame_paths: list[Path], *, roi: tuple[float, float, float, float] | None = None, + bbox: tuple[float, float, float, float] | None = None, + bbox_confidence: float = 1.0, timer: StageTimer | None = None, profile: dict[str, Any] | None = None, ) -> Any: @@ -131,22 +133,53 @@ async def predict( cache is accessed by one prediction at a time. When ``timer``/``profile`` are supplied, the ``detector`` stage is timed and cache counts recorded. ``roi`` is passed through to the core model untouched — the cache stays - full-frame (see the invariant in the ROI spec). + full-frame (see the invariant in the ROI spec). When ``bbox`` is set, + the detector and its cache are bypassed entirely: the box (stamped with + ``bbox_confidence``) is the only detection on every frame. """ async with self._lock: return await run_in_threadpool( - self._predict_sync, frame_paths, roi, timer, profile + self._predict_sync, + frame_paths, + roi, + bbox, + bbox_confidence, + timer, + profile, ) def _predict_sync( self, frame_paths: list[Path], roi: tuple[float, float, float, float] | None = None, + bbox: tuple[float, float, float, float] | None = None, + bbox_confidence: float = 1.0, timer: StageTimer | None = None, profile: dict[str, Any] | None = None, ) -> Any: started = time.perf_counter() frames = self._model.load_sequence(frame_paths) + if bbox is not None: + # Heavy core import deferred like _load_core_model. + from temporal_model.core.inference import ( # noqa: PLC0415 + make_forced_detections, + ) + + forced = make_forced_detections( + frames, bbox_xyxyn=bbox, confidence=bbox_confidence + ) + out = self._model.predict( + frames, frame_detections=forced, roi=roi, timer=timer + ) + if profile is not None: + profile["n_frames"] = len(frames) + profile["forced_bbox"] = True + logger.info( + "predict: forced bbox, seq_len=%d, %.0fms", + len(frames), + (time.perf_counter() - started) * 1000.0, + ) + return out resolved: dict[str, Any] = {} misses = [] for f in frames: diff --git a/api/src/temporal_model/api/schemas.py b/api/src/temporal_model/api/schemas.py index dd2a124..fcb006d 100644 --- a/api/src/temporal_model/api/schemas.py +++ b/api/src/temporal_model/api/schemas.py @@ -8,7 +8,13 @@ import re from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) from temporal_model.core.tubes import validate_roi @@ -31,6 +37,18 @@ class PredictRequest(BaseModel): # detection intersecting it are dropped before scoring (see # docs/specs/2026-06-10-api-roi-design.md). roi_xyxyn: tuple[float, float, float, float] | None = None + # Optional caller-supplied detection box, normalized corners + # (x_min, y_min, x_max, y_max) — same xyxyn convention as roi_xyxyn. When + # set, the YOLO detector is skipped entirely and this box is taken as the + # only detection on every frame, yielding one full-length tube (see + # docs/specs/2026-06-11-api-forced-bbox-design.md). Mutually exclusive + # with roi_xyxyn: the bbox already pins where the smoke is. + bbox_xyxyn: tuple[float, float, float, float] | None = None + # Confidence stamped on the forced detections (e.g. the upstream + # detector's score). Gates nothing, but feeds the calibrator's + # mean-confidence feature, so it shifts the returned probability. Only + # meaningful alongside bbox_xyxyn. + bbox_confidence: float = Field(default=1.0, gt=0.0, le=1.0) @field_validator("frames") @classmethod @@ -68,6 +86,31 @@ def _validate_roi( raise ValueError(f"roi_xyxyn: {e}") from e return v + @field_validator("bbox_xyxyn") + @classmethod + def _validate_bbox( + cls, v: tuple[float, float, float, float] | None + ) -> tuple[float, float, float, float] | None: + if v is None: + return v + # Same geometry rules as the ROI (shared core validator). + try: + validate_roi(v, name="bbox") + except ValueError as e: + raise ValueError(f"bbox_xyxyn: {e}") from e + return v + + @model_validator(mode="after") + def _validate_bbox_combinations(self) -> "PredictRequest": + if self.bbox_xyxyn is not None and self.roi_xyxyn is not None: + raise ValueError( + "bbox_xyxyn and roi_xyxyn are mutually exclusive: the bbox is " + "the detection, an ROI filter on top of it is meaningless" + ) + if self.bbox_xyxyn is None and "bbox_confidence" in self.model_fields_set: + raise ValueError("bbox_confidence requires bbox_xyxyn") + return self + class FrameEntry(BaseModel): frame_idx: int diff --git a/api/tests/test_app.py b/api/tests/test_app.py index bcf0878..67bcc17 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -66,9 +66,22 @@ def __init__(self, output=None, error=None): self._output = output self._error = error self.roi = None - - async def predict(self, paths, *, roi=None, timer=None, profile=None): + self.bbox = None + self.bbox_confidence = None + + async def predict( + self, + paths, + *, + roi=None, + bbox=None, + bbox_confidence=1.0, + timer=None, + profile=None, + ): self.roi = roi + self.bbox = bbox + self.bbox_confidence = bbox_confidence if self._error: raise self._error if timer is not None: @@ -412,3 +425,60 @@ def test_predict_invalid_roi_is_400(client): body = r.json() assert body["code"] == "invalid_request" assert "roi_xyxyn" in body["detail"] + + +def test_predict_passes_bbox_to_runner(client): + r = client.post( + "/predict", + json={ + "frames": KEYS, + "bbox_xyxyn": [0.1, 0.2, 0.3, 0.4], + "bbox_confidence": 0.8, + }, + ) + assert r.status_code == 200 + assert client.app.state.runner.bbox == (0.1, 0.2, 0.3, 0.4) + assert client.app.state.runner.bbox_confidence == 0.8 + + +def test_predict_bbox_confidence_defaults_to_one(client): + r = client.post( + "/predict", json={"frames": KEYS, "bbox_xyxyn": [0.1, 0.2, 0.3, 0.4]} + ) + assert r.status_code == 200 + assert client.app.state.runner.bbox_confidence == 1.0 + + +def test_predict_without_bbox_passes_none(client): + r = client.post("/predict", json={"frames": KEYS}) + assert r.status_code == 200 + assert client.app.state.runner.bbox is None + + +def test_predict_invalid_bbox_is_400(client): + r = client.post( + "/predict", json={"frames": KEYS, "bbox_xyxyn": [0.3, 0.2, 0.1, 0.4]} + ) + assert r.status_code == 400 + body = r.json() + assert body["code"] == "invalid_request" + assert "bbox_xyxyn" in body["detail"] + + +def test_predict_bbox_with_roi_is_400(client): + r = client.post( + "/predict", + json={ + "frames": KEYS, + "bbox_xyxyn": [0.1, 0.2, 0.3, 0.4], + "roi_xyxyn": [0.0, 0.0, 1.0, 1.0], + }, + ) + assert r.status_code == 400 + assert "mutually exclusive" in r.json()["detail"] + + +def test_predict_bbox_confidence_without_bbox_is_400(client): + r = client.post("/predict", json={"frames": KEYS, "bbox_confidence": 0.9}) + assert r.status_code == 400 + assert "bbox_confidence" in r.json()["detail"] diff --git a/api/tests/test_model_runner.py b/api/tests/test_model_runner.py index a30f036..5cd7d4d 100644 --- a/api/tests/test_model_runner.py +++ b/api/tests/test_model_runner.py @@ -3,6 +3,7 @@ from pathlib import Path from types import SimpleNamespace +import pytest import yaml from temporal_model.api import model_runner as mr @@ -121,6 +122,7 @@ def __init__(self): self.detect_calls: list[list[str]] = [] self.predict_calls: list[set[str]] = [] self.roi_calls: list[tuple | None] = [] + self.frame_detections_calls: list[dict] = [] def load_sequence(self, paths): return [ @@ -140,6 +142,7 @@ def detect(self, frames): def predict(self, frames, *, frame_detections=None, roi=None, timer=None): self.predict_calls.append(set(frame_detections or {})) self.roi_calls.append(roi) + self.frame_detections_calls.append(frame_detections or {}) return SimpleNamespace(frame_ids=[f.frame_id for f in frames]) @@ -191,6 +194,47 @@ def test_predict_cache_disabled_detects_every_frame(): assert model.detect_calls[1] == ["x_00", "x_01", "x_02"] # full each call +def test_predict_with_bbox_skips_detection(): + model = _OrchestrationModel() + runner = ModelRunner(model, name="m", version="1", calibrated=True) + asyncio.run( + runner.predict( + ["c/x_00.jpg", "c/x_01.jpg"], + bbox=(0.1, 0.2, 0.5, 0.8), + bbox_confidence=0.7, + ) + ) + + assert model.detect_calls == [] + forced = model.frame_detections_calls[-1] + assert set(forced) == {"x_00", "x_01"} + det = forced["x_00"].detections[0] + assert (det.cx, det.cy, det.w, det.h) == pytest.approx((0.3, 0.5, 0.4, 0.6)) + assert det.confidence == 0.7 + + +def test_predict_with_bbox_bypasses_cache(): + model = _OrchestrationModel() + runner = ModelRunner( + model, name="m", version="1", calibrated=True, detection_cache_size=4096 + ) + asyncio.run(runner.predict(["c/x_00.jpg"], bbox=(0.1, 0.2, 0.3, 0.4))) + asyncio.run(runner.predict(["c/x_00.jpg"])) + + # The forced run wrote nothing to the cache: the plain run re-detects. + assert model.detect_calls == [["x_00"]] + + +def test_predict_with_bbox_records_profile_marker(): + model = _OrchestrationModel() + runner = ModelRunner(model, name="m", version="1", calibrated=True) + profile: dict = {} + asyncio.run( + runner.predict(["c/x_00.jpg"], bbox=(0.1, 0.2, 0.3, 0.4), profile=profile) + ) + assert profile == {"n_frames": 1, "forced_bbox": True} + + class _StubFrame: def __init__(self, fid): self.frame_id = fid diff --git a/api/tests/test_schemas.py b/api/tests/test_schemas.py index 990310c..92b14c5 100644 --- a/api/tests/test_schemas.py +++ b/api/tests/test_schemas.py @@ -227,6 +227,68 @@ def test_request_rejects_invalid_roi(roi): PredictRequest(frames=["a.jpg"], roi_xyxyn=roi) +def test_request_bbox_defaults_to_none(): + req = PredictRequest(frames=["a.jpg"]) + assert req.bbox_xyxyn is None + assert req.bbox_confidence == 1.0 + + +def test_request_accepts_valid_bbox(): + req = PredictRequest( + frames=["a.jpg"], bbox_xyxyn=[0.1, 0.2, 0.3, 0.4], bbox_confidence=0.7 + ) + assert req.bbox_xyxyn == (0.1, 0.2, 0.3, 0.4) + assert req.bbox_confidence == 0.7 + + +@pytest.mark.parametrize( + "bbox", + [ + [-0.1, 0.2, 0.3, 0.4], # out of range low + [0.1, 0.2, 0.3, 1.4], # out of range high + [0.3, 0.2, 0.1, 0.4], # x_min >= x_max + [0.1, 0.4, 0.3, 0.4], # y_min >= y_max (zero height) + [0.1, 0.2, 0.3], # too short + [0.1, 0.2, 0.3, 0.4, 0.5], # too long + ["a", 0.2, 0.3, 0.4], # non-numeric + ], +) +def test_request_rejects_invalid_bbox(bbox): + with pytest.raises(ValidationError): + PredictRequest(frames=["a.jpg"], bbox_xyxyn=bbox) + + +@pytest.mark.parametrize("confidence", [0.0, -0.5, 1.5]) +def test_request_rejects_out_of_range_bbox_confidence(confidence): + with pytest.raises(ValidationError): + PredictRequest( + frames=["a.jpg"], + bbox_xyxyn=[0.1, 0.2, 0.3, 0.4], + bbox_confidence=confidence, + ) + + +def test_request_rejects_bbox_with_roi(): + with pytest.raises(ValidationError, match="mutually exclusive"): + PredictRequest( + frames=["a.jpg"], + bbox_xyxyn=[0.1, 0.2, 0.3, 0.4], + roi_xyxyn=[0.0, 0.0, 1.0, 1.0], + ) + + +def test_request_rejects_bbox_confidence_without_bbox(): + with pytest.raises(ValidationError, match="bbox_confidence requires"): + PredictRequest(frames=["a.jpg"], bbox_confidence=0.9) + + +def test_request_allows_default_equal_bbox_confidence_only_implicitly(): + # An explicit bbox_confidence is rejected without bbox_xyxyn even when it + # equals the default — explicitness, not the value, marks the client bug. + with pytest.raises(ValidationError, match="bbox_confidence requires"): + PredictRequest(frames=["a.jpg"], bbox_confidence=1.0) + + def test_verbose_details_map_num_tubes_outside_roi(): details = _details([_tube(1, 0.9)]) details["tubes"]["num_outside_roi"] = 3 diff --git a/core/src/temporal_model/core/tubes.py b/core/src/temporal_model/core/tubes.py index 58f144f..7c0400e 100644 --- a/core/src/temporal_model/core/tubes.py +++ b/core/src/temporal_model/core/tubes.py @@ -552,9 +552,7 @@ def _box_rank(det: Detection) -> tuple[float, float, float]: return (_area(det), det.confidence, det.cx) -def validate_roi( - roi: tuple[float, float, float, float], *, name: str = "roi" -) -> None: +def validate_roi(roi: tuple[float, float, float, float], *, name: str = "roi") -> None: """Validate a normalized xyxyn rectangle, raising ``ValueError`` if invalid. Single source of truth for ROI validity — the API request validator and From 6875b90b7ffbe3ddf90b663eb69315936c465b0d Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 11 Jun 2026 09:00:21 +0200 Subject: [PATCH 3/3] docs: spec and README entry for the forced-bbox predict option --- api/README.md | 8 +- .../2026-06-11-api-forced-bbox-design.md | 81 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 docs/specs/2026-06-11-api-forced-bbox-design.md diff --git a/api/README.md b/api/README.md index ee6e972..719a3e9 100644 --- a/api/README.md +++ b/api/README.md @@ -9,10 +9,14 @@ Import as `temporal_model.api`. Depends on `temporal-model-core`. - `GET /health` — readiness + loaded model name/version. - `POST /predict` — body `{ "frames": ["", ...], "bucket": "", - "roi_xyxyn": [x_min, y_min, x_max, y_max] }` + "roi_xyxyn": [x_min, y_min, x_max, y_max], + "bbox_xyxyn": [x_min, y_min, x_max, y_max], "bbox_confidence": 0.8 }` (ordered S3 keys; `bucket` optional, falls back to `S3_BUCKET`; `roi_xyxyn` optional normalized region of interest — tubes with no real - detection intersecting it are dropped before scoring); + detection intersecting it are dropped before scoring; + `bbox_xyxyn` optional caller-supplied detection — skips YOLO entirely, the + box becomes the only detection on every frame, mutually exclusive with + `roi_xyxyn`; `bbox_confidence` optional score stamped on it, default 1.0); returns `{ is_smoke, probability, model }` (`probability` = max kept-tube calibrated probability, `null` if uncalibrated). `POST /predict?verbose=true` adds a `details` block (decision, preprocessing, diff --git a/docs/specs/2026-06-11-api-forced-bbox-design.md b/docs/specs/2026-06-11-api-forced-bbox-design.md new file mode 100644 index 0000000..c30bdf8 --- /dev/null +++ b/docs/specs/2026-06-11-api-forced-bbox-design.md @@ -0,0 +1,81 @@ +# API: Caller-supplied detection bbox on `/predict` (`bbox_xyxyn`) + +**Date:** 2026-06-11 +**Status:** Approved + +## Motivation + +The platform calls `/predict` to confirm an alert its own detector already +localized: it holds a bounding box for the smoke. Today the API re-runs the +bundled YOLO over every frame to rediscover what the caller already knows — +the dominant cost of a cold request. Letting the caller supply that box +directly skips detection entirely and answers faster, while still running the +full temporal pipeline (tube → stabilized crops → ViT → calibrator). + +## Decisions + +1. **The box is the detection, not a filter.** Unlike `roi_xyxyn` (which + filters tubes built from YOLO detections), `bbox_xyxyn` replaces detection: + the same box becomes the single detection on every frame. The tube builder + then yields exactly one full-length tube (IoU = 1.0 across frames, no + gaps), and crop/classify/calibrate run unchanged. +2. **Ride the existing injection seam.** The core model already accepts + pre-supplied detections via `predict(frame_detections=...)` (the detection + cache uses it). A new pure helper, + `make_forced_detections(frames, bbox_xyxyn, confidence)` in + `core/inference.py`, builds the synthetic `FrameDetections`; there is no + second inference path to maintain. +3. **Same convention as the ROI.** `bbox_xyxyn` is + `[x_min, y_min, x_max, y_max]` normalized (ultralytics `xyxyn`), validated + by the shared `validate_roi` core rules. Internally it is converted once to + the center-based `Detection` format. +4. **Mutually exclusive with `roi_xyxyn`** (400). The bbox already pins where + the smoke is; an ROI filter on top of it is meaningless and likely a + client bug. +5. **Optional `bbox_confidence`, default 1.0.** Stamped on every synthetic + detection. It gates nothing downstream (tube building has no confidence + threshold) but feeds the calibrator's mean-confidence feature, so callers + passing their upstream detector's score get a probability consistent with + it. Must be in (0, 1]; rejected (400) when sent without `bbox_xyxyn`. +6. **Detection cache bypassed entirely.** The forced run neither reads nor + writes the cache — synthetic detections must never masquerade as real YOLO + output for later requests, and the cache invariant stays "full-frame YOLO + detections only". + +## API contract + +```json +{ + "frames": ["..."], + "bucket": "...", + "bbox_xyxyn": [0.30, 0.35, 0.50, 0.55], + "bbox_confidence": 0.82 +} +``` + +Validation (400 `invalid_request`): geometry rules shared with `roi_xyxyn` +(4 values in [0, 1], `x_min < x_max`, `y_min < y_max`); `bbox_confidence` in +(0, 1] and only alongside `bbox_xyxyn`; `bbox_xyxyn` + `roi_xyxyn` rejected. + +Response shape is unchanged. With profiling on, the server-side `profiling` +block reports `forced_bbox: true` instead of cache counters, and the +`detector` stage is absent (never ran). + +## Implementation map + +- `core/inference.py` — `make_forced_detections()` (pure, unit-tested). +- `core/tubes.py` — `validate_roi(..., name=...)` so error messages name the + field being validated. +- `api/schemas.py` — `bbox_xyxyn` / `bbox_confidence` fields + cross-field + validation. +- `api/model_runner.py` — `predict(bbox=..., bbox_confidence=...)` short + circuit: build forced detections, skip cache and `detect()`. +- `api/app.py` — threads the fields from the request body to the runner. + +## Limits + +- One box per request. Multiple simultaneous alerts mean multiple requests + (or a future `bboxes_xyxyn` extension if the need appears). +- The box is static across frames. Smoke drifting out of the (context- + expanded) crop window over a long sequence is on the caller; the stabilized + crop already tolerates in-window motion by design.