feat: per-class exemplar preview, class hide toggle, container NMS flag (REQ-182, REQ-183, REQ-184)

This commit is contained in:
asus committed 2026-10-02 11:11:33 +07:00
1 parent 1a99f2ffe4
commit 96a00267d9
18 files changed
+514 -101

No files matched your search

+3
View File
@@ -171,3 +171,6 @@ mlruns/
*.swo
*~
docs/panduan_debug.html
# agent workspaces
.superpowers/
+3 -1
View File
@@ -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))
+7 -1
View File
@@ -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:
+7
View File
@@ -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()]
+49 -8
View File
@@ -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))
+7 -1
View File
@@ -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:
+25 -15
View File
@@ -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:
+33 -9
View File
@@ -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 `<img>` 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
+28 -4
View File
@@ -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
+53
View File
@@ -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
+34 -7
View File
@@ -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 <class> · 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
+45 -42
View File
@@ -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' }}
/>
<svg viewBox="0 0 10000 10000" preserveAspectRatio="none" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
<PreviewShapes shapes={previewShapes} project={project} />
<PreviewShapes shapes={renderShapes} project={project} />
</svg>
{sam3Tuning && activeClass && (
<ExemplarCanvas
exemplars={exemplars}
exemplars={activePool}
onAdd={addExemplar}
disabled={isLoadingPreview}
/>
@@ -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({
<div style={{ marginBottom: 12 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
<div style={{ marginTop: 4 }}>
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac }} value={classParams} onChange={setClassParams} />
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac }} value={classParams} onChange={setClassParams} project={project} />
</div>
</div>
+51 -4
View File
@@ -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 (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
@@ -34,10 +60,15 @@ export default function ClassParamsTable({ classNames, globals, value, onChange
{KEYS.map(k => (
<th key={k.key} className="hint" style={{ fontWeight: 400, fontSize: '0.74rem', color: k.color, padding: '2px 4px' }}>{k.label}</th>
))}
{project && (
<th className="hint" style={{ fontWeight: 400, fontSize: '0.74rem', textAlign: 'left', padding: '2px 4px' }}>Container</th>
)}
</tr>
</thead>
<tbody>
{classNames.map(name => (
{classNames.map(name => {
const cls = project?.classes.find(c => c.name === name)
return (
<tr key={name}>
<td className="mono" style={{ fontSize: '0.76rem', padding: '2px 4px' }}>{name}</td>
{KEYS.map(({ key, label, step, color }) => (
@@ -60,8 +91,24 @@ export default function ClassParamsTable({ classNames, globals, value, onChange
/>
</td>
))}
{project && (
<td style={{ padding: '2px 4px', textAlign: 'center' }}>
{cls && (
<label title={`Keep boxes inside ${name} when auto-annotating (containment is not overlap)`} style={{ cursor: 'pointer', display: 'inline-flex' }}>
<input
type="checkbox"
checked={containers.has(name)}
onChange={() => toggleContainer(cls)}
aria-label={`Mark ${name} as container`}
style={{ cursor: 'pointer' }}
/>
</label>
)}
</td>
)}
</tr>
))}
)
})}
</tbody>
</table>
)
+16
View File
@@ -153,4 +153,20 @@ export const SearchIcon = (props) => (
</Icon>
)
export const EyeIcon = (props) => (
<Icon {...props}>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</Icon>
)
export const EyeOffIcon = (props) => (
<Icon {...props}>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<path d="m2 2 20 20" />
</Icon>
)
@@ -250,7 +250,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc
<div style={{ marginBottom: 12 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
<div style={{ marginTop: 4 }}>
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac }} value={classParams} onChange={setClassParams} />
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac }} value={classParams} onChange={setClassParams} project={project} />
</div>
</div>
+19 -4
View File
@@ -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 (
<aside className="review-side stack">
{exemplarPanel}
@@ -26,6 +31,7 @@ export default function ReviewSidebar({
<div className="class-list">
{classesList.map((item) => {
const onFrame = annotations.filter((row) => row.class_id === item.class_id).length
const hidden = hiddenClasses.has(item.class_id)
return (
<div className="class-row" key={item.class_id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
@@ -37,6 +43,15 @@ export default function ReviewSidebar({
{item.name}
<span className="faint mono">{item.class_id + 1}</span>
</button>
<button
className="btn btn-ghost"
style={{ padding: '4px 6px', cursor: 'pointer' }}
title={hidden ? `Show "${item.name}"` : `Hide "${item.name}"`}
aria-pressed={hidden}
onClick={() => onToggleHide(item.class_id)}
>
{hidden ? <EyeOffIcon size={12} style={{ opacity: 0.55 }} /> : <EyeIcon size={12} />}
</button>
<button
className="btn btn-ghost"
style={{ padding: '4px 6px' }}
@@ -61,8 +76,8 @@ export default function ReviewSidebar({
</div>
<div className="panel side-panel">
<h2>Shapes on this frame ({annotations.length})</h2>
{annotations.length === 0 ? (
<h2>Shapes on this frame ({visibleAnnotations.length})</h2>
{visibleAnnotations.length === 0 ? (
<div>
<p className="hint" style={{ marginBottom: 8 }}>
None on this frame — drag to draw a shape.
@@ -86,7 +101,7 @@ export default function ReviewSidebar({
</div>
) : (
<ul className="shape-list">
{annotations.map((item) => (
{visibleAnnotations.map((item) => (
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
<button className="shape-pick" onClick={() => setSelectedId(item.id)}>
<span className="swatch" style={{ background: classColor(item.class_id) }} />
+100
View File
@@ -0,0 +1,100 @@
import { useState, useEffect } from 'react'
import useDebounce from './useDebounce'
import { api } from '../api'
/* Per-class exemplar pools and the preview they drive for the auto-annotate
* modal (REQ-172 amended, REQ-182).
*
* Each class keeps its own boxes on the frame, so switching the active class
* shows that class's pool instead of discarding the others; a new frame
* discards them all. Every touch bumps a revision and records the class it
* touched: 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 — and the recorded class is what the debounced run requests, alone, so
* redrawing one class leaves the other exemplared classes on screen. A pool
* left empty by undo/clear only drops that class's shapes: there is nothing
* left to ask about, so no round trip. */
export default function useExemplarPools({ batchId, payload, onBusy, onError }) {
const [pools, setPools] = useState({})
const [shapesByClass, setShapesByClass] = useState({})
const [touched, setTouched] = useState(null)
const [rev, setRev] = useState(0)
const debouncedRev = useDebounce(rev, 250)
const touch = (className, mutate) => {
if (!className) return
setPools(prev => {
const next = mutate(prev[className] || [])
const copy = { ...prev }
if (next.length > 0) copy[className] = next
else delete copy[className]
return copy
})
setTouched(className)
setRev(r => r + 1)
}
const drop = (className) => setShapesByClass(prev => {
const next = { ...prev }
delete next[className]
return next
})
// One round trip per class, sequential, so a failing class only loses its
// own results and the others stay on screen (REQ-182).
const run = async (classNames) => {
if (!payload || !payload.frame_id) return
onBusy?.(true)
onError?.('')
for (const className of classNames) {
const pool = pools[className]
if (!pool || pool.length === 0) {
drop(className)
continue
}
try {
const res = await api.preview(batchId, {
...payload,
target_class_names: [className],
exemplars: pool,
exemplar_class_name: className
})
if (res.shapes) setShapesByClass(prev => ({ ...prev, [className]: res.shapes }))
} catch (err) {
onError?.(err.message || 'Preview failed')
drop(className)
}
}
onBusy?.(false)
}
// 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.
useEffect(() => {
if (debouncedRev > 0 && touched) run([touched])
}, [debouncedRev])
return {
pools,
shapesByClass,
add: (className, exemplar) => touch(className, pool => [...pool, exemplar]),
undo: (className) => touch(className, pool => pool.slice(0, -1)),
clear: (className) => touch(className, () => []),
reset: () => {
setPools({})
setShapesByClass({})
setTouched(null)
setRev(0)
},
prune: (classNames) => {
setPools(prev => Object.fromEntries(
Object.entries(prev).filter(([name]) => classNames.includes(name))
))
setShapesByClass(prev => Object.fromEntries(
Object.entries(prev).filter(([name]) => classNames.includes(name))
))
},
run,
}
}
+33 -4
View File
@@ -23,6 +23,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [activeJob, setActiveJob] = useState(null)
const [hiddenClasses, setHiddenClasses] = useState(() => new Set())
const batchId = batch?.id || rawBatchId
const stripRef = useRef(null)
@@ -184,7 +185,33 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
})
}, [])
const markAll = useCallback(() => setMarkedIds(annotations.map((row) => row.id)), [annotations])
const toggleHide = useCallback((classId) => {
setHiddenClasses((current) => {
const next = new Set(current)
if (next.has(classId)) next.delete(classId)
else next.add(classId)
return next
})
}, [])
// REQ-183: while a class is hidden, no shape of it may be selected or
// marked — however it got there (draw, reclass, copy) — so Delete can
// never hit an invisible shape.
useEffect(() => {
if (!hiddenClasses.size) return
setSelectedId((currentId) => {
const row = annotations.find((a) => a.id === currentId)
return row && hiddenClasses.has(row.class_id) ? null : currentId
})
setMarkedIds((rows) => rows.filter((id) => {
const row = annotations.find((a) => a.id === id)
return !row || !hiddenClasses.has(row.class_id)
}))
}, [hiddenClasses, annotations])
const visibleAnnotations = annotations.filter((row) => !hiddenClasses.has(row.class_id))
const markAll = useCallback(() => setMarkedIds(visibleAnnotations.map((row) => row.id)), [visibleAnnotations])
const removeMarked = useCallback(async () => {
if (!markedIds.length || !frame) return
@@ -460,7 +487,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
<AnnotationCanvas
frame={frame}
imageUrl={api.frameUrl(frame.id)}
annotations={annotations}
annotations={visibleAnnotations}
selectedId={selectedId}
activeClass={activeClass}
assistMode={assistMode}
@@ -503,9 +530,9 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
onClick={markAll}
disabled={!annotations.length}
disabled={!visibleAnnotations.length}
>
Select all ({annotations.length})
Select all ({visibleAnnotations.length})
</button>
<span className="muted" style={{ fontSize: '0.78rem' }}>
{markedIds.length
@@ -623,6 +650,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
reclass={reclass}
clearClassInBatch={clearClassInBatch}
clearClassInFrame={clearClassInFrame}
hiddenClasses={hiddenClasses}
onToggleHide={toggleHide}
annotations={annotations}
selectedId={selectedId}
setSelectedId={setSelectedId}