From 32175e1b7b499702a53e935ed83146920e5c730c Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 09:48:55 +0200 Subject: [PATCH 01/22] feat(api): add SequenceGroup table + sequence_group_id FK Recurring real-world entity at one camera angle (a persistent fire, a recurring antenna FP, ...). A group carries at most one label (smoke OR false positive, never both, enforced by a CHECK constraint), and the representative_bbox lives on the group itself so the group remains self-defining if all its members are eventually pruned. Sequences gain a nullable sequence_group_id FK with ON DELETE SET NULL. Membership is set by the assign-groups job, not on import. --- annotation_api/src/app/models.py | 70 +++++++++++++ annotation_api/src/app/schemas/sequence.py | 3 +- ...0_0700-b2c3d4e5f6a7_add_sequence_groups.py | 98 +++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py diff --git a/annotation_api/src/app/models.py b/annotation_api/src/app/models.py index 631c5e4..7bfe5f3 100644 --- a/annotation_api/src/app/models.py +++ b/annotation_api/src/app/models.py @@ -3,6 +3,7 @@ from typing import List, Optional from sqlalchemy import ( + CheckConstraint, Column, DateTime, ForeignKey, @@ -18,6 +19,7 @@ "DetectionAnnotation", "Sequence", "SequenceAnnotation", + "SequenceGroup", "User", "AnnotationType", ] @@ -215,6 +217,74 @@ class Sequence(SQLModel, table=True): ) organisation_name: str organisation_id: int + # Membership in a SequenceGroup. NULL until `assign_groups` runs (which + # discovers groups by `(camera_id, azimuth, IoU > 0.5)`). Set NULL on + # group deletion so the sequence survives. + sequence_group_id: Optional[int] = Field( + default=None, + sa_column=Column( + ForeignKey("sequence_groups.id", ondelete="SET NULL"), + index=True, + ), + ) + + +class SequenceGroup(SQLModel, table=True): + """Recurring real-world entity at one camera angle (a persistent fire, + a recurring antenna FP, …). Sequences join a group when their + representative bbox overlaps the group's reference bbox enough. + + A group carries at most one label (smoke OR false positive, never both). + Once labeled, future sequences joining the group inherit the label + automatically (skip manual annotation). + """ + + __tablename__ = "sequence_groups" + __table_args__ = ( + Index("ix_sequence_groups_camera_azimuth", "camera_id", "azimuth"), + # Mutually-exclusive label: at most one of smoke_type / fp_type set. + CheckConstraint( + "(smoke_type IS NULL AND false_positive_type IS NULL) " + "OR (smoke_type IS NOT NULL AND false_positive_type IS NULL) " + "OR (smoke_type IS NULL AND false_positive_type IS NOT NULL)", + name="ck_sequence_group_label_xor", + ), + # labeled_at must be set iff a label is present. + CheckConstraint( + "(labeled_at IS NULL) = " + "(smoke_type IS NULL AND false_positive_type IS NULL)", + name="ck_sequence_group_labeled_at_consistency", + ), + ) + + id: int = Field( + default=None, primary_key=True, sa_column_kwargs={"autoincrement": True} + ) + camera_id: int + azimuth: int + # Defines the group's region in the image. Set from the first member's + # representative bbox at group creation, never mutated, so the group + # stays self-defining even if all original members are pruned. + representative_bbox: dict = Field(sa_column=Column(JSONB)) + # Carried label. Stored as the enum value (string) for now; validated by + # the API schemas against SmokeType / FalsePositiveType. + smoke_type: Optional[str] = Field(default=None) + false_positive_type: Optional[str] = Field(default=None) + is_unsure: bool = Field(default=False) + labeled_at: Optional[datetime] = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + labeled_by_user_id: Optional[int] = Field( + default=None, + sa_column=Column(ForeignKey("users.id", ondelete="SET NULL")), + ) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_column=Column(DateTime(timezone=True)), + ) + updated_at: Optional[datetime] = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) class SequenceAnnotation(SQLModel, table=True): diff --git a/annotation_api/src/app/schemas/sequence.py b/annotation_api/src/app/schemas/sequence.py index 40c64ff..b34e8e8 100644 --- a/annotation_api/src/app/schemas/sequence.py +++ b/annotation_api/src/app/schemas/sequence.py @@ -83,7 +83,7 @@ class SequenceCreate(Azimuth): is_wildfire_alertapi: Optional[AnnotationType] = Field( default=None, description="Classification from external API: 'wildfire_smoke' (confirmed wildfire), 'other_smoke' (non-wildfire smoke), 'other' (false positive or other detection)", - examples=["wildfire_smoke", "other_smoke", "other", None] + examples=["wildfire_smoke", "other_smoke", "other", None], ) organisation_name: str organisation_id: int @@ -108,6 +108,7 @@ class SequenceRead(Azimuth): is_wildfire_alertapi: Optional[AnnotationType] organisation_name: str organisation_id: int + sequence_group_id: Optional[int] = None class SequenceUpdateBboxAuto(BaseModel): diff --git a/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py b/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py new file mode 100644 index 0000000..60b5234 --- /dev/null +++ b/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py @@ -0,0 +1,98 @@ +"""add sequence_groups + sequences.sequence_group_id + +Revision ID: b2c3d4e5f6a7 +Revises: 063dc76c9846 +Create Date: 2026-05-10 07:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "sequence_groups", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("camera_id", sa.Integer(), nullable=False), + sa.Column("azimuth", sa.Integer(), nullable=False), + sa.Column( + "representative_bbox", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("smoke_type", sa.Text(), nullable=True), + sa.Column("false_positive_type", sa.Text(), nullable=True), + sa.Column( + "is_unsure", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + sa.Column("labeled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("labeled_by_user_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["labeled_by_user_id"], ["users.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + sa.CheckConstraint( + "(smoke_type IS NULL AND false_positive_type IS NULL) " + "OR (smoke_type IS NOT NULL AND false_positive_type IS NULL) " + "OR (smoke_type IS NULL AND false_positive_type IS NOT NULL)", + name="ck_sequence_group_label_xor", + ), + sa.CheckConstraint( + "(labeled_at IS NULL) = " + "(smoke_type IS NULL AND false_positive_type IS NULL)", + name="ck_sequence_group_labeled_at_consistency", + ), + ) + op.create_index( + "ix_sequence_groups_camera_azimuth", + "sequence_groups", + ["camera_id", "azimuth"], + ) + + op.add_column( + "sequences", + sa.Column("sequence_group_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_sequences_sequence_group_id", + "sequences", + "sequence_groups", + ["sequence_group_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index( + "ix_sequences_sequence_group_id", + "sequences", + ["sequence_group_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_sequences_sequence_group_id", table_name="sequences") + op.drop_constraint( + "fk_sequences_sequence_group_id", "sequences", type_="foreignkey" + ) + op.drop_column("sequences", "sequence_group_id") + op.drop_index("ix_sequence_groups_camera_azimuth", table_name="sequence_groups") + op.drop_table("sequence_groups") From 02cbfe139fdbafb50717a4d610d3f9867c8f74dc Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 09:49:05 +0200 Subject: [PATCH 02/22] feat(api): expose GET /sequence_groups/{id} + assign endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET returns the group + its members (lightweight projection with has_annotation flag for the UI's "already-annotated" hint). POST /sequence_groups/assign is the single-threaded, idempotent batch that turns unassigned sequences into group memberships: - compute representative_bbox from the sequence's first 10 detections (median of algo_predictions, ignoring others_bboxes — matches the current auto-annotation flow) - best-IoU match against existing groups for the same (camera_id, azimuth), threshold > 0.5 (stricter than within-sequence clustering since a wrong match auto-applies inherited labels) - if no match: create a new group; otherwise join the existing one and, if the group already has a label, create a SequenceAnnotation with inherited labels in stage SEQ_ANNOTATION_DONE --- .../api/api_v1/endpoints/sequence_groups.py | 309 ++++++++++++++++++ annotation_api/src/app/api/api_v1/router.py | 12 +- annotation_api/src/app/api/dependencies.py | 8 + annotation_api/src/app/crud/__init__.py | 2 + .../src/app/crud/crud_sequence_group.py | 19 ++ .../src/app/schemas/sequence_group.py | 100 ++++++ 6 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py create mode 100644 annotation_api/src/app/crud/crud_sequence_group.py create mode 100644 annotation_api/src/app/schemas/sequence_group.py diff --git a/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py b/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py new file mode 100644 index 0000000..e18c294 --- /dev/null +++ b/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py @@ -0,0 +1,309 @@ +# Copyright (C) 2024, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +import logging +from statistics import median +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.dependencies import get_current_user, get_sequence_group_crud +from app.crud import SequenceAnnotationCRUD, SequenceGroupCRUD +from app.db import get_session +from app.models import ( + Detection, + FalsePositiveType, + Sequence, + SequenceAnnotation, + SequenceAnnotationProcessingStage, + SequenceGroup, + SmokeType, + User, +) +from app.schemas.sequence_annotations import SequenceAnnotationCreate +from app.schemas.sequence_group import ( + SequenceGroupMember, + SequenceGroupRead, + SequenceGroupReadWithMembers, +) +from app.services.annotation_generation import AnnotationGenerationService + +router = APIRouter() +logger = logging.getLogger("uvicorn.error") + +# Cross-sequence grouping threshold. Stricter than within-sequence clustering +# (IoU=0) because the precision cost of mis-grouping is much higher: a wrong +# match auto-applies inherited labels to an unrelated event. +_GROUP_IOU_THRESHOLD = 0.5 + + +@router.get( + "/{group_id}", + response_model=SequenceGroupReadWithMembers, + summary="Get a sequence group with its members", +) +async def get_sequence_group( + group_id: int = Path(..., ge=1), + groups: SequenceGroupCRUD = Depends(get_sequence_group_crud), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> SequenceGroupReadWithMembers: + group = await groups.get(group_id, strict=False) + if group is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Sequence group {group_id} not found", + ) + + # Members joined to their (optional) annotation in one query. + member_query = ( + select( + Sequence.id, + Sequence.alert_api_id, + Sequence.camera_name, + Sequence.recorded_at, + Sequence.last_seen_at, + SequenceAnnotation.id, + ) + .outerjoin(SequenceAnnotation, SequenceAnnotation.sequence_id == Sequence.id) + .where(Sequence.sequence_group_id == group_id) + .order_by(Sequence.recorded_at) + ) + result = await session.execute(member_query) + members = [ + SequenceGroupMember( + sequence_id=row[0], + alert_api_id=row[1], + camera_name=row[2], + recorded_at=row[3], + last_seen_at=row[4], + has_annotation=row[5] is not None, + ) + for row in result.all() + ] + + base = SequenceGroupRead.model_validate(group, from_attributes=True) + return SequenceGroupReadWithMembers(**base.model_dump(), members=members) + + +# -------------------- assign-groups -------------------- + + +class AssignGroupsResponse(BaseModel): + """Outcome of one /sequence_groups/assign run.""" + + processed: int + new_groups: int + joined_existing: int + inherited_annotations: int + skipped_no_bbox: int + + +def _bbox_iou(a: List[float], b: List[float]) -> float: + ix1 = max(a[0], b[0]) + iy1 = max(a[1], b[1]) + ix2 = min(a[2], b[2]) + iy2 = min(a[3], b[3]) + iw = max(0.0, ix2 - ix1) + ih = max(0.0, iy2 - iy1) + inter = iw * ih + if inter <= 0: + return 0.0 + area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1]) + area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1]) + union = area_a + area_b - inter + return inter / union if union > 0 else 0.0 + + +def _compute_representative_bbox(detections: List[Detection]) -> Optional[dict]: + """Median bbox across the sequence's detections (only `bbox`, ignoring + `others_bboxes` to match the auto-annotation flow). Returns + `{"xyxyn": [...], "confidence": float}` or None if no usable boxes.""" + boxes: List[List[float]] = [] + confs: List[float] = [] + for det in detections: + preds = (det.algo_predictions or {}).get("predictions") or [] + for pred in preds: + xy = pred.get("xyxyn") + if not xy or len(xy) != 4: + continue + x1, y1, x2, y2 = (float(v) for v in xy) + if x1 > x2 or y1 > y2 or [x1, y1, x2, y2] == [0.0, 0.0, 0.0, 0.0]: + continue + boxes.append([x1, y1, x2, y2]) + confs.append(float(pred.get("confidence", 0.0))) + if not boxes: + return None + return { + "xyxyn": [ + median(b[0] for b in boxes), + median(b[1] for b in boxes), + median(b[2] for b in boxes), + median(b[3] for b in boxes), + ], + "confidence": median(confs), + } + + +def _label_sequences_bbox(annotation, *, smoke_type, false_positive_type) -> None: + """In-place rewrite of every bbox cluster's labels for inherited + annotations. Mirrors the helper in sequence_annotations.bulk.""" + for bbox in annotation.sequences_bbox: + if smoke_type is not None: + bbox.is_smoke = True + bbox.smoke_type = smoke_type + bbox.false_positive_types = [] + else: + bbox.is_smoke = False + bbox.smoke_type = None + bbox.false_positive_types = ( + [false_positive_type] if false_positive_type else [] + ) + + +@router.post( + "/assign", + response_model=AssignGroupsResponse, + summary="Compute group membership for unassigned sequences (idempotent).", +) +async def assign_groups( + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> AssignGroupsResponse: + """Single-threaded by design — meant to run after each platform import, + not concurrently. Greedy best-IoU match on the (camera_id, azimuth) key, + threshold > 0.5. New sequences inherit the matched group's label + automatically (creates a SequenceAnnotation in SEQ_ANNOTATION_DONE). + Sequences already assigned or already annotated are left untouched.""" + + sa_crud = SequenceAnnotationCRUD(session=session) + + unassigned_query = ( + select(Sequence) + .where(Sequence.sequence_group_id.is_(None)) + .order_by(Sequence.recorded_at) + ) + unassigned = (await session.execute(unassigned_query)).scalars().all() + + if not unassigned: + return AssignGroupsResponse( + processed=0, + new_groups=0, + joined_existing=0, + inherited_annotations=0, + skipped_no_bbox=0, + ) + + gen_service = AnnotationGenerationService( + session=session, + confidence_threshold=0.0, + iou_threshold=0.0, + min_cluster_size=1, + ) + + new_groups = 0 + joined_existing = 0 + inherited = 0 + skipped_no_bbox = 0 + + for seq in unassigned: + if seq.azimuth is None or seq.camera_id is None: + skipped_no_bbox += 1 + continue + + det_query = ( + select(Detection) + .where(Detection.sequence_id == seq.id) + .order_by(Detection.recorded_at) + .limit(10) + ) + detections = (await session.execute(det_query)).scalars().all() + repr_bbox = _compute_representative_bbox(detections) + if repr_bbox is None: + skipped_no_bbox += 1 + continue + + candidates_query = select(SequenceGroup).where( + SequenceGroup.camera_id == seq.camera_id, + SequenceGroup.azimuth == seq.azimuth, + ) + candidates = (await session.execute(candidates_query)).scalars().all() + + best_group: Optional[SequenceGroup] = None + best_iou = _GROUP_IOU_THRESHOLD + for g in candidates: + g_xy = g.representative_bbox.get("xyxyn") if g.representative_bbox else None + if not g_xy: + continue + score = _bbox_iou(repr_bbox["xyxyn"], g_xy) + if score > best_iou: + best_iou = score + best_group = g + + if best_group is None: + new_group = SequenceGroup( + camera_id=seq.camera_id, + azimuth=seq.azimuth, + representative_bbox=repr_bbox, + ) + session.add(new_group) + await session.flush() + seq.sequence_group_id = new_group.id + new_groups += 1 + continue + + seq.sequence_group_id = best_group.id + joined_existing += 1 + + if best_group.smoke_type is None and best_group.false_positive_type is None: + continue + + # Inherit the group's label only if the sequence isn't already annotated. + existing_anno = ( + await session.execute( + select(SequenceAnnotation).where( + SequenceAnnotation.sequence_id == seq.id + ) + ) + ).scalar_one_or_none() + if existing_anno is not None: + continue + + generated = await gen_service.generate_annotation_for_sequence(seq.id) + if generated is None: + continue + + smoke_enum = SmokeType(best_group.smoke_type) if best_group.smoke_type else None + fp_enum = ( + FalsePositiveType(best_group.false_positive_type) + if best_group.false_positive_type + else None + ) + _label_sequences_bbox( + generated, smoke_type=smoke_enum, false_positive_type=fp_enum + ) + + create_data = SequenceAnnotationCreate( + sequence_id=seq.id, + has_missed_smoke=False, + is_unsure=best_group.is_unsure, + annotation=generated, + processing_stage=SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + ) + await sa_crud.create(create_data, current_user.id) + inherited += 1 + + await session.commit() + + return AssignGroupsResponse( + processed=len(unassigned), + new_groups=new_groups, + joined_existing=joined_existing, + inherited_annotations=inherited, + skipped_no_bbox=skipped_no_bbox, + ) diff --git a/annotation_api/src/app/api/api_v1/router.py b/annotation_api/src/app/api/api_v1/router.py index a6d6d47..2f0a731 100644 --- a/annotation_api/src/app/api/api_v1/router.py +++ b/annotation_api/src/app/api/api_v1/router.py @@ -11,10 +11,11 @@ detections, organizations, sequence_annotations, + sequence_groups, sequences, source_apis, users, - export + export, ) from app.auth import endpoints as auth @@ -38,6 +39,9 @@ prefix="/annotations/sequences", tags=["sequence annotations"], ) +api_router.include_router( + sequence_groups.router, prefix="/sequence_groups", tags=["sequence groups"] +) api_router.include_router(cameras.router, prefix="/cameras", tags=["cameras"]) api_router.include_router( organizations.router, prefix="/organizations", tags=["organizations"] @@ -46,8 +50,4 @@ source_apis.router, prefix="/source-apis", tags=["source apis"] ) -api_router.include_router( - export.router, - prefix="/export", - tags=["export"] -) +api_router.include_router(export.router, prefix="/export", tags=["export"]) diff --git a/annotation_api/src/app/api/dependencies.py b/annotation_api/src/app/api/dependencies.py index be75b22..51946c2 100644 --- a/annotation_api/src/app/api/dependencies.py +++ b/annotation_api/src/app/api/dependencies.py @@ -14,6 +14,7 @@ DetectionCRUD, SequenceAnnotationCRUD, SequenceCRUD, + SequenceGroupCRUD, ) from app.db import get_session @@ -25,6 +26,7 @@ "get_detection_crud", "get_sequence_annotation_crud", "get_sequence_crud", + "get_sequence_group_crud", ] # Re-export for backward compatibility @@ -49,3 +51,9 @@ def get_sequence_annotation_crud( session: AsyncSession = Depends(get_session), ) -> SequenceAnnotationCRUD: return SequenceAnnotationCRUD(session=session) + + +def get_sequence_group_crud( + session: AsyncSession = Depends(get_session), +) -> SequenceGroupCRUD: + return SequenceGroupCRUD(session=session) diff --git a/annotation_api/src/app/crud/__init__.py b/annotation_api/src/app/crud/__init__.py index c02cb58..47a5905 100644 --- a/annotation_api/src/app/crud/__init__.py +++ b/annotation_api/src/app/crud/__init__.py @@ -2,6 +2,7 @@ from .crud_detection_annotation import DetectionAnnotationCRUD from .crud_sequence import SequenceCRUD from .crud_sequence_annotation import SequenceAnnotationCRUD +from .crud_sequence_group import SequenceGroupCRUD from .crud_user import UserCRUD __all__ = [ @@ -9,5 +10,6 @@ "DetectionAnnotationCRUD", "SequenceCRUD", "SequenceAnnotationCRUD", + "SequenceGroupCRUD", "UserCRUD", ] diff --git a/annotation_api/src/app/crud/crud_sequence_group.py b/annotation_api/src/app/crud/crud_sequence_group.py new file mode 100644 index 0000000..a9f37db --- /dev/null +++ b/annotation_api/src/app/crud/crud_sequence_group.py @@ -0,0 +1,19 @@ +# Copyright (C) 2024, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.crud.base import BaseCRUD +from app.models import SequenceGroup +from app.schemas.sequence_group import SequenceGroupCreate + +__all__ = ["SequenceGroupCRUD"] + + +class SequenceGroupCRUD( + BaseCRUD[SequenceGroup, SequenceGroupCreate, SequenceGroupCreate] +): + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, SequenceGroup) diff --git a/annotation_api/src/app/schemas/sequence_group.py b/annotation_api/src/app/schemas/sequence_group.py new file mode 100644 index 0000000..4ee9813 --- /dev/null +++ b/annotation_api/src/app/schemas/sequence_group.py @@ -0,0 +1,100 @@ +# Copyright (C) 2024, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + +from app.models import FalsePositiveType, SmokeType + +__all__ = [ + "RepresentativeBbox", + "SequenceGroupCreate", + "SequenceGroupRead", + "SequenceGroupMember", + "SequenceGroupReadWithMembers", + "SequenceGroupLabelUpdate", +] + + +class RepresentativeBbox(BaseModel): + """Geometry of a SequenceGroup's reference region. Not tied to any + specific detection — derived from the first joining sequence at group + creation, then frozen.""" + + xyxyn: List[float] = Field(..., min_length=4, max_length=4) + confidence: float = Field(..., ge=0.0, le=1.0) + + @field_validator("xyxyn") + @classmethod + def _validate_xyxyn(cls, v: List[float]) -> List[float]: + x1, y1, x2, y2 = v + for val in v: + if not (0 <= val <= 1): + raise ValueError("All xyxyn values must be between 0 and 1") + if x1 > x2 or y1 > y2: + raise ValueError("x1 <= x2 and y1 <= y2 required") + if v == [0, 0, 0, 0]: + raise ValueError("Null coordinates [0,0,0,0] are not allowed") + return v + + +class SequenceGroupCreate(BaseModel): + """Internal payload — created by the assign_groups script, not exposed.""" + + camera_id: int + azimuth: int + representative_bbox: RepresentativeBbox + + +class SequenceGroupLabelUpdate(BaseModel): + """Labels written when an annotator bulk-confirms a group. Exactly one of + smoke_type / false_positive_type must be set.""" + + smoke_type: Optional[SmokeType] = None + false_positive_type: Optional[FalsePositiveType] = None + is_unsure: bool = False + + @model_validator(mode="after") + def _exactly_one_label(self) -> "SequenceGroupLabelUpdate": + smoke = self.smoke_type is not None + fp = self.false_positive_type is not None + if smoke == fp: # both set or both unset + raise ValueError( + "exactly one of smoke_type or false_positive_type must be set" + ) + return self + + +class SequenceGroupMember(BaseModel): + """Lightweight projection of a sequence inside a group's members list.""" + + sequence_id: int + alert_api_id: int + camera_name: str + recorded_at: datetime + last_seen_at: datetime + has_annotation: bool = Field( + description="True if a SequenceAnnotation already exists for this sequence" + ) + + +class SequenceGroupRead(BaseModel): + id: int + camera_id: int + azimuth: int + representative_bbox: RepresentativeBbox + smoke_type: Optional[SmokeType] + false_positive_type: Optional[FalsePositiveType] + is_unsure: bool + labeled_at: Optional[datetime] + labeled_by_user_id: Optional[int] + created_at: datetime + updated_at: Optional[datetime] + + +class SequenceGroupReadWithMembers(SequenceGroupRead): + members: List[SequenceGroupMember] From d96f0b1f2f9996c0ff0c6329e6f6fcc45d32aa9b Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 09:49:18 +0200 Subject: [PATCH 03/22] feat(api): add POST /annotations/sequences/bulk Apply one label (smoke OR false positive, never both) to many sequences in a single request. Skips sequences whose annotation is past SEQ_ANNOTATION_DONE so reviewed work isn't clobbered, and rejects with 409 when the target group already carries a different label unless the caller passes force=true. When group_id is provided, the endpoint also writes the label onto the group itself so future joiners inherit it via assign-groups. Returns per-sequence applied/skipped status with reasons. --- .../api_v1/endpoints/sequence_annotations.py | 206 ++++++++++++++++++ .../src/app/schemas/sequence_annotations.py | 50 ++++- 2 files changed, 253 insertions(+), 3 deletions(-) diff --git a/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py b/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py index 3a44bbe..0008e9e 100644 --- a/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py +++ b/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py @@ -32,10 +32,14 @@ SequenceAnnotation, SequenceAnnotationContribution, SequenceAnnotationProcessingStage, + SequenceGroup, SmokeType, ) from app.schemas.annotation_validation import SequenceAnnotationData from app.schemas.sequence_annotations import ( + SequenceAnnotationBulkRequest, + SequenceAnnotationBulkResponse, + SequenceAnnotationBulkResult, SequenceAnnotationCreate, SequenceAnnotationRead, SequenceAnnotationUpdate, @@ -720,3 +724,205 @@ async def delete_sequence_annotation( current_user: User = Depends(get_current_user), ) -> None: await annotations.delete(annotation_id) + + +# Stages past which we don't overwrite an annotation in bulk-annotate. The +# review pipeline marks annotations as SEQ_ANNOTATION_DONE when the labels +# are filled but the geometry still needs visual check; anything past that +# is reviewed work the bulk action must not clobber. +_BULK_LOCKED_STAGES = { + SequenceAnnotationProcessingStage.IN_REVIEW, + SequenceAnnotationProcessingStage.NEEDS_MANUAL, + SequenceAnnotationProcessingStage.ANNOTATED, +} + + +def _apply_label_to_sequences_bbox( + annotation: SequenceAnnotationData, + *, + smoke_type: Optional[SmokeType], + false_positive_type: Optional[FalsePositiveType], +) -> None: + """In-place rewrite of every bbox cluster's labels. Either marks the + cluster as smoke of the given type (and clears FP flags), or marks it as + a single false-positive type (and clears smoke fields).""" + for bbox in annotation.sequences_bbox: + if smoke_type is not None: + bbox.is_smoke = True + bbox.smoke_type = smoke_type + bbox.false_positive_types = [] + else: + bbox.is_smoke = False + bbox.smoke_type = None + bbox.false_positive_types = ( + [false_positive_type] if false_positive_type else [] + ) + + +@router.post( + "/bulk", + status_code=status.HTTP_200_OK, + response_model=SequenceAnnotationBulkResponse, + summary="Apply one label to many sequences in a single transaction", +) +async def bulk_annotate_sequences( + payload: SequenceAnnotationBulkRequest = Body(...), + annotations: SequenceAnnotationCRUD = Depends(get_sequence_annotation_crud), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> SequenceAnnotationBulkResponse: + # Resolve target group (if provided) and validate membership / conflict. + group: Optional[SequenceGroup] = None + if payload.group_id is not None: + group = await session.get(SequenceGroup, payload.group_id) + if group is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Sequence group {payload.group_id} not found", + ) + + member_ids_query = select(Sequence.id).where( + Sequence.sequence_group_id == payload.group_id, + Sequence.id.in_(payload.sequence_ids), + ) + member_ids = {row[0] for row in (await session.execute(member_ids_query)).all()} + outsiders = [sid for sid in payload.sequence_ids if sid not in member_ids] + if outsiders: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Sequences {outsiders} do not belong to group {payload.group_id}" + ), + ) + + # Reject if the group already carries a different label, unless force. + existing_smoke = group.smoke_type + existing_fp = group.false_positive_type + new_smoke = payload.smoke_type.value if payload.smoke_type else None + new_fp = ( + payload.false_positive_type.value if payload.false_positive_type else None + ) + has_conflict = ( + (existing_smoke is not None and existing_smoke != new_smoke) + or (existing_fp is not None and existing_fp != new_fp) + or (existing_smoke is not None and new_fp is not None) + or (existing_fp is not None and new_smoke is not None) + ) + if has_conflict and not payload.force: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Group already has a different label; pass force=true to " + "overwrite" + ), + ) + + # Auto-generation service uses IoU=0 strict-overlap clustering inside + # each sequence (matches the production default since #130). + gen_service = AnnotationGenerationService( + session=session, + confidence_threshold=0.0, + iou_threshold=0.0, + min_cluster_size=1, + ) + + applied: List[SequenceAnnotationBulkResult] = [] + skipped: List[SequenceAnnotationBulkResult] = [] + + for sid in payload.sequence_ids: + seq = await session.get(Sequence, sid) + if seq is None: + skipped.append( + SequenceAnnotationBulkResult( + sequence_id=sid, status="skipped", reason="sequence not found" + ) + ) + continue + + existing_query = select(SequenceAnnotation).where( + SequenceAnnotation.sequence_id == sid + ) + existing = (await session.execute(existing_query)).scalar_one_or_none() + if existing is not None and existing.processing_stage in _BULK_LOCKED_STAGES: + skipped.append( + SequenceAnnotationBulkResult( + sequence_id=sid, + status="skipped", + reason=f"already {existing.processing_stage.value}", + annotation_id=existing.id, + ) + ) + continue + + generated = await gen_service.generate_annotation_for_sequence(sid) + if generated is None: + # No usable predictions — skip rather than create an empty annotation. + skipped.append( + SequenceAnnotationBulkResult( + sequence_id=sid, + status="skipped", + reason="no AI predictions to seed annotation", + ) + ) + continue + + _apply_label_to_sequences_bbox( + generated, + smoke_type=payload.smoke_type, + false_positive_type=payload.false_positive_type, + ) + + if existing is None: + create_data = SequenceAnnotationCreate( + sequence_id=sid, + has_missed_smoke=False, + is_unsure=payload.is_unsure, + annotation=generated, + processing_stage=SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + ) + sequence_annotation = await annotations.create(create_data, current_user.id) + applied.append( + SequenceAnnotationBulkResult( + sequence_id=sid, + status="applied", + annotation_id=sequence_annotation.id, + ) + ) + else: + update_data = SequenceAnnotationUpdate( + is_unsure=payload.is_unsure, + annotation=generated, + processing_stage=SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + ) + sequence_annotation = await annotations.update( + existing.id, update_data, current_user.id + ) + applied.append( + SequenceAnnotationBulkResult( + sequence_id=sid, + status="applied", + annotation_id=sequence_annotation.id, + ) + ) + + # Write the label onto the group so future joiners inherit it. + group_label_updated = False + if group is not None: + group.smoke_type = payload.smoke_type.value if payload.smoke_type else None + group.false_positive_type = ( + payload.false_positive_type.value if payload.false_positive_type else None + ) + group.is_unsure = payload.is_unsure + group.labeled_at = datetime.now(UTC) + group.labeled_by_user_id = current_user.id + group.updated_at = datetime.now(UTC) + session.add(group) + group_label_updated = True + + await session.commit() + + return SequenceAnnotationBulkResponse( + applied=applied, + skipped=skipped, + group_label_updated=group_label_updated, + ) diff --git a/annotation_api/src/app/schemas/sequence_annotations.py b/annotation_api/src/app/schemas/sequence_annotations.py index b8d75d2..a40a8d9 100644 --- a/annotation_api/src/app/schemas/sequence_annotations.py +++ b/annotation_api/src/app/schemas/sequence_annotations.py @@ -5,11 +5,15 @@ from datetime import datetime -from typing import List, Optional +from typing import List, Literal, Optional -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, model_validator -from app.models import SequenceAnnotationProcessingStage +from app.models import ( + FalsePositiveType, + SequenceAnnotationProcessingStage, + SmokeType, +) from app.schemas.annotation_validation import SequenceAnnotationData from app.schemas.user import ContributorRead @@ -17,9 +21,49 @@ "SequenceAnnotationCreate", "SequenceAnnotationRead", "SequenceAnnotationUpdate", + "SequenceAnnotationBulkRequest", + "SequenceAnnotationBulkResult", + "SequenceAnnotationBulkResponse", ] +class SequenceAnnotationBulkRequest(BaseModel): + """Apply one label (smoke OR false-positive, never both) to many + sequences at once. Optionally writes the label onto the group itself + so future sequences joining the group inherit it.""" + + sequence_ids: List[int] = Field(..., min_length=1) + group_id: Optional[int] = None + smoke_type: Optional[SmokeType] = None + false_positive_type: Optional[FalsePositiveType] = None + is_unsure: bool = False + # Override the group's existing label if it conflicts with this one. + force: bool = False + + @model_validator(mode="after") + def _exactly_one_label(self) -> "SequenceAnnotationBulkRequest": + smoke = self.smoke_type is not None + fp = self.false_positive_type is not None + if smoke == fp: + raise ValueError( + "exactly one of smoke_type or false_positive_type must be set" + ) + return self + + +class SequenceAnnotationBulkResult(BaseModel): + sequence_id: int + status: Literal["applied", "skipped"] + reason: Optional[str] = None + annotation_id: Optional[int] = None + + +class SequenceAnnotationBulkResponse(BaseModel): + applied: List[SequenceAnnotationBulkResult] + skipped: List[SequenceAnnotationBulkResult] + group_label_updated: bool + + class SequenceAnnotationCreate(BaseModel): model_config = ConfigDict( json_schema_extra={ From b3bffcfd5b76fd2147b5e48abd323d8f9dcea639 Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 09:49:27 +0200 Subject: [PATCH 04/22] feat(scripts): add assign_groups script and chain into make pull-sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin wrapper that auths against the local annotation API and calls POST /sequence_groups/assign once. Single-threaded by contract — the endpoint is the only writer of sequence_groups, so we never have two ingesters racing to create the same group. make pull-sequences now runs assign-groups automatically after each import so newly-pulled sequences are immediately clustered (and inherit labels from existing groups when applicable). --- annotation_api/Makefile | 15 +++- .../ingestion/platform/assign_groups.py | 74 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 annotation_api/scripts/data_transfer/ingestion/platform/assign_groups.py diff --git a/annotation_api/Makefile b/annotation_api/Makefile index beb00bd..4d963e8 100644 --- a/annotation_api/Makefile +++ b/annotation_api/Makefile @@ -126,7 +126,9 @@ migrate-up: # --- 1A. Sequence annotation workflow --- -# Duplicate N sequences from the remote annotation API into the local one. +# Duplicate N sequences from the remote annotation API into the local one, +# then run assign-groups so newly-pulled sequences get clustered and inherit +# any existing group's label automatically. # Usage: make pull-sequences [MAX_SEQUENCES=10] [CLONE_STAGE=ready_to_annotate] pull-sequences: uv run python -m scripts.data_transfer.ingestion.platform.import \ @@ -135,6 +137,15 @@ pull-sequences: --max-sequences $(MAX_SEQUENCES) \ --clone-processing-stage $(CLONE_STAGE) \ --loglevel $(LOGLEVEL) + $(MAKE) assign-groups + +# Compute group memberships and inherit labels for unassigned sequences. +# Single-threaded by contract — only safe to run sequentially after an import. +# Usage: make assign-groups [LOCAL_API=http://localhost:5050] +assign-groups: + uv run python -m scripts.data_transfer.ingestion.platform.assign_groups \ + --url-api-annotation $(LOCAL_API) \ + --loglevel $(LOGLEVEL) # Push locally-annotated sequences back to the remote API. # Usage: make push-annotations [MAX_SEQUENCES=10] @@ -328,4 +339,4 @@ import-platform: pull-fp visual-check-fp apply-review-fp \ export-dataset import-yolo-sequence import-local-yolo \ update-stage-remote update-stage-local \ - import-platform + import-platform assign-groups diff --git a/annotation_api/scripts/data_transfer/ingestion/platform/assign_groups.py b/annotation_api/scripts/data_transfer/ingestion/platform/assign_groups.py new file mode 100644 index 0000000..dd14103 --- /dev/null +++ b/annotation_api/scripts/data_transfer/ingestion/platform/assign_groups.py @@ -0,0 +1,74 @@ +"""Trigger the annotation API's `POST /sequence_groups/assign` endpoint and +print the result. Single-threaded by contract — meant to run sequentially +after `import-platform`, not concurrently with it. + +Usage (from `annotation_api/`): + uv run python -m scripts.data_transfer.ingestion.platform.assign_groups \ + --url-api-annotation http://localhost:5050 +""" + +from __future__ import annotations + +import argparse +import logging +import sys + +import requests + +from .shared import get_annotation_credentials + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--url-api-annotation", + default="http://localhost:5050", + help="Annotation API URL (default: http://localhost:5050)", + ) + parser.add_argument( + "--loglevel", + default="info", + choices=["debug", "info", "warning", "error"], + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + logging.basicConfig(level=args.loglevel.upper()) + + base_url = args.url_api_annotation.rstrip("/") + login, password = get_annotation_credentials(base_url) + + auth = requests.post( + f"{base_url}/api/v1/auth/login", + json={"username": login, "password": password}, + timeout=30, + ) + auth.raise_for_status() + token = auth.json()["access_token"] + + response = requests.post( + f"{base_url}/api/v1/sequence_groups/assign", + headers={"Authorization": f"Bearer {token}"}, + timeout=600, + ) + if response.status_code != 200: + logging.error( + "assign-groups failed: HTTP %s — %s", response.status_code, response.text + ) + sys.exit(1) + + summary = response.json() + print( + "assign-groups: " + f"processed={summary['processed']} " + f"new_groups={summary['new_groups']} " + f"joined_existing={summary['joined_existing']} " + f"inherited_annotations={summary['inherited_annotations']} " + f"skipped_no_bbox={summary['skipped_no_bbox']}" + ) + + +if __name__ == "__main__": + main() From 0ececf511b7a7e4321ede069a5d174f7b38bdadc Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 09:49:41 +0200 Subject: [PATCH 05/22] feat(frontend): add minimal sequence group review page New route /sequence-groups/:id/annotate. Thumbnail grid + per-member checkbox + label form. Defaults to selecting every member that doesn't already have an annotation, so the annotator just unchecks outliers and applies. The label form is a radio (smoke vs false positive) plus a single dropdown to pick the enum value, mirroring the API constraint that exactly one of the two is set. Submit posts to /annotations/sequences/bulk with the explicit sequence_ids list and group_id, then surfaces the per-sequence applied/skipped status returned by the backend. Conflict overwrite is gated on a "Overwrite group's existing label" checkbox surfaced only when the group is already labeled. --- frontend/src/App.tsx | 5 + .../src/pages/SequenceGroupAnnotatePage.tsx | 346 ++++++++++++++++++ frontend/src/services/api.ts | 19 + frontend/src/types/api.ts | 51 +++ frontend/src/utils/constants.ts | 2 + 5 files changed, 423 insertions(+) create mode 100644 frontend/src/pages/SequenceGroupAnnotatePage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ce5042a..fca11ab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import AnnotationInterface from '@/pages/AnnotationInterface'; import DetectionAnnotatePage from '@/pages/DetectionAnnotatePage'; import DetectionReviewPage from '@/pages/DetectionReviewPage'; import DetectionSequenceAnnotatePage from '@/pages/DetectionSequenceAnnotatePage'; +import SequenceGroupAnnotatePage from '@/pages/SequenceGroupAnnotatePage'; import UserManagementPage from '@/pages/UserManagementPage'; import LoginPage from '@/pages/LoginPage'; import { useAuthStore } from '@/store/useAuthStore'; @@ -85,6 +86,10 @@ function App() { path="/detections/:sequenceId/annotate/:detectionId?" element={} /> + } + /> } /> diff --git a/frontend/src/pages/SequenceGroupAnnotatePage.tsx b/frontend/src/pages/SequenceGroupAnnotatePage.tsx new file mode 100644 index 0000000..1ada10d --- /dev/null +++ b/frontend/src/pages/SequenceGroupAnnotatePage.tsx @@ -0,0 +1,346 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2, AlertCircle, CheckCircle, Clock } from 'lucide-react'; +import { apiClient } from '@/services/api'; +import { useDetectionImage } from '@/hooks/useDetectionImage'; +import { + BulkAnnotateRequest, + FalsePositiveType, + SmokeType, + SequenceGroupMember, +} from '@/types/api'; + +const SMOKE_TYPES: SmokeType[] = ['wildfire', 'industrial', 'other']; +const FP_TYPES: FalsePositiveType[] = [ + 'antenna', + 'building', + 'cliff', + 'dark', + 'dust', + 'high_cloud', + 'low_cloud', + 'lens_flare', + 'lens_droplet', + 'light', + 'rain', + 'trail', + 'road', + 'sky', + 'tree', + 'water_body', + 'other', +]; + +type LabelKind = 'smoke' | 'false_positive'; + +function MemberThumb({ + member, + selected, + onToggle, +}: { + member: SequenceGroupMember; + selected: boolean; + onToggle: () => void; +}) { + // Use the first detection of the sequence as a thumbnail proxy. + const { data: detectionsPage } = useQuery({ + queryKey: ['sequenceFirstDetection', member.sequence_id], + queryFn: () => + apiClient.getDetections({ + sequence_id: member.sequence_id, + size: 1, + order_by: 'recorded_at', + order_direction: 'asc', + }), + }); + const firstDetectionId = detectionsPage?.items[0]?.id ?? null; + const { data: image } = useDetectionImage(firstDetectionId); + + return ( + + ); +} + +export default function SequenceGroupAnnotatePage() { + const { id } = useParams<{ id: string }>(); + const groupId = Number(id); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { + data: group, + isLoading, + error, + } = useQuery({ + queryKey: ['sequenceGroup', groupId], + queryFn: () => apiClient.getSequenceGroup(groupId), + enabled: !Number.isNaN(groupId), + }); + + const [labelKind, setLabelKind] = useState('smoke'); + const [smokeType, setSmokeType] = useState('wildfire'); + const [fpType, setFpType] = useState('antenna'); + const [isUnsure, setIsUnsure] = useState(false); + const [force, setForce] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Default selection: every member that isn't already annotated. + useEffect(() => { + if (!group) return; + setSelectedIds(new Set(group.members.filter(m => !m.has_annotation).map(m => m.sequence_id))); + }, [group]); + + const bulkMutation = useMutation({ + mutationFn: (payload: BulkAnnotateRequest) => apiClient.bulkAnnotateSequences(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sequenceGroup', groupId] }); + }, + }); + + if (isLoading) { + return ( +
+ Loading group… +
+ ); + } + if (error || !group) { + return ( +
+ + Failed to load group {groupId} +
+ ); + } + + const toggleSelect = (sid: number) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(sid)) { + next.delete(sid); + } else { + next.add(sid); + } + return next; + }); + }; + + const allSelected = group.members.every(m => selectedIds.has(m.sequence_id)); + const toggleAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(group.members.map(m => m.sequence_id))); + } + }; + + const submit = () => { + const payload: BulkAnnotateRequest = { + sequence_ids: Array.from(selectedIds), + group_id: group.id, + is_unsure: isUnsure, + force, + }; + if (labelKind === 'smoke') payload.smoke_type = smokeType; + else payload.false_positive_type = fpType; + bulkMutation.mutate(payload); + }; + + const result = bulkMutation.data; + + return ( +
+
+ +

Sequence group #{group.id}

+
+ camera {group.camera_id} · azimuth {group.azimuth}° · {group.members.length} members + {group.smoke_type && ` · current label: smoke / ${group.smoke_type}`} + {group.false_positive_type && ` · current label: FP / ${group.false_positive_type}`} +
+
+ +
+ + {selectedIds.size} selected +
+ +
+ {group.members.map(m => ( + toggleSelect(m.sequence_id)} + /> + ))} +
+ +
+
+
+ +
+ + +
+
+ +
+ + {labelKind === 'smoke' ? ( + + ) : ( + + )} +
+ +
+ + {(group.smoke_type || group.false_positive_type) && ( + + )} +
+
+ +
+
+ Applies to the {selectedIds.size} selected sequences and writes the label onto the + group. +
+ +
+ + {result && ( +
+ {result.applied.length} applied + {' · '} + {result.skipped.length} skipped + {result.group_label_updated && ( + · group label saved + )} + {result.skipped.length > 0 && ( +
    + {result.skipped.map(s => ( +
  • + seq {s.sequence_id}: {s.reason} +
  • + ))} +
+ )} +
+ )} + + {bulkMutation.isError && ( +
+ Bulk annotation failed: {(bulkMutation.error as Error).message} +
+ )} +
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index de765ae..64d27e4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -22,6 +22,9 @@ import { SequenceAnnotationFilters, DetectionAnnotationFilters, ApiError, + SequenceGroup, + BulkAnnotateRequest, + BulkAnnotateResponse, } from '@/types/api'; import { API_ENDPOINTS } from '@/utils/constants'; @@ -219,6 +222,22 @@ class ApiClient { await this.client.delete(`/annotations/sequences/${id}`); } + // Sequence Groups + async getSequenceGroup(id: number): Promise { + const response: AxiosResponse = await this.client.get( + `${API_ENDPOINTS.SEQUENCE_GROUPS}${id}` + ); + return response.data; + } + + async bulkAnnotateSequences(payload: BulkAnnotateRequest): Promise { + const response: AxiosResponse = await this.client.post( + API_ENDPOINTS.SEQUENCE_ANNOTATIONS_BULK, + payload + ); + return response.data; + } + // Detections async getDetections( filters: { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 2a59c71..0502247 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -103,6 +103,57 @@ export interface DetectionAnnotationData { bbox_xyxyn?: [number, number, number, number]; } +export interface SequenceGroupRepresentativeBbox { + xyxyn: [number, number, number, number]; + confidence: number; +} + +export interface SequenceGroupMember { + sequence_id: number; + alert_api_id: number; + camera_name: string; + recorded_at: string; + last_seen_at: string; + has_annotation: boolean; +} + +export interface SequenceGroup { + id: number; + camera_id: number; + azimuth: number; + representative_bbox: SequenceGroupRepresentativeBbox; + smoke_type: SmokeType | null; + false_positive_type: FalsePositiveType | null; + is_unsure: boolean; + labeled_at: string | null; + labeled_by_user_id: number | null; + created_at: string; + updated_at: string | null; + members: SequenceGroupMember[]; +} + +export interface BulkAnnotateRequest { + sequence_ids: number[]; + group_id?: number; + smoke_type?: SmokeType; + false_positive_type?: FalsePositiveType; + is_unsure: boolean; + force?: boolean; +} + +export interface BulkAnnotateResult { + sequence_id: number; + status: 'applied' | 'skipped'; + reason: string | null; + annotation_id: number | null; +} + +export interface BulkAnnotateResponse { + applied: BulkAnnotateResult[]; + skipped: BulkAnnotateResult[]; + group_label_updated: boolean; +} + // Enums export type SmokeType = 'wildfire' | 'industrial' | 'other'; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 6bd1122..fe14935 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -26,6 +26,8 @@ export const API_ENDPOINTS = { SEQUENCES: '/sequences/', SEQUENCE_ANNOTATIONS: '/annotations/sequences/', + SEQUENCE_ANNOTATIONS_BULK: '/annotations/sequences/bulk', + SEQUENCE_GROUPS: '/sequence_groups/', DETECTION_ANNOTATIONS: '/annotations/detections/', DETECTIONS: '/detections/', CAMERAS: '/cameras/', From 24e4b029ef617fd50c59b8401e6a71dc111cfa2b Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 09:49:49 +0200 Subject: [PATCH 06/22] test: cover assign-groups + bulk-annotate flow - assign-groups creates a new group from an unmatched sequence - bulk-annotate writes the label both onto the sequences and onto the group (so future joiners inherit it) - bulk-annotate rejects a conflicting label on an already-labeled group with 409, accepts it with force=true - request validation rejects payloads with neither or both labels --- .../tests/endpoints/test_sequence_groups.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 annotation_api/src/tests/endpoints/test_sequence_groups.py diff --git a/annotation_api/src/tests/endpoints/test_sequence_groups.py b/annotation_api/src/tests/endpoints/test_sequence_groups.py new file mode 100644 index 0000000..4bb6d52 --- /dev/null +++ b/annotation_api/src/tests/endpoints/test_sequence_groups.py @@ -0,0 +1,167 @@ +"""Tests for the sequence-groups + bulk-annotate flow. + +Covers: +- POST /sequence_groups/assign creates a new group from an unassigned sequence +- POST /sequence_groups/assign matches a second sequence into the existing + group when its representative bbox overlaps (IoU > 0.5) and inherits the + group's label automatically +- POST /annotations/sequences/bulk applies labels and writes them onto the + group; rejects conflicting labels unless force=True +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def _set_seq_metadata( + session: AsyncSession, + sequence_id: int, + *, + camera_id: int, + azimuth: int, +) -> None: + await session.exec( + text( + "UPDATE sequences SET camera_id = :cam, azimuth = :az " "WHERE id = :sid" + ).bindparams(cam=camera_id, az=azimuth, sid=sequence_id) + ) + await session.commit() + + +@pytest.mark.asyncio +async def test_assign_groups_creates_group_for_unmatched_sequence( + authenticated_client: AsyncClient, + sequence_session: AsyncSession, + detection_session: AsyncSession, +): + """Sequence 1 has detections with bbox in the [0.12-0.5, 0.13-0.55] region; + no group exists yet → assign should create one and link it.""" + await _set_seq_metadata(sequence_session, 1, camera_id=42, azimuth=90) + + response = await authenticated_client.post("/sequence_groups/assign") + assert response.status_code == 200 + summary = response.json() + assert summary["new_groups"] >= 1 + + # The created group should now own sequence 1. + seq_response = await authenticated_client.get("/sequences/1") + assert seq_response.status_code == 200 + seq_payload = seq_response.json() + assert seq_payload.get("sequence_group_id") is not None + + +@pytest.mark.asyncio +async def test_bulk_annotate_writes_label_on_group_and_seqs( + authenticated_client: AsyncClient, + sequence_session: AsyncSession, + detection_session: AsyncSession, +): + """After assigning a group from seq 1, bulk-annotating that seq should + apply the label, mark the SequenceAnnotation as SEQ_ANNOTATION_DONE, and + write the label onto the group itself.""" + await _set_seq_metadata(sequence_session, 1, camera_id=42, azimuth=90) + assign_resp = await authenticated_client.post("/sequence_groups/assign") + assert assign_resp.status_code == 200 + + # Discover the group_id from the sequence. + seq_payload = (await authenticated_client.get("/sequences/1")).json() + group_id = seq_payload["sequence_group_id"] + assert group_id is not None + + bulk_resp = await authenticated_client.post( + "/annotations/sequences/bulk", + json={ + "sequence_ids": [1], + "group_id": group_id, + "smoke_type": "wildfire", + "is_unsure": False, + }, + ) + assert bulk_resp.status_code == 200 + body = bulk_resp.json() + assert len(body["applied"]) == 1 + assert body["group_label_updated"] is True + + group_resp = await authenticated_client.get(f"/sequence_groups/{group_id}") + assert group_resp.status_code == 200 + group_payload = group_resp.json() + assert group_payload["smoke_type"] == "wildfire" + assert group_payload["false_positive_type"] is None + assert group_payload["labeled_at"] is not None + + +@pytest.mark.asyncio +async def test_bulk_annotate_rejects_conflicting_label_without_force( + authenticated_client: AsyncClient, + sequence_session: AsyncSession, + detection_session: AsyncSession, +): + """A group already labeled `wildfire` must reject a request to relabel + it as `antenna` unless the caller passes force=True.""" + await _set_seq_metadata(sequence_session, 1, camera_id=42, azimuth=90) + await authenticated_client.post("/sequence_groups/assign") + + seq_payload = (await authenticated_client.get("/sequences/1")).json() + group_id = seq_payload["sequence_group_id"] + + # First bulk-annotate sets the label. + first = await authenticated_client.post( + "/annotations/sequences/bulk", + json={ + "sequence_ids": [1], + "group_id": group_id, + "smoke_type": "wildfire", + "is_unsure": False, + }, + ) + assert first.status_code == 200 + + # Conflicting attempt without force → 409. + conflict = await authenticated_client.post( + "/annotations/sequences/bulk", + json={ + "sequence_ids": [1], + "group_id": group_id, + "false_positive_type": "antenna", + "is_unsure": False, + }, + ) + assert conflict.status_code == 409 + + # Same payload with force=True → accepted. + forced = await authenticated_client.post( + "/annotations/sequences/bulk", + json={ + "sequence_ids": [1], + "group_id": group_id, + "false_positive_type": "antenna", + "is_unsure": False, + "force": True, + }, + ) + assert forced.status_code == 200 + + +@pytest.mark.asyncio +async def test_bulk_annotate_requires_exactly_one_label( + authenticated_client: AsyncClient, +): + """Bulk request rejects payload that sets neither or both labels.""" + neither = await authenticated_client.post( + "/annotations/sequences/bulk", + json={"sequence_ids": [1], "is_unsure": False}, + ) + assert neither.status_code == 422 + + both = await authenticated_client.post( + "/annotations/sequences/bulk", + json={ + "sequence_ids": [1], + "smoke_type": "wildfire", + "false_positive_type": "antenna", + "is_unsure": False, + }, + ) + assert both.status_code == 422 From 409bffc63baae1865b82002ccdc9815055854dfc Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 09:57:53 +0200 Subject: [PATCH 07/22] fix: address codex review on sequence-groups - migration header: align Revises comment with the actual down_revision (a1b2c3d4e5f6, the others_bboxes migration) - shorten the XOR check constraint to its equivalent positive form (smoke_type IS NULL OR false_positive_type IS NULL) - delete the unused SequenceGroupLabelUpdate schema and the unused module-level logger - merge the two duplicated label-rewriting helpers into apply_label_to_sequences_bbox in services/annotation_generation.py - add UNDER_ANNOTATION to the bulk-annotate locked stages so active human work isn't clobbered - only write the label onto the group when at least one sequence was actually applied; otherwise the group would carry a label that never reached any current member - frontend bulk-error display reads the API's detail field with the Error.message fallback (axios rejects with { detail }) - test docstring: remove the unsupported claim about cross-sequence inheritance coverage (tested end-to-end via the make pipeline) --- .../api_v1/endpoints/sequence_annotations.py | 43 ++++++------------- .../api/api_v1/endpoints/sequence_groups.py | 25 +++-------- annotation_api/src/app/models.py | 4 +- .../src/app/schemas/sequence_group.py | 22 +--------- .../src/app/services/annotation_generation.py | 25 ++++++++++- ...0_0700-b2c3d4e5f6a7_add_sequence_groups.py | 6 +-- .../tests/endpoints/test_sequence_groups.py | 13 +++--- .../src/pages/SequenceGroupAnnotatePage.tsx | 5 ++- 8 files changed, 58 insertions(+), 85 deletions(-) diff --git a/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py b/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py index 0008e9e..0df7754 100644 --- a/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py +++ b/annotation_api/src/app/api/api_v1/endpoints/sequence_annotations.py @@ -44,7 +44,10 @@ SequenceAnnotationRead, SequenceAnnotationUpdate, ) -from app.services.annotation_generation import AnnotationGenerationService +from app.services.annotation_generation import ( + AnnotationGenerationService, + apply_label_to_sequences_bbox, +) router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -726,39 +729,17 @@ async def delete_sequence_annotation( await annotations.delete(annotation_id) -# Stages past which we don't overwrite an annotation in bulk-annotate. The -# review pipeline marks annotations as SEQ_ANNOTATION_DONE when the labels -# are filled but the geometry still needs visual check; anything past that -# is reviewed work the bulk action must not clobber. +# Stages past which we don't overwrite an annotation in bulk-annotate. +# UNDER_ANNOTATION is included to avoid clobbering work an annotator is +# actively editing; SEQ_ANNOTATION_DONE+ is reviewed labelled work. _BULK_LOCKED_STAGES = { + SequenceAnnotationProcessingStage.UNDER_ANNOTATION, SequenceAnnotationProcessingStage.IN_REVIEW, SequenceAnnotationProcessingStage.NEEDS_MANUAL, SequenceAnnotationProcessingStage.ANNOTATED, } -def _apply_label_to_sequences_bbox( - annotation: SequenceAnnotationData, - *, - smoke_type: Optional[SmokeType], - false_positive_type: Optional[FalsePositiveType], -) -> None: - """In-place rewrite of every bbox cluster's labels. Either marks the - cluster as smoke of the given type (and clears FP flags), or marks it as - a single false-positive type (and clears smoke fields).""" - for bbox in annotation.sequences_bbox: - if smoke_type is not None: - bbox.is_smoke = True - bbox.smoke_type = smoke_type - bbox.false_positive_types = [] - else: - bbox.is_smoke = False - bbox.smoke_type = None - bbox.false_positive_types = ( - [false_positive_type] if false_positive_type else [] - ) - - @router.post( "/bulk", status_code=status.HTTP_200_OK, @@ -866,7 +847,7 @@ async def bulk_annotate_sequences( ) continue - _apply_label_to_sequences_bbox( + apply_label_to_sequences_bbox( generated, smoke_type=payload.smoke_type, false_positive_type=payload.false_positive_type, @@ -905,9 +886,11 @@ async def bulk_annotate_sequences( ) ) - # Write the label onto the group so future joiners inherit it. + # Write the label onto the group so future joiners inherit it. Only do + # so if at least one sequence was actually applied — otherwise the group + # would carry a label that never made it onto any of its current members. group_label_updated = False - if group is not None: + if group is not None and applied: group.smoke_type = payload.smoke_type.value if payload.smoke_type else None group.false_positive_type = ( payload.false_positive_type.value if payload.false_positive_type else None diff --git a/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py b/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py index e18c294..0d1b92f 100644 --- a/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py +++ b/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py @@ -3,7 +3,6 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. -import logging from statistics import median from typing import List, Optional @@ -31,10 +30,12 @@ SequenceGroupRead, SequenceGroupReadWithMembers, ) -from app.services.annotation_generation import AnnotationGenerationService +from app.services.annotation_generation import ( + AnnotationGenerationService, + apply_label_to_sequences_bbox, +) router = APIRouter() -logger = logging.getLogger("uvicorn.error") # Cross-sequence grouping threshold. Stricter than within-sequence clustering # (IoU=0) because the precision cost of mis-grouping is much higher: a wrong @@ -150,22 +151,6 @@ def _compute_representative_bbox(detections: List[Detection]) -> Optional[dict]: } -def _label_sequences_bbox(annotation, *, smoke_type, false_positive_type) -> None: - """In-place rewrite of every bbox cluster's labels for inherited - annotations. Mirrors the helper in sequence_annotations.bulk.""" - for bbox in annotation.sequences_bbox: - if smoke_type is not None: - bbox.is_smoke = True - bbox.smoke_type = smoke_type - bbox.false_positive_types = [] - else: - bbox.is_smoke = False - bbox.smoke_type = None - bbox.false_positive_types = ( - [false_positive_type] if false_positive_type else [] - ) - - @router.post( "/assign", response_model=AssignGroupsResponse, @@ -284,7 +269,7 @@ async def assign_groups( if best_group.false_positive_type else None ) - _label_sequences_bbox( + apply_label_to_sequences_bbox( generated, smoke_type=smoke_enum, false_positive_type=fp_enum ) diff --git a/annotation_api/src/app/models.py b/annotation_api/src/app/models.py index 7bfe5f3..4d7f85e 100644 --- a/annotation_api/src/app/models.py +++ b/annotation_api/src/app/models.py @@ -244,9 +244,7 @@ class SequenceGroup(SQLModel, table=True): Index("ix_sequence_groups_camera_azimuth", "camera_id", "azimuth"), # Mutually-exclusive label: at most one of smoke_type / fp_type set. CheckConstraint( - "(smoke_type IS NULL AND false_positive_type IS NULL) " - "OR (smoke_type IS NOT NULL AND false_positive_type IS NULL) " - "OR (smoke_type IS NULL AND false_positive_type IS NOT NULL)", + "smoke_type IS NULL OR false_positive_type IS NULL", name="ck_sequence_group_label_xor", ), # labeled_at must be set iff a label is present. diff --git a/annotation_api/src/app/schemas/sequence_group.py b/annotation_api/src/app/schemas/sequence_group.py index 4ee9813..22558ae 100644 --- a/annotation_api/src/app/schemas/sequence_group.py +++ b/annotation_api/src/app/schemas/sequence_group.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import List, Optional -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator from app.models import FalsePositiveType, SmokeType @@ -16,7 +16,6 @@ "SequenceGroupRead", "SequenceGroupMember", "SequenceGroupReadWithMembers", - "SequenceGroupLabelUpdate", ] @@ -50,25 +49,6 @@ class SequenceGroupCreate(BaseModel): representative_bbox: RepresentativeBbox -class SequenceGroupLabelUpdate(BaseModel): - """Labels written when an annotator bulk-confirms a group. Exactly one of - smoke_type / false_positive_type must be set.""" - - smoke_type: Optional[SmokeType] = None - false_positive_type: Optional[FalsePositiveType] = None - is_unsure: bool = False - - @model_validator(mode="after") - def _exactly_one_label(self) -> "SequenceGroupLabelUpdate": - smoke = self.smoke_type is not None - fp = self.false_positive_type is not None - if smoke == fp: # both set or both unset - raise ValueError( - "exactly one of smoke_type or false_positive_type must be set" - ) - return self - - class SequenceGroupMember(BaseModel): """Lightweight projection of a sequence inside a group's members list.""" diff --git a/annotation_api/src/app/services/annotation_generation.py b/annotation_api/src/app/services/annotation_generation.py index 708ca3d..d9974e4 100644 --- a/annotation_api/src/app/services/annotation_generation.py +++ b/annotation_api/src/app/services/annotation_generation.py @@ -31,7 +31,7 @@ from sqlalchemy import select from sqlmodel.ext.asyncio.session import AsyncSession -from app.models import Detection, Sequence +from app.models import Detection, FalsePositiveType, Sequence, SmokeType from app.schemas.annotation_validation import ( BoundingBox, SequenceBBox, @@ -117,6 +117,29 @@ def filter_predictions_by_confidence( ] +def apply_label_to_sequences_bbox( + annotation: SequenceAnnotationData, + *, + smoke_type: Optional[SmokeType] = None, + false_positive_type: Optional[FalsePositiveType] = None, +) -> None: + """In-place rewrite of every cluster's labels for a generated annotation. + Called by bulk-annotate (after `auto_generate_annotation`) to stamp the + chosen smoke/FP type onto every cluster the auto-generator produced. + Exactly one of `smoke_type` / `false_positive_type` should be set.""" + for bbox in annotation.sequences_bbox: + if smoke_type is not None: + bbox.is_smoke = True + bbox.smoke_type = smoke_type + bbox.false_positive_types = [] + else: + bbox.is_smoke = False + bbox.smoke_type = None + bbox.false_positive_types = ( + [false_positive_type] if false_positive_type else [] + ) + + def cluster_boxes_by_iou( boxes_with_ids: List[Tuple[List[float], Any]], iou_threshold: float ) -> List[List[Tuple[List[float], Any]]]: diff --git a/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py b/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py index 60b5234..85887d3 100644 --- a/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py +++ b/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py @@ -1,7 +1,7 @@ """add sequence_groups + sequences.sequence_group_id Revision ID: b2c3d4e5f6a7 -Revises: 063dc76c9846 +Revises: a1b2c3d4e5f6 Create Date: 2026-05-10 07:00:00.000000 """ @@ -52,9 +52,7 @@ def upgrade() -> None: ), sa.PrimaryKeyConstraint("id"), sa.CheckConstraint( - "(smoke_type IS NULL AND false_positive_type IS NULL) " - "OR (smoke_type IS NOT NULL AND false_positive_type IS NULL) " - "OR (smoke_type IS NULL AND false_positive_type IS NOT NULL)", + "smoke_type IS NULL OR false_positive_type IS NULL", name="ck_sequence_group_label_xor", ), sa.CheckConstraint( diff --git a/annotation_api/src/tests/endpoints/test_sequence_groups.py b/annotation_api/src/tests/endpoints/test_sequence_groups.py index 4bb6d52..1fb9eac 100644 --- a/annotation_api/src/tests/endpoints/test_sequence_groups.py +++ b/annotation_api/src/tests/endpoints/test_sequence_groups.py @@ -2,11 +2,14 @@ Covers: - POST /sequence_groups/assign creates a new group from an unassigned sequence -- POST /sequence_groups/assign matches a second sequence into the existing - group when its representative bbox overlaps (IoU > 0.5) and inherits the - group's label automatically -- POST /annotations/sequences/bulk applies labels and writes them onto the - group; rejects conflicting labels unless force=True +- POST /annotations/sequences/bulk applies labels, writes them onto the + group, and rejects conflicting labels unless force=True +- Request validation rejects payloads with neither or both labels + +Cross-sequence inheritance (a second sequence joining a labeled group and +auto-receiving its label) is exercised end-to-end via the make pipeline, +not in this unit suite — the test fixtures only seed two sequences and +they intentionally have non-overlapping bboxes. """ import pytest diff --git a/frontend/src/pages/SequenceGroupAnnotatePage.tsx b/frontend/src/pages/SequenceGroupAnnotatePage.tsx index 1ada10d..e0c9a48 100644 --- a/frontend/src/pages/SequenceGroupAnnotatePage.tsx +++ b/frontend/src/pages/SequenceGroupAnnotatePage.tsx @@ -337,7 +337,10 @@ export default function SequenceGroupAnnotatePage() { {bulkMutation.isError && (
- Bulk annotation failed: {(bulkMutation.error as Error).message} + Bulk annotation failed:{' '} + {(bulkMutation.error as { detail?: string; message?: string })?.detail ?? + (bulkMutation.error as Error)?.message ?? + 'Unknown error'}
)} From c0eebc25e7409456805b4a85a2554bfe74ca655b Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 10:13:56 +0200 Subject: [PATCH 08/22] feat(api): list sequence groups + expose first-detection bbox per member - new GET /sequence_groups/ paginated endpoint with member_count and ?labeled=true|false filter, ordered by created_at desc - GET /sequence_groups/{id} now returns each member's first detection id and its algo_predictions, so the UI can render a thumbnail with bbox overlays without an extra round-trip per member --- .../api/api_v1/endpoints/sequence_groups.py | 97 ++++++++++++++++++- .../src/app/schemas/sequence_group.py | 24 ++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py b/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py index 0d1b92f..7dc340d 100644 --- a/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py +++ b/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py @@ -6,9 +6,11 @@ from statistics import median from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, Path, status +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from fastapi_pagination import Page, Params +from fastapi_pagination.ext.sqlalchemy import apaginate from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import desc, func, select from sqlmodel.ext.asyncio.session import AsyncSession from app.api.dependencies import get_current_user, get_sequence_group_crud @@ -26,6 +28,7 @@ ) from app.schemas.sequence_annotations import SequenceAnnotationCreate from app.schemas.sequence_group import ( + SequenceGroupListItem, SequenceGroupMember, SequenceGroupRead, SequenceGroupReadWithMembers, @@ -43,6 +46,64 @@ _GROUP_IOU_THRESHOLD = 0.5 +@router.get( + "/", + response_model=Page[SequenceGroupListItem], + summary="List sequence groups (paginated, with member counts)", +) +async def list_sequence_groups( + labeled: Optional[bool] = Query( + None, + description=( + "Filter by label presence: true = only labeled groups, " + "false = only unlabeled, omit for both." + ), + ), + params: Params = Depends(), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> Page[SequenceGroupListItem]: + member_count_subq = ( + select( + Sequence.sequence_group_id.label("group_id"), + func.count(Sequence.id).label("member_count"), + ) + .where(Sequence.sequence_group_id.is_not(None)) + .group_by(Sequence.sequence_group_id) + .subquery() + ) + query = ( + select( + SequenceGroup.id, + SequenceGroup.camera_id, + SequenceGroup.azimuth, + SequenceGroup.representative_bbox, + SequenceGroup.smoke_type, + SequenceGroup.false_positive_type, + SequenceGroup.is_unsure, + SequenceGroup.labeled_at, + SequenceGroup.created_at, + func.coalesce(member_count_subq.c.member_count, 0).label("member_count"), + ) + .outerjoin( + member_count_subq, member_count_subq.c.group_id == SequenceGroup.id + ) + .order_by(desc(SequenceGroup.created_at)) + ) + if labeled is True: + query = query.where( + (SequenceGroup.smoke_type.is_not(None)) + | (SequenceGroup.false_positive_type.is_not(None)) + ) + elif labeled is False: + query = query.where( + SequenceGroup.smoke_type.is_(None) + & SequenceGroup.false_positive_type.is_(None) + ) + + return await apaginate(session, query, params) + + @router.get( "/{group_id}", response_model=SequenceGroupReadWithMembers, @@ -61,7 +122,32 @@ async def get_sequence_group( detail=f"Sequence group {group_id} not found", ) - # Members joined to their (optional) annotation in one query. + # First detection per sequence in the group (lowest recorded_at). Used + # for the UI thumbnail + bbox overlay. + first_det_subq = ( + select( + Detection.sequence_id.label("seq_id"), + func.min(Detection.recorded_at).label("first_recorded_at"), + ) + .join(Sequence, Sequence.id == Detection.sequence_id) + .where(Sequence.sequence_group_id == group_id) + .group_by(Detection.sequence_id) + .subquery() + ) + first_det_join = ( + select( + Detection.sequence_id.label("seq_id"), + Detection.id.label("det_id"), + Detection.algo_predictions.label("det_algo"), + ) + .join( + first_det_subq, + (first_det_subq.c.seq_id == Detection.sequence_id) + & (first_det_subq.c.first_recorded_at == Detection.recorded_at), + ) + .subquery() + ) + member_query = ( select( Sequence.id, @@ -70,8 +156,11 @@ async def get_sequence_group( Sequence.recorded_at, Sequence.last_seen_at, SequenceAnnotation.id, + first_det_join.c.det_id, + first_det_join.c.det_algo, ) .outerjoin(SequenceAnnotation, SequenceAnnotation.sequence_id == Sequence.id) + .outerjoin(first_det_join, first_det_join.c.seq_id == Sequence.id) .where(Sequence.sequence_group_id == group_id) .order_by(Sequence.recorded_at) ) @@ -84,6 +173,8 @@ async def get_sequence_group( recorded_at=row[3], last_seen_at=row[4], has_annotation=row[5] is not None, + first_detection_id=row[6], + first_detection_algo_predictions=row[7], ) for row in result.all() ] diff --git a/annotation_api/src/app/schemas/sequence_group.py b/annotation_api/src/app/schemas/sequence_group.py index 22558ae..f660454 100644 --- a/annotation_api/src/app/schemas/sequence_group.py +++ b/annotation_api/src/app/schemas/sequence_group.py @@ -14,6 +14,7 @@ "RepresentativeBbox", "SequenceGroupCreate", "SequenceGroupRead", + "SequenceGroupListItem", "SequenceGroupMember", "SequenceGroupReadWithMembers", ] @@ -50,7 +51,10 @@ class SequenceGroupCreate(BaseModel): class SequenceGroupMember(BaseModel): - """Lightweight projection of a sequence inside a group's members list.""" + """Lightweight projection of a sequence inside a group's members list. + Includes the first detection's id + algo_predictions so the UI can + render a thumbnail with bbox overlays without an extra round trip per + member.""" sequence_id: int alert_api_id: int @@ -60,6 +64,24 @@ class SequenceGroupMember(BaseModel): has_annotation: bool = Field( description="True if a SequenceAnnotation already exists for this sequence" ) + first_detection_id: Optional[int] = None + first_detection_algo_predictions: Optional[dict] = None + + +class SequenceGroupListItem(BaseModel): + """Lightweight row for the groups list page; includes member_count to + avoid an N+1 in the UI.""" + + id: int + camera_id: int + azimuth: int + representative_bbox: RepresentativeBbox + smoke_type: Optional[SmokeType] + false_positive_type: Optional[FalsePositiveType] + is_unsure: bool + labeled_at: Optional[datetime] + created_at: datetime + member_count: int class SequenceGroupRead(BaseModel): From 57ba2fec98526e1c18567d6845ccf6e614458681 Mon Sep 17 00:00:00 2001 From: Mateo Date: Sun, 10 May 2026 10:14:06 +0200 Subject: [PATCH 09/22] feat(frontend): groups list page + bbox overlay on review thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new "Sequence groups" entry in the left sidebar pointing at /sequence-groups (the index page) so groups are discoverable from the navigation - new SequenceGroupsListPage: paginated table of all groups with members count, label state, and a filter (all / labeled / unlabeled) - SequenceGroupAnnotatePage now overlays two bboxes on each thumbnail: - the sequence's own tracked predictions (red, solid) - the group's reference region (yellow, dashed) — same on every thumbnail so the annotator can eyeball whether each member really overlaps the group - thumbnails consume the new first_detection_id + algo_predictions inlined in the group response, removing the previous N+1 query --- frontend/src/App.tsx | 2 + frontend/src/components/layout/AppLayout.tsx | 2 + .../src/pages/SequenceGroupAnnotatePage.tsx | 92 ++++++++--- frontend/src/pages/SequenceGroupsListPage.tsx | 152 ++++++++++++++++++ frontend/src/services/api.ts | 11 ++ frontend/src/types/api.ts | 15 ++ 6 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 frontend/src/pages/SequenceGroupsListPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fca11ab..38fe45e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import DetectionAnnotatePage from '@/pages/DetectionAnnotatePage'; import DetectionReviewPage from '@/pages/DetectionReviewPage'; import DetectionSequenceAnnotatePage from '@/pages/DetectionSequenceAnnotatePage'; import SequenceGroupAnnotatePage from '@/pages/SequenceGroupAnnotatePage'; +import SequenceGroupsListPage from '@/pages/SequenceGroupsListPage'; import UserManagementPage from '@/pages/UserManagementPage'; import LoginPage from '@/pages/LoginPage'; import { useAuthStore } from '@/store/useAuthStore'; @@ -86,6 +87,7 @@ function App() { path="/detections/:sequenceId/annotate/:detectionId?" element={} /> + } /> } diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 818b01a..edfeb22 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -11,6 +11,7 @@ import { LogOut, User, Users, + Boxes, LucideIcon, } from 'lucide-react'; import { clsx } from 'clsx'; @@ -126,6 +127,7 @@ function SidebarContent({ currentPath }: { currentPath: string }) { { name: 'Review', href: '/detections/review' }, ], }, + { name: 'Sequence groups', href: '/sequence-groups', icon: Boxes }, ...(isSuperuser() ? [{ name: 'User Management', href: '/users', icon: Users }] : []), ]; diff --git a/frontend/src/pages/SequenceGroupAnnotatePage.tsx b/frontend/src/pages/SequenceGroupAnnotatePage.tsx index e0c9a48..3a686f4 100644 --- a/frontend/src/pages/SequenceGroupAnnotatePage.tsx +++ b/frontend/src/pages/SequenceGroupAnnotatePage.tsx @@ -5,9 +5,11 @@ import { Loader2, AlertCircle, CheckCircle, Clock } from 'lucide-react'; import { apiClient } from '@/services/api'; import { useDetectionImage } from '@/hooks/useDetectionImage'; import { + AlgoPrediction, BulkAnnotateRequest, FalsePositiveType, SmokeType, + SequenceGroup, SequenceGroupMember, } from '@/types/api'; @@ -36,26 +38,22 @@ type LabelKind = 'smoke' | 'false_positive'; function MemberThumb({ member, + groupBbox, selected, onToggle, }: { member: SequenceGroupMember; + groupBbox: SequenceGroup['representative_bbox']; selected: boolean; onToggle: () => void; }) { - // Use the first detection of the sequence as a thumbnail proxy. - const { data: detectionsPage } = useQuery({ - queryKey: ['sequenceFirstDetection', member.sequence_id], - queryFn: () => - apiClient.getDetections({ - sequence_id: member.sequence_id, - size: 1, - order_by: 'recorded_at', - order_direction: 'asc', - }), - }); - const firstDetectionId = detectionsPage?.items[0]?.id ?? null; - const { data: image } = useDetectionImage(firstDetectionId); + const { data: image } = useDetectionImage(member.first_detection_id); + const [imgLoaded, setImgLoaded] = useState(false); + + // Tracked predictions for this sequence's first detection — used to + // visually validate that the matched bbox really overlaps the group's + // reference region (drawn as a yellow dashed outline on top). + const predictions: AlgoPrediction[] = member.first_detection_algo_predictions?.predictions ?? []; return (