feat: add counting bench, triage, and dataset modules

This commit includes major additions and updates to the frontend and backend architectures, introducing new dataset management, live counting features, batch processing, and triage logic. Includes new UI pages, components, and API routes.
This commit is contained in:
asus committed 2026-08-14 16:28:52 +07:00
1 parent 8285400254
commit 5c7c122105
80 files changed
+20074 -1412

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
"""Live counting routes: start/stop a session and watch it as MJPEG."""
import os
import time
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional
from backend import library, live_count, training
from backend.api.common import project_or_404
router = APIRouter(tags=["live-count"])
class StartRequest(BaseModel):
# Either a raw source (RTSP URL or absolute path) or an archive-relative
# path like "2026-08-13/batch001.mp4", which the backend resolves — the
# frontend never needs to know where the archive is mounted.
source: str = ""
source_rel: Optional[str] = None
model_path: Optional[str] = None
model_version_id: Optional[int] = None
line_y: int = 266
line_x_start: int = 469
line_x_end: int = 910
conf: float = 0.35
dedup_radius: float = 60.0
margin: int = 5
imgsz: int = 640
# Ghost rejection and spatial dedup pull in opposite directions, so they are
# separate dials now (REQ-140).
entry_travel_min: float = 60.0
handoff_radius: float = 100.0
unload_confirm_frames: int = 3
min_area_scale: float = 1.0
spatial_dedup: bool = False
@router.get("/api/projects/{project_id}/live-count/models")
def available_models(project_id: int) -> dict:
"""Weights this project can count with: its trained versions, then its base."""
project = project_or_404(project_id)
out = []
for version in training.listing(project_id):
if version.get("weights_path") and os.path.isfile(version["weights_path"]):
out.append({
"label": f"v{version['version']}",
"path": version["weights_path"],
"version_id": version["id"],
})
base = project.get("base_model_path")
if base and os.path.isfile(base):
out.append({"label": "base model", "path": base, "version_id": None})
return {"models": out}
@router.post("/api/projects/{project_id}/live-count/start")
def start(project_id: int, request: StartRequest) -> dict:
project = project_or_404(project_id)
source = request.source
if request.source_rel:
try:
source = library.resolve(project["video_root"], request.source_rel)
except library.LibraryError as exc:
raise HTTPException(400, str(exc))
if not source:
raise HTTPException(400, "Pick a video or enter a stream URL")
path = request.model_path
if not path and request.model_version_id is not None:
for item in available_models(project_id)["models"]:
if item["version_id"] == request.model_version_id:
path = item["path"]
break
if not path:
raise HTTPException(400, "Pick a model to count with")
try:
return live_count.start(
source=source, model_path=path, line_y=request.line_y,
line_x_start=request.line_x_start, line_x_end=request.line_x_end,
conf=request.conf, dedup_radius=request.dedup_radius,
margin=request.margin, imgsz=request.imgsz,
entry_travel_min=request.entry_travel_min,
handoff_radius=request.handoff_radius,
unload_confirm_frames=request.unload_confirm_frames,
min_area_scale=request.min_area_scale,
spatial_dedup=request.spatial_dedup,
)
except live_count.LiveCountError as exc:
raise HTTPException(400, str(exc))
class LineRequest(BaseModel):
line_y: Optional[int] = None
line_x_start: Optional[int] = None
line_x_end: Optional[int] = None
@router.patch("/api/live-count/line")
def move_line(request: LineRequest) -> dict:
"""Reposition the counting line mid-session, without losing the counts."""
try:
return live_count.move_line(request.line_y, request.line_x_start, request.line_x_end)
except live_count.LiveCountError as exc:
raise HTTPException(400, str(exc))
@router.post("/api/live-count/stop")
def stop() -> dict:
return live_count.stop()
@router.get("/api/live-count/status")
def status() -> dict:
return live_count.status()
@router.get("/api/live-count/stream")
def stream():
"""MJPEG of the annotated frames. Ends when the session does."""
def frames():
blank_streak = 0
while True:
jpeg = live_count.snapshot()
if jpeg is None:
blank_streak += 1
if blank_streak > 100 or not live_count.status().get("running"):
return
time.sleep(0.1)
continue
blank_streak = 0
yield (b"--frame\r\nContent-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(jpeg)).encode() + b"\r\n\r\n"
+ jpeg + b"\r\n")
time.sleep(0.05)
return StreamingResponse(frames(),
media_type="multipart/x-mixed-replace; boundary=frame",
headers={"Cache-Control": "no-store"})