feat: per-class autolabel params + frame-scoped class clear (REQ-180, REQ-181)
- REQ-181: class_params {name: {threshold?, iou_threshold?, min_box_frac?}}
on /preview and /autolabel, per-class override table in both modals;
empty overrides take the unchanged global path
- REQ-180: x button on each review sidebar class row clears that class on
the current frame only via bulk-delete, no confirmation
- includes REQ-178 empty date-folder cycle fix (archive_index.py)
This commit is contained in:
1 parent
c244ab9795
commit
1a99f2ffe4
16 files changed
+372
-45
No files matched your search
@@ -35,6 +35,7 @@ class AutolabelRequest(BaseModel):
|
||||
resume: bool = False
|
||||
append: bool = False
|
||||
custom_model_path: Optional[str] = None
|
||||
class_params: Optional[dict[str, dict[str, float]]] = None
|
||||
|
||||
class Exemplar(BaseModel):
|
||||
box: list[float] # [cx, cy, w, h], normalized 0..1
|
||||
@@ -53,6 +54,7 @@ class PreviewRequest(BaseModel):
|
||||
# Preview-only — `autolabel.start` deliberately has no equivalent.
|
||||
exemplars: Optional[list[Exemplar]] = None
|
||||
exemplar_class_name: Optional[str] = None
|
||||
class_params: Optional[dict[str, dict[str, float]]] = None
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/batches")
|
||||
@@ -116,7 +118,8 @@ def start_autolabel(batch_id: int, request: AutolabelRequest) -> dict:
|
||||
engine=request.engine, engines=engine_list, class_ids=request.class_ids,
|
||||
engine_classes=request.engine_classes,
|
||||
target_class_names=request.target_class_names,
|
||||
custom_model_path=request.custom_model_path)
|
||||
custom_model_path=request.custom_model_path,
|
||||
class_params=request.class_params)
|
||||
except batch_store.BatchError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
@router.post("/api/batches/inspect-model")
|
||||
@@ -189,6 +192,7 @@ def preview_autolabel(batch_id: int, request: PreviewRequest) -> dict:
|
||||
custom_model_path=request.custom_model_path,
|
||||
exemplars=[e.model_dump() for e in request.exemplars or []],
|
||||
exemplar_class_name=request.exemplar_class_name,
|
||||
class_params=request.class_params,
|
||||
)
|
||||
return {"shapes": shapes}
|
||||
except Exception as exc:
|
||||
|
||||
@@ -167,7 +167,14 @@ def cycles(project_id: int) -> List[dict]:
|
||||
|
||||
buckets: dict = {}
|
||||
for day in library.list_dates(project["video_root"]):
|
||||
for name in _video_names(project, day["date"]):
|
||||
names = _video_names(project, day["date"])
|
||||
if not names:
|
||||
# Folder still holds no recording: it must still appear in the
|
||||
# list, or a folder just created with "Folder baru" is invisible.
|
||||
buckets.setdefault(day["date"], {"cycle": day["date"], "video_count": 0,
|
||||
"flagged": 0, "first_start": None})
|
||||
continue
|
||||
for name in names:
|
||||
rel = f"{day['date']}/{name}"
|
||||
timing = _sidecar(project, rel) or known.get(rel) or {}
|
||||
cycle = timing.get("working_day") or day["date"]
|
||||
|
||||
+59
-5
@@ -17,6 +17,26 @@ DEFAULT_THRESHOLD = 0.35
|
||||
DEFAULT_IOU = 0.0
|
||||
|
||||
|
||||
def _parse_class_params(raw) -> dict:
|
||||
"""Normalize `class_params` (REQ-181): lowercased class name → overrides.
|
||||
|
||||
Unknown keys and non-numeric values are dropped, so a malformed payload
|
||||
degrades to the global values instead of failing the job."""
|
||||
out: dict = {}
|
||||
for name, values in (raw or {}).items():
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
entry = {}
|
||||
for key in ("threshold", "iou_threshold", "min_box_frac"):
|
||||
if key in values:
|
||||
try:
|
||||
entry[key] = float(values[key])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if entry:
|
||||
out[str(name).strip().lower()] = entry
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD,
|
||||
@@ -26,7 +46,8 @@ def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD,
|
||||
class_ids: Optional[List[int]] = None,
|
||||
engine_classes: Optional[dict[str, List[str]]] = None,
|
||||
custom_model_path: Optional[str] = None,
|
||||
target_class_names: Optional[List[str]] = None) -> dict:
|
||||
target_class_names: Optional[List[str]] = None,
|
||||
class_params: Optional[dict] = None) -> dict:
|
||||
batch = batches.get(batch_id)
|
||||
if batch is None:
|
||||
raise batches.BatchError("No such batch")
|
||||
@@ -41,7 +62,8 @@ def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD,
|
||||
"iou_threshold": iou_threshold, "min_box_frac": min_box_frac,
|
||||
"resume": resume, "append": append, "engine": active_engines[0], "engines": active_engines,
|
||||
"class_ids": class_ids, "engine_classes": engine_classes,
|
||||
"custom_model_path": custom_model_path, "target_class_names": target_class_names},
|
||||
"custom_model_path": custom_model_path, "target_class_names": target_class_names,
|
||||
"class_params": class_params},
|
||||
project_id=batch["project_id"],
|
||||
batch_id=batch_id,
|
||||
message=f"{batch['date_label']}/{batch['batch_label']} ({'+'.join(e.upper() for e in active_engines)})",
|
||||
@@ -85,6 +107,10 @@ def _run_autolabel(job) -> None:
|
||||
|
||||
conf = job.params.get("threshold", DEFAULT_THRESHOLD)
|
||||
iou_thresh = job.params.get("iou_threshold", DEFAULT_IOU)
|
||||
per_class = _parse_class_params(job.params.get("class_params"))
|
||||
# Predict at the LOWEST threshold in play so a class with a lower override
|
||||
# can still see its boxes; each box then passes its own class gate below.
|
||||
predict_conf = min([conf] + [v["threshold"] for v in per_class.values() if "threshold" in v])
|
||||
|
||||
yolo_model = None
|
||||
sam3_target_classes = []
|
||||
@@ -159,7 +185,7 @@ def _run_autolabel(job) -> None:
|
||||
all_raw_detections = []
|
||||
|
||||
if yolo_model is not None:
|
||||
results = yolo_model.predict(frame_file, conf=conf, verbose=False)
|
||||
results = yolo_model.predict(frame_file, conf=predict_conf, verbose=False)
|
||||
if results and len(results) > 0:
|
||||
model_names = results[0].names
|
||||
for box in results[0].boxes:
|
||||
@@ -189,6 +215,15 @@ def _run_autolabel(job) -> None:
|
||||
|
||||
score = float(box.conf[0].item())
|
||||
xyxyn = box.xyxyn[0].tolist()
|
||||
|
||||
cp = per_class.get(proj_cls_name) or per_class.get(raw_cls_name)
|
||||
if cp:
|
||||
if score < cp.get("threshold", conf):
|
||||
continue
|
||||
mb = cp.get("min_box_frac")
|
||||
if mb and mb > 0 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) < mb:
|
||||
continue
|
||||
|
||||
all_raw_detections.append(labeling.Detection(
|
||||
class_id=target_class_id,
|
||||
class_name=proj_cls_name or raw_cls_name,
|
||||
@@ -199,9 +234,21 @@ def _run_autolabel(job) -> None:
|
||||
|
||||
if selected_engine == "sam3" and sam3_target_classes:
|
||||
prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes]
|
||||
thr_list = iou_list = mb_list = None
|
||||
if per_class:
|
||||
names = [c["name"].strip().lower() for c in sam3_target_classes]
|
||||
if any("threshold" in v for v in per_class.values()):
|
||||
thr_list = [per_class.get(n, {}).get("threshold", conf) for n in names]
|
||||
if any("iou_threshold" in v for v in per_class.values()):
|
||||
iou_list = [per_class.get(n, {}).get("iou_threshold", iou_thresh) for n in names]
|
||||
if any("min_box_frac" in v for v in per_class.values()):
|
||||
mb_list = [per_class.get(n, {}).get("min_box_frac", job.params.get("min_box_frac", 0.0)) for n in names]
|
||||
res = labeling.label_image(
|
||||
frame_file, frame["filename"], prompts, conf,
|
||||
iou_threshold=iou_thresh, min_box_frac=job.params.get("min_box_frac", 0.0)
|
||||
iou_threshold=iou_thresh, min_box_frac=job.params.get("min_box_frac", 0.0),
|
||||
thresholds=thr_list,
|
||||
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
|
||||
min_box_fracs=mb_list,
|
||||
)
|
||||
if not res.error and res.detections:
|
||||
for det in res.detections:
|
||||
@@ -213,7 +260,14 @@ def _run_autolabel(job) -> None:
|
||||
elif res.error:
|
||||
job.log(f"[SAM3 ERROR] {frame['filename']}: {res.error}")
|
||||
|
||||
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh)
|
||||
iou_by_class_proj = {
|
||||
c["class_id"]: per_class[c["name"].strip().lower()]["iou_threshold"]
|
||||
for c in project["classes"]
|
||||
if c["name"].strip().lower() in per_class
|
||||
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)
|
||||
items = []
|
||||
for det in kept:
|
||||
if project["label_type"] == "bbox" or det.mask is None:
|
||||
|
||||
+33
-11
@@ -10,7 +10,7 @@ calls — see the domain invariants in `../AGENTS.md`.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -41,9 +41,12 @@ 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) -> List[Detection]:
|
||||
"""Greedy NMS per class: highest score wins within the SAME class."""
|
||||
if iou_threshold <= 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.
|
||||
|
||||
`iou_by_class` overrides the threshold per class id (REQ-181)."""
|
||||
if not iou_by_class and iou_threshold <= 0.0:
|
||||
return detections
|
||||
|
||||
by_class: dict[int, List[Detection]] = {}
|
||||
@@ -52,10 +55,14 @@ def deduplicate(detections: List[Detection], iou_threshold: float = 0.8) -> List
|
||||
by_class.setdefault(det.class_id, []).append(det)
|
||||
|
||||
kept: List[Detection] = []
|
||||
for cls_dets in by_class.values():
|
||||
for cls, cls_dets in by_class.items():
|
||||
iou = (iou_by_class or {}).get(cls, iou_threshold)
|
||||
if iou <= 0.0:
|
||||
kept.extend(cls_dets)
|
||||
continue
|
||||
cls_kept: List[Detection] = []
|
||||
for det in sorted(cls_dets, key=lambda d: d.score, reverse=True):
|
||||
if all(_iou(det.box, k.box) < iou_threshold for k in cls_kept):
|
||||
if all(_iou(det.box, k.box) < iou for k in cls_kept):
|
||||
cls_kept.append(det)
|
||||
kept.extend(cls_kept)
|
||||
return kept
|
||||
@@ -70,11 +77,16 @@ def label_image(
|
||||
min_box_frac: float = 0.0,
|
||||
exemplar_index: int = -1,
|
||||
exemplars: Optional[List[dict]] = None,
|
||||
thresholds: Optional[List[float]] = None,
|
||||
iou_by_class: Optional[Dict[int, float]] = None,
|
||||
min_box_fracs: Optional[List[float]] = 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."""
|
||||
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."""
|
||||
try:
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
except Exception as exc: # unreadable/corrupt frame: report, don't abort the job
|
||||
@@ -84,14 +96,24 @@ def label_image(
|
||||
try:
|
||||
if exemplars and 0 <= exemplar_index < len(prompts):
|
||||
detections = get_engine().detect_with_exemplars(
|
||||
image, prompts, threshold, exemplar_index, exemplars
|
||||
image, prompts, threshold, exemplar_index, exemplars,
|
||||
thresholds=thresholds
|
||||
)
|
||||
else:
|
||||
detections = get_engine().detect(image, prompts, threshold)
|
||||
detections = get_engine().detect(image, prompts, threshold,
|
||||
thresholds=thresholds)
|
||||
except Exception as exc:
|
||||
return ImageResult(image_path, rel_path, width, height, error=str(exc))
|
||||
|
||||
if min_box_frac > 0:
|
||||
if min_box_fracs is not None:
|
||||
def _keep(det) -> bool:
|
||||
frac = min_box_fracs[det.class_id] if det.class_id < len(min_box_fracs) else min_box_frac
|
||||
if frac <= 0:
|
||||
return True
|
||||
floor = width * height * frac
|
||||
return (det.box[2] - det.box[0]) * (det.box[3] - det.box[1]) >= floor
|
||||
detections = [d for d in detections if _keep(d)]
|
||||
elif min_box_frac > 0:
|
||||
floor = width * height * min_box_frac
|
||||
detections = [
|
||||
d for d in detections
|
||||
@@ -99,4 +121,4 @@ def label_image(
|
||||
]
|
||||
|
||||
return ImageResult(image_path, rel_path, width, height,
|
||||
deduplicate(detections, iou_threshold))
|
||||
deduplicate(detections, iou_threshold, iou_by_class=iou_by_class))
|
||||
+35
-4
@@ -12,7 +12,7 @@ import os
|
||||
from typing import List, Optional
|
||||
|
||||
from backend import batches, db, labeling, projects, review
|
||||
from backend.autolabel import DEFAULT_IOU, DEFAULT_THRESHOLD, _geometries
|
||||
from backend.autolabel import DEFAULT_IOU, DEFAULT_THRESHOLD, _geometries, _parse_class_params
|
||||
|
||||
def preview_frame(
|
||||
batch_id: int,
|
||||
@@ -25,6 +25,7 @@ def preview_frame(
|
||||
custom_model_path: Optional[str] = None,
|
||||
exemplars: Optional[List[dict]] = None,
|
||||
exemplar_class_name: Optional[str] = None,
|
||||
class_params: Optional[dict] = None,
|
||||
) -> List[dict]:
|
||||
batch = batches.get(batch_id)
|
||||
if not batch:
|
||||
@@ -69,11 +70,13 @@ def preview_frame(
|
||||
|
||||
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
|
||||
per_class = _parse_class_params(class_params)
|
||||
predict_conf = min([threshold] + [v["threshold"] for v in per_class.values() if "threshold" in v])
|
||||
|
||||
all_raw_detections = []
|
||||
|
||||
if yolo_model is not None:
|
||||
results = yolo_model.predict(frame_file, conf=threshold, verbose=False)
|
||||
results = yolo_model.predict(frame_file, conf=predict_conf, verbose=False)
|
||||
if results and len(results) > 0:
|
||||
model_names = results[0].names
|
||||
for box in results[0].boxes:
|
||||
@@ -103,6 +106,15 @@ def preview_frame(
|
||||
|
||||
score = float(box.conf[0].item())
|
||||
xyxyn = box.xyxyn[0].tolist()
|
||||
|
||||
cp = per_class.get(proj_cls_name) or per_class.get(raw_cls_name)
|
||||
if cp:
|
||||
if score < cp.get("threshold", threshold):
|
||||
continue
|
||||
mb = cp.get("min_box_frac")
|
||||
if mb and mb > 0 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) < mb:
|
||||
continue
|
||||
|
||||
all_raw_detections.append(labeling.Detection(
|
||||
class_id=target_class_id,
|
||||
class_name=proj_cls_name or raw_cls_name,
|
||||
@@ -125,10 +137,22 @@ def preview_frame(
|
||||
if c["name"].strip().lower() == wanted),
|
||||
-1,
|
||||
)
|
||||
thr_list = iou_list = mb_list = None
|
||||
if per_class:
|
||||
names = [c["name"].strip().lower() for c in sam3_target_classes]
|
||||
if any("threshold" in v for v in per_class.values()):
|
||||
thr_list = [per_class.get(n, {}).get("threshold", threshold) for n in names]
|
||||
if any("iou_threshold" in v for v in per_class.values()):
|
||||
iou_list = [per_class.get(n, {}).get("iou_threshold", iou_threshold) for n in names]
|
||||
if any("min_box_frac" in v for v in per_class.values()):
|
||||
mb_list = [per_class.get(n, {}).get("min_box_frac", min_box_frac) for n in names]
|
||||
res = labeling.label_image(
|
||||
frame_file, frame["filename"], prompts, threshold,
|
||||
iou_threshold=iou_threshold, min_box_frac=min_box_frac,
|
||||
exemplar_index=exemplar_index, exemplars=exemplars
|
||||
exemplar_index=exemplar_index, exemplars=exemplars,
|
||||
thresholds=thr_list,
|
||||
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
|
||||
min_box_fracs=mb_list,
|
||||
)
|
||||
if not res.error and res.detections:
|
||||
for det in res.detections:
|
||||
@@ -138,7 +162,14 @@ def preview_frame(
|
||||
det.class_name = real_cls["name"]
|
||||
all_raw_detections.append(det)
|
||||
|
||||
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold)
|
||||
iou_by_class_proj = {
|
||||
c["class_id"]: per_class[c["name"].strip().lower()]["iou_threshold"]
|
||||
for c in project["classes"]
|
||||
if c["name"].strip().lower() in per_class
|
||||
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)
|
||||
items = []
|
||||
for det in kept:
|
||||
if project["label_type"] == "bbox" or det.mask is None:
|
||||
|
||||
+11
-2
@@ -67,8 +67,12 @@ class Sam3Engine:
|
||||
)
|
||||
self.processor = Sam3Processor(self.model, device=self.device)
|
||||
|
||||
def detect(self, image: Image.Image, prompts: List[str], threshold: float) -> List[Detection]:
|
||||
"""Run every prompt against one image; prompt index becomes the class id."""
|
||||
def detect(self, image: Image.Image, prompts: List[str], threshold: float,
|
||||
thresholds: Optional[List[float]] = None) -> List[Detection]:
|
||||
"""Run every prompt against one image; prompt index becomes the class id.
|
||||
|
||||
`thresholds` overrides the confidence per prompt (REQ-181); still one
|
||||
`set_image` for the whole call — only the grounding head sees the change."""
|
||||
processor = Sam3Processor(self.model, device=self.device)
|
||||
processor.confidence_threshold = threshold
|
||||
|
||||
@@ -76,6 +80,8 @@ class Sam3Engine:
|
||||
with torch.autocast(self.device, dtype=self.autocast_dtype):
|
||||
state = processor.set_image(image)
|
||||
for class_id, prompt in enumerate(prompts):
|
||||
if thresholds is not None and class_id < len(thresholds):
|
||||
processor.confidence_threshold = thresholds[class_id]
|
||||
output = processor.set_text_prompt(prompt=prompt, state=state)
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
if masks.shape[0] == 0:
|
||||
@@ -110,6 +116,7 @@ class Sam3Engine:
|
||||
threshold: float,
|
||||
exemplar_index: int,
|
||||
exemplars: List[dict],
|
||||
thresholds: Optional[List[float]] = None,
|
||||
) -> List[Detection]:
|
||||
"""`detect()`, but one prompt also carries drawn box exemplars (REQ-172).
|
||||
|
||||
@@ -124,6 +131,8 @@ class Sam3Engine:
|
||||
with torch.autocast(self.device, dtype=self.autocast_dtype):
|
||||
state = processor.set_image(image)
|
||||
for class_id, prompt in enumerate(prompts):
|
||||
if thresholds is not None and class_id < len(thresholds):
|
||||
processor.confidence_threshold = thresholds[class_id]
|
||||
processor.reset_all_prompts(state)
|
||||
output = processor.set_text_prompt(prompt=prompt, state=state)
|
||||
if class_id == exemplar_index:
|
||||
|
||||
+13
-3
@@ -174,11 +174,14 @@ GET /api/projects/{id}/video?rel=… # Range streaming (REQ-013)
|
||||
POST /api/projects/{id}/batches # {rel, start_sec, end_sec, fps} → extract job
|
||||
GET /api/batches/{id} # status + review progress (REQ-045)
|
||||
GET /api/batches/{id}/frames # frames + statuses
|
||||
POST /api/batches/{id}/autolabel # {threshold} → job (REQ-030,032,034)
|
||||
POST /api/batches/{id}/autolabel # {threshold, class_params} → job (REQ-030,032,034,181)
|
||||
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)
|
||||
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)
|
||||
POST /api/batches/{ids}/approve # one or many, comma-separated → one merge job (REQ-131)
|
||||
GET /api/batches/{ids}/triage/summary # one or many, comma-separated (REQ-130)
|
||||
GET /api/batches/{ids}/triage/shapes
|
||||
@@ -190,6 +193,7 @@ GET /api/frames/{id}/annotations
|
||||
POST /api/frames/{id}/annotations # add a manual shape (REQ-042)
|
||||
PATCH /api/annotations/{id} # move/resize/reclass
|
||||
DELETE /api/annotations/{id}
|
||||
POST /api/annotations/bulk-delete # {ids[]} → N deletes (REQ-180 frame-clear uses it)
|
||||
POST /api/frames/{id}/assist # click/box → SAM3 shape (REQ-043)
|
||||
POST /api/frames/{id}/exemplar-label # drawn pool → re-detect one class (REQ-173,174)
|
||||
POST /api/frames/{id}/status # approved | rejected | pending (REQ-041)
|
||||
@@ -238,7 +242,13 @@ 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
|
||||
`source='auto'`. A re-run deletes only `source='auto'` rows — manual corrections
|
||||
`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.
|
||||
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:
|
||||
the review canvas draws the shapes from the annotation rows, so a second rendering of the
|
||||
|
||||
@@ -74,7 +74,8 @@ changes.
|
||||
|
||||
- **REQ-178** — The Video Archive page can create a `YYYY-MM-DD` folder and upload videos
|
||||
into it. Uploads are streamed to disk, never overwrite an existing file, and only accept
|
||||
`video.VIDEO_EXTS`.
|
||||
`video.VIDEO_EXTS`. The new folder appears in the archive's cycle list immediately, even
|
||||
before it holds any video.
|
||||
- **REQ-179** — The Video Archive page shows a button that copies the archive's host path
|
||||
to the clipboard, as a Linux path and as a Windows (`\\wsl.localhost\<distro>\...`) path
|
||||
when known.
|
||||
@@ -97,6 +98,9 @@ changes.
|
||||
- **REQ-031** — Detections that overlap across prompts are deduplicated (greedy IoU NMS), so
|
||||
one object is not labelled as two classes at once.
|
||||
- **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
|
||||
values. Preview and the job apply the same overrides.
|
||||
- **REQ-033** — A frame with no detections is valid and still enters the dataset as a
|
||||
negative sample — it is not a failure.
|
||||
- **REQ-034** — Auto-annotation can be re-run on the same batch; previous automatic results
|
||||
@@ -167,6 +171,9 @@ changes.
|
||||
auto-annotate modal, and reset with the frame. Defaults are confidence `0.5`, NMS `0.8`,
|
||||
min box `0.002`, max `100` — deliberately permissive, because on a dense frame an
|
||||
aggressive NMS or area floor deletes real, touching objects rather than duplicates.
|
||||
- **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.
|
||||
|
||||
## E4. Live counting preview
|
||||
|
||||
|
||||
@@ -1216,6 +1216,19 @@ failure modes to the counting path. The saving was always on the browser side.
|
||||
`vite build` passes (66 modules). Browser click-test of copy buttons/upload dialog is NOT
|
||||
automated — manual click-test pending.
|
||||
|
||||
## Task — Empty date folder visible in cycle list (REQ-178) `[DONE]`
|
||||
|
||||
1. `archive_index.cycles()` must return a bucket for a date folder with no videos →
|
||||
verify: **[DONE]** `POST .../library/dates?date=2099-01-01` → 200; then
|
||||
`GET .../archive/cycles` contains `2099-01-01` with `video_count: 0` (and the
|
||||
pre-existing empty folder `2026-08-31`, previously invisible, also appears);
|
||||
`GET .../archive/cycles/2099-01-01` returns `videos: []`; the same JSON arrives
|
||||
through the dev proxy on 5173; folder removed after verification → cycle gone from
|
||||
the list again; `uvx ruff check` output identical to HEAD (13 pre-existing, 0 new);
|
||||
`vite build` passes. Browser click-test is NOT automated — manual click-test pending.
|
||||
Docker backend rebuilt after the fix: `:9010/.../archive/cycles` now returns the
|
||||
empty folders `2026-08-29` and `2026-08-31` with `video_count: 0`.
|
||||
|
||||
## Task — Copy-path buttons (REQ-179) `[DONE]`
|
||||
|
||||
1. Copy-path buttons → verify: **[DONE]** project payload carries
|
||||
@@ -1229,6 +1242,32 @@ failure modes to the counting path. The saving was always on the browser side.
|
||||
nginx `client_max_body_size 20g` verified (600MB upload → 200); `/api/health` 200, `/docs`
|
||||
200, frontend `http://localhost:9000` 200 after rebuild.
|
||||
|
||||
## Task — Frame-scoped per-class clear in review editor (REQ-180) `[DONE]`
|
||||
|
||||
1. `×` button on each sidebar class row clears that class's shapes on the **current frame
|
||||
only**, no confirmation, batch-wide trash (REQ-046) unchanged →
|
||||
verify: **[DONE]** `ReviewPage.clearClassInFrame` filters the current frame's annotations
|
||||
and posts only those ids to `POST /annotations/bulk-delete`; round-trip on `:9010` —
|
||||
created 2 shapes on frame 23827, bulk-delete returned `{"deleted":2}`, frame back to
|
||||
`annotations: []`, DB clean after test; optimistic update + rollback wired the same way as
|
||||
`removeMarked`; `vite build` passes (68 modules), rebuilt image on `:9000` serves the new
|
||||
bundle (`index-wBeBdXus.js`). Browser click-test of the `×` button is NOT automated —
|
||||
manual click-test pending.
|
||||
|
||||
## Task — Per-class auto-annotate params (REQ-181) `[DONE]`
|
||||
|
||||
1. `class_params` through preview + job on both modals → verify: **[DONE]** ruff on the 5
|
||||
changed backend files vs HEAD: +17, all `UP006`/`UP035`/`UP045` (the files' existing
|
||||
style), 0 new real findings; on rebuilt backend `:9010` — `/preview` baseline (absent and
|
||||
`null` `class_params`) → 18 shapes, `class_params: {sack: {threshold: 0.99}}` → 0 shapes,
|
||||
`threshold: "high"` → 422 `float_parsing`; `/autolabel` accepted
|
||||
`{class_params: {sack: {threshold: 0.9, min_box_frac: 0.01}}}`, job 106's stored params
|
||||
carry it, `iou_threshold: "x"` → 422, job cancelled and test data reset
|
||||
(`reset-auto-annotations`, batch 21 back to 0 annotations); omitted `class_params` takes
|
||||
the unchanged global path; `vite build` passes, `:9000` serves the new bundle (marker
|
||||
strings present). Browser click-test of the override tables is NOT automated — manual
|
||||
click-test pending.
|
||||
|
||||
## Known open points
|
||||
|
||||
- *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume
|
||||
|
||||
+19
-1
@@ -449,6 +449,11 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
|
||||
- Confidence threshold — 0.05…0.95 step 0.05, default 0.35.
|
||||
- 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
|
||||
`class_params` on **both** the preview and the job request, so preview and run cannot
|
||||
disagree. Classes without an override behave exactly as before.
|
||||
- `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.
|
||||
@@ -470,6 +475,7 @@ preview is the answer, so no button press sits between them. Re-running is cheap
|
||||
```json
|
||||
{ "frame_id": 12, "engine": "sam3|base_model|custom",
|
||||
"threshold": 0.35, "iou_threshold": 0.0, "min_box_frac": 0.0,
|
||||
"class_params": { "sack": { "threshold": 0.55 } },
|
||||
"target_class_names": ["sack"],
|
||||
"custom_model_path": null,
|
||||
"exemplars": [ { "box": [cx, cy, w, h], "positive": true } ],
|
||||
@@ -485,10 +491,14 @@ the prompt, they are not labels.
|
||||
```json
|
||||
{ "resume": false, "append": true, "engine": "sam3",
|
||||
"threshold": 0.35, "iou_threshold": 0.0, "min_box_frac": 0.0,
|
||||
"class_params": { "sack": { "threshold": 0.55, "min_box_frac": 0.01 } },
|
||||
"target_class_names": ["sack", "half-sack"],
|
||||
"custom_model_path": null }
|
||||
```
|
||||
|
||||
`class_params` is `null`/omitted when no cell is filled — the job then runs the unchanged
|
||||
global path (REQ-181).
|
||||
|
||||
#### 4.4.2 Mass Auto-Annotate modal
|
||||
|
||||
Same anatomy, wider (1040 px), with three differences:
|
||||
@@ -501,6 +511,10 @@ 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
|
||||
`class_params` ride along on every preview and start request — one set of overrides applies
|
||||
to all selected batches.
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Review — `#/projects/{id}/review?batch={id}` or `#/batches/{id}`
|
||||
@@ -1085,7 +1099,11 @@ network.
|
||||
- **Classes** — one row per class: a chip (swatch + name + its `1`-based hotkey) that sets the
|
||||
active class *and* reclasses the selected shape, plus a trash button that clears **every shape
|
||||
of that class across the whole batch** (`DELETE /batches/{id}/classes/{classId}/annotations`,
|
||||
confirmed).
|
||||
confirmed). Next to it sits an `×` button that clears that class **on the current frame only**
|
||||
(REQ-180): one click, **no confirmation dialog**, it posts just this frame's shape ids of
|
||||
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).
|
||||
- **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
|
||||
|
||||
@@ -3,19 +3,8 @@ import { api } from '../api'
|
||||
import ExemplarCanvas from './ExemplarCanvas'
|
||||
import { PreviewShapes } from './PreviewShapes'
|
||||
import ClassPromptPanel from './ClassPromptPanel'
|
||||
|
||||
function useDebounce(value, delay) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
return debouncedValue
|
||||
}
|
||||
import ClassParamsTable, { buildClassParams } from './ClassParamsTable'
|
||||
import useDebounce from '../hooks/useDebounce'
|
||||
|
||||
export default function AutoAnnotateModal({
|
||||
batch,
|
||||
@@ -29,6 +18,7 @@ export default function AutoAnnotateModal({
|
||||
const [threshold, setThreshold] = useState(0.35)
|
||||
const [iouThreshold, setIouThreshold] = useState(0.0)
|
||||
const [minBoxFrac, setMinBoxFrac] = useState(0.0)
|
||||
const [classParams, setClassParams] = useState({})
|
||||
|
||||
|
||||
// Class selection state
|
||||
@@ -97,6 +87,7 @@ export default function AutoAnnotateModal({
|
||||
iou_threshold: iouThreshold,
|
||||
min_box_frac: minBoxFrac,
|
||||
target_class_names: selectedClasses,
|
||||
class_params: buildClassParams(classParams),
|
||||
custom_model_path: customModelStagedPath,
|
||||
exemplars: engine === 'sam3' ? exemplars : [],
|
||||
exemplar_class_name: engine === 'sam3' ? activeClassName : null
|
||||
@@ -195,6 +186,7 @@ export default function AutoAnnotateModal({
|
||||
iou_threshold: iouThreshold,
|
||||
min_box_frac: minBoxFrac,
|
||||
target_class_names: selectedClasses,
|
||||
class_params: buildClassParams(classParams),
|
||||
custom_model_path: customModelStagedPath
|
||||
})
|
||||
onSuccess()
|
||||
@@ -363,6 +355,13 @@ export default function AutoAnnotateModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClassPromptPanel
|
||||
classes={project.classes}
|
||||
selectedClasses={selectedClasses}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
|
||||
// Per-class overrides of the job's globals (REQ-181). Empty input = inherit.
|
||||
const KEYS = [
|
||||
{ key: 'threshold', label: 'Conf', step: 0.05, color: '#38bdf8' },
|
||||
{ key: 'iou_threshold', label: 'IoU', step: 0.05, color: '#c084fc' },
|
||||
{ key: 'min_box_frac', label: 'MinBox', step: 0.005, color: '#34d399' },
|
||||
]
|
||||
|
||||
export function buildClassParams(classParams) {
|
||||
const out = {}
|
||||
for (const [name, values] of Object.entries(classParams || {})) {
|
||||
const entry = {}
|
||||
for (const { key } of KEYS) {
|
||||
const raw = values[key]
|
||||
if (raw === '' || raw == null) continue
|
||||
const num = parseFloat(raw)
|
||||
if (Number.isFinite(num)) entry[key] = num
|
||||
}
|
||||
if (Object.keys(entry).length > 0) out[name] = entry
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
export default function ClassParamsTable({ classNames, globals, value, onChange }) {
|
||||
const set = (name, key, raw) =>
|
||||
onChange({ ...value, [name]: { ...(value[name] || {}), [key]: raw } })
|
||||
if (!classNames.length) return null
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="hint" style={{ fontWeight: 400, fontSize: '0.74rem', textAlign: 'left', padding: '2px 4px' }}>Class</th>
|
||||
{KEYS.map(k => (
|
||||
<th key={k.key} className="hint" style={{ fontWeight: 400, fontSize: '0.74rem', color: k.color, padding: '2px 4px' }}>{k.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classNames.map(name => (
|
||||
<tr key={name}>
|
||||
<td className="mono" style={{ fontSize: '0.76rem', padding: '2px 4px' }}>{name}</td>
|
||||
{KEYS.map(({ key, label, step, color }) => (
|
||||
<td key={key} style={{ padding: '2px 4px' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={value[name]?.[key] ?? ''}
|
||||
min={0}
|
||||
max={1}
|
||||
step={step}
|
||||
placeholder={String(globals[key])}
|
||||
title={`Empty = use the global value (${globals[key]})`}
|
||||
aria-label={`${name} ${label} override, empty uses global ${globals[key]}`}
|
||||
onChange={(e) => set(name, key, e.target.value)}
|
||||
style={{
|
||||
width: 64, fontSize: '0.76rem', background: '#09090b', color,
|
||||
border: '1px solid #3f3f46', borderRadius: 4, padding: '2px 4px',
|
||||
cursor: 'text',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { PreviewShapes } from './PreviewShapes'
|
||||
import ClassParamsTable, { buildClassParams } from './ClassParamsTable'
|
||||
|
||||
const ENGINES = [
|
||||
{ id: 'sam3', label: 'SAM3' },
|
||||
@@ -17,6 +18,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc
|
||||
const [threshold, setThreshold] = useState(0.35)
|
||||
const [iouThreshold, setIouThreshold] = useState(0.0)
|
||||
const [minBoxFrac, setMinBoxFrac] = useState(0.0)
|
||||
const [classParams, setClassParams] = useState({})
|
||||
const [append, setAppend] = useState(true)
|
||||
|
||||
const available = engine === 'custom' ? customClasses : project.classes.map(c => c.name)
|
||||
@@ -81,6 +83,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc
|
||||
iou_threshold: iouThreshold,
|
||||
min_box_frac: minBoxFrac,
|
||||
target_class_names: selectedClasses,
|
||||
class_params: buildClassParams(classParams),
|
||||
custom_model_path: customPath,
|
||||
}).then(res => setPreviewShapes(res.shapes || []))
|
||||
.catch(exc => { setError(exc.message); setPreviewShapes([]) })
|
||||
@@ -114,6 +117,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc
|
||||
iou_threshold: iouThreshold,
|
||||
min_box_frac: minBoxFrac,
|
||||
target_class_names: selectedClasses,
|
||||
class_params: buildClassParams(classParams),
|
||||
custom_model_path: customPath,
|
||||
})
|
||||
} catch {
|
||||
@@ -243,6 +247,13 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc
|
||||
<Slider label="Min Box Size (Fraction of Frame)" value={minBoxFrac} min={0} max={0.5} step={0.005}
|
||||
color="#34d399" onChange={setMinBoxFrac} format={v => `${(v * 100).toFixed(1)}%`} />
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="row" style={{ gap: 6, alignItems: 'center', marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={append} onChange={(e) => setAppend(e.target.checked)} style={{ cursor: 'pointer' }} />
|
||||
<span className="hint" style={{ fontSize: '0.8rem' }}>Append to existing annotations</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { classColor } from '../api'
|
||||
import { TrashIcon } from './Icons'
|
||||
import { TrashIcon, XIcon } from './Icons'
|
||||
import ShortcutsPanel from './ShortcutsPanel'
|
||||
|
||||
export default function ReviewSidebar({
|
||||
@@ -9,6 +9,7 @@ export default function ReviewSidebar({
|
||||
activeClass,
|
||||
reclass,
|
||||
clearClassInBatch,
|
||||
clearClassInFrame,
|
||||
annotations,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
@@ -23,7 +24,9 @@ export default function ReviewSidebar({
|
||||
<div className="panel side-panel">
|
||||
<h2>Classes</h2>
|
||||
<div className="class-list">
|
||||
{classesList.map((item) => (
|
||||
{classesList.map((item) => {
|
||||
const onFrame = annotations.filter((row) => row.class_id === item.class_id).length
|
||||
return (
|
||||
<div className="class-row" key={item.class_id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
className={`class-chip ${item.class_id === activeClass ? 'active' : ''}`}
|
||||
@@ -34,6 +37,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' }}
|
||||
title={`Clear ${onFrame} "${item.name}" shape${onFrame === 1 ? '' : 's'} on this frame`}
|
||||
disabled={onFrame === 0}
|
||||
onClick={() => clearClassInFrame(item)}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 6px' }}
|
||||
@@ -43,7 +55,8 @@ export default function ReviewSidebar({
|
||||
<TrashIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function useDebounce(value, delay) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
return debouncedValue
|
||||
}
|
||||
@@ -392,6 +392,26 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}
|
||||
|
||||
async function clearClassInFrame(item) {
|
||||
const doomed = annotations.filter((row) => row.class_id === item.class_id)
|
||||
if (!doomed.length || !frame) return
|
||||
const ids = new Set(doomed.map((row) => row.id))
|
||||
const previous = annotations
|
||||
const previousCount = frame.annotation_count ?? 0
|
||||
setAnnotations((rows) => rows.filter((row) => !ids.has(row.id)))
|
||||
setMarkedIds((rows) => rows.filter((id) => !ids.has(id)))
|
||||
patchFrameLocally(frame.id, { annotation_count: Math.max(0, previousCount - doomed.length) })
|
||||
try {
|
||||
await api.bulkDeleteAnnotations(doomed.map((row) => row.id))
|
||||
setBatch(await api.getBatch(batchId))
|
||||
} catch (exc) {
|
||||
setAnnotations(previous)
|
||||
setMarkedIds((rows) => [...rows, ...doomed.filter((row) => !rows.includes(row.id)).map((row) => row.id)])
|
||||
patchFrameLocally(frame.id, { annotation_count: previousCount })
|
||||
setError(exc.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
@@ -602,6 +622,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
activeClass={activeClass}
|
||||
reclass={reclass}
|
||||
clearClassInBatch={clearClassInBatch}
|
||||
clearClassInFrame={clearClassInFrame}
|
||||
annotations={annotations}
|
||||
selectedId={selectedId}
|
||||
setSelectedId={setSelectedId}
|
||||
|
||||
Reference in new issue
Block a user