feat(api-routes): ✨ Introduce new video protection, recording management, and object detection endpoints in route handlers
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
707c48111a
commit
7bc33ef2c2
4 changed files with 100 additions and 1 deletions
|
|
@ -24,7 +24,7 @@ from jobs.protect_job_store import ProtectJobStore
|
|||
from pipeline.protection_processor import ProtectionProcessor
|
||||
from pipeline.video_processor import VideoProcessor
|
||||
|
||||
from .routes import health, invisible_protect, media, process, recordings
|
||||
from .routes import detect, health, invisible_protect, media, process, recordings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured logging
|
||||
|
|
@ -136,6 +136,7 @@ def create_app() -> FastAPI:
|
|||
app.include_router(invisible_protect.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(recordings.router)
|
||||
app.include_router(detect.router)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
|
|
|
|||
64
services/imajin-video/service/src/api/routes/detect.py
Normal file
64
services/imajin-video/service/src/api/routes/detect.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Single-frame face detection route.
|
||||
|
||||
POST /detect/frame — run SCRFD face detection on a single base64-encoded frame.
|
||||
Used by the frontend video player to generate live adversary overlays during playback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["detect"])
|
||||
|
||||
|
||||
class FrameDetectRequest(BaseModel):
|
||||
frame_b64: str = Field(..., description="Base64-encoded image (JPEG or PNG, any size)")
|
||||
|
||||
|
||||
class FrameDetectResponse(BaseModel):
|
||||
bboxes: list[list[float]] = Field(
|
||||
..., description="[[x1, y1, x2, y2], ...] in pixel coordinates of the input frame"
|
||||
)
|
||||
confidences: list[float]
|
||||
face_count: int
|
||||
|
||||
|
||||
@router.post("/detect/frame", response_model=FrameDetectResponse)
|
||||
async def detect_frame(
|
||||
body: FrameDetectRequest,
|
||||
request: Request,
|
||||
) -> FrameDetectResponse:
|
||||
"""Run SCRFD face detection on a single base64-encoded frame.
|
||||
|
||||
Input frame can be any resolution — the detector resizes internally.
|
||||
Returns bounding boxes in pixel coordinates of the *input* frame.
|
||||
"""
|
||||
detector = request.state.detector
|
||||
if not detector._initialized:
|
||||
raise HTTPException(status_code=503, detail="Face detector not initialised")
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(body.frame_b64)
|
||||
buf = np.frombuffer(raw, dtype=np.uint8)
|
||||
frame = cv2.imdecode(buf, cv2.IMREAD_COLOR)
|
||||
if frame is None:
|
||||
raise ValueError("cv2.imdecode returned None")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid frame_b64: {exc}") from exc
|
||||
|
||||
faces = await detector.detect(frame)
|
||||
|
||||
return FrameDetectResponse(
|
||||
bboxes=[
|
||||
[float(f.bbox[0]), float(f.bbox[1]), float(f.bbox[2]), float(f.bbox[3])]
|
||||
for f in faces
|
||||
],
|
||||
confidences=[f.confidence for f in faces],
|
||||
face_count=len(faces),
|
||||
)
|
||||
|
|
@ -8,8 +8,10 @@ GET /protections — list available protection operation slugs
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from jobs.protect_job_store import ProtectJobStore
|
||||
from models.protection_types import (
|
||||
|
|
@ -78,6 +80,23 @@ async def get_protect_job(job_id: str, request: Request) -> ProtectJobStatusResp
|
|||
)
|
||||
|
||||
|
||||
@router.get("/protect-jobs/{job_id}/output")
|
||||
async def stream_protect_job_output(job_id: str, request: Request) -> FileResponse:
|
||||
"""Stream the protected output video for browser playback (supports range requests)."""
|
||||
protect_job_store: ProtectJobStore = request.state.protect_job_store
|
||||
record = await protect_job_store.get(job_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Protect job not found: {job_id}")
|
||||
if record.status != "done":
|
||||
raise HTTPException(status_code=409, detail=f"Job not completed: status={record.status}")
|
||||
if not record.output_path:
|
||||
raise HTTPException(status_code=404, detail="No output path recorded for this job")
|
||||
output = Path(record.output_path)
|
||||
if not output.exists():
|
||||
raise HTTPException(status_code=404, detail="Output file not found on disk")
|
||||
return FileResponse(str(output), media_type="video/mp4", headers={"Accept-Ranges": "bytes"})
|
||||
|
||||
|
||||
@router.get("/protections")
|
||||
async def list_protections() -> dict:
|
||||
"""List all available protection operation slugs."""
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from pathlib import Path
|
|||
|
||||
import cv2
|
||||
from fastapi import APIRouter, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -184,6 +185,20 @@ async def list_recordings() -> list[RecordingItem]:
|
|||
return items
|
||||
|
||||
|
||||
@router.get("/recordings/{rec_id}/stream")
|
||||
async def stream_recording(rec_id: str) -> FileResponse:
|
||||
"""Stream a recording MP4 for browser playback (supports range requests)."""
|
||||
try:
|
||||
uuid.UUID(rec_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid recording ID — must be a UUID")
|
||||
|
||||
p = RECORDINGS_DIR / f"{rec_id}.mp4"
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
return FileResponse(str(p), media_type="video/mp4", headers={"Accept-Ranges": "bytes"})
|
||||
|
||||
|
||||
@router.delete("/recordings/{rec_id}", status_code=204)
|
||||
async def delete_recording(rec_id: str) -> None:
|
||||
"""Delete a recording (MP4 + thumbnail) by UUID."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue