Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions src/app/api/api_v1/endpoints/detections.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
get_webhook_crud,
)
from app.core.config import settings
from app.core.time import utcnow
from app.core.time import to_utc_naive, utcnow
from app.crud import AlertCRUD, CameraCRUD, DetectionCRUD, OrganizationCRUD, PoseCRUD, SequenceCRUD, WebhookCRUD
from app.models import Alert, AlertSequence, Camera, Detection, Organization, Pose, Role, Sequence, UserRole
from app.schemas.alerts import AlertCreate, AlertUpdate
Expand Down Expand Up @@ -104,7 +104,7 @@ async def _get_last_bbox_for_sequence(
) -> Optional[Tuple[float, float, float, float, float]]:
dets = await detections.fetch_all(
filters=("sequence_id", sequence_id),
order_by="created_at",
order_by="recorded_at",
order_desc=True,
limit=1,
)
Expand Down Expand Up @@ -358,6 +358,13 @@ async def create_detection(
max_length=settings.MAX_BBOX_STR_LENGTH,
),
pose_id: int = Form(..., gt=0, description="pose id of the detection"),
recorded_at: Optional[datetime] = Form(
None,
description=(
"Timestamp of when the image was captured by the engine. Timezone-aware values are "
"converted to UTC; naive values are assumed UTC. Defaults to server now if omitted."
),
),
file: UploadFile = File(..., alias="file"),
detections: DetectionCRUD = Depends(get_detection_crud),
webhooks: WebhookCRUD = Depends(get_webhook_crud),
Expand Down Expand Up @@ -393,6 +400,12 @@ async def create_detection(
created: List[Detection] = []
camera = cast(Camera, await cameras.get(token_payload.sub, strict=True))

# The engine may report when the image was actually captured; fall back to now when it doesn't.
# Aware timestamps are normalized to UTC (naive timestamps are assumed to already be UTC) so
# the value matches the DB columns and the time-window comparisons below.
# All bboxes from a single upload share the same capture time.
effective_recorded_at = to_utc_naive(recorded_at) if recorded_at is not None else utcnow()

for idx, bbox_str in enumerate(bbox_strings):
single_bboxes = _bbox_list_to_str([bbox_str])
other_bbox_strings = bbox_strings[:idx] + bbox_strings[idx + 1 :]
Expand All @@ -404,6 +417,7 @@ async def create_detection(
bucket_key=bucket_key,
bbox=single_bboxes,
others_bboxes=others_bboxes,
recorded_at=effective_recorded_at,
)
)

Expand All @@ -415,7 +429,7 @@ async def create_detection(
inequality_pair=(
"last_seen_at",
">",
utcnow() - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS),
effective_recorded_at - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS),
),
order_by="last_seen_at",
order_desc=True,
Expand All @@ -430,7 +444,7 @@ async def create_detection(
break

if matched_sequence is not None:
await sequences.update(matched_sequence.id, SequenceUpdate(last_seen_at=det.created_at))
await sequences.update(matched_sequence.id, SequenceUpdate(last_seen_at=det.recorded_at))
det = await detections.update(det.id, DetectionSequence(sequence_id=matched_sequence.id))
# Only the primary bbox tracks the sequence; siblings in others_bboxes are unrelated detections.
det_max_conf = max_conf_from_bboxes(det.bbox)
Expand All @@ -445,11 +459,11 @@ async def create_detection(
dets_ = await detections.fetch_all(
filters=det_filters,
inequality_pair=(
"created_at",
"recorded_at",
">",
utcnow() - timedelta(seconds=settings.SEQUENCE_MIN_INTERVAL_SECONDS),
effective_recorded_at - timedelta(seconds=settings.SEQUENCE_MIN_INTERVAL_SECONDS),
),
order_by="created_at",
order_by="recorded_at",
order_desc=False,
)
overlapping_dets: List[Detection] = []
Expand All @@ -462,7 +476,7 @@ async def create_detection(
overlapping_dets.append(cand)

if len(overlapping_dets) >= settings.SEQUENCE_MIN_INTERVAL_DETS:
first_det = min(overlapping_dets, key=lambda item: item.created_at)
first_det = min(overlapping_dets, key=lambda item: item.recorded_at)
cone_azimuth, cone_angle = resolve_cone(pose.azimuth, first_det.bbox, camera.angle_of_view)
seq_max_conf = max_conf_from_bboxes(*[d.bbox for d in overlapping_dets])
sequence_ = await sequences.create(
Expand All @@ -472,8 +486,8 @@ async def create_detection(
camera_azimuth=pose.azimuth,
sequence_azimuth=cone_azimuth,
cone_angle=cone_angle,
started_at=first_det.created_at,
last_seen_at=det.created_at,
started_at=first_det.recorded_at,
last_seen_at=det.recorded_at,
max_conf=seq_max_conf,
)
)
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/api_v1/endpoints/sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async def get_sequence(
async def fetch_sequence_detections(
sequence_id: int = Path(..., gt=0),
limit: int = Query(10, description="Maximum number of detections to fetch", ge=1, le=100),
desc: bool = Query(True, description="Whether to order the detections by created_at in descending order"),
desc: bool = Query(True, description="Whether to order the detections by recorded_at in descending order"),
cameras: CameraCRUD = Depends(get_camera_crud),
detections: DetectionCRUD = Depends(get_detection_crud),
sequences: SequenceCRUD = Depends(get_sequence_crud),
Expand All @@ -135,7 +135,7 @@ async def fetch_sequence_detections(
)
for elt in await detections.fetch_all(
filters=("sequence_id", sequence_id),
order_by="created_at",
order_by="recorded_at",
order_desc=desc,
limit=limit,
)
Expand Down
12 changes: 12 additions & 0 deletions src/app/core/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,15 @@
def utcnow() -> datetime:
"""UTC wall clock, returned as a naive datetime to match existing DB columns."""
return datetime.now(timezone.utc).replace(tzinfo=None)


def to_utc_naive(value: datetime) -> datetime:
"""Normalize a datetime to naive UTC, matching how DB columns store time.

A timezone-aware value (e.g. ``2026-05-27T10:00:00+02:00`` from a French engine) is
converted to UTC before the tzinfo is dropped, so it lands at ``08:00:00``. A naive value
is assumed to already be UTC and returned unchanged.
"""
if value.tzinfo is not None:
return value.astimezone(timezone.utc).replace(tzinfo=None)
return value
5 changes: 5 additions & 0 deletions src/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ class Detection(SQLModel, table=True):
bbox: str = Field(..., min_length=2, max_length=settings.MAX_BBOX_STR_LENGTH_SINGLE, nullable=False)
others_bboxes: Union[str, None] = Field(default=None, max_length=settings.MAX_BBOX_STR_LENGTH_OTHERS, nullable=True)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
recorded_at: datetime = Field(
default_factory=utcnow,
nullable=False,
description="UTC timestamp of when the image was captured on-device. Defaults to created_at when unknown.",
)


class Sequence(SQLModel, table=True):
Expand Down
4 changes: 4 additions & 0 deletions src/app/schemas/detections.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.

import re
from datetime import datetime
from typing import Optional, Union

from pydantic import BaseModel, Field
Expand Down Expand Up @@ -37,6 +38,9 @@ class DetectionCreate(BaseModel):
json_schema_extra={"examples": ["[(0.1, 0.1, 0.9, 0.9, 0.5)]"]},
)
others_bboxes: Optional[str] = Field(None, max_length=settings.MAX_BBOX_STR_LENGTH_OTHERS)
recorded_at: Optional[datetime] = Field(
None, description="UTC timestamp of when the image was captured on-device. Defaults to server now if omitted."
)


class DetectionUrl(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""add recorded_at column to detections and backfill from created_at

Revision ID: c4e9f1a2b3d5
Revises: b3d8a9c1e2f4
Create Date: 2026-05-27 10:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "c4e9f1a2b3d5"
Comment thread
Acruve15 marked this conversation as resolved.
Dismissed
down_revision: Union[str, None] = "b3d8a9c1e2f4"
Comment thread
Acruve15 marked this conversation as resolved.
Dismissed
branch_labels: Union[str, Sequence[str], None] = None
Comment thread
Acruve15 marked this conversation as resolved.
Dismissed
depends_on: Union[str, Sequence[str], None] = None
Comment thread
Acruve15 marked this conversation as resolved.
Dismissed


def upgrade() -> None:
# Add as nullable first so existing rows don't violate the NOT NULL constraint.
op.add_column("detections", sa.Column("recorded_at", sa.DateTime(), nullable=True))
# Backfill: legacy detections have no capture time, so fall back to the DB insertion time.
op.execute("UPDATE detections SET recorded_at = created_at WHERE recorded_at IS NULL")
# Tighten to NOT NULL to match the SQLModel definition.
op.alter_column("detections", "recorded_at", nullable=False)


def downgrade() -> None:
op.drop_column("detections", "recorded_at")
4 changes: 4 additions & 0 deletions src/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"bbox": "[(.1,.1,.7,.8,.9)]",
"others_bboxes": None,
"created_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format),
"recorded_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format),
},
{
"id": 2,
Expand All @@ -164,6 +165,7 @@
"bbox": "[(.1,.1,.7,.8,.9)]",
"others_bboxes": None,
"created_at": datetime.strptime("2023-11-07T15:18:19.226673", dt_format),
"recorded_at": datetime.strptime("2023-11-07T15:18:19.226673", dt_format),
},
{
"id": 3,
Expand All @@ -174,6 +176,7 @@
"bbox": "[(.1,.1,.7,.8,.9)]",
"others_bboxes": None,
"created_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format),
"recorded_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format),
},
{
"id": 4,
Expand All @@ -184,6 +187,7 @@
"bbox": "[(.1,.1,.7,.8,.9)]",
"others_bboxes": None,
"created_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format),
"recorded_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format),
},
]

Expand Down
Loading