from fastapi import APIRouter, UploadFile, File, Form, HTTPException from pydantic import BaseModel,validator from typing import Optional, List, Literal import cv2 import numpy as np import logging import time from .utils import ( validate_form, process_image, save_image, load_json, save_json, validate_user_and_camera, extract_metadata, _bucket_key, _key_exists, ) router = APIRouter() logger = logging.getLogger(__name__) @router.post("/predict") async def predict( user_id: str = Form(...), camera_name: str = Form(...), images: list[UploadFile] = File(...) ): images = validate_form(user_id, camera_name, images) validate_user_and_camera(user_id, camera_name) json_path = _bucket_key(user_id, camera_name, f"{camera_name}_detections.json") data = load_json(json_path) new_results = [] for file in images: raw = await file.read() metadata = extract_metadata(raw) nparr = np.frombuffer(raw, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if img is None: raise HTTPException(400, f"Invalid image: {file.filename}") t0 = time.perf_counter() detections = process_image(img) logger.info(f"[{file.filename}] inference: {round((time.perf_counter() - t0) * 1000, 2)}ms") url = save_image(user_id, camera_name, file.filename, raw) record = { "filename": file.filename, "image_url": url, "detections": detections, "metadata": metadata } data.append(record) new_results.append(record) save_json(json_path, data) return { "message": "Images processed successfully", "camera": camera_name, "results": new_results } # ───────────────── # VALID LABELS # ───────────────── VALID_LABELS = { "Deer | Doe", "Deer | Buck | White Tail Bucks", "Deer | Buck | Mule Bucks", } VALID_LABELS_DISPLAY = list(VALID_LABELS) # Precompute normalized → canonical mapping (FAST lookup) NORMALIZED_LABEL_MAP = {l.strip(): l for l in VALID_LABELS} def normalize_label(label: str) -> str: return label.strip() def validate_label(label: str) -> str: norm = normalize_label(label) if norm not in NORMALIZED_LABEL_MAP: raise HTTPException( status_code=422, detail=f"Invalid label '{label}'. Must be one of: {VALID_LABELS_DISPLAY}" ) return NORMALIZED_LABEL_MAP[norm] def extract_filename(url: str) -> str: return url.split("/")[-1].split("?")[0] # ───────────────── # Request Models # ───────────────── class DetectionOperation(BaseModel): action: Literal["add", "update", "delete"] detection_index: Optional[int] = None label: Optional[str] = None bbox: Optional[List[float]] = None # [x1, y1, x2, y2] @validator("label") def validate_label_field(cls, v): if v is None: return v return validate_label(v) @validator("bbox") def validate_bbox(cls, v): if v is None: return v if len(v) != 4: raise ValueError("bbox must have exactly 4 values: [x1, y1, x2, y2]") x1, y1, x2, y2 = v if x2 <= x1 or y2 <= y1: raise ValueError("bbox must satisfy x2 > x1 and y2 > y1") return v @validator("detection_index") def validate_index(cls, v): if v is not None and v < 0: raise ValueError("detection_index must be >= 0") return v class MultiUpdateRequest(BaseModel): user_id: str camera_name: str image_url: str operations: List[DetectionOperation] @validator("operations") def validate_operations(cls, ops): if not ops: raise ValueError("operations list cannot be empty") for op in ops: if op.action == "add": if op.label is None or op.bbox is None: raise ValueError("'add' requires both label and bbox") elif op.action == "update": if op.detection_index is None: raise ValueError("'update' requires detection_index") if op.label is None and op.bbox is None: raise ValueError("'update' requires label or bbox") elif op.action == "delete": if op.detection_index is None: raise ValueError("'delete' requires detection_index") return ops # ───────────────── # Endpoint # ───────────────── @router.post("/modify_detections") async def modify_detections(req: MultiUpdateRequest): validate_user_and_camera(req.user_id, req.camera_name) json_key = _bucket_key( req.user_id, req.camera_name, f"{req.camera_name}_detections.json" ) if not _key_exists(json_key): raise HTTPException(status_code=404, detail="Detections file not found") data = load_json(json_key) target_filename = extract_filename(req.image_url) # Find record (optimized) record = next( ( item for item in data if extract_filename(item.get("image_url", item.get("filename", ""))) == target_filename ), None ) if record is None: raise HTTPException(status_code=404, detail="Image not found") dets = record.setdefault("detections", []) # ── Split operations ───────────────── delete_ops = [op for op in req.operations if op.action == "delete"] other_ops = [op for op in req.operations if op.action != "delete"] # ── DELETE (reverse order) ─────────── for op in sorted(delete_ops, key=lambda x: x.detection_index, reverse=True): idx = op.detection_index if idx is None or idx >= len(dets): raise HTTPException( status_code=400, detail=f"Invalid delete index {idx} — only {len(dets)} detection(s) exist" ) dets.pop(idx) # ── ADD + UPDATE ──────────────────── for op in other_ops: if op.action == "add": dets.append({ "label": op.label, "confidence": 1.0, "bbox": op.bbox, "manually_edited": True }) elif op.action == "update": idx = op.detection_index if idx is None or idx >= len(dets): raise HTTPException( status_code=400, detail=f"Invalid update index {idx} — only {len(dets)} detection(s) exist" ) if op.label is not None: dets[idx]["label"] = op.label if op.bbox is not None: dets[idx]["bbox"] = op.bbox dets[idx]["manually_edited"] = True save_json(json_key, data) logger.info( "Detections modified | user=%s camera=%s file=%s ops=%d final_count=%d", req.user_id, req.camera_name, target_filename, len(req.operations), len(dets) ) return { "success": True, "message": "Detections modified successfully", "filename": target_filename, "total_detections": len(dets), "detections": dets }