From ac95674c07feae3b439fedf02bb4946bfb6aa2a2 Mon Sep 17 00:00:00 2001 From: ervanfahriaw Date: Mon, 24 Aug 2026 14:57:42 +0700 Subject: [PATCH] feat: sync live-count, exemplar annotation modules, and update .gitignore --- .gitignore | 5 + backend/api/batches.py | 17 +- backend/api/live_count.py | 36 +- backend/api/review.py | 37 + backend/assets/clock_glyphs.npz | Bin 0 -> 8187 bytes backend/autolabel.py | 122 -- backend/exemplar.py | 270 +++ backend/labeling.py | 14 +- backend/live_count.py | 182 +- backend/live_render.py | 60 + backend/live_source.py | 131 ++ backend/preview.py | 151 ++ backend/sam3_engine.py | 35 + docs/GT.xlsx | Bin 0 -> 599880 bytes docs/design.md | 115 ++ docs/requirements.md | 102 ++ docs/tasks.md | 167 ++ docs/ui-spec.md | 1550 +++++++++++++++++ frontend/src/api.js | 5 + frontend/src/app.css | 68 +- frontend/src/components/AnnotationCanvas.jsx | 96 +- frontend/src/components/AutoAnnotateModal.jsx | 267 +-- frontend/src/components/ClassPromptPanel.jsx | 137 ++ frontend/src/components/ExemplarCanvas.jsx | 126 ++ .../src/components/ExemplarFilterPanel.jsx | 125 ++ frontend/src/components/LiveVideoPanel.jsx | 224 +++ .../src/components/MassAutoAnnotateModal.jsx | 2 +- frontend/src/components/PreviewShapes.jsx | 64 + frontend/src/components/ReviewSidebar.jsx | 2 + frontend/src/components/ShortcutsPanel.jsx | 10 +- frontend/src/components/liveCountFields.js | 31 + frontend/src/hooks/useExemplarPool.js | 153 ++ frontend/src/pages/LiveCountPage.jsx | 77 +- frontend/src/pages/ReviewPage.jsx | 42 + scripts/auto_pull.py | 0 scripts/restart_app.sh | 0 scripts/transcode_archive.py | 0 scripts/webhook.py | 0 start.sh | 0 39 files changed, 3979 insertions(+), 444 deletions(-) create mode 100644 backend/assets/clock_glyphs.npz create mode 100644 backend/exemplar.py create mode 100644 backend/live_render.py create mode 100644 backend/live_source.py create mode 100644 backend/preview.py create mode 100644 docs/GT.xlsx create mode 100644 docs/ui-spec.md create mode 100644 frontend/src/components/ClassPromptPanel.jsx create mode 100644 frontend/src/components/ExemplarCanvas.jsx create mode 100644 frontend/src/components/ExemplarFilterPanel.jsx create mode 100644 frontend/src/components/LiveVideoPanel.jsx create mode 100644 frontend/src/components/PreviewShapes.jsx create mode 100644 frontend/src/components/liveCountFields.js create mode 100644 frontend/src/hooks/useExemplarPool.js mode change 100755 => 100644 scripts/auto_pull.py mode change 100755 => 100644 scripts/restart_app.sh mode change 100755 => 100644 scripts/transcode_archive.py mode change 100755 => 100644 scripts/webhook.py mode change 100755 => 100644 start.sh diff --git a/.gitignore b/.gitignore index 3117ab7..d82e562 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,11 @@ weights/ *.sqlite *.sqlite3 +# Asset exceptions +!backend/assets/ +!backend/assets/** + + # Training Logs & Caches *.log *.tfevents* diff --git a/backend/api/batches.py b/backend/api/batches.py index 9dfc5cd..392ec0e 100644 --- a/backend/api/batches.py +++ b/backend/api/batches.py @@ -36,6 +36,11 @@ class AutolabelRequest(BaseModel): append: bool = False custom_model_path: Optional[str] = None +class Exemplar(BaseModel): + box: list[float] # [cx, cy, w, h], normalized 0..1 + positive: bool = True + + class PreviewRequest(BaseModel): frame_id: int engine: str @@ -44,6 +49,10 @@ class PreviewRequest(BaseModel): min_box_frac: float = 0.0 target_class_names: Optional[list[str]] = None custom_model_path: Optional[str] = None + # Drawn box exemplars (REQ-172): normalized cxcywh, positive or negative. + # Preview-only — `autolabel.start` deliberately has no equivalent. + exemplars: Optional[list[Exemplar]] = None + exemplar_class_name: Optional[str] = None @router.post("/api/projects/{project_id}/batches") @@ -162,14 +171,14 @@ async def autolabel_with_model( @router.post("/api/batches/{batch_id}/preview") def preview_autolabel(batch_id: int, request: PreviewRequest) -> dict: - from backend import autolabel, jobs + from backend import jobs, preview if not jobs.gpu_lock.acquire(timeout=20): busy = jobs.running_types() kind = busy[0] if busy else "background" raise HTTPException(409, f"The GPU is busy with a {kind} job — wait for it to finish") try: - shapes = autolabel.preview_frame( + shapes = preview.preview_frame( batch_id=batch_id, frame_id=request.frame_id, engine=request.engine, @@ -177,7 +186,9 @@ def preview_autolabel(batch_id: int, request: PreviewRequest) -> dict: iou_threshold=request.iou_threshold, min_box_frac=request.min_box_frac, target_class_names=request.target_class_names, - custom_model_path=request.custom_model_path + custom_model_path=request.custom_model_path, + exemplars=[e.model_dump() for e in request.exemplars or []], + exemplar_class_name=request.exemplar_class_name, ) return {"shapes": shapes} except Exception as exc: diff --git a/backend/api/live_count.py b/backend/api/live_count.py index 243ef20..e8656d3 100644 --- a/backend/api/live_count.py +++ b/backend/api/live_count.py @@ -15,9 +15,9 @@ 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. + # Either a live stream — which must be a WebRTC (WHEP) URL, REQ-176 — or an + # archive-relative path like "2026-08-13/batch001.mp4", which the backend + # resolves so the frontend never needs to know where the archive is mounted. source: str = "" source_rel: Optional[str] = None model_path: Optional[str] = None @@ -60,14 +60,22 @@ def available_models(project_id: int) -> dict: def start(project_id: int, request: StartRequest) -> dict: project = project_or_404(project_id) - source = request.source + source, whep = 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)) + elif source: + # A live source is a WebRTC URL and nothing else. The browser watches it + # over WebRTC; the counter decodes the RTSP leg of the same MediaMTX + # path, derived here (REQ-176). + try: + whep, source = live_count.whep_url(source), live_count.whep_to_rtsp(source) + except live_count.LiveCountError as exc: + raise HTTPException(400, str(exc)) if not source: - raise HTTPException(400, "Pick a video or enter a stream URL") + raise HTTPException(400, "Pick a video or enter a WebRTC stream URL") path = request.model_path if not path and request.model_version_id is not None: @@ -88,6 +96,7 @@ def start(project_id: int, request: StartRequest) -> dict: unload_confirm_frames=request.unload_confirm_frames, min_area_scale=request.min_area_scale, spatial_dedup=request.spatial_dedup, + whep=whep, ) except live_count.LiveCountError as exc: raise HTTPException(400, str(exc)) @@ -118,9 +127,24 @@ def status() -> dict: return live_count.status() +@router.get("/api/live-count/overlay") +def overlay() -> dict: + """Geometry only — boxes, line and counts for the frame just processed. + + What the WebRTC preview draws over the video, instead of the server + encoding a JPEG per frame for it (REQ-177). + """ + return live_count.overlay() + + @router.get("/api/live-count/stream") def stream(): - """MJPEG of the annotated frames. Ends when the session does.""" + """MJPEG of the annotated frames — the archive-file preview. Ends with the session.""" + if live_count.status().get("preview") == "webrtc": + # Nothing is encoding JPEGs for this session; without this the generator + # would sit on a worker thread for ten seconds producing nothing. + raise HTTPException(409, "This session is watched over WebRTC, not MJPEG") + def frames(): blank_streak = 0 while True: diff --git a/backend/api/review.py b/backend/api/review.py index 007901c..0969dec 100644 --- a/backend/api/review.py +++ b/backend/api/review.py @@ -40,6 +40,25 @@ class AssistRequest(BaseModel): threshold: float = 0.5 +class PoolExemplar(BaseModel): + # Normalized xyxy against the frame, as drawn on the review canvas. + box: List[float] + positive: bool = True + + +class ExemplarLabelRequest(BaseModel): + # The whole frame-local pool, newest last (REQ-173/174). + exemplars: List[PoolExemplar] + class_id: int = 0 + # The filter panel (REQ-175). Defaults mirror `exemplar.DEFAULTS`. + threshold: float = 0.5 + iou_threshold: float = 0.8 + min_box_frac: float = 0.002 + max_detections: int = 100 + # Off by default: a drag previews, only Apply writes. + apply: bool = False + + @router.get("/api/frames/{frame_id}/annotations") def list_annotations(frame_id: int) -> dict: target = review_store.frame(frame_id) @@ -99,6 +118,24 @@ def assist(frame_id: int, request: AssistRequest) -> dict: raise HTTPException(400, str(exc)) +@router.post("/api/frames/{frame_id}/exemplar-label") +def exemplar_label(frame_id: int, request: ExemplarLabelRequest) -> dict: + from backend import exemplar as exemplar_store + + try: + return exemplar_store.label( + frame_id, request.class_id, + [item.model_dump() for item in request.exemplars], + threshold=request.threshold, + iou_threshold=request.iou_threshold, + min_box_frac=request.min_box_frac, + max_detections=request.max_detections, + apply=request.apply, + ) + 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: diff --git a/backend/assets/clock_glyphs.npz b/backend/assets/clock_glyphs.npz new file mode 100644 index 0000000000000000000000000000000000000000..d11a1ff30c67a0d1988912b3f79be18c62bb0aec GIT binary patch literal 8187 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 literal 0 HcmV?d00001 diff --git a/backend/autolabel.py b/backend/autolabel.py index 2657481..0324e8a 100644 --- a/backend/autolabel.py +++ b/backend/autolabel.py @@ -259,125 +259,3 @@ def _reset_reviewed(batch_id: int) -> None: "AND review_status = 'approved'", (batch_id,), ) - - -def preview_frame( - batch_id: int, - frame_id: int, - engine: str, - threshold: float = DEFAULT_THRESHOLD, - iou_threshold: float = DEFAULT_IOU, - min_box_frac: float = 0.0, - target_class_names: Optional[List[str]] = None, - custom_model_path: Optional[str] = None -) -> List[dict]: - batch = batches.get(batch_id) - if not batch: - raise ValueError("No such batch") - project = projects.get(batch["project_id"]) - - frame = next((f for f in batches.frames(batch_id) if f["id"] == frame_id), None) - if not frame: - raise ValueError("Frame not found") - - directory = batches.frames_dir(batch["project_slug"], batch_id) - frame_file = os.path.join(directory, frame["filename"]) - fw = max(1, frame.get("width") or 1) - fh = max(1, frame.get("height") or 1) - - yolo_model = None - sam3_target_classes = [] - - if engine == "sam3" and not custom_model_path: - allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None - if allowed_classes_set: - sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set] - else: - sam3_target_classes = [c for c in project["classes"]] - - prompts = [c["prompt"] for c in sam3_target_classes] - if prompts: - from backend.sam3_engine import get_engine - get_engine() - else: - from ultralytics import YOLO - if custom_model_path and os.path.isfile(custom_model_path): - m_path = custom_model_path - else: - m_path = projects.training_start_point(project) - with db.cursor() as cur: - cur.execute("SELECT weights_path FROM model_versions WHERE project_id = ? ORDER BY version DESC LIMIT 1", (project["id"],)) - row = cur.fetchone() - if row and os.path.isfile(row[0]): - m_path = row[0] - yolo_model = YOLO(m_path) - - name_to_class_id = {item["name"].strip().lower(): item["class_id"] for item in project["classes"]} - allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None - - all_raw_detections = [] - - if yolo_model is not None: - results = yolo_model.predict(frame_file, conf=threshold, verbose=False) - if results and len(results) > 0: - model_names = results[0].names - for box in results[0].boxes: - cls_idx = int(box.cls[0].item()) - raw_cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower() - - target_class_id = name_to_class_id.get(raw_cls_name) - if target_class_id is None: - for item in project["classes"]: - if item["class_id"] == cls_idx: - target_class_id = item["class_id"] - break - if target_class_id is None and 0 <= cls_idx < len(project["classes"]): - target_class_id = project["classes"][cls_idx]["class_id"] - - if target_class_id is None: - continue - - target_cls_obj = next((c for c in project["classes"] if c["class_id"] == target_class_id), None) - proj_cls_name = target_cls_obj["name"].strip().lower() if target_cls_obj else "" - - if allowed_classes_set is not None: - if (raw_cls_name not in allowed_classes_set and - proj_cls_name not in allowed_classes_set and - str(target_class_id) not in allowed_classes_set): - continue - - score = float(box.conf[0].item()) - xyxyn = box.xyxyn[0].tolist() - all_raw_detections.append(labeling.Detection( - class_id=target_class_id, - class_name=proj_cls_name or raw_cls_name, - box=[xyxyn[0]*fw, xyxyn[1]*fh, xyxyn[2]*fw, xyxyn[3]*fh], - score=score, - mask=None - )) - - if engine == "sam3" and sam3_target_classes: - prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes] - res = labeling.label_image( - frame_file, frame["filename"], prompts, threshold, - iou_threshold=iou_threshold, min_box_frac=min_box_frac - ) - if not res.error and res.detections: - for det in res.detections: - if 0 <= det.class_id < len(sam3_target_classes): - real_cls = sam3_target_classes[det.class_id] - det.class_id = real_cls["class_id"] - det.class_name = real_cls["name"] - all_raw_detections.append(det) - - kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold) - items = [] - for det in kept: - if project["label_type"] == "bbox" or det.mask is None: - geom = review.bbox(det.box[0]/fw, det.box[1]/fh, det.box[2]/fw, det.box[3]/fh) - items.append({"class_id": det.class_id, "geometry": geom, "score": det.score}) - else: - for geometry in _geometries(det, fw, fh, project["label_type"]): - items.append({"class_id": det.class_id, "geometry": geometry, "score": det.score}) - - return items diff --git a/backend/exemplar.py b/backend/exemplar.py new file mode 100644 index 0000000..eeae72b --- /dev/null +++ b/backend/exemplar.py @@ -0,0 +1,270 @@ +"""Exemplar-driven manual labeling in the review editor (REQ-173/174/175). + +A drag on the review canvas is not just a rectangle: it is a visual prompt. +The drawn box joins a frame-local pool, the pool is replayed against SAM3 +together with the class's text prompt, and the whole class is re-detected on +that frame from the result. + +The pool lives in the editor, not in the database, and is sent whole on every +call. That keeps this module stateless and matches REQ-172's reasoning: SAM3's +geometric prompts pool features from *this* image, so a pool only means +anything for as long as the user is looking at the frame it was drawn on. + +Split out of `review.py` because that file is already at the 400-line limit. +""" + +import json +import time +from typing import List, Optional + +from backend import db, projects, review + +# A detection this close to a box the user drew is the same object: the user's +# own shape wins, so the detection is dropped rather than stacked on top of it. +DUPLICATE_IOU = 0.6 +# A detection overlapping a negative box by this much is what the user pointed +# at when they said "not this" (REQ-174). Lower than DUPLICATE_IOU because a +# negative is drawn roughly, around something the user wants gone. +NEGATIVE_IOU = 0.3 +# Below this, no detection is really "inside" a drawn box, so a polygon project +# keeps the rectangle rather than snapping to an unrelated mask. +SNAP_IOU = 0.1 + +# What the filter panel opens with (REQ-175). Measured on a dense `sack` frame: +# NMS at 0.8 only removes near-duplicates and a 0.002 area floor only removes +# specks, where the aggressive-looking values delete real, touching objects. +DEFAULTS = { + "threshold": 0.5, + "iou_threshold": 0.8, + "min_box_frac": 0.002, + "max_detections": 100, +} + + +def _class_prompt(project_id: int, class_id: int) -> str: + project = projects.get(project_id) + for item in project["classes"]: + if item["class_id"] == class_id: + return (item.get("prompt") or item["name"]).strip() + raise review.ReviewError(f"Class {class_id} does not exist in this project") + + +def _rect(points: List[float], label_type: str) -> dict: + """The drawn rectangle as a storable shape for this project.""" + x0, y0, x1, y1 = points + if label_type == "bbox": + return review.bbox(x0, y0, x1, y1) + return review.polygon([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) + + +def _cxcywh(points: List[float]) -> List[float]: + x0, y0, x1, y1 = points + return [(x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0] + + +def _replace_class(frame_id: int, class_id: int, items: List[dict]) -> None: + """Swap every shape of one class on one frame for a fresh set. + + "Replace everything, re-add drawn": the user's exemplar shapes are part of + `items`, so they come back verbatim in the same transaction. + """ + now = time.time() + with db.cursor() as cur: + cur.execute("DELETE FROM annotations WHERE frame_id = ? AND class_id = ?", + (frame_id, class_id)) + cur.executemany( + """INSERT INTO annotations (frame_id, class_id, geometry, score, source, created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + [(frame_id, class_id, json.dumps(item["geometry"]), item.get("score", 1.0), + item.get("source", "auto"), now) for item in items], + ) + + +def _append_drawn(frame_id: int, class_id: int, drawn: List[dict]) -> int: + """Add drawn shapes the frame does not already carry, leaving the rest alone. + + The pool is re-sent whole on every call, so most of it is usually already + stored; only what is genuinely new gets inserted. + """ + from backend.labeling import _iou + + existing = [review.to_box(row["geometry"]) for row in review.listing(frame_id) + if row["class_id"] == class_id] + fresh = [item for item in drawn + if not any(_iou(review.to_box(item["geometry"]), box) >= 0.9 + for box in existing)] + for item in fresh: + review.add(frame_id, class_id, item["geometry"], source="manual") + return len(fresh) + + +def _drop_negative_overlaps(frame_id: int, class_id: int, + negatives: List[List[float]]) -> int: + """Delete shapes of this class the user shift-dragged over (REQ-174). + + Used on the path where SAM3 never runs; the re-detect path filters the + detections instead, which has the same effect on what ends up stored. + """ + from backend.labeling import _iou + + doomed = [row["id"] for row in review.listing(frame_id) + if row["class_id"] == class_id + and any(_iou(review.to_box(row["geometry"]), box) >= NEGATIVE_IOU + for box in negatives)] + return review.delete_many(doomed) + + +def label(frame_id: int, class_id: int, exemplars: List[dict], + threshold: float = 0.5, iou_threshold: float = 0.8, + min_box_frac: float = 0.002, max_detections: int = 100, + apply: bool = False) -> dict: + """Detect one class on one frame from the frame's exemplar pool. + + `exemplars` is the whole pool, newest last, each + `{"box": [x0, y0, x1, y1], "positive": bool}` normalized to the frame. + Positive boxes are both prompts and labels; negative boxes are prompts and + deletions, never labels. + + Nothing is written unless `apply` is set (REQ-175): a drag previews, the + filter panel re-previews, and only Apply touches the frame. Apply re-runs + rather than trusting shapes sent back from the browser — SAM3 is + deterministic for a given pool and threshold, so the second pass reproduces + what was previewed. + """ + from backend import batches, jobs + from backend.labeling import _iou, deduplicate + from PIL import Image + + target = review.frame(frame_id) + if target is None: + raise review.ReviewError("No such frame") + label_type = target["label_type"] + prompt = _class_prompt(target["project_id"], class_id) + + positives, negatives = [], [] + for item in exemplars: + box = review.validate({"type": "bbox", "points": item["box"]}, "bbox")["points"] + (positives if item.get("positive", True) else negatives).append(box) + if not positives and not negatives: + raise review.ReviewError("No exemplars to run") + + # The GPU lock is shared with background jobs. Shorter than `review.assist`'s + # 20s on purpose: this fires from a mouse gesture, so a long stall would feel + # like a hung editor — and the fallback keeps the drawing rather than failing. + if not jobs.gpu_lock.acquire(timeout=5): + busy = jobs.running_types() + kind = busy[0] if busy else "background" + drawn = [{"geometry": _rect(box, label_type), "score": 1.0, "source": "manual"} + for box in positives] + if apply: + # Never the replace path here: with no detections to put back, it + # would wipe the class and leave only the drawings. Applying a + # detection-less run just files the drawings and honours the + # negatives. + _append_drawn(frame_id, class_id, drawn) + _drop_negative_overlaps(frame_id, class_id, negatives) + return _result(frame_id, drawn, apply, redetected=False, + message=f"The GPU is busy with a {kind} job — this is your drawing " + "only, nothing was detected") + + try: + from backend.sam3_engine import get_engine + + path = batches.frame_path(frame_id) + with Image.open(path) as handle: + image = handle.convert("RGB") + width, height = image.size + engine = get_engine() + state = engine.open_state(image) + found = engine.apply_prompts( + state, threshold=threshold, text=prompt, + exemplars=[{"box": _cxcywh(box), "positive": True} for box in positives] + + [{"box": _cxcywh(box), "positive": False} for box in negatives], + ) + finally: + jobs.gpu_lock.release() + + # The panel's filters, in the order the batch job applies them (REQ-175): + # area floor, then NMS, then the cap on how many survive. + if min_box_frac > 0: + floor = width * height * min_box_frac + found = [d for d in found + if (d.box[2] - d.box[0]) * (d.box[3] - d.box[1]) >= floor] + found = deduplicate(found, iou_threshold) + found.sort(key=lambda d: d.score, reverse=True) + if max_detections > 0: + found = found[:max_detections] + + detections = [(_norm_box(d.box, width, height), d) for d in found] + items: List[dict] = [] + + # The user's own boxes first, so the duplicate check below measures against + # what they drew rather than the other way round. + for box in positives: + geometry = _rect(box, label_type) + if label_type != "bbox": + snapped = _snap(box, detections, width, height) + if snapped is not None: + geometry = snapped + items.append({"geometry": geometry, "score": 1.0, "source": "manual"}) + + for norm, detection in detections: + if any(_iou(norm, box) >= NEGATIVE_IOU for box in negatives): + continue + if any(_iou(norm, box) >= DUPLICATE_IOU for box in positives): + continue + for geometry in _detection_shapes(detection, norm, width, height, label_type): + items.append({"geometry": geometry, "score": detection.score, "source": "auto"}) + + if apply: + _replace_class(frame_id, class_id, items) + return _result(frame_id, items, apply, redetected=True, message=None) + + +def _result(frame_id: int, items: List[dict], applied: bool, + redetected: bool, message: Optional[str]) -> dict: + """A preview carries the shapes; an apply also carries the frame as stored.""" + return { + "shapes": items, + "applied": applied, + "redetected": redetected, + "message": message, + "annotations": review.listing(frame_id) if applied else None, + } + + +def _norm_box(box: List[float], width: int, height: int) -> List[float]: + return [box[0] / width, box[1] / height, box[2] / width, box[3] / height] + + +def _snap(drawn: List[float], detections, width: int, height: int) -> Optional[dict]: + """The mask polygon of whatever SAM3 found inside a drawn box. + + A rectangle is a bad polygon label, so in a polygon project the drag is a + prompt for the shape rather than the shape itself (REQ-173). + """ + from backend.labeling import _iou + + best = None + best_iou = SNAP_IOU + for norm, detection in detections: + if detection.mask is None: + continue + overlap = _iou(norm, drawn) + if overlap >= best_iou: + best, best_iou = detection, overlap + if best is None: + return None + points = review.mask_to_polygons(best.mask) + if not points or len(points[0]) < 3: + return None + return review.polygon([(x / width, y / height) for x, y in points[0]]) + + +def _detection_shapes(detection, norm: List[float], width: int, height: int, + label_type: str) -> List[dict]: + if label_type == "bbox" or detection.mask is None: + return [review.bbox(*norm)] + return [review.polygon([(x / width, y / height) for x, y in points]) + for points in review.mask_to_polygons(detection.mask) + if len(points) >= 3] diff --git a/backend/labeling.py b/backend/labeling.py index f0ff939..04f7563 100644 --- a/backend/labeling.py +++ b/backend/labeling.py @@ -68,8 +68,13 @@ def label_image( threshold: float, iou_threshold: float = 0.8, min_box_frac: float = 0.0, + exemplar_index: int = -1, + exemplars: Optional[List[dict]] = None, ) -> ImageResult: - """Detect every prompt in one image and return the surviving instances.""" + """Detect every prompt in one image and return the surviving instances. + + When `exemplars` are given, the prompt at `exemplar_index` also carries them + as drawn box exemplars (REQ-172); every other prompt runs on text alone.""" try: image = Image.open(image_path).convert("RGB") except Exception as exc: # unreadable/corrupt frame: report, don't abort the job @@ -77,7 +82,12 @@ def label_image( width, height = image.size try: - detections = get_engine().detect(image, prompts, threshold) + if exemplars and 0 <= exemplar_index < len(prompts): + detections = get_engine().detect_with_exemplars( + image, prompts, threshold, exemplar_index, exemplars + ) + else: + detections = get_engine().detect(image, prompts, threshold) except Exception as exc: return ImageResult(image_path, rel_path, width, height, error=str(exc)) diff --git a/backend/live_count.py b/backend/live_count.py index 17cf188..d73682d 100644 --- a/backend/live_count.py +++ b/backend/live_count.py @@ -1,4 +1,4 @@ -"""Live counting test bench: point a trained model at an RTSP stream and watch it count. +"""Live counting test bench: point a trained model at a live stream and watch it count. This is a **test harness**, not the production counter. It reuses the real pipeline pieces from `algoritma-batch` — ByteTrack, the bbox stabiliser and the @@ -22,6 +22,10 @@ import cv2 import numpy as np from backend import config, jobs +from backend import live_render +from backend.live_source import ( # re-exported: callers catch live_count.LiveCountError + LiveCountError, _is_stream, _open, whep_to_rtsp, whep_url, +) # `algoritma-batch/src` is copied to /app/src in the image; in a source checkout # it still lives under algoritma-batch/. Both are made importable as `src.*`. @@ -31,10 +35,6 @@ for candidate in ("/app", os.path.join(_REPO, "algoritma-batch")): sys.path.insert(0, candidate) -class LiveCountError(Exception): - pass - - class Session: """One running counter. Owns a capture thread and the latest rendered frame.""" @@ -43,8 +43,11 @@ class Session: dedup_radius: float, margin: int, imgsz: int, entry_travel_min: float, handoff_radius: float, unload_confirm_frames: int, min_area_scale: float, - spatial_dedup: bool): + spatial_dedup: bool, whep_url: str = ""): self.source = source + # When the browser watches the camera over WebRTC it never asks for the + # MJPEG, so encoding a JPEG per frame would be pure waste (REQ-177). + self.whep_url = whep_url self.model_path = model_path self.line_y = line_y self.line_x_start = line_x_start @@ -75,6 +78,7 @@ class Session: self.events: list[dict] = [] self._jpeg: Optional[bytes] = None + self._overlay: dict = {} self._counter = None # set once the worker builds it self._lock = threading.Lock() self._thread = threading.Thread(target=self._run, name="live-count", daemon=True) @@ -92,6 +96,10 @@ class Session: with self._lock: return self._jpeg + def overlay(self) -> dict: + with self._lock: + return dict(self._overlay) + def move_line(self, line_y: Optional[int] = None, line_x_start: Optional[int] = None, line_x_end: Optional[int] = None) -> dict: """Reposition the counting line without restarting. @@ -119,6 +127,8 @@ class Session: return { "running": self._thread.is_alive(), "source": self.source, + "whep_url": self.whep_url, + "preview": "webrtc" if self.whep_url else "mjpeg", "model_path": self.model_path, "error": self.error, "frames": self.frames, @@ -227,7 +237,11 @@ class Session: self.fps = since / (now - tick) tick, since = now, 0 - self._render(frame, inside, outside, counter) + self._set_overlay(inside, outside, counter) + if not self.whep_url: + jpeg = live_render.render(self, frame, inside, outside, counter) + with self._lock: + self._jpeg = jpeg except Exception as exc: # surfaced in status(), not swallowed self.error = f"{type(exc).__name__}: {exc}" finally: @@ -263,59 +277,33 @@ class Session: if not self.error: self.error = f"trace write failed: {exc}" - def _render(self, frame, detections, ignored, counter) -> None: - height, width = frame.shape[:2] - - # Shade what the region excludes. Without this the neighbouring truck's - # sacks simply vanish from the overlay, and "are they being ignored?" - # looks identical to "is the model missing them?". - if self.line_x_start > 0 or self.line_x_end < width: - shade = frame.copy() - if self.line_x_start > 0: - cv2.rectangle(shade, (0, 0), (self.line_x_start, height), (0, 0, 0), -1) - if self.line_x_end < width: - cv2.rectangle(shade, (self.line_x_end, 0), (width, height), (0, 0, 0), -1) - cv2.addWeighted(shade, 0.55, frame, 0.45, 0, frame) - - # Ignored detections stay visible, in grey, so the region can be judged. - for det in ignored: - x1, y1, x2, y2 = (int(v) for v in det.bbox) - cv2.rectangle(frame, (x1, y1), (x2, y2), (130, 130, 130), 1) - - for edge in (self.line_x_start, self.line_x_end): - if 0 < edge < width: - cv2.line(frame, (edge, 0), (edge, height), (255, 0, 255), 2) - cv2.putText(frame, "IGNORED", (max(4, self.line_x_start - 92), height - 14), - cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA) - cv2.putText(frame, "IGNORED", (min(width - 88, self.line_x_end + 8), height - 14), - cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA) + def _set_overlay(self, detections, ignored, counter) -> None: + """The same drawing `_render` burns into the JPEG, as geometry. + The browser draws it on a canvas over the WebRTC video, so the frames + themselves never pass through this process. Coordinates are in the + 1280x720 space the pipeline works in; the canvas scales them. + """ + boxes = [] for det in detections: - x1, y1, x2, y2 = (int(v) for v in det.bbox) - counted = bool(counter.counted_tracks.get(det.track_id)) - colour = (74, 222, 128) if counted else (248, 191, 113) - cv2.rectangle(frame, (x1, y1), (x2, y2), colour, 2) - cv2.putText(frame, f"#{det.track_id} {det.confidence:.2f}", (x1, max(14, y1 - 6)), - cv2.FONT_HERSHEY_SIMPLEX, 0.45, colour, 1, cv2.LINE_AA) - - cv2.line(frame, (self.line_x_start, self.line_y), (self.line_x_end, self.line_y), - (0, 255, 255), 2) - for edge in (self.line_y - self.margin, self.line_y + self.margin): - cv2.line(frame, (self.line_x_start, edge), (self.line_x_end, edge), - (0, 160, 160), 1) - - panel = f"IN {self.loading} OUT {self.unloading} NET {self.loading - self.unloading}" - cv2.rectangle(frame, (12, 12), (12 + 9 * len(panel) + 20, 84), (0, 0, 0), -1) - cv2.putText(frame, panel, (24, 46), cv2.FONT_HERSHEY_SIMPLEX, 0.8, - (74, 222, 128), 2, cv2.LINE_AA) - cv2.putText(frame, f"{self.fps:.1f} fps {self.tracked} tracked {self.ignored} ignored", - (24, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (200, 200, 200), 1, cv2.LINE_AA) - - ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 75]) - if ok: - with self._lock: - self._jpeg = buffer.tobytes() - + state = "counted" if counter.counted_tracks.get(det.track_id) else "tracked" + boxes.append({"id": det.track_id, "b": [int(v) for v in det.bbox], + "c": round(float(det.confidence), 2), "s": state}) + for det in ignored: + boxes.append({"id": det.track_id, "b": [int(v) for v in det.bbox], + "c": round(float(det.confidence), 2), "s": "ignored"}) + payload = { + "frame": self.frames, + "line": {"y": self.line_y, "x_start": self.line_x_start, + "x_end": self.line_x_end, "margin": self.margin}, + "boxes": boxes, + "loading": self.loading, + "unloading": self.unloading, + "net": self.loading - self.unloading, + "fps": round(self.fps, 1), + } + with self._lock: + self._overlay = payload def _too_small(bbox, scale: float) -> bool: """`predict.py`'s perspective curve: a sack at the top of the frame is @@ -339,74 +327,6 @@ def _too_small(bbox, scale: float) -> bool: return (x2 - x1) * (y2 - y1) < minimum * scale -def _is_stream(source: str) -> bool: - return str(source).startswith(("rtsp://", "rtmp://", "http://", "https://")) - - -class _ThreadedStream: - """Decode in a background thread and always hand out the newest frame. - - A plain VideoCapture.read() on RTSP is blocking, and decoding 1080p costs - more than inference does — measured here at 6.7 fps end-to-end against 200 - fps for the model itself. Worse, reading slower than the camera sends builds - a backlog, so the picture drifts further behind real time the longer it - runs. Dropping stale frames keeps latency flat, which is what a counting - test needs to mean anything. `predict.py` does the same thing. - """ - - def __init__(self, source: str): - self._capture = cv2.VideoCapture(source) - try: - self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1) - except Exception: - pass - self._frame = None - self._lock = threading.Lock() - self._running = True - self._thread = threading.Thread(target=self._pump, daemon=True) - if self._capture.isOpened(): - self._thread.start() - - def _pump(self) -> None: - while self._running: - ok, frame = self._capture.read() - if not ok: - time.sleep(0.01) - continue - with self._lock: - self._frame = frame - - def isOpened(self) -> bool: - return self._capture.isOpened() - - def read(self): - with self._lock: - if self._frame is None: - return False, None - frame, self._frame = self._frame, None - return True, frame - - def release(self) -> None: - self._running = False - # The thread is only started when the capture opened, so a failed - # source would otherwise raise "cannot join thread before it is - # started" here — inside the caller's finally, skipping the GPU lock - # release and wedging every later session on "GPU busy". - if self._thread.is_alive(): - self._thread.join(timeout=2) - self._capture.release() - - -def _open(source: str): - if _is_stream(source): - os.environ.setdefault( - "OPENCV_FFMPEG_CAPTURE_OPTIONS", - "rtsp_transport;tcp|buffer_size;20480000|max_delay;500000", - ) - return _ThreadedStream(source) - return cv2.VideoCapture(source) - - # ---- module-level single session ---------------------------------------- _session: Optional[Session] = None @@ -417,7 +337,8 @@ def start(source: str, model_path: str, line_y: int, line_x_start: int, line_x_e conf: float = 0.35, dedup_radius: float = 60.0, margin: int = 5, imgsz: int = 640, 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) -> dict: + min_area_scale: float = 1.0, spatial_dedup: bool = False, + whep: str = "") -> dict: global _session with _guard: if _session is not None and _session.status()["running"]: @@ -429,7 +350,7 @@ def start(source: str, model_path: str, line_y: int, line_x_start: int, line_x_e _session = Session(source, model_path, line_y, line_x_start, line_x_end, conf, dedup_radius, margin, imgsz, entry_travel_min, handoff_radius, unload_confirm_frames, min_area_scale, - spatial_dedup) + spatial_dedup, whep) _session.start() time.sleep(0.4) # let an immediate failure surface in the response return _session.status() @@ -459,9 +380,14 @@ def status() -> dict: "fps": 0.0, "tracked": 0, "ignored": 0, "too_small": 0, "traced": 0, "trace_path": "", "elapsed": 0.0, "events": [], "error": "", "source": "", "model_path": "", + "whep_url": "", "preview": "mjpeg", "line": {"y": 0, "x_start": 0, "x_end": 1280}} return _session.status() def snapshot() -> Optional[bytes]: return _session.snapshot() if _session is not None else None + + +def overlay() -> dict: + return _session.overlay() if _session is not None else {} diff --git a/backend/live_render.py b/backend/live_render.py new file mode 100644 index 0000000..23b9dac --- /dev/null +++ b/backend/live_render.py @@ -0,0 +1,60 @@ +"""Burning the counting overlay into the frame, as an MJPEG. + +Split out of `live_count.py` to keep it inside the 400-line limit. This is the +fallback preview, used for archive files; a WebRTC session draws the same +geometry on a canvas in the browser instead (REQ-177). +""" + +import cv2 + + +def render(session, frame, detections, ignored, counter): + height, width = frame.shape[:2] + + # Shade what the region excludes. Without this the neighbouring truck's + # sacks simply vanish from the overlay, and "are they being ignored?" + # looks identical to "is the model missing them?". + if session.line_x_start > 0 or session.line_x_end < width: + shade = frame.copy() + if session.line_x_start > 0: + cv2.rectangle(shade, (0, 0), (session.line_x_start, height), (0, 0, 0), -1) + if session.line_x_end < width: + cv2.rectangle(shade, (session.line_x_end, 0), (width, height), (0, 0, 0), -1) + cv2.addWeighted(shade, 0.55, frame, 0.45, 0, frame) + + # Ignored detections stay visible, in grey, so the region can be judged. + for det in ignored: + x1, y1, x2, y2 = (int(v) for v in det.bbox) + cv2.rectangle(frame, (x1, y1), (x2, y2), (130, 130, 130), 1) + + for edge in (session.line_x_start, session.line_x_end): + if 0 < edge < width: + cv2.line(frame, (edge, 0), (edge, height), (255, 0, 255), 2) + cv2.putText(frame, "IGNORED", (max(4, session.line_x_start - 92), height - 14), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA) + cv2.putText(frame, "IGNORED", (min(width - 88, session.line_x_end + 8), height - 14), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA) + + for det in detections: + x1, y1, x2, y2 = (int(v) for v in det.bbox) + counted = bool(counter.counted_tracks.get(det.track_id)) + colour = (74, 222, 128) if counted else (248, 191, 113) + cv2.rectangle(frame, (x1, y1), (x2, y2), colour, 2) + cv2.putText(frame, f"#{det.track_id} {det.confidence:.2f}", (x1, max(14, y1 - 6)), + cv2.FONT_HERSHEY_SIMPLEX, 0.45, colour, 1, cv2.LINE_AA) + + cv2.line(frame, (session.line_x_start, session.line_y), (session.line_x_end, session.line_y), + (0, 255, 255), 2) + for edge in (session.line_y - session.margin, session.line_y + session.margin): + cv2.line(frame, (session.line_x_start, edge), (session.line_x_end, edge), + (0, 160, 160), 1) + + panel = f"IN {session.loading} OUT {session.unloading} NET {session.loading - session.unloading}" + cv2.rectangle(frame, (12, 12), (12 + 9 * len(panel) + 20, 84), (0, 0, 0), -1) + cv2.putText(frame, panel, (24, 46), cv2.FONT_HERSHEY_SIMPLEX, 0.8, + (74, 222, 128), 2, cv2.LINE_AA) + cv2.putText(frame, f"{session.fps:.1f} fps {session.tracked} tracked {session.ignored} ignored", + (24, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (200, 200, 200), 1, cv2.LINE_AA) + + ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 75]) + return buffer.tobytes() if ok else None diff --git a/backend/live_source.py b/backend/live_source.py new file mode 100644 index 0000000..662c3e7 --- /dev/null +++ b/backend/live_source.py @@ -0,0 +1,131 @@ +"""Opening a live source, and the URL algebra around it. + +Split out of `live_count.py` so that file stays inside the 400-line limit: this +is the transport layer (what a "source" is, how it is opened, how a WebRTC URL +maps to the RTSP leg of the same stream), not the counting logic. +""" + +import os +import threading +import time + +import cv2 + + +class LiveCountError(Exception): + pass + + +def _is_stream(source: str) -> bool: + return str(source).startswith(("rtsp://", "rtmp://", "http://", "https://")) + + +# MediaMTX fans one camera out to several protocols on one host: WHEP for +# browsers, RTSP for decoders. The ports are the server's, not ours, so they +# come from the environment rather than the code (REQ-176). +WHEP_PATH = os.environ.get("MEDIAMTX_WHEP_PATH", "/whep") +RTSP_PORT = os.environ.get("MEDIAMTX_RTSP_PORT", "8554") + + +def whep_url(source: str) -> str: + """The URL a browser POSTs its SDP offer to, for a WebRTC stream URL.""" + trimmed = source.rstrip("/") + return trimmed if trimmed.endswith(WHEP_PATH) else trimmed + WHEP_PATH + + +def whep_to_rtsp(source: str) -> str: + """`http://host:8889/cam` -> `rtsp://host:8554/cam`. + + The browser and the counter watch the same camera, but not over the same + protocol: WebRTC is what makes the *preview* cheap, while pulling it into + Python would add ICE and a jitter buffer on top of exactly the same H.264 + decode RTSP already does. So the AI counts from the RTSP leg of the same + MediaMTX path — one ingest, two consumers. + """ + from urllib.parse import urlparse + + parsed = urlparse(source) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise LiveCountError( + "A live source must be a WebRTC (WHEP) URL, e.g. http://host:8889/cam") + path = parsed.path.rstrip("/") + if path.endswith(WHEP_PATH): + path = path[: -len(WHEP_PATH)] + if not path.strip("/"): + raise LiveCountError(f"No stream path in {source} — expected e.g. http://host:8889/cam") + return f"rtsp://{parsed.hostname}:{RTSP_PORT}{path}" + + +class _ThreadedStream: + """Decode in a background thread and always hand out the newest frame. + + A plain VideoCapture.read() on RTSP is blocking, and getting the frames + there costs far more than looking at them does — measured on this camera at + 8.3 fps for decode alone against 205 fps for tracking and inference. Worse, reading slower than the camera sends builds + a backlog, so the picture drifts further behind real time the longer it + runs. Dropping stale frames keeps latency flat, which is what a counting + test needs to mean anything. `predict.py` does the same thing. + """ + + def __init__(self, source: str): + self._capture = cv2.VideoCapture(source) + try: + self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1) + except Exception: + pass + self._frame = None + self._lock = threading.Lock() + self._running = True + self._thread = threading.Thread(target=self._pump, daemon=True) + if self._capture.isOpened(): + self._thread.start() + + def _pump(self) -> None: + while self._running: + ok, frame = self._capture.read() + if not ok: + time.sleep(0.01) + continue + with self._lock: + self._frame = frame + + def isOpened(self) -> bool: + return self._capture.isOpened() + + def read(self): + with self._lock: + if self._frame is None: + return False, None + frame, self._frame = self._frame, None + return True, frame + + def release(self) -> None: + self._running = False + # The thread is only started when the capture opened, so a failed + # source would otherwise raise "cannot join thread before it is + # started" here — inside the caller's finally, skipping the GPU lock + # release and wedging every later session on "GPU busy". + if self._thread.is_alive(): + self._thread.join(timeout=2) + self._capture.release() + + +# TCP, and it is worth writing down why, because the obvious reasoning gives the +# wrong answer here. The link to the streaming server is a ZeroTier VPN with 15% +# packet loss and a 41-104 ms round trip, which is exactly the case where UDP is +# supposed to win — and with the ffmpeg CLI it does, 16 fps against 6.8. Through +# OpenCV it loses badly: measured over 30 s on this camera, tcp 6.4 fps, udp with +# a socket buffer 4.4, bare udp 2.4. OpenCV drops what it cannot reassemble +# rather than showing it, so the loss becomes missing frames. Left overridable +# because on a clean LAN the answer flips back. +RTSP_TRANSPORT = os.environ.get("RTSP_TRANSPORT", "tcp") + + +def _open(source: str): + if _is_stream(source): + os.environ.setdefault( + "OPENCV_FFMPEG_CAPTURE_OPTIONS", + f"rtsp_transport;{RTSP_TRANSPORT}|buffer_size;1048576|max_delay;200000", + ) + return _ThreadedStream(source) + return cv2.VideoCapture(source) diff --git a/backend/preview.py b/backend/preview.py new file mode 100644 index 0000000..70430bb --- /dev/null +++ b/backend/preview.py @@ -0,0 +1,151 @@ +"""The auto-annotate preview: one frame, run now, nothing written (REQ-171/172). + +Split out of `autolabel.py` to keep that file under the 400-line limit. The job +path and this path share `labeling.label_image`, so what the preview shows is +what a batch run would write — with one deliberate exception: drawn box +exemplars (REQ-172) only ever apply here. SAM3's geometric prompts pool features +from the current image, so replaying them on another frame would ask about +whatever happens to sit at those coordinates there. +""" + +import os +from typing import List, Optional + +from backend import batches, db, labeling, projects, review +from backend.autolabel import DEFAULT_IOU, DEFAULT_THRESHOLD, _geometries + +def preview_frame( + batch_id: int, + frame_id: int, + engine: str, + threshold: float = DEFAULT_THRESHOLD, + iou_threshold: float = DEFAULT_IOU, + min_box_frac: float = 0.0, + target_class_names: Optional[List[str]] = None, + custom_model_path: Optional[str] = None, + exemplars: Optional[List[dict]] = None, + exemplar_class_name: Optional[str] = None, +) -> List[dict]: + batch = batches.get(batch_id) + if not batch: + raise ValueError("No such batch") + project = projects.get(batch["project_id"]) + + frame = next((f for f in batches.frames(batch_id) if f["id"] == frame_id), None) + if not frame: + raise ValueError("Frame not found") + + directory = batches.frames_dir(batch["project_slug"], batch_id) + frame_file = os.path.join(directory, frame["filename"]) + fw = max(1, frame.get("width") or 1) + fh = max(1, frame.get("height") or 1) + + yolo_model = None + sam3_target_classes = [] + + if engine == "sam3" and not custom_model_path: + allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None + if allowed_classes_set: + sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set] + else: + sam3_target_classes = [c for c in project["classes"]] + + prompts = [c["prompt"] for c in sam3_target_classes] + if prompts: + from backend.sam3_engine import get_engine + get_engine() + else: + from ultralytics import YOLO + if custom_model_path and os.path.isfile(custom_model_path): + m_path = custom_model_path + else: + m_path = projects.training_start_point(project) + with db.cursor() as cur: + cur.execute("SELECT weights_path FROM model_versions WHERE project_id = ? ORDER BY version DESC LIMIT 1", (project["id"],)) + row = cur.fetchone() + if row and os.path.isfile(row[0]): + m_path = row[0] + yolo_model = YOLO(m_path) + + name_to_class_id = {item["name"].strip().lower(): item["class_id"] for item in project["classes"]} + allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None + + all_raw_detections = [] + + if yolo_model is not None: + results = yolo_model.predict(frame_file, conf=threshold, verbose=False) + if results and len(results) > 0: + model_names = results[0].names + for box in results[0].boxes: + cls_idx = int(box.cls[0].item()) + raw_cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower() + + target_class_id = name_to_class_id.get(raw_cls_name) + if target_class_id is None: + for item in project["classes"]: + if item["class_id"] == cls_idx: + target_class_id = item["class_id"] + break + if target_class_id is None and 0 <= cls_idx < len(project["classes"]): + target_class_id = project["classes"][cls_idx]["class_id"] + + if target_class_id is None: + continue + + target_cls_obj = next((c for c in project["classes"] if c["class_id"] == target_class_id), None) + proj_cls_name = target_cls_obj["name"].strip().lower() if target_cls_obj else "" + + if allowed_classes_set is not None: + if (raw_cls_name not in allowed_classes_set and + proj_cls_name not in allowed_classes_set and + str(target_class_id) not in allowed_classes_set): + continue + + score = float(box.conf[0].item()) + xyxyn = box.xyxyn[0].tolist() + all_raw_detections.append(labeling.Detection( + class_id=target_class_id, + class_name=proj_cls_name or raw_cls_name, + box=[xyxyn[0]*fw, xyxyn[1]*fh, xyxyn[2]*fw, xyxyn[3]*fh], + score=score, + mask=None + )) + + if engine == "sam3" and sam3_target_classes: + prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes] + # Exemplars belong to exactly one class — the chip that was active when + # they were drawn. An unknown name means no exemplar class, so the run + # falls back to plain text rather than silently attaching the boxes to + # whichever class happens to be first. + exemplar_index = -1 + if exemplars and exemplar_class_name: + wanted = exemplar_class_name.strip().lower() + exemplar_index = next( + (i for i, c in enumerate(sam3_target_classes) + if c["name"].strip().lower() == wanted), + -1, + ) + res = labeling.label_image( + frame_file, frame["filename"], prompts, threshold, + iou_threshold=iou_threshold, min_box_frac=min_box_frac, + exemplar_index=exemplar_index, exemplars=exemplars + ) + if not res.error and res.detections: + for det in res.detections: + if 0 <= det.class_id < len(sam3_target_classes): + real_cls = sam3_target_classes[det.class_id] + det.class_id = real_cls["class_id"] + det.class_name = real_cls["name"] + all_raw_detections.append(det) + + kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold) + items = [] + for det in kept: + if project["label_type"] == "bbox" or det.mask is None: + geom = review.bbox(det.box[0]/fw, det.box[1]/fh, det.box[2]/fw, det.box[3]/fh) + items.append({"class_id": det.class_id, "geometry": geom, "score": det.score}) + else: + for geometry in _geometries(det, fw, fh, project["label_type"]): + items.append({"class_id": det.class_id, "geometry": geometry, "score": det.score}) + + return items diff --git a/backend/sam3_engine.py b/backend/sam3_engine.py index f735171..d4e060f 100644 --- a/backend/sam3_engine.py +++ b/backend/sam3_engine.py @@ -103,6 +103,41 @@ class Sam3Engine: + def detect_with_exemplars( + self, + image: Image.Image, + prompts: List[str], + threshold: float, + exemplar_index: int, + exemplars: List[dict], + ) -> List[Detection]: + """`detect()`, but one prompt also carries drawn box exemplars (REQ-172). + + Still one `set_image` for the whole call. The prompt set is reset before + every class because `state["geometric_prompt"]` survives `set_text_prompt` + — without the reset, one class's boxes would leak into the next class. + """ + processor = Sam3Processor(self.model, device=self.device) + processor.confidence_threshold = threshold + + detections: List[Detection] = [] + with torch.autocast(self.device, dtype=self.autocast_dtype): + state = processor.set_image(image) + for class_id, prompt in enumerate(prompts): + processor.reset_all_prompts(state) + output = processor.set_text_prompt(prompt=prompt, state=state) + if class_id == exemplar_index: + for exemplar in exemplars: + output = processor.add_geometric_prompt( + box=exemplar["box"], + label=bool(exemplar.get("positive", True)), + state=state, + ) + detections.extend(self._collect(output, class_id, prompt)) + del state + return detections + + # ---- interactive / exemplar prompting ------------------------------ diff --git a/docs/GT.xlsx b/docs/GT.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0463bbeeeac1c76384c164247657febdacedd737 GIT binary patch literal 599880 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⋚p)>5vBp>$1|b)_$9n_x9-pK8I(werEPj3 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�+@FtbJr`FVs}``ipRQZl^3 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 literal 0 HcmV?d00001 diff --git a/docs/design.md b/docs/design.md index 9ce6ef0..bec76c9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -123,9 +123,14 @@ box. | `batches.py` | batch lifecycle | **new** | | `review.py` | annotation CRUD, frame status, click-assist | **new** | | `autolabel.py` | the SAM3 job over a whole batch | **new** | +| `preview.py` | one-frame preview for the auto-annotate modal (REQ-171,172) | **new** | +| `exemplar.py` | exemplar-driven labeling in the review editor (REQ-173,174) | **new** | | `dataset.py` | merge into the master dataset, stable split | **new** | | `evaluate.py` | validate base vs new model | **new** | | `hardware.py` | VRAM detection → training defaults | **new** | +| `live_count.py` | the live counting session: capture → track → count | **new** | +| `live_source.py` | what a source is, how it opens, WHEP↔RTSP (REQ-176) | **new** | +| `live_render.py` | the MJPEG overlay, archive-file preview only (REQ-177) | **new** | | `api/` | the FastAPI routes, one module per domain | **new** | Removed: `uploads.py`, `static/index.html`, and the old flow's endpoints. @@ -167,6 +172,9 @@ POST /api/projects/{id}/batches # {rel, start_sec, end_sec, fps} GET /api/batches/{id} # status + review progress (REQ-045) GET /api/batches/{id}/frames # frames + statuses POST /api/batches/{id}/autolabel # {threshold} → job (REQ-030,032,034) +POST /api/batches/{id}/preview # one frame, run now, nothing written; + # +exemplars[] {box:[cx,cy,w,h], positive} + # +exemplar_class_name (REQ-171,172) DELETE /api/batches/{id}/classes/{class_id}/annotations # clear all shapes of class in batch (REQ-046) POST /api/batches/{ids}/approve # one or many, comma-separated → one merge job (REQ-131) GET /api/batches/{ids}/triage/summary # one or many, comma-separated (REQ-130) @@ -180,6 +188,7 @@ POST /api/frames/{id}/annotations # add a manual shape (REQ-042) PATCH /api/annotations/{id} # move/resize/reclass DELETE /api/annotations/{id} POST /api/frames/{id}/assist # click/box → SAM3 shape (REQ-043) +POST /api/frames/{id}/exemplar-label # drawn pool → re-detect one class (REQ-173,174) POST /api/frames/{id}/status # approved | rejected | pending (REQ-041) POST /api/projects/{id}/train # → train job (REQ-060,061,062) @@ -187,11 +196,36 @@ GET /api/projects/{id}/models # versions + metrics (REQ-063,06 GET /api/models/{id}/weights # download best.pt POST /api/models/{id}/promote # make it the project's base model (REQ-064) +GET /api/projects/{id}/live-count/models # weights this project can count with +POST /api/projects/{id}/live-count/start # {source|source_rel, model_path, dials} + # source must be a WHEP URL (REQ-176) +POST /api/live-count/stop +PATCH /api/live-count/line # move the line mid-session +GET /api/live-count/status # counts + preview: "webrtc" | "mjpeg" +GET /api/live-count/overlay # boxes/line/counts for the canvas (REQ-177) +GET /api/live-count/stream # MJPEG; 409 on a WebRTC session (REQ-177) + GET /api/jobs?project_id=… REQ-070,071 GET /api/jobs/{id} POST /api/jobs/{id}/cancel ``` +### Live counting (REQ-176, REQ-177) + +One camera, one ingest on the streaming server, two consumers: + +``` +camera ──▶ MediaMTX ──┬── WHEP :8889/cam/whep ──▶ browser

` of the keyboard map. Keep it visible; this is a keyboard-first + screen and the panel is the discovery mechanism. + +--- + +## 6. Simplification rules + +The UI may be redesigned. The following table separates what is free, what needs care, and what +must not move. + +### 6.1 Free to change + +- Visual style: spacing, radii, typography, the exact palette (as long as class colours stay + consistent across screens and contrast stays ≥ 4.5:1). +- Component library choice, CSS approach, icon set (SVG only — and the leftover emoji in + buttons *should* be replaced). +- Inline styles → design tokens/classes. Most of this codebase styles inline; that is technical + debt, not intent. +- Table vs card layouts, where a section sits on the page, panel collapse/expand behaviour. +- Adding light-mode polish, responsive breakpoints, focus rings, reduced-motion handling. +- Merging the two near-identical auto-annotate modals into one component with a + `batches: Batch[]` prop — they already share `PreviewShapes`. +- Replacing hash routing with a real router, as long as the URL shapes in §2.1 keep working + (they are pasted between screens and bookmarked). + +### 6.2 Change only with a decision recorded + +These are real product choices, not accidents. Changing them is allowed but must be deliberate +and written down in `./requirements.md`. + +| Thing | Why it is the way it is | +|---|---| +| Review opens on the first annotated frame | opening on an empty frame 1 reads as "nothing worked" | +| Only the newest cycle expands on Counting Accuracy | eleven expanded cycles is the wall of rows the grouping exists to avoid | +| Base datasets start unticked on Models | they are somebody else's labels; they must be opted into | +| All project datasets start ticked on Models | the common case is "train on everything I have" | +| Live Count defaults to archive video, not RTSP | ~100 fps vs ~6 fps; accuracy testing against a file is far quicker | +| Delta is signed, not an absolute accuracy % | over-count and under-count are different failures | +| The scatter uses a log area axis | box sizes span three orders of magnitude; a linear axis piles everything at the left edge | +| The crop grid pages from the server, sorted server-side | a batch holds ~85 k shapes; "worst 120" must come from the whole batch | +| Only 8 class chips show on a project card | a base model can carry 80 classes | +| The exemplar filter panel sits in the sidebar, not over the frame | it governs shapes drawn on the canvas; a card on top covers the thing being judged | + +### 6.3 Must not be simplified away + +Removing any of these breaks the pipeline, corrupts data, or produces numbers that quietly lie. + +**Data integrity** + +1. **Normalized 0–1 geometry, end to end.** The moment a screen stores pixels, that shape is + wrong at any other display size. Only the two documented exemplar formats deviate. +2. **`source: "auto" | "manual"` must be preserved and displayed.** A batch re-run deletes only + auto rows; if the UI stops distinguishing them, manual corrections become invisible and + destructible. +3. **The stable val split.** A frame that lands in `val` stays in `val` forever, across every + dataset. The UI must never offer a "reshuffle split" control. Without this, base-vs-new mAP + is meaningless — the new model would be scored on images it trained on. +4. **Triage rules are frozen onto a dataset at merge time.** Data Prep's copy that says so, and + Resync's warning that it re-stamps the rule version, are part of the feature, not filler. +5. **Class deletion renumbers.** A YOLO label is an integer index; a class list and a set of + label files that disagree do not fail loudly — they train a model on the wrong names. The + confirmation must keep enumerating exactly what will be rewritten. +6. **The merge gate lives in Data Prep, not in Review or Batches.** Review signs frames off; + Batches selects; only *Confirm merge* creates a dataset. Collapsing these puts an unfiltered + merge one click from an annotation screen. +7. **Merging unreviewed batches requires an explicit acknowledgement checkbox.** Wrong boxes + become training labels. +8. **The wall-clock start time is displayed by string slicing, never parsed into a `Date`.** + Parsing re-applies the browser's timezone and shifts every recording by hours. + +**Machine behaviour** + +9. **One `set_image` per image.** The N-prompt job calls `set_image` once per frame and loops + prompts over that cached backbone state. Any UI that implies per-prompt image loading (e.g. + "run each prompt separately") is pushing the backend toward a restructure that must not + happen. +10. **Exemplars are tuning aids, never labels, in the auto-annotate modal.** They go to + `/preview` only, and are dropped on frame or class change. +11. **Exemplar runs in Review are dry runs until Apply, and Apply re-runs server-side rather + than posting previewed geometry.** The browser is not the authority on what gets stored. +12. **The whole pool is re-sent on every run.** SAM3 can only append geometric prompts; this is + also how undo works. +13. **One exemplar pass at a time, with a single queued rerun.** Not a request per drag, not a + stack of pending passes. +14. **Debounces exist for GPU cost, not for looks:** 400 ms after a drag, 250 ms after a slider, + 300 ms on the triage simulate. Removing them fires a GPU round trip per pixel. +15. **The mass auto-annotate loop submits sequentially.** `Promise.all` drops every remaining + batch on the first rejection. +16. **Jobs are polled only while jobs are running, and data is reloaded once when the last one + finishes.** Constant polling on a static 222-row table is wasted work; no reload means new + frames and model versions never appear. +17. **A 409 from a preview or an assist means the GPU lock is held.** It must reach the screen + as its own message — it is the one failure the user can act on (wait, or stop the job). + +**Interaction contracts** + +18. **Every Review action has a keyboard shortcut, and the Shortcuts panel stays visible.** The + mouse is for drawing shapes, not for navigating. +19. **Ctrl/Cmd/Alt combos are never intercepted.** Ctrl+S must not approve a frame. +20. **Typing in an input never triggers a shortcut.** +21. **Select mode does not move shapes; draw mode does not marquee.** Mixing them silently + rewrites geometry the user was only ticking off. +22. **Escape cancels an in-flight gesture without committing it**, on every canvas. +23. **Approve/reject advance the frame and update locally before the request returns.** +24. **Optimistic edits roll back on failure**, with the backend's own message shown. +25. **The overlay wrapper hugs the image and contains nothing else.** Any extra element inside + it shifts every box off the pixels it describes. Related: overlay CSS must be scoped to the + direct child SVG (`> svg`), or a nested icon SVG inherits `position:absolute; width:100%` + and stretches across the whole frame. +26. **Progress is always `progress/total` + bar + cancel.** Never a spinner with no end. + +**Navigation** + +27. **The nav order is the pipeline order**, and every project-scoped page keeps a working + project id in its links. +28. **`?batches=` and `?batch=` survive.** They are how Batches hands a selection to Data Prep + and how Review is deep-linked. + +### 6.4 Known rough edges (fix, don't preserve) + +- Emoji used as icons in buttons (`🔄`, `🏷️`, `📋`, `🚀`, `📦`, `🤖`, `⚡`, `🖼️`) — replace with SVG. +- `window.confirm` / `window.prompt` / `window.alert` for renames, deletes and the Resync + report — replace with real dialogs, keeping the same enumerated consequences. +- Mixed English/Indonesian copy (Video Archive, Counting Accuracy). Pick one language per + screen; the domain words *siklus*, *batch*, *truk* are the operator's vocabulary and can stay + if the rest follows. +- Heavy inline styling throughout — move to tokens. +- `AutoAnnotateModal` and `MassAutoAnnotateModal` duplicate ~70 % of their logic. +- `BatchList` and `ActiveJobsBanner` are exported from `LibraryPage` and imported by + `BatchesPage`; they belong in `components/`. +- Track-5-frames only works for polygon geometry. + +--- + +## 7. API surface index + +Everything the frontend calls, grouped as the pages use it. Errors are `{"detail": "..."}`. + +**Health & hardware** +``` +GET /api/health → { gpu, vram_free_gb, sam3_ready } +GET /api/hardware → { gpu, batch, imgsz } +``` + +**Projects & classes** +``` +GET /api/projects → { projects[], video_root_default } +POST /api/projects { name, label_type, video_root, val_every, classes[] } +GET /api/projects/{id} +PATCH /api/projects/{id} { prompts: { classId: text }, val_every, … } +DELETE /api/projects/{id} +POST /api/projects/{id}/classes { name, prompt? } +DELETE /api/projects/{id}/classes/{classId} +POST /api/projects/{id}/base-model multipart file (.pt) +POST /api/projects/{id}/secondary-model multipart file (.pt) +``` + +**Archive & video** +``` +GET /api/projects/{id}/library → { dates[] } +GET /api/projects/{id}/library/{date} → { videos[] } +GET /api/projects/{id}/archive/cycles → { cycles[] } +GET /api/projects/{id}/archive/cycles/{cycle} → { videos[] } +POST /api/projects/{id}/archive/truck-scan +GET /api/projects/{id}/video/info?rel=… +GET /api/projects/{id}/video?rel=… Range streaming +``` + +**Batches, frames, annotations** +``` +POST /api/projects/{id}/batches { rel, start_sec, end_sec, fps } +GET /api/projects/{id}/batches +GET /api/batches/{id} +PATCH /api/batches/{id} { batch_label } +DELETE /api/batches/{id} +GET /api/batches/{id}/frames +POST /api/batches/{id}/preview see §4.4.1 +POST /api/batches/{id}/autolabel see §4.4.1 +POST /api/batches/{id}/autolabel-with-model multipart +POST /api/batches/inspect-model multipart → { staged_path, filename, classes[] } +POST /api/batches/{id}/reset-auto-annotations +DELETE /api/batches/{id}/classes/{classId}/annotations +GET /api/batches/{id}/next-pending?after_idx= +POST /api/batches/{id}/approve-all +POST /api/batches/{ids}/approve { dataset_id | dataset_name } +GET /api/frames/{id}/image?w= +GET /api/frames/{id}/annotations +POST /api/frames/{id}/annotations { class_id, geometry } +POST /api/frames/{id}/assist { box, class_id } +POST /api/frames/{id}/exemplar-label see §5.6 +POST /api/frames/{id}/status { status } +PATCH /api/annotations/{id} { geometry | class_id } +DELETE /api/annotations/{id} +POST /api/annotations/bulk-delete { annotation_ids } +POST /api/annotations/bulk-reclass { annotation_ids, class_id } +GET /api/annotations/{id}/crop +``` + +**Triage & augmentation** +``` +GET /api/projects/{id}/triage/rules +PUT /api/projects/{id}/triage/rules { rules } +GET /api/batches/{ids}/triage/summary +GET /api/batches/{ids}/triage/shapes?sort=&offset=&limit= +POST /api/batches/{ids}/triage/simulate { rules } +POST /api/triage/overrides { annotation_ids, verdict, target_class } +DELETE /api/triage/overrides { annotation_ids } +GET /api/projects/{id}/augment +PUT /api/projects/{id}/augment { settings } +``` + +**Datasets** +``` +GET /api/projects/{id}/dataset master summary +GET /api/projects/{id}/datasets +POST /api/projects/{id}/datasets { name } +POST /api/projects/{id}/datasets/combine-preview{ dataset_ids } +PATCH /api/datasets/{id} { name } +DELETE /api/datasets/{id} +POST /api/datasets/{id}/resync +GET /api/datasets/{id}/download +GET /api/projects/{id}/base-datasets +DELETE /api/base-datasets/{id} +POST /api/projects/{id}/import multipart zip +GET /api/projects/{id}/export?batch_ids=&approved_only=&include_empty= +``` + +**Training & models** +``` +POST /api/projects/{id}/train { epochs, dataset_ids, base_dataset_ids, class_ids } +GET /api/projects/{id}/models +POST /api/models/{id}/promote +GET /api/models/{id}/weights +``` + +**Counting** +``` +GET /api/projects/{id}/live-count/models +POST /api/projects/{id}/live-count/start { source_rel | source, model_path, ...cfg } +POST /api/live-count/stop +PATCH /api/live-count/line { line_y, line_x_start, line_x_end } +GET /api/live-count/status +GET /api/live-count/stream?k= MJPEG +GET /api/projects/{id}/counting-bench[?date=] +POST /api/projects/{id}/counting-bench/run { model_path, video_rels, all_videos, recount } +PATCH /api/projects/{id}/counting-bench/ground-truth { video_rel, ground_truth } +POST /api/projects/{id}/counting-bench/scan-clock { rescan } +PATCH /api/projects/{id}/counting-bench/clock { video_rel, started_at } +``` + +**SAM3 playground** +``` +POST /api/sam3/playground-test multipart: file, prompts, threshold, iou_threshold +``` + +**Jobs** +``` +GET /api/jobs?project_id= +GET /api/jobs/{id} +POST /api/jobs/{id}/cancel +``` + +--- + +## 8. Redesign brief — prompt siap-pakai + +Bagian ini adalah **satu prompt yang berdiri sendiri**, untuk diserahkan ke seorang desainer +atau ke sebuah model, ketika UI reTraining mau dirancang ulang dari nol. Isinya adalah +pemadatan §1–§7: sengaja mengulang, supaya bisa disalin utuh tanpa membawa dokumen ini. Kalau +suatu saat berbeda dengan §1–§7, **§1–§7 yang benar dan bagian ini yang bug.** + +Copy mulai dari sini. + +--- + +### Apa ini + +**reTraining** adalah *internal tool* self-hosted, single-user, single-GPU, yang mengubah +rekaman CCTV mentah menjadi dataset YOLO dan model deteksi yang **terbukti lebih baik** dari +model sebelumnya. Bukan SaaS, bukan landing page, tidak ada login/multi-tenant/onboarding. Satu +operator, satu layar, sesi kerja berjam-jam (review ribuan frame). + +Stack sekarang: React 19 + Vite, hash-routing tanpa router library, FastAPI, SAM3 (auto-label +zero-shot), Ultralytics YOLO11 (training), Docker + GPU. + +Domain kasus pertama: menghitung **karung (sack)** yang dimuat/dibongkar dari truk di sebuah +line pakan — itu sebabnya ada modul counting. + +### Alur pipeline (urutan nav = urutan kerja, tidak boleh diacak) + +``` +Projects → Video Archive → Trim → Batches → Auto-annotate → Review → Data Prep → Datasets → Models & Training → (Live Count / Counting Accuracy) + ① ② ③ ④ ⑤ ⑥ ⑦ ⑧ ⑨ +``` + +1. **Projects** — 1 project = 1 model yang sedang diperbaiki: base model `.pt`, daftar class, + `label_type` (`bbox`/`polygon`, terkunci setelah merge pertama), root arsip video, val-split. +2. **Video Archive** — browse arsip video *read-only* yang dikelompokkan per **siklus** + (06:00–05:59 hari berikutnya, jadi selalu melewati tengah malam; nama siklus = tanggal + mulainya). Pengelompokan diambil dari **timestamp yang terbakar di gambar video**, bukan nama + folder. +3. **Trim** — pilih rentang in/out di player + fps sampling → job ekstraksi frame → jadi satu + **batch**. +4. **Batches** — antrean kerja. Per batch: jumlah frame, reviewed, shapes, status. Dari sini + auto-annotate (SAM3 text-prompt / base model / custom YOLO) dan pilih batch untuk merge. +5. **Review** — layar inti. Perbaiki shape hasil auto-label, approve/reject tiap frame. Review + **tidak** merge. +6. **Data Prep** — gerbang merge satu-satunya: buang box sampah (filter outlier), atur + augmentasi, lalu *Confirm merge* → membuat/menambah **dataset**. +7. **Datasets** — dataset bernama; aturan triage **dibekukan** ke dataset saat merge. +8. **Models & Training** — fine-tune dari base model, lalu skor base vs model baru di val set + yang sama (mAP50, mAP50-95, precision, recall + delta bertanda). +9. **Live Counting** & **Counting Accuracy** — jalankan counter (tracker + line-cross / + possession) pada video atau RTSP; bandingkan hasil hitung AI vs ground truth manusia per + rekaman, per siklus. + +Plus **SAM3 Playground** (`#/sam3-playground`) — scratchpad tanpa project untuk mencari kalimat +prompt yang jitu. + +### Rute yang harus tetap hidup + +``` +#/projects +#/projects/{id} → Video Archive +#/projects/{id}/trim/{encodedRel} +#/projects/{id}/batches +#/projects/{id}/review?batch={batchId} (alias: #/batches/{batchId}) +#/projects/{id}/data-prep?batches=1,2,3 +#/projects/{id}/datasets +#/projects/{id}/models +#/projects/{id}/live-count +#/projects/{id}/counting-bench +#/sam3-playground +``` + +`?batches=` dan `?batch=` adalah cara Batches menyerahkan seleksi ke Data Prep dan cara Review +di-deep-link — jangan dihapus. + +### Shell + +Top bar tetap 48 px: wordmark kiri → nav urut pipeline di tengah → kanan badge health +(`GPU: …`, `VRAM: … GB`, `SAM3: Ready/Off` hijau saat siap, polling 3 detik) + toggle tema. +Error boundary membungkus area halaman, di-key per rute. + +### Design token sekarang (boleh diganti, maknanya harus bertahan) + +``` +--bg #0b0f19 · --panel rgba(17,24,39,.5) · --panel-raised rgba(30,41,59,.7) +--border rgba(255,255,255,.12) · --text #f3f4f6 · --text-muted #9ca3af +--accent #a855f7 · --ok #10b981 · --warn #f59e0b · --danger #ef4444 +--radius 12px · --space 8px · Inter / JetBrains Mono · transition 160ms +``` + +Warna **semantik** (jangan dipakai untuk dekorasi): hijau `#4ade80` = kept/approved/positive +exemplar/metrik lebih baik · merah `#f87171` = dropped/rejected/negative exemplar/metrik lebih +buruk · amber `#fbbf24` = perlu dicek (jam tidak terverifikasi, frame tertahan, belum tersimpan) +· biru `#38bdf8` = seleksi/marquee · ungu `#c084fc` = kerja mesin sedang jalan (job, SAM3). + +Warna class = **data, bukan dekorasi**: +`["#f59e0b","#38bdf8","#10b981","#facc15","#6366f1","#f97316","#ec4899","#9ca3af"][classId % 8]` +— index class yang sama wajib berwarna sama di **semua** tempat (badge filmstrip, stroke canvas, +chip class, tag kartu project, crop grid). + +### Review editor — layar terpenting + +Layout: head (batch + progress + Approve All + Prepare & merge) → banner job → grid: kiri canvas ++ frame-bar + filmstrip, kanan sidebar (panel filter exemplar, Classes, Shapes on this frame, +Shortcuts). + +- Overlay pakai **SVG** (bukan ``), `viewBox="0 0 frameW frameH"`, + `preserveAspectRatio="none"`. Wrapper overlay **memeluk gambar persis dan tidak berisi elemen + lain**. +- Canvas dibatasi `max-width: calc(72vh * width / height)`. +- Dua mode: **Draw** / **Select** (`V`). Select mode tidak pernah mengubah geometri; Draw mode + tidak marquee. +- **Di draw mode, drag bukan menggambar kotak — itu adalah *contoh*.** User menggambar satu + instance, SAM3 mencari sisanya, hasilnya di-*preview*, di-*tune* lewat 4 slider (Confidence, + Overlap/NMS, Min box size, Max shapes), lalu baru **Apply** yang menulis. Shift-drag = contoh + negatif ("bukan ini"). Panel filter duduk di **sidebar**, bukan menutupi frame. +- Tahan `S` = SAM3 click-assist (satu box → satu shape). +- Keyboard-first: `V ← → A X U N C T 1–9 Del Esc Enter`. Semua aksi punya shortcut; panel + Shortcuts selalu terlihat. Ctrl/Cmd/Alt **tidak pernah** di-intercept; mengetik di input tidak + memicu shortcut. +- Approve/Reject langsung pindah frame dan meng-update status lokal sebelum request selesai. + +### Aturan mutlak — melanggar ini merusak pipeline atau membuat angkanya berbohong + +Versi panjangnya ada di [§6.3](#63-must-not-be-simplified-away). + +**Integritas data** + +1. Geometri **normalized 0–1** ujung-ke-ujung. `bbox {points:[x0,y0,x1,y1]}`, + `polygon {points:[[x,y]…]}`. Dua pengecualian sengaja: exemplar ke `/preview` pakai + `[cx,cy,w,h]`, ke `/exemplar-label` pakai `[x0,y0,x1,y1]` — jangan "diseragamkan". +2. `source: "auto" | "manual"` wajib disimpan **dan ditampilkan** — re-run batch hanya menghapus + baris `auto`, koreksi manusia harus selamat. +3. **Val split stabil.** Frame yang sudah masuk `val` selamanya `val`. UI tidak boleh pernah + menyediakan tombol "reshuffle split". +4. Aturan triage **dibekukan** ke dataset saat merge; copy yang menjelaskan ini, dan peringatan + Resync, adalah bagian dari fitur. +5. Menghapus class **merenumber** class di atasnya, di DB dan di file label. Konfirmasi harus + menyebut jumlah shape yang mati dan file yang ditulis ulang. +6. Gerbang merge hanya di **Data Prep** — bukan di Review, bukan di Batches. +7. Merge batch yang belum di-review butuh checkbox pengakuan eksplisit. +8. Waktu rekaman ditampilkan dengan **slicing string**, tidak pernah di-`new Date()` (offset + browser menggeser semuanya berjam-jam). + +**Perilaku mesin** + +9. Satu `set_image` per gambar; jangan ada UI yang menyiratkan "jalankan tiap prompt terpisah". +10. Exemplar di modal auto-annotate = alat tuning, **tidak pernah** jadi label. +11. Exemplar run di Review = dry-run sampai Apply; Apply menjalankan ulang di server, bukan + mengirim balik geometri hasil preview. +12. Seluruh pool exemplar dikirim ulang tiap run (itu juga mekanisme undo-nya). +13. Satu pass sekali waktu, dengan maksimal satu rerun antre. +14. Debounce ada karena biaya GPU, bukan estetika: 400 ms setelah drag, 250 ms setelah slider, + 300 ms untuk triage simulate. +15. Mass auto-annotate submit **berurutan**, bukan `Promise.all`. +16. Job dipolling hanya selagi ada job jalan; data di-reload **sekali** saat job terakhir selesai. +17. HTTP **409** = GPU lock dipegang job lain — harus muncul sebagai pesan tersendiri. +18. Progress selalu `progress/total` + bar + tombol cancel. Spinner tanpa ujung tidak diterima. + +### Yang bebas diubah / yang harus diperbaiki + +**Bebas** ([§6.1](#61-free-to-change)): spacing, radius, tipografi, palet persisnya, pilihan +component library, pendekatan CSS, tabel vs kartu, penempatan section, collapse/expand, +breakpoint, focus ring, reduced-motion, menggabungkan dua modal auto-annotate yang 70% duplikat, +mengganti hash routing dengan router sungguhan (asal bentuk URL di atas tetap jalan). + +**Wajib diperbaiki** ([§6.4](#64-known-rough-edges-fix-dont-preserve)): + +- Emoji sebagai ikon (`🔄 🏷️ 📋 🚀 📦 🤖 ⚡ 🖼️`) → ganti SVG (Heroicons/Lucide). +- `window.confirm` / `prompt` / `alert` → dialog sungguhan, dengan konsekuensi tetap dienumerasi. +- Copy campur Inggris–Indonesia → satu bahasa per layar (istilah *siklus*, *batch*, *truk* adalah + kosakata operator, boleh tetap). +- Inline style masif → design token. +- Empty state harus berupa kalimat yang menyebut aksi berikutnya, bukan "No data". +- Error backend datang sebagai `{"detail": "..."}` — tampilkan pesannya, bukan "Error 500". + +### Checklist pra-serah + +- [ ] Tidak ada emoji sebagai ikon (SVG saja) +- [ ] `cursor: pointer` di setiap elemen yang bisa diklik +- [ ] Hover state dengan transisi 150–300 ms +- [ ] Kontras teks minimal 4.5:1 +- [ ] Focus state terlihat untuk navigasi keyboard +- [ ] `prefers-reduced-motion` dihormati +- [ ] Responsif di 375 / 768 / 1024 / 1440 px +- [ ] Halaman tidak pernah scroll horizontal +- [ ] Urutan nav = urutan pipeline, dan tiap link project-scoped membawa project id yang valid + +### Yang diminta + +Rancang ulang tampilan **reTraining** sebagai *dense internal tool* untuk sesi kerja panjang: +frame video dan canvas anotasi adalah kontennya, sisanya adalah chrome yang diam. Tanpa gradient +dekoratif, tanpa motion marketing. Kepadatan informasi dan jangkauan keyboard lebih penting +daripada whitespace. Konsistensi antar halaman lebih penting daripada kepintaran per halaman. + +Kirimkan: design system (palet, tipografi, spacing, komponen dasar) lebih dulu, lalu layout tiap +layar di daftar pipeline, lalu detail Review editor. diff --git a/frontend/src/api.js b/frontend/src/api.js index 28f3600..8abeace 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -117,6 +117,9 @@ export const api = { body: { annotation_ids: annotationIds, class_id: classId }, }), assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }), + // The whole frame-local exemplar pool, re-sent on every drag (REQ-173/174). + exemplarLabel: (frameId, body) => + request(`/frames/${frameId}/exemplar-label`, { method: 'POST', body }), setFrameStatus: (frameId, status) => request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }), @@ -150,6 +153,8 @@ export const api = { liveCountStop: () => request('/live-count/stop', { method: 'POST' }), liveCountMoveLine: (body) => request('/live-count/line', { method: 'PATCH', body }), liveCountStatus: () => request('/live-count/status'), + // Polled far faster than status: this is what the WebRTC preview draws. + liveCountOverlay: () => request('/live-count/overlay'), // `key` busts the browser cache so a restarted session gets a fresh connection. liveCountStreamUrl: (key = 0) => `/api/live-count/stream?k=${key}`, diff --git a/frontend/src/app.css b/frontend/src/app.css index 43b95c8..eb1b312 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -437,7 +437,7 @@ main.page { user-select: none; } -.canvas-wrap svg { +.canvas-wrap > svg { position: absolute; top: 0; left: 0; @@ -447,7 +447,7 @@ main.page { touch-action: none; } -.canvas-wrap svg.assist { cursor: copy; } +.canvas-wrap > svg.assist { cursor: copy; } .canvas-wrap .shape rect, .canvas-wrap .shape polygon { @@ -478,9 +478,9 @@ main.page { /* Select mode: the cursor promises a marquee, and a shape is a target to tick rather than something to drag (REQ-045a). */ -.canvas-wrap svg.selecting { cursor: cell; } -.canvas-wrap svg.selecting .shape rect, -.canvas-wrap svg.selecting .shape polygon { cursor: pointer; } +.canvas-wrap > svg.selecting { cursor: cell; } +.canvas-wrap > svg.selecting .shape rect, +.canvas-wrap > svg.selecting .shape polygon { cursor: pointer; } .canvas-wrap .shape.marked rect, .canvas-wrap .shape.marked polygon { @@ -532,6 +532,22 @@ main.page { .canvas-wrap .handle-ne, .canvas-wrap .handle-sw { cursor: nesw-resize; } .canvas-wrap .handle-vertex, .canvas-wrap .handle-midpoint { cursor: pointer; } +/* The frame-local exemplar pool (REQ-173/174): a prompt, not a shape, so it is + drawn behind everything, dashed, and never a pointer target. */ +.canvas-wrap .exemplar { + fill: none; + stroke-width: 1.5; + stroke-dasharray: 2 4; + opacity: 0.85; + pointer-events: none; + vector-effect: non-scaling-stroke; +} + +.canvas-wrap .exemplar.negative { + fill: rgba(248, 113, 113, 0.1); + stroke-dasharray: 6 3; +} + .canvas-wrap .draft { fill: rgba(255, 255, 255, 0.08); stroke-width: 2; @@ -724,3 +740,45 @@ kbd { .topbar .crumbs { display: none; } main.page { padding: 16px 12px 40px; } } + +/* The exemplar filter popup (REQ-175). It sits at the top of the sidebar, not + over the frame: the proposals it governs are drawn on the canvas, and a card + floating on top of them hid the thing being judged. The accent border is what + marks it as a live decision rather than another standing panel. */ +.exemplar-panel { + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid rgba(56, 189, 248, 0.45); + background: rgba(17, 24, 39, 0.92); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + line-height: 1.45; +} + +.exemplar-panel input[type="range"] { + accent-color: var(--accent); + display: block; + cursor: pointer; +} +.exemplar-panel .btn { cursor: pointer; transition: background 160ms ease, border-color 160ms ease; } + +/* Preview shapes are proposals, not labels: dashed and unclickable. The class + under review is hidden while they show (see AnnotationCanvas); what stays + visible belongs to other classes, dimmed so the two never read as one set. */ +.canvas-wrap > svg.previewing .shape { opacity: 0.4; } + +.canvas-wrap .preview-shape { + fill: rgba(56, 189, 248, 0.1); + stroke-width: 2; + stroke-dasharray: 4 3; + pointer-events: none; + vector-effect: non-scaling-stroke; +} + +.canvas-wrap .preview-shape.drawn { + fill: rgba(52, 211, 153, 0.16); + stroke-dasharray: none; +} + +@media (prefers-reduced-motion: reduce) { + .exemplar-panel .btn { transition: none; } +} diff --git a/frontend/src/components/AnnotationCanvas.jsx b/frontend/src/components/AnnotationCanvas.jsx index 5448dd5..3c0c4c6 100644 --- a/frontend/src/components/AnnotationCanvas.jsx +++ b/frontend/src/components/AnnotationCanvas.jsx @@ -39,7 +39,8 @@ function overlaps(geometry, [mx0, my0, mx1, my1]) { export default function AnnotationCanvas({ frame, imageUrl, annotations, selectedId, activeClass, assistMode, classes, - mode = 'draw', selectedIds, onSelect, onCreate, onUpdate, onAssist, onMarquee, + mode = 'draw', selectedIds, exemplars = [], preview = null, + onSelect, onCreate, onUpdate, onAssist, onExemplar, onMarquee, }) { const wrapRef = useRef(null) const svgRef = useRef(null) @@ -173,7 +174,10 @@ export default function AnnotationCanvas({ return } if (x1 - x0 >= MIN_SIZE && y1 - y0 >= MIN_SIZE) { + // Draw mode is exemplar mode (REQ-173): the drag is both the label and + // the visual prompt, and Shift makes it a negative one (REQ-174). if (assistMode) onAssist([x0, y0, x1, y1]) + else if (onExemplar) onExemplar([x0, y0, x1, y1], !additive.current) else onCreate({ type: 'bbox', points: [x0, y0, x1, y1] }) } return @@ -209,30 +213,75 @@ export default function AnnotationCanvas({ ref={svgRef} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" - className={[assistMode ? 'assist' : '', selecting ? 'selecting' : ''].filter(Boolean).join(' ') || undefined} + className={[assistMode ? 'assist' : '', selecting ? 'selecting' : '', + preview ? 'previewing' : ''].filter(Boolean).join(' ') || undefined} onPointerDown={startDraw} onPointerMove={onPointerMove} onPointerUp={onPointerUp} > - {annotations.map((annotation) => ( - - ))} + {/* A preview replaces this class on the frame wholesale, so its stored + shapes step aside for the proposals — left on screen they read as + part of the result and a rejected box looks like it never went. + Other classes are untouched by the run and stay as they are. */} + {annotations + .filter((annotation) => !(preview && annotation.class_id === activeClass)) + .map((annotation) => ( + + ))} + + {preview?.map((shape, i) => { + const drawn = shape.source === 'manual' + const stroke = drawn ? '#34d399' : classColor(activeClass) + if (shape.geometry.type === 'bbox') { + const [x0, y0, x1, y1] = shape.geometry.points + return ( + + ) + } + return ( + `${x * width},${y * height}`).join(' ')} + stroke={stroke} + /> + ) + })} + + {!selecting && exemplars.map((item, i) => { + const [x0, y0, x1, y1] = item.box + return ( + + ) + })} {draft && (() => { const [x0, y0, x1, y1] = normalise(draft) @@ -241,7 +290,10 @@ export default function AnnotationCanvas({ className={selecting ? 'draft marquee' : assistMode ? 'draft assist' : 'draft'} x={x0 * width} y={y0 * height} width={(x1 - x0) * width} height={(y1 - y0) * height} - stroke={selecting ? '#38bdf8' : assistMode ? 'var(--accent)' : classColor(activeClass)} + stroke={selecting ? '#38bdf8' + : assistMode ? 'var(--accent)' + : additive.current ? '#f87171' + : classColor(activeClass)} /> ) })()} diff --git a/frontend/src/components/AutoAnnotateModal.jsx b/frontend/src/components/AutoAnnotateModal.jsx index 8785ac9..60f9305 100644 --- a/frontend/src/components/AutoAnnotateModal.jsx +++ b/frontend/src/components/AutoAnnotateModal.jsx @@ -1,5 +1,8 @@ import React, { useState, useEffect, useRef } from 'react' import { api } from '../api' +import ExemplarCanvas from './ExemplarCanvas' +import { PreviewShapes } from './PreviewShapes' +import ClassPromptPanel from './ClassPromptPanel' function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value) @@ -14,69 +17,6 @@ function useDebounce(value, delay) { return debouncedValue } -// Shared with MassAutoAnnotateModal: draws detection boxes/polygons over a -// frame in a 0..10000 viewBox. -export function PreviewShapes({ shapes, project }) { - return shapes.map((shape, i) => { - if (!shape.geometry || !shape.geometry.points) return null - const classObj = project.classes.find(c => c.class_id === shape.class_id) - const className = classObj?.name || 'Unknown' - const color = ['#38bdf8', '#34d399', '#f472b6', '#a78bfa', '#fbbf24'][shape.class_id % 5] || '#fff' - - let minX = 1, minY = 1, maxX = 0, maxY = 0 - if (shape.geometry.type === 'bbox') { - const [left, top, right, bottom] = shape.geometry.points - minX = left; minY = top; maxX = right; maxY = bottom; - } else { - shape.geometry.points.forEach(pt => { - if (pt[0] < minX) minX = pt[0] - if (pt[1] < minY) minY = pt[1] - if (pt[0] > maxX) maxX = pt[0] - if (pt[1] > maxY) maxY = pt[1] - }) - } - - const x0 = minX * 10000 - const y0 = minY * 10000 - const bw = (maxX - minX) * 10000 - const bh = (maxY - minY) * 10000 - - return ( - - {shape.geometry.type === 'polygon' && ( - `${pt[0] * 10000},${pt[1] * 10000}`).join(' ')} - fill={color} - fillOpacity={0.35} - stroke={color} - strokeWidth="10" - /> - )} - - 300 ? y0 - 100 : y0 + 300} - fill={color} - fontSize="240" - fontWeight="bold" - style={{ textShadow: '10px 10px 10px #000, -10px -10px 10px #000, 10px -10px 10px #000, -10px 10px 10px #000' }} - > - {className} {shape.score ? `${(shape.score * 100).toFixed(1)}%` : ''} - - - ) - }) -} - export default function AutoAnnotateModal({ batch, project, @@ -103,11 +43,30 @@ export default function AutoAnnotateModal({ return [] }) + // SAM3 prompt tuning (REQ-171): the class prompt is edited here, next to the + // live preview, and saved back onto the project so the batch job uses it. + const [classPrompts, setClassPrompts] = useState(() => + Object.fromEntries(project.classes.map(c => [c.class_id, c.prompt || c.name])) + ) + const [promptDraft, setPromptDraft] = useState('') + const [promptSaving, setPromptSaving] = useState(false) + const [promptError, setPromptError] = useState('') + + // Exemplars (REQ-172): tuning aid for the previewed frame only. They belong to + // the active class and are never written or carried into the batch job. + const [activeClassName, setActiveClassName] = useState(null) + const [exemplars, setExemplars] = useState([]) + // Bumped on every add/undo/clear. Debouncing the count rather than the array + // is what lets "clear all" re-run too — an empty array on its own is + // indistinguishable from the initial state. + const [exemplarRev, setExemplarRev] = useState(0) + // Preview state const [frames, setFrames] = useState([]) const [frameIndex, setFrameIndex] = useState(0) const [previewShapes, setPreviewShapes] = useState([]) const [isLoadingPreview, setIsLoadingPreview] = useState(false) + const [previewError, setPreviewError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Fetch frames on mount @@ -130,6 +89,7 @@ export default function AutoAnnotateModal({ let isMounted = true setIsLoadingPreview(true) + setPreviewError('') api.preview(batch.id, { frame_id: frame.id, engine, @@ -137,24 +97,93 @@ export default function AutoAnnotateModal({ iou_threshold: iouThreshold, min_box_frac: minBoxFrac, target_class_names: selectedClasses, - custom_model_path: customModelStagedPath + custom_model_path: customModelStagedPath, + exemplars: engine === 'sam3' ? exemplars : [], + exemplar_class_name: engine === 'sam3' ? activeClassName : null }).then(res => { if (isMounted && res.shapes) { setPreviewShapes(res.shapes) } }).catch(err => { - console.error("Preview failed:", err) + // The GPU lock (REQ-065) answers 409 here while a batch job holds it, and + // that is the one failure the user can act on — so it goes on screen. + setPreviewError(err.message || 'Preview failed') setPreviewShapes([]) }).finally(() => { setIsLoadingPreview(false) }) } + const activeClass = project.classes.find(c => c.name === activeClassName) || null + const sam3Tuning = engine === 'sam3' + // Clear shapes when frame changes useEffect(() => { setPreviewShapes([]) }, [frameIndex]) + // The active class owns the exemplars, so keep it pointing at something the + // user actually ticked. + useEffect(() => { + if (!sam3Tuning) return + if (!activeClassName || !selectedClasses.includes(activeClassName)) { + setActiveClassName(selectedClasses[0] || null) + } + }, [sam3Tuning, selectedClasses, activeClassName]) + + // Exemplars are pooled from one image and belong to one class, so both a new + // frame and a new class invalidate them (REQ-172). + useEffect(() => { + setExemplars([]) + setExemplarRev(0) + }, [frameIndex, activeClassName]) + + useEffect(() => { + if (activeClass) { + setPromptDraft(classPrompts[activeClass.class_id] ?? activeClass.name) + setPromptError('') + } + }, [activeClassName]) + + // Auto re-run: drawing a box is the question, the redrawn preview is the + // answer, so waiting for a button press in between defeats the point. + // set_image is already cached for this frame, so only the grounding head runs. + const debouncedRev = useDebounce(exemplarRev, 250) + useEffect(() => { + if (debouncedRev > 0) handlePreview() + }, [debouncedRev]) + + const addExemplar = (exemplar) => { + setExemplars(prev => [...prev, exemplar]) + setExemplarRev(rev => rev + 1) + } + + const undoExemplar = () => { + setExemplars(prev => prev.slice(0, -1)) + setExemplarRev(rev => rev + 1) + } + + const clearExemplars = () => { + setExemplars([]) + setExemplarRev(rev => rev + 1) + } + + const savePrompt = async () => { + if (!activeClass) return + const text = promptDraft.trim() + if (!text || text === classPrompts[activeClass.class_id]) return + setPromptSaving(true) + setPromptError('') + try { + await api.patchProject(project.id, { prompts: { [activeClass.class_id]: text } }) + setClassPrompts({ ...classPrompts, [activeClass.class_id]: text }) + } catch (err) { + setPromptError(err.message || 'Could not save the prompt') + } finally { + setPromptSaving(false) + } + } + const handleStart = async () => { setIsSubmitting(true) try { @@ -195,31 +224,30 @@ export default function AutoAnnotateModal({
{currentFrame ? ( -
+ /* This wrapper must hug the image and nothing else: both overlays + * are sized to it, so anything else inside would shift every box + * off the pixels it describes. */ +
Preview Frame + {sam3Tuning && activeClass && ( + + )} {isLoadingPreview && (
Inferring...
)} -
- -
) : (
@@ -227,6 +255,50 @@ export default function AutoAnnotateModal({
)}
+ + {previewError && ( +
+ {previewError} +
+ )} + +
+ + {sam3Tuning && activeClass && ( + <> + + + + Drag = example of {activeClass.name} + {' · '}Shift-drag = not this + + + )} +
@@ -291,30 +363,21 @@ export default function AutoAnnotateModal({ />
-
- Target Classes: -
- {project.classes.map(c => { - const isSelected = selectedClasses.includes(c.name) - return ( - - ) - })} -
-
+
diff --git a/frontend/src/components/ClassPromptPanel.jsx b/frontend/src/components/ClassPromptPanel.jsx new file mode 100644 index 0000000..69172f3 --- /dev/null +++ b/frontend/src/components/ClassPromptPanel.jsx @@ -0,0 +1,137 @@ +import React from 'react' + +/* Target-class picker for the auto-annotate modal, plus the SAM3 prompt editor + * (REQ-171). + * + * The prompt lives on the project class, not on the job, so what is tuned here + * against the live preview is exactly what a batch run will send. Editing it is + * a real write to `project_classes.prompt` — the same field the Projects page + * edits, just reachable from where the feedback is. + * + * With SAM3 a chip carries a second meaning: one selected chip is *active*, and + * it owns the prompt shown below and any exemplars drawn on the frame. Because + * a click then has to mean "activate" rather than "deselect", each chip is a + * pair of buttons — the name activates, the × deselects. + */ +export default function ClassPromptPanel({ + classes, + selectedClasses, + onSelectedChange, + sam3Tuning, + activeClassName, + onActivate, + activeClass, + promptDraft, + onPromptDraftChange, + onPromptSave, + promptSaving, + promptError, + savedPrompt, +}) { + const toggle = (name) => { + if (selectedClasses.includes(name)) { + onSelectedChange(selectedClasses.filter(n => n !== name)) + } else { + onSelectedChange([...selectedClasses, name]) + } + } + + const dirty = activeClass && promptDraft.trim() && promptDraft.trim() !== savedPrompt + + return ( +
+ Target Classes: +
+ {classes.map(c => { + const isSelected = selectedClasses.includes(c.name) + const isActive = sam3Tuning && isSelected && c.name === activeClassName + + if (!sam3Tuning) { + return ( + + ) + } + + return ( + + + {isSelected && ( + + )} + + ) + })} +
+ + {sam3Tuning && activeClass && ( +
+ +
+ onPromptDraftChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onPromptSave() } }} + placeholder={activeClass.name} + style={{ fontSize: '0.82rem' }} + /> + +
+

+ {promptError + ? {promptError} + : dirty + ? 'Unsaved — the batch run still uses the stored prompt until you save.' + : 'Saved on the class. This is the text the batch run sends.'} +

+
+ )} +
+ ) +} diff --git a/frontend/src/components/ExemplarCanvas.jsx b/frontend/src/components/ExemplarCanvas.jsx new file mode 100644 index 0000000..1cc81b6 --- /dev/null +++ b/frontend/src/components/ExemplarCanvas.jsx @@ -0,0 +1,126 @@ +import React, { useEffect, useRef, useState } from 'react' + +/* Drag-to-draw box exemplars over the auto-annotate preview frame (REQ-172). + * + * Coordinates are normalized 0..1 against this overlay, which is sized to the + * image itself — so the numbers sent to SAM3 are resolution-independent and + * survive the preview being scaled to whatever the window allows. + * + * Boxes are stored in the format the backend wants: [cx, cy, w, h]. + */ + +const POSITIVE = '#34d399' +const NEGATIVE = '#f43f5e' + +// Under this the drag was really a click. Zero-area boxes make SAM3's ROI pool +// return nothing useful, so they are dropped rather than sent. +const MIN_SIDE = 0.005 + +function toCorners(box) { + const [cx, cy, w, h] = box + return { x0: cx - w / 2, y0: cy - h / 2, w, h } +} + +export default function ExemplarCanvas({ exemplars, onAdd, disabled = false }) { + const svgRef = useRef(null) + const [draft, setDraft] = useState(null) // { x0, y0, x1, y1, positive } + + // Escape abandons the box being dragged. Without it a drag started by mistake + // can only be finished, and every finished box costs a GPU round trip. + useEffect(() => { + if (!draft) return undefined + const onKey = (event) => { if (event.key === 'Escape') setDraft(null) } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [draft]) + + const pointAt = (event) => { + const rect = svgRef.current.getBoundingClientRect() + return { + x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)), + y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)), + } + } + + const handleDown = (event) => { + if (disabled || event.button !== 0) return + const { x, y } = pointAt(event) + event.currentTarget.setPointerCapture(event.pointerId) + // Shift marks the box as a counter-example: "not this one". + setDraft({ x0: x, y0: y, x1: x, y1: y, positive: !event.shiftKey }) + } + + const handleMove = (event) => { + if (!draft) return + const { x, y } = pointAt(event) + setDraft({ ...draft, x1: x, y1: y }) + } + + const handleUp = () => { + if (!draft) return + const w = Math.abs(draft.x1 - draft.x0) + const h = Math.abs(draft.y1 - draft.y0) + setDraft(null) + if (w < MIN_SIDE || h < MIN_SIDE) return + onAdd({ + box: [(draft.x0 + draft.x1) / 2, (draft.y0 + draft.y1) / 2, w, h], + positive: draft.positive, + }) + } + + const draftRect = draft && { + x0: Math.min(draft.x0, draft.x1), + y0: Math.min(draft.y0, draft.y1), + w: Math.abs(draft.x1 - draft.x0), + h: Math.abs(draft.y1 - draft.y0), + } + + return ( + setDraft(null)} + style={{ + position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', + cursor: disabled ? 'default' : 'crosshair', + touchAction: 'none', + }} + > + {exemplars.map((exemplar, index) => { + const { x0, y0, w, h } = toCorners(exemplar.box) + const color = exemplar.positive ? POSITIVE : NEGATIVE + return ( + + + + {exemplar.positive ? '+' : '−'}{index + 1} + + + ) + })} + + {draftRect && ( + + )} + + ) +} diff --git a/frontend/src/components/ExemplarFilterPanel.jsx b/frontend/src/components/ExemplarFilterPanel.jsx new file mode 100644 index 0000000..023cf1d --- /dev/null +++ b/frontend/src/components/ExemplarFilterPanel.jsx @@ -0,0 +1,125 @@ +import { useEffect, useRef } from 'react' +import { CheckIcon, XIcon } from './Icons' + +/* The filter popup for an exemplar run (REQ-175). + * + * It lives at the top of the sidebar rather than over the frame — the shapes it + * governs are drawn on the canvas, and a card on top of them covered the thing + * being judged. It is mounted only while a run is undecided: the first drag + * opens it, Apply and Discard close it. Nothing here is stored — the run it + * governs is one frame's, and the next frame starts from the defaults again. */ + +const SLIDERS = [ + { key: 'threshold', label: 'Confidence', min: 0.05, max: 0.95, step: 0.05, + color: '#38bdf8', format: (v) => v.toFixed(2), + hint: 'SAM3 score cutoff. Lower finds more, and more junk.' }, + { key: 'iou_threshold', label: 'Overlap (NMS)', min: 0.1, max: 1, step: 0.05, + color: '#c084fc', format: (v) => v.toFixed(2), + hint: 'Two boxes overlapping this much are one object — the weaker goes. Lower deletes more.' }, + { key: 'min_box_frac', label: 'Min box size', min: 0, max: 0.05, step: 0.001, + color: '#34d399', format: (v) => `${(v * 100).toFixed(1)}% of frame`, + hint: 'Boxes smaller than this are specks. Higher deletes more.' }, + { key: 'max_detections', label: 'Max shapes', min: 5, max: 300, step: 5, + color: '#fbbf24', format: (v) => String(v), + hint: 'Keep only the highest-scoring N.' }, +] + +export default function ExemplarFilterPanel({ + filters, preview, busy, className, negatives = 0, replacing = 0, + onFilter, onReset, onApply, onDiscard, onUndo, +}) { + const rootRef = useRef(null) + + // The sidebar scrolls, so a run opened while it is scrolled down would ask + // for a decision the user cannot see. + useEffect(() => { + rootRef.current?.scrollIntoView({ block: 'nearest' }) + }, []) + + // Enter applies, Escape discards — the panel is modal in intent even though + // it never blocks the canvas underneath. + useEffect(() => { + function onKey(event) { + if (event.target?.matches?.('input, textarea, select')) { + if (event.key !== 'Enter' && event.key !== 'Escape') return + } + if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); onApply() } + if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onDiscard() } + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [onApply, onDiscard]) + + const drawn = preview?.shapes.filter((shape) => shape.source === 'manual').length ?? 0 + const found = (preview?.shapes.length ?? 0) - drawn + + return ( +
+
+ Auto-label “{className}” + +
+ +

+ {busy + ? 'asking SAM3…' + : preview + ? `${found} found + ${drawn} drawn` + + (negatives ? ` · ${negatives} rejected` : '') + : 'Drag an example on the frame'} +

+ {preview && !busy && ( +

+ Apply replaces the {replacing} “{className}” shape{replacing === 1 ? '' : 's'} on + this frame — nothing is saved yet. +

+ )} + + {preview?.message && ( +

+ {preview.message} +

+ )} + + {SLIDERS.map((slider) => ( +
+
+ + + {slider.format(filters[slider.key])} + +
+ onFilter(slider.key, parseFloat(event.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+ ))} + +
+ + + + +
+
+ ) +} diff --git a/frontend/src/components/LiveVideoPanel.jsx b/frontend/src/components/LiveVideoPanel.jsx new file mode 100644 index 0000000..0d4885c --- /dev/null +++ b/frontend/src/components/LiveVideoPanel.jsx @@ -0,0 +1,224 @@ +import React, { useEffect, useRef, useState } from 'react' +import { api } from '../api' + +/* The live-count preview. + * + * A WebRTC session shows the camera itself — the browser pulls it straight from + * the streaming server over WHEP, so the frames never pass through this app and + * the backend never encodes a JPEG for them (REQ-177). What the model saw is + * drawn on a canvas on top, from a small JSON feed. An archive file has no + * WebRTC leg, so it keeps the MJPEG the backend renders. + * + * Coordinates arrive in the 1280x720 space the pipeline works in; the canvas is + * sized to that, and CSS stretches it over the video. That is exact whatever the + * camera's real resolution is — the backend stretches each frame to 1280x720 per + * axis, and stretching the canvas back over the picture is the inverse of it — but + * only while the picture *fills* the element box. Do not give the video an + * `aspect-ratio` or an `object-fit` that letterboxes it: this camera is 704x576, + * so a forced 16:9 would pillarbox the picture and leave every box offset. */ + +const W = 1280 +const H = 720 +const OVERLAY_HZ = 10 + +const COLOURS = { + counted: '#4ade80', + tracked: '#f8bf71', + ignored: '#828282', +} + +export default function LiveVideoPanel({ running, whepUrl, streamKey, placing, onPlace }) { + return ( +
+ {!running ? ( +

+ Not running. Set a source and press Start. +

+ ) : whepUrl ? ( + + ) : ( + Live counting + )} +
+ ) +} + +function WebrtcPreview({ whepUrl, placing, onPlace }) { + const videoRef = useRef(null) + const canvasRef = useRef(null) + // A failed handshake used to be invisible: the canvas kept drawing boxes over + // a black rectangle, which looks exactly like a broken renderer rather than a + // stream that never arrived. The state is on screen now. + const [link, setLink] = useState('connecting') + + useEffect(() => { + let pc = null + let resourceUrl = '' + let cancelled = false + + async function connect() { + pc = new RTCPeerConnection() + pc.addTransceiver('video', { direction: 'recvonly' }) + pc.ontrack = (event) => { + if (videoRef.current) videoRef.current.srcObject = event.streams[0] + } + pc.oniceconnectionstatechange = () => { + if (cancelled) return + if (pc.iceConnectionState === 'connected') setLink('live') + else if (['failed', 'disconnected', 'closed'].includes(pc.iceConnectionState)) { + setLink(`WebRTC ${pc.iceConnectionState} — is ${whepUrl} reachable from this browser?`) + } + } + await pc.setLocalDescription(await pc.createOffer()) + // MediaMTX accepts a complete offer; gathering first avoids trickling + // candidates over a second request. + await new Promise((resolve) => { + if (pc.iceGatheringState === 'complete') return resolve() + pc.onicegatheringstatechange = () => pc.iceGatheringState === 'complete' && resolve() + setTimeout(resolve, 2000) + }) + if (cancelled) return + const response = await fetch(whepUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/sdp' }, + body: pc.localDescription.sdp, + }) + if (!response.ok) throw new Error(`WHEP ${response.status} from ${whepUrl}`) + resourceUrl = response.headers.get('Location') || '' + const answer = await response.text() + if (cancelled) return + await pc.setRemoteDescription({ type: 'answer', sdp: answer }) + } + + connect().catch((exc) => { + if (!cancelled) setLink(exc.message) + }) + + return () => { + cancelled = true + // Tell the server the viewer left, so it stops sending to a dead peer. + if (resourceUrl) { + fetch(new URL(resourceUrl, whepUrl).href, { method: 'DELETE' }).catch(() => {}) + } + if (pc) pc.close() + } + }, [whepUrl]) + + // Poll the geometry and repaint. Independent of the video's frame rate: the + // boxes are a few hundred bytes, the video is never touched. + useEffect(() => { + let timer = null + let stopped = false + + async function tick() { + try { + draw(canvasRef.current, await api.liveCountOverlay()) + } catch { + /* a dropped poll is corrected by the next one */ + } + if (!stopped) timer = setTimeout(tick, 1000 / OVERLAY_HZ) + } + tick() + return () => { + stopped = true + clearTimeout(timer) + } + }, []) + + return ( +
+
+ ) +} + +/* Mirrors what `backend/live_render.py` burns into the MJPEG, so the two + * previews say the same thing about the same session. */ +function draw(canvas, data) { + if (!canvas) return + const ctx = canvas.getContext('2d') + ctx.clearRect(0, 0, W, H) + if (!data || !data.line) return + const { y, x_start: xs, x_end: xe, margin } = data.line + + // Shade what the region excludes, so "ignored" never looks like "missed". + ctx.fillStyle = 'rgba(0, 0, 0, 0.55)' + if (xs > 0) ctx.fillRect(0, 0, xs, H) + if (xe < W) ctx.fillRect(xe, 0, W - xe, H) + + ctx.lineWidth = 2 + ctx.font = '16px ui-monospace, monospace' + for (const box of data.boxes || []) { + const [x1, y1, x2, y2] = box.b + ctx.strokeStyle = COLOURS[box.s] || COLOURS.tracked + ctx.lineWidth = box.s === 'ignored' ? 1 : 2 + ctx.strokeRect(x1, y1, x2 - x1, y2 - y1) + if (box.s !== 'ignored') { + ctx.fillStyle = ctx.strokeStyle + ctx.fillText(`#${box.id} ${box.c}`, x1, Math.max(14, y1 - 5)) + } + } + + ctx.lineWidth = 2 + ctx.strokeStyle = '#ff00ff' + for (const edge of [xs, xe]) { + if (edge > 0 && edge < W) { + ctx.beginPath() + ctx.moveTo(edge, 0) + ctx.lineTo(edge, H) + ctx.stroke() + } + } + + ctx.strokeStyle = '#ffff00' + ctx.beginPath() + ctx.moveTo(xs, y) + ctx.lineTo(xe, y) + ctx.stroke() + ctx.strokeStyle = '#00a0a0' + ctx.lineWidth = 1 + for (const edge of [y - margin, y + margin]) { + ctx.beginPath() + ctx.moveTo(xs, edge) + ctx.lineTo(xe, edge) + ctx.stroke() + } + + const panel = `IN ${data.loading} OUT ${data.unloading} NET ${data.net}` + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + ctx.fillRect(12, 12, 9 * panel.length + 30, 46) + ctx.fillStyle = COLOURS.counted + ctx.font = 'bold 24px ui-monospace, monospace' + ctx.fillText(panel, 24, 44) +} diff --git a/frontend/src/components/MassAutoAnnotateModal.jsx b/frontend/src/components/MassAutoAnnotateModal.jsx index 9d1f657..0a67459 100644 --- a/frontend/src/components/MassAutoAnnotateModal.jsx +++ b/frontend/src/components/MassAutoAnnotateModal.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { api } from '../api' -import { PreviewShapes } from './AutoAnnotateModal' +import { PreviewShapes } from './PreviewShapes' const ENGINES = [ { id: 'sam3', label: 'SAM3' }, diff --git a/frontend/src/components/PreviewShapes.jsx b/frontend/src/components/PreviewShapes.jsx new file mode 100644 index 0000000..ad332c3 --- /dev/null +++ b/frontend/src/components/PreviewShapes.jsx @@ -0,0 +1,64 @@ +import React from 'react' + +// Shared with MassAutoAnnotateModal: draws detection boxes/polygons over a +// frame in a 0..10000 viewBox. +export function PreviewShapes({ shapes, project }) { + return shapes.map((shape, i) => { + if (!shape.geometry || !shape.geometry.points) return null + const classObj = project.classes.find(c => c.class_id === shape.class_id) + const className = classObj?.name || 'Unknown' + const color = ['#38bdf8', '#34d399', '#f472b6', '#a78bfa', '#fbbf24'][shape.class_id % 5] || '#fff' + + let minX = 1, minY = 1, maxX = 0, maxY = 0 + if (shape.geometry.type === 'bbox') { + const [left, top, right, bottom] = shape.geometry.points + minX = left; minY = top; maxX = right; maxY = bottom; + } else { + shape.geometry.points.forEach(pt => { + if (pt[0] < minX) minX = pt[0] + if (pt[1] < minY) minY = pt[1] + if (pt[0] > maxX) maxX = pt[0] + if (pt[1] > maxY) maxY = pt[1] + }) + } + + const x0 = minX * 10000 + const y0 = minY * 10000 + const bw = (maxX - minX) * 10000 + const bh = (maxY - minY) * 10000 + + return ( + + {shape.geometry.type === 'polygon' && ( + `${pt[0] * 10000},${pt[1] * 10000}`).join(' ')} + fill={color} + fillOpacity={0.35} + stroke={color} + strokeWidth="10" + /> + )} + + 300 ? y0 - 100 : y0 + 300} + fill={color} + fontSize="240" + fontWeight="bold" + style={{ textShadow: '10px 10px 10px #000, -10px -10px 10px #000, 10px -10px 10px #000, -10px 10px 10px #000' }} + > + {className} {shape.score ? `${(shape.score * 100).toFixed(1)}%` : ''} + + + ) + }) +} diff --git a/frontend/src/components/ReviewSidebar.jsx b/frontend/src/components/ReviewSidebar.jsx index 2184d91..8551c48 100644 --- a/frontend/src/components/ReviewSidebar.jsx +++ b/frontend/src/components/ReviewSidebar.jsx @@ -4,6 +4,7 @@ import { TrashIcon } from './Icons' import ShortcutsPanel from './ShortcutsPanel' export default function ReviewSidebar({ + exemplarPanel, classesList, activeClass, reclass, @@ -18,6 +19,7 @@ export default function ReviewSidebar({ }) { return (