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() 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..984690f 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,15 +32,23 @@ SequenceAnnotation, SequenceAnnotationContribution, SequenceAnnotationProcessingStage, + SequenceGroup, SmokeType, ) from app.schemas.annotation_validation import SequenceAnnotationData from app.schemas.sequence_annotations import ( + SequenceAnnotationBulkRequest, + SequenceAnnotationBulkResponse, + SequenceAnnotationBulkResult, SequenceAnnotationCreate, SequenceAnnotationRead, SequenceAnnotationUpdate, ) -from app.services.annotation_generation import AnnotationGenerationService +from app.services.annotation_generation import ( + AnnotationGenerationService, + apply_label_to_sequences_bbox, + derive_group_label_from_annotation, +) router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -411,6 +419,12 @@ async def create_sequence_annotation( # Commit the detection annotations await annotations.session.commit() + # If the seq is part of a validated group, fan the label out to other + # members. Skips locked annotations and re-uses auto-generation per seq. + propagation_warning = await _propagate_to_group_if_validated( + sequence_annotation, annotations, annotations.session, current_user.id + ) + # Get contributors for this annotation contributors = await annotations.get_annotation_contributors(sequence_annotation.id) @@ -419,6 +433,7 @@ async def create_sequence_annotation( annotation_dict["contributors"] = [ {"id": user.id, "username": user.username} for user in contributors ] + annotation_dict["group_propagation_warning"] = propagation_warning return SequenceAnnotationRead(**annotation_dict) @@ -701,6 +716,13 @@ async def update_sequence_annotation( # Commit the detection annotations await annotations.session.commit() + # Same propagation hook as create_sequence_annotation: when the + # annotation has just reached SEQ_ANNOTATION_DONE and the seq is in a + # validated group, fan the label out to the rest of the group. + propagation_warning = await _propagate_to_group_if_validated( + updated_annotation, annotations, annotations.session, current_user.id + ) + # Get contributors for this annotation contributors = await annotations.get_annotation_contributors(annotation_id) @@ -709,6 +731,7 @@ async def update_sequence_annotation( annotation_dict["contributors"] = [ {"id": user.id, "username": user.username} for user in contributors ] + annotation_dict["group_propagation_warning"] = propagation_warning return SequenceAnnotationRead(**annotation_dict) @@ -720,3 +743,322 @@ 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 +# (or via group propagation). UNDER_ANNOTATION is included to avoid +# clobbering work an annotator is actively editing; SEQ_ANNOTATION_DONE+ +# is reviewed labelled work. Mirrored on the frontend by the +# ANNOTATED_STAGES set in +# frontend/src/pages/SequenceGroupAnnotatePage.tsx — keep both in sync +# when a new processing stage is added. +_BULK_LOCKED_STAGES = { + SequenceAnnotationProcessingStage.UNDER_ANNOTATION, + SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + SequenceAnnotationProcessingStage.IN_REVIEW, + SequenceAnnotationProcessingStage.NEEDS_MANUAL, + SequenceAnnotationProcessingStage.ANNOTATED, +} + + +async def _propagate_to_group_if_validated( + sequence_annotation: SequenceAnnotation, + annotations: SequenceAnnotationCRUD, + session: AsyncSession, + current_user_id: int, +) -> Optional[str]: + """If the source annotation has just reached SEQ_ANNOTATION_DONE and + the underlying sequence belongs to a validated group, derive a single + label from the annotation and fan it out to other group members that + aren't locked. Group's own label is updated accordingly. + + Returns a warning string when propagation was *attempted but skipped* + so the caller can surface it back to the annotator. Returns None when + propagation either succeeded or was simply not applicable (no group, + group not validated, no label to derive).""" + if ( + sequence_annotation.processing_stage + != SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE + ): + return None + + seq = await session.get(Sequence, sequence_annotation.sequence_id) + if seq is None or seq.sequence_group_id is None: + return None + + group = await session.get(SequenceGroup, seq.sequence_group_id) + if group is None or not group.is_validated: + return None + + annotation_data = SequenceAnnotationData(**sequence_annotation.annotation) + derived = derive_group_label_from_annotation(annotation_data) + if derived is None: + # No label signal we can carry over (e.g. is_smoke=True clusters + # with no smoke_type set). Group state stays as it is; the per-seq + # annotation still saves. If this turns out to mask real conflicts + # we'll surface it as a separate validation step at save time. + return None + smoke_type, fp_type = derived + new_smoke = smoke_type.value if smoke_type else None + new_fp = fp_type.value if fp_type else None + + # Refuse to silently flip the group's label if a previous member's + # annotation already set a different one. The new annotation is kept + # (it still belongs to its own sequence) but propagation stops here + # so the conflicting state is visible to the operator. + if group.smoke_type is not None and group.smoke_type != new_smoke: + message = ( + f"Group {group.id} already labeled smoke/{group.smoke_type}; " + f"this annotation implies {new_smoke or 'no smoke'}. " + "Saved on this sequence but not propagated." + ) + logger.warning(message) + return message + if group.false_positive_type is not None and group.false_positive_type != new_fp: + message = ( + f"Group {group.id} already labeled FP/{group.false_positive_type}; " + f"this annotation implies {new_fp or 'no FP'}. " + "Saved on this sequence but not propagated." + ) + logger.warning(message) + return message + + group.smoke_type = new_smoke + group.false_positive_type = new_fp + group.is_unsure = sequence_annotation.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) + + other_member_ids = ( + await session.execute( + select(Sequence.id).where( + Sequence.sequence_group_id == group.id, + Sequence.id != seq.id, + ) + ) + ).scalars().all() + + gen_service = AnnotationGenerationService( + session=session, + confidence_threshold=0.0, + iou_threshold=0.0, + min_cluster_size=1, + ) + + for member_id in other_member_ids: + existing = ( + await session.execute( + select(SequenceAnnotation).where( + SequenceAnnotation.sequence_id == member_id + ) + ) + ).scalar_one_or_none() + if existing is not None and existing.processing_stage in _BULK_LOCKED_STAGES: + continue + + generated = await gen_service.generate_annotation_for_sequence(member_id) + if generated is None: + continue + apply_label_to_sequences_bbox( + generated, smoke_type=smoke_type, false_positive_type=fp_type + ) + + if existing is None: + await annotations.create( + SequenceAnnotationCreate( + sequence_id=member_id, + has_missed_smoke=False, + is_unsure=group.is_unsure, + annotation=generated, + processing_stage=SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + ), + current_user_id, + ) + else: + await annotations.update( + existing.id, + SequenceAnnotationUpdate( + is_unsure=group.is_unsure, + annotation=generated, + processing_stage=SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + ), + current_user_id, + ) + + await session.commit() + + +@router.post( + "/bulk", + status_code=status.HTTP_200_OK, + response_model=SequenceAnnotationBulkResponse, + summary="Apply one label to many sequences (per-sequence commits)", +) +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 carries a different label. Pass force=true " + "to overwrite the group's label and re-propagate to " + "members that aren't already past SEQ_ANNOTATION_DONE. " + "Members locked at SEQ_ANNOTATION_DONE+ are not touched " + "even with force=true." + ), + ) + + # 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. 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 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 + ) + 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/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..757d4b9 --- /dev/null +++ b/annotation_api/src/app/api/api_v1/endpoints/sequence_groups.py @@ -0,0 +1,505 @@ +# 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 UTC, datetime +from statistics import median +from typing import List, Optional + +from fastapi import APIRouter, Body, 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 desc, func, 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, + SequenceAnnotationUpdate, +) +from app.schemas.sequence_group import ( + SequenceGroupListItem, + SequenceGroupMember, + SequenceGroupRead, + SequenceGroupReadWithMembers, + SequenceGroupUpdate, +) +from app.services.annotation_generation import ( + AnnotationGenerationService, + apply_label_to_sequences_bbox, + box_iou, +) + +router = APIRouter() + +# 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. R&D on 857 +# real sequences shows 0.3 captures natural smoke drift while filtering +# accidental tiny overlaps; 0.5 was too strict in practice. +_GROUP_IOU_THRESHOLD = 0.3 + + +@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]: + # Singletons (size-1 groups) are excluded from the list because the + # whole point of this page is to find groups worth bulk-annotating. + 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) + .having(func.count(Sequence.id) >= 2) + .subquery() + ) + query = ( + select( + SequenceGroup.id, + SequenceGroup.camera_id, + SequenceGroup.azimuth, + SequenceGroup.representative_bbox, + SequenceGroup.smoke_type, + SequenceGroup.false_positive_type, + SequenceGroup.is_unsure, + SequenceGroup.is_validated, + SequenceGroup.labeled_at, + SequenceGroup.created_at, + member_count_subq.c.member_count, + ) + # Inner-join so singletons (no row in the subquery) drop out. + .join(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) + ) + + # `unique=False` is required because the row tuple includes the JSONB + # `representative_bbox`, which is a dict and therefore not hashable. + return await apaginate(session, query, params, unique=False) + + +@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", + ) + + # First detection per sequence in the group (lowest recorded_at, then + # lowest id as a deterministic tie-breaker so ties don't duplicate the + # member row). Used for the UI thumbnail + bbox overlay. + detection_rownum = ( + select( + Detection.id.label("det_id"), + Detection.sequence_id.label("seq_id"), + Detection.algo_predictions.label("det_algo"), + func.row_number() + .over( + partition_by=Detection.sequence_id, + order_by=(Detection.recorded_at.asc(), Detection.id.asc()), + ) + .label("rn"), + ) + .join(Sequence, Sequence.id == Detection.sequence_id) + .where(Sequence.sequence_group_id == group_id) + .subquery() + ) + first_det_join = ( + select( + detection_rownum.c.seq_id, + detection_rownum.c.det_id, + detection_rownum.c.det_algo, + ) + .where(detection_rownum.c.rn == 1) + .subquery() + ) + + member_query = ( + select( + Sequence.id, + Sequence.alert_api_id, + Sequence.camera_name, + Sequence.recorded_at, + Sequence.last_seen_at, + SequenceAnnotation.processing_stage, + 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) + ) + 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], + annotation_processing_stage=(row[5].value if row[5] is not None else None), + first_detection_id=row[6], + first_detection_algo_predictions=row[7], + ) + for row in result.all() + ] + + base = SequenceGroupRead.model_validate(group, from_attributes=True) + return SequenceGroupReadWithMembers(**base.model_dump(), members=members) + + +@router.patch( + "/{group_id}", + response_model=SequenceGroupRead, + summary="Update group review state (currently: is_validated only)", +) +async def update_sequence_group( + group_id: int = Path(..., ge=1), + payload: SequenceGroupUpdate = Body(...), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> SequenceGroupRead: + group = await session.get(SequenceGroup, group_id) + if group is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Sequence group {group_id} not found", + ) + changes = payload.model_dump(exclude_unset=True) + if "is_validated" in changes: + group.is_validated = changes["is_validated"] + if changes: + group.updated_at = datetime.now(UTC) + session.add(group) + await session.commit() + await session.refresh(group) + return SequenceGroupRead.model_validate(group, from_attributes=True) + + +@router.delete( + "/{group_id}/members/{sequence_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove a sequence from a group (does not delete the sequence)", +) +async def remove_member_from_group( + group_id: int = Path(..., ge=1), + sequence_id: int = Path(..., ge=1), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> None: + seq = await session.get(Sequence, sequence_id) + if seq is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Sequence {sequence_id} not found", + ) + if seq.sequence_group_id != group_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Sequence {sequence_id} is not a member of group {group_id}" + ), + ) + seq.sequence_group_id = None + # Sticky exclusion: prevents assign_groups from silently re-attaching + # this sequence on the next import. The annotator has decided it's an + # outlier for this camera/azimuth/region. + seq.is_group_excluded = True + session.add(seq) + await session.commit() + + +@router.post( + "/members/{sequence_id}/re-include", + status_code=status.HTTP_204_NO_CONTENT, + summary=( + "Clear the manual is_group_excluded flag on a sequence so the next " + "assign-groups run can put it back into a group. Recovery path " + "after an accidental DELETE /members." + ), +) +async def reinclude_sequence_in_grouping( + sequence_id: int = Path(..., ge=1), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> None: + seq = await session.get(Sequence, sequence_id) + if seq is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Sequence {sequence_id} not found", + ) + seq.is_group_excluded = False + session.add(seq) + await session.commit() + + +# -------------------- 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 _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 + # Clamp confidence to [0, 1]: upstream xyxyn validation guarantees + # 0 ≤ coords ≤ 1, but `confidence` is unconstrained on detections, and + # downstream RepresentativeBbox validates `0.0 <= confidence <= 1.0`. + # A stray >1 (or <0) would make this group fail validation on read. + median_conf = median(confs) if confs else 0.0 + median_conf = max(0.0, min(1.0, median_conf)) + 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_conf, + } + + +@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.3. Operates on every sequence whose `sequence_group_id` + is NULL: each gets assigned to a matching group (or seeds a new one). + + Label inheritance is conditional — when the matched group already has + a label, the joining sequence gets a SequenceAnnotation in + SEQ_ANNOTATION_DONE with that label. If a placeholder annotation is + already there in stage READY_TO_ANNOTATE (the import script's default), + it is upgraded in place; any later stage is left untouched.""" + + sa_crud = SequenceAnnotationCRUD(session=session) + + unassigned_query = ( + select(Sequence) + .where( + Sequence.sequence_group_id.is_(None), + # Don't re-attach sequences an annotator removed by hand. + Sequence.is_group_excluded.is_(False), + ) + .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 = box_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. import.py creates an empty + # READY_TO_ANNOTATE annotation for every imported sequence, so we + # need to UPDATE that placeholder rather than skip on existence. + # Skip only if the existing annotation is past the placeholder + # stage (the human / review pipeline has touched it). + existing_anno = ( + await session.execute( + select(SequenceAnnotation).where( + SequenceAnnotation.sequence_id == seq.id + ) + ) + ).scalar_one_or_none() + if existing_anno is not None and existing_anno.processing_stage != ( + SequenceAnnotationProcessingStage.READY_TO_ANNOTATE + ): + 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 + ) + apply_label_to_sequences_bbox( + generated, smoke_type=smoke_enum, false_positive_type=fp_enum + ) + + if existing_anno is None: + await sa_crud.create( + SequenceAnnotationCreate( + sequence_id=seq.id, + has_missed_smoke=False, + is_unsure=best_group.is_unsure, + annotation=generated, + processing_stage=SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + ), + current_user.id, + ) + else: + await sa_crud.update( + existing_anno.id, + SequenceAnnotationUpdate( + is_unsure=best_group.is_unsure, + annotation=generated, + processing_stage=SequenceAnnotationProcessingStage.SEQ_ANNOTATION_DONE, + ), + 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/models.py b/annotation_api/src/app/models.py index 631c5e4..6edf823 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,80 @@ 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.3)`). 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, + ), + ) + # Sticky flag set when an annotator manually removed this sequence + # from a group. assign_groups must skip these so the next import + # doesn't silently re-attach a known outlier. + is_group_excluded: bool = Field(default=False) + + +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 OR false_positive_type IS 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) + # Set to True once an annotator has reviewed the group and confirmed + # membership is correct. Annotation propagation to other members only + # kicks in for validated groups. + is_validated: 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/app/schemas/sequence_annotations.py b/annotation_api/src/app/schemas/sequence_annotations.py index b8d75d2..ce2a7a1 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={ @@ -111,6 +155,15 @@ class SequenceAnnotationRead(BaseModel): default=None, description="List of users who have contributed to this sequence annotation", ) + group_propagation_warning: Optional[str] = Field( + default=None, + description=( + "Set when the annotation belongs to a validated group but the " + "fan-out to other members was skipped (most often because the " + "group already carries a different label). The annotation " + "itself was saved; the group state was left untouched." + ), + ) class SequenceAnnotationUpdate(BaseModel): 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..43d2c13 --- /dev/null +++ b/annotation_api/src/app/schemas/sequence_group.py @@ -0,0 +1,119 @@ +# 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 + +from app.models import FalsePositiveType, SmokeType + +__all__ = [ + "RepresentativeBbox", + "SequenceGroupCreate", + "SequenceGroupRead", + "SequenceGroupListItem", + "SequenceGroupMember", + "SequenceGroupReadWithMembers", + "SequenceGroupUpdate", +] + + +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 SequenceGroupMember(BaseModel): + """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 + camera_name: str + recorded_at: datetime + last_seen_at: datetime + annotation_processing_stage: Optional[str] = Field( + default=None, + description=( + "Stage of the SequenceAnnotation for this sequence, or null if " + "no annotation row exists. READY_TO_ANNOTATE is the placeholder " + "import.py creates; SEQ_ANNOTATION_DONE+ means a human has " + "submitted labels." + ), + ) + 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 + is_validated: bool + labeled_at: Optional[datetime] + created_at: datetime + member_count: int + + +class SequenceGroupUpdate(BaseModel): + """Patch the group's review state. For now, only `is_validated` is + user-mutable here — labels are written by the per-sequence annotation + flow (and propagated to the group when validated).""" + + is_validated: Optional[bool] = None + + +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 + is_validated: bool + labeled_at: Optional[datetime] + labeled_by_user_id: Optional[int] + created_at: datetime + updated_at: Optional[datetime] + + +class SequenceGroupReadWithMembers(SequenceGroupRead): + members: List[SequenceGroupMember] diff --git a/annotation_api/src/app/services/annotation_generation.py b/annotation_api/src/app/services/annotation_generation.py index 708ca3d..fc3af53 100644 --- a/annotation_api/src/app/services/annotation_generation.py +++ b/annotation_api/src/app/services/annotation_generation.py @@ -26,12 +26,13 @@ """ import logging +from collections import Counter from typing import List, Dict, Any, Optional, Tuple 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 +118,60 @@ def filter_predictions_by_confidence( ] +def derive_group_label_from_annotation( + annotation_data: SequenceAnnotationData, +) -> Optional[Tuple[Optional[SmokeType], Optional[FalsePositiveType]]]: + """Pick a single (smoke_type, false_positive_type) pair representing the + annotation, used to update the group's label and to propagate to other + members. Returns None when the annotation carries no label signal. + + - If any cluster is marked smoke, returns the most common smoke type. + - Else if any cluster has false-positive types, returns the most common. + - Else returns None. + """ + smoke_types: List[str] = [] + fp_types: List[str] = [] + for bbox in annotation_data.sequences_bbox: + if bbox.is_smoke and bbox.smoke_type is not None: + smoke_types.append( + bbox.smoke_type.value + if hasattr(bbox.smoke_type, "value") + else bbox.smoke_type + ) + for fp in bbox.false_positive_types or []: + fp_types.append(fp.value if hasattr(fp, "value") else fp) + if smoke_types: + most = Counter(smoke_types).most_common(1)[0][0] + return SmokeType(most), None + if fp_types: + most = Counter(fp_types).most_common(1)[0][0] + return None, FalsePositiveType(most) + return None + + +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 new file mode 100644 index 0000000..85887d3 --- /dev/null +++ b/annotation_api/src/migrations/versions/2026_05_10_0700-b2c3d4e5f6a7_add_sequence_groups.py @@ -0,0 +1,96 @@ +"""add sequence_groups + sequences.sequence_group_id + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +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 OR false_positive_type IS 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") diff --git a/annotation_api/src/migrations/versions/2026_05_10_1000-c3d4e5f6a7b8_add_group_is_validated.py b/annotation_api/src/migrations/versions/2026_05_10_1000-c3d4e5f6a7b8_add_group_is_validated.py new file mode 100644 index 0000000..8c693b3 --- /dev/null +++ b/annotation_api/src/migrations/versions/2026_05_10_1000-c3d4e5f6a7b8_add_group_is_validated.py @@ -0,0 +1,34 @@ +"""add sequence_groups.is_validated + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-05-10 10:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "c3d4e5f6a7b8" +down_revision: Union[str, None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "sequence_groups", + sa.Column( + "is_validated", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + op.drop_column("sequence_groups", "is_validated") diff --git a/annotation_api/src/migrations/versions/2026_05_11_1300-d4e5f6a7b8c9_add_sequence_group_excluded.py b/annotation_api/src/migrations/versions/2026_05_11_1300-d4e5f6a7b8c9_add_sequence_group_excluded.py new file mode 100644 index 0000000..1668a87 --- /dev/null +++ b/annotation_api/src/migrations/versions/2026_05_11_1300-d4e5f6a7b8c9_add_sequence_group_excluded.py @@ -0,0 +1,34 @@ +"""add sequences.is_group_excluded + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-05-11 13:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "d4e5f6a7b8c9" +down_revision: Union[str, None] = "c3d4e5f6a7b8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "sequences", + sa.Column( + "is_group_excluded", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + op.drop_column("sequences", "is_group_excluded") 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..89fdedd --- /dev/null +++ b/annotation_api/src/tests/endpoints/test_sequence_groups.py @@ -0,0 +1,363 @@ +"""Tests for the sequence-groups + bulk-annotate + propagation flow. + +Covers: +- POST /sequence_groups/assign creates a new group from an unassigned sequence +- 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 +- Propagation on per-sequence annotation save: + * unvalidated group → no fan-out + * validated, no group label yet → group label set + other unlocked + members get annotations + * validated, conflicting label → warning returned, group untouched + * validated, member locked at SEQ_ANNOTATION_DONE+ → skipped + +Cross-sequence inheritance via assign-groups inheritance is exercised +end-to-end through the make pipeline — the test fixtures only seed two +sequences with non-overlapping bboxes, so they can't share a group. +""" + +from datetime import datetime, timezone + +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() + + +async def _seed_two_member_group( + session: AsyncSession, + sequence_ids: list[int], + *, + is_validated: bool, + smoke_type: str | None = None, + false_positive_type: str | None = None, +) -> int: + """Force two existing sequences into the same SequenceGroup so the + propagation hook has a real two-member target to fan out across.""" + insert_sql = text( + """ + INSERT INTO sequence_groups + (camera_id, azimuth, representative_bbox, is_validated, + smoke_type, false_positive_type, labeled_at) + VALUES + (1, 0, CAST(:bbox AS jsonb), :is_validated, + :smoke_type, :false_positive_type, :labeled_at) + RETURNING id + """ + ).bindparams( + bbox='{"xyxyn":[0.1,0.1,0.4,0.4],"confidence":0.9}', + is_validated=is_validated, + smoke_type=smoke_type, + false_positive_type=false_positive_type, + labeled_at=( + datetime(2026, 1, 1, tzinfo=timezone.utc) + if (smoke_type or false_positive_type) + else None + ), + ) + result = await session.exec(insert_sql) + group_id = result.scalar_one() + for sid in sequence_ids: + await session.exec( + text( + "UPDATE sequences SET sequence_group_id = :gid WHERE id = :sid" + ).bindparams(gid=group_id, sid=sid) + ) + await session.commit() + return group_id + + +def _annotation_payload(*, stage: str, smoke_type: str) -> dict: + return { + "sequence_id": 1, # overwritten per call + "has_missed_smoke": False, + "is_unsure": False, + "annotation": { + "sequences_bbox": [ + { + "is_smoke": True, + "smoke_type": smoke_type, + "false_positive_types": [], + "bboxes": [{"detection_id": 1, "xyxyn": [0.1, 0.1, 0.4, 0.4]}], + } + ] + }, + "processing_stage": stage, + } + + +@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_propagation_skipped_when_group_not_validated( + authenticated_client: AsyncClient, + sequence_session: AsyncSession, + detection_session: AsyncSession, +): + """Group exists, both seqs joined, but is_validated=False → posting an + annotation on seq 1 must not write a label onto the group or create + an annotation for seq 2.""" + group_id = await _seed_two_member_group( + sequence_session, [1, 2], is_validated=False + ) + + payload = _annotation_payload(stage="seq_annotation_done", smoke_type="wildfire") + payload["sequence_id"] = 1 + resp = await authenticated_client.post("/annotations/sequences/", json=payload) + assert resp.status_code == 201 + assert resp.json().get("group_propagation_warning") is None + + group_resp = await authenticated_client.get(f"/sequence_groups/{group_id}") + assert group_resp.json()["smoke_type"] is None + + # No fan-out: seq 2 should still have no annotation row. + other_anno = await authenticated_client.get( + "/annotations/sequences/?sequence_id=2" + ) + assert other_anno.json()["total"] == 0 + + +@pytest.mark.asyncio +async def test_propagation_writes_label_and_fans_out( + authenticated_client: AsyncClient, + sequence_session: AsyncSession, + detection_session: AsyncSession, +): + """Validated group, no existing label, no conflict → group gets the + derived label and the other unlocked member gets an inherited + annotation in SEQ_ANNOTATION_DONE.""" + group_id = await _seed_two_member_group( + sequence_session, [1, 2], is_validated=True + ) + + payload = _annotation_payload(stage="seq_annotation_done", smoke_type="wildfire") + payload["sequence_id"] = 1 + resp = await authenticated_client.post("/annotations/sequences/", json=payload) + assert resp.status_code == 201 + assert resp.json().get("group_propagation_warning") is None + + group_resp = await authenticated_client.get(f"/sequence_groups/{group_id}") + group_payload = group_resp.json() + assert group_payload["smoke_type"] == "wildfire" + assert group_payload["false_positive_type"] is None + + other = await authenticated_client.get("/annotations/sequences/?sequence_id=2") + items = other.json()["items"] + assert len(items) == 1 + assert items[0]["processing_stage"] == "seq_annotation_done" + assert items[0]["smoke_types"] == ["wildfire"] + + +@pytest.mark.asyncio +async def test_propagation_warns_and_skips_on_conflict( + authenticated_client: AsyncClient, + sequence_session: AsyncSession, + detection_session: AsyncSession, +): + """Validated group already labeled `industrial` smoke → annotating + seq 1 with `wildfire` returns a warning, leaves the group label + untouched, and does NOT propagate to seq 2.""" + group_id = await _seed_two_member_group( + sequence_session, + [1, 2], + is_validated=True, + smoke_type="industrial", + ) + + payload = _annotation_payload(stage="seq_annotation_done", smoke_type="wildfire") + payload["sequence_id"] = 1 + resp = await authenticated_client.post("/annotations/sequences/", json=payload) + assert resp.status_code == 201 + body = resp.json() + assert body["group_propagation_warning"] is not None + assert "industrial" in body["group_propagation_warning"] + # The seq's own annotation still saved as wildfire. + assert body["smoke_types"] == ["wildfire"] + + # Group label is unchanged. + group_resp = await authenticated_client.get(f"/sequence_groups/{group_id}") + assert group_resp.json()["smoke_type"] == "industrial" + + # Seq 2 must not have an annotation propagated. + other = await authenticated_client.get("/annotations/sequences/?sequence_id=2") + assert other.json()["total"] == 0 + + +@pytest.mark.asyncio +async def test_propagation_skips_locked_members( + authenticated_client: AsyncClient, + sequence_session: AsyncSession, + detection_session: AsyncSession, +): + """Seq 2 already has an annotation in ANNOTATED (locked stage); when + seq 1 is saved in a validated group, propagation must not overwrite + seq 2's reviewed work.""" + await _seed_two_member_group(sequence_session, [1, 2], is_validated=True) + + locked = _annotation_payload(stage="annotated", smoke_type="industrial") + locked["sequence_id"] = 2 + resp = await authenticated_client.post("/annotations/sequences/", json=locked) + assert resp.status_code == 201 + + trigger = _annotation_payload(stage="seq_annotation_done", smoke_type="wildfire") + trigger["sequence_id"] = 1 + resp = await authenticated_client.post("/annotations/sequences/", json=trigger) + assert resp.status_code == 201 + + # Seq 2 keeps its reviewed `industrial` label. + other = await authenticated_client.get("/annotations/sequences/?sequence_id=2") + items = other.json()["items"] + assert len(items) == 1 + assert items[0]["processing_stage"] == "annotated" + assert items[0]["smoke_types"] == ["industrial"] + + +@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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ce5042a..38fe45e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,8 @@ 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 SequenceGroupsListPage from '@/pages/SequenceGroupsListPage'; import UserManagementPage from '@/pages/UserManagementPage'; import LoginPage from '@/pages/LoginPage'; import { useAuthStore } from '@/store/useAuthStore'; @@ -85,6 +87,11 @@ 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..c061f8e 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'; @@ -110,6 +111,7 @@ function SidebarContent({ currentPath }: { currentPath: string }) { // Create dynamic navigation with badge counts const navigationWithBadges: NavigationItem[] = [ { name: 'Dashboard', href: '/', icon: BarChart3 }, + { name: 'Sequence groups', href: '/sequence-groups', icon: Boxes }, { name: 'Sequences', icon: Layers, diff --git a/frontend/src/pages/AnnotationInterface.tsx b/frontend/src/pages/AnnotationInterface.tsx index 61cb844..01dbff6 100644 --- a/frontend/src/pages/AnnotationInterface.tsx +++ b/frontend/src/pages/AnnotationInterface.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { Link, useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { AlertCircle, X } from 'lucide-react'; import { apiClient } from '@/services/api'; @@ -68,6 +68,20 @@ export default function AnnotationInterface() { const [activeDetectionIndex, setActiveDetectionIndex] = useState(null); const [activeSection, setActiveSection] = useState<'detections' | 'sequence'>('detections'); const [showKeyboardModal, setShowKeyboardModal] = useState(false); + // Set when the backend reports a propagation conflict on a validated + // group. Rendered as a sticky banner the annotator must dismiss; while + // it is set we also block the auto-advance so the warning stays visible + // long enough to reconcile the group. + const [groupConflictWarning, setGroupConflictWarning] = useState<{ + message: string; + groupId: number | null; + } | null>(null); + + // Reset the banner whenever we navigate to a different sequence so a + // stale conflict from sequence A doesn't shadow sequence B's state. + useEffect(() => { + setGroupConflictWarning(null); + }, [sequenceId]); const detectionRefs = useRef<(HTMLDivElement | null)[]>([]); const sequenceReviewerRef = useRef(null); @@ -213,16 +227,32 @@ export default function AnnotationInterface() { return apiClient.updateSequenceAnnotation(annotation!.id, updatedAnnotation); }, - onSuccess: () => { - // Show success toast notification - showToastNotification('Annotation saved successfully', 'success'); - - // Refresh annotations and sequences + onSuccess: saved => { + // Refresh annotations and sequences either way. queryClient.invalidateQueries({ queryKey: QUERY_KEYS.SEQUENCE_ANNOTATIONS }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.SEQUENCES }); - // Invalidate annotation counts to update sidebar badges queryClient.invalidateQueries({ queryKey: ['annotation-counts'] }); + // Conflict path: the annotation saved, but the validated group's + // existing label disagreed and propagation was skipped. The toast + // store only holds one message and the auto-advance would either + // navigate away or overwrite any follow-up toast, so surface this + // via a sticky banner instead and stop the workflow advance so the + // annotator can act on it. + if (saved?.group_propagation_warning) { + showToastNotification('Annotation saved — group propagation skipped', 'info'); + setGroupConflictWarning({ + message: saved.group_propagation_warning, + groupId: sequence?.sequence_group_id ?? null, + }); + return; + } + + // Successful save with no propagation issue — clear any stale + // banner that might still be visible from an earlier conflict. + setGroupConflictWarning(null); + showToastNotification('Annotation saved successfully', 'success'); + // Check for next sequence in workflow setTimeout(() => { const nextSequence = getNextSequenceInWorkflow(); @@ -345,6 +375,35 @@ export default function AnnotationInterface() { {/* Content with top padding to account for fixed header */}
+ {groupConflictWarning && ( + // Sticky so the warning stays visible while the annotator + // scrolls the page; sits just below the fixed AnnotationHeader. +
+
+
+
Group propagation skipped
+
{groupConflictWarning.message}
+
+
+ {groupConflictWarning.groupId != null && ( + + Open group + + )} + +
+
+
+ )} apiClient.removeSequenceFromGroup(groupId, member.sequence_id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sequenceGroup', groupId] }); + }, + }); + + const predictions: AlgoPrediction[] = member.first_detection_algo_predictions?.predictions ?? []; + + return ( +
+ + + +
+ {image?.url ? ( + <> + {`seq setImgLoaded(true)} + /> + {imgLoaded && ( + <> + {predictions.map((p, i) => { + const [x1, y1, x2, y2] = p.xyxyn; + if (x2 <= x1 || y2 <= y1) return null; + return ( +
+ ); + })} + {(() => { + const [gx1, gy1, gx2, gy2] = groupBbox.xyxyn; + if (gx2 <= gx1 || gy2 <= gy1) return null; + return ( +
+ ); + })()} + + )} + + ) : ( + + )} +
+
+
seq #{member.sequence_id}
+
+ {new Date(member.recorded_at).toLocaleString()} + {memberIsAnnotated(member) ? ( + + ) : ( + + )} +
+
+ +
+ ); +} + +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 validateMutation = useMutation({ + mutationFn: (validated: boolean) => + apiClient.patchSequenceGroup(groupId, { is_validated: validated }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sequenceGroup', groupId] }); + }, + }); + + if (isLoading) { + return ( +
+ Loading group… +
+ ); + } + if (error || !group) { + return ( +
+ + Failed to load group {groupId} +
+ ); + } + + return ( +
+
+ +
+
+

Sequence group #{group.id}

+
+ camera {group.camera_id} · azimuth {group.azimuth}° · {group.members.length} members + {group.smoke_type && ( + + smoke / {group.smoke_type} + + )} + {group.false_positive_type && ( + + FP / {group.false_positive_type} + + )} +
+
+
+ {group.is_validated ? ( + <> + + Validated + + + + ) : ( + + )} +
+
+
+ +
+
+ + + tracked prediction (per-sequence) + + + + group reference region + + Click a thumbnail to annotate the sequence. + The X removes a sequence from this group. + {group.is_validated && ( + + Group is validated — annotating any sequence will propagate the labels to all other + unannotated members. + + )} +
+
+ + {group.members.length === 0 ? ( +
+ This group has no members. +
+ ) : ( +
+ {group.members.map(m => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/SequenceGroupsListPage.tsx b/frontend/src/pages/SequenceGroupsListPage.tsx new file mode 100644 index 0000000..0ac3d4e --- /dev/null +++ b/frontend/src/pages/SequenceGroupsListPage.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Loader2, AlertCircle, Tag, ShieldCheck } from 'lucide-react'; +import { apiClient } from '@/services/api'; + +type Filter = 'all' | 'labeled' | 'unlabeled'; + +export default function SequenceGroupsListPage() { + const [filter, setFilter] = useState('unlabeled'); + const [page, setPage] = useState(1); + const size = 50; + + const { data, isLoading, error } = useQuery({ + queryKey: ['sequenceGroupsList', filter, page, size], + queryFn: () => + apiClient.getSequenceGroups({ + labeled: filter === 'all' ? undefined : filter === 'labeled', + page, + size, + }), + placeholderData: prev => prev, + }); + + if (isLoading && !data) { + return ( +
+ Loading groups… +
+ ); + } + if (error) { + return ( +
+ + Failed to load groups +
+ ); + } + + const items = data?.items ?? []; + const totalPages = data?.pages ?? 1; + const total = data?.total ?? 0; + + return ( +
+
+

Sequence groups

+

+ {total} group{total === 1 ? '' : 's'} with 2 or more members. Click a row to review + membership; annotate one member from the per-sequence page and the labels propagate to the + rest if the group has been validated. +

+
+ +
+ {(['all', 'unlabeled', 'labeled'] as Filter[]).map(f => ( + + ))} +
+ +
+ + + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map(g => ( + + + + + + + + + + )) + )} + +
Group #CameraAzimuthMembersLabelValidatedCreated
+ {filter === 'unlabeled' + ? 'No unlabeled multi-sequence groups yet. Run ' + + 'make assign-groups after an import; single-sequence ' + + 'groups are intentionally hidden here.' + : 'No groups match this filter.'} +
+ + #{g.id} + + {g.camera_id}{g.azimuth}°{g.member_count} + {g.smoke_type ? ( + + smoke / {g.smoke_type} + + ) : g.false_positive_type ? ( + + FP / {g.false_positive_type} + + ) : ( + unlabeled + )} + + {g.is_validated ? ( + + validated + + ) : ( + no + )} + + {new Date(g.created_at).toLocaleString()} +
+
+ + {totalPages > 1 && ( +
+ + + Page {page} / {totalPages} + + +
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index de765ae..412b8c7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -22,6 +22,8 @@ import { SequenceAnnotationFilters, DetectionAnnotationFilters, ApiError, + SequenceGroup, + SequenceGroupListItem, } from '@/types/api'; import { API_ENDPOINTS } from '@/utils/constants'; @@ -219,6 +221,39 @@ 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 getSequenceGroups( + filters: { labeled?: boolean; page?: number; size?: number } = {} + ): Promise> { + const response: AxiosResponse> = await this.client.get( + API_ENDPOINTS.SEQUENCE_GROUPS, + { params: filters } + ); + return response.data; + } + + async patchSequenceGroup( + id: number, + payload: { is_validated?: boolean } + ): Promise { + const response: AxiosResponse = await this.client.patch( + `${API_ENDPOINTS.SEQUENCE_GROUPS}${id}`, + payload + ); + return response.data; + } + + async removeSequenceFromGroup(groupId: number, sequenceId: number): Promise { + await this.client.delete(`${API_ENDPOINTS.SEQUENCE_GROUPS}${groupId}/members/${sequenceId}`); + } + // Detections async getDetections( filters: { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 2a59c71..6e74cca 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -20,6 +20,9 @@ export interface Sequence { is_wildfire_alertapi: AnnotationType | null; organisation_name: string; organisation_id: number; + // Membership in a SequenceGroup; null until assign-groups runs or + // when the sequence has been excluded from grouping manually. + sequence_group_id?: number | null; detection_annotation_stats?: DetectionAnnotationStats; } @@ -52,6 +55,11 @@ export interface SequenceAnnotation { created_at: string; updated_at: string | null; contributors?: Contributor[]; + // Set when the annotation belongs to a validated SequenceGroup but + // fan-out to the rest of the group was skipped (e.g. the group already + // carries a different label). The annotation itself was saved; the + // operator must reconcile the conflict manually. + group_propagation_warning?: string | null; } export interface SequenceAnnotationData { @@ -103,6 +111,55 @@ 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; + // null when no SequenceAnnotation row exists. READY_TO_ANNOTATE is the + // placeholder import.py creates; only SEQ_ANNOTATION_DONE+ counts as + // human-submitted work in the UI. + annotation_processing_stage: string | null; + first_detection_id: number | null; + first_detection_algo_predictions: AlgoPredictions | null; +} + +export interface SequenceGroupListItem { + id: number; + camera_id: number; + azimuth: number; + representative_bbox: SequenceGroupRepresentativeBbox; + smoke_type: SmokeType | null; + false_positive_type: FalsePositiveType | null; + is_unsure: boolean; + is_validated: boolean; + labeled_at: string | null; + created_at: string; + member_count: number; +} + +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; + is_validated: boolean; + labeled_at: string | null; + labeled_by_user_id: number | null; + created_at: string; + updated_at: string | null; + members: SequenceGroupMember[]; +} + // Enums export type SmokeType = 'wildfire' | 'industrial' | 'other'; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 6bd1122..534487a 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -26,6 +26,7 @@ export const API_ENDPOINTS = { SEQUENCES: '/sequences/', SEQUENCE_ANNOTATIONS: '/annotations/sequences/', + SEQUENCE_GROUPS: '/sequence_groups/', DETECTION_ANNOTATIONS: '/annotations/detections/', DETECTIONS: '/detections/', CAMERAS: '/cameras/',