diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index dbbbb4bd..eb5bb228 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -362,7 +362,10 @@ def create_detection( Args: media: byte data of the picture - bboxes: list of tuples where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf + bboxes: list of tuples where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf. + An empty list reports a frame with no detection: the API attaches it to recently + seen sequences of the pose (keeping their frame timeline continuous) and stores + nothing otherwise (204). pose_id: pose_id of the detection crops: optional list of cropped pictures, one per bbox (must align with `bboxes`). Each crop frames a single object, so its length must equal that of `bboxes`. @@ -370,8 +373,8 @@ def create_detection( Returns: HTTP response """ - if not isinstance(bboxes, (list, tuple)) or len(bboxes) == 0 or len(bboxes) > 5: - raise ValueError("bboxes must be a non-empty list of tuples with a maximum of 5 boxes") + if not isinstance(bboxes, (list, tuple)) or len(bboxes) > 5: + raise ValueError("bboxes must be a list of tuples with a maximum of 5 boxes") if crops is not None and len(crops) != len(bboxes): raise ValueError("crops must have the same length as bboxes") data: Dict[str, str] = { diff --git a/client/tests/test_client.py b/client/tests/test_client.py index 362e60db..e84cb15e 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -91,10 +91,11 @@ def test_cam_workflow(cam_token, cam_pose_id, mock_img): assert response.status_code == 200, response.__dict__ assert isinstance(response.json()["last_image"], str) # Check that adding bboxes works - with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): + with pytest.raises(ValueError, match="bboxes must be a list of tuples"): cam_client.create_detection(mock_img, None, pose_id=cam_pose_id) - with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): - cam_client.create_detection(mock_img, [], pose_id=cam_pose_id) + # An empty frame with no recently-seen sequence is not stored + response = cam_client.create_detection(mock_img, [], pose_id=cam_pose_id) + assert response.status_code == 204, response.__dict__ response = cam_client.create_detection(mock_img, [(0, 0, 1.0, 0.9, 0.5)], pose_id=cam_pose_id) assert response.status_code == 201, response.__dict__ response = cam_client.create_detection( @@ -114,7 +115,13 @@ def test_cam_workflow(cam_token, cam_pose_id, mock_img): pose_id=cam_pose_id, crops=[mock_img], ) - return response.json()["id"] + detection_id = response.json()["id"] + # An empty frame extends the freshly created sequence with a continuity detection + response = cam_client.create_detection(mock_img, [], pose_id=cam_pose_id) + assert response.status_code == 201, response.__dict__ + assert response.json()["bbox"] == "[]" + assert isinstance(response.json()["sequence_id"], int) + return detection_id def test_agent_workflow(test_cam_workflow, agent_token): @@ -155,4 +162,7 @@ def test_user_workflow(test_cam_workflow, user_token): assert len(response.json()) == 1 response = user_client.fetch_sequences_detections(response.json()[0]["id"]) assert response.status_code == 200, response.__dict__ - assert len(response.json()) == 4 + # 4 real detections + the continuity row added by the empty frame in test_cam_workflow + detections = response.json() + assert len(detections) == 5 + assert sum(det["bbox"] == "[]" for det in detections) == 1 diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index dad0b7f7..26fac477 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -8,7 +8,7 @@ import re from ast import literal_eval from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Set, Tuple, cast +from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast import pandas as pd from fastapi import ( @@ -18,6 +18,7 @@ Form, HTTPException, Path, + Response, Security, UploadFile, status, @@ -40,7 +41,7 @@ from app.schemas.detections import ( BOX_PATTERN, BOXES_PATTERN, - COMPILED_BOXES_PATTERN, + EMPTY_BBOXES, DetectionCreate, DetectionRead, DetectionSequence, @@ -92,20 +93,52 @@ async def _get_last_bbox_for_sequence( detections: DetectionCRUD, sequence_id: int, ) -> Optional[Tuple[float, float, float, float, float]]: - dets = await detections.fetch_all( - filters=("sequence_id", sequence_id), - order_by="created_at", - order_desc=True, - limit=1, - ) - if not dets: + # Continuity rows (empty bbox) are skipped: spatial matching must compare against the + # last real evidence, not against a frame where nothing was detected. + det = await detections.get_latest_with_bbox(sequence_id) + if det is None: return None - bbox_strs = _extract_bbox_strings(dets[0].bbox) + bbox_strs = _extract_bbox_strings(det.bbox) if not bbox_strs: return None return _parse_bbox(bbox_strs[0]) +async def _get_continuity_sequences( + sequences: SequenceCRUD, + camera_id: int, + pose_id: int, +) -> List[Sequence]: + """Sequences of the pose seen recently enough for an unmatched frame to be attached to them.""" + return await sequences.fetch_all( + filters=[("camera_id", camera_id), ("pose_id", pose_id)], + inequality_pair=( + "last_seen_at", + ">", + utcnow() - timedelta(seconds=settings.SEQUENCE_CONTINUITY_SECONDS), + ), + ) + + +async def _create_continuity_detection( + detections: DetectionCRUD, + camera_id: int, + pose_id: int, + bucket_key: str, + sequence_id: int, +) -> Detection: + """Attach a frame to a sequence whose object was not detected on it (empty bbox). + + Continuity rows keep the sequence's frame timeline gapless for the temporal model. + They never refresh last_seen_at nor max_conf: the sequence's lifetime and confidence + track real evidence only. + """ + det = await detections.create( + DetectionCreate(camera_id=camera_id, pose_id=pose_id, bucket_key=bucket_key, bbox=EMPTY_BBOXES) + ) + return await detections.update(det.id, DetectionSequence(sequence_id=sequence_id)) + + async def _get_camera_by_id( camera: Camera, cameras: CameraCRUD, @@ -340,7 +373,14 @@ async def _attach_sequence_to_alert( return alert_id -@router.post("/", status_code=status.HTTP_201_CREATED, summary="Register a new wildfire detection") +@router.post( + "/", + status_code=status.HTTP_201_CREATED, + summary="Register a new wildfire detection", + # The return annotation is not a valid response-model type (a 204 Response is returned + # for an empty frame extending no sequence), so the model is declared explicitly. + response_model=DetectionRead, +) async def create_detection( bboxes: str = Form( ..., @@ -357,11 +397,13 @@ async def create_detection( cameras: CameraCRUD = Depends(get_camera_crud), poses: PoseCRUD = Depends(get_pose_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[Role.CAMERA]), -) -> Detection: +) -> Union[Detection, Response]: telemetry_client.capture(f"camera|{token_payload.sub}", event="detections-create") - # Throw an error if the format is invalid and can't be captured by the regex - if any(box[0] >= box[2] or box[1] >= box[3] for box in COMPILED_BOXES_PATTERN.findall(bboxes)): + # The Form regex already constrains the format; parse to validate coordinate ordering on + # every box (an empty list parses to no box at all and is a valid frame with no detection). + bbox_strings = _extract_bbox_strings(bboxes) + if any(box[0] >= box[2] or box[1] >= box[3] for box in map(_parse_bbox, bbox_strings)): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="xmin & ymin are expected to be respectively smaller than xmax & ymax", @@ -374,10 +416,6 @@ async def create_detection( if not pose.active: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Pose is not active.") - bbox_strings = _extract_bbox_strings(bboxes) - if not bbox_strings: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid bbox format.") - # Validate crop/bbox alignment before any S3 upload to avoid orphan objects. # Each crop frames a single object, so there must be exactly one crop per bbox (or none at all). crops = crop_files or [] @@ -387,6 +425,22 @@ async def create_detection( detail="Number of crops must match the number of bboxes.", ) + # Frame with no detection: it only matters as continuity for recently-seen sequences of + # the pose. Without one, store nothing at all — empty frames must never seed a sequence + # (the historical placeholder bboxes did, creating phantom sequences). + if not bbox_strings: + continuity_sequences = await _get_continuity_sequences(sequences, token_payload.sub, pose_id) + if not continuity_sequences: + return Response(status_code=status.HTTP_204_NO_CONTENT) + bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) + continuity_dets: List[Detection] = [] + for seq in continuity_sequences: + continuity_dets.append( + await _create_continuity_detection(detections, token_payload.sub, pose_id, bucket_key, seq.id) + ) + await sequences.enqueue_validation(seq.id) + return DetectionRead(**continuity_dets[0].model_dump()) + # Upload media bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) crop_bucket_keys: List[Optional[str]] = [None] * len(bbox_strings) @@ -496,6 +550,15 @@ async def create_detection( created.append(det) + # Continuity pass: a recently-seen sequence of this pose whose object was not detected on + # this frame (no bbox matched it, e.g. one of two smokes faded) still gets the frame, + # attached with an empty bbox, so its frame timeline stays gapless for the temporal model. + for seq in await _get_continuity_sequences(sequences, token_payload.sub, pose_id): + if seq.id in affected_sequences: + continue + await _create_continuity_detection(detections, token_payload.sub, pose_id, bucket_key, seq.id) + affected_sequences.add(seq.id) + # Mark touched sequences due for validation (idempotent: one queue entry per sequence, # whichever uvicorn worker received the detection). The per-process validation worker # claims due sequences from the DB and runs the gated pipeline: triangulation and ALL @@ -576,7 +639,11 @@ async def delete_detection( detection = cast(Detection, await detections.get(detection_id, strict=True)) camera = cast(Camera, await cameras.get(detection.camera_id, strict=True)) bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) - bucket.delete_file(detection.bucket_key) + # The frame object is shared by every detection of the same upload (multi-bbox siblings, + # continuity rows): only delete it once no other row references it. Crops are per-row. + sharing = await detections.fetch_all(filters=("bucket_key", detection.bucket_key)) + if all(d.id == detection_id for d in sharing): + bucket.delete_file(detection.bucket_key) if detection.crop_bucket_key: bucket.delete_file(detection.crop_bucket_key) await detections.delete(detection_id) diff --git a/src/app/core/config.py b/src/app/core/config.py index 32c94c50..a744e414 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -70,6 +70,9 @@ def sqlachmey_uri(cls, v: str) -> str: SEQUENCE_RELAXATION_SECONDS: int = int(os.environ.get("SEQUENCE_RELAXATION_SECONDS") or 120 * 60) SEQUENCE_MIN_INTERVAL_DETS: int = int(os.environ.get("SEQUENCE_MIN_INTERVAL_DETS") or 3) SEQUENCE_MIN_INTERVAL_SECONDS: int = int(os.environ.get("SEQUENCE_MIN_INTERVAL_SECONDS") or 5 * 60) + # Window after a sequence's last real detection during which a frame with no matching bbox + # is still attached to it (with an empty bbox) to keep the frame timeline continuous. + SEQUENCE_CONTINUITY_SECONDS: int = int(os.environ.get("SEQUENCE_CONTINUITY_SECONDS") or 2 * 60) TRIANGULATION_RELAXATION_SECONDS: int = int(os.environ.get("TRIANGULATION_RELAXATION_SECONDS") or 30 * 60) ALERT_MERGE_MAX_DISTANCE_KM: float = float(os.environ.get("ALERT_MERGE_MAX_DISTANCE_KM") or 2.0) diff --git a/src/app/crud/crud_detection.py b/src/app/crud/crud_detection.py index 5d11c404..3de161c7 100644 --- a/src/app/crud/crud_detection.py +++ b/src/app/crud/crud_detection.py @@ -3,11 +3,15 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. +from typing import Any, Union, cast + +from sqlalchemy import desc +from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from app.crud.base import BaseCRUD from app.models import Detection -from app.schemas.detections import DetectionCreate, DetectionSequence +from app.schemas.detections import EMPTY_BBOXES, DetectionCreate, DetectionSequence __all__ = ["DetectionCRUD"] @@ -15,3 +19,15 @@ class DetectionCRUD(BaseCRUD[Detection, DetectionCreate, DetectionSequence]): def __init__(self, session: AsyncSession) -> None: super().__init__(session, Detection) + + async def get_latest_with_bbox(self, sequence_id: int) -> Union[Detection, None]: + """Latest detection of the sequence carrying a real bbox (continuity rows excluded).""" + statement: Any = ( + select(Detection) + .where(cast(Any, Detection.sequence_id) == sequence_id) + .where(cast(Any, Detection.bbox) != EMPTY_BBOXES) + .order_by(desc(cast(Any, Detection.created_at))) + .limit(1) + ) + results = await self.session.exec(statement) + return results.first() diff --git a/src/app/schemas/detections.py b/src/app/schemas/detections.py index 3c58dc5e..1243752d 100644 --- a/src/app/schemas/detections.py +++ b/src/app/schemas/detections.py @@ -21,9 +21,13 @@ class DetectionLabel(BaseModel): # Regex for a float between 0 and 1, with a maximum of 3 decimals FLOAT_PATTERN = r"(0?\.[0-9]{1,3}|0|1)" BOX_PATTERN = rf"\({FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN}\)" -BOXES_PATTERN = rf"^\[{BOX_PATTERN}(,{BOX_PATTERN})*\]$" +# An empty list is valid: a frame with no detection (kept for sequence continuity). +BOXES_PATTERN = rf"^\[({BOX_PATTERN}(,{BOX_PATTERN})*)?\]$" COMPILED_BOXES_PATTERN = re.compile(BOXES_PATTERN) +# Stored bbox of a continuity detection: a frame attached to a sequence with no detection on it. +EMPTY_BBOXES = "[]" + class DetectionCreate(BaseModel): camera_id: int = Field(..., gt=0) diff --git a/src/app/services/sequence_counts.py b/src/app/services/sequence_counts.py index 75667c9b..dc400f56 100644 --- a/src/app/services/sequence_counts.py +++ b/src/app/services/sequence_counts.py @@ -10,15 +10,18 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.models import Detection +from app.schemas.detections import EMPTY_BBOXES async def get_detection_counts_by_sequence_ids(session: AsyncSession, sequence_ids: List[int]) -> Dict[int, int]: if not sequence_ids: return {} + # Continuity rows (empty bbox) carry a frame, not a detection: don't count them. stmt: Any = ( select(cast(Any, Detection.sequence_id), func.count(cast(Any, Detection.id))) .where(cast(Any, Detection.sequence_id).in_(sequence_ids)) + .where(cast(Any, Detection.bbox) != EMPTY_BBOXES) .group_by(cast(Any, Detection.sequence_id)) ) res = await session.exec(stmt) diff --git a/src/app/services/validation.py b/src/app/services/validation.py index 64a2cdb1..e35855ab 100644 --- a/src/app/services/validation.py +++ b/src/app/services/validation.py @@ -167,12 +167,10 @@ async def _notify_for_sequence(sequence_id: int, organization_id: int, alert_id: if sequence_ is None: return camera = await CameraCRUD(session).get(sequence_.camera_id) - dets = await detections.fetch_all( - filters=("sequence_id", sequence_id), order_by="created_at", order_desc=True, limit=1 - ) - if camera is None or not dets: + # Latest real detection: continuity rows (empty bbox) must never reach a channel. + det = await detections.get_latest_with_bbox(sequence_id) + if camera is None or det is None: return - det = dets[0] org = await OrganizationCRUD(session).get(organization_id) for webhook in await WebhookCRUD(session).fetch_all(): diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index 5db7b414..10c451c8 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -213,21 +213,158 @@ async def test_create_detection_rejects_inverted_bbox( @pytest.mark.asyncio -async def test_create_detection_rejects_empty_bbox_strings( - async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes, monkeypatch +async def test_create_detection_empty_bboxes_without_active_sequence_stores_nothing( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes ): - monkeypatch.setattr(detections_api, "_extract_bbox_strings", lambda _: []) auth = pytest.get_token( pytest.camera_table[0]["id"], ["camera"], pytest.camera_table[0]["organization_id"], ) - payload = {"pose_id": pytest.pose_table[0]["id"], "bboxes": "[(0.1,0.1,0.2,0.2,0.9)]"} + payload = {"pose_id": pytest.pose_table[0]["id"], "bboxes": "[]"} + # Empty frames must never seed a sequence (no phantom sequences from no-detection frames) + for _ in range(settings.SEQUENCE_MIN_INTERVAL_DETS): + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 204, print(response.__dict__) + detection_session.expire_all() + dets = (await detection_session.exec(select(Detection))).all() + assert len(dets) == len(pytest.detection_table) + seqs = (await detection_session.exec(select(Sequence))).all() + assert len(seqs) == len(pytest.sequence_table) + + +@pytest.mark.asyncio +async def test_create_detection_empty_bboxes_rejects_crops( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + payload = {"pose_id": pytest.pose_table[0]["id"], "bboxes": "[]"} response = await async_client.post( - "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + "/detections", + data=payload, + files=[("file", ("logo.png", mock_img, "image/png")), ("crop", ("crop.png", mock_img, "image/png"))], + headers=auth, ) assert response.status_code == 422 - assert response.json()["detail"] == "Invalid bbox format." + assert response.json()["detail"] == "Number of crops must match the number of bboxes." + + +@pytest.mark.asyncio +async def test_create_detection_empty_bboxes_extends_active_sequence( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[1]["id"], + ["camera"], + pytest.camera_table[1]["organization_id"], + ) + payload = {"pose_id": 3, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]"} + for _ in range(settings.SEQUENCE_MIN_INTERVAL_DETS): + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, print(response.__dict__) + sequence_id = response.json()["sequence_id"] + assert isinstance(sequence_id, int) + detection_session.expire_all() + sequence = await detection_session.get(Sequence, sequence_id) + last_seen_before = sequence.last_seen_at + # Clear the queue marker to observe the re-enqueue triggered by the continuity row + sequence.validation_due_at = None + detection_session.add(sequence) + await detection_session.commit() + + response = await async_client.post( + "/detections", + data={"pose_id": 3, "bboxes": "[]"}, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert response.status_code == 201, print(response.__dict__) + data = response.json() + assert data["bbox"] == "[]" + assert data["sequence_id"] == sequence_id + + detection_session.expire_all() + sequence = await detection_session.get(Sequence, sequence_id) + # A continuity row is not real evidence: the sequence lifetime is untouched... + assert sequence.last_seen_at == last_seen_before + # ...but the frame set changed, so validation is due again + assert sequence.validation_due_at is not None + + # Spatial matching must survive the continuity row (compare against the last REAL bbox) + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, print(response.__dict__) + assert response.json()["sequence_id"] == sequence_id + + +@pytest.mark.asyncio +async def test_create_detection_continuity_row_for_unmatched_sequence( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[1]["id"], + ["camera"], + pytest.camera_table[1]["organization_id"], + ) + # Two disjoint objects on the same pose -> two sequences. Each frame gets distinct bytes: + # bucket keys are content+second based, so identical uploads would collide on one key. + both = {"pose_id": 3, "bboxes": "[(0.6,0.6,0.7,0.7,0.6),(0.1,0.1,0.2,0.2,0.8)]"} + for idx in range(settings.SEQUENCE_MIN_INTERVAL_DETS): + response = await async_client.post( + "/detections", + data=both, + files={"file": ("logo.png", mock_img + bytes([idx]), "image/png")}, + headers=auth, + ) + assert response.status_code == 201, print(response.__dict__) + detection_session.expire_all() + new_seqs = ( + await detection_session.exec( + select(Sequence).where(Sequence.id > max(entry["id"] for entry in pytest.sequence_table)) + ) + ).all() + assert len(new_seqs) == 2 + + # Only the first object is detected on the next frame + response = await async_client.post( + "/detections", + data={"pose_id": 3, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]"}, + files={"file": ("logo.png", mock_img + b"-final-frame", "image/png")}, + headers=auth, + ) + assert response.status_code == 201, print(response.__dict__) + matched_seq_id = response.json()["sequence_id"] + assert isinstance(matched_seq_id, int) + (unmatched_seq,) = [seq for seq in new_seqs if seq.id != matched_seq_id] + unmatched_seq_id = unmatched_seq.id + unmatched_last_seen = unmatched_seq.last_seen_at + + # The unmatched sequence still gets the frame, as a continuity row sharing the bucket_key + bucket_key = response.json()["bucket_key"] + detection_session.expire_all() + rows = ( + await detection_session.exec( + select(Detection).where(Detection.bucket_key == bucket_key) # type: ignore[attr-defined] + ) + ).all() + assert len(rows) == 2 + by_seq = {row.sequence_id: row for row in rows} + assert by_seq[matched_seq_id].bbox == "[(0.6,0.6,0.7,0.7,0.6)]" + assert by_seq[unmatched_seq_id].bbox == "[]" + # No duplicate attach: the matched sequence got the real row only + assert len(by_seq) == 2 + + unmatched_after = await detection_session.get(Sequence, unmatched_seq_id) + assert unmatched_after.last_seen_at == unmatched_last_seen @pytest.mark.asyncio @@ -343,6 +480,53 @@ async def test_get_last_bbox_for_sequence_returns_none_for_invalid_bbox(detectio assert last_bbox is None +@pytest.mark.asyncio +async def test_get_last_bbox_for_sequence_skips_continuity_rows(detection_session: AsyncSession): + detections = DetectionCRUD(detection_session) + now = utcnow() + camera_id = pytest.camera_table[0]["id"] + pose = Pose(camera_id=camera_id, azimuth=80.0) + detection_session.add(pose) + await detection_session.commit() + await detection_session.refresh(pose) + + sequence = Sequence( + camera_id=camera_id, + pose_id=pose.id, + camera_azimuth=80.0, + sequence_azimuth=80.0, + cone_angle=10.0, + started_at=now - timedelta(seconds=10), + last_seen_at=now, + ) + detection_session.add(sequence) + await detection_session.commit() + await detection_session.refresh(sequence) + + real_det = Detection( + camera_id=camera_id, + pose_id=pose.id, + sequence_id=sequence.id, + bucket_key="bbox-real", + bbox="[(0.1,0.1,0.2,0.2,0.9)]", + created_at=now - timedelta(seconds=5), + ) + continuity_det = Detection( + camera_id=camera_id, + pose_id=pose.id, + sequence_id=sequence.id, + bucket_key="bbox-continuity", + bbox="[]", + created_at=now, + ) + detection_session.add(real_det) + detection_session.add(continuity_det) + await detection_session.commit() + + last_bbox = await _get_last_bbox_for_sequence(detections, sequence.id) + assert last_bbox == (0.1, 0.1, 0.2, 0.2, 0.9) + + @pytest.mark.asyncio async def test_get_camera_by_id_adds_missing_sequence_camera(detection_session: AsyncSession): cam_crud = CameraCRUD(detection_session) @@ -742,7 +926,9 @@ async def drain_validation_queue(): ) assert resp3.status_code == 201, resp3.text await drain_validation_queue() - assert await count(Detection) == base_det + 3 + # +2: the real detection (new sequence) plus a continuity row attaching the frame to the + # still-active first sequence, whose object was not detected on it. + assert await count(Detection) == base_det + 4 assert await count(Sequence) == base_seq + 2 assert await count(Alert) == base_alert + 2 assert await count(AlertSequence) == base_map + 2 @@ -755,7 +941,8 @@ async def drain_validation_queue(): ) assert resp4.status_code == 201, resp4.text await drain_validation_queue() - assert await count(Detection) == base_det + 5 + # +2 real detections (one per bbox, both sequences matched -> no continuity row) + assert await count(Detection) == base_det + 6 assert await count(Sequence) == base_seq + 2 assert await count(Alert) == base_alert + 2 assert await count(AlertSequence) == base_map + 2