"""Annotation and review-state routes (REQ-040…045).""" from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel from backend import batches as batch_store from backend import review as review_store router = APIRouter(tags=["review"]) class AnnotationRequest(BaseModel): class_id: int = 0 geometry: Dict[str, Any] class AnnotationPatch(BaseModel): class_id: Optional[int] = None geometry: Optional[Dict[str, Any]] = None class BulkRequest(BaseModel): annotation_ids: List[int] class BulkReclassRequest(BaseModel): annotation_ids: List[int] class_id: int class StatusRequest(BaseModel): status: str class AssistRequest(BaseModel): box: List[float] class_id: int = 0 threshold: float = 0.5 class PoolExemplar(BaseModel): # Normalized xyxy box or [x, y] point against the frame. box: Optional[List[float]] = None point: Optional[List[float]] = None positive: bool = True kind: Optional[str] = None class ExemplarLabelRequest(BaseModel): # The whole frame-local pool, newest last (REQ-173/174). exemplars: List[PoolExemplar] class_id: int = 0 # The filter panel (REQ-175). Defaults mirror `exemplar.DEFAULTS`. threshold: float = 0.5 iou_threshold: float = 0.8 min_box_frac: float = 0.002 max_detections: int = 100 # Off by default: a drag previews, only Apply writes. apply: bool = False @router.get("/api/frames/{frame_id}/annotations") def list_annotations(frame_id: int) -> dict: target = review_store.frame(frame_id) if target is None: raise HTTPException(404, "No such frame") return { "frame": { "id": target["id"], "batch_id": target["batch_id"], "idx": target["idx"], "width": target["width"], "height": target["height"], "review_status": target["review_status"], "label_type": target["label_type"], }, "annotations": review_store.listing(frame_id), } @router.post("/api/frames/{frame_id}/annotations") def add_annotation(frame_id: int, request: AnnotationRequest) -> dict: try: return review_store.add(frame_id, request.class_id, request.geometry) except review_store.ReviewError as exc: raise HTTPException(400, str(exc)) @router.patch("/api/annotations/{annotation_id}") def patch_annotation(annotation_id: int, request: AnnotationPatch) -> dict: try: return review_store.update(annotation_id, request.class_id, request.geometry) except review_store.ReviewError as exc: raise HTTPException(400, str(exc)) @router.delete("/api/annotations/{annotation_id}") def delete_annotation(annotation_id: int) -> dict: return {"deleted": review_store.delete(annotation_id)} @router.post("/api/annotations/bulk-delete") def bulk_delete(request: BulkRequest) -> dict: return {"deleted": review_store.delete_many(request.annotation_ids)} @router.post("/api/annotations/bulk-reclass") def bulk_reclass(request: BulkReclassRequest) -> dict: try: return {"updated": review_store.reclass_many(request.annotation_ids, request.class_id)} except review_store.ReviewError as exc: raise HTTPException(400, str(exc)) @router.post("/api/frames/{frame_id}/assist") def assist(frame_id: int, request: AssistRequest) -> dict: try: return review_store.assist(frame_id, request.box, request.class_id, request.threshold) except review_store.ReviewError as exc: raise HTTPException(400, str(exc)) @router.post("/api/frames/{frame_id}/exemplar-label") def exemplar_label(frame_id: int, request: ExemplarLabelRequest) -> dict: from backend import exemplar as exemplar_store try: return exemplar_store.label( frame_id, request.class_id, [item.model_dump() for item in request.exemplars], threshold=request.threshold, iou_threshold=request.iou_threshold, min_box_frac=request.min_box_frac, max_detections=request.max_detections, apply=request.apply, ) except review_store.ReviewError as exc: raise HTTPException(400, str(exc)) @router.post("/api/frames/{frame_id}/status") def set_status(frame_id: int, request: StatusRequest) -> dict: try: return review_store.set_status(frame_id, request.status) except review_store.ReviewError as exc: raise HTTPException(400, str(exc)) @router.get("/api/batches/{batch_id}/next-pending") def next_pending(batch_id: int, after_idx: int = -1) -> dict: if batch_store.get(batch_id) is None: raise HTTPException(404, "No such batch") return {"frame_id": review_store.next_pending(batch_id, after_idx)}