feat: setup dataset enrichment app codebase and scripts

This commit is contained in:
asus committed 2026-08-05 11:52:27 +07:00
1 parent b5c28cc98a
commit d07578462e
72 files changed
+11370

No files matched your search

View File
Whitespace-only changes.
+152
View File
@@ -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}
+35
View File
@@ -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))
+29
View File
@@ -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}
+63
View File
@@ -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))
+207
View File
@@ -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),
},
)
+91
View File
@@ -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)}