152 lines
4.7 KiB
Python
152 lines
4.7 KiB
Python
"""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 against the frame, as drawn on the review canvas.
|
|
box: List[float]
|
|
positive: bool = True
|
|
|
|
|
|
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)}
|