diff --git a/backend/api/batches.py b/backend/api/batches.py index 392ec0e..8ae4ee0 100644 --- a/backend/api/batches.py +++ b/backend/api/batches.py @@ -35,6 +35,7 @@ class AutolabelRequest(BaseModel): resume: bool = False append: bool = False custom_model_path: Optional[str] = None + class_params: Optional[dict[str, dict[str, float]]] = None class Exemplar(BaseModel): box: list[float] # [cx, cy, w, h], normalized 0..1 @@ -53,6 +54,7 @@ class PreviewRequest(BaseModel): # Preview-only — `autolabel.start` deliberately has no equivalent. exemplars: Optional[list[Exemplar]] = None exemplar_class_name: Optional[str] = None + class_params: Optional[dict[str, dict[str, float]]] = None @router.post("/api/projects/{project_id}/batches") @@ -116,7 +118,8 @@ def start_autolabel(batch_id: int, request: AutolabelRequest) -> dict: engine=request.engine, engines=engine_list, class_ids=request.class_ids, engine_classes=request.engine_classes, target_class_names=request.target_class_names, - custom_model_path=request.custom_model_path) + custom_model_path=request.custom_model_path, + class_params=request.class_params) except batch_store.BatchError as exc: raise HTTPException(400, str(exc)) @router.post("/api/batches/inspect-model") @@ -189,6 +192,7 @@ def preview_autolabel(batch_id: int, request: PreviewRequest) -> dict: custom_model_path=request.custom_model_path, exemplars=[e.model_dump() for e in request.exemplars or []], exemplar_class_name=request.exemplar_class_name, + class_params=request.class_params, ) return {"shapes": shapes} except Exception as exc: diff --git a/backend/archive_index.py b/backend/archive_index.py index 8553b5f..96469f2 100644 --- a/backend/archive_index.py +++ b/backend/archive_index.py @@ -167,7 +167,14 @@ def cycles(project_id: int) -> List[dict]: buckets: dict = {} for day in library.list_dates(project["video_root"]): - for name in _video_names(project, day["date"]): + names = _video_names(project, day["date"]) + if not names: + # Folder still holds no recording: it must still appear in the + # list, or a folder just created with "Folder baru" is invisible. + buckets.setdefault(day["date"], {"cycle": day["date"], "video_count": 0, + "flagged": 0, "first_start": None}) + continue + for name in names: rel = f"{day['date']}/{name}" timing = _sidecar(project, rel) or known.get(rel) or {} cycle = timing.get("working_day") or day["date"] diff --git a/backend/autolabel.py b/backend/autolabel.py index 0324e8a..99b989b 100644 --- a/backend/autolabel.py +++ b/backend/autolabel.py @@ -17,6 +17,26 @@ DEFAULT_THRESHOLD = 0.35 DEFAULT_IOU = 0.0 +def _parse_class_params(raw) -> dict: + """Normalize `class_params` (REQ-181): lowercased class name → overrides. + + Unknown keys and non-numeric values are dropped, so a malformed payload + degrades to the global values instead of failing the job.""" + out: dict = {} + for name, values in (raw or {}).items(): + if not isinstance(values, dict): + continue + entry = {} + for key in ("threshold", "iou_threshold", "min_box_frac"): + if key in values: + try: + entry[key] = float(values[key]) + except (TypeError, ValueError): + pass + if entry: + out[str(name).strip().lower()] = entry + return out + def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD, @@ -26,7 +46,8 @@ def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD, class_ids: Optional[List[int]] = None, engine_classes: Optional[dict[str, List[str]]] = None, custom_model_path: Optional[str] = None, - target_class_names: Optional[List[str]] = None) -> dict: + target_class_names: Optional[List[str]] = None, + class_params: Optional[dict] = None) -> dict: batch = batches.get(batch_id) if batch is None: raise batches.BatchError("No such batch") @@ -41,7 +62,8 @@ def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD, "iou_threshold": iou_threshold, "min_box_frac": min_box_frac, "resume": resume, "append": append, "engine": active_engines[0], "engines": active_engines, "class_ids": class_ids, "engine_classes": engine_classes, - "custom_model_path": custom_model_path, "target_class_names": target_class_names}, + "custom_model_path": custom_model_path, "target_class_names": target_class_names, + "class_params": class_params}, project_id=batch["project_id"], batch_id=batch_id, message=f"{batch['date_label']}/{batch['batch_label']} ({'+'.join(e.upper() for e in active_engines)})", @@ -85,6 +107,10 @@ def _run_autolabel(job) -> None: conf = job.params.get("threshold", DEFAULT_THRESHOLD) iou_thresh = job.params.get("iou_threshold", DEFAULT_IOU) + per_class = _parse_class_params(job.params.get("class_params")) + # Predict at the LOWEST threshold in play so a class with a lower override + # can still see its boxes; each box then passes its own class gate below. + predict_conf = min([conf] + [v["threshold"] for v in per_class.values() if "threshold" in v]) yolo_model = None sam3_target_classes = [] @@ -159,7 +185,7 @@ def _run_autolabel(job) -> None: all_raw_detections = [] if yolo_model is not None: - results = yolo_model.predict(frame_file, conf=conf, verbose=False) + results = yolo_model.predict(frame_file, conf=predict_conf, verbose=False) if results and len(results) > 0: model_names = results[0].names for box in results[0].boxes: @@ -189,6 +215,15 @@ def _run_autolabel(job) -> None: score = float(box.conf[0].item()) xyxyn = box.xyxyn[0].tolist() + + cp = per_class.get(proj_cls_name) or per_class.get(raw_cls_name) + if cp: + if score < cp.get("threshold", conf): + continue + mb = cp.get("min_box_frac") + if mb and mb > 0 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) < mb: + continue + all_raw_detections.append(labeling.Detection( class_id=target_class_id, class_name=proj_cls_name or raw_cls_name, @@ -199,9 +234,21 @@ def _run_autolabel(job) -> None: if selected_engine == "sam3" and sam3_target_classes: prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes] + thr_list = iou_list = mb_list = None + if per_class: + names = [c["name"].strip().lower() for c in sam3_target_classes] + if any("threshold" in v for v in per_class.values()): + thr_list = [per_class.get(n, {}).get("threshold", conf) for n in names] + if any("iou_threshold" in v for v in per_class.values()): + iou_list = [per_class.get(n, {}).get("iou_threshold", iou_thresh) for n in names] + if any("min_box_frac" in v for v in per_class.values()): + mb_list = [per_class.get(n, {}).get("min_box_frac", job.params.get("min_box_frac", 0.0)) for n in names] res = labeling.label_image( frame_file, frame["filename"], prompts, conf, - iou_threshold=iou_thresh, min_box_frac=job.params.get("min_box_frac", 0.0) + iou_threshold=iou_thresh, min_box_frac=job.params.get("min_box_frac", 0.0), + thresholds=thr_list, + iou_by_class=dict(enumerate(iou_list)) if iou_list else None, + min_box_fracs=mb_list, ) if not res.error and res.detections: for det in res.detections: @@ -213,7 +260,14 @@ def _run_autolabel(job) -> None: elif res.error: job.log(f"[SAM3 ERROR] {frame['filename']}: {res.error}") - kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh) + iou_by_class_proj = { + c["class_id"]: per_class[c["name"].strip().lower()]["iou_threshold"] + for c in project["classes"] + if c["name"].strip().lower() in per_class + and "iou_threshold" in per_class[c["name"].strip().lower()] + } + kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh, + iou_by_class=iou_by_class_proj or None) items = [] for det in kept: if project["label_type"] == "bbox" or det.mask is None: diff --git a/backend/labeling.py b/backend/labeling.py index 04f7563..606afc1 100644 --- a/backend/labeling.py +++ b/backend/labeling.py @@ -10,7 +10,7 @@ calls — see the domain invariants in `../AGENTS.md`. """ from dataclasses import dataclass, field -from typing import List, Optional +from typing import Dict, List, Optional from PIL import Image @@ -41,9 +41,12 @@ def _iou(box_a: List[float], box_b: List[float]) -> float: return inter / union if union > 0 else 0.0 -def deduplicate(detections: List[Detection], iou_threshold: float = 0.8) -> List[Detection]: - """Greedy NMS per class: highest score wins within the SAME class.""" - if iou_threshold <= 0.0: +def deduplicate(detections: List[Detection], iou_threshold: float = 0.8, + iou_by_class: Optional[Dict[int, float]] = None) -> List[Detection]: + """Greedy NMS per class: highest score wins within the SAME class. + + `iou_by_class` overrides the threshold per class id (REQ-181).""" + if not iou_by_class and iou_threshold <= 0.0: return detections by_class: dict[int, List[Detection]] = {} @@ -52,10 +55,14 @@ def deduplicate(detections: List[Detection], iou_threshold: float = 0.8) -> List by_class.setdefault(det.class_id, []).append(det) kept: List[Detection] = [] - for cls_dets in by_class.values(): + for cls, cls_dets in by_class.items(): + iou = (iou_by_class or {}).get(cls, iou_threshold) + if iou <= 0.0: + kept.extend(cls_dets) + continue cls_kept: List[Detection] = [] for det in sorted(cls_dets, key=lambda d: d.score, reverse=True): - if all(_iou(det.box, k.box) < iou_threshold for k in cls_kept): + if all(_iou(det.box, k.box) < iou for k in cls_kept): cls_kept.append(det) kept.extend(cls_kept) return kept @@ -70,11 +77,16 @@ def label_image( min_box_frac: float = 0.0, exemplar_index: int = -1, exemplars: Optional[List[dict]] = None, + thresholds: Optional[List[float]] = None, + iou_by_class: Optional[Dict[int, float]] = None, + min_box_fracs: Optional[List[float]] = None, ) -> ImageResult: """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.""" + as drawn box exemplars (REQ-172); every other prompt runs on text alone. + `thresholds`, `iou_by_class` and `min_box_fracs` are per-prompt overrides + aligned with `prompts` (REQ-181); classes without one use the global values.""" try: image = Image.open(image_path).convert("RGB") except Exception as exc: # unreadable/corrupt frame: report, don't abort the job @@ -84,14 +96,24 @@ def label_image( try: if exemplars and 0 <= exemplar_index < len(prompts): detections = get_engine().detect_with_exemplars( - image, prompts, threshold, exemplar_index, exemplars + image, prompts, threshold, exemplar_index, exemplars, + thresholds=thresholds ) else: - detections = get_engine().detect(image, prompts, threshold) + detections = get_engine().detect(image, prompts, threshold, + thresholds=thresholds) except Exception as exc: return ImageResult(image_path, rel_path, width, height, error=str(exc)) - if min_box_frac > 0: + if min_box_fracs is not None: + def _keep(det) -> bool: + frac = min_box_fracs[det.class_id] if det.class_id < len(min_box_fracs) else min_box_frac + if frac <= 0: + return True + floor = width * height * frac + return (det.box[2] - det.box[0]) * (det.box[3] - det.box[1]) >= floor + detections = [d for d in detections if _keep(d)] + elif min_box_frac > 0: floor = width * height * min_box_frac detections = [ d for d in detections @@ -99,4 +121,4 @@ def label_image( ] return ImageResult(image_path, rel_path, width, height, - deduplicate(detections, iou_threshold)) + deduplicate(detections, iou_threshold, iou_by_class=iou_by_class)) diff --git a/backend/preview.py b/backend/preview.py index 70430bb..3078f1d 100644 --- a/backend/preview.py +++ b/backend/preview.py @@ -12,7 +12,7 @@ import os from typing import List, Optional from backend import batches, db, labeling, projects, review -from backend.autolabel import DEFAULT_IOU, DEFAULT_THRESHOLD, _geometries +from backend.autolabel import DEFAULT_IOU, DEFAULT_THRESHOLD, _geometries, _parse_class_params def preview_frame( batch_id: int, @@ -25,6 +25,7 @@ def preview_frame( custom_model_path: Optional[str] = None, exemplars: Optional[List[dict]] = None, exemplar_class_name: Optional[str] = None, + class_params: Optional[dict] = None, ) -> List[dict]: batch = batches.get(batch_id) if not batch: @@ -69,11 +70,13 @@ def preview_frame( 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 + per_class = _parse_class_params(class_params) + predict_conf = min([threshold] + [v["threshold"] for v in per_class.values() if "threshold" in v]) all_raw_detections = [] if yolo_model is not None: - results = yolo_model.predict(frame_file, conf=threshold, verbose=False) + results = yolo_model.predict(frame_file, conf=predict_conf, verbose=False) if results and len(results) > 0: model_names = results[0].names for box in results[0].boxes: @@ -103,6 +106,15 @@ def preview_frame( score = float(box.conf[0].item()) xyxyn = box.xyxyn[0].tolist() + + cp = per_class.get(proj_cls_name) or per_class.get(raw_cls_name) + if cp: + if score < cp.get("threshold", threshold): + continue + mb = cp.get("min_box_frac") + if mb and mb > 0 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) < mb: + continue + all_raw_detections.append(labeling.Detection( class_id=target_class_id, class_name=proj_cls_name or raw_cls_name, @@ -125,10 +137,22 @@ def preview_frame( if c["name"].strip().lower() == wanted), -1, ) + thr_list = iou_list = mb_list = None + if per_class: + names = [c["name"].strip().lower() for c in sam3_target_classes] + if any("threshold" in v for v in per_class.values()): + thr_list = [per_class.get(n, {}).get("threshold", threshold) for n in names] + if any("iou_threshold" in v for v in per_class.values()): + iou_list = [per_class.get(n, {}).get("iou_threshold", iou_threshold) for n in names] + if any("min_box_frac" in v for v in per_class.values()): + mb_list = [per_class.get(n, {}).get("min_box_frac", min_box_frac) for n in names] 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 + exemplar_index=exemplar_index, exemplars=exemplars, + thresholds=thr_list, + iou_by_class=dict(enumerate(iou_list)) if iou_list else None, + min_box_fracs=mb_list, ) if not res.error and res.detections: for det in res.detections: @@ -138,7 +162,14 @@ def preview_frame( det.class_name = real_cls["name"] all_raw_detections.append(det) - kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold) + iou_by_class_proj = { + c["class_id"]: per_class[c["name"].strip().lower()]["iou_threshold"] + for c in project["classes"] + if c["name"].strip().lower() in per_class + and "iou_threshold" in per_class[c["name"].strip().lower()] + } + kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold, + iou_by_class=iou_by_class_proj or None) items = [] for det in kept: if project["label_type"] == "bbox" or det.mask is None: diff --git a/backend/sam3_engine.py b/backend/sam3_engine.py index d4e060f..c88fa3c 100644 --- a/backend/sam3_engine.py +++ b/backend/sam3_engine.py @@ -67,8 +67,12 @@ class Sam3Engine: ) self.processor = Sam3Processor(self.model, device=self.device) - def detect(self, image: Image.Image, prompts: List[str], threshold: float) -> List[Detection]: - """Run every prompt against one image; prompt index becomes the class id.""" + def detect(self, image: Image.Image, prompts: List[str], threshold: float, + thresholds: Optional[List[float]] = None) -> List[Detection]: + """Run every prompt against one image; prompt index becomes the class id. + + `thresholds` overrides the confidence per prompt (REQ-181); still one + `set_image` for the whole call — only the grounding head sees the change.""" processor = Sam3Processor(self.model, device=self.device) processor.confidence_threshold = threshold @@ -76,6 +80,8 @@ class Sam3Engine: with torch.autocast(self.device, dtype=self.autocast_dtype): state = processor.set_image(image) for class_id, prompt in enumerate(prompts): + if thresholds is not None and class_id < len(thresholds): + processor.confidence_threshold = thresholds[class_id] output = processor.set_text_prompt(prompt=prompt, state=state) masks, boxes, scores = output["masks"], output["boxes"], output["scores"] if masks.shape[0] == 0: @@ -110,6 +116,7 @@ class Sam3Engine: threshold: float, exemplar_index: int, exemplars: List[dict], + thresholds: Optional[List[float]] = None, ) -> List[Detection]: """`detect()`, but one prompt also carries drawn box exemplars (REQ-172). @@ -124,6 +131,8 @@ class Sam3Engine: with torch.autocast(self.device, dtype=self.autocast_dtype): state = processor.set_image(image) for class_id, prompt in enumerate(prompts): + if thresholds is not None and class_id < len(thresholds): + processor.confidence_threshold = thresholds[class_id] processor.reset_all_prompts(state) output = processor.set_text_prompt(prompt=prompt, state=state) if class_id == exemplar_index: diff --git a/docs/design.md b/docs/design.md index 820ade7..cba1c36 100644 --- a/docs/design.md +++ b/docs/design.md @@ -174,11 +174,14 @@ GET /api/projects/{id}/video?rel=… # Range streaming (REQ-013) POST /api/projects/{id}/batches # {rel, start_sec, end_sec, fps} → extract job 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}/autolabel # {threshold, class_params} → job (REQ-030,032,034,181) POST /api/batches/{id}/preview # one frame, run now, nothing written; + # +class_params {name: {threshold?, iou_threshold?, min_box_frac?}} + # empty override = global value, keys are class names (REQ-181) # +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) +DELETE /api/batches/{id}/classes/{class_id}/annotations # clear all shapes of class in batch (REQ-046); + # frame-scoped clear = client filters this frame → bulk-delete (REQ-180) 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) GET /api/batches/{ids}/triage/shapes @@ -190,6 +193,7 @@ GET /api/frames/{id}/annotations POST /api/frames/{id}/annotations # add a manual shape (REQ-042) PATCH /api/annotations/{id} # move/resize/reclass DELETE /api/annotations/{id} +POST /api/annotations/bulk-delete # {ids[]} → N deletes (REQ-180 frame-clear uses it) 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) @@ -238,7 +242,13 @@ updated. Range and fps live on the batch, so one video can be used repeatedly. **autolabel (REQ-030…034).** Per frame: one `set_image`, then loop each class's prompt (see the domain invariants in `../AGENTS.md`), cross-prompt NMS, write `annotations` rows with -`source='auto'`. A re-run deletes only `source='auto'` rows — manual corrections +`source='auto'`. `class_params` (REQ-181) is consulted per class: a numeric +`threshold` / `iou_threshold` / `min_box_frac` entry replaces the job's global for that class +only; classes without an entry — and a run with `class_params` empty or absent — take the +exact global path. The YOLO confidence floor uses `min([global] + overrides)`, the SAM3 +threshold/dedup/min-box lists are built only from classes that actually override that key, +and `/preview` applies the same overrides so the preview and the job cannot disagree. +A re-run deletes only `source='auto'` rows — manual corrections (`source='manual'`) survive — and returns already-approved frames to `pending`, because that approval was given against labels that no longer exist. No overlay images are written: the review canvas draws the shapes from the annotation rows, so a second rendering of the diff --git a/docs/requirements.md b/docs/requirements.md index b7aa292..8aa7d32 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -74,7 +74,8 @@ changes. - **REQ-178** — The Video Archive page can create a `YYYY-MM-DD` folder and upload videos into it. Uploads are streamed to disk, never overwrite an existing file, and only accept - `video.VIDEO_EXTS`. + `video.VIDEO_EXTS`. The new folder appears in the archive's cycle list immediately, even + before it holds any video. - **REQ-179** — The Video Archive page shows a button that copies the archive's host path to the clipboard, as a Linux path and as a Windows (`\\wsl.localhost\\...`) path when known. @@ -97,6 +98,9 @@ changes. - **REQ-031** — Detections that overlap across prompts are deduplicated (greedy IoU NMS), so one object is not labelled as two classes at once. - **REQ-032** — The confidence threshold is configurable per job. +- **REQ-181** — Auto-annotation accepts per-class overrides of confidence, IoU and minimum box + fraction on top of the job's global values; classes without an override use the global + values. Preview and the job apply the same overrides. - **REQ-033** — A frame with no detections is valid and still enters the dataset as a negative sample — it is not a failure. - **REQ-034** — Auto-annotation can be re-run on the same batch; previous automatic results @@ -167,6 +171,9 @@ changes. auto-annotate modal, and reset with the frame. Defaults are confidence `0.5`, NMS `0.8`, min box `0.002`, max `100` — deliberately permissive, because on a dense frame an aggressive NMS or area floor deletes real, touching objects rather than duplicates. +- **REQ-180** — In the manual review editor, each class row can clear that class's shapes on + the **current frame only** (REQ-046 stays batch-wide). One click, no confirmation dialog; + the next frame is untouched. ## E4. Live counting preview diff --git a/docs/tasks.md b/docs/tasks.md index 2c69833..559973a 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1216,6 +1216,19 @@ failure modes to the counting path. The saving was always on the browser side. `vite build` passes (66 modules). Browser click-test of copy buttons/upload dialog is NOT automated — manual click-test pending. +## Task — Empty date folder visible in cycle list (REQ-178) `[DONE]` + +1. `archive_index.cycles()` must return a bucket for a date folder with no videos → + verify: **[DONE]** `POST .../library/dates?date=2099-01-01` → 200; then + `GET .../archive/cycles` contains `2099-01-01` with `video_count: 0` (and the + pre-existing empty folder `2026-08-31`, previously invisible, also appears); + `GET .../archive/cycles/2099-01-01` returns `videos: []`; the same JSON arrives + through the dev proxy on 5173; folder removed after verification → cycle gone from + the list again; `uvx ruff check` output identical to HEAD (13 pre-existing, 0 new); + `vite build` passes. Browser click-test is NOT automated — manual click-test pending. + Docker backend rebuilt after the fix: `:9010/.../archive/cycles` now returns the + empty folders `2026-08-29` and `2026-08-31` with `video_count: 0`. + ## Task — Copy-path buttons (REQ-179) `[DONE]` 1. Copy-path buttons → verify: **[DONE]** project payload carries @@ -1229,6 +1242,32 @@ failure modes to the counting path. The saving was always on the browser side. nginx `client_max_body_size 20g` verified (600MB upload → 200); `/api/health` 200, `/docs` 200, frontend `http://localhost:9000` 200 after rebuild. +## Task — Frame-scoped per-class clear in review editor (REQ-180) `[DONE]` + +1. `×` button on each sidebar class row clears that class's shapes on the **current frame + only**, no confirmation, batch-wide trash (REQ-046) unchanged → + verify: **[DONE]** `ReviewPage.clearClassInFrame` filters the current frame's annotations + and posts only those ids to `POST /annotations/bulk-delete`; round-trip on `:9010` — + created 2 shapes on frame 23827, bulk-delete returned `{"deleted":2}`, frame back to + `annotations: []`, DB clean after test; optimistic update + rollback wired the same way as + `removeMarked`; `vite build` passes (68 modules), rebuilt image on `:9000` serves the new + bundle (`index-wBeBdXus.js`). Browser click-test of the `×` button is NOT automated — + manual click-test pending. + +## Task — Per-class auto-annotate params (REQ-181) `[DONE]` + +1. `class_params` through preview + job on both modals → verify: **[DONE]** ruff on the 5 + changed backend files vs HEAD: +17, all `UP006`/`UP035`/`UP045` (the files' existing + style), 0 new real findings; on rebuilt backend `:9010` — `/preview` baseline (absent and + `null` `class_params`) → 18 shapes, `class_params: {sack: {threshold: 0.99}}` → 0 shapes, + `threshold: "high"` → 422 `float_parsing`; `/autolabel` accepted + `{class_params: {sack: {threshold: 0.9, min_box_frac: 0.01}}}`, job 106's stored params + carry it, `iou_threshold: "x"` → 422, job cancelled and test data reset + (`reset-auto-annotations`, batch 21 back to 0 annotations); omitted `class_params` takes + the unchanged global path; `vite build` passes, `:9000` serves the new bundle (marker + strings present). Browser click-test of the override tables is NOT automated — manual + click-test pending. + ## Known open points - *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 2063d42..1959c8b 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -449,6 +449,11 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable). - Confidence threshold — 0.05…0.95 step 0.05, default 0.35. - NMS IoU threshold — 0…0.9 step 0.05, default 0.0. - Min box size (fraction of frame) — 0…0.5 step 0.005, default 0, shown as a percentage. +- **Per-class overrides** (REQ-181) — a compact table below the sliders, one row per currently + selected class: `Conf` / `IoU` / `MinBox` number inputs. Each input's placeholder shows the + current global value and an empty input inherits it; only filled cells are sent, as + `class_params` on **both** the preview and the job request, so preview and run cannot + disagree. Classes without an override behave exactly as before. - `ClassPromptPanel`: - Target-class chips. Without SAM3 a chip is a simple toggle. - **With SAM3 a chip is two buttons**: the *name* selects-and-activates, the `×` deselects. @@ -470,6 +475,7 @@ preview is the answer, so no button press sits between them. Re-running is cheap ```json { "frame_id": 12, "engine": "sam3|base_model|custom", "threshold": 0.35, "iou_threshold": 0.0, "min_box_frac": 0.0, + "class_params": { "sack": { "threshold": 0.55 } }, "target_class_names": ["sack"], "custom_model_path": null, "exemplars": [ { "box": [cx, cy, w, h], "positive": true } ], @@ -485,10 +491,14 @@ the prompt, they are not labels. ```json { "resume": false, "append": true, "engine": "sam3", "threshold": 0.35, "iou_threshold": 0.0, "min_box_frac": 0.0, + "class_params": { "sack": { "threshold": 0.55, "min_box_frac": 0.01 } }, "target_class_names": ["sack", "half-sack"], "custom_model_path": null } ``` +`class_params` is `null`/omitted when no cell is filled — the job then runs the unchanged +global path (REQ-181). + #### 4.4.2 Mass Auto-Annotate modal Same anatomy, wider (1040 px), with three differences: @@ -501,6 +511,10 @@ Same anatomy, wider (1040 px), with three differences: must not abort the rest; the backend GPU lock serialises the real work anyway. Progress is reported as `done/total (failed)` and the result message is `Queued N auto-annotation job(s)`. +The per-class overrides table (REQ-181) appears here too, below the sliders, and its +`class_params` ride along on every preview and start request — one set of overrides applies +to all selected batches. + --- ### 4.5 Review — `#/projects/{id}/review?batch={id}` or `#/batches/{id}` @@ -1085,7 +1099,11 @@ network. - **Classes** — one row per class: a chip (swatch + name + its `1`-based hotkey) that sets the active class *and* reclasses the selected shape, plus a trash button that clears **every shape of that class across the whole batch** (`DELETE /batches/{id}/classes/{classId}/annotations`, - confirmed). + confirmed). Next to it sits an `×` button that clears that class **on the current frame only** + (REQ-180): one click, **no confirmation dialog**, it posts just this frame's shape ids of + that class to `POST /annotations/bulk-delete`, the row's count and the frame's badge update + optimistically with rollback on failure, and every other frame is untouched. The batch-wide + trash keeps its confirmation — that one is unrecoverable across the whole batch (REQ-046). - **Shapes on this frame (N)** — one row per annotation: swatch, class name, and either the score to 2 dp (auto) or the word `manual`; click selects, trash deletes. When empty: *"None on this frame — drag to draw a shape."* plus, if the batch has shapes elsewhere, a diff --git a/frontend/src/components/AutoAnnotateModal.jsx b/frontend/src/components/AutoAnnotateModal.jsx index 60f9305..644321e 100644 --- a/frontend/src/components/AutoAnnotateModal.jsx +++ b/frontend/src/components/AutoAnnotateModal.jsx @@ -3,19 +3,8 @@ 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) - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value) - }, delay) - return () => { - clearTimeout(handler) - } - }, [value, delay]) - return debouncedValue -} +import ClassParamsTable, { buildClassParams } from './ClassParamsTable' +import useDebounce from '../hooks/useDebounce' export default function AutoAnnotateModal({ batch, @@ -29,6 +18,7 @@ export default function AutoAnnotateModal({ const [threshold, setThreshold] = useState(0.35) const [iouThreshold, setIouThreshold] = useState(0.0) const [minBoxFrac, setMinBoxFrac] = useState(0.0) + const [classParams, setClassParams] = useState({}) // Class selection state @@ -97,6 +87,7 @@ export default function AutoAnnotateModal({ iou_threshold: iouThreshold, min_box_frac: minBoxFrac, target_class_names: selectedClasses, + class_params: buildClassParams(classParams), custom_model_path: customModelStagedPath, exemplars: engine === 'sam3' ? exemplars : [], exemplar_class_name: engine === 'sam3' ? activeClassName : null @@ -195,6 +186,7 @@ export default function AutoAnnotateModal({ iou_threshold: iouThreshold, min_box_frac: minBoxFrac, target_class_names: selectedClasses, + class_params: buildClassParams(classParams), custom_model_path: customModelStagedPath }) onSuccess() @@ -363,6 +355,13 @@ export default function AutoAnnotateModal({ /> +
+ Per-class overrides (empty = global): +
+ +
+
+ 0) out[name] = entry + } + return Object.keys(out).length > 0 ? out : undefined +} + +export default function ClassParamsTable({ classNames, globals, value, onChange }) { + const set = (name, key, raw) => + onChange({ ...value, [name]: { ...(value[name] || {}), [key]: raw } }) + if (!classNames.length) return null + return ( + + + + + {KEYS.map(k => ( + + ))} + + + + {classNames.map(name => ( + + + {KEYS.map(({ key, label, step, color }) => ( + + ))} + + ))} + +
Class{k.label}
{name} + set(name, key, e.target.value)} + style={{ + width: 64, fontSize: '0.76rem', background: '#09090b', color, + border: '1px solid #3f3f46', borderRadius: 4, padding: '2px 4px', + cursor: 'text', + }} + /> +
+ ) +} diff --git a/frontend/src/components/MassAutoAnnotateModal.jsx b/frontend/src/components/MassAutoAnnotateModal.jsx index 0a67459..db397da 100644 --- a/frontend/src/components/MassAutoAnnotateModal.jsx +++ b/frontend/src/components/MassAutoAnnotateModal.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { api } from '../api' import { PreviewShapes } from './PreviewShapes' +import ClassParamsTable, { buildClassParams } from './ClassParamsTable' const ENGINES = [ { id: 'sam3', label: 'SAM3' }, @@ -17,6 +18,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc const [threshold, setThreshold] = useState(0.35) const [iouThreshold, setIouThreshold] = useState(0.0) const [minBoxFrac, setMinBoxFrac] = useState(0.0) + const [classParams, setClassParams] = useState({}) const [append, setAppend] = useState(true) const available = engine === 'custom' ? customClasses : project.classes.map(c => c.name) @@ -81,6 +83,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc iou_threshold: iouThreshold, min_box_frac: minBoxFrac, target_class_names: selectedClasses, + class_params: buildClassParams(classParams), custom_model_path: customPath, }).then(res => setPreviewShapes(res.shapes || [])) .catch(exc => { setError(exc.message); setPreviewShapes([]) }) @@ -114,6 +117,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc iou_threshold: iouThreshold, min_box_frac: minBoxFrac, target_class_names: selectedClasses, + class_params: buildClassParams(classParams), custom_model_path: customPath, }) } catch { @@ -243,6 +247,13 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc `${(v * 100).toFixed(1)}%`} /> +
+ Per-class overrides (empty = global): +
+ +
+
+