diff --git a/.gitignore b/.gitignore index cdd765a..5b79240 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,6 @@ mlruns/ *.swo *~ docs/panduan_debug.html + +# agent workspaces +.superpowers/ diff --git a/backend/api/projects.py b/backend/api/projects.py index 7280596..75d8c2c 100644 --- a/backend/api/projects.py +++ b/backend/api/projects.py @@ -37,6 +37,7 @@ class ProjectPatch(BaseModel): prompts: Optional[Dict[int, str]] = None val_every: Optional[int] = None video_root: Optional[str] = None + containers: Optional[Dict[int, bool]] = None @router.get("") @@ -68,7 +69,8 @@ def patch_project(project_id: int, request: ProjectPatch) -> dict: try: return project_store.update(project_id, prompts=request.prompts, val_every=request.val_every, - video_root=request.video_root) + video_root=request.video_root, + containers=request.containers) except project_store.ProjectError as exc: raise HTTPException(400, str(exc)) diff --git a/backend/autolabel.py b/backend/autolabel.py index 99b989b..2ea2b73 100644 --- a/backend/autolabel.py +++ b/backend/autolabel.py @@ -166,6 +166,10 @@ def _run_autolabel(job) -> None: 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 + # REQ-184: classes marked container keep boxes that sit inside them. The + # prompt pass above still sees prompt-index ids, so it gets the index-aligned set. + container_ids = {c["class_id"] for c in project["classes"] if c.get("container")} + prompt_container_ids = {i for i, c in enumerate(sam3_target_classes) if c.get("container")} job.log(f"Starting auto-labeling with {selected_engine}...") @@ -249,6 +253,7 @@ def _run_autolabel(job) -> None: thresholds=thr_list, iou_by_class=dict(enumerate(iou_list)) if iou_list else None, min_box_fracs=mb_list, + container_ids=prompt_container_ids, ) if not res.error and res.detections: for det in res.detections: @@ -267,7 +272,8 @@ def _run_autolabel(job) -> None: 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) + iou_by_class=iou_by_class_proj or None, + container_ids=container_ids) items = [] for det in kept: if project["label_type"] == "bbox" or det.mask is None: diff --git a/backend/db.py b/backend/db.py index 14ddb74..517b348 100644 --- a/backend/db.py +++ b/backend/db.py @@ -262,6 +262,13 @@ def migrate() -> None: # REQ-110: augmentation settings, null until the user changes them. if "augment" not in cols: cur.execute("ALTER TABLE projects ADD COLUMN augment TEXT") + # REQ-184: a class flagged container keeps boxes that sit inside it. + cur.execute("PRAGMA table_info(project_classes)") + class_cols = [column[1] for column in cur.fetchall()] + if "container" not in class_cols: + cur.execute( + "ALTER TABLE project_classes ADD COLUMN container INTEGER NOT NULL DEFAULT 0" + ) # REQ-107: what rule set a run's numbers were measured under. cur.execute("PRAGMA table_info(model_versions)") version_cols = [column[1] for column in cur.fetchall()] diff --git a/backend/labeling.py b/backend/labeling.py index 606afc1..b1469e4 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 Dict, List, Optional +from typing import Dict, List, Optional, Set from PIL import Image @@ -41,11 +41,29 @@ 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, - iou_by_class: Optional[Dict[int, float]] = None) -> List[Detection]: - """Greedy NMS per class: highest score wins within the SAME class. +def _containment_fraction(box_a: List[float], box_b: List[float]) -> float: + """Intersection over the smaller box's area: how much of the smaller box + sits inside the other one (REQ-184's containment test).""" + ax0, ay0, ax1, ay1 = box_a + bx0, by0, bx1, by1 = box_b + inter_w = max(0.0, min(ax1, bx1) - max(ax0, bx0)) + inter_h = max(0.0, min(ay1, by1) - max(ay0, by0)) + area_a = max(0.0, ax1 - ax0) * max(0.0, ay1 - ay0) + area_b = max(0.0, bx1 - bx0) * max(0.0, by1 - by0) + small = min(area_a, area_b) + return inter_w * inter_h / small if small > 0 else 0.0 - `iou_by_class` overrides the threshold per class id (REQ-181).""" + +def deduplicate(detections: List[Detection], iou_threshold: float = 0.8, + iou_by_class: Optional[Dict[int, float]] = None, + container_ids: Optional[Set[int]] = None) -> List[Detection]: + """Greedy NMS: highest score wins within the SAME class, then across + classes (REQ-031) — unless the kept box is a container class and the + candidate is at least 90% inside it, which is containment, not overlap + (REQ-184). + + `iou_by_class` overrides the threshold per class id (REQ-181); + `container_ids` are the class ids marked container.""" if not iou_by_class and iou_threshold <= 0.0: return detections @@ -65,7 +83,26 @@ def deduplicate(detections: List[Detection], iou_threshold: float = 0.8, if all(_iou(det.box, k.box) < iou for k in cls_kept): cls_kept.append(det) kept.extend(cls_kept) - return kept + + # Cross-class pass (REQ-031), greedy score-desc. A pair whose resolved + # threshold is <= 0 is never suppressed — zero means NMS off for that + # class, mirroring the within-class pass above. + cross_kept: List[Detection] = [] + for det in sorted(kept, key=lambda d: d.score, reverse=True): + drop = False + for k in cross_kept: + if det.class_id == k.class_id: + continue + if (container_ids and k.class_id in container_ids + and _containment_fraction(det.box, k.box) >= 0.9): + continue + iou = (iou_by_class or {}).get(det.class_id, iou_threshold) + if iou > 0.0 and _iou(det.box, k.box) >= iou: + drop = True + break + if not drop: + cross_kept.append(det) + return cross_kept def label_image( @@ -80,13 +117,16 @@ def label_image( thresholds: Optional[List[float]] = None, iou_by_class: Optional[Dict[int, float]] = None, min_box_fracs: Optional[List[float]] = None, + container_ids: Optional[Set[int]] = 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. `thresholds`, `iou_by_class` and `min_box_fracs` are per-prompt overrides - aligned with `prompts` (REQ-181); classes without one use the global values.""" + aligned with `prompts` (REQ-181); classes without one use the global values. + `container_ids` are prompt indices marked container (REQ-184) — the caller + maps them, because detections still carry prompt-index class ids here.""" try: image = Image.open(image_path).convert("RGB") except Exception as exc: # unreadable/corrupt frame: report, don't abort the job @@ -121,4 +161,5 @@ def label_image( ] return ImageResult(image_path, rel_path, width, height, - deduplicate(detections, iou_threshold, iou_by_class=iou_by_class)) + deduplicate(detections, iou_threshold, iou_by_class=iou_by_class, + container_ids=container_ids)) diff --git a/backend/preview.py b/backend/preview.py index 3078f1d..39ab4d3 100644 --- a/backend/preview.py +++ b/backend/preview.py @@ -72,6 +72,10 @@ def preview_frame( 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]) + # REQ-184: classes marked container keep boxes that sit inside them. The + # prompt pass below still sees prompt-index ids, so it gets the index-aligned set. + container_ids = {c["class_id"] for c in project["classes"] if c.get("container")} + prompt_container_ids = {i for i, c in enumerate(sam3_target_classes) if c.get("container")} all_raw_detections = [] @@ -153,6 +157,7 @@ def preview_frame( thresholds=thr_list, iou_by_class=dict(enumerate(iou_list)) if iou_list else None, min_box_fracs=mb_list, + container_ids=prompt_container_ids, ) if not res.error and res.detections: for det in res.detections: @@ -169,7 +174,8 @@ def preview_frame( 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) + iou_by_class=iou_by_class_proj or None, + container_ids=container_ids) items = [] for det in kept: if project["label_type"] == "bbox" or det.mask is None: diff --git a/backend/projects.py b/backend/projects.py index 89a96e1..79333e6 100644 --- a/backend/projects.py +++ b/backend/projects.py @@ -13,7 +13,7 @@ import os import re import shutil import time -from typing import List, Optional +from typing import Dict, List, Optional from backend import config, db @@ -124,15 +124,17 @@ def create(name: str, label_type: str, video_root: str, classes: Optional[List[d def _write_classes(cur, project_id: int, classes: List[dict]) -> None: cur.execute("DELETE FROM project_classes WHERE project_id = ?", (project_id,)) cur.executemany( - "INSERT INTO project_classes (project_id, class_id, name, prompt) VALUES (?, ?, ?, ?)", - [(project_id, index, item["name"], item["prompt"]) + "INSERT INTO project_classes (project_id, class_id, name, prompt, container) " + "VALUES (?, ?, ?, ?, ?)", + [(project_id, index, item["name"], item["prompt"], item.get("container", 0)) for index, item in enumerate(classes)], ) def _row_to_dict(cur, row) -> dict: cur.execute( - "SELECT class_id, name, prompt FROM project_classes WHERE project_id = ? ORDER BY class_id", + "SELECT class_id, name, prompt, container FROM project_classes " + "WHERE project_id = ? ORDER BY class_id", (row["id"],), ) classes = [dict(item) for item in cur.fetchall()] @@ -205,9 +207,10 @@ def listing() -> List[dict]: def update(project_id: int, prompts: Optional[dict] = None, val_every: Optional[int] = None, - video_root: Optional[str] = None) -> dict: - """Edit the things that are safe to change: prompts, split ratio, archive root. - Class names and label type are not among them.""" + video_root: Optional[str] = None, + containers: Optional[Dict[int, bool]] = None) -> dict: + """Edit the things that are safe to change: prompts, container flags, + split ratio, archive root. Class names and label type are not among them.""" if get(project_id) is None: raise ProjectError("No such project") @@ -226,6 +229,11 @@ def update(project_id: int, prompts: Optional[dict] = None, val_every: Optional[ "UPDATE project_classes SET prompt = ? WHERE project_id = ? AND class_id = ?", (str(prompt).strip(), project_id, int(class_id)), ) + for class_id, flag in (containers or {}).items(): + cur.execute( + "UPDATE project_classes SET container = ? WHERE project_id = ? AND class_id = ?", + (1 if flag else 0, project_id, int(class_id)), + ) return get(project_id) @@ -344,9 +352,7 @@ def set_base_model(project_id: int, weights_path: str) -> dict: "UPDATE projects SET base_model_path = ?, base_model_kind = 'uploaded' WHERE id = ?", (stored, project_id), ) - _write_classes(cur, project_id, - [{"name": n, "prompt": p} - for n, p in zip(names, _kept_prompts(project, names))]) + _write_classes(cur, project_id, _kept_classes(project, names)) return get(project_id) @@ -372,11 +378,15 @@ def set_secondary_model(project_id: int, weights_path: str, name: str = "") -> d return get(project_id) -def _kept_prompts(project: dict, names: List[str]) -> List[str]: - """Keep the prompt the user already wrote for a class that survives a - base-model swap; fall back to the class name for new ones.""" - known = {item["name"]: item["prompt"] for item in project["classes"]} - return [known.get(name, name) for name in names] +def _kept_classes(project: dict, names: List[str]) -> List[dict]: + """Keep the stored per-class attributes — prompt (REQ-171) and container + flag (REQ-184) — for a class that survives a base-model swap; new classes + fall back to the class name as prompt and no flag.""" + known = {item["name"]: item for item in project["classes"]} + return [{"name": name, + "prompt": known[name]["prompt"] if name in known else name, + "container": known[name].get("container", 0) if name in known else 0} + for name in names] def training_start_point(project: dict) -> str: diff --git a/docs/design.md b/docs/design.md index cba1c36..3938ea6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -57,6 +57,7 @@ projects( project_classes( id, project_id → projects, class_id INT, name, prompt, + container INTEGER NOT NULL DEFAULT 0, -- container class flag (REQ-184) UNIQUE(project_id, class_id)) -- class_id = the YOLO class index (REQ-003/005) batches( @@ -112,7 +113,7 @@ box. | Module | Role | Status | |---|---|---| | `sam3_engine.py` | SAM3 singleton, `open_state`/`apply_prompts`/`segment_at` | reused, plus a `release()` for REQ-065 | -| `labeling.py` | per-frame detection + cross-prompt NMS (REQ-031) | reused; the folder-walking half went with the old flow | +| `labeling.py` | per-frame detection + cross-class greedy NMS with per-class IoU override and the container containment carve-out (REQ-031, REQ-184) | reused; the folder-walking half went with the old flow | | `exporters.py` | ~~YOLO label writing~~ | **deleted** — `dataset.py` writes labels, `mask_to_polygons` moved to `review.py` | | `sessions.py` | ~~exemplar/tap interaction~~ | **deleted** — see below | | `jobs.py` | single-worker queue | extended: job types + persistence | @@ -158,7 +159,7 @@ GET /api/projects REQ-001 POST /api/projects REQ-001,002,004,005 GET /api/projects/{id} # includes label_type_locked: bool (REQ-002), video_root_linux/video_root_windows (REQ-179) DELETE /api/projects/{id} -PATCH /api/projects/{id} # class prompts, val_every (REQ-005) +PATCH /api/projects/{id} # { prompts: {classId: text}, containers: {classId: bool} }, val_every (REQ-005, REQ-184) POST /api/projects/{id}/classes # add new class {name, prompt} (REQ-008) DELETE /api/projects/{id}/classes/{class_id} # delete class, delete shapes, reindex classes (REQ-007) POST /api/projects/{id}/base-model # upload .pt, read classes (REQ-003) @@ -178,8 +179,9 @@ POST /api/batches/{id}/autolabel # {threshold, class_params} → 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) + # +exemplars[] {box:[cx,cy,w,h], positive} — the touched class's own pool only + # +exemplar_class_name, +target_class_names; per-class, accumulating + # on the client (REQ-171,172,182) 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) @@ -241,13 +243,16 @@ frames/%06d.jpg`. `frames` rows are written once the files exist; the batch's fr 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 +the domain invariants in `../AGENTS.md`), cross-class greedy NMS (REQ-031), write `annotations` rows with `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. +The job and `/preview` also read `project_classes.container` per class when building the +container-id set for the NMS containment carve-out — same stored flag for both (REQ-184, +amended REQ-031). 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: @@ -326,7 +331,7 @@ Pages: The canvas editor is hand-written; the normalized-coordinate conventions already exist in `sessions.py` (`annotations_payload`, `detections_payload`) as a reference. -**Auto-annotate modal (REQ-171, REQ-172).** `AutoAnnotateModal.jsx` splits into +**Auto-annotate modal (REQ-171, REQ-172, REQ-182, REQ-184).** `AutoAnnotateModal.jsx` splits into `PreviewShapes.jsx` (the result overlay, shared with the mass modal), `ClassPromptPanel.jsx` (class chips + the editable SAM3 prompt) and `ExemplarCanvas.jsx` (the drag-to-draw layer). All three overlays and the `` share one shrink-wrapped @@ -335,9 +340,28 @@ every box shifts off the pixels it describes. With SAM3 one selected chip is *active*: it owns the prompt field and any exemplars drawn on the frame, so its chip is a pair of buttons — the name activates, the `×` deselects. -Exemplars are normalized `[cx, cy, w, h]`, sent only to `/preview`, and dropped whenever the -frame or the active class changes. A redraw is debounced 250 ms and re-runs the whole prompt -set from empty, which is also how undo works — SAM3 can only append geometric prompts. +Exemplars are normalized `[cx, cy, w, h]`, one pool per class keyed by class name +(`hooks/useExemplarPools.js`), sent only to `/preview`, and dropped when the frame changes — +switching the active class swaps pools instead of discarding them (REQ-172). The preview is +per class and accumulates (REQ-182): exemplar changes share **one 250 ms debounce** that +re-runs the most recently touched class against its own pool and replaces only that class's +shapes — rapid alternation between classes re-runs only the last one — while the other +exemplared classes' results stay on screen and the canvas renders the union of every class +with a non-empty pool. Undo is the same re-run with the shortened pool, because SAM3 can +only append geometric prompts. Clearing the last example sends no request and restores the +plain full-set preview (the session's last full-set run, blank if none ran yet); +Run Preview with examples runs the exemplared classes +sequentially and merges, without examples it is the unchanged full-set request. The +per-class overrides table also carries a **Container** checkbox (REQ-184) that toggles +`project_classes.container` through `PATCH /api/projects/{id} { containers: … }`. + +**Review sidebar class rows (REQ-180, REQ-183).** Each class row's frame-clear `×` (REQ-180) +gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it survives +frame changes, resets when the review page is left, and never touches stored data. Hiding a +class filters it out of the `annotations` array handed to `AnnotationCanvas` and to the +"Shapes on this frame" list, and a purge effect drops hidden shapes from the marked and +selected ids — so hidden shapes are not selectable, not marquee-selectable, not deletable — +while the row keeps its real per-frame count. **Exemplar-driven labeling in review (REQ-173, REQ-174, REQ-175).** In `draw` mode a drag on `AnnotationCanvas` is an exemplar, not a rectangle: `onExemplar(box, positive)` where diff --git a/docs/requirements.md b/docs/requirements.md index 8aa7d32..0199f7f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -95,8 +95,11 @@ changes. - **REQ-030** — Once frames are extracted, the system runs SAM3 over all of them using each class's prompt, as a background job with progress and cancellation. -- **REQ-031** — Detections that overlap across prompts are deduplicated (greedy IoU NMS), so - one object is not labelled as two classes at once. +- **REQ-031** — Detections that overlap across prompts are deduplicated (greedy IoU NMS + across classes, threshold = the lower-scoring detection's class IoU override if set, + else the job's global IoU), so one object is not labelled as two classes at once. A + detection contained inside a higher-scoring box of a class marked container (REQ-184) + is never removed by that box — containment is not overlap. - **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 @@ -122,8 +125,16 @@ changes. re-run immediately, and can be undone or cleared. Exemplars are a **tuning aid only**: they are never written as annotations and never carried into the batch job, because SAM3's geometric prompts pool features from the current image — replaying them on another frame - would ask about whatever happens to sit at those coordinates there. They belong to exactly - one class, so a new frame or a new active class discards them. + would ask about whatever happens to sit at those coordinates there. Each class keeps its own + pool on the current frame: switching the active class shows that class's pool instead of + discarding the others, and a new frame discards them all. +- **REQ-182** — The auto-annotate preview is **per class and accumulates**. While at least one + class has example boxes on the previewed frame, the preview shows only the classes that have + examples, each conditioned on its own pool; re-drawing one class's example refreshes only + that class's predictions and leaves the other exemplared classes' results on screen. Classes + without examples contribute nothing while any example exists, and clearing the last example + restores the plain full-set preview. The preview and the batch job still use the same + thresholds and per-class overrides. ## E. Review & correction @@ -174,6 +185,19 @@ changes. - **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. +- **REQ-183** — In the manual review editor, each class row has a **hide toggle**. A hidden + class's shapes disappear from both the annotation canvas and the "Shapes on this frame" + list — not selectable, not marquee-selectable, not deletable while hidden — while the class + row keeps its real per-frame count and its eye state, so what is hidden stays visible as + state. The choice is session state: it survives frame changes, resets when the review page + is left, and never touches stored data. +- **REQ-184** — A class can be marked as **container** (persisted on + `project_classes.container`, edited next to the per-class overrides in the auto-annotate + modals). When the cross-class NMS of REQ-031 compares another class's box against a + higher-scoring box of a container class, containment is not overlap: if at least 90% of + the smaller box's area lies inside the container's box, both survive. The flag changes + nothing else — unmarked classes are governed by IoU alone — and preview and batch job + read the same stored flag. ## E4. Live counting preview diff --git a/docs/tasks.md b/docs/tasks.md index 559973a..4a07973 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1268,6 +1268,59 @@ failure modes to the counting path. The saving was always on the browser side. strings present). Browser click-test of the override tables is NOT automated — manual click-test pending. +## Task — Per-class accumulating exemplar preview (REQ-172, REQ-182) `[DONE]` + +1. Exemplar pools keyed by class and a per-class accumulating preview in the auto-annotate + modal — switching the active class swaps pools and a frame change clears them all + (REQ-172); a redraw re-runs only the touched class against its own pool, the canvas unions + every exemplared class, classes without examples contribute nothing while any example + exists, clearing the last example falls back to the full-set preview, no examples at all → + the unchanged full-set request (REQ-182) → verify: **[DONE]** `cd frontend && npm run + build` passes (✓ 536 ms, 69 modules); `wc -l` caps hold — + `frontend/src/components/AutoAnnotateModal.jsx` (400) and + `frontend/src/hooks/useExemplarPools.js` (100) both ≤ 400; rebuilt Docker frontend + bundle carries the `pools` marker (`grep -c pools dist/assets/*.js` → 1) and serves + HTTP 200 on :9000; browser drag/undo/clear across two classes is NOT automated — + manual click-test pending. + +## Task — Per-class hide toggle in review editor (REQ-183) `[DONE]` + +1. Eye button on each sidebar class row — a hidden class's shapes leave the canvas, the + "Shapes on this frame" list and every selection path while the row keeps its real + per-frame count and eye state; the choice is session-only (survives frame changes, resets + when the review page is left, stored data untouched) → verify: **[DONE]** `cd frontend && + npm run build` passes (✓ 535 ms, 69 modules); `wc -l` caps hold — + `frontend/src/components/Icons.jsx` (172) and + `frontend/src/components/ReviewSidebar.jsx` (129) ≤ 400, + `frontend/src/pages/ReviewPage.jsx` (666) exempt (pre-existing over the 400 cap, not + split by this task); rebuilt Docker frontend serves the new bundle (HTTP 200 on :9000, + `aria-pressed` present in the shipped JS); browser click-test of the eye toggle is NOT + automated — manual click-test pending. + +## Task — Container class flag (REQ-184, REQ-031) `[DONE]` + +1. `project_classes.container` (`INTEGER NOT NULL DEFAULT 0`), the `containers` patch on + `PATCH /api/projects/{id}` beside the prompts patch, and the Container checkbox in both + auto-annotate modals' overrides table → verify: **[DONE]** migration + `PRAGMA table_info(project_classes)` lists `container`, a second `db.migrate()` run is + a no-op; API round-trip on project 5 sets `truck → container 1` and clears it back + (`curl -X PATCH :9010/api/projects/5 -d '{"containers":{"2":true}}'` → + `[(0,'sack',0),(1,'box',0),(2,'truck',1)]`, revert → all 0); + `cd frontend && npm run build` passes (✓ 536 ms); + `wc -l frontend/src/components/ClassParamsTable.jsx` = 115 ≤ 400; both modals stay at + 400 / 347. +2. Cross-class NMS reads the stored flag — preview and batch job build the same container-id + set → verify: **[DONE]** scripted NMS check 6/6 (`uv run python`, pasted in + `t3-report.md`, re-run at T5): containment ≥ 90 % keeps both boxes for the container + class only (one direction), no blanket exemption below 0.9 (IoU 0.802 / containment + 0.890 → dropped), `iou <= 0` never drops, within-class pass unchanged, per-class IoU + override consulted on cross pairs; `uv run ruff check backend/` → 409 errors, all + +13 being pre-existing categories (UP006/UP035/UP045) on the new signature lines. + +The REQ-031 amendment rides on this entry: cross-class greedy NMS with the per-class IoU +override and the containment carve-out is only real once the flag exists and both live sites +read it. + ## 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 1959c8b..3074d02 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -441,7 +441,7 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable). - *Run Preview*, and for SAM3: *Undo box*, *Clear boxes*, plus the hint `Drag = example of · Shift-drag = not this`. - A frame slider across the whole batch; opens at the middle frame. Changing frames clears the - preview and the exemplars. + accumulated preview and the exemplars of **every** class (REQ-172). - An "Inferring…" chip while a request is in flight; a red banner for preview errors — a **409** here means a batch job holds the GPU lock, and that is the one failure the user can act on. @@ -450,10 +450,18 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable). - 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 + selected class: `Conf` / `IoU` / `MinBox` number inputs and a **Container** checkbox + (REQ-184). 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. + disagree. Classes without an override behave exactly as before. The checkbox marks the + class as a container in the cross-class NMS (REQ-031): when a higher-scoring box of this + class is compared against another class's box, containment is not overlap — if ≥ 90 % of + the smaller box's area lies inside this class's box, both survive. The carve-out runs + **one direction only** (only the container's box earns it) and changes nothing else; + unmarked classes are governed by IoU alone. Toggling is optimistic + `PATCH /projects/{id} { containers: { [classId]: bool } }` into `project_classes.container` + with revert on rejection — preview and batch job read the same stored flag. - `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. @@ -465,8 +473,17 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable). "Unsaved — the batch run still uses the stored prompt until you save." - *Cancel* / **Start Auto-Annotation** → `POST /batches/{id}/autolabel`. -**Preview auto-rerun.** Adding, undoing or clearing an exemplar bumps a revision counter, -debounced 250 ms, which re-runs the preview. Drawing a box *is* the question and the redrawn +**Preview auto-rerun (REQ-172, REQ-182).** Example pools are keyed by class: switching the +active class shows that class's own pool instead of discarding the others, a new frame clears +them all. Adding, undoing or clearing an exemplar records the touched class and bumps a +single revision counter — **one shared debounce** across all classes, 250 ms — which re-runs +the most recently touched class against its own pool and replaces only its shapes, so rapid +alternation between classes re-runs only the last one; the other exemplared classes' results +stay on screen and the canvas shows the union of every class with examples. Classes without +examples contribute nothing while any example exists; Run Preview with examples runs those +classes sequentially and merges, and with none it is the plain full-set preview. Clearing the +last example sends no request and restores the plain full-set preview (the session's last +full-set run, blank if none ran yet). Drawing a box *is* the question and the redrawn preview is the answer, so no button press sits between them. Re-running is cheap because `set_image` is already cached for this frame — only the grounding head runs. @@ -511,7 +528,8 @@ 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 +The per-class overrides table (REQ-181) appears here too, below the sliders — same columns +including the **Container** checkbox (REQ-184) — and its `class_params` ride along on every preview and start request — one set of overrides applies to all selected batches. @@ -1104,6 +1122,15 @@ network. 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). +- **Eye button (REQ-183)** — first control in the class row, before the frame-clear `×`: a + ghost icon button (`cursor: pointer`, `aria-pressed` bound to the hidden state, title + `Hide "name"` / `Show "name"`), drawing the eye icon while the class is visible and the + slashed eye at 55 % opacity while it is hidden. Hiding drops that class's shapes from the + annotation canvas **and** from *Shapes on this frame* — not selectable, not + marquee-selectable, not deletable while hidden, and any selection or mark on them is + cleared — while the row keeps its real per-frame count (the `×` and the trash still count + every shape). The choice is **session state**: it survives frame changes, resets when the + review page is left, and never touches stored data. - **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 644321e..996747b 100644 --- a/frontend/src/components/AutoAnnotateModal.jsx +++ b/frontend/src/components/AutoAnnotateModal.jsx @@ -4,7 +4,7 @@ import ExemplarCanvas from './ExemplarCanvas' import { PreviewShapes } from './PreviewShapes' import ClassPromptPanel from './ClassPromptPanel' import ClassParamsTable, { buildClassParams } from './ClassParamsTable' -import useDebounce from '../hooks/useDebounce' +import useExemplarPools from '../hooks/useExemplarPools' export default function AutoAnnotateModal({ batch, @@ -42,14 +42,9 @@ export default function AutoAnnotateModal({ 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. + // Exemplars (REQ-172): tuning aid for the previewed frame only, pooled per + // class; 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([]) @@ -73,7 +68,34 @@ export default function AutoAnnotateModal({ const currentFrame = frames[frameIndex] + // Per-class pools plus the accumulated per-class preview they drive (REQ-182). + const { pools, shapesByClass, add, undo, clear, reset, prune, run } = useExemplarPools({ + batchId: batch.id, + payload: { + frame_id: currentFrame ? currentFrame.id : null, + engine, + threshold, + iou_threshold: iouThreshold, + min_box_frac: minBoxFrac, + class_params: buildClassParams(classParams), + custom_model_path: customModelStagedPath + }, + onBusy: setIsLoadingPreview, + onError: setPreviewError + }) + + const activePool = activeClassName ? pools[activeClassName] || [] : [] + // While any selected class has examples, only those classes contribute. + const exemplaredClasses = selectedClasses.filter(name => pools[name]?.length) + const renderShapes = exemplaredClasses.length > 0 + ? exemplaredClasses.flatMap(name => shapesByClass[name] || []) + : previewShapes + const handlePreview = () => { + if (exemplaredClasses.length > 0) { + run(exemplaredClasses) + return + } const frame = currentFrame if (!frame) return @@ -89,7 +111,7 @@ export default function AutoAnnotateModal({ target_class_names: selectedClasses, class_params: buildClassParams(classParams), custom_model_path: customModelStagedPath, - exemplars: engine === 'sam3' ? exemplars : [], + exemplars: engine === 'sam3' ? activePool : [], exemplar_class_name: engine === 'sam3' ? activeClassName : null }).then(res => { if (isMounted && res.shapes) { @@ -108,8 +130,10 @@ export default function AutoAnnotateModal({ const activeClass = project.classes.find(c => c.name === activeClassName) || null const sam3Tuning = engine === 'sam3' - // Clear shapes when frame changes + // A new frame discards the pools, their per-class results and the full-set + // preview; a class switch discards none of them (REQ-172 amended, REQ-182). useEffect(() => { + reset() setPreviewShapes([]) }, [frameIndex]) @@ -122,12 +146,10 @@ export default function AutoAnnotateModal({ } }, [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). + // Unchecking a class takes its pool and its drawn results with it (REQ-182). useEffect(() => { - setExemplars([]) - setExemplarRev(0) - }, [frameIndex, activeClassName]) + prune(selectedClasses) + }, [selectedClasses]) useEffect(() => { if (activeClass) { @@ -136,28 +158,9 @@ export default function AutoAnnotateModal({ } }, [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 addExemplar = (exemplar) => add(activeClassName, exemplar) + const undoExemplar = () => undo(activeClassName) + const clearExemplars = () => clear(activeClassName) const savePrompt = async () => { if (!activeClass) return @@ -226,11 +229,11 @@ export default function AutoAnnotateModal({ style={{ maxWidth: '100%', maxHeight: '42vh', display: 'block' }} /> - + {sam3Tuning && activeClass && ( @@ -270,7 +273,7 @@ export default function AutoAnnotateModal({ type="button" className="btn btn-ghost" onClick={undoExemplar} - disabled={exemplars.length === 0} + disabled={activePool.length === 0} style={{ fontSize: '0.82rem' }} > Undo box @@ -279,7 +282,7 @@ export default function AutoAnnotateModal({ type="button" className="btn btn-ghost" onClick={clearExemplars} - disabled={exemplars.length === 0} + disabled={activePool.length === 0} style={{ fontSize: '0.82rem' }} > Clear boxes @@ -358,7 +361,7 @@ export default function AutoAnnotateModal({
Per-class overrides (empty = global):
- +
diff --git a/frontend/src/components/ClassParamsTable.jsx b/frontend/src/components/ClassParamsTable.jsx index 61904b6..3fc9386 100644 --- a/frontend/src/components/ClassParamsTable.jsx +++ b/frontend/src/components/ClassParamsTable.jsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useState } from 'react' +import { api } from '../api' // Per-class overrides of the job's globals (REQ-181). Empty input = inherit. const KEYS = [ @@ -22,9 +23,34 @@ export function buildClassParams(classParams) { return Object.keys(out).length > 0 ? out : undefined } -export default function ClassParamsTable({ classNames, globals, value, onChange }) { +export default function ClassParamsTable({ classNames, globals, value, onChange, project }) { + // Container flag (REQ-184): saved immediately on toggle, like the prompt + // saves in the modals; there is no error slot in this component, so a + // rejected save reverts and logs instead. + const [containers, setContainers] = useState(() => + new Set((project?.classes || []).filter(c => c.container).map(c => c.name)) + ) const set = (name, key, raw) => onChange({ ...value, [name]: { ...(value[name] || {}), [key]: raw } }) + const toggleContainer = async (cls) => { + const on = !containers.has(cls.name) + const next = new Set(containers) + if (on) next.add(cls.name) + else next.delete(cls.name) + setContainers(next) + try { + await api.patchProject(project.id, { containers: { [cls.class_id]: on } }) + } catch (err) { + // Functional revert: only undo this toggle, even if another landed meanwhile. + setContainers(prev => { + const reverted = new Set(prev) + if (on) reverted.delete(cls.name) + else reverted.add(cls.name) + return reverted + }) + console.error('Could not save the container flag:', err) + } + } if (!classNames.length) return null return ( @@ -34,34 +60,55 @@ export default function ClassParamsTable({ classNames, globals, value, onChange {KEYS.map(k => ( ))} + {project && ( + + )} - {classNames.map(name => ( - - - {KEYS.map(({ key, label, step, color }) => ( - - ))} - - ))} + {classNames.map(name => { + const cls = project?.classes.find(c => c.name === name) + return ( + + + {KEYS.map(({ key, label, step, color }) => ( + + ))} + {project && ( + + )} + + ) + })}
{k.label}Container
{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', - }} - /> -
{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', + }} + /> + + {cls && ( + + )} +
) diff --git a/frontend/src/components/Icons.jsx b/frontend/src/components/Icons.jsx index ef2e12f..155137a 100644 --- a/frontend/src/components/Icons.jsx +++ b/frontend/src/components/Icons.jsx @@ -153,4 +153,20 @@ export const SearchIcon = (props) => ( ) +export const EyeIcon = (props) => ( + + + + +) + +export const EyeOffIcon = (props) => ( + + + + + + +) + diff --git a/frontend/src/components/MassAutoAnnotateModal.jsx b/frontend/src/components/MassAutoAnnotateModal.jsx index db397da..e5f6a5c 100644 --- a/frontend/src/components/MassAutoAnnotateModal.jsx +++ b/frontend/src/components/MassAutoAnnotateModal.jsx @@ -250,7 +250,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc
Per-class overrides (empty = global):
- +
diff --git a/frontend/src/components/ReviewSidebar.jsx b/frontend/src/components/ReviewSidebar.jsx index ad5c708..e948f24 100644 --- a/frontend/src/components/ReviewSidebar.jsx +++ b/frontend/src/components/ReviewSidebar.jsx @@ -1,6 +1,6 @@ import React from 'react' import { classColor } from '../api' -import { TrashIcon, XIcon } from './Icons' +import { EyeIcon, EyeOffIcon, TrashIcon, XIcon } from './Icons' import ShortcutsPanel from './ShortcutsPanel' export default function ReviewSidebar({ @@ -10,6 +10,8 @@ export default function ReviewSidebar({ reclass, clearClassInBatch, clearClassInFrame, + hiddenClasses, + onToggleHide, annotations, selectedId, setSelectedId, @@ -18,6 +20,9 @@ export default function ReviewSidebar({ jumpToNextAnnotated, batchAnnotationCount, }) { + // REQ-183: the shapes panel lists visible shapes only; class rows keep + // counting every shape of the class (data truth). + const visibleAnnotations = annotations.filter((row) => !hiddenClasses.has(row.class_id)) return (