feat: per-class exemplar preview, class hide toggle, container NMS flag (REQ-182, REQ-183, REQ-184)
This commit is contained in:
1 parent
1a99f2ffe4
commit
96a00267d9
18 files changed
+514
-101
No files matched your search
@@ -171,3 +171,6 @@ mlruns/
|
||||
*.swo
|
||||
*~
|
||||
docs/panduan_debug.html
|
||||
|
||||
# agent workspaces
|
||||
.superpowers/
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) }} />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user