feat: setup dataset enrichment app codebase and scripts
This commit is contained in:
1 parent
b5c28cc98a
commit
d07578462e
72 files changed
+11370
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,152 @@
|
||||
"""Batch, frame and auto-annotation routes (REQ-020…034)."""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend import autolabel, dataset, library
|
||||
from backend import batches as batch_store
|
||||
from backend import review as review_store
|
||||
from backend.api.common import project_or_404, thumbnail
|
||||
|
||||
router = APIRouter(tags=["batches"])
|
||||
|
||||
|
||||
class BatchRequest(BaseModel):
|
||||
rel: str
|
||||
start_sec: float = 0.0
|
||||
end_sec: float
|
||||
fps: float = 1.0
|
||||
|
||||
|
||||
class AutolabelRequest(BaseModel):
|
||||
engine: str = "sam3"
|
||||
engines: Optional[list[str]] = None
|
||||
class_ids: Optional[list[int]] = None
|
||||
engine_classes: Optional[dict[str, list[str]]] = None
|
||||
threshold: float = autolabel.DEFAULT_THRESHOLD
|
||||
iou_threshold: float = autolabel.DEFAULT_IOU
|
||||
min_box_frac: float = 0.0
|
||||
resume: bool = False
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/batches")
|
||||
def create_batch(project_id: int, request: BatchRequest) -> dict:
|
||||
try:
|
||||
return batch_store.create(project_id, request.rel, request.start_sec,
|
||||
request.end_sec, request.fps)
|
||||
except (batch_store.BatchError, library.LibraryError) as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/batches")
|
||||
def list_batches(project_id: int) -> dict:
|
||||
project_or_404(project_id)
|
||||
return {"batches": batch_store.listing(project_id)}
|
||||
|
||||
|
||||
class BatchPatch(BaseModel):
|
||||
batch_label: Optional[str] = None
|
||||
date_label: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/api/batches/{batch_id}")
|
||||
def read_batch(batch_id: int) -> dict:
|
||||
batch = batch_store.get(batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(404, "No such batch")
|
||||
return batch
|
||||
|
||||
|
||||
@router.patch("/api/batches/{batch_id}")
|
||||
def update_batch(batch_id: int, request: BatchPatch) -> dict:
|
||||
try:
|
||||
return batch_store.update(batch_id, request.model_dump(exclude_unset=True))
|
||||
except batch_store.BatchError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.delete("/api/batches/{batch_id}")
|
||||
def delete_batch(batch_id: int) -> dict:
|
||||
if not batch_store.delete(batch_id):
|
||||
raise HTTPException(404, "No such batch")
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
|
||||
@router.get("/api/batches/{batch_id}/frames")
|
||||
def list_frames(batch_id: int) -> dict:
|
||||
if batch_store.get(batch_id) is None:
|
||||
raise HTTPException(404, "No such batch")
|
||||
return {"frames": batch_store.frames(batch_id)}
|
||||
|
||||
|
||||
@router.post("/api/batches/{batch_id}/autolabel")
|
||||
def start_autolabel(batch_id: int, request: AutolabelRequest) -> dict:
|
||||
try:
|
||||
engine_list = request.engines if (request.engines and len(request.engines) > 0) else [request.engine]
|
||||
return autolabel.start(batch_id, request.threshold, request.iou_threshold,
|
||||
request.min_box_frac, resume=request.resume,
|
||||
engines=engine_list, class_ids=request.class_ids,
|
||||
engine_classes=request.engine_classes)
|
||||
except batch_store.BatchError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
|
||||
@router.post("/api/batches/{batch_id}/approve-all")
|
||||
def approve_all_batch_frames(batch_id: int) -> dict:
|
||||
if batch_store.get(batch_id) is None:
|
||||
raise HTTPException(404, "No such batch")
|
||||
updated = batch_store.approve_all_frames(batch_id)
|
||||
return {"approved_count": updated}
|
||||
|
||||
|
||||
@router.post("/api/batches/{batch_id}/approve")
|
||||
def approve_batch(batch_id: int) -> dict:
|
||||
try:
|
||||
return dataset.approve(batch_id)
|
||||
except dataset.DatasetError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/dataset")
|
||||
def dataset_summary(project_id: int) -> dict:
|
||||
project_or_404(project_id)
|
||||
return dataset.summary(project_id)
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/dataset/download")
|
||||
def dataset_download(project_id: int):
|
||||
project = project_or_404(project_id)
|
||||
try:
|
||||
path = dataset.zip_path(project)
|
||||
except dataset.DatasetError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
return FileResponse(path, media_type="application/zip",
|
||||
filename=f"{project['slug']}-dataset.zip")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/image")
|
||||
def frame_image(frame_id: int, w: int = 0):
|
||||
path = batch_store.frame_path(frame_id)
|
||||
if path is None or not os.path.isfile(path):
|
||||
raise HTTPException(404, "No such frame")
|
||||
if w and 16 <= w <= 2048:
|
||||
return Response(content=thumbnail(path, w), media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=3600"})
|
||||
return FileResponse(path, media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
|
||||
@router.delete("/api/batches/{batch_id}/classes/{class_id}/annotations")
|
||||
def clear_batch_class_annotations(batch_id: int, class_id: int) -> dict:
|
||||
if batch_store.get(batch_id) is None:
|
||||
raise HTTPException(404, "No such batch")
|
||||
deleted = review_store.clear_batch_class_annotations(batch_id, class_id)
|
||||
return {"deleted": deleted}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Helpers shared by the route modules."""
|
||||
|
||||
import io
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend import projects as project_store
|
||||
|
||||
|
||||
def project_or_404(project_id: int) -> dict:
|
||||
project = project_store.get(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(404, "No such project")
|
||||
return project
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _thumbnail_cached(path: str, width: int, mtime: float) -> bytes:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as image:
|
||||
image = image.convert("RGB")
|
||||
if image.width > width:
|
||||
height = max(1, round(image.height * width / image.width))
|
||||
image = image.resize((width, height), Image.BILINEAR)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, "JPEG", quality=72)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def thumbnail(path: str, width: int) -> bytes:
|
||||
# mtime is part of the key so a re-extracted frame never serves a stale one.
|
||||
return _thumbnail_cached(path, width, os.path.getmtime(path))
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Job routes (REQ-070, REQ-071)."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend import jobs as job_store
|
||||
|
||||
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_jobs(project_id: Optional[int] = None, limit: int = 50) -> dict:
|
||||
return {"jobs": [job.to_dict() for job in job_store.listing(project_id, limit)]}
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
def get_job(job_id: int) -> dict:
|
||||
job = job_store.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "No such job")
|
||||
return job.to_dict()
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel")
|
||||
def cancel_job(job_id: int) -> dict:
|
||||
if not job_store.cancel(job_id):
|
||||
raise HTTPException(400, "Job is not cancellable")
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Training and model-version routes (REQ-060…065)."""
|
||||
|
||||
import os
|
||||
from typing import Optional, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend import hardware, training
|
||||
from backend.api.common import project_or_404
|
||||
|
||||
router = APIRouter(tags=["models"])
|
||||
|
||||
|
||||
class TrainRequest(BaseModel):
|
||||
epochs: int = 50
|
||||
batch: Optional[int] = None
|
||||
imgsz: Optional[int] = None
|
||||
device: Optional[Union[int, str]] = None
|
||||
batch_ids: Optional[list] = None
|
||||
|
||||
|
||||
@router.get("/api/hardware")
|
||||
def read_hardware() -> dict:
|
||||
return hardware.defaults()
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/train")
|
||||
def start_training(project_id: int, request: TrainRequest) -> dict:
|
||||
project_or_404(project_id)
|
||||
try:
|
||||
return training.start(
|
||||
project_id, request.epochs,
|
||||
{"batch": request.batch, "imgsz": request.imgsz, "device": request.device},
|
||||
batch_ids=request.batch_ids,
|
||||
)
|
||||
except training.TrainingError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/models")
|
||||
def list_models(project_id: int) -> dict:
|
||||
project = project_or_404(project_id)
|
||||
return {"models": training.listing(project_id),
|
||||
"base_model_kind": project["base_model_kind"]}
|
||||
|
||||
|
||||
@router.get("/api/models/{model_id}/weights")
|
||||
def download_weights(model_id: int):
|
||||
version = training.get_version(model_id)
|
||||
if version is None or not os.path.isfile(version["weights_path"]):
|
||||
raise HTTPException(404, "No weights for that version")
|
||||
return FileResponse(version["weights_path"], media_type="application/octet-stream",
|
||||
filename=f"v{version['version']}-best.pt")
|
||||
|
||||
|
||||
@router.post("/api/models/{model_id}/promote")
|
||||
def promote_model(model_id: int) -> dict:
|
||||
try:
|
||||
return training.promote(model_id)
|
||||
except training.TrainingError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Project, library and video-streaming routes (REQ-001…013)."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend import config, library, video
|
||||
from backend import projects as project_store
|
||||
from backend.api.common import project_or_404
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
|
||||
VIDEO_MEDIA = {".mp4": "video/mp4", ".m4v": "video/mp4", ".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime", ".webm": "video/webm", ".avi": "video/x-msvideo"}
|
||||
STREAM_CHUNK = 1024 * 512
|
||||
|
||||
|
||||
class ClassSpec(BaseModel):
|
||||
name: str
|
||||
prompt: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectRequest(BaseModel):
|
||||
name: str
|
||||
label_type: str = "bbox"
|
||||
video_root: str
|
||||
classes: List[ClassSpec] = Field(default_factory=list)
|
||||
val_every: int = 5
|
||||
|
||||
|
||||
class ProjectPatch(BaseModel):
|
||||
prompts: Optional[Dict[int, str]] = None
|
||||
val_every: Optional[int] = None
|
||||
video_root: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_projects() -> dict:
|
||||
return {"projects": project_store.listing(), "video_root_default": config.VIDEO_ROOT}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_project(request: ProjectRequest) -> dict:
|
||||
try:
|
||||
return project_store.create(
|
||||
name=request.name,
|
||||
label_type=request.label_type,
|
||||
video_root=request.video_root,
|
||||
classes=[item.model_dump() for item in request.classes],
|
||||
val_every=request.val_every,
|
||||
)
|
||||
except project_store.ProjectError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
def read_project(project_id: int) -> dict:
|
||||
return project_or_404(project_id)
|
||||
|
||||
|
||||
@router.patch("/{project_id}")
|
||||
def patch_project(project_id: int, request: ProjectPatch) -> dict:
|
||||
try:
|
||||
return project_store.update(project_id, prompts=request.prompts,
|
||||
val_every=request.val_every,
|
||||
video_root=request.video_root)
|
||||
except project_store.ProjectError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.post("/{project_id}/base-model")
|
||||
async def upload_base_model(project_id: int, file: UploadFile = File(...)) -> dict:
|
||||
if not (file.filename or "").endswith(".pt"):
|
||||
raise HTTPException(400, "Base model must be a .pt checkpoint")
|
||||
# Staged to a temp file first: reading the classes can fail, and a rejected
|
||||
# upload must not leave a broken model.pt in the project.
|
||||
with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as staged:
|
||||
shutil.copyfileobj(file.file, staged)
|
||||
staged_path = staged.name
|
||||
await file.close()
|
||||
try:
|
||||
return project_store.set_base_model(project_id, staged_path)
|
||||
except project_store.ProjectError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
finally:
|
||||
os.unlink(staged_path)
|
||||
|
||||
|
||||
@router.post("/{project_id}/secondary-model")
|
||||
async def upload_secondary_model(project_id: int, file: UploadFile = File(...)) -> dict:
|
||||
project_or_404(project_id)
|
||||
if not file.filename.endswith(".pt"):
|
||||
raise HTTPException(400, "The secondary model must be a .pt file")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as staged:
|
||||
shutil.copyfileobj(file.file, staged)
|
||||
staged_path = staged.name
|
||||
await file.close()
|
||||
try:
|
||||
return project_store.set_secondary_model(project_id, staged_path, name=file.filename)
|
||||
except project_store.ProjectError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
finally:
|
||||
os.unlink(staged_path)
|
||||
|
||||
|
||||
|
||||
@router.post("/{project_id}/classes")
|
||||
def add_class(project_id: int, request: ClassSpec) -> dict:
|
||||
try:
|
||||
return project_store.add_class(project_id, request.name, request.prompt)
|
||||
except project_store.ProjectError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.delete("/{project_id}/classes/{class_id}")
|
||||
def delete_class(project_id: int, class_id: int) -> dict:
|
||||
try:
|
||||
return project_store.delete_class(project_id, class_id)
|
||||
except project_store.ProjectError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
def delete_project(project_id: int) -> dict:
|
||||
return {"deleted": project_store.delete(project_id)}
|
||||
|
||||
|
||||
@router.get("/{project_id}/library")
|
||||
def list_library(project_id: int) -> dict:
|
||||
project = project_or_404(project_id)
|
||||
try:
|
||||
return {"video_root": project["video_root"],
|
||||
"dates": library.list_dates(project["video_root"])}
|
||||
except library.LibraryError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/{project_id}/library/{date}")
|
||||
def list_library_date(project_id: int, date: str) -> dict:
|
||||
project = project_or_404(project_id)
|
||||
try:
|
||||
return {"date": date,
|
||||
"videos": library.list_videos(project["video_root"], date, project_id)}
|
||||
except library.LibraryError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/{project_id}/video/info")
|
||||
def video_info(project_id: int, rel: str) -> dict:
|
||||
project = project_or_404(project_id)
|
||||
try:
|
||||
path = library.resolve(project["video_root"], rel)
|
||||
info = dict(video.probe(path))
|
||||
except (library.LibraryError, video.VideoError) as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
info.update({"rel": rel, "batch_label": library.batch_label(os.path.basename(path)),
|
||||
"date_label": rel.split("/", 1)[0]})
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/{project_id}/video")
|
||||
def stream_video(project_id: int, rel: str, request: Request):
|
||||
"""Serve a video with Range support so the player can seek (REQ-013)."""
|
||||
project = project_or_404(project_id)
|
||||
try:
|
||||
path = library.resolve(project["video_root"], rel)
|
||||
except library.LibraryError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
|
||||
media = VIDEO_MEDIA.get(os.path.splitext(path)[1].lower(), "application/octet-stream")
|
||||
size = os.path.getsize(path)
|
||||
header = request.headers.get("range")
|
||||
if not header or not header.startswith("bytes="):
|
||||
return FileResponse(path, media_type=media, headers={"Accept-Ranges": "bytes"})
|
||||
|
||||
start_text, _, end_text = header[6:].partition("-")
|
||||
start = int(start_text) if start_text else 0
|
||||
end = int(end_text) if end_text else size - 1
|
||||
start, end = max(0, start), min(end, size - 1)
|
||||
if start > end:
|
||||
raise HTTPException(416, "Requested range is not satisfiable")
|
||||
|
||||
def chunks():
|
||||
with open(path, "rb") as handle:
|
||||
handle.seek(start)
|
||||
remaining = end - start + 1
|
||||
while remaining > 0:
|
||||
block = handle.read(min(STREAM_CHUNK, remaining))
|
||||
if not block:
|
||||
break
|
||||
remaining -= len(block)
|
||||
yield block
|
||||
|
||||
return StreamingResponse(
|
||||
chunks(), status_code=206, media_type=media,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(end - start + 1),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""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 StatusRequest(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class AssistRequest(BaseModel):
|
||||
box: List[float]
|
||||
class_id: int = 0
|
||||
threshold: float = 0.5
|
||||
|
||||
|
||||
@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/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}/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)}
|
||||
Reference in new issue
Block a user