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
+537
-124
No files matched your search
@@ -171,3 +171,6 @@ mlruns/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
docs/panduan_debug.html
|
docs/panduan_debug.html
|
||||||
|
|
||||||
|
# agent workspaces
|
||||||
|
.superpowers/
|
||||||
@@ -37,6 +37,7 @@ class ProjectPatch(BaseModel):
|
|||||||
prompts: Optional[Dict[int, str]] = None
|
prompts: Optional[Dict[int, str]] = None
|
||||||
val_every: Optional[int] = None
|
val_every: Optional[int] = None
|
||||||
video_root: Optional[str] = None
|
video_root: Optional[str] = None
|
||||||
|
containers: Optional[Dict[int, bool]] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -68,7 +69,8 @@ def patch_project(project_id: int, request: ProjectPatch) -> dict:
|
|||||||
try:
|
try:
|
||||||
return project_store.update(project_id, prompts=request.prompts,
|
return project_store.update(project_id, prompts=request.prompts,
|
||||||
val_every=request.val_every,
|
val_every=request.val_every,
|
||||||
video_root=request.video_root)
|
video_root=request.video_root,
|
||||||
|
containers=request.containers)
|
||||||
except project_store.ProjectError as exc:
|
except project_store.ProjectError as exc:
|
||||||
raise HTTPException(400, str(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"]}
|
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
|
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}...")
|
job.log(f"Starting auto-labeling with {selected_engine}...")
|
||||||
|
|
||||||
@@ -249,6 +253,7 @@ def _run_autolabel(job) -> None:
|
|||||||
thresholds=thr_list,
|
thresholds=thr_list,
|
||||||
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
|
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
|
||||||
min_box_fracs=mb_list,
|
min_box_fracs=mb_list,
|
||||||
|
container_ids=prompt_container_ids,
|
||||||
)
|
)
|
||||||
if not res.error and res.detections:
|
if not res.error and res.detections:
|
||||||
for det in 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()]
|
and "iou_threshold" in per_class[c["name"].strip().lower()]
|
||||||
}
|
}
|
||||||
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh,
|
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 = []
|
items = []
|
||||||
for det in kept:
|
for det in kept:
|
||||||
if project["label_type"] == "bbox" or det.mask is None:
|
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.
|
# REQ-110: augmentation settings, null until the user changes them.
|
||||||
if "augment" not in cols:
|
if "augment" not in cols:
|
||||||
cur.execute("ALTER TABLE projects ADD COLUMN augment TEXT")
|
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.
|
# REQ-107: what rule set a run's numbers were measured under.
|
||||||
cur.execute("PRAGMA table_info(model_versions)")
|
cur.execute("PRAGMA table_info(model_versions)")
|
||||||
version_cols = [column[1] for column in cur.fetchall()]
|
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 dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Set
|
||||||
|
|
||||||
from PIL import Image
|
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
|
return inter / union if union > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
def deduplicate(detections: List[Detection], iou_threshold: float = 0.8,
|
def _containment_fraction(box_a: List[float], box_b: List[float]) -> float:
|
||||||
iou_by_class: Optional[Dict[int, float]] = None) -> List[Detection]:
|
"""Intersection over the smaller box's area: how much of the smaller box
|
||||||
"""Greedy NMS per class: highest score wins within the SAME class.
|
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:
|
if not iou_by_class and iou_threshold <= 0.0:
|
||||||
return detections
|
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):
|
if all(_iou(det.box, k.box) < iou for k in cls_kept):
|
||||||
cls_kept.append(det)
|
cls_kept.append(det)
|
||||||
kept.extend(cls_kept)
|
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(
|
def label_image(
|
||||||
@@ -80,13 +117,16 @@ def label_image(
|
|||||||
thresholds: Optional[List[float]] = None,
|
thresholds: Optional[List[float]] = None,
|
||||||
iou_by_class: Optional[Dict[int, float]] = None,
|
iou_by_class: Optional[Dict[int, float]] = None,
|
||||||
min_box_fracs: Optional[List[float]] = None,
|
min_box_fracs: Optional[List[float]] = None,
|
||||||
|
container_ids: Optional[Set[int]] = None,
|
||||||
) -> ImageResult:
|
) -> ImageResult:
|
||||||
"""Detect every prompt in one image and return the surviving instances.
|
"""Detect every prompt in one image and return the surviving instances.
|
||||||
|
|
||||||
When `exemplars` are given, the prompt at `exemplar_index` also carries them
|
When `exemplars` are given, the prompt at `exemplar_index` also carries them
|
||||||
as drawn box exemplars (REQ-172); every other prompt runs on text alone.
|
as drawn box exemplars (REQ-172); every other prompt runs on text alone.
|
||||||
`thresholds`, `iou_by_class` and `min_box_fracs` are per-prompt overrides
|
`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:
|
try:
|
||||||
image = Image.open(image_path).convert("RGB")
|
image = Image.open(image_path).convert("RGB")
|
||||||
except Exception as exc: # unreadable/corrupt frame: report, don't abort the job
|
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,
|
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
|
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)
|
per_class = _parse_class_params(class_params)
|
||||||
predict_conf = min([threshold] + [v["threshold"] for v in per_class.values() if "threshold" in v])
|
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 = []
|
all_raw_detections = []
|
||||||
|
|
||||||
@@ -153,6 +157,7 @@ def preview_frame(
|
|||||||
thresholds=thr_list,
|
thresholds=thr_list,
|
||||||
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
|
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
|
||||||
min_box_fracs=mb_list,
|
min_box_fracs=mb_list,
|
||||||
|
container_ids=prompt_container_ids,
|
||||||
)
|
)
|
||||||
if not res.error and res.detections:
|
if not res.error and res.detections:
|
||||||
for det in res.detections:
|
for det in res.detections:
|
||||||
@@ -169,7 +174,8 @@ def preview_frame(
|
|||||||
and "iou_threshold" in per_class[c["name"].strip().lower()]
|
and "iou_threshold" in per_class[c["name"].strip().lower()]
|
||||||
}
|
}
|
||||||
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold,
|
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 = []
|
items = []
|
||||||
for det in kept:
|
for det in kept:
|
||||||
if project["label_type"] == "bbox" or det.mask is None:
|
if project["label_type"] == "bbox" or det.mask is None:
|
||||||
|
|||||||
+25
-15
@@ -13,7 +13,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from backend import config, db
|
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:
|
def _write_classes(cur, project_id: int, classes: List[dict]) -> None:
|
||||||
cur.execute("DELETE FROM project_classes WHERE project_id = ?", (project_id,))
|
cur.execute("DELETE FROM project_classes WHERE project_id = ?", (project_id,))
|
||||||
cur.executemany(
|
cur.executemany(
|
||||||
"INSERT INTO project_classes (project_id, class_id, name, prompt) VALUES (?, ?, ?, ?)",
|
"INSERT INTO project_classes (project_id, class_id, name, prompt, container) "
|
||||||
[(project_id, index, item["name"], item["prompt"])
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[(project_id, index, item["name"], item["prompt"], item.get("container", 0))
|
||||||
for index, item in enumerate(classes)],
|
for index, item in enumerate(classes)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(cur, row) -> dict:
|
def _row_to_dict(cur, row) -> dict:
|
||||||
cur.execute(
|
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"],),
|
(row["id"],),
|
||||||
)
|
)
|
||||||
classes = [dict(item) for item in cur.fetchall()]
|
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,
|
def update(project_id: int, prompts: Optional[dict] = None, val_every: Optional[int] = None,
|
||||||
video_root: Optional[str] = None) -> dict:
|
video_root: Optional[str] = None,
|
||||||
"""Edit the things that are safe to change: prompts, split ratio, archive root.
|
containers: Optional[Dict[int, bool]] = None) -> dict:
|
||||||
Class names and label type are not among them."""
|
"""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:
|
if get(project_id) is None:
|
||||||
raise ProjectError("No such project")
|
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 = ?",
|
"UPDATE project_classes SET prompt = ? WHERE project_id = ? AND class_id = ?",
|
||||||
(str(prompt).strip(), project_id, int(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)
|
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 = ?",
|
"UPDATE projects SET base_model_path = ?, base_model_kind = 'uploaded' WHERE id = ?",
|
||||||
(stored, project_id),
|
(stored, project_id),
|
||||||
)
|
)
|
||||||
_write_classes(cur, project_id,
|
_write_classes(cur, project_id, _kept_classes(project, names))
|
||||||
[{"name": n, "prompt": p}
|
|
||||||
for n, p in zip(names, _kept_prompts(project, names))])
|
|
||||||
return get(project_id)
|
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)
|
return get(project_id)
|
||||||
|
|
||||||
|
|
||||||
def _kept_prompts(project: dict, names: List[str]) -> List[str]:
|
def _kept_classes(project: dict, names: List[str]) -> List[dict]:
|
||||||
"""Keep the prompt the user already wrote for a class that survives a
|
"""Keep the stored per-class attributes — prompt (REQ-171) and container
|
||||||
base-model swap; fall back to the class name for new ones."""
|
flag (REQ-184) — for a class that survives a base-model swap; new classes
|
||||||
known = {item["name"]: item["prompt"] for item in project["classes"]}
|
fall back to the class name as prompt and no flag."""
|
||||||
return [known.get(name, name) for name in names]
|
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:
|
def training_start_point(project: dict) -> str:
|
||||||
|
|||||||
+33
-9
@@ -57,6 +57,7 @@ projects(
|
|||||||
|
|
||||||
project_classes(
|
project_classes(
|
||||||
id, project_id → projects, class_id INT, name, prompt,
|
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)
|
UNIQUE(project_id, class_id)) -- class_id = the YOLO class index (REQ-003/005)
|
||||||
|
|
||||||
batches(
|
batches(
|
||||||
@@ -112,7 +113,7 @@ box.
|
|||||||
| Module | Role | Status |
|
| Module | Role | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sam3_engine.py` | SAM3 singleton, `open_state`/`apply_prompts`/`segment_at` | reused, plus a `release()` for REQ-065 |
|
| `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` |
|
| `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 |
|
| `sessions.py` | ~~exemplar/tap interaction~~ | **deleted** — see below |
|
||||||
| `jobs.py` | single-worker queue | extended: job types + persistence |
|
| `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
|
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)
|
GET /api/projects/{id} # includes label_type_locked: bool (REQ-002), video_root_linux/video_root_windows (REQ-179)
|
||||||
DELETE /api/projects/{id}
|
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)
|
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)
|
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)
|
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;
|
POST /api/batches/{id}/preview # one frame, run now, nothing written;
|
||||||
# +class_params {name: {threshold?, iou_threshold?, min_box_frac?}}
|
# +class_params {name: {threshold?, iou_threshold?, min_box_frac?}}
|
||||||
# empty override = global value, keys are class names (REQ-181)
|
# empty override = global value, keys are class names (REQ-181)
|
||||||
# +exemplars[] {box:[cx,cy,w,h], positive}
|
# +exemplars[] {box:[cx,cy,w,h], positive} — the touched class's own pool only
|
||||||
# +exemplar_class_name (REQ-171,172)
|
# +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);
|
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)
|
# 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)
|
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.
|
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
|
**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
|
`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
|
`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
|
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
|
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,
|
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.
|
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
|
A re-run deletes only `source='auto'` rows — manual corrections
|
||||||
(`source='manual'`) survive — and returns already-approved frames to `pending`, because
|
(`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:
|
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
|
The canvas editor is hand-written; the normalized-coordinate conventions already exist in
|
||||||
`sessions.py` (`annotations_payload`, `detections_payload`) as a reference.
|
`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),
|
`PreviewShapes.jsx` (the result overlay, shared with the mass modal),
|
||||||
`ClassPromptPanel.jsx` (class chips + the editable SAM3 prompt) and `ExemplarCanvas.jsx`
|
`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
|
(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
|
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.
|
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
|
Exemplars are normalized `[cx, cy, w, h]`, one pool per class keyed by class name
|
||||||
frame or the active class changes. A redraw is debounced 250 ms and re-runs the whole prompt
|
(`hooks/useExemplarPools.js`), sent only to `/preview`, and dropped when the frame changes —
|
||||||
set from empty, which is also how undo works — SAM3 can only append geometric prompts.
|
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
|
**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
|
`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
|
- **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.
|
class's prompt, as a background job with progress and cancellation.
|
||||||
- **REQ-031** — Detections that overlap across prompts are deduplicated (greedy IoU NMS), so
|
- **REQ-031** — Detections that overlap across prompts are deduplicated (greedy IoU NMS
|
||||||
one object is not labelled as two classes at once.
|
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-032** — The confidence threshold is configurable per job.
|
||||||
- **REQ-181** — Auto-annotation accepts per-class overrides of confidence, IoU and minimum box
|
- **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
|
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
|
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
|
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
|
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
|
would ask about whatever happens to sit at those coordinates there. Each class keeps its own
|
||||||
one class, so a new frame or a new active class discards them.
|
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
|
## 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
|
- **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 **current frame only** (REQ-046 stays batch-wide). One click, no confirmation dialog;
|
||||||
the next frame is untouched.
|
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
|
## 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
|
strings present). Browser click-test of the override tables is NOT automated — manual
|
||||||
click-test pending.
|
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
|
## Known open points
|
||||||
|
|
||||||
- *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume
|
- *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
|
- *Run Preview*, and for SAM3: *Undo box*, *Clear boxes*, plus the hint
|
||||||
`Drag = example of <class> · Shift-drag = not this`.
|
`Drag = example of <class> · Shift-drag = not this`.
|
||||||
- A frame slider across the whole batch; opens at the middle frame. Changing frames clears the
|
- 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**
|
- 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.
|
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.
|
- 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.
|
- 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
|
- **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
|
selected class: `Conf` / `IoU` / `MinBox` number inputs and a **Container** checkbox
|
||||||
current global value and an empty input inherits it; only filled cells are sent, as
|
(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
|
`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`:
|
- `ClassPromptPanel`:
|
||||||
- Target-class chips. Without SAM3 a chip is a simple toggle.
|
- 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.
|
- **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."
|
"Unsaved — the batch run still uses the stored prompt until you save."
|
||||||
- *Cancel* / **Start Auto-Annotation** → `POST /batches/{id}/autolabel`.
|
- *Cancel* / **Start Auto-Annotation** → `POST /batches/{id}/autolabel`.
|
||||||
|
|
||||||
**Preview auto-rerun.** Adding, undoing or clearing an exemplar bumps a revision counter,
|
**Preview auto-rerun (REQ-172, REQ-182).** Example pools are keyed by class: switching the
|
||||||
debounced 250 ms, which re-runs the preview. Drawing a box *is* the question and the redrawn
|
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
|
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.
|
`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
|
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)`.
|
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
|
`class_params` ride along on every preview and start request — one set of overrides applies
|
||||||
to all selected batches.
|
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
|
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
|
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).
|
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
|
- **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:
|
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
|
*"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 { PreviewShapes } from './PreviewShapes'
|
||||||
import ClassPromptPanel from './ClassPromptPanel'
|
import ClassPromptPanel from './ClassPromptPanel'
|
||||||
import ClassParamsTable, { buildClassParams } from './ClassParamsTable'
|
import ClassParamsTable, { buildClassParams } from './ClassParamsTable'
|
||||||
import useDebounce from '../hooks/useDebounce'
|
import useExemplarPools from '../hooks/useExemplarPools'
|
||||||
|
|
||||||
export default function AutoAnnotateModal({
|
export default function AutoAnnotateModal({
|
||||||
batch,
|
batch,
|
||||||
@@ -42,14 +42,9 @@ export default function AutoAnnotateModal({
|
|||||||
const [promptSaving, setPromptSaving] = useState(false)
|
const [promptSaving, setPromptSaving] = useState(false)
|
||||||
const [promptError, setPromptError] = useState('')
|
const [promptError, setPromptError] = useState('')
|
||||||
|
|
||||||
// Exemplars (REQ-172): tuning aid for the previewed frame only. They belong to
|
// Exemplars (REQ-172): tuning aid for the previewed frame only, pooled per
|
||||||
// the active class and are never written or carried into the batch job.
|
// class; never written or carried into the batch job.
|
||||||
const [activeClassName, setActiveClassName] = useState(null)
|
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
|
// Preview state
|
||||||
const [frames, setFrames] = useState([])
|
const [frames, setFrames] = useState([])
|
||||||
@@ -73,7 +68,34 @@ export default function AutoAnnotateModal({
|
|||||||
|
|
||||||
const currentFrame = frames[frameIndex]
|
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 = () => {
|
const handlePreview = () => {
|
||||||
|
if (exemplaredClasses.length > 0) {
|
||||||
|
run(exemplaredClasses)
|
||||||
|
return
|
||||||
|
}
|
||||||
const frame = currentFrame
|
const frame = currentFrame
|
||||||
if (!frame) return
|
if (!frame) return
|
||||||
|
|
||||||
@@ -89,7 +111,7 @@ export default function AutoAnnotateModal({
|
|||||||
target_class_names: selectedClasses,
|
target_class_names: selectedClasses,
|
||||||
class_params: buildClassParams(classParams),
|
class_params: buildClassParams(classParams),
|
||||||
custom_model_path: customModelStagedPath,
|
custom_model_path: customModelStagedPath,
|
||||||
exemplars: engine === 'sam3' ? exemplars : [],
|
exemplars: engine === 'sam3' ? activePool : [],
|
||||||
exemplar_class_name: engine === 'sam3' ? activeClassName : null
|
exemplar_class_name: engine === 'sam3' ? activeClassName : null
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (isMounted && res.shapes) {
|
if (isMounted && res.shapes) {
|
||||||
@@ -108,8 +130,10 @@ export default function AutoAnnotateModal({
|
|||||||
const activeClass = project.classes.find(c => c.name === activeClassName) || null
|
const activeClass = project.classes.find(c => c.name === activeClassName) || null
|
||||||
const sam3Tuning = engine === 'sam3'
|
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(() => {
|
useEffect(() => {
|
||||||
|
reset()
|
||||||
setPreviewShapes([])
|
setPreviewShapes([])
|
||||||
}, [frameIndex])
|
}, [frameIndex])
|
||||||
|
|
||||||
@@ -122,12 +146,10 @@ export default function AutoAnnotateModal({
|
|||||||
}
|
}
|
||||||
}, [sam3Tuning, selectedClasses, activeClassName])
|
}, [sam3Tuning, selectedClasses, activeClassName])
|
||||||
|
|
||||||
// Exemplars are pooled from one image and belong to one class, so both a new
|
// Unchecking a class takes its pool and its drawn results with it (REQ-182).
|
||||||
// frame and a new class invalidate them (REQ-172).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExemplars([])
|
prune(selectedClasses)
|
||||||
setExemplarRev(0)
|
}, [selectedClasses])
|
||||||
}, [frameIndex, activeClassName])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeClass) {
|
if (activeClass) {
|
||||||
@@ -136,28 +158,9 @@ export default function AutoAnnotateModal({
|
|||||||
}
|
}
|
||||||
}, [activeClassName])
|
}, [activeClassName])
|
||||||
|
|
||||||
// Auto re-run: drawing a box is the question, the redrawn preview is the
|
const addExemplar = (exemplar) => add(activeClassName, exemplar)
|
||||||
// answer, so waiting for a button press in between defeats the point.
|
const undoExemplar = () => undo(activeClassName)
|
||||||
// set_image is already cached for this frame, so only the grounding head runs.
|
const clearExemplars = () => clear(activeClassName)
|
||||||
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 savePrompt = async () => {
|
const savePrompt = async () => {
|
||||||
if (!activeClass) return
|
if (!activeClass) return
|
||||||
@@ -226,11 +229,11 @@ export default function AutoAnnotateModal({
|
|||||||
style={{ maxWidth: '100%', maxHeight: '42vh', display: 'block' }}
|
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' }}>
|
<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>
|
</svg>
|
||||||
{sam3Tuning && activeClass && (
|
{sam3Tuning && activeClass && (
|
||||||
<ExemplarCanvas
|
<ExemplarCanvas
|
||||||
exemplars={exemplars}
|
exemplars={activePool}
|
||||||
onAdd={addExemplar}
|
onAdd={addExemplar}
|
||||||
disabled={isLoadingPreview}
|
disabled={isLoadingPreview}
|
||||||
/>
|
/>
|
||||||
@@ -270,7 +273,7 @@ export default function AutoAnnotateModal({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={undoExemplar}
|
onClick={undoExemplar}
|
||||||
disabled={exemplars.length === 0}
|
disabled={activePool.length === 0}
|
||||||
style={{ fontSize: '0.82rem' }}
|
style={{ fontSize: '0.82rem' }}
|
||||||
>
|
>
|
||||||
Undo box
|
Undo box
|
||||||
@@ -279,7 +282,7 @@ export default function AutoAnnotateModal({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={clearExemplars}
|
onClick={clearExemplars}
|
||||||
disabled={exemplars.length === 0}
|
disabled={activePool.length === 0}
|
||||||
style={{ fontSize: '0.82rem' }}
|
style={{ fontSize: '0.82rem' }}
|
||||||
>
|
>
|
||||||
Clear boxes
|
Clear boxes
|
||||||
@@ -358,7 +361,7 @@ export default function AutoAnnotateModal({
|
|||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
|
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
|
||||||
<div style={{ marginTop: 4 }}>
|
<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>
|
||||||
</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.
|
// Per-class overrides of the job's globals (REQ-181). Empty input = inherit.
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
@@ -22,9 +23,34 @@ export function buildClassParams(classParams) {
|
|||||||
return Object.keys(out).length > 0 ? out : undefined
|
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) =>
|
const set = (name, key, raw) =>
|
||||||
onChange({ ...value, [name]: { ...(value[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
|
if (!classNames.length) return null
|
||||||
return (
|
return (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
@@ -34,34 +60,55 @@ export default function ClassParamsTable({ classNames, globals, value, onChange
|
|||||||
{KEYS.map(k => (
|
{KEYS.map(k => (
|
||||||
<th key={k.key} className="hint" style={{ fontWeight: 400, fontSize: '0.74rem', color: k.color, padding: '2px 4px' }}>{k.label}</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{classNames.map(name => (
|
{classNames.map(name => {
|
||||||
<tr key={name}>
|
const cls = project?.classes.find(c => c.name === name)
|
||||||
<td className="mono" style={{ fontSize: '0.76rem', padding: '2px 4px' }}>{name}</td>
|
return (
|
||||||
{KEYS.map(({ key, label, step, color }) => (
|
<tr key={name}>
|
||||||
<td key={key} style={{ padding: '2px 4px' }}>
|
<td className="mono" style={{ fontSize: '0.76rem', padding: '2px 4px' }}>{name}</td>
|
||||||
<input
|
{KEYS.map(({ key, label, step, color }) => (
|
||||||
type="number"
|
<td key={key} style={{ padding: '2px 4px' }}>
|
||||||
value={value[name]?.[key] ?? ''}
|
<input
|
||||||
min={0}
|
type="number"
|
||||||
max={1}
|
value={value[name]?.[key] ?? ''}
|
||||||
step={step}
|
min={0}
|
||||||
placeholder={String(globals[key])}
|
max={1}
|
||||||
title={`Empty = use the global value (${globals[key]})`}
|
step={step}
|
||||||
aria-label={`${name} ${label} override, empty uses global ${globals[key]}`}
|
placeholder={String(globals[key])}
|
||||||
onChange={(e) => set(name, key, e.target.value)}
|
title={`Empty = use the global value (${globals[key]})`}
|
||||||
style={{
|
aria-label={`${name} ${label} override, empty uses global ${globals[key]}`}
|
||||||
width: 64, fontSize: '0.76rem', background: '#09090b', color,
|
onChange={(e) => set(name, key, e.target.value)}
|
||||||
border: '1px solid #3f3f46', borderRadius: 4, padding: '2px 4px',
|
style={{
|
||||||
cursor: 'text',
|
width: 64, fontSize: '0.76rem', background: '#09090b', color,
|
||||||
}}
|
border: '1px solid #3f3f46', borderRadius: 4, padding: '2px 4px',
|
||||||
/>
|
cursor: 'text',
|
||||||
</td>
|
}}
|
||||||
))}
|
/>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -153,4 +153,20 @@ export const SearchIcon = (props) => (
|
|||||||
</Icon>
|
</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 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
|
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
|
||||||
<div style={{ marginTop: 4 }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { classColor } from '../api'
|
import { classColor } from '../api'
|
||||||
import { TrashIcon, XIcon } from './Icons'
|
import { EyeIcon, EyeOffIcon, TrashIcon, XIcon } from './Icons'
|
||||||
import ShortcutsPanel from './ShortcutsPanel'
|
import ShortcutsPanel from './ShortcutsPanel'
|
||||||
|
|
||||||
export default function ReviewSidebar({
|
export default function ReviewSidebar({
|
||||||
@@ -10,6 +10,8 @@ export default function ReviewSidebar({
|
|||||||
reclass,
|
reclass,
|
||||||
clearClassInBatch,
|
clearClassInBatch,
|
||||||
clearClassInFrame,
|
clearClassInFrame,
|
||||||
|
hiddenClasses,
|
||||||
|
onToggleHide,
|
||||||
annotations,
|
annotations,
|
||||||
selectedId,
|
selectedId,
|
||||||
setSelectedId,
|
setSelectedId,
|
||||||
@@ -18,6 +20,9 @@ export default function ReviewSidebar({
|
|||||||
jumpToNextAnnotated,
|
jumpToNextAnnotated,
|
||||||
batchAnnotationCount,
|
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 (
|
return (
|
||||||
<aside className="review-side stack">
|
<aside className="review-side stack">
|
||||||
{exemplarPanel}
|
{exemplarPanel}
|
||||||
@@ -26,6 +31,7 @@ export default function ReviewSidebar({
|
|||||||
<div className="class-list">
|
<div className="class-list">
|
||||||
{classesList.map((item) => {
|
{classesList.map((item) => {
|
||||||
const onFrame = annotations.filter((row) => row.class_id === item.class_id).length
|
const onFrame = annotations.filter((row) => row.class_id === item.class_id).length
|
||||||
|
const hidden = hiddenClasses.has(item.class_id)
|
||||||
return (
|
return (
|
||||||
<div className="class-row" key={item.class_id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div className="class-row" key={item.class_id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<button
|
<button
|
||||||
@@ -37,6 +43,15 @@ export default function ReviewSidebar({
|
|||||||
{item.name}
|
{item.name}
|
||||||
<span className="faint mono">{item.class_id + 1}</span>
|
<span className="faint mono">{item.class_id + 1}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ padding: '4px 6px' }}
|
style={{ padding: '4px 6px' }}
|
||||||
@@ -61,8 +76,8 @@ export default function ReviewSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel side-panel">
|
<div className="panel side-panel">
|
||||||
<h2>Shapes on this frame ({annotations.length})</h2>
|
<h2>Shapes on this frame ({visibleAnnotations.length})</h2>
|
||||||
{annotations.length === 0 ? (
|
{visibleAnnotations.length === 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="hint" style={{ marginBottom: 8 }}>
|
<p className="hint" style={{ marginBottom: 8 }}>
|
||||||
None on this frame — drag to draw a shape.
|
None on this frame — drag to draw a shape.
|
||||||
@@ -86,7 +101,7 @@ export default function ReviewSidebar({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="shape-list">
|
<ul className="shape-list">
|
||||||
{annotations.map((item) => (
|
{visibleAnnotations.map((item) => (
|
||||||
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
|
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
|
||||||
<button className="shape-pick" onClick={() => setSelectedId(item.id)}>
|
<button className="shape-pick" onClick={() => setSelectedId(item.id)}>
|
||||||
<span className="swatch" style={{ background: classColor(item.class_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 [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [activeJob, setActiveJob] = useState(null)
|
const [activeJob, setActiveJob] = useState(null)
|
||||||
|
const [hiddenClasses, setHiddenClasses] = useState(() => new Set())
|
||||||
|
|
||||||
const batchId = batch?.id || rawBatchId
|
const batchId = batch?.id || rawBatchId
|
||||||
const stripRef = useRef(null)
|
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 () => {
|
const removeMarked = useCallback(async () => {
|
||||||
if (!markedIds.length || !frame) return
|
if (!markedIds.length || !frame) return
|
||||||
@@ -460,7 +487,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
|||||||
<AnnotationCanvas
|
<AnnotationCanvas
|
||||||
frame={frame}
|
frame={frame}
|
||||||
imageUrl={api.frameUrl(frame.id)}
|
imageUrl={api.frameUrl(frame.id)}
|
||||||
annotations={annotations}
|
annotations={visibleAnnotations}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
activeClass={activeClass}
|
activeClass={activeClass}
|
||||||
assistMode={assistMode}
|
assistMode={assistMode}
|
||||||
@@ -503,9 +530,9 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
|||||||
className="btn"
|
className="btn"
|
||||||
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
|
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
|
||||||
onClick={markAll}
|
onClick={markAll}
|
||||||
disabled={!annotations.length}
|
disabled={!visibleAnnotations.length}
|
||||||
>
|
>
|
||||||
Select all ({annotations.length})
|
Select all ({visibleAnnotations.length})
|
||||||
</button>
|
</button>
|
||||||
<span className="muted" style={{ fontSize: '0.78rem' }}>
|
<span className="muted" style={{ fontSize: '0.78rem' }}>
|
||||||
{markedIds.length
|
{markedIds.length
|
||||||
@@ -623,6 +650,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
|||||||
reclass={reclass}
|
reclass={reclass}
|
||||||
clearClassInBatch={clearClassInBatch}
|
clearClassInBatch={clearClassInBatch}
|
||||||
clearClassInFrame={clearClassInFrame}
|
clearClassInFrame={clearClassInFrame}
|
||||||
|
hiddenClasses={hiddenClasses}
|
||||||
|
onToggleHide={toggleHide}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
setSelectedId={setSelectedId}
|
setSelectedId={setSelectedId}
|
||||||
|
|||||||
Reference in new issue
Block a user