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 (