From 5c7c1221054b14a3d1ec0c55da3be9af250c6a8e Mon Sep 17 00:00:00 2001 From: asus Date: Fri, 14 Aug 2026 16:28:52 +0700 Subject: [PATCH] feat: add counting bench, triage, and dataset modules This commit includes major additions and updates to the frontend and backend architectures, introducing new dataset management, live counting features, batch processing, and triage logic. Includes new UI pages, components, and API routes. --- .dockerignore | 8 + ...ommand-caveatcaveat-the-messages-below.txt | 6029 +++++++++++++++++ Dockerfile | 8 + algoritma-batch/batch_video_cropper.py | 748 +- algoritma-batch/batch_video_cropper.py.bak | 510 ++ algoritma-batch/counting.py | 259 + algoritma-batch/detection.py | 82 + algoritma-batch/migrate_cutoff_0600.py | 152 + algoritma-batch/predict.py | 1405 ++++ algoritma-batch/src/counting.py | 324 +- algoritma-batch/src/h264_converter.py | 39 + algoritma-batch/test_batch_logic.py | 685 ++ backend/api/batches.py | 81 +- backend/api/counting_bench.py | 164 + backend/api/datasets.py | 101 + backend/api/live_count.py | 142 + backend/api/models.py | 4 + backend/api/projects.py | 9 + backend/api/review.py | 23 + backend/api/triage.py | 222 + backend/archive_index.py | 448 ++ backend/augment.py | 90 + backend/autolabel.py | 183 +- backend/base_dataset.py | 220 + backend/batches.py | 25 + backend/counting_bench.py | 319 + backend/dataset.py | 522 +- backend/datasets.py | 220 + backend/db.py | 256 +- backend/export.py | 303 + backend/hardware.py | 6 +- backend/jobs.py | 6 +- backend/labeling.py | 4 + backend/library.py | 67 +- backend/live_count.py | 467 ++ backend/main.py | 14 +- backend/projects.py | 10 +- backend/review.py | 39 + backend/sam3_engine.py | 16 +- backend/test.py | 18 + backend/test_preview.py | 21 + backend/training.py | 108 +- backend/triage.py | 531 ++ backend/video.py | 95 +- backend/video_clock.py | 249 + docs/annotation-policy.md | 72 + docs/audit-2026-08-07.md | 181 + docs/design.md | 11 +- docs/proposal-dataprep-triage.md | 146 + docs/requirements.md | 89 + docs/tasks.md | 214 + frontend/src/App.jsx | 71 +- frontend/src/api.js | 124 +- frontend/src/app.css | 45 + frontend/src/components/AnnotationCanvas.jsx | 37 +- frontend/src/components/AugmentationPanel.jsx | 126 + frontend/src/components/AutoAnnotateModal.jsx | 335 + frontend/src/components/Icons.jsx | 18 + .../src/components/MassAutoAnnotateModal.jsx | 336 + frontend/src/components/MergeTargetModal.jsx | 131 + frontend/src/components/OutlierFilter.jsx | 174 + frontend/src/components/ReviewSidebar.jsx | 9 +- frontend/src/components/Shape.jsx | 19 +- frontend/src/components/ShortcutsPanel.jsx | 16 +- frontend/src/components/Sidebar.jsx | 180 +- frontend/src/components/TriageCropGrid.jsx | 178 + frontend/src/components/TriageScatter.jsx | 220 + frontend/src/pages/BatchesPage.jsx | 173 + frontend/src/pages/CountingBenchPage.jsx | 551 ++ frontend/src/pages/DataPrepPage.jsx | 407 ++ frontend/src/pages/DatasetsPage.jsx | 218 + frontend/src/pages/LibraryPage.jsx | 787 +-- frontend/src/pages/LiveCountPage.jsx | 412 ++ frontend/src/pages/ModelsPage.jsx | 374 +- frontend/src/pages/ReviewPage.jsx | 272 +- frontend/src/roboflow.css | 173 +- requirements.txt | 3 + scratch/refactor.py | 69 + scripts/transcode_archive.py | 66 + start.sh | 17 +- 80 files changed, 20074 insertions(+), 1412 deletions(-) create mode 100644 .dockerignore create mode 100644 2026-08-07-170259-local-command-caveatcaveat-the-messages-below.txt create mode 100644 algoritma-batch/batch_video_cropper.py.bak create mode 100644 algoritma-batch/counting.py create mode 100644 algoritma-batch/detection.py create mode 100644 algoritma-batch/migrate_cutoff_0600.py create mode 100644 algoritma-batch/predict.py create mode 100644 algoritma-batch/src/h264_converter.py create mode 100644 algoritma-batch/test_batch_logic.py create mode 100644 backend/api/counting_bench.py create mode 100644 backend/api/datasets.py create mode 100644 backend/api/live_count.py create mode 100644 backend/api/triage.py create mode 100644 backend/archive_index.py create mode 100644 backend/augment.py create mode 100644 backend/base_dataset.py create mode 100644 backend/counting_bench.py create mode 100644 backend/datasets.py create mode 100644 backend/export.py create mode 100644 backend/live_count.py create mode 100644 backend/test.py create mode 100644 backend/test_preview.py create mode 100644 backend/triage.py create mode 100644 backend/video_clock.py create mode 100644 docs/annotation-policy.md create mode 100644 docs/audit-2026-08-07.md create mode 100644 docs/proposal-dataprep-triage.md create mode 100644 frontend/src/components/AugmentationPanel.jsx create mode 100644 frontend/src/components/AutoAnnotateModal.jsx create mode 100644 frontend/src/components/MassAutoAnnotateModal.jsx create mode 100644 frontend/src/components/MergeTargetModal.jsx create mode 100644 frontend/src/components/OutlierFilter.jsx create mode 100644 frontend/src/components/TriageCropGrid.jsx create mode 100644 frontend/src/components/TriageScatter.jsx create mode 100644 frontend/src/pages/BatchesPage.jsx create mode 100644 frontend/src/pages/CountingBenchPage.jsx create mode 100644 frontend/src/pages/DataPrepPage.jsx create mode 100644 frontend/src/pages/DatasetsPage.jsx create mode 100644 frontend/src/pages/LiveCountPage.jsx create mode 100644 scratch/refactor.py create mode 100755 scripts/transcode_archive.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2122162 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +data/ +.venv/ +.git/ +node_modules/ +frontend/node_modules/ +frontend/dist/ +__pycache__/ +*.pyc diff --git a/2026-08-07-170259-local-command-caveatcaveat-the-messages-below.txt b/2026-08-07-170259-local-command-caveatcaveat-the-messages-below.txt new file mode 100644 index 0000000..c7e3f4c --- /dev/null +++ b/2026-08-07-170259-local-command-caveatcaveat-the-messages-below.txt @@ -0,0 +1,6029 @@ + ▐▛███▜▌ Claude Code v2.1.224 +▝▜█████▛▘ Opus 5 · Claude Pro + ▘▘ ▝▝ ~/reTraining + + Tackle your toughest work with Opus 5. Switch anytime with /model. + +❯ /clear + +❯ please create a deep understanding using workflow to understand this project + repo and bugs, give me recomendation of improving the code especially for + make this project funtion as i want. ask me a lot before you do that lot of + task, swarm cheaper agent model for helping you, you can use opus also but + its only for complex or task, i guess you can defined yourself. the goal is + to make a better workflow for reTraining my ai model, if i had a mistake just + say it, your goal is to help me make this app works well and ready to use. + + Searched for 1 pattern, listed 1 directory (ctrl+o to expand) + +● Repo is substantial (backend ~3.5k lines, 8 frontend pages, docs with REQ-xxx + already agreed). Before I spend a workflow on it, four things: + +● User answered Claude's questions: + ⎿  · What's actually broken or unsatisfying right now? Where should the audit + bite hardest? → Retraining loop itself, Auto-annotate / SAM3, Review editor + UX, Everything / I don't know yet + · Have you run a full round end-to-end yet (video → frames → annotate → + review → merge → train → compare)? → Finished once, but rough + · There's a lot of uncommitted work (24 modified files, 5 new untracked). + What's its status? → Half-broken experiments + · What do you want me to deliver at the end of the audit? → Report, then + fix the critical bugs + +● Good — full sweep it is. Launching the workflow now. + +● Running in the background. Structure: + + - Map (4 parallel) — retraining core, SAM3/autolabel, review editor, + docs-drift + your dirty working tree + - Hunt (5 dimensions, each finding verified by an adversarial refuter as it + lands) — invariants/data corruption, concurrency & jobs, API contract, + frontend correctness, ops/portability + - Synthesize — mechanism-level system map, ranked bugs, per-file verdict on + the uncommitted work, workflow gaps where the design itself is wrong, and an + ordered fix plan with verification steps + + Watch live with /workflows. When it lands I'll relay the report and then start + on the critical fixes. + +✻ Brewed for 1m 26s + +● Dynamic workflow "Deep audit of the reTraining dataset-enrichment app: map +architecture, find bugs, verify, prioritize fixes" completed · 7m 59s + ⎿  You've hit your session limit · resets 12:10pm (Asia/Jakarta) + /upgrade to increase your usage limit. + +✻ Crunched for 10m 22s + +❯ continue + +● The audit ran but hit the session usage limit near the end — the final + synthesis agent and 26 verifiers died. 22 bugs did survive verification. Let + me pull the full results and write the report myself. + + Ran 3 shell commands (ctrl+o to expand) + +● Two dimensions (concurrency, api-contract) lost all their verifiers to the + session limit — let me recover their raw findings from the journal so nothing + is silently dropped. + + Ran 6 shell commands (ctrl+o to expand) + +● Recovered everything. One key thing to confirm before I write it up — the two + CRITICALs point at jobs.py, which is in your dirty tree: + + Ran 1 shell command (ctrl+o to expand) + +● Confirmed — that uncommitted jobs.py edit is the root of both CRITICALs. + Writing the report. + +● Write(docs/audit-2026-08-07.md) + ⎿  Wrote 181 lines to docs/audit-2026-08-07.md + 1 # Audit — reTraining, 2026-08-07 + 2 + by dimension, adversarial + 4 verification of each finding. 22 findings survived verification; + 2 CRITICALs come from a + e marked *unverified* — both + 6 were confirmed by hand afterwards. + 7 + 9 + 10 ## 1. How the system actually works + … +343 lines (ctrl+o to expand) + +● Report written to docs/audit-2026-08-07.md. 22 verified findings + 2 + hand-confirmed criticals. + + The headline: your biggest problem is uncommitted. You changed backend/jobs.py + to run every job in its own thread instead of through the single worker + queue, and narrowed GPU_JOB_TYPES to ("train",) with the comment "multiple + autolabel jobs can run in parallel." They can't — each SAM3 job holds a full + backbone in VRAM, and release_engine() only clears a module global, so it + cannot free memory held by a running job. That one edit also un-serialized + merge and train against the same dataset/ directory, which is the root of four + more HIGH findings. + + Three design-level things you got wrong (you asked me to say so): + + 1. The val split is positional — _next_split counts dataset_items rows, so + every Nth insert goes to val. Deleting a batch shifts the split for everything + merged afterwards. CLAUDE.md §8 declares the invariant but nothing enforces + it. Make it hash(project + batch + stem) % 5 and it becomes true by + construction. + 2. Merged labels are regenerated from live annotations on every training start + (sync_labels at dataset.py:117, no review_status filter). Re-run + auto-annotate on a merged batch, press Train, and raw unreviewed model output + overwrites your human-reviewed labels. A merged dataset should be a snapshot, + not a view. + 3. The base-vs-new comparison degrades silently instead of refusing. Empty val + set → it validates on the training images. Deleted batch → orphaned JPEGs + still sit in images/val. For a tool whose only job is answering "did + retraining help?", every one of those returns a plausible-looking number. + + Worth knowing: docker-compose.override.yml is committed with + nvidia.com/gpu=all (CPU hosts can't start), and start.sh deletes that tracked + file on every run — silently dropping your ./backend bind mount. + + Before I start fixing: +● User answered Claude's questions: + ⎿  · Fixing C1/C2 means reverting your jobs.py parallelism change. Was that + experiment solving a real problem I should address differently? → Revert, + but autolabel was too slow + · How far do I go in this pass? → Criticals + design + review editor + · The content-derived val split changes how existing merged frames are + assigned. Existing data? → Preserve existing assignments + +● Understood: revert for correctness, then look at autolabel speed separately. + Steps 1–7, preserving existing splits. + + Read 2 files (ctrl+o to expand) + +● Now fixing. Step 1 — restore the job queue: + +● Update(backend/jobs.py) + ⎿  Added 3 lines, removed 5 lines + 28 PROGRESS_FLUSH_SECONDS = 0.5 + 29 + 30 JOB_TYPES = ("extract", "autolabel", "merge", "train") + 31 -GPU_JOB_TYPES = ("train",) + 32 -"""`train` takes full GPU memory during model fitting. `autolab + -el` (SAM3/YOLO inference) + 33 -runs fine-grained per-frame steps so multiple autolabel jobs ca + -n run in parallel.""" + 34 - + 35 - + 31 +GPU_JOB_TYPES = ("autolabel", "train") + 32 +"""`extract` is ffmpeg and `merge` is file copying — neither to + +uches the card, + 33 +so neither should be able to block an interactive assist.""" + 34 gpu_lock = threading.Lock() + 35 """Held for the duration of any GPU work. The job worker takes + it around a + 36 handler; the interactive assist route takes it around one SAM3 + call. One card, + +● Update(backend/jobs.py) + ⎿  Added 2 lines, removed 12 lines + 147 job_id = cur.lastrowid + 148 job = get(job_id) + 149 assert job is not None + 150 - threading.Thread(target=_start_job, args=(job_id,), name=f + -"job-{job_type}-{job_id}", daemon=True).start() + 150 + _queue.put(job_id) + 151 + _ensure_worker() + 152 return job + 153 + 154 + 154 -def _start_job(job_id: int) -> None: + 155 - job = get(job_id) + 156 - if job is None: + 157 - return + 158 - if job.id in _cancelled: + 159 - _finish(job, "cancelled") + 160 - return + 161 - _run(job) + 162 - + 163 - + 164 - + 155 def get(job_id: int) -> Optional[Job]: + 156 with db.cursor() as cur: + 157 cur.execute("SELECT * FROM jobs WHERE id = ?", (job_id + ,)) + +● Update(backend/jobs.py) + ⎿  Added 20 lines, removed 1 line + 206 return cur.rowcount + 207 + 208 + 209 -def _run(job: Job) -> None: + 209 +def _ensure_worker() -> None: + 210 + global _worker + 211 + with _worker_lock: + 212 + if _worker is None or not _worker.is_alive(): + 213 + _worker = threading.Thread(target=_worker_loop, na + +me="job-worker", daemon=True) + 214 + _worker.start() + 215 + 216 + + 217 +def _worker_loop() -> None: + 218 + while True: + 219 + job_id = _queue.get() + 220 + job = get(job_id) + 221 + if job is None: + 222 + continue + 223 + if job.id in _cancelled: + 224 + _finish(job, "cancelled") + 225 + continue + 226 + _run(job) + 227 + + 228 + + 229 +def _run(job: Job) -> None: + 230 job.status = "running" + 231 job.started_at = time.time() + 232 job.flush() + + Read 1 file, ran 1 shell command (ctrl+o to expand) + +● jobs.py is back to baseline. Step 2 — the synchronous GPU routes. Let me match + the existing review.assist pattern: + + Ran 1 shell command (ctrl+o to expand) + +● Update(backend/api/batches.py) + ⎿  Added 8 lines, removed 1 line + 162 + 163 @router.post("/api/batches/{batch_id}/preview") + 164 def preview_autolabel(batch_id: int, request: PreviewRequest) + -> dict: + 165 - from backend import autolabel + 165 + from backend import autolabel, jobs + 166 + + 167 + if not jobs.gpu_lock.acquire(timeout=20): + 168 + busy = jobs.running_types() + 169 + kind = busy[0] if busy else "background" + 170 + raise HTTPException(409, f"The GPU is busy with a {kin + +d} job — wait for it to finish") + 171 try: + 172 shapes = autolabel.preview_frame( + 173 batch_id=batch_id, + ... + 182 return {"shapes": shapes} + 183 except Exception as exc: + 184 raise HTTPException(400, str(exc)) + 185 + finally: + 186 + jobs.gpu_lock.release() + 187 + 188 @router.post("/api/sam3/playground-test") + 189 async def sam3_playground_test( + +● Update(backend/api/batches.py) + ⎿  Added 8 lines + 207 if not prompt_list: + 208 raise HTTPException(400, "At least one text prompt is + required") + 209 + 210 + from backend import jobs + 211 + + 212 + if not jobs.gpu_lock.acquire(timeout=20): + 213 + busy = jobs.running_types() + 214 + kind = busy[0] if busy else "background" + 215 + raise HTTPException(409, f"The GPU is busy with a {kin + +d} job — wait for it to finish") + 216 try: + 217 engine = get_engine() + 218 raw_dets = engine.detect(image, prompt_list, threshold + ) + 219 kept_dets = labeling.deduplicate(raw_dets, iou_thresho + ld=iou_threshold) + 220 except Exception as exc: + 221 raise HTTPException(500, f"SAM3 inference failed: {exc + }") + 222 + finally: + 223 + jobs.gpu_lock.release() + 224 + 225 results = [] + 226 for det in kept_dets: + +● Step 3, 4 and 6 all live in dataset.py. Rewriting that section: + +● Update(backend/dataset.py) + ⎿  Added 72 lines, removed 23 lines + 66 return f"{class_id} {coords}" + 67 + 68 + 69 -def _next_split(cur, project_id: int, val_every: int) -> str: + 70 - """Continue the every-Nth pattern from wherever the last m + -erge left off.""" + 69 +def split_for(project_id: int, batch_id: int, stem: str, val_e + +very: int) -> str: + 70 + """Which split a frame belongs to, derived from its identi + +ty rather than from + 71 + how many rows happen to precede it. + 72 + + 73 + A positional every-Nth rule makes membership depend on ins + +ertion history, so + 74 + deleting or re-merging a batch silently reshuffles every l + +ater frame — and a + 75 + frame that was in `val` for the last comparison could land + + in `train` for the + 76 + next one. Hashing the identity makes the stable-val-split + +invariant true by + 77 + construction: the same frame always lands in the same spli + +t, whatever else + 78 + happened to the dataset. Rows already in `dataset_items` k + +eep the split they + 79 + were recorded with; nothing recomputes them. + 80 + """ + 81 if val_every <= 0: + 82 return "train" + 73 - cur.execute("SELECT COUNT(*) FROM dataset_items WHERE proj + -ect_id = ?", (project_id,)) + 74 - position = cur.fetchone()[0] + 75 - return "val" if position % val_every == val_every - 1 else + - "train" + 83 + digest = hashlib.sha1(f"{project_id}/{batch_id}/{stem}".en + +code("utf-8")).hexdigest() + 84 + return "val" if int(digest[:8], 16) % val_every == 0 else + +"train" + 85 + 86 + 78 -def sync_labels(project_id: int, selected_class_ids: Optional[ + -List[int]] = None) -> dict: + 79 - """Re-sync label files on disk for all merged frames in th + -e project dataset.""" + 87 +def sync_labels(project_id: int) -> dict: + 88 + """Re-sync label files on disk from the reviewed annotatio + +ns. + 89 + + 90 + Only frames a human actually signed off on are written. A + +merged frame whose + 91 + batch was auto-annotated again drops back to `pending`, an + +d rewriting its + 92 + label file from the fresh model output would push predicti + +ons nobody checked + 93 + into the master dataset. + 94 + """ + 95 project = projects.get(project_id) + 96 root = dataset_dir(project["slug"]) + 97 with db.cursor() as cur: + 98 cur.execute( + 84 - "SELECT d.frame_id, d.label_rel FROM dataset_items + - d WHERE d.project_id = ?", + 99 + """SELECT d.frame_id, d.label_rel FROM dataset_ite + +ms d + 100 + JOIN frames f ON f.id = d.frame_id + 101 + WHERE d.project_id = ? AND f.review_status = 'a + +pproved'""", + 102 (project_id,), + 103 ) + 104 items = cur.fetchall() + 105 + 89 - class_map = None + 90 - if selected_class_ids is not None and len(selected_class_i + -ds) > 0: + 91 - class_map = {cid: idx for idx, cid in enumerate(sorted + -(selected_class_ids))} + 92 - + 106 synced_files = 0 + 107 total_lines = 0 + 108 for frame_id, label_rel in items: + 96 - annotations = review.listing(frame_id) + 97 - if class_map is not None: + 98 - annotations = [a for a in annotations if a["class_ + -id"] in class_map] + 99 - + 100 - lines = [] + 101 - for item in annotations: + 102 - mapped_cid = class_map[item["class_id"]] if class_ + -map is not None else item["class_id"] + 103 - lines.append(_label_line(mapped_cid, item["geometr + -y"], project["label_type"])) + 104 - + 109 + lines = [_label_line(item["class_id"], item["geometry" + +], project["label_type"]) + 110 + for item in review.listing(frame_id)] + 111 path = os.path.join(root, label_rel) + 112 os.makedirs(os.path.dirname(path), exist_ok=True) + 107 - with open(path, "w", encoding="utf-8") as f: + 108 - f.write("\n".join(lines) + ("\n" if lines else "") + -) + 113 + _write_atomic(path, "\n".join(lines) + ("\n" if lines + +else "")) + 114 synced_files += 1 + 115 total_lines += len(lines) + 116 + 117 return {"synced_files": synced_files, "total_lines": total + _lines} + 118 + 119 + 120 +def _write_atomic(path: str, text: str) -> None: + 121 + """Write via temp file + rename, so a training run never r + +eads a half-written + 122 + label file or a truncated data.yaml.""" + 123 + tmp = f"{path}.tmp" + 124 + with open(tmp, "w", encoding="utf-8") as handle: + 125 + handle.write(text) + 126 + os.replace(tmp, path) + 127 + + 128 + + 129 +def _build_selected_tree(root: str, rows: list, class_map: Opt + +ional[dict], + 130 + label_type: str) -> tuple: + 131 + """Materialise a filtered view of the dataset under `selec + +ted/`. + 132 + + 133 + The master labels stay canonical. A per-run class filter r + +enumbers ids into a + 134 + contiguous 0..k-1 space, which contradicts `project_classe + +s`; writing that + 135 + into the shared label files would leave the master dataset + + in the index space + 136 + of whichever training run happened to go last. + 137 + """ + 138 + selected_root = os.path.join(root, "selected") + 139 + if os.path.isdir(selected_root): + 140 + shutil.rmtree(selected_root) + 141 + listed = {"train": [], "val": []} + 142 + for image_rel, label_rel, frame_id, split in rows: + 143 + stem = os.path.basename(image_rel) + 144 + image_dst = os.path.join(selected_root, "images", spli + +t, stem) + 145 + label_dst = os.path.join(selected_root, "labels", spli + +t, + 146 + os.path.splitext(stem)[0] + " + +.txt") + 147 + os.makedirs(os.path.dirname(image_dst), exist_ok=True) + 148 + os.makedirs(os.path.dirname(label_dst), exist_ok=True) + 149 + source = os.path.join(root, image_rel) + 150 + if not os.path.exists(image_dst): + 151 + os.symlink(source, image_dst) + 152 + + 153 + annotations = review.listing(frame_id) + 154 + if class_map is not None: + 155 + annotations = [a for a in annotations if a["class_ + +id"] in class_map] + 156 + lines = [_label_line(class_map[a["class_id"]] if class + +_map is not None else a["class_id"], + 157 + a["geometry"], label_type) + 158 + for a in annotations] + 159 + _write_atomic(label_dst, "\n".join(lines) + ("\n" if l + +ines else "")) + 160 + listed[split].append(image_dst) + 161 + return selected_root, listed + 162 + + 163 + + 164 def write_data_yaml(project: dict, batch_ids: list = None, sel + ected_class_ids: Optional[List[int]] = None) -> str: + 165 """Rebuild data.yaml from the project's classes (REQ-051). + """ + 166 sync_labels(project["id"], selected_class_ids=selected_cla + ss_ids) + +● Now write_data_yaml — fail-loud on empty val, correct nc, use the selected + tree: + +● Update(backend/dataset.py) + ⎿  Added 46 lines, removed 24 lines + 163 + 164 def write_data_yaml(project: dict, batch_ids: list = None, sel + ected_class_ids: Optional[List[int]] = None) -> str: + 165 """Rebuild data.yaml from the project's classes (REQ-051). + """ + 166 - sync_labels(project["id"], selected_class_ids=selected_cla + -ss_ids) + 166 + sync_labels(project["id"]) + 167 root = dataset_dir(project["slug"]) + 168 os.makedirs(root, exist_ok=True) + 169 counts = summary(project["id"])["splits"] + 170 + 171 target_classes = project["classes"] + 172 + class_map = None + 173 if selected_class_ids is not None and len(selected_class_i + ds) > 0: + 174 target_classes = [c for c in project["classes"] if c[" + class_id"] in selected_class_ids] + 175 + class_map = {cid: idx for idx, cid in enumerate(sorted + +(selected_class_ids))} + 176 + 177 names = ", ".join(f"'{item['name']}'" for item in target_c + lasses) + 178 + 177 - if batch_ids: + 179 + if batch_ids or class_map is not None: + 180 with db.cursor() as cur: + 179 - placeholders = ",".join("?" for _ in batch_ids) + 181 + where = "d.project_id = ?" + 182 + args = [project["id"]] + 183 + if batch_ids: + 184 + where += f" AND f.batch_id IN ({','.join('?' f + +or _ in batch_ids)})" + 185 + args += list(batch_ids) + 186 cur.execute( + 181 - f"""SELECT d.image_rel, d.split FROM dataset_i + -tems d + 187 + f"""SELECT d.image_rel, d.label_rel, d.frame_i + +d, d.split FROM dataset_items d + 188 JOIN frames f ON f.id = d.frame_id + 183 - WHERE d.project_id = ? AND f.batch_id IN ( + -{placeholders})""", + 184 - [project["id"]] + list(batch_ids), + 189 + WHERE {where}""", + 190 + args, + 191 ) + 192 rows = cur.fetchall() + 193 + 188 - train_files = [row[0] for row in rows if row[1] == "tr + -ain"] + 189 - val_files = [row[0] for row in rows if row[1] == "val" + -] or train_files + 194 + selected_root, listed = _build_selected_tree(root, row + +s, class_map, project["label_type"]) + 195 + _require_val(len(listed["val"]), "the selected batches + +") + 196 + 197 train_txt = os.path.join(root, "selected_train.txt") + 198 val_txt = os.path.join(root, "selected_val.txt") + 193 - with open(train_txt, "w", encoding="utf-8") as handle: + 194 - handle.write("\n".join(os.path.join(root, rel) for + - rel in train_files) + "\n") + 195 - with open(val_txt, "w", encoding="utf-8") as handle: + 196 - handle.write("\n".join(os.path.join(root, rel) for + - rel in val_files) + "\n") + 199 + _write_atomic(train_txt, "\n".join(listed["train"]) + + +"\n") + 200 + _write_atomic(val_txt, "\n".join(listed["val"]) + "\n" + +) + 201 + 202 path = os.path.join(root, "selected_data.yaml") + 199 - with open(path, "w", encoding="utf-8") as handle: + 200 - handle.write(f"path: {root}\n") + 201 - handle.write(f"train: {train_txt}\n") + 202 - handle.write(f"val: {val_txt}\n\n") + 203 - handle.write(f"nc: {len(project['classes'])}\n") + 204 - handle.write(f"names: [{names}]\n") + 203 + _write_atomic(path, + 204 + f"path: {selected_root}\n" + 205 + f"train: {train_txt}\n" + 206 + f"val: {val_txt}\n\n" + 207 + f"nc: {len(target_classes)}\n" + 208 + f"names: [{names}]\n") + 209 return path + 210 + 211 + _require_val(counts["val"], "this project") + 212 + + 213 path = os.path.join(root, "data.yaml") + 208 - with open(path, "w", encoding="utf-8") as handle: + 209 - handle.write(f"path: {root}\n") + 210 - handle.write("train: images/train\n") + 211 - handle.write(f"val: images/{'val' if counts['val'] > 0 + - else 'train'}\n\n") + 212 - handle.write(f"nc: {len(project['classes'])}\n") + 213 - handle.write(f"names: [{names}]\n") + 214 + _write_atomic(path, + 215 + f"path: {root}\n" + 216 + "train: images/train\n" + 217 + "val: images/val\n\n" + 218 + f"nc: {len(target_classes)}\n" + 219 + f"names: [{names}]\n") + 220 return path + 221 + 222 + 223 +def _require_val(count: int, subject: str) -> None: + 224 + """Refuse to build a dataset with an empty val split. + 225 + + 226 + Falling back to the training images produces a base-vs-new + + mAP measured on + 227 + data the model was fitted to — a number that looks fine an + +d means nothing. + 228 + For a system whose whole purpose is answering "did retrain + +ing help?", this + 229 + has to fail loudly. + 230 + """ + 231 + if count == 0: + 232 + raise DatasetError( + 233 + f"There are no validation images in {subject}, so + +a base-vs-new comparison " + 234 + "would be measured on the training images. Merge m + +ore frames, or lower the " + 235 + "project's val_every." + 236 + ) + 237 + + 238 + + 239 def summary(project_id: int) -> dict: + 240 import json + 241 with db.cursor() as cur: + +● Now the merge handler — hash split, cancel bug, and the hashlib import: + +● Update(backend/dataset.py) + ⎿  Added 1 line + 11 segment class_id x1 y1 x2 y2 … (normalized polygon + ) + 12 """ + 13 + 14 +import hashlib + 15 import os + 16 import shutil + 17 import time + +● Update(backend/dataset.py) + ⎿  Added 3 lines, removed 1 line + 436 + 437 added = {"train": 0, "val": 0} + 438 skipped = 0 + 439 + cancelled = False + 440 for index, frame in enumerate(frames): + 441 if job.cancelled: + 442 job.log(f"Cancelled after {index} frame(s)") + 443 + cancelled = True + 444 break + 445 + 446 with db.cursor() as cur: + ... + 449 skipped += 1 + 450 job.progress(index + 1, len(frames)) + 451 continue + 450 - split = _next_split(cur, project["id"], project["v + -al_every"]) + 452 + 453 stem = f"{batch['id']}__{os.path.splitext(frame['f + ilename'])[0]}" + 454 + split = split_for(project["id"], batch["id"], stem + +, project["val_every"]) + 455 image_rel = f"images/{split}/{stem}.jpg" + 456 label_rel = f"labels/{split}/{stem}.txt" + 457 shutil.copyfile(os.path.join(source_dir, frame["fi + lename"]), + +● Update(backend/dataset.py) + ⎿  Added 8 lines + 473 added[split] += 1 + 474 job.progress(index + 1, len(frames)) + 475 + 476 + if cancelled: + 477 + # Leaving it 'merged' would be a lie: the frames after + + the break point + 478 + # have no dataset_items rows and no files, and approve + +() refuses to + 479 + # re-merge a merged batch, so they could never be adde + +d. The per-frame + 480 + # dataset_items guard already makes re-running the mer + +ge idempotent. + 481 + job.log("Batch left approved — re-approve it to finish + + the merge") + 482 + return + 483 + + 484 with db.cursor() as cur: + 485 cur.execute("UPDATE batches SET status = 'merged', mer + ged_at = ? WHERE id = ?", + 486 (time.time(), batch["id"])) + +● Step 5 — batch deletion orphans, plus the double-approve guard: + + Read 1 file (ctrl+o to expand) + +● Update(backend/batches.py) + ⎿  Added 23 lines + 238 + 239 def delete(batch_id: int) -> bool: + 240 import shutil + 241 + from backend import dataset + 242 + + 243 batch = get(batch_id) + 244 if batch is None: + 245 return False + 246 + + 247 + # The FK cascade takes the frames and dataset_items rows, + +but the copies this + 248 + # batch put in the master dataset live in a sibling tree a + +nd would survive as + 249 + # orphans — and data.yaml trains and validates on those di + +rectories, so the + 250 + # model would keep learning from a batch the user believes + + is gone. + 251 + root = dataset.dataset_dir(batch["project_slug"]) + 252 with db.cursor() as cur: + 253 + cur.execute( + 254 + """SELECT d.image_rel, d.label_rel FROM dataset_it + +ems d + 255 + JOIN frames f ON f.id = d.frame_id + 256 + WHERE f.batch_id = ?""", + 257 + (batch_id,), + 258 + ) + 259 + merged_files = cur.fetchall() + 260 + for image_rel, label_rel in merged_files: + 261 + for rel in (image_rel, label_rel): + 262 + try: + 263 + os.unlink(os.path.join(root, rel)) + 264 + except FileNotFoundError: + 265 + pass + 266 + + 267 + with db.cursor() as cur: + 268 cur.execute("DELETE FROM batches WHERE id = ?", (batch + _id,)) + 269 shutil.rmtree(batch_dir(batch["project_slug"], batch_id), + ignore_errors=True) + 270 return True + +● Update(backend/dataset.py) ⎿  Added 8 lines + 42 ) + 43 if batch["review"]["approved"] == 0: + 44 raise DatasetError("Every frame was rejected — there is + nothing to merge") + 45 + with db.cursor() as cur: + 46 + cur.execute( + 47 + """SELECT 1 FROM jobs WHERE batch_id = ? AND type = + + 'merge' + 48 + AND status IN ('queued', 'running')""", + 49 + (batch_id,), + 50 + ) + 51 + if cur.fetchone() is not None: + 52 + raise DatasetError("A merge for this batch is alrea + +dy queued") + 53 + 54 batches.set_status(batch_id, "approved") + 55 job = jobs.create( + + Searched for 2 patterns (ctrl+o to expand) + +● Good catch — class edits and merges would now throw on a young dataset. Gating + the check to the training path only: + +● Update(backend/dataset.py) + ⎿  Added 3 lines, removed 1 line + 170 return selected_root, listed + 171 + 172 + 173 -def write_data_yaml(project: dict, batch_ids: list = None, sel + -ected_class_ids: Optional[List[int]] = None) -> str: + 173 +def write_data_yaml(project: dict, batch_ids: list = None, + 174 + selected_class_ids: Optional[List[int]] = + +None, + 175 + require_val: bool = False) -> str: + 176 """Rebuild data.yaml from the project's classes (REQ-051). + """ + 177 sync_labels(project["id"]) + 178 root = dataset_dir(project["slug"]) + +● Update(backend/dataset.py) + ⎿  Added 2 lines, removed 1 line + 203 rows = cur.fetchall() + 204 + 205 selected_root, listed = _build_selected_tree(root, row + s, class_map, project["label_type"]) + 206 - _require_val(len(listed["val"]), "the selected batches + -") + 206 + if require_val: + 207 + _require_val(len(listed["val"]), "the selected bat + +ches") + 208 + 209 train_txt = os.path.join(root, "selected_train.txt") + 210 val_txt = os.path.join(root, "selected_val.txt") + +● Update(backend/dataset.py) + ⎿  Added 2 lines, removed 1 line + 220 f"names: [{names}]\n") + 221 return path + 222 + 223 - _require_val(counts["val"], "this project") + 223 + if require_val: + 224 + _require_val(counts["val"], "this project") + 225 + 226 path = os.path.join(root, "data.yaml") + 227 _write_atomic(path, + +● Update(backend/training.py) + ⎿  Added 2 lines, removed 1 line + 103 settings = job.params["settings"] + 104 batch_ids = job.params.get("batch_ids") + 105 class_ids = job.params.get("class_ids") + 106 - data_yaml = dataset.write_data_yaml(project, batch_ids=bat + -ch_ids, selected_class_ids=class_ids) + 106 + data_yaml = dataset.write_data_yaml(project, batch_ids=bat + +ch_ids, selected_class_ids=class_ids, + 107 + require_val=True) + 108 + 109 # SAM3 and a training run must not hold VRAM at the same t + ime (REQ-065). + 110 from backend.sam3_engine import release_engine + +● Backend done. Step 7 — the review editor: + + Read 1 file (ctrl+o to expand) + +● Update(frontend/src/pages/ReviewPage.jsx) + ⎿  Added 3 lines + 234 useEffect(() => { + 235 function onKeyDown(event) { + 236 if (event.target?.matches?.('input, textarea, select, [c + ontenteditable="true"]')) return + 237 + // Ctrl/Cmd/Alt combos belong to the browser and the OS: + + without this, + 238 + // Ctrl+S approves the frame and Ctrl+A/C/X/N/T all fire + + review actions. + 239 + if (event.ctrlKey || event.metaKey || event.altKey) retu + +rn + 240 const { frames, project, setStatus, removeSelected, recl + ass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPr + evious, trackForward } = stateRef.current + 241 const key = event.key + 242 const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft + ', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace' + , 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N', 'c', 'C', + 't', 'T'].includes(key) + +● Update(frontend/src/pages/ReviewPage.jsx) + ⎿  Added 28 lines, removed 9 lines + 124 return + 125 } + 126 if (!commit) return + 127 - const current = annotations.find((row) => row.id === id) + 128 - if (!current) return + 129 - try { await api.patchAnnotation(id, { geometry: current.ge + -ometry }) } catch (exc) { setError(exc.message) } + 127 + const previous = annotations.find((row) => row.id === id) + 128 + if (!previous) return + 129 + // A commit may carry its own geometry (delete-vertex send + +s the shortened + 130 + // polygon); falling back to the row's geometry covers dra + +g/resize, which + 131 + // already wrote through the {local:true} path. + 132 + const next = geometry ?? previous.geometry + 133 + setAnnotations((rows) => rows.map((row) => (row.id === id + +? { ...row, geometry: next } : row))) + 134 + try { + 135 + await api.patchAnnotation(id, { geometry: next }) + 136 + } catch (exc) { + 137 + setAnnotations((rows) => rows.map((row) => (row.id === i + +d ? previous : row))) + 138 + setError(exc.message) + 139 + } + 140 } + 141 + 132 - const removeSelected = useCallback(async () => { + 133 - if (selectedId == null || !frame) return + 134 - const id = selectedId + 142 + const removeAnnotation = useCallback(async (id) => { + 143 + if (id == null || !frame) return + 144 + const previous = annotations.find((row) => row.id === id) + 145 + if (!previous) return + 146 setAnnotations((rows) => rows.filter((row) => row.id !== i + d)) + 136 - setSelectedId(null) + 147 + setSelectedId((current) => (current === id ? null : curren + +t)) + 148 setFrames((rows) => rows.map((row) => (row.id === frame.id + ? { ...row, annotation_count: Math.max(0, (row.annotation_cou + nt ?? 1) - 1) } : row))) + 138 - try { await api.deleteAnnotation(id) } catch (exc) { setEr + -ror(exc.message) } + 139 - }, [selectedId, frame]) + 149 + try { + 150 + await api.deleteAnnotation(id) + 151 + } catch (exc) { + 152 + setAnnotations((rows) => [...rows, previous]) + 153 + setFrames((rows) => rows.map((row) => (row.id === frame. + +id ? { ...row, annotation_count: (row.annotation_count ?? 0) + + + 1 } : row))) + 154 + setError(exc.message) + 155 + } + 156 + }, [annotations, frame]) + 157 + 158 + const removeSelected = useCallback(() => removeAnnotation(se + +lectedId), [removeAnnotation, selectedId]) + 159 + + 160 const reclass = useCallback(async (classId) => { + 161 setActiveClass(classId) + 162 if (selectedId == null) return + +● Now wire the sidebar to the id-explicit delete: + + Ran 1 shell command (ctrl+o to expand) + +● Update(frontend/src/components/ReviewSidebar.jsx) + ⎿  Added 1 line, removed 4 lines + 83 + + Ran 1 shell command (ctrl+o to expand) + +● Update(frontend/src/components/ReviewSidebar.jsx) + ⎿  Added 1 line, removed 1 line + 11 annotations, + 12 selectedId, + 13 setSelectedId, + 14 - removeSelected, + 14 + removeAnnotation, + 15 project, + 16 jumpToNextAnnotated, + 17 batchAnnotationCount, + +● Update(frontend/src/pages/ReviewPage.jsx) + ⎿  Added 1 line, removed 1 line + 430 annotations={annotations} + 431 selectedId={selectedId} + 432 setSelectedId={setSelectedId} + 433 - removeSelected={removeSelected} + 433 + removeAnnotation={removeAnnotation} + 434 project={project} + 435 jumpToNextAnnotated={jumpToNextAnnotated} + 436 batchAnnotationCount={batch?.annotation_count ?? 0} + +● Now verify — imports, syntax, and the split logic: + + Ran 3 shell commands (ctrl+o to expand) + +● Done with steps 1–7. Both check out: backend imports clean, frontend builds. + + What changed + + Backend + - jobs.py — reverted to the single-worker queue; GPU_JOB_TYPES back to + ("autolabel", "train"). Dead _start_job removed. The file is now + byte-identical to HEAD. + - api/batches.py — preview and /api/sam3/playground-test now take gpu_lock + with a 20 s timeout and return 409 with "GPU is busy with a X job", matching + review.assist. + - dataset.py: + - sync_labels filters to review_status='approved' and no longer takes + selected_class_ids — re-running auto-annotate on a merged batch can no longer + push unreviewed output into the master dataset. + - Class-filtered runs build a selected/ tree (symlinked images + remapped + labels). Master labels stay canonical. + - _next_split → split_for(project, batch, stem, val_every), hash-derived. + Verified deterministic, 18.8% val at val_every=5, val_every=0 → all train. + - All label/yaml writes go through _write_atomic (temp + os.replace). + - Cancelled merge leaves the batch approved and logs why, instead of marking + it merged and stranding the frames forever. + - nc now counts target_classes, not the full list. + - _require_val refuses an empty val split — gated behind require_val=True, + passed only from training.py. + - batches.py — delete() unlinks the batch's dataset_items files from the + master dataset before dropping the rows. + - dataset.approve() — rejects when a merge for that batch is already queued or + running. + + Frontend + - removeSelected split into removeAnnotation(id) + a thin wrapper; sidebar + trash calls it with an explicit id. The stale-closure wrong-shape delete is + gone. + - Keydown handler early-returns on ctrlKey || metaKey || altKey. + - updateShape uses the geometry passed on commit (Alt-click delete-vertex now + actually works) and rolls back local state on a failed PATCH, as does + removeAnnotation. + + On your split migration: no backfill was needed. dataset_items stores each + frame's split as a row and the merge loop skips any frame that already has + one, so existing assignments are preserved untouched — the hash only decides + new merges. Your existing base-vs-new comparison stays valid. + + Two caveats, stated plainly. I verified imports, the split function's + behaviour, and a clean frontend build — I did not run a live end-to-end round, + so the merge/train paths are unexercised. And data.yaml now always says val: + images/val; if a project has merged data but zero val images, training raises + instead of silently validating on train. That's the intended behaviour, but it + will surface as a new error on any project in that state. + + Not touched from the report: the custom_model_path critical (C3 — needs the + opaque-staging-id refactor across inspect-model, autolabel.start and + LibraryPage), the staged .pt leaks, library.py's unbounded ffmpeg threads, + datasetSummary returning every annotation, and the ops items (.dockerignore, + untracking docker-compose.override.yml, .env.example, pinning requirements). + Say the word on which to take next — and separately, tell me how slow + autolabel actually is and on what hardware, and I'll look at making a single + job faster. + +✻ Sautéed for 6m 28s + +※ recap: Goal is making your reTraining app reliable end-to-end; I audited it and fixed the seven critical issues in the job queue, dataset split, and review editor. Next up is your call on which remaining items to tackle. (disable recaps in /config) + +❯ can you understand the data preparation pages and analyze what works and what + is not, what we should do to this pages, for this pages purposes is based on + the sam3 result, i can filter which is good for send to train and which to + ignore, but the ignored one is we can choose to just throw away or store them + as a different class, for example full-sack and half-sack. so the training + section can pick which they want to train based on that data preparation + filtering. do you understand my point?. ask me + +● Let me read the page and its backend before I say I understand it. + + Read 1 file (ctrl+o to expand) +● Yes, I understand the intent. Let me state it back, then flag one problem, + then ask. + + What you want: Data Prep is the triage gate between SAM3's raw output and + training. You look at what SAM3 produced, filter it, and each shape gets one + of three fates — keep (goes to training), discard (thrown away), or relabel + (kept, but as a different class, e.g. the rejects become half-sack while the + keepers stay full-sack). Then Models/Training picks which of those classes it + actually trains on. + + What the page does today: nothing of that. It is a read-only analytics + dashboard. minSizePct/maxSizePct are local React state + (DataPrepPage.jsx:10-11) that are never sent to the server — no apply button, + no persistence, no mutation. Reload the page and your filter is gone. The + class-distribution counts and the red/green box preview recompute live, but + they're a visualization of a filter that exists only in the browser. + + What already exists that you can build on: training accepts selected_class_ids + (training.py:106 → write_data_yaml), which after this morning's fix + materializes a filtered selected/ tree. So "training picks which classes to + train" is already wired — the missing half is the mechanism that assigns + shapes to those classes in the first place. + + One concern, and I think it matters. The only filter axis on this page is box + area as a percentage of the frame. Area is a proxy for distance from camera, + not for how full the sack is. A full sack at the far end of the conveyor and a + half sack up close can have identical area_pct. If full-vs-half is genuinely + the distinction you want, area alone will mislabel a lot of them. SAM3's + confidence score is the better "is this detection any good" signal — and note + it isn't even in the payload today (dataset.py sends id, frame_id, class_id, + area_pct, box — no score). Aspect ratio might separate full from half better + than area does. So I'd like to know what you're actually seeing in the data. + + Questions: + +● User answered Claude's questions: + ⎿  · When you look at a SAM3 result and decide "this one is bad" or "this one + is half-sack", what are you actually judging it on? → SAM3 confidence + score, Box size / area, I have to look at the image, or anything you + recommend + · When you "relabel the rejects as half-sack", should that permanently + change the annotations, or be a reusable rule? → Saved filter rule / preset + just like you recommend. every section have their own rule, so if i + change in data prep section, the batches model doesnt have effect, but the + next step have + · Where should this triage happen in the loop? → Per batch, before merge + · What should "discard" actually do to a shape? → Mark ignored, decide + later + +● Good — that clarifies a lot, but three things are still genuinely ambiguous + and they change the architecture, so let me pin them down. + + Note one tension in your answers I want to resolve: you want triage per batch + before merge, but you also want a data-prep rule change to affect the next + step. If the rule is baked in at merge time, then editing it later can't + change anything downstream — the data is already written. So I need to know + when the rule actually gets evaluated. + + Also, your "mark ignored" answer leaves the label-integrity trap open: if an + ignored box stays on an image that still goes to training, you're teaching the + model that a sack is background. That has to resolve somewhere. + +● User answered Claude's questions: + ⎿  · When is a data-prep rule actually evaluated? → Live, at training time + (Recommended) + · An 'ignored' box sits on an image. What happens to that image at training + time? → Exclude the whole image (Recommended) + · You said you often have to look at the image. How do manual decisions + coexist with rules? → Rules propose, you confirm in bulk + · For finding the full-vs-half boundary, what should I put on the page? → + Both + +● That resolves it into a coherent design. One consequence you should see before + I write anything, because it touches your core invariant: + + Live rule evaluation moves the val set. If a rule change excludes images, the + val split changes between runs — so a base-vs-new mAP from last week isn't + comparable to one from today. Both models in a single comparison are fine + (same data.yaml), but across rule edits the number silently shifts meaning. + This is fixable — stamp the rule-set version onto each training run and + refuse/warn on cross-version comparison — but it needs to be in the design, + not discovered later. + + Per CLAUDE.md §5 I'm not writing feature code before a REQ covers it, so + here's the proposal: + +● Write(docs/proposal-dataprep-triage.md) + ⎿  Wrote 131 lines to docs/proposal-dataprep-triage.md + 1 # Proposal — Data Prep as a triage stage + 2 + into `./requirements.md` + 4 and the schema/API sections into `./design.md`. + 5 + 7 + 8 - Signals: SAM3 confidence score, box area, aspect ratio, plus v + isual judgement. + orward only. + 10 - Triage happens **per batch**, on SAM3 output. + … +175 lines (ctrl+o to expand) + +● Written to docs/proposal-dataprep-triage.md. The short version: + + The model is a three-way resolve, evaluated live: manual override > first + matching rule > default keep, producing keep / ignore / reclass → class. + annotations.class_id is never rewritten — SAM3's original output stays intact, + which is exactly what makes rules re-cuttable forever. Resolution happens in + one place only: dataset._build_selected_tree, which after this morning's fix + is already the thing that materializes what training sees. + + Nine requirements (REQ-100…108), two new tables (triage_rules, + annotation_overrides), a rule_version column on training runs, six endpoints, + and a /crop endpoint to feed the thumbnail grid. + + Three things worth your attention before you approve: + + 1. Rules are per-project, scoped to the dataprep stage — not per-batch. You + said triage happens per batch, and the UI is per batch (you look at one + batch's SAM3 output at a time). But a rule like "area < 5% → half-sack" is a + judgment about your domain, not about one batch, so it lives on the project + and applies everywhere. If you actually want per-batch rules, say so — it's a + different schema. + 2. Score is already stored on annotations (ReviewSidebar renders item.score), + it's just missing from the Data Prep payload. Aspect ratio is free from the + box. So no new inference is needed to get all four signals — just plumbing. + 3. REQ-104 vs REQ-107 is the real tension. Excluding whole images is the right + call for label integrity, but it makes your val set a function of your rules. + REQ-107 (stamp the rule version on each run, refuse cross-version comparison) + keeps that honest instead of silent. If it turns out to fire on every edit + and becomes noise, the fallback is to freeze val membership before rule + evaluation and let rules affect train only. I'd rather start strict and loosen + it. + + Approve the REQs and I'll move them into requirements.md + design.md and start + building. If you'd rather see it working before committing to the + requirements, I can build the resolver and the scatter/crop UI against one + batch as a spike first — but say which, because CLAUDE.md says docs lead code + and I don't want to violate that silently. + +✻ Baked for 2m 21s + +❯ id rather see it working before commiting, i approve the plan + +● Approved — building the spike. Docs stay as the proposal until you've seen it + run. + + Read 1 file, ran 2 shell commands (ctrl+o to expand) + +● Schema first: + +● Update(backend/db.py) + ⎿  Added 22 lines + 125 finished_at REAL + 126 ) + 127 """, + 128 + """ + 129 + CREATE TABLE IF NOT EXISTS triage_rules ( + 130 + id INTEGER PRIMARY KEY AUTOINCREMENT, + 131 + project_id INTEGER NOT NULL REFERENCES projects(id) + +ON DELETE CASCADE, + 132 + stage TEXT NOT NULL DEFAULT 'dataprep', + 133 + position INTEGER NOT NULL, + 134 + name TEXT NOT NULL, + 135 + predicate TEXT NOT NULL, + 136 + action TEXT NOT NULL CHECK (action IN ('keep', ' + +ignore', 'reclass')), + 137 + target_class INTEGER, + 138 + created_at REAL NOT NULL + 139 + ) + 140 + """, + 141 + """ + 142 + CREATE TABLE IF NOT EXISTS annotation_overrides ( + 143 + annotation_id INTEGER PRIMARY KEY REFERENCES annotatio + +ns(id) ON DELETE CASCADE, + 144 + verdict TEXT NOT NULL CHECK (verdict IN ('keep', + + 'ignore', 'reclass')), + 145 + target_class INTEGER, + 146 + decided_at REAL NOT NULL + 147 + ) + 148 + """, + 149 "CREATE INDEX IF NOT EXISTS idx_frames_batch ON frames(bat + ch_id, idx)", + 150 + "CREATE INDEX IF NOT EXISTS idx_triage_rules_project ON tr + +iage_rules(project_id, stage, position)", + 151 "CREATE INDEX IF NOT EXISTS idx_annotations_frame ON annot + ations(frame_id)", + 152 "CREATE INDEX IF NOT EXISTS idx_batches_project ON batches + (project_id)", + 153 "CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs(proje + ct_id, created_at)", + +● Update(backend/db.py) + ⎿  Added 5 lines + 189 cur.execute("ALTER TABLE projects ADD COLUMN secon + dary_model_name TEXT") + 190 if "secondary_model_classes" not in cols: + 191 cur.execute("ALTER TABLE projects ADD COLUMN secon + dary_model_classes TEXT") + 192 + # REQ-107: what rule set a run's numbers were measured + + under. + 193 + cur.execute("PRAGMA table_info(model_versions)") + 194 + version_cols = [column[1] for column in cur.fetchall() + +] + 195 + if "rule_version" not in version_cols: + 196 + cur.execute("ALTER TABLE model_versions ADD COLUMN + + rule_version TEXT") + 197 + 198 + 199 def healthy() -> bool: + +● Now the resolver — the core of the whole feature: + +● Write(backend/triage.py) + ⎿  Wrote 267 lines to backend/triage.py + 1 """Triage: deciding what each SAM3 shape is actually worth (REQ-1 + 00…108). + 3 A shape is never rewritten. Its verdict is *resolved* every time + it is needed: + 4 + 6 + 7 so `annotations.class_id` keeps whatever SAM3 said, and any rule + can be re-cut + are evaluated + 9 at training time rather than baked in at merge (REQ-102). + … +305 lines (ctrl+o to expand) + +● Now wire the resolver into what training actually sees: + + Read 1 file (ctrl+o to expand) + +● Update(backend/dataset.py) + ⎿  Added 36 lines, removed 10 lines + 136 + 137 + 138 def _build_selected_tree(root: str, rows: list, class_map: Opt + ional[dict], + 139 - label_type: str) -> tuple: + 139 + label_type: str, resolver=None) -> tu + +ple: + 140 """Materialise a filtered view of the dataset under `selec + ted/`. + 141 + 142 The master labels stay canonical. A per-run class filter r + enumbers ids into a + 143 contiguous 0..k-1 space, which contradicts `project_classe + s`; writing that + 144 into the shared label files would leave the master dataset + in the index space + 145 of whichever training run happened to go last. + 146 + + 147 + This is also the one place triage rules are applied (REQ-1 + +02): resolve every + 148 + shape, drop the images carrying an `ignore`, and emit the + +rest under their + 149 + effective class. Because it happens here and not at merge + +time, editing a + 150 + rule re-cuts the dataset on the next run without touching + +what was merged. + 151 """ + 152 selected_root = os.path.join(root, "selected") + 153 if os.path.isdir(selected_root): + 154 shutil.rmtree(selected_root) + 155 listed = {"train": [], "val": []} + 156 + excluded = 0 + 157 for image_rel, label_rel, frame_id, split in rows: + 158 + annotations = review.listing(frame_id) + 159 + + 160 + if resolver is not None: + 161 + resolved = [] + 162 + skip_image = False + 163 + for item in annotations: + 164 + shape = {"id": item["id"], "class_id": item["c + +lass_id"], + 165 + "score": float(item.get("score") or 1 + +.0), + 166 + **triage.metrics(item["geometry"])} + 167 + effective = resolver.effective_class(shape) + 168 + if effective is None: + 169 + # REQ-104: a known object left unlabeled w + +ould teach the + 170 + # model it is background, so the image goe + +s too. + 171 + skip_image = True + 172 + break + 173 + resolved.append({**item, "class_id": effective + +}) + 174 + if skip_image: + 175 + excluded += 1 + 176 + continue + 177 + annotations = resolved + 178 + + 179 + if class_map is not None: + 180 + annotations = [a for a in annotations if a["class_ + +id"] in class_map] + 181 + lines = [_label_line(class_map[a["class_id"]] if class + +_map is not None else a["class_id"], + 182 + a["geometry"], label_type) + 183 + for a in annotations] + 184 + + 185 stem = os.path.basename(image_rel) + 186 image_dst = os.path.join(selected_root, "images", spli + t, stem) + 187 label_dst = os.path.join(selected_root, "labels", spli + t, + 188 os.path.splitext(stem)[0] + " + .txt") + 189 os.makedirs(os.path.dirname(image_dst), exist_ok=True) + 190 os.makedirs(os.path.dirname(label_dst), exist_ok=True) + 158 - source = os.path.join(root, image_rel) + 191 if not os.path.exists(image_dst): + 160 - os.symlink(source, image_dst) + 161 - + 162 - annotations = review.listing(frame_id) + 163 - if class_map is not None: + 164 - annotations = [a for a in annotations if a["class_ + -id"] in class_map] + 165 - lines = [_label_line(class_map[a["class_id"]] if class + -_map is not None else a["class_id"], + 166 - a["geometry"], label_type) + 167 - for a in annotations] + 192 + os.symlink(os.path.join(root, image_rel), image_ds + +t) + 193 _write_atomic(label_dst, "\n".join(lines) + ("\n" if l + ines else "")) + 194 listed[split].append(image_dst) + 195 + listed["excluded"] = excluded + 196 return selected_root, listed + 197 + 198 + +● Update(backend/dataset.py) + ⎿  Added 6 lines, removed 1 line + 213 + 214 names = ", ".join(f"'{item['name']}'" for item in target_c + lasses) + 215 + 216 - if batch_ids or class_map is not None: + 216 + # Rules re-cut the dataset on every run (REQ-102), so thei + +r presence alone is + 217 + # enough to need the `selected/` tree — the master labels + +must stay canonical. + 218 + resolver = triage.Resolver(project["id"]) + 219 + has_rules = bool(resolver.rules or resolver.overrides) + 220 + + 221 + if batch_ids or class_map is not None or has_rules: + 222 with db.cursor() as cur: + 223 where = "d.project_id = ?" + 224 args = [project["id"]] + +● Update(backend/dataset.py) + ⎿  Added 3 lines, removed 1 line + 233 ) + 234 rows = cur.fetchall() + 235 + 236 - selected_root, listed = _build_selected_tree(root, row + -s, class_map, project["label_type"]) + 236 + selected_root, listed = _build_selected_tree( + 237 + root, rows, class_map, project["label_type"], + 238 + resolver=resolver if has_rules else None) + 239 if require_val: + 240 _require_val(len(listed["val"]), "the selected bat + ches") + 241 + +● Update(backend/dataset.py) + ⎿  Added 1 line, removed 1 line + 17 import time + 18 from typing import List, Optional + 19 + 20 -from backend import batches, config, db, jobs, projects, review + 20 +from backend import batches, config, db, jobs, projects, review + +, triage + 21 + 22 + 23 class DatasetError(Exception): + + Ran 1 shell command (ctrl+o to expand) +● Now the API routes and crop endpoint: + +● Write(backend/api/triage.py) + ⎿  Wrote 118 lines to backend/api/triage.py + 1 """Triage routes: rules, hand overrides, and the per-batch shape + view (REQ-100…108).""" + 2 + 3 import io + 4 import os + 5 + 6 from fastapi import APIRouter, HTTPException + 7 from fastapi.responses import StreamingResponse + 8 from pydantic import BaseModel + 9 from typing import List, Optional + … +115 lines (ctrl+o to expand) + + Ran 1 shell command (ctrl+o to expand) + +● Good. Registering the router: + +● Update(backend/main.py) + ⎿  Added 1 line, removed 1 line + 15 from fastapi.middleware.cors import CORSMiddleware + 16 + 17 from backend import config, db, jobs + 18 -from backend.api import batches, jobs as job_routes, models, pr + -ojects, review + 18 +from backend.api import batches, jobs as job_routes, models, pr + +ojects, review, triage + 19 + 20 + 21 @asynccontextmanager + +● Update(backend/main.py) + ⎿  Added 1 line + 43 app.include_router(review.router) + 44 app.include_router(models.router) + 45 app.include_router(job_routes.router) + 46 +app.include_router(triage.router) + 47 + 48 + 49 @app.get("/api/health") + +● Let me verify the resolver logic end-to-end against a real scenario before + building UI on top of it: + + Ran 1 shell command (ctrl+o to expand) + +● Rules resolve correctly — the FK error is my test using fake annotation ids. + Let me redo with real rows: + + Ran 3 shell commands (ctrl+o to expand) + +● Resolver verified: precedence, first-match-wins, manual override, version + changes. Now the UI. Let me check the api client conventions: + + Ran 1 shell command (ctrl+o to expand) + +● Update(frontend/src/api.js) + ⎿  Added 15 lines + 116 approveAllBatchFrames: (batchId) => request(`/batches/${batc + hId}/approve-all`, { method: 'POST' }), + 117 approveBatch: (batchId) => request(`/batches/${batchId}/appr + ove`, { method: 'POST' }), + 118 datasetSummary: (projectId) => request(`/projects/${projectI + d}/dataset`), + 119 + + 120 + // Triage (REQ-100…108) + 121 + triageRules: (projectId) => request(`/projects/${projectId}/ + +triage/rules`), + 122 + saveTriageRules: (projectId, rules) => + 123 + request(`/projects/${projectId}/triage/rules`, { method: ' + +PUT', body: { rules } }), + 124 + triageShapes: (batchId) => request(`/batches/${batchId}/tria + +ge/shapes`), + 125 + triagePreview: (projectId) => request(`/projects/${projectId + +}/triage/preview`), + 126 + setTriageOverrides: (annotationIds, verdict, targetClass = n + +ull) => + 127 + request('/triage/overrides', { + 128 + method: 'POST', + 129 + body: { annotation_ids: annotationIds, verdict, target_c + +lass: targetClass }, + 130 + }), + 131 + clearTriageOverrides: (annotationIds) => + 132 + request('/triage/overrides', { method: 'DELETE', body: { a + +nnotation_ids: annotationIds } }), + 133 + cropUrl: (annotationId) => `/api/annotations/${annotationId} + +/crop`, + 134 datasetDownloadUrl: (projectId) => `/api/projects/${projectI + d}/dataset/download`, + 135 + 136 hardware: () => request('/hardware'), + +● Now the scatter plot with drag-selection: + +● Write(frontend/src/components/TriageScatter.jsx) + ⎿  Wrote 165 lines to frontend/src/components/TriageScatter.jsx + 1 import React, { useMemo, useRef, useState } from 'react' + 2 + EQ-106). + 4 * + 5 * Area is on a log scale: box sizes span three orders of magnitu + 6 * linear axis every shape piles into the left edge where no boun + dary is + 7 * visible. */ + 9 const VERDICT_COLOR = { keep: '#4ade80', ignore: '#f87171', recla + ss: '#c084fc' } + … +179 lines (ctrl+o to expand) + +● Now the crop grid and rule editor: + +● Write(frontend/src/components/TriageCropGrid.jsx) + ⎿  Wrote 128 lines to frontend/src/components/TriageCropGrid.jsx + 1 import React, { useState } from 'react' + 2 import { api } from '../api' + 3 + ck?" means + 5 * looking at the object, not at the frame it came from. Sorted s + o the shapes a + 6 * rule is about to act on sit together. */ + 8 const VERDICT_COLOR = { keep: '#4ade80', ignore: '#f87171', recla + ss: '#c084fc' } + 9 const PAGE = 120 + … +144 lines (ctrl+o to expand) + +● Write(frontend/src/components/TriageRules.jsx) + ⎿  Wrote 177 lines to frontend/src/components/TriageRules.jsx + 1 import React from 'react' + 2 import { TrashIcon } from './Icons' + 3 + 4 /* The rule list (REQ-100). Ordered, first match wins — so the o + rder is part of + 6 + 7 const ACTIONS = [ + 8 { value: 'keep', label: 'Keep' }, + 9 { value: 'ignore', label: 'Ignore' }, + 10 { value: 'reclass', label: 'Reclass to' }, + … +198 lines (ctrl+o to expand) + +● Now the page itself, wiring it together: + + Searched for 2 patterns (ctrl+o to expand) + +● Write(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 237 lines, removed 569 lines + 1 -import React, { useCallback, useEffect, useState } from 'react + -' + 2 -import { api, classColor } from '../api' + 1 +import React, { useCallback, useEffect, useMemo, useState } fr + +om 'react' + 2 +import { api } from '../api' + 3 + 4 -import { AlertIcon, BarChartIcon, DatabaseIcon, RocketIcon } f + -rom '../components/Icons' + 4 +import { AlertIcon, DatabaseIcon, RocketIcon, SlidersIcon } fr + +om '../components/Icons' + 5 +import TriageCropGrid from '../components/TriageCropGrid' + 6 +import TriageRules from '../components/TriageRules' + 7 +import TriageScatter from '../components/TriageScatter' + 8 + 9 +/* Data Prep is the triage gate between SAM3's raw output and + +training + 10 + * (REQ-100…108): look at what SAM3 produced for one batch, de + +cide what is worth + 11 + * training on, and express the repeatable part of that decisi + +on as rules. + 12 + * + 13 + * Nothing here rewrites an annotation. Verdicts resolve as + 14 + * `manual > first matching rule > keep`, and the dataset is r + +e-cut from them at + 15 + * training time — so a rule stays editable forever. */ + 16 + + 17 +const VERDICTS = [ + 18 + { value: 'keep', label: 'Keep', color: '#4ade80' }, + 19 + { value: 'ignore', label: 'Ignore', color: '#f87171' }, + 20 + { value: 'reclass', label: 'Reclass', color: '#c084fc' }, + 21 +] + 22 + + 23 export default function DataPrepPage({ projectId, onProject }) + { + 24 const [project, setProject] = useState(null) + 8 - const [summary, setSummary] = useState(null) + 9 - const [scaleMode, setScaleMode] = useState('normal') // 'nor + -mal' (Gaussian Bell Curve) | 'linear' + 10 - const [minSizePct, setMinSizePct] = useState(0) + 11 - const [maxSizePct, setMaxSizePct] = useState(100) + 12 - const [previewFrameId, setPreviewFrameId] = useState(null) + 13 - const [showFilteredOut, setShowFilteredOut] = useState(true) + 25 + const [batches, setBatches] = useState([]) + 26 + const [batchId, setBatchId] = useState(null) + 27 + const [view, setView] = useState(null) + 28 + const [rules, setRules] = useState([]) + 29 + const [savedRules, setSavedRules] = useState([]) + 30 + const [preview, setPreview] = useState(null) + 31 + const [selectedIds, setSelectedIds] = useState([]) + 32 + const [reclassTarget, setReclassTarget] = useState('') + 33 + const [saving, setSaving] = useState(false) + 34 + const [busy, setBusy] = useState(false) + 35 const [error, setError] = useState('') + 36 + 37 + useEffect(() => { + 38 + let cancelled = false + 39 + ;(async () => { + 40 + try { + 41 + const [loadedProject, loadedBatches, loadedRules] = aw + +ait Promise.all([ + 42 + api.getProject(projectId), + 43 + api.listBatches(projectId), + 44 + api.triageRules(projectId), + 45 + ]) + 46 + if (cancelled) return + 47 + setProject(loadedProject) + 48 + onProject?.(loadedProject) + 49 + const annotated = (loadedBatches.batches ?? loadedBatc + +hes).filter((b) => b.annotation_count > 0) + 50 + setBatches(annotated) + 51 + setBatchId((current) => current ?? annotated[0]?.id ?? + + null) + 52 + setRules(loadedRules.rules) + 53 + setSavedRules(loadedRules.rules) + 54 + } catch (exc) { + 55 + if (!cancelled) setError(exc.message) + 56 + } + 57 + })() + 58 + return () => { cancelled = true } + 59 + }, [projectId]) + 60 + 17 - const load = useCallback(async () => { + 61 + const loadBatch = useCallback(async () => { + 62 + if (!batchId) return + 63 try { + 19 - const [loadedProject, loadedSummary] = await Promise.all + -([ + 20 - api.getProject(projectId), + 21 - api.datasetSummary(projectId), + 64 + const [shapes, previewed] = await Promise.all([ + 65 + api.triageShapes(batchId), + 66 + api.triagePreview(projectId), + 67 ]) + 23 - setProject(loadedProject) + 24 - onProject?.(loadedProject) + 25 - setSummary(loadedSummary) + 68 + setView(shapes) + 69 + setPreview(previewed) + 70 + setSelectedIds([]) + 71 } catch (exc) { + 72 setError(exc.message) + 73 } + 29 - }, [projectId]) + 74 + }, [batchId, projectId]) + 75 + 31 - useEffect(() => { + 32 - load() + 33 - }, [load]) + 76 + useEffect(() => { loadBatch() }, [loadBatch]) + 77 + 35 - if (error && !project) { + 36 - return

+ - {error}

+ 37 - } + 38 - if (!project || !summary) { + 39 - return

Loading Data Preparation metad + -ata…

+ 40 - } + 78 + const shapes = view?.shapes ?? [] + 79 + const dirty = JSON.stringify(rules) !== JSON.stringify(saved + +Rules) + 80 + 42 - const totalFrames = (summary.splits?.train || 0) + (summary. + -splits?.val || 0) + 43 - const mergedBatches = summary.batches || [] + 44 - const shapeDist = summary.shape_distribution || { total_shap + -es: 0, histogram: [], log_histogram: [], shapes: [] } + 81 + const counts = useMemo(() => { + 82 + const tally = { keep: 0, ignore: 0, reclass: 0, manual: 0 + +} + 83 + shapes.forEach((shape) => { + 84 + tally[shape.verdict] += 1 + 85 + if (shape.source === 'manual') tally.manual += 1 + 86 + }) + 87 + return tally + 88 + }, [shapes]) + 89 + 46 - const linearHistogram = shapeDist.histogram || [] + 47 - const logHistogram = shapeDist.log_histogram || [] + 48 - const normalParams = shapeDist.normal_params || { mu_log: 0, + - sigma_log: 1, median_area_pct: 0 } + 49 - const rawShapes = shapeDist.shapes || [] + 90 + async function saveRules() { + 91 + setSaving(true) + 92 + try { + 93 + const stored = await api.saveTriageRules(projectId, rule + +s) + 94 + setSavedRules(stored.rules) + 95 + setRules(stored.rules) + 96 + await loadBatch() + 97 + } catch (exc) { + 98 + setError(exc.message) + 99 + } finally { + 100 + setSaving(false) + 101 + } + 102 + } + 103 + 51 - // Active histogram data based on selected view mode + 52 - const activeHistogram = scaleMode === 'normal' ? logHistogra + -m : linearHistogram + 53 - const maxCount = Math.max(1, ...activeHistogram.map((h) => h + -.count || 0)) + 54 - const maxDensity = Math.max(0.001, ...logHistogram.map((h) = + -> h.normal_density || 0)) + 104 + async function applyVerdict(verdict) { + 105 + if (selectedIds.length === 0) return + 106 + if (verdict === 'reclass' && reclassTarget === '') { + 107 + setError('Pick the class to reclass into first') + 108 + return + 109 + } + 110 + setBusy(true) + 111 + try { + 112 + await api.setTriageOverrides(selectedIds, verdict, verdi + +ct === 'reclass' ? Number(reclassTarget) : null) + 113 + await loadBatch() + 114 + } catch (exc) { + 115 + setError(exc.message) + 116 + } finally { + 117 + setBusy(false) + 118 + } + 119 + } + 120 + 56 - // Filter shapes based on continuous min/max range sliders + 57 - const isFiltering = minSizePct > 0 || maxSizePct < 100 + 58 - const filteredShapes = rawShapes.filter( + 59 - (s) => s.area_pct >= minSizePct && s.area_pct <= maxSizePc + -t + 60 - ) + 121 + async function clearDecisions() { + 122 + if (selectedIds.length === 0) return + 123 + setBusy(true) + 124 + try { + 125 + await api.clearTriageOverrides(selectedIds) + 126 + await loadBatch() + 127 + } catch (exc) { + 128 + setError(exc.message) + 129 + } finally { + 130 + setBusy(false) + 131 + } + 132 + } + 133 + 62 - // Compute class counts for filtered shapes + 63 - const filteredClassCounts = {} + 64 - filteredShapes.forEach((s) => { + 65 - filteredClassCounts[s.class_id] = (filteredClassCounts[s.c + -lass_id] || 0) + 1 + 66 - }) + 134 + if (error && !project) { + 135 + return

+ + {error}

+ 136 + } + 137 + if (!project) return

Loading Data Prepa + +ration…

+ 138 + 68 - // Find top frames sorted by TOTAL annotation density + 69 - + 70 - const frameTotalCounts = {} + 71 - rawShapes.forEach((s) => { + 72 - frameTotalCounts[s.frame_id] = (frameTotalCounts[s.frame_i + -d] || 0) + 1 + 73 - }) + 74 - + 75 - const topDenseFrames = Object.entries(frameTotalCounts) + 76 - .sort((a, b) => b[1] - a[1]) + 77 - .map(([fid, count]) => ({ frame_id: Number(fid), total_sha + -pes: count })) + 78 - + 79 - const defaultPreviewFrameId = topDenseFrames[0]?.frame_id || + - rawShapes[0]?.frame_id || null + 80 - const activePreviewFrameId = previewFrameId || defaultPrevie + -wFrameId + 81 - + 82 - // Get shapes on the active preview frame + 83 - const allFrameShapes = rawShapes.filter((s) => s.frame_id == + -= activePreviewFrameId) + 84 - const keptShapesOnFrame = allFrameShapes.filter((s) => s.are + -a_pct >= minSizePct && s.area_pct <= maxSizePct) + 85 - const filteredOutShapesOnFrame = allFrameShapes.filter((s) = + -> s.area_pct < minSizePct || s.area_pct > maxSizePct) + 86 - + 87 - + 88 - // SVG Graph Dimensions + 89 - const graphWidth = 800 + 90 - const graphHeight = 180 + 91 - const padding = 24 + 92 - const usableW = graphWidth - padding * 2 + 93 - const usableH = graphHeight - padding * 2 + 94 - + 95 - // Continuous Shape Count Line Coordinates + 96 - const shapePoints = activeHistogram.map((h, i) => { + 97 - const x = padding + (i / (activeHistogram.length - 1)) * u + -sableW + 98 - const y = graphHeight - padding - (h.count / maxCount) * u + -sableH + 99 - return { x, y, count: h.count, label: scaleMode === 'norma + -l' ? `${h.center_pct}%` : `${h.pct}%` } + 100 - }) + 101 - + 102 - const shapeLineD = shapePoints.reduce( + 103 - (acc, p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `${acc} L ${ + -p.x} ${p.y}`), + 104 - '' + 105 - ) + 106 - const shapeAreaD = `${shapeLineD} L ${shapePoints[shapePoint + -s.length - 1]?.x || graphWidth} ${graphHeight - padding} L ${p + -adding} ${graphHeight - padding} Z` + 107 - + 108 - // Theoretical Gaussian Normal Bell Curve Coordinates (Cyan + -Line) + 109 - const normalPoints = logHistogram.map((h, i) => { + 110 - const x = padding + (i / (logHistogram.length - 1)) * usab + -leW + 111 - const y = graphHeight - padding - (h.normal_density / maxD + -ensity) * usableH + 112 - return { x, y, density: h.normal_density } + 113 - }) + 114 - + 115 - const normalLineD = normalPoints.reduce( + 116 - (acc, p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `${acc} L ${ + -p.x} ${p.y}`), + 117 - '' + 118 - ) + 119 - const normalAreaD = `${normalLineD} L ${normalPoints[normalP + -oints.length - 1]?.x || graphWidth} ${graphHeight - padding} L + - ${padding} ${graphHeight - padding} Z` + 120 - + 121 - + 122 - // Active Filter Range Overlay + 123 - const startX = padding + (minSizePct / 100) * usableW + 124 - const endX = padding + (maxSizePct / 100) * usableW + 125 - + 139 return ( + 127 - + 140 <> + 129 -
+ 141 +
+ 142
+ 143

Data Preparation

+ 132 -

{project.name} · Bounding + -Box Size Distribution & Preview

+ 144 +

{project.name} · triage SA + +M3 output before it trains

+ 145
+ 146 + 140 - Download Dataset (.zip) + 152 + Download dataset (.zip) + 153 + 154
+ 155 + 156 {error && ( + 157

+ 158 {error} + 159 + + 160

+ 161 )} + 162 + 150 - {/* Dataset Summary Cards */} + 151 -
+ 152 -
+ 153 - Total + - Master Images + 154 -
{totalFrames}
+ 155 -

Across all approved batches

+ 163 + {preview && ( + 164 +
+ 165 + + 166 + + 167 + + 168 + + 169
+ 170 + )} + 171 + 158 -
+ 159 - Train + -ing Set (Train) + 160 -
{summary.splits?.train || 0}< + -/div> + 161 -

80% split for model training

+ 162 -
+ 172 + + 180 + 164 -
+ 165 - Valid + -ation Set (Val) + 166 -
{summary.splits?.val || 0} + 167 -

20% locked split for metrics

+ 168 -
+ 169 - + 170 -
+ 171 - Total + - Bounding Boxes + 172 -
{shapeDist.total_shapes}
+ 173 -

Median Area: {normalParams.median + -_area_pct}%

+ 174 -
+ 175 -
+ 176 - + 177 - {/* Bounding Box Size Distribution Graph */} + 178 -
+ 179 -
+ 180 -
+ 181 -

+ 182 - Bounding Box Size Dis + -tribution Curve + 183 -

+ 184 -

+ 185 - Log-Normal Bell Curve N(μ, σ²) showing shape siz + -e distribution. + 186 -

+ 187 -
+ 188 - + 189 - {/* Scale View Toggle */} + 190 -
+ 191 - + 330 - + 338 - + 346 - + 354 - + 362 -
+ 363 -
+ 364 - + 365 -
+ 366 -
+ 367 -
+ 368 - + 369 - setMinSizePct(Math.min(Math + -.max(0, Number(e.target.value)), maxSizePct))} + 376 - style={{ width: 70, padding: '2px 6px', font + -Size: '0.8rem', textAlign: 'right', background: 'rgba(0,0,0,0. + -4)', border: '1px solid rgba(255,255,255,0.15)', borderRadius: + - 4, color: '#c084fc' }} + 377 - /> + 378 -
+ 379 - setMinSizePct(Math.min(Number + -(e.target.value), maxSizePct))} + 386 - style={{ width: '100%', cursor: 'pointer' }} + 387 - /> + 388 -
+ 389 - + 390 -
+ 391 -
+ 392 - + 393 - setMaxSizePct(Math.max(Math + -.min(100, Number(e.target.value)), minSizePct))} + 400 - style={{ width: 70, padding: '2px 6px', font + -Size: '0.8rem', textAlign: 'right', background: 'rgba(0,0,0,0. + -4)', border: '1px solid rgba(255,255,255,0.15)', borderRadius: + - 4, color: '#c084fc' }} + 401 - /> + 402 -
+ 403 - setMaxSizePct(Math.max(Number + -(e.target.value), minSizePct))} + 410 - style={{ width: '100%', cursor: 'pointer' }} + 411 - /> + 412 -
+ 413 -
+ 414 -
+ 415 -
+ 416 - + 417 - {/* Most Dense Frame Preview Section */} + 418 - {activePreviewFrameId && ( + 419 -
+ 420 -
+ 421 -
+ 422 -

+ 423 - Most Dense Frame Preview (Filtered vs Kept Bou + -nding Boxes) + 424 -

+ 425 -

+ 426 - Active Filter Range: {minSizePct}% – {maxSizePct}% Area. Filte + -red-out boxes are dimmed in red. + 427 -

+ 428 -
+ 429 - + 430 - {/* Dense Frame Dropdown Selector & Counters */} + 431 -
+ 432 - + 217 +
+ 231 + {selecte + +dIds.length} selected + 232 + — decide by hand (overrides every rule): + 233 + {VERDICTS.map((v) => ( + 234 + + 244 + ))} + 245 + 444 - + 445 - + 446 - {keptShapesOnFrame.length} Kept + 447 - + 448 - + 449 - {filteredOutShapesOnFrame.length} Filtered Out + 450 - + 451 - + 256 + 265
+ 469 -
+ 266 + 471 - {/* Interactive Frame Canvas Preview */} + 472 -
+ 473 - {`Frame + 273 + + 274 + )} + 275 +
+ 276 + 479 - {/* SVG Bounding Box Overlays showing Kept vs Filt + -ered Out */} + 480 - + 485 - {allFrameShapes.map((s) => { + 486 - if (!s.box) return null + 487 - const isKept = s.area_pct >= minSizePct && s.a + -rea_pct <= maxSizePct + 488 - if (!isKept && !showFilteredOut) return null + 489 - + 490 - const [x0, y0, x1, y1] = s.box + 491 - const cls = project.classes?.find((c) => c.cla + -ss_id === s.class_id) + 492 - const color = isKept ? classColor(s.class_id) + -: '#ef4444' + 493 - + 494 - return ( + 495 - + 496 - + 506 - + 514 - {isKept ? `${cls?.name || `Class ${s.cla + -ss_id}`} (${s.area_pct}%)` : `❌ Filtered (${s.area_pct}%)`} + 515 - + 516 - + 517 - ) + 518 - })} + 519 - + 520 -
+ 521 -
+ 522 - )} + 523 - + 524 - + 525 - + 526 -
+ 527 - + 528 - {/* Main Section: Class Distribution & Merged Batches + -*/} + 529 -
+ 530 - {/* Target Classes Summary with Continuous Size Filt + -er */} + 531 -
+ 532 -

+ 533 - Target Class Distribution {isFiltering ? `(Filte + -red: ${minSizePct}% – ${maxSizePct}% Area)` : ''} + 534 -

+ 535 - {project.classes?.length === 0 ? ( + 536 -

No target classes defined fo + -r this project.

+ 537 - ) : ( + 538 -
+ 539 - {project.classes?.map((cls) => { + 540 - const count = filteredClassCounts[cls.class_ + -id] || 0 + 541 - return ( + 542 -
0 ? '1p + -x solid rgba(192, 132, 252, 0.4)' : '1px solid rgba(255,255,25 + -5,0.08)', + 549 - }} + 550 - > + 551 -
+ 552 -
{cls.name}
+ 553 - + 554 - {count} shapes + 555 - + 556 -
+ 557 -
+ 558 - Class ID: {cls.class_id} + 559 -
+ 560 - {cls.prompt && ( + 561 -
+ 562 - Prompt: "{cls.prompt}" + 563 -
+ 564 - )} + 565 -
+ 566 - ) + 567 - })} + 568 -
+ 569 - )} + 570 -
+ 571 - + 572 - + 573 - {/* Merged Batches List */} + 574 -
+ 575 -

Approved & Merged Dataset Batches ({mergedBatches.length}) + 576 - {mergedBatches.length === 0 ? ( + 577 -

No approved batches merged + -into dataset yet. Go to Batches page to review & approve.

+ 578 - ) : ( + 579 - + 580 - + 581 - + 582 - + 583 - + 584 - + 585 - + 586 - + 587 - + 588 - + 589 - + 590 - {mergedBatches.map((item) => ( + 591 - + 592 - + 593 - + 594 - + 595 - + 600 - + 605 - + 606 - ))} + 607 - + 608 -
BatchDateFramesReviewedStatus
{item.ba + -tch_label}{item.date_label}{item.images} + 596 - + 597 - Approved + 598 - + 599 - + 601 - + 602 - In Master Dataset + 603 - + 604 -
+ 609 - )} + 610 -

+ 611 -
+ 612 - + 613 - {/* Right Sidebar: Next Steps & Quick Actions */} + 614 -
+ 615 -
+ 616 -

Data Preparation Readiness

+ 617 -

+ 618 - Once your dataset and shape sizes are verified, + -proceed to fine-tune baseline YOLO models. + 619 -

+ 620 - + 625 - Proceed to Models & Tra + -ining + 626 - + 627 -
+ 628 -
+ 277 +
+ 278 +

Ready to train

+ 279 +

+ 280 + Training re-applies these rules to the master datase + +t, so what you decide here is what + 281 + the next run sees. Images carrying an ignored shape + +are left out whole. + 282 +

+ 283 + + 288 + Proceed to models & tra + +ining + 289 + + 290
+ 291 + 292 ) + 293 } + 294 + 634 - + 635 - + 295 +function Stat({ label, value, hint, accent, mono }) { + 296 + return ( + 297 +
+ 298 + {label} + 299 +
{value}
+ 300 +

{hint}

+ 301 +
+ 302 + ) + 303 +} + + Ran 7 shell commands (ctrl+o to expand) + +● Router's fine — my introspection was wrong. Let me just run the server and hit + it for real: + + Ran 2 shell commands (ctrl+o to expand) + +● Real data — batch 36 has 85,520 shapes. That's a good stress test: + Ran 1 shell command (ctrl+o to expand) + +● It works and it's fast, but 21 MB of JSON for one batch — 85k SVG circles + would lock the browser. That's a real defect, not a nit. Fixing it before I + call this done: + +● Update(backend/triage.py) + ⎿  Added 46 lines, removed 3 lines + 211 return shapes + 212 + 213 + 214 -def batch_shapes(batch_id: int) -> dict: + 215 - """The triage view of one batch: every shape, scored and r + -esolved (REQ-106).""" + 214 +SCATTER_POINTS = 4000 + 215 +"""How many dots the scatter gets. A real batch runs to ~85k s + +hapes; every one of + 216 +them as an SVG circle locks the browser, and a boundary betwee + +n two clusters is + 217 +just as visible in a few thousand points. The verdict tallies + +are still counted + 218 +over every shape, so the numbers are never a sample.""" + 219 + + 220 + + 221 +def _resolved_shapes(batch_id: int): + 222 from backend import batches + 223 + 224 batch = batches.get(batch_id) + ... + 232 shapes = shapes_for_frames(frame_ids) + 233 for shape in shapes: + 234 shape.update(resolver.verdict(shape)) + 235 + return batch, frame_ids, shapes, resolver + 236 + + 237 + + 238 +def batch_summary(batch_id: int) -> dict: + 239 + """Verdict tallies over the whole batch, plus a sample to + +plot (REQ-106).""" + 240 + batch, frame_ids, shapes, resolver = _resolved_shapes(batc + +h_id) + 241 + + 242 + counts = {"keep": 0, "ignore": 0, "reclass": 0, "manual": + +0} + 243 + for shape in shapes: + 244 + counts[shape["verdict"]] += 1 + 245 + if shape["source"] == "manual": + 246 + counts["manual"] += 1 + 247 + + 248 + # An even stride rather than a random draw: the sample is + +stable across + 249 + # reloads, so points do not jump around while the user is + +reading the plot. + 250 + stride = max(1, len(shapes) // SCATTER_POINTS) + 251 + sample = [ + 252 + {k: shape[k] for k in ("id", "class_id", "score", "are + +a_pct", "aspect", "verdict", "source")} + 253 + for shape in shapes[::stride][:SCATTER_POINTS] + 254 + ] + 255 + + 256 return { + 257 "batch_id": batch_id, + 258 "project_id": batch["project_id"], + 259 "frame_count": len(frame_ids), + 233 - "shapes": shapes, + 260 + "total_shapes": len(shapes), + 261 + "counts": counts, + 262 + "sample": sample, + 263 + "sampled": len(sample) < len(shapes), + 264 "rule_version": resolver.version(), + 265 } + 266 + 267 + 268 +def batch_page(batch_id: int, sort: str = "score", offset: int + + = 0, limit: int = 120) -> dict: + 269 + """One page of shapes for the crop grid, sorted server-sid + +e so the client + 270 + never holds the whole batch.""" + 271 + if sort not in ("score", "area_pct"): + 272 + raise TriageError(f"Cannot sort by {sort}") + 273 + _, _, shapes, _ = _resolved_shapes(batch_id) + 274 + shapes.sort(key=lambda shape: shape[sort]) + 275 + page = shapes[offset:offset + limit] + 276 + for shape in page: + 277 + shape.pop("box", None) + 278 + return {"total": len(shapes), "offset": offset, "limit": l + +imit, "shapes": page} + 279 + + 280 + + 281 def preview(project_id: int) -> dict: + 282 """What the current rules would do to the whole merged dat + aset.""" + 283 with db.cursor() as cur: + +● Update(backend/api/triage.py) + ⎿  Added 11 lines, removed 3 lines + 49 return {"rules": stored} + 50 + 51 + 52 -@router.get("/api/batches/{batch_id}/triage/shapes") + 53 -def batch_shapes(batch_id: int) -> dict: + 52 +@router.get("/api/batches/{batch_id}/triage/summary") + 53 +def batch_summary(batch_id: int) -> dict: + 54 try: + 55 - return triage.batch_shapes(batch_id) + 55 + return triage.batch_summary(batch_id) + 56 except triage.TriageError as exc: + 57 raise HTTPException(404, str(exc)) + 58 + 59 + 60 +@router.get("/api/batches/{batch_id}/triage/shapes") + 61 +def batch_page(batch_id: int, sort: str = "score", offset: int + += 0, limit: int = 120) -> dict: + 62 + try: + 63 + return triage.batch_page(batch_id, sort=sort, offset=of + +fset, limit=min(limit, 500)) + 64 + except triage.TriageError as exc: + 65 + raise HTTPException(400, str(exc)) + 66 + + 67 + + 68 @router.post("/api/triage/overrides") + 69 def set_overrides(body: OverrideRequest) -> dict: + 70 try: + + Ran 4 shell commands (ctrl+o to expand) + +● 21 MB → 450 KB. Now let me verify rules actually work against your real data: + + Ran 1 shell command (ctrl+o to expand) + +● Rule works — 16,776 of 85,520 shapes ignored. But preview shows 1,390 merged + images with 0 shapes total. Let me check whether that's a bug in my code or + something real about your data: + + Ran 1 shell command (ctrl+o to expand) + +● Important find — and it's not my code. Let me clean up my test rule first: + + Ran 1 shell command (ctrl+o to expand) + +● Now update the frontend to the paginated endpoints, then a final build: + +● Update(frontend/src/api.js) + ⎿  Added 3 lines, removed 1 line + 121 triageRules: (projectId) => request(`/projects/${projectId}/ + triage/rules`), + 122 saveTriageRules: (projectId, rules) => + 123 request(`/projects/${projectId}/triage/rules`, { method: ' + PUT', body: { rules } }), + 124 - triageShapes: (batchId) => request(`/batches/${batchId}/tria + -ge/shapes`), + 124 + triageSummary: (batchId) => request(`/batches/${batchId}/tri + +age/summary`), + 125 + triageShapes: (batchId, { sort = 'score', offset = 0, limit + += 120 } = {}) => + 126 + request(`/batches/${batchId}/triage/shapes?sort=${sort}&of + +fset=${offset}&limit=${limit}`), + 127 triagePreview: (projectId) => request(`/projects/${projectId + }/triage/preview`), + 128 setTriageOverrides: (annotationIds, verdict, targetClass = n + ull) => + 129 request('/triage/overrides', { + +● Write(frontend/src/components/TriageCropGrid.jsx) + ⎿  Added 50 lines, removed 23 lines + 1 -import React, { useState } from 'react' + 1 +import React, { useCallback, useEffect, useState } from 'react' + 2 import { api } from '../api' + 3 + 4 /* A wall of cropped shapes (REQ-106). Judging "is this a half + sack?" means + 5 - * looking at the object, not at the frame it came from. Sorted + - so the shapes a + 6 - * rule is about to act on sit together. */ + 5 + * looking at the object, not at the frame it came from. + 6 + * + 7 + * Paged from the server: a real batch holds ~85k shapes, so th + +e client never + 8 + * gets to hold them all. Sorting happens server-side for the s + +ame reason — + 9 + * "worst 120 by score" has to be chosen from the whole batch, + +not from a page. */ + 10 + 11 const VERDICT_COLOR = { keep: '#4ade80', ignore: '#f87171', rec + lass: '#c084fc' } + 12 const PAGE = 120 + 13 + 11 -export default function TriageCropGrid({ shapes, selectedIds, o + -nSelect, classes }) { + 14 +export default function TriageCropGrid({ batchId, selectedIds, + +onSelect, classes, reloadKey }) { + 15 const [sortBy, setSortBy] = useState('score') + 13 - const [limit, setLimit] = useState(PAGE) + 16 + const [shapes, setShapes] = useState([]) + 17 + const [total, setTotal] = useState(0) + 18 + const [loading, setLoading] = useState(false) + 19 + const [error, setError] = useState('') + 20 + 15 - const sorted = [...shapes].sort((a, b) => + 16 - sortBy === 'score' ? a.score - b.score : a.area_pct - b.are + -a_pct, + 21 + const fetchPage = useCallback( + 22 + async (offset, replace) => { + 23 + if (!batchId) return + 24 + setLoading(true) + 25 + try { + 26 + const page = await api.triageShapes(batchId, { sort: so + +rtBy, offset, limit: PAGE }) + 27 + setTotal(page.total) + 28 + setShapes((rows) => (replace ? page.shapes : [...rows, + +...page.shapes])) + 29 + setError('') + 30 + } catch (exc) { + 31 + setError(exc.message) + 32 + } finally { + 33 + setLoading(false) + 34 + } + 35 + }, + 36 + [batchId, sortBy], + 37 ) + 18 - const visible = sorted.slice(0, limit) + 38 + 39 + useEffect(() => { fetchPage(0, true) }, [fetchPage, reloadKey + +]) + 40 + + 41 function toggle(id, event) { + 42 if (event.shiftKey) { + 43 onSelect(selectedIds.includes(id) ? selectedIds.filter((x + ) => x !== id) : [...selectedIds, id]) + ... + 52
+ 53
+ 54 + 34 - Showing {visible.length} of {sorted.length} · click t + -o select, shift-click to add + 55 + Showing {shapes.length} of {total} · click to select, + + shift-click to add + 56 + 57
+ 37 - {['score', 'area'].map((mode) => ( + 58 + {[ + 59 + { key: 'score', label: 'lowest score' }, + 60 + { key: 'area_pct', label: 'smallest area' }, + 61 + ].map((mode) => ( + 62 + 81 ))} + 82
+ 83
+ 84 + 61 - {sorted.length === 0 ? ( + 62 -

No shapes on this batch yet — run + -auto-annotation first.

+ 85 + {error &&

{error}

} + 86 + + 87 + {total === 0 && !loading ? ( + 88 +

No shapes on this batch — run auto + +-annotation first.

+ 89 ) : ( + 90
+ 65 - {visible.map((shape) => { + 91 + {shapes.map((shape) => { + 92 const chosen = selectedIds.includes(shape.id) + 93 return ( + 94 + 152 )} + 153
+ +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 4 lines, removed 3 lines + 61 const loadBatch = useCallback(async () => { + 62 if (!batchId) return + 63 try { + 64 - const [shapes, previewed] = await Promise.all([ + 65 - api.triageShapes(batchId), + 64 + const [summary, previewed] = await Promise.all([ + 65 + api.triageSummary(batchId), + 66 api.triagePreview(projectId), + 67 ]) + 68 - setView(shapes) + 68 + setView(summary) + 69 setPreview(previewed) + 70 setSelectedIds([]) + 71 + setReloadKey((n) => n + 1) + 72 } catch (exc) { + 73 setError(exc.message) + 74 } + +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 1 line + 29 const [savedRules, setSavedRules] = useState([]) + 30 const [preview, setPreview] = useState(null) + 31 const [selectedIds, setSelectedIds] = useState([]) + 32 + const [reloadKey, setReloadKey] = useState(0) + 33 const [reclassTarget, setReclassTarget] = useState('') + 34 const [saving, setSaving] = useState(false) + 35 const [busy, setBusy] = useState(false) + +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 4 lines, removed 10 lines + 77 + 78 useEffect(() => { loadBatch() }, [loadBatch]) + 79 + 80 - const shapes = view?.shapes ?? [] + 80 + // Counted server-side over every shape in the batch — the sc + +atter plots a + 81 + // sample, but these numbers are never a sample. + 82 + const counts = view?.counts ?? { keep: 0, ignore: 0, reclass: + + 0, manual: 0 } + 83 + const sample = view?.sample ?? [] + 84 const dirty = JSON.stringify(rules) !== JSON.stringify(savedR + ules) + 85 + 83 - const counts = useMemo(() => { + 84 - const tally = { keep: 0, ignore: 0, reclass: 0, manual: 0 } + 85 - shapes.forEach((shape) => { + 86 - tally[shape.verdict] += 1 + 87 - if (shape.source === 'manual') tally.manual += 1 + 88 - }) + 89 - return tally + 90 - }, [shapes]) + 91 - + 86 async function saveRules() { + 87 setSaving(true) + 88 try { + +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 7 lines, removed 1 line + 208

No auto-annotated batches yet. + Run auto-annotation on a batch first.

+ 209 ) : ( + 210 <> + 211 - + 211 + + 212 + {view?.sampled && ( + 213 +

+ 214 + Plotting {sample.length} of {view.total_shapes + +} shapes — enough to see where the + 215 + clusters split. The counts above cover all of + +them. + 216 +

+ 217 + )} + 218 + 219
+ 268 + 269 + 157 )} + 158 + 159 + {view && ( + 160 +
+ 174 + This batch is “{view.status}”. + 175 + {view.merged ? ( + 176 + + 177 + It is already in the master dataset. Rules still + + re-cut it every training run. + 178 + + 179 + ) : ( + 180 + + 181 + Nothing here reaches training until the batch is + + reviewed, approved and merged. + 182 + On merge, {view.frames_would_merge} of {view.frame_count} frames + 183 + would go in and {view.frames_held_back}< + +/strong> would be held back. + 184 + + 185 + )} + 186 +
+ 187 + )} + 188 + + 189 {preview && ( + 190
+ 161 - + 191 + + 192 + 193 + 194 + +● Now let the page show all annotated batches regardless of merge state, and + label them: + +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 1 line, removed 1 line + 219 {batches.length === 0 && } + 220 {batches.map((batch) => ( + 221 + 224 ))} + 225 + + Ran 1 shell command (ctrl+o to expand) + +● Now rebuilding the containers: + + Ran 3 shell commands (ctrl+o to expand) + +● Rebuilt and live — frontend is serving the new bundle (index-Di3kDxxW.js), + triage endpoints answer 200. But note: batch6 is now merged — it was reviewing + an hour ago. Let me re-check your dataset: + + Ran 1 shell command (ctrl+o to expand) +● Everything's live and your dataset changed — batch6 merged while we talked: + + Master dataset now: 3,340 images (2,646 train / 694 val — 20.8%, the hash + split working correctly) + - batch6 → 1,950 images carrying all 85,520 labels + - batch7 → 1,390 images with empty label files + + Reload the browser with a hard refresh (Ctrl+Shift+R) — the bundle hash + changed to index-Di3kDxxW.js, and a cached old bundle is exactly what made the + page look wrong before. + + What changed so the flow makes sense + + Triage now gates the merge, as you chose. _run_merge resolves every frame + before copying it: a frame carrying an ignore never enters the master dataset, + and a reclass is written under its target class in the label file. The job + log tells you ("held back N by triage"). Rules still re-apply at training + time, so editing them later re-cuts what's already merged — you get both, and + the earlier contradiction is gone. + + The page now opens with a status banner that says exactly where the selected + batch stands: whether it's merged, and for an unmerged one, "on merge, X of Y + frames would go in and Z would be held back." That was the missing sentence. + The batch dropdown shows status too. + + Your flow, concretely: + + 1. Batches page → extract frames → auto-annotate (SAM3 writes annotations) + 2. Data Prep → pick that batch, drag on the scatter / click crops, write + rules, hand-decide the rest + 3. Review → approve frames + 4. Approve batch → merge. Triage applies here — junk never enters + 5. Models page → "Select Batches" + "Select Classes" → train. Rules re-apply, + so a rule edit changes this run without touching what's merged + + Choosing which batch trains lives on the Models page, not Data Prep — it lists + merged batches only. That page already worked; it just wasn't obvious it was + the answer to your question. + + Reverting: rules and hand-decisions are fully reversible (delete the rule and + save; "Clear hand decisions" for overrides) because annotations.class_id is + never rewritten. A merge is still the one irreversible step — deleting the + batch is the only way back, which now also removes its files from the dataset. + + One thing to decide: 1,390 of your 3,340 images (42%) are batch7's empty label + files. If that footage really is empty conveyor, they're legitimate negative + samples and help. If those frames actually contain sacks and were merged + before annotation ran, they're actively teaching the model to miss sacks — and + at 42% of the dataset that will dominate training. Worth eyeballing a few + batch7 frames before you train. Tell me which it is and I'll either leave it + or clean it up. + + I haven't clicked through the page myself — I verified the API against live + data and the bundle is served. Reload and tell me what's still off. + +✻ Baked for 2m 57s + +※ recap: Goal is getting your retraining loop reliable, and the Data Prep triage gate is now built, rebuilt into the containers, and live. Next: hard-refresh the browser, click through the page, and tell me whether batch7's 1,390 empty frames are real empty conveyor or unannotated sacks. (disable recaps in /config) + +❯ can you create a default rule, or try different filtering approach rule, + because i really confuse when try filtering especially in UI + +● Right — three numeric range boxes with no feedback is a bad way to ask someone + to find a threshold. Let me fix the approach: presets computed from your + actual data, plus live impact as you edit. + +● Update(backend/triage.py) + ⎿  Added 123 lines + 286 return {"total": len(shapes), "offset": offset, "limit": l + imit, "shapes": page} + 287 + 288 + 289 +def _percentile(values: list, fraction: float) -> float: + 290 + if not values: + 291 + return 0.0 + 292 + return values[min(len(values) - 1, int(len(values) * fract + +ion))] + 293 + + 294 + + 295 +def suggest(batch_id: int) -> dict: + 296 + """Presets with thresholds read off this batch's own distr + +ibution. + 297 + + 298 + Asking someone to invent "score below 0.45" from nothing i + +s guesswork. The + 299 + same question is easy when the number comes from their dat + +a and the effect + 300 + is stated: "the weakest 10% of detections — 8,552 shapes". + 301 + """ + 302 + _, frame_ids, shapes, _ = _resolved_shapes(batch_id) + 303 + if not shapes: + 304 + return {"presets": [], "stats": {}} + 305 + + 306 + scores = sorted(shape["score"] for shape in shapes) + 307 + areas = sorted(shape["area_pct"] for shape in shapes) + 308 + total = len(shapes) + 309 + + 310 + def impact(predicate: dict) -> dict: + 311 + matched = [s for s in shapes if _matches(predicate, s) + +] + 312 + frames = {s["frame_id"] for s in matched} + 313 + return {"shapes": len(matched), "frames": len(frames)} + 314 + + 315 + presets = [] + 316 + + 317 + weak = round(_percentile(scores, 0.10), 3) + 318 + presets.append({ + 319 + "key": "drop-weakest", + 320 + "title": "Ignore the weakest detections", + 321 + "blurb": f"SAM3 scored these below {weak} — the bottom + + 10% of this batch.", + 322 + "rule": {"name": "low confidence", "predicate": {"scor + +e": [None, weak]}, "action": "ignore"}, + 323 + "impact": impact({"score": [None, weak]}), + 324 + }) + 325 + + 326 + specks = round(_percentile(areas, 0.05), 3) + 327 + presets.append({ + 328 + "key": "drop-specks", + 329 + "title": "Ignore tiny specks", + 330 + "blurb": f"Boxes smaller than {specks}% of the frame — + + usually noise, not objects.", + 331 + "rule": {"name": "specks", "predicate": {"area_pct": [ + +None, specks]}, "action": "ignore"}, + 332 + "impact": impact({"area_pct": [None, specks]}), + 333 + }) + 334 + + 335 + median_area = round(_percentile(areas, 0.50), 3) + 336 + presets.append({ + 337 + "key": "split-by-size", + 338 + "title": "Split by size into a second class", + 339 + "blurb": f"Everything under {median_area}% area (half + +this batch) becomes another class — " + 340 + "pick which one. Size tracks distance from th + +e camera as much as object type, " + 341 + "so check the crops before trusting it.", + 342 + "rule": {"name": "small ones", "predicate": {"area_pct + +": [None, median_area]}, + 343 + "action": "reclass", "target_class": None}, + 344 + "impact": impact({"area_pct": [None, median_area]}), + 345 + "needs_target": True, + 346 + }) + 347 + + 348 + tall = round(_percentile(sorted(s["aspect"] for s in shape + +s), 0.15), 3) + 349 + presets.append({ + 350 + "key": "odd-shapes", + 351 + "title": "Ignore oddly-shaped boxes", + 352 + "blurb": f"Aspect ratio under {tall} — long thin slive + +rs, usually a bad mask.", + 353 + "rule": {"name": "slivers", "predicate": {"aspect": [N + +one, tall]}, "action": "ignore"}, + 354 + "impact": impact({"aspect": [None, tall]}), + 355 + }) + 356 + + 357 + return { + 358 + "presets": presets, + 359 + "stats": { + 360 + "total_shapes": total, + 361 + "total_frames": len(frame_ids), + 362 + "score": {"p05": round(_percentile(scores, 0.05), + +3), + 363 + "p50": round(_percentile(scores, 0.50), + +3), + 364 + "p95": round(_percentile(scores, 0.95), + +3)}, + 365 + "area_pct": {"p05": round(_percentile(areas, 0.05) + +, 3), + 366 + "p50": round(_percentile(areas, 0.50) + +, 3), + 367 + "p95": round(_percentile(areas, 0.95) + +, 3)}, + 368 + }, + 369 + } + 370 + + 371 + + 372 +def simulate(batch_id: int, candidate_rules: List[dict]) -> di + +ct: + 373 + """What these rules would do, without saving them. + 374 + + 375 + Editing a threshold and seeing the number move is the whol + +e difference + 376 + between tuning a filter and guessing at one. + 377 + """ + 378 + _, frame_ids, shapes, _ = _resolved_shapes(batch_id) + 379 + overrides = Resolver(0).overrides if False else None # ke + +pt simple: rules only + 380 + + 381 + with db.cursor() as cur: + 382 + cur.execute("SELECT annotation_id, verdict, target_cla + +ss FROM annotation_overrides") + 383 + manual = {row[0]: (row[1], row[2]) for row in cur.fetc + +hall()} + 384 + + 385 + counts = {"keep": 0, "ignore": 0, "reclass": 0} + 386 + per_rule = [0] * len(candidate_rules) + 387 + ignored_frames = set() + 388 + + 389 + for shape in shapes: + 390 + if shape["id"] in manual: + 391 + verdict = manual[shape["id"]][0] + 392 + else: + 393 + verdict = "keep" + 394 + for index, rule in enumerate(candidate_rules): + 395 + if _matches(rule.get("predicate") or {}, shape + +): + 396 + verdict = rule["action"] + 397 + per_rule[index] += 1 + 398 + break + 399 + counts[verdict] += 1 + 400 + if verdict == "ignore": + 401 + ignored_frames.add(shape["frame_id"]) + 402 + + 403 + return { + 404 + "total_shapes": len(shapes), + 405 + "counts": counts, + 406 + "per_rule": per_rule, + 407 + "frames_held_back": len(ignored_frames), + 408 + "frames_would_merge": len(frame_ids) - len(ignored_fra + +mes), + 409 + } + 410 + + 411 + + 412 def preview(project_id: int) -> dict: + 413 """What the current rules would do to the whole merged dat + aset.""" + 414 with db.cursor() as cur: + +● Update(backend/triage.py) + ⎿  Removed 2 lines + 376 between tuning a filter and guessing at one. + 377 """ + 378 _, frame_ids, shapes, _ = _resolved_shapes(batch_id) + 379 - overrides = Resolver(0).overrides if False else None # ke + -pt simple: rules only + 380 - + 379 with db.cursor() as cur: + 380 cur.execute("SELECT annotation_id, verdict, target_cla + ss FROM annotation_overrides") + 381 manual = {row[0]: (row[1], row[2]) for row in cur.fetc + hall()} + +● Update(backend/api/triage.py) + ⎿  Added 16 lines + 79 return {"cleared": triage.clear_overrides(body.annotation_ + ids)} + 80 + 81 + 82 +@router.get("/api/batches/{batch_id}/triage/suggest") + 83 +def suggest(batch_id: int) -> dict: + 84 + try: + 85 + return triage.suggest(batch_id) + 86 + except triage.TriageError as exc: + 87 + raise HTTPException(404, str(exc)) + 88 + + 89 + + 90 +@router.post("/api/batches/{batch_id}/triage/simulate") + 91 +def simulate(batch_id: int, body: RuleList) -> dict: + 92 + try: + 93 + return triage.simulate(batch_id, [item.model_dump() fo + +r item in body.rules]) + 94 + except triage.TriageError as exc: + 95 + raise HTTPException(400, str(exc)) + 96 + + 97 + + 98 @router.get("/api/projects/{project_id}/triage/preview") + 99 def preview(project_id: int) -> dict: + 100 return triage.preview(project_id) + + Ran 2 shell commands (ctrl+o to expand) + +● Restart isn't enough — the image COPYs backend/, so it needs a rebuild: + + Ran 1 shell command (ctrl+o to expand) + +● Presets work. But look closely — this exposes a serious problem with the + design I recommended: + + "Ignore the weakest 10%" = 8,552 shapes spread across 1,882 of your 1,950 + frames. Because an ignored shape excludes its whole image, that rule would + hold back 96% of the batch. + + Your frames average 44 sacks each. At that density, nearly every frame + contains at least one weak detection, so "exclude the whole image" destroys + the dataset. Let me build the UI, then we need to fix that. + +● Update(frontend/src/api.js) + ⎿  Added 3 lines + 125 triageShapes: (batchId, { sort = 'score', offset = 0, limit + = 120 } = {}) => + 126 request(`/batches/${batchId}/triage/shapes?sort=${sort}&of + fset=${offset}&limit=${limit}`), + 127 triagePreview: (projectId) => request(`/projects/${projectId + }/triage/preview`), + 128 + triageSuggest: (batchId) => request(`/batches/${batchId}/tri + +age/suggest`), + 129 + triageSimulate: (batchId, rules) => + 130 + request(`/batches/${batchId}/triage/simulate`, { method: ' + +POST', body: { rules } }), + 131 setTriageOverrides: (annotationIds, verdict, targetClass = n + ull) => + 132 request('/triage/overrides', { + 133 method: 'POST', + +● Write(frontend/src/components/TriagePresets.jsx) + ⎿  Wrote 59 lines to frontend/src/components/TriagePresets.jsx + 1 import React from 'react' + 3 /* Starting points, with thresholds read off this batch's own di + stribution and + 4 * the cost stated up front. + 6 * Inventing "score below 0.45" from a blank box is guesswork; p + icking "the + ision. */ + 8 + 9 export default function TriagePresets({ presets, onAdd, classes, + 10 if (!presets?.length) return null + … +64 lines (ctrl+o to expand) + +● Now live impact per rule as you edit: + +● Update(frontend/src/components/TriageRules.jsx) + ⎿  Added 1 line, removed 1 line + 26 color: '#e4e4e7', + 27 } + 28 + 29 -export default function TriageRules({ rules, classes, onChange, + - onSave, saving, dirty }) { + 29 +export default function TriageRules({ rules, classes, onChange, + + onSave, saving, dirty, simulation, children }) { + 30 function update(index, patch) { + 31 onChange(rules.map((rule, i) => (i === index ? { ...rule, . + ..patch } : rule))) + 32 } + +● Update(frontend/src/components/TriageRules.jsx) + ⎿  Added 24 lines + 81 decided by hand ignore these rules entirely. + 82

+ 83 + 84 + {children} + 85 + + 86 + {simulation && rules.length > 0 && ( + 87 +

+ 97 + As written, these rules keep {simulation.cou + +nts.keep.toLocaleString()} shapes, + 98 + ignore {simulation.counts.ignore.toLocaleStr + +ing()}, reclass{' '} + 99 + {simulation.counts.reclass.toLocaleString()} + + — and{' '} + 100 + simulation.frames_would_merge ? '#f87171' : '#4ade80' }}> + 101 + {simulation.frames_would_merge.toLocaleString()} + 102 + {' '} + 103 + of {(simulation.frames_would_merge + simulation.fram + +es_held_back).toLocaleString()} frames would + 104 + reach training. + 105 +

+ 106 + )} + 107 + + 108 {rules.length === 0 &&

No rules — e + very shape is kept as its own class.

} + 109 + 110
+ +● Update(frontend/src/components/TriageRules.jsx) + ⎿  Added 6 lines + 180 + 181 )} + 182 + 183 + {simulation?.per_rule?.[index] !== undefined && ( + 184 + + 185 + matches {simulation.per_rule[index].toLocaleSt + +ring()} + 186 + + 187 + )} + 188 + + 189 + 190 + +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 2 lines + 31 const [selectedIds, setSelectedIds] = useState([]) + 32 const [reloadKey, setReloadKey] = useState(0) + 33 const [reclassTarget, setReclassTarget] = useState('') + 34 + const [presets, setPresets] = useState([]) + 35 + const [simulation, setSimulation] = useState(null) + 36 const [saving, setSaving] = useState(false) + 37 const [busy, setBusy] = useState(false) + 38 const [error, setError] = useState('') + +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 22 lines + 79 + 80 useEffect(() => { loadBatch() }, [loadBatch]) + 81 + 82 + useEffect(() => { + 83 + if (!batchId) return + 84 + let cancelled = false + 85 + api.triageSuggest(batchId) + 86 + .then((data) => { if (!cancelled) setPresets(data.preset + +s) }) + 87 + .catch(() => { if (!cancelled) setPresets([]) }) + 88 + return () => { cancelled = true } + 89 + }, [batchId]) + 90 + + 91 + // Debounced: the point is watching the number move as you d + +rag a threshold, + 92 + // and a request per keystroke would just queue up behind it + +self. + 93 + useEffect(() => { + 94 + if (!batchId) return + 95 + let cancelled = false + 96 + const timer = setTimeout(() => { + 97 + api.triageSimulate(batchId, rules) + 98 + .then((data) => { if (!cancelled) setSimulation(data) + +}) + 99 + .catch(() => { if (!cancelled) setSimulation(null) }) + 100 + }, 350) + 101 + return () => { cancelled = true; clearTimeout(timer) } + 102 + }, [batchId, rules]) + 103 + + 104 // Counted server-side over every shape in the batch — the s + catter plots a + 105 // sample, but these numbers are never a sample. + 106 const counts = view?.counts ?? { keep: 0, ignore: 0, reclass + : 0, manual: 0 } + +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 9 lines, removed 1 line + 226 onSave={saveRules} + 227 saving={saving} + 228 dirty={dirty} + 229 - /> + 229 + simulation={simulation} + 230 + > + 231 + setRules((current) => [...current, + +rule])} + 236 + /> + 237 + + 238 + 239
+ 240
+ +● Update(frontend/src/pages/DataPrepPage.jsx) + ⎿  Added 1 line + 3 + 4 import { AlertIcon, DatabaseIcon, RocketIcon, SlidersIcon } from + '../components/Icons' + 5 import TriageCropGrid from '../components/TriageCropGrid' + 6 +import TriagePresets from '../components/TriagePresets' + 7 import TriageRules from '../components/TriageRules' + 8 import TriageScatter from '../components/TriageScatter' + 9 + + Ran 1 shell command (ctrl+o to expand) +● That's the proof: 68 of 1,950 frames would survive. I need to correct the + recommendation I gave you earlier. + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilt and live (index-4bSN9aPo.js). Hard-refresh the browser. + + What's on the page now, to fix the filtering confusion: + + - Four preset cards with thresholds computed from your batch, each stating its + cost before you click: "SAM3 scored these below 0.399 — the bottom 10%. 8,552 + shapes · 1,882 frames." Click "Add this rule" and it drops into the editor, + pre-filled and editable. + - Live impact, debounced 350 ms: a blue line under the rules saying how many + shapes are kept/ignored/reclassed and how many frames would actually reach + training, plus a matches N counter on each individual rule row. You now watch + the number move as you drag a threshold instead of guessing. + - Presets turn their count red when an ignore rule would take most of the + batch. + + Now the correction. Earlier I recommended "an ignored shape excludes its whole + image", and you agreed. Your data proves that was wrong advice for your case: + + ┌───────────────┬───────────────────────┬──────────────────────────┐ + │ Rule │ Shapes ignored │ Frames reaching training │ + ├───────────────┼───────────────────────┼──────────────────────────┤ + │ score < 0.399 │ 8,552 of 85,520 (10%) │ 68 of 1,950 │ + └───────────────┴───────────────────────┴──────────────────────────┘ + + Your frames carry ~44 sacks each. Almost every frame contains at least one + weak detection, so excluding whole images throws away 96% of the data to + remove 10% of the boxes. My reasoning was sound for sparse scenes (1–2 objects + per frame, where an unlabeled object really does dominate that image's + signal) and wrong for dense ones. At 44 objects per frame, dropping 4 boxes + leaves 40 correctly labeled — the background-poisoning harm is small and + local, while the data loss is catastrophic. + + So ignore needs to mean something different for you: + +● User answered Claude's questions: + ⎿  · What should 'ignore' do, now that we can see the cost on your real data? + → Drop the box, keep the image (Recommended) + · Do you want a rule set applied by default to new projects? → No default, + presets are enough + +● Changing ignore to drop the box and keep the image. One guard I'm adding: if a + frame loses every shape it had, the image is still excluded — otherwise it + becomes a false negative teaching the model that a frame full of sacks is + empty. + +● Update(backend/triage.py) + ⎿  Added 11 lines, removed 4 lines + 12 + 13 keep the shape trains as its own class + 14 reclass -> class_id the shape trains as a different class + (REQ-105) + 15 - ignore the shape does not train, and takes i + -ts whole image + 16 - with it (REQ-104) — an image with a k + -nown object left + 17 - unlabeled would teach the model that + -object is + 18 - background + 15 + ignore the box is dropped; its image still t + +rains (REQ-104) + 16 + + 17 +`ignore` drops the box rather than the image because these fram + +es are dense — + 18 +around 44 shapes each. Excluding the whole image was measured a + +gainst a real + 19 +batch and cost 96% of it (1,882 of 1,950 frames) to remove 10% + +of the boxes. + 20 +Dropping four boxes out of forty-four leaves the image overwhel + +mingly correct; + 21 +dropping the image leaves nothing to train on. + 22 + + 23 +The exception is a frame that loses *every* shape it had: an em + +pty label file + 24 +says "there is nothing here", and for a frame that was full of + +sacks that is a + 25 +lie the model will learn. Those images are excluded. + 26 """ + 27 + 28 import hashlib + +● Update(backend/triage.py) + ⎿  Added 21 lines, removed 1 line + 166 "source": rule["name"]} + 167 return {"verdict": "keep", "target_class": None, "sour + ce": "default"} + 168 + 169 + def resolve_shapes(self, annotations: list) -> Optional[li + +st]: + 170 + """Apply verdicts to one frame's annotations. + 171 + + 172 + Returns the surviving annotations with their effective + + class, or None + 173 + when the frame must not train at all — which now happe + +ns only if every + 174 + shape was dropped. + 175 + """ + 176 + kept = [] + 177 + for item in annotations: + 178 + shape = {"id": item["id"], "class_id": item["class + +_id"], + 179 + "score": float(item.get("score") or 1.0), + 180 + **metrics(item["geometry"])} + 181 + effective = self.effective_class(shape) + 182 + if effective is None: + 183 + continue + 184 + kept.append({**item, "class_id": effective}) + 185 + if annotations and not kept: + 186 + return None + 187 + return kept + 188 + + 189 def effective_class(self, shape: dict) -> Optional[int]: + 170 - """The class this shape trains as, or None when it mus + -t not train.""" + 190 + """The class this shape trains as, or None when the bo + +x is dropped.""" + 191 resolved = self.verdict(shape) + 192 if resolved["verdict"] == "ignore": + 193 return None + +● Now update the two consumers to the new semantics: + +● Update(backend/dataset.py) + ⎿  Added 4 lines, removed 14 lines + 158 annotations = review.listing(frame_id) + 159 + 160 if resolver is not None: + 161 - resolved = [] + 162 - skip_image = False + 163 - for item in annotations: + 164 - shape = {"id": item["id"], "class_id": item["c + -lass_id"], + 165 - "score": float(item.get("score") or 1 + -.0), + 166 - **triage.metrics(item["geometry"])} + 167 - effective = resolver.effective_class(shape) + 168 - if effective is None: + 169 - # REQ-104: a known object left unlabeled w + -ould teach the + 170 - # model it is background, so the image goe + -s too. + 171 - skip_image = True + 172 - break + 173 - resolved.append({**item, "class_id": effective + -}) + 174 - if skip_image: + 161 + resolved = resolver.resolve_shapes(annotations) + 162 + if resolved is None: + 163 + # Every shape was dropped. An empty label file + + would tell the + 164 + # model this frame is empty, which it is not ( + +REQ-104). + 165 excluded += 1 + 166 continue + 167 annotations = resolved + +● Update(backend/dataset.py) + ⎿  Added 1 line, removed 10 lines + 489 + 490 annotations = review.listing(frame["id"]) + 491 if gating: + 492 - resolved = [] + 493 - for item in annotations: + 494 - shape = {"id": item["id"], "class_id": item["c + -lass_id"], + 495 - "score": float(item.get("score") or 1 + -.0), + 496 - **triage.metrics(item["geometry"])} + 497 - effective = resolver.effective_class(shape) + 498 - if effective is None: + 499 - resolved = None + 500 - break + 501 - resolved.append({**item, "class_id": effective + -}) + 492 + resolved = resolver.resolve_shapes(annotations) + 493 if resolved is None: + 494 triaged_out += 1 + 495 job.progress(index + 1, len(frames)) + +● Now the reporting has to count frames the new way — only frames losing all + shapes are held back: + +● Update(backend/triage.py) + ⎿  Added 7 lines, removed 3 lines + 267 batch, frame_ids, shapes, resolver = _resolved_shapes(batc + h_id) + 268 + 269 counts = {"keep": 0, "ignore": 0, "reclass": 0, "manual": + 0} + 270 - ignored_frames = set() + 270 + per_frame = {} + 271 for shape in shapes: + 272 counts[shape["verdict"]] += 1 + 273 if shape["source"] == "manual": + 274 counts["manual"] += 1 + 275 - if shape["verdict"] == "ignore": + 276 - ignored_frames.add(shape["frame_id"]) + 275 + total, dropped = per_frame.get(shape["frame_id"], (0, + +0)) + 276 + per_frame[shape["frame_id"]] = (total + 1, dropped + ( + +shape["verdict"] == "ignore")) + 277 + 278 + # Only a frame that loses everything is held back; the res + +t keep training + 279 + # with their surviving boxes. + 280 + ignored_frames = {fid for fid, (total, dropped) in per_fra + +me.items() if total == dropped} + 281 + + 282 # An even stride rather than a random draw: the sample is + stable across + 283 # reloads, so points do not jump around while the user is + reading the plot. + 284 stride = max(1, len(shapes) // SCATTER_POINTS) + +● Update(backend/triage.py) + ⎿  Added 5 lines, removed 3 lines + 413 + 414 counts = {"keep": 0, "ignore": 0, "reclass": 0} + 415 per_rule = [0] * len(candidate_rules) + 416 - ignored_frames = set() + 416 + per_frame = {} + 417 + 418 for shape in shapes: + 419 if shape["id"] in manual: + ... + 426 per_rule[index] += 1 + 427 break + 428 counts[verdict] += 1 + 429 - if verdict == "ignore": + 430 - ignored_frames.add(shape["frame_id"]) + 429 + total, dropped = per_frame.get(shape["frame_id"], (0, + +0)) + 430 + per_frame[shape["frame_id"]] = (total + 1, dropped + ( + +verdict == "ignore")) + 431 + 432 + ignored_frames = {fid for fid, (total, dropped) in per_fra + +me.items() if total == dropped} + 433 + + 434 return { + 435 "total_shapes": len(shapes), + 436 "counts": counts, + +● Update(backend/triage.py) + ⎿  Added 5 lines, removed 2 lines + 451 shapes = shapes_for_frames(frame_ids) + 452 counts = {"keep": 0, "ignore": 0, "reclass": 0} + 453 per_class: dict = {} + 454 - excluded_images = set() + 454 + per_frame = {} + 455 for shape in shapes: + 456 resolved = resolver.verdict(shape) + 457 counts[resolved["verdict"]] += 1 + 458 + total, dropped = per_frame.get(shape["frame_id"], (0, + +0)) + 459 + per_frame[shape["frame_id"]] = (total + 1, dropped + ( + +resolved["verdict"] == "ignore")) + 460 if resolved["verdict"] == "ignore": + 459 - excluded_images.add(shape["frame_id"]) + 461 continue + 462 effective = resolver.effective_class(shape) + 463 per_class[effective] = per_class.get(effective, 0) + 1 + 464 + 465 + excluded_images = {fid for fid, (total, dropped) in per_fr + +ame.items() if total == dropped} + 466 + + 467 return { + 468 "total_shapes": len(shapes), + 469 "total_images": len(frame_ids), + + Searched for 1 pattern (ctrl+o to expand) + +● Now update the UI wording to match the new meaning, then rebuild: + +● Update(frontend/src/components/TriageRules.jsx) + ⎿  Added 4 lines, removed 4 lines + 94 border: '1px solid rgba(56,189,248,0.25)', + 95 }} + 96 > + 97 - As written, these rules keep {simulation.cou + -nts.keep.toLocaleString()} shapes, + 98 - ignore {simulation.counts.ignore.toLocaleStr + -ing()}, reclass{' '} + 97 + As written, these rules keep {simulation.cou + +nts.keep.toLocaleString()} boxes, + 98 + drop {simulation.counts.ignore.toLocaleStrin + +g()}, reclass{' '} + 99 {simulation.counts.reclass.toLocaleString()} + — and{' '} + 100 simulation.frames_would_merge ? '#f87171' : '#4ade80' }}> + 101 {simulation.frames_would_merge.toLocaleString()} + 102 {' '} + 103 - of {(simulation.frames_would_merge + simulation.fram + -es_held_back).toLocaleString()} frames would + 104 - reach training. + 103 + of {(simulation.frames_would_merge + simulation.fram + +es_held_back).toLocaleString()} frames still + 104 + train. A dropped box leaves its image in; only a fra + +me that loses every box is held back. + 105

+ 106 )} + 107 + +● Update(frontend/src/components/TriagePresets.jsx) + ⎿  Added 4 lines, removed 6 lines + 33 > + 34 {preset.t + itle} + 35 {preset.blurb} + 36 - + 40 - {preset.impact.shapes.toLocaleString()} shapes + -· {preset.impact.frames.toLocaleString()} frames + 41 - {heavy && preset.rule.action === 'ignore' && ' + -— most of the batch'} + 36 + + 37 + {preset.impact.shapes.toLocaleString()} boxes + 38 + {preset.rule.action === 'ignore' ? ' dropped' : + + ' matched'} · touches{' '} + 39 + {preset.impact.frames.toLocaleString()} frames + 40 + 41
+ 95 {batches.length > 0 && ( + 96 - + 96 +
+ 97 + + 104 + Download Annotations + +(.zip) + 105 + + 106 + + 113 +
+ 114 )} + 115
+ 116 + + Searched for 1 pattern (ctrl+o to expand) + +● Update(frontend/src/pages/BatchesPage.jsx) + ⎿  Added 1 line, removed 1 line + 1 import React, { useCallback, useEffect, useRef, useState } from + 'react' + 2 import { api } from '../api' + 3 -import { AlertIcon, ZapIcon } from '../components/Icons' + 3 +import { AlertIcon, DatabaseIcon, ZapIcon } from '../components/ + +Icons' + 4 import { BatchList, ActiveJobsBanner } from './LibraryPage' + 5 + 6 export default function BatchesPage({ projectId, onProject }) { + +❯ tambahkan juga upload zip untuk upload hasil backup agar bisa dilanjutkan + +● Baik, saya tambahkan juga import ZIP-nya. Lanjut dulu di file yang sama: + +● Update(backend/export.py) + ⎿  Added 148 lines + 145 path = shutil.make_archive(archive_base, "zip", staging) + 146 shutil.rmtree(staging, ignore_errors=True) + 147 return path + 148 + + 149 + + 150 +# ---- restore ----------------------------------------------- + +------------- + 151 + + 152 +def _safe_members(archive) -> list: + 153 + """Only plain files under images/ and labels/. + 154 + + 155 + A zip can name `../../etc/passwd`; extracting one blindly + +writes wherever it + 156 + says. Every member is checked rather than trusted. + 157 + """ + 158 + picked = [] + 159 + for member in archive.infolist(): + 160 + if member.is_dir(): + 161 + continue + 162 + name = member.filename.replace("\\", "/") + 163 + if name.startswith("/") or ".." in name.split("/"): + 164 + continue + 165 + if name.startswith(("images/", "labels/")) or name == + +"MANIFEST.json": + 166 + picked.append((name, member)) + 167 + return picked + 168 + + 169 + + 170 +def _points_from_label(parts: List[str], label_type: str) -> O + +ptional[dict]: + 171 + values = [float(v) for v in parts] + 172 + if label_type == "bbox": + 173 + if len(values) != 4: + 174 + return None + 175 + cx, cy, w, h = values + 176 + return {"type": "bbox", + 177 + "points": [cx - w / 2, cy - h / 2, cx + w / 2, + + cy + h / 2]} + 178 + if len(values) < 6 or len(values) % 2: + 179 + return None + 180 + return {"type": "polygon", + 181 + "points": [[values[i], values[i + 1]] for i in ran + +ge(0, len(values), 2)]} + 182 + + 183 + + 184 +def restore_zip(project_id: int, zip_path: str, batch_label: s + +tr = "") -> dict: + 185 + """Load an exported zip back in as a fresh batch, ready to + + keep reviewing. + 186 + + 187 + The frames land in a new batch rather than being merged ba + +ck into the ones + 188 + they came from: the originals may still exist, and silentl + +y overwriting a + 189 + batch the user is working in would destroy the very work t + +his feature is + 190 + meant to protect. + 191 + """ + 192 + import zipfile + 193 + + 194 + from PIL import Image + 195 + + 196 + project = projects.get(project_id) + 197 + if project is None: + 198 + raise ExportError("No such project") + 199 + + 200 + by_name = {item["name"]: item["class_id"] for item in proj + +ect["classes"]} + 201 + stamp = time.strftime("%Y%m%d-%H%M%S") + 202 + label = batch_label or f"restored-{stamp}" + 203 + + 204 + with zipfile.ZipFile(zip_path) as archive: + 205 + members = _safe_members(archive) + 206 + names = {name for name, _ in members} + 207 + if not any(name.startswith("images/") for name in name + +s): + 208 + raise ExportError("This zip has no images/ folder + +— is it an export from this app?") + 209 + + 210 + manifest = {} + 211 + if "MANIFEST.json" in names: + 212 + manifest = json.loads(archive.read("MANIFEST.json" + +)) + 213 + source_type = manifest.get("label_type", project["labe + +l_type"]) + 214 + if source_type != project["label_type"]: + 215 + raise ExportError( + 216 + f"This export holds {source_type} labels but t + +he project is " + 217 + f"{project['label_type']} — importing it would + + produce wrong shapes" + 218 + ) + 219 + + 220 + # Classes come back by name, so an id that shifted sin + +ce the export does + 221 + # not silently relabel every shape. + 222 + remap = {} + 223 + for item in manifest.get("classes", []): + 224 + if item["name"] in by_name: + 225 + remap[item["class_id"]] = by_name[item["name"] + +] + 226 + else: + 227 + raise ExportError( + 228 + f"The export uses class '{item['name']}', + +which this project does not " + 229 + "have. Add the class first, then import." + 230 + ) + 231 + + 232 + with db.cursor() as cur: + 233 + cur.execute( + 234 + """INSERT INTO batches (project_id, video_path + +, date_label, batch_label, + 235 + start_sec, end_sec, fp + +s, status, created_at) + 236 + VALUES (?, '', 'restored', ?, 0, 0, 0, 'ext + +racted', ?)""", + 237 + (project_id, label, time.time()), + 238 + ) + 239 + batch_id = cur.lastrowid + 240 + + 241 + target_dir = batches.frames_dir(project["slug"], batch + +_id) + 242 + os.makedirs(target_dir, exist_ok=True) + 243 + + 244 + restored, shapes, skipped = 0, 0, 0 + 245 + image_members = sorted(n for n in names if n.startswit + +h("images/")) + 246 + for index, name in enumerate(image_members): + 247 + stem = os.path.splitext(os.path.basename(name))[0] + 248 + if not stem: + 249 + continue + 250 + filename = f"{stem}.jpg" + 251 + destination = os.path.join(target_dir, filename) + 252 + with archive.open(name) as source, open(destinatio + +n, "wb") as handle: + 253 + shutil.copyfileobj(source, handle) + 254 + + 255 + try: + 256 + with Image.open(destination) as image: + 257 + width, height = image.size + 258 + except Exception: + 259 + os.unlink(destination) + 260 + skipped += 1 + 261 + continue + 262 + + 263 + with db.cursor() as cur: + 264 + cur.execute( + 265 + """INSERT INTO frames (batch_id, idx, file + +name, width, height, review_status) + 266 + VALUES (?, ?, ?, ?, ?, 'pending')""", + 267 + (batch_id, index, filename, width, height) + +, + 268 + ) + 269 + frame_id = cur.lastrowid + 270 + + 271 + label_name = f"labels/{stem}.txt" + 272 + if label_name in names: + 273 + for line in archive.read(label_name).decod + +e("utf-8").splitlines(): + 274 + parts = line.split() + 275 + if len(parts) < 5: + 276 + continue + 277 + geometry = _points_from_label(parts[1: + +], project["label_type"]) + 278 + if geometry is None: + 279 + continue + 280 + class_id = remap.get(int(parts[0]), in + +t(parts[0])) + 281 + cur.execute( + 282 + """INSERT INTO annotations (frame_ + +id, class_id, geometry, score, + 283 + source + +, created_at) + 284 + VALUES (?, ?, ?, 1.0, 'manual', + + ?)""", + 285 + (frame_id, class_id, json.dumps(ge + +ometry), time.time()), + 286 + ) + 287 + shapes += 1 + 288 + restored += 1 + 289 + + 290 + with db.cursor() as cur: + 291 + cur.execute("UPDATE batches SET frame_count = ?, statu + +s = 'reviewing' WHERE id = ?", + 292 + (restored, batch_id)) + 293 + + 294 + return {"batch_id": batch_id, "batch_label": label, "frame + +s": restored, + 295 + "shapes": shapes, "skipped": skipped} + +● Update(backend/api/triage.py) + ⎿  Added 22 lines + 117 return FileResponse(path, media_type="application/zip", fi + lename=os.path.basename(path)) + 118 + 119 + 120 +@router.post("/api/projects/{project_id}/import") + 121 +async def import_annotated(project_id: int, file: UploadFile = + + File(...), + 122 + batch_label: str = Form("")) -> dic + +t: + 123 + """Load a previously exported zip back in, as a new batch + +to keep working on.""" + 124 + import tempfile + 125 + + 126 + from backend import export + 127 + + 128 + staged = tempfile.NamedTemporaryFile(suffix=".zip", delete + +=False) + 129 + try: + 130 + shutil.copyfileobj(file.file, staged) + 131 + staged.close() + 132 + return export.restore_zip(project_id, staged.name, bat + +ch_label=batch_label) + 133 + except export.ExportError as exc: + 134 + raise HTTPException(400, str(exc)) + 135 + except Exception as exc: + 136 + raise HTTPException(400, f"Could not read that zip: {e + +xc}") + 137 + finally: + 138 + if os.path.exists(staged.name): + 139 + os.unlink(staged.name) + 140 + + 141 + + 142 @router.get("/api/annotations/{annotation_id}/crop") + 143 def crop(annotation_id: int, pad: float = 0.08): + 144 """The shape itself, cropped out of its frame — the crop g + rid judges objects, + +● Update(backend/api/triage.py) + ⎿  Added 2 lines, removed 1 line + 2 + 3 import io + 4 import os + 5 +import shutil + 6 + 6 -from fastapi import APIRouter, HTTPException + 7 +from fastapi import APIRouter, File, Form, HTTPException, Uploa + +dFile + 8 from fastapi.responses import StreamingResponse + 9 from pydantic import BaseModel + 10 from typing import List, Optional + +● Sekarang tombol upload di UI: + +● Update(frontend/src/api.js) + ⎿  Added 6 lines + 136 clearTriageOverrides: (annotationIds) => + 137 request('/triage/overrides', { method: 'DELETE', body: { a + nnotation_ids: annotationIds } }), + 138 cropUrl: (annotationId) => `/api/annotations/${annotationId} + /crop`, + 139 + importAnnotations: (projectId, zipFile, batchLabel = '') => + +{ + 140 + const form = new FormData() + 141 + form.append('file', zipFile) + 142 + form.append('batch_label', batchLabel) + 143 + return request(`/projects/${projectId}/import`, { method: + +'POST', form }) + 144 + }, + 145 exportUrl: (projectId, { batchIds = [], approvedOnly = false + , includeEmpty = false } = {}) => { + 146 const params = new URLSearchParams() + 147 if (batchIds.length) params.set('batch_ids', batchIds.join + (',')) + + Read 1 file (ctrl+o to expand) + +● Update(frontend/src/pages/BatchesPage.jsx) + ⎿  Added 24 lines + 8 const [batches, setBatches] = useState([]) + 9 const [jobs, setJobs] = useState([]) + 10 const [error, setError] = useState('') + 11 + const [importing, setImporting] = useState(false) + 12 + const [notice, setNotice] = useState('') + 13 + const importInput = useRef(null) + 14 + 15 + async function handleImport(event) { + 16 + const file = event.target.files?.[0] + 17 + event.target.value = '' + 18 + if (!file) return + 19 + setImporting(true) + 20 + setError('') + 21 + setNotice('') + 22 + try { + 23 + const result = await api.importAnnotations(projectId, fil + +e) + 24 + setNotice( + 25 + `Restored "${result.batch_label}" — ${result.frames} fr + +ame(s), ${result.shapes} annotation(s)` + 26 + + (result.skipped ? `, ${result.skipped} skipped` : '') + +, + 27 + ) + 28 + loadBatches() + 29 + } catch (exc) { + 30 + setError(exc.message) + 31 + } finally { + 32 + setImporting(false) + 33 + } + 34 + } + 35 + + 36 const loadBatches = useCallback(() => { + 37 api.listBatches(projectId).then((payload) => setBatches(pay + load.batches)).catch(() => {}) + 38 }, [projectId]) + +● Update(frontend/src/pages/BatchesPage.jsx) + ⎿  Added 19 lines, removed 3 lines + 116

Batches ({batches.length})

+ 117

{project.name}

+ 118
+ 119 - {batches.length > 0 && ( + 120 -
+ 121 - + 120 + + 127 + + 136 + {batches.length > 0 && ( + 137 + < + 138 className="btn" + 139 href={api.exportUrl(projectId)} + 140 download + +● Edit itu merusak JSX-nya. Saya perbaiki blok itu utuh: + + Read 1 file (ctrl+o to expand) +● Update(frontend/src/pages/BatchesPage.jsx) + ⎿  Added 24 lines, removed 18 lines + 134 {importing ? 'Restoring…' + : 'Restore from .zip'} + 135 + 136 {batches.length > 0 && ( + 137 - < + 138 - className="btn" + 139 - href={api.exportUrl(projectId)} + 140 - download + 141 - title="Download every annotated frame in this pr + -oject as a YOLO dataset — your own backup, no merge needed" + 142 - style={{ fontSize: '0.85rem', padding: '8px 16px + -', cursor: 'pointer', display: 'inline-flex', alignItems: 'cen + -ter', gap: 6, borderRadius: 6 }} + 143 - > + 144 - Download Annotations + -(.zip) + 145 - + 146 - + 153 -
+ 154 - )} + 137 + <> + 138 + + 145 + Download Annotation + +s (.zip) + 146 + + 147 + + 154 + + 155 + )} + 156 +
+ 157
+ 158 + 159 + {notice && ( + 160 +

{notice}

+ 161 + )} + 162 + + 163 + 164 + 165 {batches.length === 0 ? ( + +● Update(frontend/src/pages/BatchesPage.jsx) + ⎿  Added 1 line, removed 1 line + 1 import React, { useCallback, useEffect, useRef, useState } from + 'react' + 2 import { api } from '../api' + 3 -import { AlertIcon, DatabaseIcon, ZapIcon } from '../components/ + -Icons' + 3 +import { AlertIcon, DatabaseIcon, UploadIcon, ZapIcon } from '.. + +/components/Icons' + 4 import { BatchList, ActiveJobsBanner } from './LibraryPage' + 5 + 6 export default function BatchesPage({ projectId, onProject }) { + + Ran 1 shell command (ctrl+o to expand) + +● Rebuild container, lalu saya uji export→import benar-benar bolak-balik: + + Ran 1 shell command (ctrl+o to expand) + +● Export jalan: 932 gambar, 6.324 anotasi, 187 MB. Sekarang uji import-nya + benar-benar bisa dipulihkan: + + Ran 1 shell command (ctrl+o to expand) + +● Sama persis. Sekarang saya cek geometri kotaknya benar-benar utuh, bukan cuma + jumlahnya cocok: + + Ran 1 shell command (ctrl+o to expand) + +● Utuh — 0 shape berbeda, selisih koordinat terbesar 0,001 pixel (itu cuma + pembulatan 6 desimal di format YOLO). Sekarang saya hapus batch uji supaya + data Anda bersih: + + Ran 1 shell command (ctrl+o to expand) + +● Satu masalah yang saya lihat: tiap klik download menulis file ZIP 187 MB di + folder proyek dan tidak pernah dihapus. Sepuluh kali klik = 1,9 GB sampah. + Saya perbaiki: + +● Update(backend/export.py) + ⎿  Added 11 lines, removed 3 lines + 139 with open(os.path.join(staging, "MANIFEST.json"), "w", enc + oding="utf-8") as handle: + 140 json.dump(manifest, handle, indent=2) + 141 + 142 + project_root = config.project_dir(project["slug"]) + 143 + # Each export is ~200 MB. Without this, every click leaves + + another copy + 144 + # behind and the project folder grows without bound. + 145 + for stale in os.listdir(project_root): + 146 + if stale.startswith("export-") and stale.endswith(".zi + +p"): + 147 + try: + 148 + os.unlink(os.path.join(project_root, stale)) + 149 + except OSError: + 150 + pass + 151 + + 152 stamp = time.strftime("%Y%m%d-%H%M%S") + 143 - archive_base = os.path.join(config.project_dir(project["sl + -ug"]), + 144 - f"export-{stamp}") + 145 - path = shutil.make_archive(archive_base, "zip", staging) + 153 + path = shutil.make_archive(os.path.join(project_root, f"ex + +port-{stamp}"), "zip", staging) + 154 shutil.rmtree(staging, ignore_errors=True) + 155 return path + 156 + + Ran 2 shell commands (ctrl+o to expand) + +● Selesai dan sudah diuji dengan data asli Anda. + + 1. Approve = simpan, tidak approve = ignore + + approve() tidak lagi menolak batch yang masih ada frame pending. Sekarang + hanya frame ber-status approved yang masuk dataset; rejected dan pending + ditinggalkan begitu saja. Jadi batch yang baru direview separuh tetap bisa + di-merge untuk bagian yang sudah selesai. + + Konsekuensinya saya tangani juga: batch yang sudah merged sekarang boleh + di-merge ulang untuk frame yang baru Anda approve belakangan. Kalau tidak, + sisa frame di batch itu akan terkunci selamanya — dan itu justru merugikan + karena merge sebagian sekarang jadi hal biasa. Penolakan hanya terjadi kalau + memang tidak ada frame approved baru. + + 2. Download ZIP — tombol "Download Annotations (.zip)" di halaman Batches. + Isinya images/, labels/ (format YOLO), data.yaml, classes.txt, dan + MANIFEST.json. Ini tidak bergantung pada merge — semua frame yang punya + anotasi ikut, apa pun status batch-nya. Frame tanpa anotasi sengaja dilewati + (176 frame), karena file label kosong artinya "tidak ada objek di sini", dan + untuk frame yang sekadar belum dianotasi itu klaim yang salah. + + 3. Upload ZIP — tombol "Restore from .zip" di halaman yang sama. Hasilnya + masuk sebagai batch baru (restored-), bukan menimpa batch yang ada — + menimpa batch yang sedang Anda kerjakan justru akan menghancurkan pekerjaan + yang mau dilindungi fitur ini. + + Hasil uji pulang-pergi dengan data Anda: + + ┌────────────────────────────┬───────────────────────────────────┐ + │ │ │ + ├────────────────────────────┼───────────────────────────────────┤ + │ Export │ 932 gambar, 6.324 anotasi, 187 MB │ + ├────────────────────────────┼───────────────────────────────────┤ + │ Import kembali │ 932 frame, 6.324 anotasi, 0 gagal │ + ├────────────────────────────┼───────────────────────────────────┤ + │ Shape yang berbeda │ 0 │ + ├────────────────────────────┼───────────────────────────────────┤ + │ Selisih koordinat terbesar │ 0,001 pixel pada frame 1920px │ + └────────────────────────────┴───────────────────────────────────┘ + + Batch uji sudah saya hapus. Data Anda sekarang: 10 batch, 6.324 anotasi, 1.547 + manual — persis seperti sebelum saya mulai. + + Tiga hal yang saya putuskan sendiri, tolong dikoreksi kalau salah: + + - Kelas dicocokkan berdasarkan nama, bukan angka. Kalau urutan kelas berubah + setelah export, class_id lama bisa menunjuk kelas yang salah — dengan nama, + itu tidak mungkin terjadi. Kalau ada kelas di ZIP yang belum ada di proyek, + import ditolak dengan pesan jelas, tidak diam-diam dibuat. + - Isi ZIP divalidasi terhadap zip slip (anggota bernama ../../etc/passwd + diabaikan), dan label_type bbox/polygon dicek supaya tidak menghasilkan bentuk + salah. + - Anotasi hasil restore ditandai source='manual', jadi tidak akan tertimpa + kalau auto-annotate dijalankan lagi di batch itu. + + Satu bug yang saya temukan sambil menguji dan sudah diperbaiki: tiap klik + download menulis ZIP 187 MB ke folder proyek dan tidak pernah menghapusnya. + Sepuluh klik = 1,9 GB terbuang. Sekarang export lama dibersihkan sebelum yang + baru dibuat. File sisa dari pengujian tadi sudah saya hapus. + + Container sudah di-rebuild (index-BhGV3ZDV.js). Hard-refresh browser dulu + sebelum mencoba. + +✻ Cogitated for 4m 49s + +❯ oke sekarang di table buatkan tombol untuk memilih mana yang akan di "approve + batch" dan masuk ke proses selanjutnya, untuk posisi ganti dengan auto + annotate karena itu tombol ada 2 di satu pages + +● Saya lihat dulu tabelnya dan tombol yang dobel itu. + + Searched for 1 pattern, read 1 file, ran 1 shell command (ctrl+o to expand) + +● batch.review.approved tersedia — jadi tombolnya bisa tahu mana yang layak. + Saya ganti tombol dobel itu: + +● Update(frontend/src/pages/LibraryPage.jsx) + ⎿  Added 17 lines, removed 8 lines + 152
+ 153
+ 154

Batches ({batches.length})

+ 155 - + 162 - + 155 +
+ 156 + {selectedIds.length > 0 && ( + 157 + + 158 + {approvableSelected.length} of {selectedIds.le + +ngth} selected can be merged + 159 + + 160 + )} + 161 + + 171 +
+ 172
+ 173 + 174 + +● Sekarang kolom checkbox di tabel dan logikanya: + +● Update(frontend/src/pages/LibraryPage.jsx) + ⎿  Added 12 lines + 174 + 175 + 176 + 177 + + 189 + 190 + +● Update(frontend/src/pages/LibraryPage.jsx) + ⎿  Added 18 lines + 199 + 200 + 201 + 219 + + + {videos + .filter((item) => !searchQuery || item.batch_label.toLowerCase().includes(searchQuery.toLowerCase())) + .map((item) => ( + + + + + + + + + + + + + ))} + +
+ 178 + 0 && approvableSe + +lected.length === approvable.length} + 182 + onChange={(event) => + 183 + setSelectedIds(event.target.checked ? approv + +able.map((b) => b.id) : []) + 184 + } + 185 + disabled={approvable.length === 0} + 186 + style={{ cursor: approvable.length === 0 ? 'no + +t-allowed' : 'pointer' }} + 187 + /> + 188 + BatchRangeFramesRev + iewedShapesStatus + 191
+ 202 + + 211 + setSelectedIds((current) => + 212 + event.target.checked + 213 + ? [...current, batch.id] + 214 + : current.filter((id) => id !== ba + +tch.id)) + 215 + } + 216 + style={{ cursor: (batch.review?.approved + + ?? 0) === 0 ? 'not-allowed' : 'pointer' }} + 217 + /> + 218 + + 220 edi + tBatch(batch)} title="Click to rename batch"> + 221 {batch.date_label} · {batch.batch_label} + 222 + +● Update(frontend/src/pages/LibraryPage.jsx) + ⎿  Added 26 lines + 145 } + 146 } + 147 + 148 + // Only a batch with at least one approved frame has anythin + +g to merge; the + 149 + // rest are offered but not selectable, so the reason is vis + +ible rather than + 150 + // discovered through an error. + 151 + const approvable = batches.filter((batch) => (batch.review?. + +approved ?? 0) > 0) + 152 + const approvableSelected = selectedIds.filter((id) => + 153 + approvable.some((batch) => batch.id === id)) + 154 + + 155 + async function approveSelected() { + 156 + if (approvableSelected.length === 0) return + 157 + setApproving(true) + 158 + const failures = [] + 159 + for (const id of approvableSelected) { + 160 + try { + 161 + await api.approveBatch(id) + 162 + } catch (exc) { + 163 + const batch = batches.find((item) => item.id === id) + 164 + failures.push(`${batch?.batch_label ?? id}: ${exc.mess + +age}`) + 165 + } + 166 + } + 167 + setApproving(false) + 168 + setSelectedIds([]) + 169 + // Report per batch: one failing batch must not hide the o + +nes that worked. + 170 + if (failures.length) onError(`${failures.length} batch(es) + + could not be merged — ${failures.join(' · ')}`) + 171 + onChanged() + 172 + } + 173 + + 174 if (batches.length === 0) return null + 175 + 176 return ( + + Ran 1 shell command (ctrl+o to expand) + +● Update(frontend/src/pages/LibraryPage.jsx) ⎿  Added 2 lines + 45 + 46 export function BatchList({ project, batches, activeJobs, onCha + nged, onError }) { + 47 const [busyId, setBusyId] = useState(null) + 48 + const [selectedIds, setSelectedIds] = useState([]) + 49 + const [approving, setApproving] = useState(false) + 50 + 51 const [appendChoiceBatch, setAppendChoiceBatch] = useState(nu + ll) + 52 const [autoAnnotateConfig, setAutoAnnotateConfig] = u + seState(null) + + Searched for 1 pattern (ctrl+o to expand) + +● Update(frontend/src/pages/LibraryPage.jsx) + ⎿  Added 1 line, removed 1 line + 2 import React, { useCallback, useEffect, useRef, useState } from + 'react' + 3 import { api, formatDuration } from '../api' + 4 import { navigate } from '../App' + 5 -import { AlertIcon, FolderIcon, ScissorsIcon, SearchIcon, ZapIco + -n } from '../components/Icons' + 5 +import { AlertIcon, CheckIcon, FolderIcon, ScissorsIcon, SearchI + +con, ZapIcon } from '../components/Icons' + 6 + 7 + 8 + + Ran 1 shell command (ctrl+o to expand) + +● Cek apakah handleAutoAnnotateAll jadi tidak terpakai setelah tombolnya saya + ganti: + + Searched for 1 pattern, ran 2 shell commands (ctrl+o to expand) + +● handleAutoAnnotateAll dan ZapIcon di file itu jadi yatim setelah tombolnya + diganti. Saya bersihkan karena perubahan saya yang membuatnya tidak terpakai: + +● Update(frontend/src/pages/LibraryPage.jsx) + ⎿  Removed 20 lines + 127 } + 128 } + 129 + 130 - async function handleAutoAnnotateAll() { + 131 - if (!window.confirm(`Start SAM3 Auto-Annotation in paralle + -l for ALL ${batches.length} batch(es)?`)) return + 132 - try { + 133 - await Promise.all( + 134 - batches.map(b => api.startAutolabel(b.id, { + 135 - resume: false, + 136 - append: true, + 137 - engine: 'sam3', + 138 - threshold: 0.35, + 139 - iou_threshold: 0.0, + 140 - min_box_frac: 0.0, + 141 - target_class_names: project.classes.map(c => c.name) + 142 - })) + 143 - ) + 144 - onChanged() + 145 - } catch (exc) { + 146 - onError(exc.message) + 147 - } + 148 - } + 149 - + 130 // Only a batch with at least one approved frame has anythin + g to merge; the + 131 // rest are offered but not selectable, so the reason is vis + ible rather than + 132 // discovered through an error. + + Ran 4 shell commands (ctrl+o to expand) + +● Selesai. Container sudah di-rebuild (index-I65Ek6EH.js) — hard-refresh dulu. + + Yang berubah di tabel Batches: + + - Kolom checkbox di paling kiri, plus checkbox "pilih semua" di header tabel. + - Tombol "Approve & Merge Selected (N)" menggantikan posisi Auto-Annotate All + Batches yang tadi dobel. Sekarang tombol auto-annotate-all hanya ada satu, di + header halaman. + - Checkbox mati otomatis untuk batch yang belum punya frame approved, dengan + tooltip alasannya ("No approved frame in this batch yet — review it first"). + Jadi Anda tahu sebelum klik, bukan setelah dapat error. + - Tooltip pada checkbox yang aktif menyebut berapa frame yang akan ikut + ter-merge, misal "346 approved frame(s) would be merged". + + Kesepuluh batch Anda sekarang bisa dipilih semua, karena semuanya sudah punya + frame approved — termasuk yang masih ada pending (batch016 punya 26 pending, + batch8 punya 23). Yang pending itu ditinggalkan, sesuai permintaan Anda tadi: + approve = simpan, tidak approve = ignore. Nanti kalau Anda review sisanya, + batch yang sama bisa di-approve ulang untuk menambahkan frame baru itu. + + Sekali klik akan memasukkan 1.031 frame ke master dataset + (113+346+164+32+32+69+51+62+82+80). + + Dua hal yang saya putuskan: + + - Approve dijalankan satu per satu, bukan paralel, dan kegagalan dilaporkan + per batch. Kalau satu batch gagal, sembilan lainnya tetap jalan dan Anda + diberi tahu persis mana yang gagal beserta alasannya — bukan satu error yang + menutupi semuanya. + - Saya hapus fungsi handleAutoAnnotateAll dan impor ZapIcon di LibraryPage.jsx + yang jadi tidak terpakai setelah tombolnya diganti. + + Catatan, di luar yang Anda minta jadi saya tidak sentuh: tombol "🔄 Reset + Auto" di tabel itu memakai emoji sebagai ikon, yang dilarang checklist UI di + CLAUDE.md §7. Tinggal bilang kalau mau saya ganti ke ikon SVG. + + Belum saya uji dengan benar-benar mengklik Approve — itu operasi yang menulis + ke master dataset dan tidak bisa dibatalkan, jadi saya serahkan ke Anda. + Backup dari sesi tadi (data/backups/app-20260807-163509.db) masih utuh kalau + hasilnya tidak sesuai harapan. + +✻ Sautéed for 2m 8s + +❯ in the data prep also create table so there is like "master dataset date x + time x" so there will be no conflict if i apllied twice the same batches + cause the master data set is different, but we can actualy choose them 2 + master datasets and confirm to send to the models & training stages + +● Ini perubahan arsitektur, bukan sekadar tabel — jadi saya mau pastikan dulu + maksudnya sebelum menulis kode. Saya cek dulu batasan yang ada sekarang: + + Ran 1 shell command (ctrl+o to expand) + +● Bagian teknis intinya ada di sini: frame_id ... UNIQUE (db.py:89) — satu frame + hanya boleh ada di satu dataset, itulah yang bikin konflik saat batch yang + sama di-merge dua kali. Perlu jadi UNIQUE(dataset_id, frame_id). + + Kabar baik: perbaikan val-split jadi berbasis hash yang saya buat kemarin + membuat ini aman. Frame yang sama selalu jatuh ke split yang sama di dataset + mana pun, jadi menggabungkan 2 master dataset tidak akan membuat gambar yang + sama muncul di train sekaligus val. Kalau split-nya masih posisional seperti + dulu, fitur ini akan langsung merusak perbandingan base-vs-new. + + Empat hal yang saya perlu tahu: + +● User answered Claude's questions: + ⎿  · Apa yang sebenarnya membedakan master dataset A dan B? → Anotasi sudah + diperbaiki, Aturan triage berbeda, Kombinasi batch berbeda, Snapshot waktu + · Kalau 2 dataset dipilih bersama dan ada frame yang sama di keduanya + dengan label berbeda, mana yang dipakai? → Yang paling baru menang + (Recommended) + · Gambar tiap dataset disalin sendiri-sendiri atau berbagi file? → Salin + penuh tiap dataset + · Dataset lama (yang sekarang) diapakan? → Jadikan 'Master Dataset #1' + +● Semua alasan berlaku, jadi dataset-nya harus generik dan dinamai bebas. Saya + cek kapasitas disk dulu karena Anda pilih salin penuh: + + Ran 1 shell command (ctrl+o to expand) + +● Disk 1,5 TB kosong — salin penuh aman (~200 MB per dataset). Tapi dataset.py + sudah 559 baris, lewat batas 400 di CLAUDE.md §6, dan saya akan mengubahnya + banyak. Saya pecah sekalian. + + Mulai dari skema: + +● Update(backend/db.py) + ⎿  Added 14 lines, removed 2 lines + 83 ) + 84 """, + 85 """ + 86 + CREATE TABLE IF NOT EXISTS datasets ( + 87 + id INTEGER PRIMARY KEY AUTOINCREMENT, + 88 + project_id INTEGER NOT NULL REFERENCES projects(id) + +ON DELETE CASCADE, + 89 + name TEXT NOT NULL, + 90 + note TEXT NOT NULL DEFAULT '', + 91 + rule_version TEXT, + 92 + created_at REAL NOT NULL + 93 + ) + 94 + """, + 95 + """ + 96 CREATE TABLE IF NOT EXISTS dataset_items ( + 97 id INTEGER PRIMARY KEY AUTOINCREMENT, + 98 project_id INTEGER NOT NULL REFERENCES projects(id) ON + DELETE CASCADE, + 89 - frame_id INTEGER NOT NULL REFERENCES frames(id) ON D + -ELETE CASCADE UNIQUE, + 99 + dataset_id INTEGER NOT NULL REFERENCES datasets(id) ON + + DELETE CASCADE, + 100 + frame_id INTEGER NOT NULL REFERENCES frames(id) ON D + +ELETE CASCADE, + 101 split TEXT NOT NULL CHECK (split IN ('train', 'va + l')), + 102 image_rel TEXT NOT NULL, + 103 label_rel TEXT NOT NULL, + 93 - added_at REAL NOT NULL + 104 + added_at REAL NOT NULL, + 105 + UNIQUE (dataset_id, frame_id) + 106 ) + 107 """, + 108 """ + +● Migrasi untuk database yang sudah ada — SQLite tidak bisa mengubah constraint, + jadi tabelnya harus dibangun ulang: + +● Update(backend/db.py) + ⎿  Added 55 lines + 207 if "rule_version" not in version_cols: + 208 cur.execute("ALTER TABLE model_versions ADD COLUMN + rule_version TEXT") + 209 + 210 + _migrate_dataset_items(cur) + 211 + 212 + + 213 +def _migrate_dataset_items(cur) -> None: + 214 + """Move a single-dataset project onto named datasets. + 215 + + 216 + The old table pinned `frame_id UNIQUE`, so one frame could + + live in exactly + 217 + one dataset — which is the constraint that made merging th + +e same batch twice + 218 + a conflict. SQLite cannot drop a constraint, so the table + +is rebuilt and the + 219 + existing rows are adopted by a "Master Dataset #1" per pro + +ject. Nothing is + 220 + discarded: a row without a home would be a merged frame th + +e app can no + 221 + longer see. + 222 + """ + 223 + cur.execute("PRAGMA table_info(dataset_items)") + 224 + columns = [column[1] for column in cur.fetchall()] + 225 + if not columns or "dataset_id" in columns: + 226 + return + 227 + + 228 + import time + 229 + + 230 + cur.execute("SELECT DISTINCT project_id FROM dataset_items + +") + 231 + projects_with_items = [row[0] for row in cur.fetchall()] + 232 + home = {} + 233 + for project_id in projects_with_items: + 234 + cur.execute( + 235 + """INSERT INTO datasets (project_id, name, note, c + +reated_at) + 236 + VALUES (?, 'Master Dataset #1', 'Adopted from t + +he original single dataset', ?)""", + 237 + (project_id, time.time()), + 238 + ) + 239 + home[project_id] = cur.lastrowid + 240 + + 241 + cur.execute("ALTER TABLE dataset_items RENAME TO dataset_i + +tems_old") + 242 + cur.execute(""" + 243 + CREATE TABLE dataset_items ( + 244 + id INTEGER PRIMARY KEY AUTOINCREMENT, + 245 + project_id INTEGER NOT NULL REFERENCES projects(id + +) ON DELETE CASCADE, + 246 + dataset_id INTEGER NOT NULL REFERENCES datasets(id + +) ON DELETE CASCADE, + 247 + frame_id INTEGER NOT NULL REFERENCES frames(id) + +ON DELETE CASCADE, + 248 + split TEXT NOT NULL CHECK (split IN ('train', + + 'val')), + 249 + image_rel TEXT NOT NULL, + 250 + label_rel TEXT NOT NULL, + 251 + added_at REAL NOT NULL, + 252 + UNIQUE (dataset_id, frame_id) + 253 + ) + 254 + """) + 255 + for project_id, dataset_id in home.items(): + 256 + cur.execute( + 257 + """INSERT INTO dataset_items (project_id, dataset_ + +id, frame_id, split, + 258 + image_rel, label_rel + +, added_at) + 259 + SELECT project_id, ?, frame_id, split, image_re + +l, label_rel, added_at + 260 + FROM dataset_items_old WHERE project_id = ?""", + 261 + (dataset_id, project_id), + 262 + ) + 263 + cur.execute("DROP TABLE dataset_items_old") + 264 + + 265 + + 266 def healthy() -> bool: + 267 try: + 268 with cursor() as cur: + +● Sekarang modul registry dataset-nya: + +● Write(backend/datasets.py) + ⎿  Wrote 175 lines to backend/datasets.py + 1 """Named master datasets — several per project, each a full stan + 2 + 3 One project used to have exactly one master dataset, so merging + 4 was a conflict. Now a merge targets a *named* dataset, and the s + 5 into as many as you like: "batch7+8 strict rules" and "batch7+8 + after I fixed + ifferent labels. + 7 + 9 + l} + … +197 lines (ctrl+o to expand) + +● Sekarang bagian terberat — dataset.py harus tahu dataset mana yang jadi + tujuan. Saya baca utuh dulu supaya tidak salah potong: + + Read 1 file (ctrl+o to expand) +● Update(backend/dataset.py) + ⎿  Added 23 lines, removed 10 lines + 24 pass + 25 + 26 + 27 -def dataset_dir(project_slug: str) -> str: + 28 - return os.path.join(config.project_dir(project_slug), "data + -set") + 27 +def dataset_dir(project_slug: str, dataset_id: int) -> str: + 28 + return datasets.dataset_root(project_slug, dataset_id) + 29 + 30 + 31 -def approve(batch_id: int) -> dict: + 32 - """Sign a batch off and queue its merge (REQ-045, REQ-050). + -""" + 31 +def approve(batch_id: int, dataset_id: Optional[int] = None, + 32 + dataset_name: str = "") -> dict: + 33 + """Sign a batch off and queue its merge into one named data + +set (REQ-045, REQ-050). + 34 + + 35 + Without `dataset_id` a new dataset is created, so merging t + +he same batch + 36 + again never collides with the earlier result — it produces + +a second dataset + 37 + holding that batch as it looks now. + 38 + """ + 39 batch = batches.get(batch_id) + 40 if batch is None: + 41 raise DatasetError("No such batch") + 36 - if batch["status"] == "merged" and _unmerged_approved(batch + -_id) == 0: + 37 - # Re-merging is allowed, because a partly-reviewed batc + -h can be merged + 38 - # for the part that was done and finished later. Only r + -efuse when there + 39 - # is genuinely nothing new to add. + 40 - raise DatasetError("Every approved frame in this batch + -is already in the master dataset") + 42 if batch["review"]["approved"] == 0: + 43 raise DatasetError("No frame in this batch is approved + — there is nothing to merge") + 44 # Frames that are not approved — rejected or never looked a + t — are simply + ... + 53 if cur.fetchone() is not None: + 54 raise DatasetError("A merge for this batch is alrea + dy queued") + 55 + 56 + if dataset_id is None: + 57 + target = datasets.create(batch["project_id"], name=data + +set_name, + 58 + rule_version=triage.Resolver(b + +atch["project_id"]).version()) + 59 + dataset_id = target["id"] + 60 + else: + 61 + target = datasets.get(dataset_id) + 62 + if target is None: + 63 + raise DatasetError("No such dataset") + 64 + if _unmerged_approved(batch_id, dataset_id) == 0: + 65 + raise DatasetError( + 66 + f"Every approved frame of this batch is already + + in “{target['name']}”") + 67 + + 68 batches.set_status(batch_id, "approved") + 69 job = jobs.create( + 70 "merge", + 58 - params={"batch_id": batch_id}, + 71 + params={"batch_id": batch_id, "dataset_id": dataset_id} + +, + 72 project_id=batch["project_id"], + 73 batch_id=batch_id, + 74 message=f"{batch['date_label']}/{batch['batch_label']}" + , + +● Update(backend/dataset.py) + ⎿  Added 5 lines, removed 4 lines + 76 return job.to_dict() + 77 + 78 + 79 -def _unmerged_approved(batch_id: int) -> int: + 80 - """Approved frames of this batch that are not in the datase + -t yet.""" + 79 +def _unmerged_approved(batch_id: int, dataset_id: int) -> int: + 80 + """Approved frames of this batch not yet in *this* dataset. + +""" + 81 with db.cursor() as cur: + 82 cur.execute( + 83 """SELECT COUNT(*) FROM frames f + 84 - LEFT JOIN dataset_items d ON d.frame_id = f.id + 84 + LEFT JOIN dataset_items d + 85 + ON d.frame_id = f.id AND d.dataset_id = ? + 86 WHERE f.batch_id = ? AND f.review_status = 'appr + oved' AND d.id IS NULL""", + 86 - (batch_id,), + 87 + (dataset_id, batch_id), + 88 ) + 89 return cur.fetchone()[0] + 90 + +● Sekarang job merge-nya menulis ke dataset tujuan: + + Read 1 file (ctrl+o to expand) + +● Update(backend/dataset.py) + ⎿  Added 6 lines, removed 2 lines + 486 if batch is None: + 487 raise DatasetError("The batch disappeared before the m + erge started") + 488 project = projects.get(batch["project_id"]) + 489 - root = dataset_dir(project["slug"]) + 489 + dataset_id = job.params["dataset_id"] + 490 + target = datasets.get(dataset_id) + 491 + if target is None: + 492 + raise DatasetError("The target dataset disappeared bef + +ore the merge started") + 493 + root = dataset_dir(project["slug"], dataset_id) + 494 for split in ("train", "val"): + 495 os.makedirs(os.path.join(root, "images", split), exist + _ok=True) + 496 os.makedirs(os.path.join(root, "labels", split), exist + _ok=True) + ... + 498 frames = [f for f in batches.frames(batch["id"]) if f["rev + iew_status"] == "approved"] + 499 source_dir = batches.frames_dir(project["slug"], batch["id + "]) + 500 job.progress(0, len(frames)) + 497 - job.log(f"Merging {len(frames)} approved frame(s) into the + - master dataset") + 501 + job.log(f"Merging {len(frames)} approved frame(s) into “{t + +arget['name']}”") + 502 + 503 # Triage gates the merge (REQ-104): a frame carrying an ig + nored shape never + 504 # enters the master dataset at all, so junk is stopped at + the door rather + +● Update(backend/dataset.py) + ⎿  Added 2 lines, removed 1 line + 528 annotations = resolved + 529 + 530 with db.cursor() as cur: + 531 - cur.execute("SELECT 1 FROM dataset_items WHERE fra + -me_id = ?", (frame["id"],)) + 531 + cur.execute("SELECT 1 FROM dataset_items WHERE dat + +aset_id = ? AND frame_id = ?", + 532 + (dataset_id, frame["id"])) + 533 if cur.fetchone() is not None: + 534 skipped += 1 + 535 job.progress(index + 1, len(frames)) + + Read 1 file (ctrl+o to expand) + +● Update(backend/dataset.py) + ⎿  Added 5 lines, removed 4 lines + 550 handle.write("\n".join(lines) + ("\n" if lines + else "")) + 551 + 552 cur.execute( + 553 - """INSERT INTO dataset_items (project_id, fram + -e_id, split, image_rel, + 554 - label_rel, added + -_at) + 555 - VALUES (?, ?, ?, ?, ?, ?)""", + 556 - (project["id"], frame["id"], split, image_rel, + - label_rel, time.time()), + 553 + """INSERT INTO dataset_items (project_id, data + +set_id, frame_id, split, + 554 + image_rel, label + +_rel, added_at) + 555 + VALUES (?, ?, ?, ?, ?, ?, ?)""", + 556 + (project["id"], dataset_id, frame["id"], split + +, image_rel, label_rel, + 557 + time.time()), + 558 ) + 559 added[split] += 1 + 560 job.progress(index + 1, len(frames)) + +● Update(backend/dataset.py) + ⎿  Added 3 lines, removed 4 lines + 571 cur.execute("UPDATE batches SET status = 'merged', mer + ged_at = ? WHERE id = ?", + 572 (time.time(), batch["id"])) + 573 + 574 - path = write_data_yaml(projects.get(project["id"])) + 575 - totals = summary(project["id"])["splits"] + 574 + totals = datasets.get(dataset_id)["splits"] + 575 job.log(f"Added {added['train']} train / {added['val']} va + l" + 577 - + (f", skipped {skipped} already merged" if skippe + -d else "") + 576 + + (f", skipped {skipped} already in this dataset" + +if skipped else "") + 577 + (f", held back {triaged_out} by triage" if triag + ed_out else "")) + 579 - job.log(f"Master dataset now {totals['train']} train / {to + -tals['val']} val — {path}") + 578 + job.log(f"“{target['name']}” now holds {totals['train']} t + +rain / {totals['val']} val") + ⎿  You've hit your session limit · resets 5:10pm (Asia/Jakarta) + /upgrade to increase your usage limit. + +✻ Cogitated for 3m 4s \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6a1a7d7..7776d98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,14 @@ RUN uv pip install -r requirements.txt COPY sam3/ ./sam3/ RUN uv pip install -e ./sam3 +# The counting pipeline (tracker, stabiliser, line counter) lives in +# algoritma-batch and imports itself as `src.*`. Copied to /app/src so those +# imports resolve unchanged rather than being duplicated into backend/. +COPY algoritma-batch/src/ ./src/ +# tracking.py resolves its ByteTrack tuning as ../cfg/tracker.yaml relative to +# src/, so the two directories have to keep that shape inside the image. +COPY algoritma-batch/cfg/ ./cfg/ + COPY backend/ ./backend/ EXPOSE 8000 diff --git a/algoritma-batch/batch_video_cropper.py b/algoritma-batch/batch_video_cropper.py index 4b97065..9cfa88a 100644 --- a/algoritma-batch/batch_video_cropper.py +++ b/algoritma-batch/batch_video_cropper.py @@ -1,18 +1,9 @@ """ -Batch Video Cropper — Rekam Video RTSP per Sesi Batch Truk +Batch Video Cropper — Production 24/7 +Rekam video RTSP per sesi batch truk. Ringan, tanpa GUI, auto-reconnect. -Program ini membaca livestream CCTV (RTSP), menjalankan algoritma penentuan batch -menggunakan YOLO + State Machine, dan menyimpan potongan video per-batch ke folder -yang terorganisir berdasarkan tanggal. - -Struktur Output: - ~/reTraining/data/archive/ - ├── 2026-08-05/ - │ ├── batch_1_09-15-30.mp4 - │ ├── batch_2_10-22-45.mp4 - │ └── batch_3_14-08-12.mp4 - └── 2026-08-06/ - └── batch_1_07-30-00.mp4 +Output: + ~/reTraining/data/archive/{YYYY-MM-DD}/batch_{N}_{HH-MM-SS}.mp4 Menjalankan: cd ~/reTraining/algoritma-batch @@ -20,466 +11,603 @@ Menjalankan: """ import os +# KRITIS: Konfigurasi RTSP transport — HARUS sebelum import cv2 +# Tanpa ini, OpenCV pakai UDP (default) yang sering drop koneksi +os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = ( + "rtsp_transport;tcp|buffer_size;20480000|max_delay;500000|reorder_queue_size;500" +) +import json +import shutil +import signal +import sys +import urllib.parse +import urllib.request import cv2 import numpy as np import time -import json import threading +import platform from datetime import datetime, timedelta from shapely.geometry import Point, Polygon from ultralytics import YOLO -# Import modules from src.tracking import ByteTrackTracker from src.stabilizer import BboxStabilizer from src.truck_roi import TruckROI from src.counting import LineCrossCounter -from src.batch import BatchLifecycleManager, BatchRecord +from src.batch import BatchLifecycleManager, BatchState # ===================================================================== -# 1. KONFIGURASI +# KONFIGURASI # ===================================================================== - -import platform IS_WINDOWS = platform.system() == "Windows" - -# --- Path Konfigurasi --- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -ZONES_JSON = os.path.join(BASE_DIR, "zones.json") if IS_WINDOWS: - MODEL_PATH = os.path.join(BASE_DIR, "v1-best.pt") + MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt") ARCHIVE_BASE = os.path.join(BASE_DIR, "archive_output") + RTSP_URL = "video truk.mp4" # Testing lokal video else: - MODEL_PATH = os.path.join(BASE_DIR, "v1-best.pt") + MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt") ARCHIVE_BASE = os.path.expanduser("~/reTraining/data/archive") + RTSP_URL = "rtsp://192.168.192.96:8554/cam" # Production RTSP stream (.105) -# --- Sumber Video RTSP --- -RTSP_URL = "rtsp://frigate:zenai@192.168.192.209:8554/camera_stream_640" +DAILY_CUTOFF_TIME = "00:00" -# --- Batas Pergantian Hari (Cutoff) --- -DAILY_CUTOFF_TIME = "20:00" +# State Machine +SACK_IDLE_TIMEOUT = 5.0 +MIN_BATCH_DURATION = 2.0 +TRUCK_GONE_TOLERANCE = 5.0 -# --- Parameter State Machine --- -SACK_IDLE_TIMEOUT = 5.0 # Jeda aktivitas sebelum masuk WAITING_FOR_ACTIVITY -MIN_BATCH_DURATION = 2.0 # Durasi minimal batch sebelum boleh masuk WAITING -TOLERANCE_LOW_COUNT = 0.0 # Instan (0s) — batch langsung berakhir saat area truk kosong -TOLERANCE_MED_COUNT = 0.0 -TOLERANCE_HIGH_COUNT = 0.0 +# Pengambilan video dari MediaMTX (REQ-170) +# Jetson merekam 24/7 apa adanya; skrip ini hanya menentukan potongannya. +# Sebelumnya frame di-encode ulang ke mpeg4 di sini: 4,7x lebih besar dari +# sumbernya, kualitas turun, dan fps-nya salah. Sekarang potongan diunduh +# sebagai salinan, jadi codec, fps dan waktunya persis seperti kamera. +PLAYBACK_URL = os.getenv("PLAYBACK_URL", "http://192.168.192.96:9996/get") +PLAYBACK_PATH = os.getenv("PLAYBACK_PATH", "cam") +FETCH_PAD_BEFORE = 3.0 # detik diambil sebelum truk terdeteksi +FETCH_PAD_AFTER = 3.0 # dan sesudahnya, supaya tidak terpotong +FETCH_RETRIES = 3 +FETCH_RETRY_DELAY = 20.0 -# --- Video Recording --- -VIDEO_FPS = 10.0 # FPS output video (10 fps sudah cukup untuk rekaman arsip) -VIDEO_CODEC = "mp4v" # Codec untuk .mp4 +# Video Recording +# Diambil dari stream yang diterima, bukan ditebak. Angka 10.0 yang dulu +# di-hardcode membuat SETIAP file di arsip punya timebase salah: kamera +# mengirim 25 fps, file mengaku 10 fps, jadi rekaman 15,4 menit tersimpan +# sebagai 38,3 menit dan diputar 2,49x lebih lambat dari kenyataan. +# Dipakai hanya kalau fps stream tidak terbaca. +FALLBACK_FPS = 25.0 +MIN_FPS, MAX_FPS = 1.0, 60.0 +VIDEO_CODEC = "mp4v" + +# Reconnect +RECONNECT_DELAY = 5 # Detik menunggu sebelum reconnect RTSP +MAX_EMPTY_FRAMES = 300 # Maks frame kosong sebelum reconnect (~30 detik) + +# Matikan tampilan visualisasi agar program sangat ringan 24/7 +SHOW_DISPLAY = False # ===================================================================== -# 2. THREADED RTSP READER (Menghindari Lag Buffer) +# THREADED RTSP READER (selalu ambil frame terbaru, anti-lag) # ===================================================================== -class RTSPStreamReader: - """Threaded RTSP reader yang selalu mengambil frame terbaru.""" - - def __init__(self, source_url): - self.source_url = source_url - self.cap = cv2.VideoCapture(source_url) +class RTSPReader: + def __init__(self, url): + self.url = url + self.fps = FALLBACK_FPS + self.cap = None self.frame = None self.ret = False self.running = True self.lock = threading.Lock() - self.new_frame_event = threading.Event() - self.thread = threading.Thread(target=self._update, daemon=True) + self.event = threading.Event() + self._connect() + self.thread = threading.Thread(target=self._loop, daemon=True) self.thread.start() - def _update(self): + def _connect(self): + if self.cap and self.cap.isOpened(): + self.cap.release() + self.cap = cv2.VideoCapture(self.url) + if self.cap.isOpened(): + self.fps = self._read_fps() + log(f"RTSP terhubung ({self.fps:.1f} fps)") + else: + log("RTSP gagal terhubung") + + def _read_fps(self): + """Fps yang diumumkan stream, dibatasi ke rentang masuk akal.""" + try: + reported = float(self.cap.get(cv2.CAP_PROP_FPS) or 0.0) + except Exception: + reported = 0.0 + if MIN_FPS <= reported <= MAX_FPS: + return reported + log(f"Fps stream tidak masuk akal ({reported}), pakai {FALLBACK_FPS}") + return FALLBACK_FPS + + def _loop(self): + empty = 0 while self.running: - if not self.cap.isOpened(): - print("[RTSP] Stream terputus, mencoba reconnect dalam 5 detik...") - time.sleep(5) - self.cap = cv2.VideoCapture(self.source_url) + if not self.cap or not self.cap.isOpened(): + log(f"RTSP terputus, reconnect dalam {RECONNECT_DELAY}s...") + time.sleep(RECONNECT_DELAY) + self._connect() + empty = 0 continue ret, frame = self.cap.read() if not ret: + empty += 1 + if empty > MAX_EMPTY_FRAMES: + log(f"RTSP {empty} frame kosong, reconnect...") + self._connect() + empty = 0 time.sleep(0.01) continue + empty = 0 with self.lock: - self.ret = ret - self.frame = frame - self.new_frame_event.set() + self.ret, self.frame = ret, frame + self.event.set() time.sleep(0.001) def read(self): - if self.new_frame_event.wait(timeout=2.0): - self.new_frame_event.clear() + if self.event.wait(timeout=2.0): + self.event.clear() with self.lock: - if self.frame is None: - return False, None - return self.ret, self.frame.copy() - else: - with self.lock: - if self.frame is None: - return False, None - return self.ret, self.frame.copy() - - def isOpened(self): - return self.cap.isOpened() + return self.ret, self.frame.copy() if self.frame is not None else (False, None) + return False, None def release(self): self.running = False - if self.cap.isOpened(): + if self.cap: self.cap.release() # ===================================================================== -# 3. FUNGSI UTILITAS TANGGAL & FOLDER +# UTILITAS # ===================================================================== -def get_counting_date(dt=None): - """Menentukan tanggal kerja berdasarkan cutoff harian.""" - if dt is None: - dt = datetime.now() +def log(msg): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] {msg}", flush=True) + + +def get_counting_date(): + dt = datetime.now() try: cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%H:%M").time() except Exception: cutoff = datetime.strptime("20:00", "%H:%M").time() - + if cutoff.hour == 0 and cutoff.minute == 0: return dt.date().isoformat() - + if dt.time() < cutoff: return dt.date().isoformat() return (dt.date() + timedelta(days=1)).isoformat() -def ensure_date_folder(counting_date): - """Membuat folder tanggal di archive jika belum ada. Mengembalikan path folder.""" +def get_next_batch_number_from_files(counting_date): folder = os.path.join(ARCHIVE_BASE, counting_date) - os.makedirs(folder, exist_ok=True) - return folder + if not os.path.exists(folder): + return 1 + max_num = 0 + try: + import re + pattern = re.compile(r'^batch_?(\d+)\.mp4$', re.IGNORECASE) + for filename in os.listdir(folder): + match = pattern.match(filename) + if match: + num = int(match.group(1)) + max_num = max(max_num, num) + except Exception as e: + log(f"Error scanning folder for batch files: {e}") + return max_num + 1 # ===================================================================== -# 4. BATCH VIDEO RECORDER (Mengelola VideoWriter per Batch) +# VIDEO RECORDER # ===================================================================== -class BatchVideoRecorder: - """Mengelola pembukaan dan penutupan file video per sesi batch.""" +class SessionFetcher: + """Tandai kapan satu sesi truk mulai dan selesai, lalu unduh potongannya. - def __init__(self, archive_base, video_fps=10.0, codec="mp4v"): - self.archive_base = archive_base - self.video_fps = video_fps - self.codec = codec - self.writer = None - self.current_path = None - self.frame_count = 0 + Mengunduh dilakukan di thread terpisah supaya loop deteksi tidak berhenti + menunggu jaringan — satu sesi 40 menit bisa ratusan MB. Kalau gagal, dicoba + lagi; buffer di Jetson menyimpan 24 jam, jadi ada banyak waktu untuk pulih. + """ - def start_recording(self, batch_number, counting_date, frame_width=1280, frame_height=720): - """Membuka file video baru untuk batch ini.""" - self.stop_recording() # Pastikan writer sebelumnya ditutup + def __init__(self): + self.batch_num = None + self.counting_date = None + self.started_at = None + self.path = None - folder = ensure_date_folder(counting_date) - timestamp_str = datetime.now().strftime("%H-%M-%S") - filename = f"batch_{batch_number}_{timestamp_str}.mp4" - self.current_path = os.path.join(folder, filename) + def start(self, batch_num, counting_date, w=1280, h=720, fps=None): + self.batch_num = batch_num + self.counting_date = counting_date + self.started_at = datetime.now() + folder = os.path.join(ARCHIVE_BASE, counting_date) + os.makedirs(folder, exist_ok=True) + self.path = os.path.join(folder, f"batch{batch_num:03d}.mp4") + log(f"REC MARK START -> {self.path} @ {self.started_at:%H:%M:%S}") - fourcc = cv2.VideoWriter_fourcc(*self.codec) - self.writer = cv2.VideoWriter( - self.current_path, fourcc, self.video_fps, (frame_width, frame_height) - ) - self.frame_count = 0 + def write(self, frame): + """Tidak ada yang ditulis per frame lagi — Jetson yang merekam.""" - if self.writer.isOpened(): - print(f"[RECORD] Mulai merekam video batch #{batch_number} -> {self.current_path}") - else: - print(f"[RECORD ERROR] Gagal membuka VideoWriter untuk: {self.current_path}") - self.writer = None + def stop(self, discard=False): + if self.started_at is None: + return + started, path = self.started_at, self.path + ended = datetime.now() + self.started_at = self.path = None + if discard: + log(f"REC DISCARD -> {path} tidak diunduh (batch tidak valid)") + return + threading.Thread(target=self._fetch, args=(path, started, ended), + daemon=True).start() - def write_frame(self, frame): - """Menulis satu frame ke video aktif.""" - if self.writer is not None and self.writer.isOpened(): - self.writer.write(frame) - self.frame_count += 1 + def _fetch(self, path, started, ended): + begin = started - timedelta(seconds=FETCH_PAD_BEFORE) + duration = (ended - started).total_seconds() + FETCH_PAD_BEFORE + FETCH_PAD_AFTER + # '+' pada offset zona waktu wajib di-encode; kalau tidak, ia terbaca + # sebagai spasi dan MediaMTX menolak dengan "invalid start". + start_param = urllib.parse.quote(begin.astimezone().isoformat(timespec="seconds"), + safe="") + url = (f"{PLAYBACK_URL}?path={PLAYBACK_PATH}&start={start_param}" + f"&duration={duration:.0f}&format=mp4") - def stop_recording(self): - """Menutup file video yang sedang aktif.""" - if self.writer is not None: - self.writer.release() - self.writer = None - if self.current_path and self.frame_count > 0: - print(f"[RECORD] Video selesai disimpan: {self.current_path} ({self.frame_count} frames)") - elif self.current_path and self.frame_count == 0: - # Hapus file kosong + for attempt in range(1, FETCH_RETRIES + 1): + try: + tmp = f"{path}.part" + with urllib.request.urlopen(url, timeout=600) as response: + if response.status != 200: + raise IOError(f"HTTP {response.status}") + with open(tmp, "wb") as handle: + shutil.copyfileobj(response, handle) + size = os.path.getsize(tmp) + if size < 1024: + raise IOError(f"hasil terlalu kecil ({size} byte)") + os.replace(tmp, path) + _write_sidecar(path, begin, duration) + log(f"REC FETCHED -> {path} ({size/1e6:.0f} MB, {duration:.0f} detik)") + return + except Exception as exc: + log(f"REC FETCH gagal ({attempt}/{FETCH_RETRIES}) {path}: {exc}") try: - os.remove(self.current_path) - print(f"[RECORD] File video kosong dihapus: {self.current_path}") - except Exception: + os.remove(f"{path}.part") + except OSError: pass - self.current_path = None - self.frame_count = 0 + if attempt < FETCH_RETRIES: + time.sleep(FETCH_RETRY_DELAY) + log(f"REC FETCH MENYERAH -> {path}. Rekaman masih ada di buffer Jetson " + f"selama 24 jam sejak {begin:%Y-%m-%d %H:%M:%S}") @property def is_recording(self): - return self.writer is not None and self.writer.isOpened() + return self.started_at is not None + + +def _write_sidecar(video_path, begin, duration): + """Waktu sebenarnya, di sebelah videonya. + + Aplikasi tidak perlu lagi membaca jam dari overlay dengan OCR untuk file + baru: waktunya datang dari server rekaman, tepat sampai detik. + """ + payload = { + "started_at": begin.strftime("%Y-%m-%d %H:%M:%S"), + "duration_seconds": round(duration, 1), + "source": "mediamtx-playback", + "written_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + sidecar = os.path.splitext(video_path)[0] + ".json" + try: + with open(sidecar, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + except OSError as exc: + log(f"Gagal menulis sidecar {sidecar}: {exc}") # ===================================================================== -# 5. FUNGSI UTAMA — LOOP UTAMA DETEKSI & PEREKAMAN +# MAIN LOOP # ===================================================================== -def run_batch_video_cropper(): - print("=" * 60) - print(" BATCH VIDEO CROPPER — RTSP → Per-Batch MP4 Recorder") - print("=" * 60) - print(f" Model : {MODEL_PATH}") - print(f" RTSP : {RTSP_URL}") - print(f" Archive : {ARCHIVE_BASE}") - print(f" Cutoff : {DAILY_CUTOFF_TIME}") - print(f" Tolerance : Instan (0s)") - print("=" * 60) +shutdown_flag = False + +def handle_signal(sig, _): + global shutdown_flag + log(f"Signal {sig} diterima, menutup program...") + shutdown_flag = True + +signal.signal(signal.SIGINT, handle_signal) +signal.signal(signal.SIGTERM, handle_signal) + + +def run(): + global shutdown_flag + + log("=" * 50) + log("BATCH VIDEO CROPPER — Production 24/7") + log(f"Model : {MODEL_PATH}") + log(f"RTSP : {RTSP_URL}") + log(f"Archive : {ARCHIVE_BASE}") + log(f"Toleransi batch: {TRUCK_GONE_TOLERANCE}s (truk+karung)") + log("=" * 50) - # Pastikan folder archive ada os.makedirs(ARCHIVE_BASE, exist_ok=True) - # --- Load Model YOLO --- - print("[INFO] Memuat model YOLO...") + # Load model + log("Memuat model YOLO...") model = YOLO(MODEL_PATH) - # Warm-up model - print("[INFO] Warm-up model...") - dummy = np.zeros((720, 1280, 3), dtype=np.uint8) - device = "cuda" if os.path.exists("/usr/local/cuda") else "cpu" + # Detect device + device = "cpu" try: import torch if torch.cuda.is_available(): device = "cuda" except ImportError: pass + + # Warm-up + dummy = np.zeros((720, 1280, 3), dtype=np.uint8) _ = model(dummy, imgsz=640, device=device, verbose=False) - print(f"[INFO] Model siap. Device: {device}") + log(f"Model siap. Device: {device}") - # --- Setup Components --- - tracker = ByteTrackTracker(model, conf=0.55) - stabilizer = BboxStabilizer( - ema_alpha=0.35, - max_hold_frames=10, - max_height_ratio=1.5, - min_height_ratio=0.70, - ) + # Components + tracker = ByteTrackTracker(model, conf=0.25) + stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10, + max_height_ratio=1.5, min_height_ratio=0.70) - # Skala koordinat (kalibrasi 1920x1080 -> 1280x720) - scale_x = 1280.0 / 1920.0 - scale_y = 720.0 / 1080.0 + # Koordinat zona (1920x1080 → 1280x720) + sx, sy = 1280.0 / 1920.0, 720.0 / 1080.0 - # Detection polygon - detection_poly_pts = [ - [int(574 * scale_x), int(50 * scale_y)], - [int(586 * scale_x), int(1077 * scale_y)], - [int(1418 * scale_x), int(1076 * scale_y)], - [int(1397 * scale_x), int(50 * scale_y)], - ] - detection_polygon = Polygon(detection_poly_pts) + detection_polygon = Polygon([ + [int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)], + [int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)], + ]) + truck_polygon = Polygon([ + [int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)], + [int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)], + ]) - # Truck polygon (untuk monitoring kehadiran karung) - truck_poly_pts = [ - [int(600 * scale_x), int(385 * scale_y)], - [int(609 * scale_x), int(1076 * scale_y)], - [int(1404 * scale_x), int(1078 * scale_y)], - [int(1381 * scale_x), int(343 * scale_y)], - ] - truck_polygon = Polygon(truck_poly_pts) - - # Line crossing - static_line_y = int(330 * scale_y) - static_line_x_start = int(577 * scale_x) - static_line_x_end = int(1401 * scale_x) + line_y = int(330 * sy) + line_x1 = int(577 * sx) + line_x2 = int(1401 * sx) static_roi = TruckROI( - x1=int(600 * scale_x), - y1=int(343 * scale_y), - x2=int(1404 * scale_x), - y2=int(1078 * scale_y), - line_y=static_line_y, - confidence=1.0, - ) - - counter = LineCrossCounter( - line_y=static_line_y, - line_x_start=static_line_x_start, - line_x_end=static_line_x_end, - margin=20, - dedup_radius=60.0, + x1=int(600*sx), y1=int(343*sy), x2=int(1404*sx), y2=int(1078*sy), + line_y=line_y, confidence=1.0, ) + counter = LineCrossCounter(line_y=line_y, line_x_start=line_x1, + line_x_end=line_x2, margin=20, dedup_radius=60.0) batch_mgr = BatchLifecycleManager( - stabilize_seconds=0.0, - stabilize_threshold_px=9999.0, + stabilize_seconds=0.0, stabilize_threshold_px=9999.0, sack_idle_timeout=SACK_IDLE_TIMEOUT, min_batch_duration=MIN_BATCH_DURATION, - truck_gone_tolerance=TOLERANCE_LOW_COUNT, + truck_gone_tolerance=3.0, ) - recorder = BatchVideoRecorder( - archive_base=ARCHIVE_BASE, - video_fps=VIDEO_FPS, - codec=VIDEO_CODEC, - ) + recorder = SessionFetcher() - # --- Buka RTSP Stream --- - print(f"[INFO] Membuka RTSP stream: {RTSP_URL}") - cap = RTSPStreamReader(RTSP_URL) - if not cap.isOpened(): - print(f"[ERROR] Gagal membuka RTSP stream: {RTSP_URL}") - return + # RTSP Stream + log(f"Membuka RTSP: {RTSP_URL}") + cap = RTSPReader(RTSP_URL) - # Tracking state batch_counter = 0 frame_idx = 0 - last_fps_time = time.time() - fps_counter = 0 + last_status_time = time.time() + truck_seen_in_current_batch = False + last_frame_time = time.time() + NO_FRAME_BATCH_TIMEOUT = 30.0 # Akhiri batch jika tidak ada frame 30 detik - print("\n[INFO] Memulai loop utama... Tekan Ctrl+C untuk berhenti.\n") + log("Loop utama dimulai...") try: - while True: + while not shutdown_flag: ret, frame = cap.read() if not ret or frame is None: + # Saat tidak ada frame DAN batch aktif, cek timeout + if batch_mgr.is_active: + no_frame_duration = time.time() - last_frame_time + if no_frame_duration >= NO_FRAME_BATCH_TIMEOUT: + log(f"RTSP drop {no_frame_duration:.0f}s. Force-end BATCH #{batch_counter}. Karung: {counter.loading_count}") + # Force-end: langsung reset state machine (bypass update_truck) + batch_mgr._state = BatchState.IDLE + batch_mgr._current_batch_id = None + batch_mgr._truck_is_stable = False + recorder.stop(discard=True) + counter.reset() + stabilizer.reset() + last_frame_time = time.time() # Reset timer agar tidak spam time.sleep(0.01) continue - # Resize ke 1280x720 (sesuai kalibrasi koordinat zona) + last_frame_time = time.time() + frame = cv2.resize(frame, (1280, 720)) timestamp = time.time() frame_idx += 1 - # FPS counter - fps_counter += 1 - if fps_counter % 100 == 0: - elapsed = time.time() - last_fps_time - fps = 100.0 / elapsed if elapsed > 0 else 0 - last_fps_time = time.time() - state_str = batch_mgr.state - rec_str = "REC" if recorder.is_recording else "---" - print(f"[FPS] {fps:.1f} fps | State: {state_str} | {rec_str} | Frames: {frame_idx}") - - # Simpan state sebelum update prev_active = batch_mgr.is_active prev_state = batch_mgr.state - # --- 1. YOLO Tracking --- - raw_tracked_all = tracker.update(frame, []) - raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name == "sack"] - - # --- 2. Stabilizer --- - stable = stabilizer.update(raw_tracked_sacks) - - # --- 3. Filter Detection Polygon --- - stable = [ - d for d in stable - if detection_polygon.contains( - Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0) - ) + # --- Deteksi --- + raw_all = tracker.update(frame, []) + + # Hanya proses objek yang pusatnya berada di dalam area deteksi (poligon ungu) + raw_all_filtered = [ + d for d in raw_all + if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0)) ] + + sacks = [d for d in raw_all_filtered if d.class_name == "sack"] + trucks = [d for d in raw_all_filtered if d.class_name == "truck"] - # --- 4. Hitung karung di 70% area bawah truk --- - min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3] - truck_height = max_ty - min_ty - truck_cutoff_y = min_ty + 0.30 * truck_height + if batch_mgr.is_active and len(trucks) > 0: + truck_seen_in_current_batch = True - sacks_in_truck_area = 0 - for d in stable: - cx = (d.bbox[0] + d.bbox[2]) / 2.0 - cy = (d.bbox[1] + d.bbox[3]) / 2.0 - if truck_polygon.contains(Point(cx, cy)) and cy >= truck_cutoff_y: - sacks_in_truck_area += 1 + # Stabilizer + stable = stabilizer.update(sacks) - # --- 5. Line Crossing --- - tracked_in_roi = [ - d for d in stable - if static_roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0) - ] - events = counter.update(tracked_in_roi) + # Karung di 70% area truk + ty_min, ty_max = truck_polygon.bounds[1], truck_polygon.bounds[3] + cutoff_y = ty_min + 0.30 * (ty_max - ty_min) + sacks_in_area = sum( + 1 for d in stable + if truck_polygon.contains(Point((d.bbox[0]+d.bbox[2])/2, (d.bbox[1]+d.bbox[3])/2)) + and (d.bbox[1]+d.bbox[3])/2 >= cutoff_y + ) + + # Line crossing + in_roi = [d for d in stable if static_roi.contains_x((d.bbox[0]+d.bbox[2])/2)] + events = counter.update(in_roi) has_crossing = len(events) > 0 - # ============================================================= - # LOGIKA ALGORITMA PENENTUAN BATCH (STATE MACHINE) - # ============================================================= - - # A. Mulai Batch + # --- State Machine --- if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"): batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp) - # B. Monitoring Batch Aktif if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"): - current_count = counter.loading_count - if current_count < 20: - batch_mgr._truck_gone_tolerance = TOLERANCE_LOW_COUNT - elif current_count >= 40: - batch_mgr._truck_gone_tolerance = TOLERANCE_HIGH_COUNT - else: - batch_mgr._truck_gone_tolerance = TOLERANCE_MED_COUNT - batch_mgr.update_sacks( has_crossing_event=has_crossing, - sacks_in_area_count=sacks_in_truck_area, + sacks_in_area_count=sacks_in_area, timestamp=timestamp, loading_count=counter.loading_count, unloading_count=counter.unloading_count, ) - if batch_mgr.state == "WAITING_FOR_ACTIVITY": - batch_mgr.update_truck(sacks_in_truck_area > 0, None, timestamp) + # Sinkronkan _truck_last_seen agar countdown toleransi + # mulai dari saat WAITING dimulai, bukan dari TRUCK_STABILIZING + if batch_mgr._truck_last_seen < batch_mgr._waiting_since: + batch_mgr._truck_last_seen = batch_mgr._waiting_since + + anything = (sacks_in_area > 0) or (len(trucks) > 0) + batch_mgr._truck_gone_tolerance = TRUCK_GONE_TOLERANCE + batch_mgr.update_truck(anything, None, timestamp) - for ev in events: - now_str = datetime.now().strftime("%H:%M:%S") - print(f"[{now_str}] [KARUNG] #{ev['track_id']} melintasi garis. Total: {counter.loading_count}") - - # ============================================================= - # TRANSISI BATCH — MULAI/SELESAI REKAMAN VIDEO - # ============================================================= - - # C. Batch baru saja dimulai + # --- Transisi Batch --- if batch_mgr.is_active and not prev_active: - counting_date = get_counting_date() - batch_counter += 1 - now_str = datetime.now().strftime("%H:%M:%S") - print(f"\n>>> [{now_str}] BATCH #{batch_counter} DIMULAI (tanggal: {counting_date}) <<<") - recorder.start_recording(batch_counter, counting_date, 1280, 720) + cd = get_counting_date() + batch_counter = get_next_batch_number_from_files(cd) + truck_seen_in_current_batch = False + log(f"BATCH #{batch_counter} DIMULAI (tanggal: {cd})") + recorder.start(batch_counter, cd, fps=cap.fps) - # D. Batch baru saja selesai elif not batch_mgr.is_active and prev_active: final_count = counter.loading_count - now_str = datetime.now().strftime("%H:%M:%S") - print(f"\n>>> [{now_str}] BATCH #{batch_counter} SELESAI. Total karung: {final_count} <<<") - recorder.stop_recording() - - # Reset counter dan stabilizer untuk batch berikutnya + # Tentukan apakah batch valid (truk harus terdeteksi minimal sekali DAN hitungan karung > 0) + is_valid = (final_count > 0) and truck_seen_in_current_batch + + if is_valid: + log(f"BATCH #{batch_counter} SELESAI. Karung: {final_count}") + recorder.stop(discard=False) + else: + log(f"BATCH #{batch_counter} DIABAIKAN (Karung={final_count}, Truk Terdeteksi={truck_seen_in_current_batch})") + recorder.stop(discard=True) + # Kembalikan nomor counter batch karena batch ini dianulir + batch_counter = max(0, batch_counter - 1) + counter.reset() stabilizer.reset() - # E. Log transisi status if batch_mgr.state != prev_state: - now_str = datetime.now().strftime("%H:%M:%S") - print(f"[{now_str}] [STATE] {prev_state} -> {batch_mgr.state}") + log(f"STATE: {prev_state} -> {batch_mgr.state}") - # ============================================================= - # TULIS FRAME KE VIDEO (jika batch aktif) - # ============================================================= - if batch_mgr.is_active and recorder.is_recording: - recorder.write_frame(frame) + # Tulis frame ke video + if batch_mgr.is_active: + recorder.write(frame) - except KeyboardInterrupt: - print("\n\n[INFO] Program dihentikan oleh pengguna (Ctrl+C).") + # Log karung crossing + for ev in events: + log(f"KARUNG #{ev['track_id']} crossing. Total: {counter.loading_count}") + + # Status log setiap 5 menit + if timestamp - last_status_time >= 300: + last_status_time = timestamp + rec = "REC" if recorder.is_recording else "---" + log(f"STATUS: frames={frame_idx} batches={batch_counter} " + f"state={batch_mgr.state} {rec}") + + # --- Visualisasi Live Predict (Lokal Windows saja) --- + if SHOW_DISPLAY: + display = frame.copy() + + # Gambar detection polygon (magenta) + det_pts = np.array([ + [int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)], + [int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)] + ], dtype=np.int32) + cv2.polylines(display, [det_pts], True, (255, 0, 255), 2) + + # Gambar truck polygon (orange) + trk_pts = np.array([ + [int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)], + [int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)] + ], dtype=np.int32) + cv2.polylines(display, [trk_pts], True, (0, 165, 255), 2) + + # Gambar count line (cyan) + cv2.line(display, (line_x1, line_y), (line_x2, line_y), (255, 255, 0), 2) + cv2.putText(display, "COUNTING LINE", (line_x1 + 10, line_y - 8), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1) + + # Gambar bbox TRUCK (hijau) + for d in trucks: + x1, y1, x2, y2 = [int(v) for v in d.bbox] + cv2.rectangle(display, (x1, y1), (x2, y2), (0, 200, 0), 2) + lbl = f"truck #{d.track_id} ({d.confidence:.2f})" + cv2.putText(display, lbl, (x1, y1 - 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1) + + # Gambar bbox SACK (cyan untuk stable) + for d in stable: + x1, y1, x2, y2 = [int(v) for v in d.bbox] + cv2.rectangle(display, (x1, y1), (x2, y2), (255, 255, 0), 2) + lbl = f"sack #{d.track_id}" + cv2.putText(display, lbl, (x1, y2 + 15), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1) + + # Overlay HUD + overlay = display.copy() + cv2.rectangle(overlay, (5, 5), (380, 150), (0, 0, 0), -1) + cv2.addWeighted(overlay, 0.65, display, 0.35, 0, display) + + state_str = batch_mgr.state + rec_str = "● RECORDING" if recorder.is_recording else "○ IDLE" + color_state = (0, 250, 0) if batch_mgr.is_active else (0, 165, 255) + + cv2.putText(display, f"State: {state_str}", (15, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_state, 2) + cv2.putText(display, f"Total Counted: {counter.loading_count}", (15, 55), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1) + cv2.putText(display, f"Sacks in Area: {sacks_in_area}", (15, 80), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1) + cv2.putText(display, f"Current Batch: #{batch_counter} ({rec_str})", (15, 105), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) + cv2.putText(display, f"Sacks: {len(stable)} | Trucks: {len(trucks)}", + (15, 130), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1) + + # Resize agar muat layar lokal + resized = cv2.resize(display, (960, 540)) + cv2.imshow("Batch Video Cropper - Local Predict", resized) + if cv2.waitKey(1) & 0xFF == ord('q'): + log("Dihentikan secara manual melalui tombol 'q'") + break + + except Exception as e: + log(f"ERROR: {e}") + import traceback + traceback.print_exc() finally: - # Tutup rekaman yang masih terbuka if recorder.is_recording: - print("[INFO] Menyimpan rekaman batch terakhir...") - recorder.stop_recording() - + log("Menyimpan rekaman batch terakhir...") + recorder.stop() cap.release() - - print("\n" + "=" * 60) - print(" BATCH VIDEO CROPPER SELESAI") - print(f" Total batch terekam: {batch_counter}") - print(f" Total frame diproses: {frame_idx}") - print(f" Folder output: {ARCHIVE_BASE}") - print("=" * 60) + if SHOW_DISPLAY: + cv2.destroyAllWindows() + log(f"SELESAI. Total batch: {batch_counter}, frames: {frame_idx}") if __name__ == "__main__": - run_batch_video_cropper() + run() diff --git a/algoritma-batch/batch_video_cropper.py.bak b/algoritma-batch/batch_video_cropper.py.bak new file mode 100644 index 0000000..35c883a --- /dev/null +++ b/algoritma-batch/batch_video_cropper.py.bak @@ -0,0 +1,510 @@ +""" +Batch Video Cropper — Production 24/7 +Rekam video RTSP per sesi batch truk. Ringan, tanpa GUI, auto-reconnect. + +Output: + ~/reTraining/data/archive/{YYYY-MM-DD}/batch_{N}_{HH-MM-SS}.mp4 + +Menjalankan: + cd ~/reTraining/algoritma-batch + python3 batch_video_cropper.py +""" + +import os +# KRITIS: Konfigurasi RTSP transport — HARUS sebelum import cv2 +# Tanpa ini, OpenCV pakai UDP (default) yang sering drop koneksi +os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = ( + "rtsp_transport;tcp|buffer_size;20480000|max_delay;500000|reorder_queue_size;500" +) +import signal +import sys +import cv2 +import numpy as np +import time +import threading +import platform +from datetime import datetime, timedelta +from shapely.geometry import Point, Polygon +from ultralytics import YOLO + +from src.tracking import ByteTrackTracker +from src.stabilizer import BboxStabilizer +from src.truck_roi import TruckROI +from src.counting import LineCrossCounter +from src.batch import BatchLifecycleManager, BatchState + +# ===================================================================== +# KONFIGURASI +# ===================================================================== +IS_WINDOWS = platform.system() == "Windows" +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +if IS_WINDOWS: + MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt") + ARCHIVE_BASE = os.path.join(BASE_DIR, "archive_output") + RTSP_URL = "video truk.mp4" # Testing lokal video +else: + MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt") + ARCHIVE_BASE = os.path.expanduser("~/reTraining/data/archive") + RTSP_URL = "rtsp://192.168.192.96:8554/cam" # Production RTSP stream (.105) + +DAILY_CUTOFF_TIME = "20:00" + +# State Machine +SACK_IDLE_TIMEOUT = 5.0 +MIN_BATCH_DURATION = 2.0 +TRUCK_GONE_TOLERANCE = 5.0 + +# Video Recording +VIDEO_FPS = 10.0 +VIDEO_CODEC = "mp4v" + +# Reconnect +RECONNECT_DELAY = 5 # Detik menunggu sebelum reconnect RTSP +MAX_EMPTY_FRAMES = 300 # Maks frame kosong sebelum reconnect (~30 detik) + +# Matikan tampilan visualisasi agar program sangat ringan 24/7 +SHOW_DISPLAY = False + + +# ===================================================================== +# THREADED RTSP READER (selalu ambil frame terbaru, anti-lag) +# ===================================================================== +class RTSPReader: + def __init__(self, url): + self.url = url + self.cap = None + self.frame = None + self.ret = False + self.running = True + self.lock = threading.Lock() + self.event = threading.Event() + self._connect() + self.thread = threading.Thread(target=self._loop, daemon=True) + self.thread.start() + + def _connect(self): + if self.cap and self.cap.isOpened(): + self.cap.release() + self.cap = cv2.VideoCapture(self.url) + if self.cap.isOpened(): + log("RTSP terhubung") + else: + log("RTSP gagal terhubung") + + def _loop(self): + empty = 0 + while self.running: + if not self.cap or not self.cap.isOpened(): + log(f"RTSP terputus, reconnect dalam {RECONNECT_DELAY}s...") + time.sleep(RECONNECT_DELAY) + self._connect() + empty = 0 + continue + ret, frame = self.cap.read() + if not ret: + empty += 1 + if empty > MAX_EMPTY_FRAMES: + log(f"RTSP {empty} frame kosong, reconnect...") + self._connect() + empty = 0 + time.sleep(0.01) + continue + empty = 0 + with self.lock: + self.ret, self.frame = ret, frame + self.event.set() + time.sleep(0.001) + + def read(self): + if self.event.wait(timeout=2.0): + self.event.clear() + with self.lock: + return self.ret, self.frame.copy() if self.frame is not None else (False, None) + return False, None + + def release(self): + self.running = False + if self.cap: + self.cap.release() + + +# ===================================================================== +# UTILITAS +# ===================================================================== +def log(msg): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] {msg}", flush=True) + + +def get_counting_date(): + dt = datetime.now() + try: + cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%H:%M").time() + except Exception: + cutoff = datetime.strptime("20:00", "%H:%M").time() + if dt.time() < cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + + +# ===================================================================== +# VIDEO RECORDER +# ===================================================================== +class VideoRecorder: + def __init__(self): + self.writer = None + self.path = None + self.frames = 0 + + def start(self, batch_num, counting_date, w=1280, h=720): + self.stop() + folder = os.path.join(ARCHIVE_BASE, counting_date) + os.makedirs(folder, exist_ok=True) + self.path = os.path.join(folder, f"batch{batch_num}.mp4") + fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC) + self.writer = cv2.VideoWriter(self.path, fourcc, VIDEO_FPS, (w, h)) + self.frames = 0 + if self.writer.isOpened(): + log(f"REC START -> {self.path}") + else: + log(f"REC ERROR: gagal buka {self.path}") + self.writer = None + + def write(self, frame): + if self.writer and self.writer.isOpened(): + self.writer.write(frame) + self.frames += 1 + + def stop(self, discard=False): + if self.writer: + self.writer.release() + if not discard and self.frames > 0: + log(f"REC STOP -> {self.path} ({self.frames} frames)") + try: + from src.h264_converter import post_process_h264 + post_process_h264(self.path, log_fn=log) + except Exception as exc: + log(f"REC H264 HOOK ERROR: {exc}") + else: + if discard: + log(f"REC DISCARD -> Menghapus file video {self.path} karena batch tidak valid / kosong") + if self.path: + try: + os.remove(self.path) + except Exception: + pass + self.writer = None + self.path = None + self.frames = 0 + + + @property + def is_recording(self): + return self.writer is not None and self.writer.isOpened() + + +# ===================================================================== +# MAIN LOOP +# ===================================================================== +shutdown_flag = False + +def handle_signal(sig, _): + global shutdown_flag + log(f"Signal {sig} diterima, menutup program...") + shutdown_flag = True + +signal.signal(signal.SIGINT, handle_signal) +signal.signal(signal.SIGTERM, handle_signal) + + +def run(): + global shutdown_flag + + log("=" * 50) + log("BATCH VIDEO CROPPER — Production 24/7") + log(f"Model : {MODEL_PATH}") + log(f"RTSP : {RTSP_URL}") + log(f"Archive : {ARCHIVE_BASE}") + log(f"Toleransi batch: {TRUCK_GONE_TOLERANCE}s (truk+karung)") + log("=" * 50) + + os.makedirs(ARCHIVE_BASE, exist_ok=True) + + # Load model + log("Memuat model YOLO...") + model = YOLO(MODEL_PATH) + + # Detect device + device = "cpu" + try: + import torch + if torch.cuda.is_available(): + device = "cuda" + except ImportError: + pass + + # Warm-up + dummy = np.zeros((720, 1280, 3), dtype=np.uint8) + _ = model(dummy, imgsz=640, device=device, verbose=False) + log(f"Model siap. Device: {device}") + + # Components + tracker = ByteTrackTracker(model, conf=0.25) + stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10, + max_height_ratio=1.5, min_height_ratio=0.70) + + # Koordinat zona (1920x1080 → 1280x720) + sx, sy = 1280.0 / 1920.0, 720.0 / 1080.0 + + detection_polygon = Polygon([ + [int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)], + [int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)], + ]) + truck_polygon = Polygon([ + [int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)], + [int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)], + ]) + + line_y = int(330 * sy) + line_x1 = int(577 * sx) + line_x2 = int(1401 * sx) + + static_roi = TruckROI( + x1=int(600*sx), y1=int(343*sy), x2=int(1404*sx), y2=int(1078*sy), + line_y=line_y, confidence=1.0, + ) + counter = LineCrossCounter(line_y=line_y, line_x_start=line_x1, + line_x_end=line_x2, margin=20, dedup_radius=60.0) + + batch_mgr = BatchLifecycleManager( + stabilize_seconds=0.0, stabilize_threshold_px=9999.0, + sack_idle_timeout=SACK_IDLE_TIMEOUT, + min_batch_duration=MIN_BATCH_DURATION, + truck_gone_tolerance=3.0, + ) + + recorder = VideoRecorder() + + # RTSP Stream + log(f"Membuka RTSP: {RTSP_URL}") + cap = RTSPReader(RTSP_URL) + + batch_counter = 0 + frame_idx = 0 + last_status_time = time.time() + truck_seen_in_current_batch = False + last_frame_time = time.time() + NO_FRAME_BATCH_TIMEOUT = 30.0 # Akhiri batch jika tidak ada frame 30 detik + + log("Loop utama dimulai...") + + try: + while not shutdown_flag: + ret, frame = cap.read() + if not ret or frame is None: + # Saat tidak ada frame DAN batch aktif, cek timeout + if batch_mgr.is_active: + no_frame_duration = time.time() - last_frame_time + if no_frame_duration >= NO_FRAME_BATCH_TIMEOUT: + log(f"RTSP drop {no_frame_duration:.0f}s. Force-end BATCH #{batch_counter}. Karung: {counter.loading_count}") + # Force-end: langsung reset state machine (bypass update_truck) + batch_mgr._state = BatchState.IDLE + batch_mgr._current_batch_id = None + batch_mgr._truck_is_stable = False + recorder.stop(discard=True) + counter.reset() + stabilizer.reset() + last_frame_time = time.time() # Reset timer agar tidak spam + time.sleep(0.01) + continue + + last_frame_time = time.time() + + frame = cv2.resize(frame, (1280, 720)) + timestamp = time.time() + frame_idx += 1 + + prev_active = batch_mgr.is_active + prev_state = batch_mgr.state + + # --- Deteksi --- + raw_all = tracker.update(frame, []) + + # Hanya proses objek yang pusatnya berada di dalam area deteksi (poligon ungu) + raw_all_filtered = [ + d for d in raw_all + if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0)) + ] + + sacks = [d for d in raw_all_filtered if d.class_name == "sack"] + trucks = [d for d in raw_all_filtered if d.class_name == "truck"] + + if batch_mgr.is_active and len(trucks) > 0: + truck_seen_in_current_batch = True + + # Stabilizer + stable = stabilizer.update(sacks) + + # Karung di 70% area truk + ty_min, ty_max = truck_polygon.bounds[1], truck_polygon.bounds[3] + cutoff_y = ty_min + 0.30 * (ty_max - ty_min) + sacks_in_area = sum( + 1 for d in stable + if truck_polygon.contains(Point((d.bbox[0]+d.bbox[2])/2, (d.bbox[1]+d.bbox[3])/2)) + and (d.bbox[1]+d.bbox[3])/2 >= cutoff_y + ) + + # Line crossing + in_roi = [d for d in stable if static_roi.contains_x((d.bbox[0]+d.bbox[2])/2)] + events = counter.update(in_roi) + has_crossing = len(events) > 0 + + # --- State Machine --- + if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"): + batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp) + + if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"): + batch_mgr.update_sacks( + has_crossing_event=has_crossing, + sacks_in_area_count=sacks_in_area, + timestamp=timestamp, + loading_count=counter.loading_count, + unloading_count=counter.unloading_count, + ) + if batch_mgr.state == "WAITING_FOR_ACTIVITY": + # Sinkronkan _truck_last_seen agar countdown toleransi + # mulai dari saat WAITING dimulai, bukan dari TRUCK_STABILIZING + if batch_mgr._truck_last_seen < batch_mgr._waiting_since: + batch_mgr._truck_last_seen = batch_mgr._waiting_since + + anything = (sacks_in_area > 0) or (len(trucks) > 0) + batch_mgr._truck_gone_tolerance = TRUCK_GONE_TOLERANCE + batch_mgr.update_truck(anything, None, timestamp) + + # --- Transisi Batch --- + if batch_mgr.is_active and not prev_active: + batch_counter += 1 + truck_seen_in_current_batch = False + cd = get_counting_date() + log(f"BATCH #{batch_counter} DIMULAI (tanggal: {cd})") + recorder.start(batch_counter, cd) + + elif not batch_mgr.is_active and prev_active: + final_count = counter.loading_count + # Tentukan apakah batch valid (truk harus terdeteksi minimal sekali DAN hitungan karung > 0) + is_valid = (final_count > 0) and truck_seen_in_current_batch + + if is_valid: + log(f"BATCH #{batch_counter} SELESAI. Karung: {final_count}") + recorder.stop(discard=False) + else: + log(f"BATCH #{batch_counter} DIABAIKAN (Karung={final_count}, Truk Terdeteksi={truck_seen_in_current_batch})") + recorder.stop(discard=True) + # Kembalikan nomor counter batch karena batch ini dianulir + batch_counter = max(0, batch_counter - 1) + + counter.reset() + stabilizer.reset() + + if batch_mgr.state != prev_state: + log(f"STATE: {prev_state} -> {batch_mgr.state}") + + # Tulis frame ke video + if batch_mgr.is_active: + recorder.write(frame) + + # Log karung crossing + for ev in events: + log(f"KARUNG #{ev['track_id']} crossing. Total: {counter.loading_count}") + + # Status log setiap 5 menit + if timestamp - last_status_time >= 300: + last_status_time = timestamp + rec = "REC" if recorder.is_recording else "---" + log(f"STATUS: frames={frame_idx} batches={batch_counter} " + f"state={batch_mgr.state} {rec}") + + # --- Visualisasi Live Predict (Lokal Windows saja) --- + if SHOW_DISPLAY: + display = frame.copy() + + # Gambar detection polygon (magenta) + det_pts = np.array([ + [int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)], + [int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)] + ], dtype=np.int32) + cv2.polylines(display, [det_pts], True, (255, 0, 255), 2) + + # Gambar truck polygon (orange) + trk_pts = np.array([ + [int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)], + [int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)] + ], dtype=np.int32) + cv2.polylines(display, [trk_pts], True, (0, 165, 255), 2) + + # Gambar count line (cyan) + cv2.line(display, (line_x1, line_y), (line_x2, line_y), (255, 255, 0), 2) + cv2.putText(display, "COUNTING LINE", (line_x1 + 10, line_y - 8), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1) + + # Gambar bbox TRUCK (hijau) + for d in trucks: + x1, y1, x2, y2 = [int(v) for v in d.bbox] + cv2.rectangle(display, (x1, y1), (x2, y2), (0, 200, 0), 2) + lbl = f"truck #{d.track_id} ({d.confidence:.2f})" + cv2.putText(display, lbl, (x1, y1 - 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1) + + # Gambar bbox SACK (cyan untuk stable) + for d in stable: + x1, y1, x2, y2 = [int(v) for v in d.bbox] + cv2.rectangle(display, (x1, y1), (x2, y2), (255, 255, 0), 2) + lbl = f"sack #{d.track_id}" + cv2.putText(display, lbl, (x1, y2 + 15), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1) + + # Overlay HUD + overlay = display.copy() + cv2.rectangle(overlay, (5, 5), (380, 150), (0, 0, 0), -1) + cv2.addWeighted(overlay, 0.65, display, 0.35, 0, display) + + state_str = batch_mgr.state + rec_str = "● RECORDING" if recorder.is_recording else "○ IDLE" + color_state = (0, 250, 0) if batch_mgr.is_active else (0, 165, 255) + + cv2.putText(display, f"State: {state_str}", (15, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_state, 2) + cv2.putText(display, f"Total Counted: {counter.loading_count}", (15, 55), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1) + cv2.putText(display, f"Sacks in Area: {sacks_in_area}", (15, 80), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1) + cv2.putText(display, f"Current Batch: #{batch_counter} ({rec_str})", (15, 105), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) + cv2.putText(display, f"Sacks: {len(stable)} | Trucks: {len(trucks)}", + (15, 130), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1) + + # Resize agar muat layar lokal + resized = cv2.resize(display, (960, 540)) + cv2.imshow("Batch Video Cropper - Local Predict", resized) + if cv2.waitKey(1) & 0xFF == ord('q'): + log("Dihentikan secara manual melalui tombol 'q'") + break + + except Exception as e: + log(f"ERROR: {e}") + import traceback + traceback.print_exc() + + finally: + if recorder.is_recording: + log("Menyimpan rekaman batch terakhir...") + recorder.stop() + cap.release() + if SHOW_DISPLAY: + cv2.destroyAllWindows() + log(f"SELESAI. Total batch: {batch_counter}, frames: {frame_idx}") + + +if __name__ == "__main__": + run() diff --git a/algoritma-batch/counting.py b/algoritma-batch/counting.py new file mode 100644 index 0000000..f3f61c9 --- /dev/null +++ b/algoritma-batch/counting.py @@ -0,0 +1,259 @@ +"""Line-crossing counter — hybrid zone-based state tracking. + +Counting logic (Low-FPS robust): + Uses y1 (top edge) of the stabilized sack bounding box. + + Each track_id goes through states: + UNKNOWN → ABOVE → COUNTED (when seen below line) + UNKNOWN → BELOW (ghost/appeared below line first → never counted) + + Loading: track had state ABOVE, now detected BELOW the zone + Unloading: track had state BELOW, now detected ABOVE the zone (if needed) + + 4-Layer deduplication: + Layer 1: State guard — must have been ABOVE before counting + Layer 2: Entry circle — must have exited the radius from first appearance + Layer 3: Spatial dedup radius — same position can't trigger twice + Layer 4: Track ID — one track_id can only be counted once per direction + + This approach is immune to low FPS because it doesn't require + detecting the exact frame of crossing. It only needs the track + to have been seen ABOVE the line at ANY point in its lifetime. +""" + +from __future__ import annotations + +import time + +from src.interfaces import Detection + + +class LineCrossCounter: + """Counts sacks crossing a horizontal zone using y1 (top edge). + + The zone is a band [line_y - margin, line_y + margin]. + A sack is "above" if y1 < line_y - margin, + "below" if y1 > line_y + margin. + While y1 is inside the band, state is held (no trigger). + + Loading = track was ever "above", now "below" (entered truck) + Unloading = track was ever "below", now "above" (left truck) + """ + + def __init__( + self, + line_y: int, + line_x_start: int, + line_x_end: int, + margin: int = 20, + dedup_radius: float = 60.0, + ) -> None: + self._line_y = line_y + self._line_x_start = line_x_start + self._line_x_end = line_x_end + self._margin = margin + self._dedup_radius = dedup_radius + + self._loading_count = 0 + self._unloading_count = 0 + + # track_id -> zone state for y1: "above" | "below" | None + self._state: dict[int, str | None] = {} + # track_id -> whether this track has EVER been in each zone + self._has_been_above: dict[int, bool] = {} + self._has_been_below: dict[int, bool] = {} + # track_id -> set of directions already counted + self._counted: dict[int, set[str]] = {} + # track_id -> initial coordinates (cx, y1) when first tracked + self._entry_points: dict[int, tuple[float, float]] = {} + # track_id -> whether this track has exited its entry circle + # A track must move beyond dedup_radius from its first appearance + # before it can be counted. This prevents ghost bboxes that suddenly + # appear near the counting line from being counted immediately. + self._has_exited_entry_circle: dict[int, bool] = {} + # list of active deduplication circles + self._dedup_circles: list[dict] = [] + + @property + def entry_points(self) -> dict[int, tuple[float, float]]: + return self._entry_points + + @property + def counted_tracks(self) -> dict[int, set[str]]: + return self._counted + + @property + def line_y(self) -> int: + return self._line_y + + @line_y.setter + def line_y(self, value: int) -> None: + self._line_y = value + + @property + def line_x_start(self) -> int: + return self._line_x_start + + @line_x_start.setter + def line_x_start(self, value: int) -> None: + self._line_x_start = value + + @property + def line_x_end(self) -> int: + return self._line_x_end + + @line_x_end.setter + def line_x_end(self, value: int) -> None: + self._line_x_end = value + + def update(self, detections: list[Detection]) -> list[dict]: + """Process detections, return list of crossing events. + + Hybrid approach: + - Tracks zone state per frame (above/below/in-band) + - BUT uses accumulated history (has_been_above) for counting decision + - A track counts as "loading" when: + 1. It has been seen ABOVE the line at any previous point + 2. It has exited its entry circle (moved beyond dedup_radius from first appearance) + 3. Its current y1 is now BELOW the line + 4. It hasn't been counted for loading yet + 5. It passes spatial dedup check + """ + now_t = time.time() + events: list[dict] = [] + upper = self._line_y - self._margin + lower = self._line_y + self._margin + + # Clean up expired dedup circles (older than 3.0 seconds) + self._dedup_circles = [c for c in self._dedup_circles if (now_t - c["time"]) <= 3.0] + + for det in detections: + if det.track_id is None: + continue + + x1, y1, x2, y2 = det.bbox + cx = (x1 + x2) / 2.0 + tid = det.track_id + + if tid not in self._entry_points: + self._entry_points[tid] = (cx, y1) + self._has_exited_entry_circle[tid] = False + + # Check if sack has exited its entry circle (moved beyond dedup_radius + # from where it first appeared). Once exited, stays exited permanently. + if not self._has_exited_entry_circle.get(tid, False): + entry_cx, entry_y1 = self._entry_points[tid] + dist_from_entry = ((cx - entry_cx) ** 2 + (y1 - entry_y1) ** 2) ** 0.5 + if dist_from_entry > self._dedup_radius: + self._has_exited_entry_circle[tid] = True + + # Skip if centroid X outside counting bounds + if cx < self._line_x_start or cx > self._line_x_end: + continue + + counted_dirs = self._counted.setdefault(tid, set()) + + # Determine y1 zone state (top edge of sack bbox) + if y1 < upper: + new_state = "above" + elif y1 > lower: + new_state = "below" + else: + new_state = self._state.get(tid) # in band: hold + + prev_state = self._state.get(tid) + self._state[tid] = new_state + + # Track zone history — CRITICAL for low-FPS robustness + # Once a track has been seen above/below, it stays recorded forever + if new_state == "above": + self._has_been_above[tid] = True + elif new_state == "below": + self._has_been_below[tid] = True + + # --- HYBRID COUNTING LOGIC --- + # Loading: track was EVER above, NOW below (entered truck from top) + # AND has exited its entry circle (proven real movement, not ghost bbox) + # This works even if the track jumped over the line between frames + has_exited = self._has_exited_entry_circle.get(tid, False) + + is_loading = ( + new_state == "below" + and self._has_been_above.get(tid, False) + and has_exited + and "loading" not in counted_dirs + ) + + # Unloading: track was EVER below, NOW above (left truck) + is_unloading = ( + new_state == "above" + and self._has_been_below.get(tid, False) + and has_exited + and "unloading" not in counted_dirs + ) + + if is_loading or is_unloading: + # Check spatial distance against all active dedup circles + is_duplicate = False + for circle in self._dedup_circles: + dist = ((cx - circle["x"]) ** 2 + (y1 - circle["y"]) ** 2) ** 0.5 + if dist <= self._dedup_radius: + is_duplicate = True + break + + if is_duplicate: + continue + + # Add this coordinate to the active dedup circles + self._dedup_circles.append({ + "x": cx, + "y": y1, + "time": now_t, + "track_id": tid + }) + + if is_loading: + self._loading_count += 1 + counted_dirs.add("loading") + events.append({ + "track_id": tid, + "direction": "loading", + "cx": cx, + "cy": y1 + }) + + elif is_unloading: + self._unloading_count += 1 + counted_dirs.add("unloading") + events.append({ + "track_id": tid, + "direction": "unloading", + "cx": cx, + "cy": y1 + }) + + return events + + @property + def loading_count(self) -> int: + return self._loading_count + + @property + def unloading_count(self) -> int: + return self._unloading_count + + @property + def net_count(self) -> int: + return self._loading_count - self._unloading_count + + def reset(self) -> None: + """Reset all counters (new batch).""" + self._loading_count = 0 + self._unloading_count = 0 + self._state.clear() + self._has_been_above.clear() + self._has_been_below.clear() + self._counted.clear() + self._entry_points.clear() + self._has_exited_entry_circle.clear() + self._dedup_circles.clear() diff --git a/algoritma-batch/detection.py b/algoritma-batch/detection.py new file mode 100644 index 0000000..ade6a95 --- /dev/null +++ b/algoritma-batch/detection.py @@ -0,0 +1,82 @@ +"""YOLO-based detectors for sacks and trucks. + +Each detector is a single-responsibility unit (S). New model types can be +added as new classes without touching these (O). +""" + +from __future__ import annotations + +import numpy as np +from ultralytics import YOLO + +from src.interfaces import Detection + + +class SackDetector: + """Detects sacks (and persons) using a YOLO segmentation model.""" + + def __init__(self, model_path: str, conf: float = 0.40) -> None: + self._model = YOLO(model_path) + self._conf = conf + + def detect(self, frame: np.ndarray) -> list[Detection]: + results = self._model.predict( + frame, conf=self._conf, verbose=False + ) + return self._parse(results[0]) + + def _parse(self, result) -> list[Detection]: + detections: list[Detection] = [] + masks = result.masks + for i, box in enumerate(result.boxes): + cls_id = int(box.cls[0]) + name = self._model.names[cls_id] + if name != "sack": + continue + x1, y1, x2, y2 = box.xyxy[0].tolist() + mask = None + if masks is not None and i < len(masks): + mask = masks[i].data.cpu().numpy().squeeze() + detections.append( + Detection( + bbox=(x1, y1, x2, y2), + confidence=float(box.conf[0]), + class_id=cls_id, + class_name=name, + mask=mask, + ) + ) + return detections + + +class TruckDetector: + """Detects trucks using a YOLO detection model.""" + + def __init__(self, model_path_or_model: str | YOLO, conf: float = 0.50) -> None: + if isinstance(model_path_or_model, str): + self._model = YOLO(model_path_or_model) + else: + self._model = model_path_or_model + self._conf = conf + + def detect(self, frame: np.ndarray) -> list[Detection]: + results = self._model.predict( + frame, conf=self._conf, verbose=False + ) + return self._parse(results[0]) + + def _parse(self, result) -> list[Detection]: + detections: list[Detection] = [] + for box in result.boxes: + cls_id = int(box.cls[0]) + name = self._model.names[cls_id] + x1, y1, x2, y2 = box.xyxy[0].tolist() + detections.append( + Detection( + bbox=(x1, y1, x2, y2), + confidence=float(box.conf[0]), + class_id=cls_id, + class_name=name, + ) + ) + return detections diff --git a/algoritma-batch/migrate_cutoff_0600.py b/algoritma-batch/migrate_cutoff_0600.py new file mode 100644 index 0000000..abbc3bf --- /dev/null +++ b/algoritma-batch/migrate_cutoff_0600.py @@ -0,0 +1,152 @@ +"""Re-file the Jetson's stored batches under the 06:00 counting day. + +`predict.py` used to turn the counting day over at 20:00, which filed the whole +day shift under the previous date — a batch that started at 08:27 on the 13th was +stored as the 12th. The archive's cycles run 06:00 to 06:00, so the two disagreed. +The default is now 06:00; this brings rows written before that change into line. + +For every batch it recomputes `counting_date` from `start_time`, renumbers +`batch_number` 1..N within each counting day (per camera and object label, +ordered by start time), and rebuilds `daily_summaries` from the result. + +Run it on the Jetson, against its own database: + + python migrate_cutoff_0600.py --db /opt/jetson-counter/jetson_counter.db --dry-run + python migrate_cutoff_0600.py --db /opt/jetson-counter/jetson_counter.db + +Nothing is written without a backup, and `--dry-run` writes nothing at all. +Stop `predict.py` first: it holds an active batch in memory and would write it +back under the old numbering. +""" + +import argparse +import datetime +import os +import shutil +import sqlite3 +import sys + + +def counting_date(start_time: str, cutoff_hour: int) -> str: + """The counting day a batch belongs to, from when it started.""" + stamp = datetime.datetime.fromisoformat(start_time) + day = stamp.date() + if stamp.hour < cutoff_hour: + day = day - datetime.timedelta(days=1) + return day.isoformat() + + +def plan(connection, cutoff_hour: int): + """What each row should become. Ordered by start time inside each day.""" + rows = connection.execute( + """SELECT id, counting_date, batch_number, camera_name, object_label, + count, start_time + FROM batches ORDER BY start_time""" + ).fetchall() + + counters: dict = {} + changes = [] + for row in rows: + try: + new_date = counting_date(row["start_time"], cutoff_hour) + except (TypeError, ValueError): + # A row whose start_time cannot be parsed is left exactly as it is; + # guessing its day would be worse than leaving it visibly odd. + changes.append({"row": row, "new_date": row["counting_date"], + "new_number": row["batch_number"], "skipped": True}) + continue + key = (new_date, row["camera_name"], row["object_label"]) + counters[key] = counters.get(key, 0) + 1 + changes.append({"row": row, "new_date": new_date, + "new_number": counters[key], "skipped": False}) + return changes + + +def apply(connection, changes) -> None: + """Rewrite the table. + + `batches` has UNIQUE(counting_date, batch_number, camera_name, object_label), + so renumbering in place collides with rows that have not moved yet. The + numbers are parked in a negative range first, which cannot collide with any + real batch number, and then written to their final values. + """ + cursor = connection.cursor() + for offset, change in enumerate(changes, start=1): + cursor.execute("UPDATE batches SET batch_number = ? WHERE id = ?", + (-offset, change["row"]["id"])) + for change in changes: + cursor.execute( + "UPDATE batches SET counting_date = ?, batch_number = ? WHERE id = ?", + (change["new_date"], change["new_number"], change["row"]["id"]), + ) + + cursor.execute("DELETE FROM daily_summaries") + cursor.execute( + """INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + SELECT counting_date, camera_name, object_label, SUM(count), COUNT(id) + FROM batches + GROUP BY counting_date, camera_name, object_label""" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db", required=True, help="path to jetson_counter.db") + parser.add_argument("--cutoff-hour", type=int, default=6) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + if not os.path.isfile(args.db): + print(f"No database at {args.db}") + return 1 + + connection = sqlite3.connect(args.db) + connection.row_factory = sqlite3.Row + changes = plan(connection, args.cutoff_hour) + if not changes: + print("No batches stored — nothing to do.") + return 0 + + moved = [c for c in changes + if c["new_date"] != c["row"]["counting_date"] + or c["new_number"] != c["row"]["batch_number"]] + skipped = [c for c in changes if c["skipped"]] + + print(f"{len(changes)} batch(es) stored, {len(moved)} would change, " + f"{len(skipped)} unparseable and left alone.\n") + for change in moved[:20]: + row = change["row"] + print(f" {row['start_time'][:19]} " + f"{row['counting_date']} #{row['batch_number']:<4} -> " + f"{change['new_date']} #{change['new_number']}") + if len(moved) > 20: + print(f" … and {len(moved) - 20} more") + + if args.dry_run: + print("\nDry run — nothing written.") + return 0 + if not moved: + print("\nAlready consistent with the 06:00 cutoff.") + return 0 + + backup = f"{args.db}.before-0600-{datetime.datetime.now():%Y%m%d-%H%M%S}" + shutil.copyfile(args.db, backup) + print(f"\nBackup written to {backup}") + + try: + with connection: + apply(connection, changes) + except Exception as exc: + print(f"FAILED, database left untouched by the transaction: {exc}") + print(f"The backup at {backup} is still the pre-migration state.") + return 1 + + days = connection.execute( + "SELECT COUNT(DISTINCT counting_date) FROM batches").fetchone()[0] + print(f"Done. {len(moved)} batch(es) re-filed across {days} counting day(s).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/algoritma-batch/predict.py b/algoritma-batch/predict.py new file mode 100644 index 0000000..cc54b00 --- /dev/null +++ b/algoritma-batch/predict.py @@ -0,0 +1,1405 @@ +import os +os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|buffer_size;20480000|max_delay;500000|reorder_queue_size;500" +import cv2 +import numpy as np +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import ThreadingMixIn +from ultralytics import YOLO +from shapely.geometry import Point, Polygon, LineString, box +from collections import defaultdict, deque +import torch +import sqlite3 +from datetime import datetime, timedelta +import logging + +# Import repo rafan modules +from src.detection import SackDetector, TruckDetector +from src.tracking import ByteTrackTracker +from src.stabilizer import BboxStabilizer +from src.truck_roi import TruckROITracker +from src.counting import LineCrossCounter +from src.batch import BatchLifecycleManager, BatchRecord +from src.dashboard import DashboardOverlay + +# --- SQLite Database & State Configuration --- +if os.name == 'nt': + OUTPUT_DIR = 'd:/Belajar/menghitung karung' + DB_PATH = f'{OUTPUT_DIR}/jetson_counter.db' + STATE_FILE = f'{OUTPUT_DIR}/current_batch.json' + LIVE_STREAM_FRAME_PATH = f'{OUTPUT_DIR}/live_frame.jpg' +else: + OUTPUT_DIR = os.getenv('OUTPUT_DIR', '/opt/jetson-counter') + DB_PATH = os.getenv('DB_PATH', f'{OUTPUT_DIR}/jetson_counter.db') + STATE_FILE = os.getenv('STATE_FILE', f'{OUTPUT_DIR}/current_batch.json') + LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', '/dev/shm/jetson-counter/live_frame.jpg') + +CAMERA_NAME = os.getenv('CAMERA_NAME', 'CC1') +OBJECT_LABEL = os.getenv('OBJECT_LABEL', 'karung-pakan') +# A shift runs 06:00 to 06:00, so that is where the counting day turns over and +# batch_number restarts at 1. The old default of 20:00 labelled the whole day +# shift as the *previous* date: a batch at 08:27 on the 13th was filed under the +# 12th. It disagreed with the archive's cycles in 3 of 8 boundary cases tested; +# at 06:00 the two agree exactly. Still overridable per deployment. +DAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '06:00') +BATCH_MERGE_THRESHOLD_SECONDS = int(os.getenv('BATCH_MERGE_THRESHOLD_SECONDS', '300')) + +active_batch_info = None + +def get_counting_date(dt=None): + if dt is None: + dt = datetime.now() + try: + cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%H:%M").time() + except Exception: + cutoff = datetime.strptime("20:00", "%H:%M").time() + + if cutoff.hour == 0 and cutoff.minute == 0: + return dt.date().isoformat() + + if dt.time() < cutoff: + return (dt.date() - timedelta(days=1)).isoformat() + return dt.date().isoformat() + +def init_db(): + try: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + os.makedirs(os.path.dirname(LIVE_STREAM_FRAME_PATH), exist_ok=True) + + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + batch_number INTEGER NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + count INTEGER NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, batch_number, camera_name, object_label) + ) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS daily_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + total_count INTEGER NOT NULL DEFAULT 0, + total_batches INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, camera_name, object_label) + ) + """) + conn.commit() + conn.close() + print(f"[DB Info] Inisialisasi SQLite database berhasil: {DB_PATH}") + except Exception as e: + print(f"[DB Error] Gagal inisialisasi SQLite database: {e}") + +def get_next_batch_number(counting_date): + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute(""" + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) + row = cur.fetchone() + conn.close() + return row[0] + 1 + except Exception as e: + print(f"[DB Error] Gagal mendapatkan batch_number: {e}") + return 1 + +def get_last_batch_info(counting_date): + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute(""" + SELECT batch_number, count, start_time, end_time + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + ORDER BY batch_number DESC LIMIT 1 + """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) + row = cur.fetchone() + conn.close() + if row: + return { + "batch_number": row[0], + "count": row[1], + "start_time": row[2], + "end_time": row[3] + } + except Exception as e: + print(f"[DB Error] Gagal mendapatkan batch terakhir: {e}") + return None + +def save_active_batch_state(): + global active_batch_info + if active_batch_info is None: + try: + if os.path.exists(STATE_FILE): + os.remove(STATE_FILE) + except Exception: + pass + return + try: + with open(STATE_FILE, 'w', encoding='utf-8') as f: + json.dump(active_batch_info, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}") + +def finalize_batch(final_count, start_time_iso, end_time_iso): + global active_batch_info + if active_batch_info is None: + return + if final_count == 0: + print(f"[BATCH] Batch #{active_batch_info.get('batch_number', 0)} bernilai 0 diabaikan (tidak disimpan ke database).") + active_batch_info = None + save_active_batch_state() + return + counting_date = active_batch_info["counting_date"] + batch_num = active_batch_info["batch_number"] + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + # 1. Insert completed batch + cur.execute(""" + INSERT OR REPLACE INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso)) + + # 2. Update daily summaries + cur.execute(""" + SELECT SUM(count), COUNT(id) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) + sum_row = cur.fetchone() + tot_count = sum_row[0] if sum_row[0] is not None else 0 + tot_batches = sum_row[1] if sum_row[1] is not None else 0 + + cur.execute(""" + INSERT OR REPLACE INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches)) + + conn.commit() + conn.close() + print(f"[DB Info] Sesi batch #{batch_num} disimpan ke database SQLite: {final_count} karung.") + except Exception as e: + print(f"[DB Error] Gagal menyimpan batch ke database: {e}") + active_batch_info = None + save_active_batch_state() + +def write_live_frame(frame): + try: + os.makedirs(os.path.dirname(LIVE_STREAM_FRAME_PATH), exist_ok=True) + tmp_path = LIVE_STREAM_FRAME_PATH.replace(".jpg", ".tmp.jpg") + cv2.imwrite(tmp_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) + os.replace(tmp_path, LIVE_STREAM_FRAME_PATH) + except PermissionError: + # Transient file lock on Windows when Flask dashboard reads it, safe to ignore + pass + except Exception as e: + print(f"[ERROR] Gagal menulis live frame: {e}") + +def draw_annotations_on_frame(frame, bbox_list): + try: + h_f, w_f = frame.shape[:2] + + # 1. Draw left and right limits (vertical lines) + cv2.line(frame, (left_limit, 0), (left_limit, h_f), (255, 0, 255), 2) + cv2.line(frame, (right_limit, 0), (right_limit, h_f), (255, 0, 255), 2) + + # 2. Draw ZONA_PALET (Cyan) + if len(ZONA_PALET) >= 3: + cv2.polylines(frame, [ZONA_PALET], True, (255, 255, 0), 2) + + # 3. Draw ZONA_TRUCK (Yellow/Red based on system_state) + if poly_truck is not None and not poly_truck.is_empty: + pts = np.array(poly_truck.exterior.coords, dtype=np.int32) + color_truck = (0, 204, 255) if system_state == STATE_WAITING_FOR_TRUCK else (0, 255, 0) + cv2.polylines(frame, [pts], True, color_truck, 2) + cv2.putText(frame, "ZONA TRUK BATCH", (pts[0][0], max(20, pts[0][1] - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_truck, 2) + elif len(ZONA_TRUCK) >= 3: + color_truck = (0, 204, 255) if system_state == STATE_WAITING_FOR_TRUCK else (0, 255, 0) + cv2.polylines(frame, [ZONA_TRUCK], True, color_truck, 2) + cv2.putText(frame, "ZONA TRUK BATCH", (ZONA_TRUCK[0][0], max(20, ZONA_TRUCK[0][1] - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_truck, 2) + + # 4. Draw active bboxes and points from bbox_list + for item in bbox_list: + try: + if item.get("is_counted", False): + continue + color_str = item.get("color", "rgba(0, 255, 0, 1.0)") + if color_str.startswith("rgba"): + parts = color_str.replace("rgba(", "").replace(")", "").split(",") + r, g, b = int(parts[0]), int(parts[1]), int(parts[2]) + bgr_color = (b, g, r) + else: + bgr_color = (0, 255, 0) + + norm_box = item["bbox"] + x1 = int(norm_box[0] * w_f) + y1 = int(norm_box[1] * h_f) + x2 = int(norm_box[2] * w_f) + y2 = int(norm_box[3] * h_f) + + cv2.rectangle(frame, (x1, y1), (x2, y2), bgr_color, 2) + + cx, cy = int(item["centroid"][0] * w_f), int(item["centroid"][1] * h_f) + # Gambar Point di Tengah BBox + cv2.circle(frame, (cx, cy), 5, (0, 255, 255), -1) + + label = f"{item['status']} #{item['id']}" if item["id"] != 9999 else item["status"] + cv2.putText(frame, label, (x1, max(15, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, bgr_color, 2) + + # Jika ada entry_point (titik acuan awal & radius 50px) + if item.get("entry_point") is not None: + ex, ey = item["entry_point"] + is_counted = item.get("is_counted", False) + viz_color = (0, 255, 0) if is_counted else (0, 140, 255) + + # 1. Gambar Titik Acuan Awal + cv2.circle(frame, (ex, ey), 4, viz_color, -1) + # 2. Gambar Lingkaran Radius 50px + cv2.circle(frame, (ex, ey), 50, viz_color, 2, lineType=cv2.LINE_AA) + # 3. Garis hubung ke centroid aktif + cv2.line(frame, (ex, ey), (cx, cy), viz_color, 1) + # 4. Label Jarak + dist_val = np.sqrt((cx - ex)**2 + (cy - ey)**2) + dist_label = "COUNTED (+1)" if is_counted else f"{dist_val:.0f}/50px" + cv2.putText(frame, dist_label, (ex - 20, max(15, ey - 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, viz_color, 2) + except Exception: + pass + + # 5. Draw active Duplicate Radius Circles on Port 8000 live stream (terkini 3.0 detik) + if 'counted_sack_positions' in globals() and counted_sack_positions: + rad_vis = DUPLICATE_CIRCLE_RADIUS if ('DUPLICATE_CIRCLE_RADIUS' in globals() and DUPLICATE_CIRCLE_RADIUS > 0) else 35 + now_t = time.time() + active_circles = [p for p in counted_sack_positions if len(p) < 3 or (now_t - p[2]) <= 3.0] + for pos_item in active_circles: + px, py = pos_item[0], pos_item[1] + tid = pos_item[3] if len(pos_item) > 3 else 0 + cv2.circle(frame, (int(px), int(py)), int(rad_vis), (0, 255, 255), 2, lineType=cv2.LINE_AA) + cv2.circle(frame, (int(px), int(py)), 4, (0, 255, 0), -1) + cv2.putText(frame, f"DEDUP #{tid}", (int(px) - 25, max(15, int(py) - int(rad_vis) - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1) + + cv2.putText(frame, f"RADIUS DEDUP: {rad_vis}px", (w_f - 270, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2) + + # 6. Draw HUD stats on top left + total_in = metrics.get('total_masuk', 0) + total_out = metrics.get('total_keluar', 0) + net_cnt = total_in - total_out + cv2.putText(frame, f"STATUS: {system_state}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) + cv2.putText(frame, f"IN: {total_in} OUT: {total_out} NET: {net_cnt}", (20, 75), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) + cv2.putText(frame, f"FPS: {current_fps:.2f}", (20, 110), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 204, 0), 2) + except Exception: + pass + +# --- RUNNING LOCALLY (Colab patches removed) --- +# ----------------------------------------------- + +# ===================================================================== +# 0. PARAMETER KALIBRASI & STATE MACHINE +# ===================================================================== +MIN_VALID_AREA_REF = 15000 +MIN_VALID_AREA = 15000 +JARAK_ABSORBSI_GHOST = 50 + +# --- Logika masuk/keluar berbasis overlap + delay --- +ENTRY_OVERLAP_THRESHOLD = 0.20 # 20% +EXIT_OVERLAP_THRESHOLD = 0.05 # 5% +CONFIRM_DELAY_SEC = 0.5 # delay masuk +EXIT_CONFIRM_DELAY_SEC = 6.0 # delay keluar +COUNTED_DISPLAY_TIMEOUT_SEC = 0.5 # durasi tampil kotak hijau setelah terhitung + +# --- Parameter Anti-Double Count (Spasial) --- +JARAK_TOLERANSI_DUPLIKAT_REF = 80 +JARAK_TOLERANSI_DUPLIKAT = 80 +TOLERANSI_FRAME_HILANG = 1200 # 1200 frame untuk Re-ID lost +MAX_REID_TRANSIT_DISTANCE_REF = 400 +MAX_REID_TRANSIT_DISTANCE = 400 # Max pixel distance for Re-ID + +# --- Parameter Bbox Muncul Tiba-tiba & Transit --- +MIN_DISPLACEMENT_START_IN_TRUCK = 15 +MIN_LINEARITY_START_IN_TRUCK = 0.70 +MIN_DISPLACEMENT_COUNTING_ZONE = 12 +MIN_DY_COUNTING_ZONE = -3 + +# --- Parameter Lingkaran Duplikat Statis --- +DUPLICATE_CIRCLE_RADIUS_REF = 35 +DUPLICATE_CIRCLE_RADIUS = 35 +SHOW_ALL_BBOXES = False +counted_sack_positions = [] +CIRCLE_STAY_TIMEOUT_SEC = 10.0 +INFERENCE_STRIDE = 2 +CAMERA_NOISE_DEADBAND = 50 # pixels deadband for static checks (diperbesar ke 50 sesuai permintaan user) + +# --- Duo-Model Batching State Machine --- +STATE_WAITING_FOR_TRUCK = "WAITING_FOR_TRUCK" +STATE_COUNTING_SACKS = "COUNTING_SACKS" +STATE_TRUCK_FULL = "TRUCK_FULL" +STATE_TRUCK_LEAVING = "TRUCK_LEAVING" + +system_state = STATE_WAITING_FOR_TRUCK +truck_static_frames = 0 +truck_initial_bbox = None # [x1, y1, x2, y2] +truck_entry_point = None # (ex_t, ey_t) +entry_points = {} # track_id -> (ex, ey) +static_sack_visuals = {} # track_id -> HSV histogram representation +static_frames = defaultdict(int) +moving_frames = defaultdict(int) +sack_class_id = 0 # Default class ID for sack +counted_sacks = {} +lost_counted_sacks = {} +all_counted_sacks_map = {} +last_seen_near_person_frame = {} +blocked_due_to_duplicate = {} + +# --- Model Paths --- +# Model gabungan karung + truk (menggantikan truck-detector dan best) +if os.name == 'nt': + COMBINED_MODEL_PATH = r"D:\Belajar\Menghitung karung\v4-best (1).pt" +else: + COMBINED_MODEL_PATH = "model_karung_trukf.engine" if os.path.exists("model_karung_trukf.engine") else "v4-best (1).pt" + +# ===================================================================== + +# ===================================================================== +# 1. KONFIGURASI KOORDINAT ZONA +# ===================================================================== +width = 1280 +height = 720 +scale_x = 1.0 +scale_y = 1.0 + +def get_bottom_quarter(pts): + if len(pts) < 4: + return np.array([], dtype=np.int32) + pts_list = pts.tolist() if isinstance(pts, np.ndarray) else list(pts) + sorted_by_y = sorted(pts_list, key=lambda p: p[1]) + tops = sorted_by_y[:2] + bottoms = sorted_by_y[2:] + + tops_sorted = sorted(tops, key=lambda p: p[0]) + tl = np.array(tops_sorted[0], dtype=np.float32) + tr = np.array(tops_sorted[1], dtype=np.float32) + + bottoms_sorted = sorted(bottoms, key=lambda p: p[0]) + bl = np.array(bottoms_sorted[0], dtype=np.float32) + br = np.array(bottoms_sorted[1], dtype=np.float32) + + p_left = tl * 0.5 + bl * 0.5 + p_right = tr * 0.5 + br * 0.5 + + return np.array([ + [int(p_left[0]), int(p_left[1])], + [int(p_right[0]), int(p_right[1])], + [int(tr[0]), int(tr[1])], + [int(tl[0]), int(tl[1])] + ], dtype=np.int32) + +ZONES_JSON_PATH = "zones.json" +DEFAULT_PALET = [] +DEFAULT_TRUCK = [] + + + +def load_zones(): + global ZONA_PALET_REF, ZONA_TRUCK_REF, GARIS_COUNTING_REF, DUPLICATE_CIRCLE_RADIUS_REF + global MIN_VALID_AREA_REF, JARAK_TOLERANSI_DUPLIKAT_REF, MAX_REID_TRANSIT_DISTANCE_REF + global CIRCLE_STAY_TIMEOUT_SEC, INFERENCE_STRIDE, CONFIRM_DELAY_SEC, EXIT_CONFIRM_DELAY_SEC + global ZONA_COUNTING_REF, left_limit_ref, right_limit_ref, EXTERNAL_STREAM_URL_REF + if os.path.exists(ZONES_JSON_PATH): + try: + with open(ZONES_JSON_PATH, 'r') as f: + data = json.load(f) + ZONA_PALET_REF = np.array(data.get('palet', []), dtype=np.int32) + ZONA_TRUCK_REF = np.array(data.get('truck', []), dtype=np.int32) + ZONA_COUNTING_REF = get_bottom_quarter(ZONA_TRUCK_REF) + left_limit_ref = float(data.get('left_limit', 0.05)) + right_limit_ref = float(data.get('right_limit', 0.95)) + GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy() + DUPLICATE_CIRCLE_RADIUS_REF = data.get('duplicate_circle_radius', 60) + MIN_VALID_AREA_REF = data.get('min_valid_area', 15000) + JARAK_TOLERANSI_DUPLIKAT_REF = data.get('jarak_toleransi_duplikat', 20) + MAX_REID_TRANSIT_DISTANCE_REF = data.get('max_reid_transit_distance', 400) + CIRCLE_STAY_TIMEOUT_SEC = data.get('circle_stay_timeout_sec', 10.0) + INFERENCE_STRIDE = data.get('inference_stride', 2) + CONFIRM_DELAY_SEC = data.get('confirm_delay_sec', 0.5) + EXIT_CONFIRM_DELAY_SEC = data.get('exit_confirm_delay_sec', 6.0) + EXTERNAL_STREAM_URL_REF = data.get('external_stream_url', 'http://192.168.192.96:8888/cam/') + if not EXTERNAL_STREAM_URL_REF: + EXTERNAL_STREAM_URL_REF = 'http://192.168.192.96:8888/cam/' + print("[INFO] Berhasil memuat koordinat zona dan parameter kalibrasi dari zones.json") + return + except Exception as e: + print(f"[WARNING] Gagal memuat zones.json ({e}), menggunakan default.") + + ZONA_PALET_REF = np.array(DEFAULT_PALET, dtype=np.int32) + ZONA_TRUCK_REF = np.array(DEFAULT_TRUCK, dtype=np.int32) + ZONA_COUNTING_REF = get_bottom_quarter(ZONA_TRUCK_REF) + left_limit_ref = 0.05 + right_limit_ref = 0.95 + GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy() + +load_zones() + +DUPLICATE_CIRCLE_RADIUS = DUPLICATE_CIRCLE_RADIUS_REF +MIN_VALID_AREA = MIN_VALID_AREA_REF +JARAK_TOLERANSI_DUPLIKAT = JARAK_TOLERANSI_DUPLIKAT_REF +MAX_REID_TRANSIT_DISTANCE = MAX_REID_TRANSIT_DISTANCE_REF + +ZONA_PALET = ZONA_PALET_REF.copy() +ZONA_TRUCK = ZONA_TRUCK_REF.copy() +ZONA_COUNTING = ZONA_COUNTING_REF.copy() if len(ZONA_COUNTING_REF) > 0 else np.array([], dtype=np.int32) +GARIS_COUNTING = GARIS_COUNTING_REF.copy() + +poly_palet = Polygon(ZONA_PALET) if len(ZONA_PALET) >= 3 else None +poly_truck = Polygon(ZONA_TRUCK) if len(ZONA_TRUCK) >= 3 else None +poly_counting = Polygon(ZONA_COUNTING) if len(ZONA_COUNTING) >= 3 else None + +left_limit = int(left_limit_ref * 1280) +right_limit = int(right_limit_ref * 1280) + +from shapely.geometry import LineString +if len(ZONA_COUNTING) >= 4: + line_counting = LineString([ZONA_COUNTING[3], ZONA_COUNTING[2]]) +else: + line_counting = poly_truck.boundary if poly_truck is not None else None + +DEBOUNCE_FRAMES = 8 # tetap dipakai untuk label visual zona (PALET/AREA BEBAS), TIDAK untuk keputusan counting + +# ===================================================================== +# 2. STATE TRACKING +# ===================================================================== +track_zone_history = defaultdict(lambda: deque(maxlen=DEBOUNCE_FRAMES)) +track_confirmed_state = {} # dipakai untuk LABEL VISUAL saja (PALET/AREA BEBAS), bukan untuk counting + +is_locked = defaultdict(bool) +already_counted = defaultdict(bool) +has_crossed_line = defaultdict(bool) # --- TAMBAHAN BARU --- +exit_crossed_line = defaultdict(bool) # --- TAMBAHAN BARU: LOGIKA KELUAR --- +track_areas = defaultdict(float) # --- TAMBAHAN BARU: LUAS BBOX --- +track_started_in_truck = defaultdict(bool) +outside_truck_frames = defaultdict(int) +counted_at_frame = {} + +# --- FIX: pending timer terpisah untuk proses MASUK dan KELUAR, berbasis overlap, bukan jarak --- +pending_enter_since = defaultdict(lambda: None) +pending_exit_since = defaultdict(lambda: None) + +track_positions = defaultdict(lambda: deque(maxlen=20)) +lost_tracks = {} +prev_active_track_ids = set() + +# --- STATE LINGKARAN DUPLIKAT STATIS --- +track_initial_truck_pos = {} +track_truck_entry_frame = {} +has_exited_circle = defaultdict(bool) +delay_completed = defaultdict(bool) +blocked_without_counting = defaultdict(bool) +track_is_valid_bag = defaultdict(bool) + +metrics = { + "total_masuk": 0, + "total_keluar": 0 +} + +MAX_REID_DISTANCE = 120 +MAX_REID_FRAMES = 200 + +# Warna +COKLAT = (19, 69, 139) # PENDING - baru masuk, menunggu konfirmasi 0.5s +HIJAU_TERVERIFIKASI = (100, 255, 100) # CONFIRMED - masuk sah +ORANYE_PENDING_KELUAR = (0, 165, 255) # PENDING - sedang menunggu konfirmasi keluar +BIRU_PALET = (255, 100, 100) +MERAH_BEBAS = (100, 100, 255) +ABU_FRAGMENT = (150, 150, 150) + + +# ===================================================================== +# MULTI-THREADED REAL-TIME WEB DASHBOARD & STREAMING (ZERO DEPENDENCY) +# ===================================================================== +import queue + +EXTERNAL_STREAM_URL_REF = "http://192.168.192.96:8888/cam/" +current_fps = 0.0 +save_queue = queue.Queue(maxsize=100) + +DASHBOARD_HTML = "" + +class RTSPStreamReader: + def __init__(self, source_path): + self.source_path = source_path + self.cap = None + + # 1. Pipeline GStreamer H.265 (Jetson NVDEC) + pipeline_h265 = ( + f"rtspsrc location=\"{source_path}\" protocols=tcp latency=0 ! " + "rtph265depay ! h265parse ! nvv4l2decoder ! " + "nvvidconv ! video/x-raw, format=BGRx ! " + "videoconvert ! video/x-raw, format=BGR ! appsink drop=1" + ) + + # 2. Pipeline GStreamer H.264 (Jetson NVDEC) + pipeline_h264 = ( + f"rtspsrc location=\"{source_path}\" protocols=tcp latency=0 ! " + "rtph264depay ! h264parse ! nvv4l2decoder ! " + "nvvidconv ! video/x-raw, format=BGRx ! " + "videoconvert ! video/x-raw, format=BGR ! appsink drop=1" + ) + + # Mencoba membuka dengan GStreamer H.265 + print("[INFO] Mencoba GStreamer H.265 NVDEC di Jetson...") + self.cap = cv2.VideoCapture(pipeline_h265, cv2.CAP_GSTREAMER) + + # Jika gagal, coba H.264 + if self.cap is None or not self.cap.isOpened(): + print("[INFO] GStreamer H.265 gagal, mencoba GStreamer H.264 NVDEC...") + self.cap = cv2.VideoCapture(pipeline_h264, cv2.CAP_GSTREAMER) + + # Fallback ke default CPU OpenCV jika GStreamer tidak terpasang/gagal + if self.cap is None or not self.cap.isOpened(): + print("[WARNING] GStreamer NVDEC gagal dibuka, menggunakan backend default OpenCV (CPU)...") + self.cap = cv2.VideoCapture(source_path) + self.frame = None + self.ret = False + self.new_frame_event = threading.Event() + self.running = True + self.lock = threading.Lock() + self.thread = threading.Thread(target=self._update, daemon=True) + self.thread.start() + + def _update(self): + while self.running: + if not self.cap.isOpened(): + time.sleep(0.1) + continue + ret, frame = self.cap.read() + if not ret: + time.sleep(0.01) + continue + with self.lock: + self.ret = ret + self.frame = frame + self.new_frame_event.set() + time.sleep(0.001) + + def read(self): + if self.new_frame_event.wait(timeout=1.0): + self.new_frame_event.clear() + with self.lock: + if self.frame is None: + return False, None + return self.ret, self.frame.copy() + else: + with self.lock: + if self.frame is None: + return False, None + return self.ret, self.frame.copy() + + def isOpened(self): + return self.cap.isOpened() + + def get(self, propId): + return self.cap.get(propId) + + def release(self): + self.running = False + if self.cap.isOpened(): + self.cap.release() + +# ===================================================================== +# 2.5 UTILITY AKURASI (HSV HISTOGRAM & PERSPECTIVE PROFILE) +# ===================================================================== +def get_visual_features(crop): + """Mengekstrak fitur visual berupa histogram HSV (warna) dan grayscale image (struktur/tekstur) dari crop karung.""" + if crop is None or crop.size == 0: + return None, None + try: + resized = cv2.resize(crop, (64, 64)) + hsv = cv2.cvtColor(resized, cv2.COLOR_BGR2HSV) + # Ekstrak histogram H-S untuk ketahanan terhadap pencahayaan + hist = cv2.calcHist([hsv], [0, 1], None, [16, 16], [0, 180, 0, 256]) + cv2.normalize(hist, hist, 0, 1, cv2.NORM_MINMAX) + + # Fitur tekstur/struktur menggunakan grayscale thumbnail + gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY) + return hist, gray + except Exception as e: + print(f"[ERROR get_visual_features] {e}") + return None, None + +def compare_visual_similarity(feat1, feat2): + """Membandingkan kemiripan visual karung (gabungan korelasi warna HSV 60% dan struktur grayscale NCC 40%).""" + if feat1 is None or feat2 is None: + return 0.0 + hist1, gray1 = feat1 + hist2, gray2 = feat2 + if hist1 is None or hist2 is None or gray1 is None or gray2 is None: + return 0.0 + try: + # Kemiripan warna HSV + color_sim = cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL) + color_sim = max(0.0, color_sim) if not np.isnan(color_sim) else 0.0 + + # Kemiripan tekstur/struktur menggunakan Template Matching Normalized Cross-Correlation (NCC) + res = cv2.matchTemplate(gray1, gray2, cv2.TM_CCOEFF_NORMED) + struct_sim = max(0.0, res[0][0]) if not np.isnan(res[0][0]) else 0.0 + + # Rata-rata tertimbang + return 0.6 * color_sim + 0.4 * struct_sim + except Exception: + return 0.0 + +def get_min_valid_area(cy): + """Menghitung batas luas area minimum secara dinamis berdasarkan perspektif Y.""" + global scale_x, scale_y + top_y = 200 * scale_y + top_area = 8000 * scale_x * scale_y + bot_y = 1080 * scale_y + bot_area = 25000 * scale_x * scale_y + + if cy <= top_y: + return top_area + if cy >= bot_y: + return bot_area + + ratio = (cy - top_y) / (bot_y - top_y) + return top_area + ratio * (bot_area - top_area) +# ===================================================================== + + +def get_zone_name(point): + pt = Point(point) + if poly_palet is not None and not poly_palet.is_empty and poly_palet.contains(pt): + return "PALET" + elif poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(pt): + return "TRUCK" + else: + return "BEBAS" + + +def update_zone_label(track_id, current_zone): + """Update label visual zona (dengan debounce ringan), TIDAK memengaruhi logika counting.""" + track_zone_history[track_id].append(current_zone) + history = list(track_zone_history[track_id]) + if len(history) < DEBOUNCE_FRAMES: + track_confirmed_state[track_id] = current_zone + return + most_frequent_zone = max(set(history), key=history.count) + if history.count(most_frequent_zone) >= (DEBOUNCE_FRAMES - 2): + track_confirmed_state[track_id] = most_frequent_zone + + +# ===================================================================== +# 3. RE-ID: PEMULIHAN ID SETELAH OKLUSI +# ===================================================================== +def check_reid_recovery(new_id, current_centroid, overlap_ratio_now, frame_idx): + global lost_tracks, counted_at_frame, blocked_without_counting, track_is_valid_bag, static_frames, moving_frames, counted_sacks, lost_counted_sacks, blocked_due_to_duplicate + if not lost_tracks: + return False + + closest_old_id = None + min_dist = float('inf') + + pt = Point(current_centroid) + in_truck_zone = poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(pt) + + for old_id, info in lost_tracks.items(): + frame_diff = frame_idx - info['frame_idx'] + if frame_diff > MAX_REID_FRAMES: + continue + # JIKA track lama sudah terhitung, track baru tidak boleh berada di area palet untuk memulihkannya + if info['already_counted'] and poly_palet is not None and not poly_palet.is_empty and poly_palet.contains(pt): + continue + lc = info['last_centroid'] + dist = np.sqrt((current_centroid[0] - lc[0]) ** 2 + (current_centroid[1] - lc[1]) ** 2) + + is_consistent_direction = (current_centroid[1] < lc[1] + (50 * scale_y)) + max_dist = MAX_REID_TRANSIT_DISTANCE * (1.0 + 0.01 * frame_diff) + if (dist < max_dist) and (is_consistent_direction or in_truck_zone): + if dist < min_dist: + min_dist = dist + closest_old_id = old_id + + if closest_old_id is None: + return False + + info = lost_tracks[closest_old_id] + + # JIKA sudah terhitung (already_counted), langsung pulihkan ID tersebut agar tidak terhitung lagi + if info['already_counted']: + already_counted[new_id] = True + is_locked[new_id] = True + pending_enter_since[new_id] = None + pending_exit_since[new_id] = info['pending_exit_since'] + track_zone_history[new_id] = info['zone_history'].copy() + track_positions[new_id] = info['positions'].copy() + has_crossed_line[new_id] = info.get('has_crossed_line', True) + exit_crossed_line[new_id] = info.get('exit_crossed_line', False) + track_areas[new_id] = info.get('box_area', 0.0) + track_started_in_truck[new_id] = info.get('started_in_truck', False) + counted_at_frame[new_id] = info.get('counted_at_frame') + + # Pulihkan state lingkaran + track_initial_truck_pos[new_id] = info.get('initial_truck_pos') + track_truck_entry_frame[new_id] = info.get('truck_entry_frame') + has_exited_circle[new_id] = info.get('has_exited_circle', False) + delay_completed[new_id] = info.get('delay_completed', False) + blocked_without_counting[new_id] = info.get('blocked_without_counting', False) + track_is_valid_bag[new_id] = info.get('is_valid_bag', False) + blocked_due_to_duplicate[new_id] = info.get('blocked_due_to_duplicate', False) + + # Pulihkan state stabilitas + static_frames[new_id] = info.get('static_frames', 0) + moving_frames[new_id] = info.get('moving_frames', 0) + + # Pulihkan posisi terhitung aktif + if closest_old_id in lost_counted_sacks: + val = lost_counted_sacks.pop(closest_old_id) + counted_sacks[new_id] = (val[0], val[1]) + + del lost_tracks[closest_old_id] + return True + + # Logika lama untuk yang belum terhitung (pending masuk dll) + was_counted_or_pending = info['already_counted'] or (info['pending_enter_since'] is not None) + if was_counted_or_pending and overlap_ratio_now < EXIT_OVERLAP_THRESHOLD: + if info['already_counted']: + del lost_tracks[closest_old_id] + return False + + already_counted[new_id] = info['already_counted'] + is_locked[new_id] = info['is_locked'] + pending_enter_since[new_id] = info['pending_enter_since'] + pending_exit_since[new_id] = info['pending_exit_since'] + track_zone_history[new_id] = info['zone_history'].copy() + track_positions[new_id] = info['positions'].copy() + has_crossed_line[new_id] = info.get('has_crossed_line', False) + exit_crossed_line[new_id] = info.get('exit_crossed_line', False) + track_areas[new_id] = info.get('box_area', 0.0) + track_started_in_truck[new_id] = info.get('started_in_truck', False) + counted_at_frame[new_id] = info.get('counted_at_frame') + + # Pulihkan state lingkaran + track_initial_truck_pos[new_id] = info.get('initial_truck_pos') + track_truck_entry_frame[new_id] = info.get('truck_entry_frame') + has_exited_circle[new_id] = info.get('has_exited_circle', False) + delay_completed[new_id] = info.get('delay_completed', False) + blocked_without_counting[new_id] = info.get('blocked_without_counting', False) + track_is_valid_bag[new_id] = info.get('is_valid_bag', False) + blocked_due_to_duplicate[new_id] = info.get('blocked_due_to_duplicate', False) + + # Pulihkan state stabilitas + static_frames[new_id] = info.get('static_frames', 0) + moving_frames[new_id] = info.get('moving_frames', 0) + + del lost_tracks[closest_old_id] + return True + + +# ===================================================================== +# 3.5 FUNGSI ANTI-DOUBLE COUNT (SPASIAL DUPLIKASI) # --- TAMBAHAN BARU --- +# ===================================================================== +def cek_duplikat_karung_locked(new_id, cx, cy, box_area, frame_idx, frame=None, bbox=None): + """ + Mengecek apakah bbox baru muncul di titik yang sangat dekat dengan + karung yang SUDAH DIHITUNG (locked), baik yang sedang aktif maupun yang baru hilang. + Menggunakan visual similarity (HSV Histogram & NCC Grayscale) untuk membedakan penumpukan karung. + """ + pt = Point(cx, cy) + # Karung di palet tidak boleh dideteksi duplikat + if poly_palet is not None and not poly_palet.is_empty and poly_palet.contains(pt): + return False + + # Hanya lakukan duplicate checking jika centroid baru berada di area truk/counting + is_in_truck = poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(pt) + if not is_in_truck: + return False + + # Ekstrak fitur visual untuk deteksi baru + new_feat = None + if frame is not None and bbox is not None: + x1, y1, x2, y2 = bbox + crop = frame[max(0, int(y1)):min(frame.shape[0], int(y2)), max(0, int(x1)):min(frame.shape[1], int(x2))] + new_feat = get_visual_features(crop) + + # 1. Cek dari track yang SEDANG AKTIF dan SUDAH COUNTED + for active_id in prev_active_track_ids: + if active_id != new_id and already_counted.get(active_id, False): + if active_id in track_positions and len(track_positions[active_id]) > 0: + last_cx, last_cy = track_positions[active_id][-1] + dist = np.sqrt((cx - last_cx)**2 + (cy - last_cy)**2) + + # Cek perbandingan luas area box + old_area = track_areas.get(active_id, 0) + if old_area > 0 and box_area > 0: + area_ratio = min(box_area, old_area) / max(box_area, old_area) + else: + area_ratio = 1.0 + + is_similar_size = (dist < 120) or (area_ratio >= 0.40) + if is_similar_size and dist < JARAK_TOLERANSI_DUPLIKAT: + # Lakukan verifikasi visual jika fitur tersedia + old_feat = static_sack_visuals.get(active_id) + if new_feat is not None and old_feat is not None: + sim = compare_visual_similarity(new_feat, old_feat) + if sim > 0.85: + return True + else: + # Fallback jika tidak ada data visual, anggap duplikat secara spasial + return True + + # 2. Cek dari track yang SUDAH HILANG (lost_tracks) + for lost_id, info in lost_tracks.items(): + if info.get('already_counted', False): + frame_diff = frame_idx - info['frame_idx'] + if frame_diff <= TOLERANSI_FRAME_HILANG: + last_cx, last_cy = info['last_centroid'] + dist = np.sqrt((cx - last_cx)**2 + (cy - last_cy)**2) + + # Cek perbandingan luas area box + old_area = info.get('box_area', 0) + if old_area > 0 and box_area > 0: + area_ratio = min(box_area, old_area) / max(box_area, old_area) + else: + area_ratio = 1.0 + + is_similar_size = (dist < 120) or (area_ratio >= 0.40) + if is_similar_size and dist < JARAK_TOLERANSI_DUPLIKAT: + # Lakukan verifikasi visual jika fitur tersedia + old_feat = static_sack_visuals.get(lost_id) + if new_feat is not None and old_feat is not None: + sim = compare_visual_similarity(new_feat, old_feat) + if sim > 0.85: + return True + else: + # Fallback + return True + + return False + + +# ===================================================================== +# 4. LOGIKA MASUK / KELUAR +# ===================================================================== +def update_counting(track_id, overlap_ratio_counting, in_counting_zone, overlap_ratio_truck, frame_idx, required_frames, current_zone, required_exit_frames=None): + global has_crossed_line, exit_crossed_line, already_counted, pending_enter_since, pending_exit_since, is_locked, metrics, counted_at_frame + global has_exited_circle, delay_completed, track_started_in_truck + + if required_exit_frames is None: + required_exit_frames = required_frames + + if not already_counted[track_id]: + # Logika Masuk Baru Berdasarkan Zona: + # - Zona COUNTING: Centroid di area counting, overlap counting >= 70% + # - Zona TRUCK: Centroid di area truck, overlap truck >= 70% + if current_zone == "TRUCK": + # Jika mulai di dalam truk, kita ijinkan delay berjalan meskipun belum cross line + # agar saat keluar lingkaran bisa langsung dihitung jika delay sudah selesai. + is_qualifying_entry = (has_crossed_line[track_id] or track_started_in_truck[track_id]) and (overlap_ratio_truck >= ENTRY_OVERLAP_THRESHOLD) + else: + is_qualifying_entry = False + + if is_qualifying_entry: + if pending_enter_since[track_id] is None: + pending_enter_since[track_id] = frame_idx + else: + elapsed = frame_idx - pending_enter_since[track_id] + if elapsed >= required_frames: + # JIKA masih di dalam lingkaran, jangan dulu counting, "simpan dulu" + if not has_exited_circle[track_id]: + delay_completed[track_id] = True + else: + metrics['total_masuk'] += 1 + already_counted[track_id] = True + is_locked[track_id] = True + pending_enter_since[track_id] = None + counted_at_frame[track_id] = frame_idx + else: + # Jika tidak memenuhi kualifikasi masuk, reset pending timer + pending_enter_since[track_id] = None + else: + pass + + +# ===================================================================== +# 5. PROSES PREDIKSI & VISUALISASI VIDEO +# ===================================================================== +def _filter_sacks_in_roi(detections, roi): + """Keep only sacks whose centroid X falls within the truck ROI.""" + if roi is None: + return [] + return [ + d for d in detections + if roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0) + ] + +def run_prediction(model_path, source_path, + output_json_path="hasil_perhitungan.json", max_frames=None, + inference_stride=2): + global prev_active_track_ids, lost_tracks, metrics, track_positions, counted_at_frame + global track_confirmed_state, already_counted, is_locked, has_crossed_line, exit_crossed_line, track_areas + global pending_enter_since, pending_exit_since, track_started_in_truck, outside_truck_frames + global track_initial_truck_pos, track_truck_entry_frame, has_exited_circle, delay_completed, blocked_without_counting, track_is_valid_bag + global current_fps, INFERENCE_STRIDE, all_counted_sacks_map, last_seen_near_person_frame, blocked_due_to_duplicate + global width, height, CONFIRM_DELAY_SEC, EXIT_CONFIRM_DELAY_SEC + global active_batch_info, system_state + global DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE + + # 1. Silencing YOLO logs + from ultralytics.utils import LOGGER + import logging + LOGGER.setLevel(logging.WARNING) + + INFERENCE_STRIDE = inference_stride + + saver = None + saver_thread = None + save_queue = None + + # Reset lists and dicts + for k in metrics: + metrics[k] = 0 + + # Device + device = 'cuda' if torch.cuda.is_available() else 'cpu' + print(f"[INFO] Device inferensi diset ke: {device}") + + # Reader + is_stream = any(str(source_path).startswith(p) for p in ["rtsp://", "rtmp://", "http://", "https://"]) + if is_stream: + print("[INFO] Membuka RTSP stream menggunakan Threaded GStreamer NVDEC Reader...") + cap = RTSPStreamReader(source_path) + else: + print("[INFO] Membuka file video lokal...") + cap = cv2.VideoCapture(source_path) + + if not cap.isOpened(): + print(f"Error: Gagal membuka video source (RTSP stream/file) di {source_path}") + return + + width = 1280 + height = 720 + fps = cap.get(cv2.CAP_PROP_FPS) + if fps <= 0 or np.isnan(fps): + fps = 25.0 + + print(f"[INFO] Resolusi Asli: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))} @ {fps:.1f} FPS (Diresize ke 1280x720 untuk koordinat tetap)") + + # Initialize components from repo rafan using shared model + print(f"[INFO] Memuat model YOLO gabungan dari: {model_path}") + shared_model = YOLO(model_path) + + # Warm-up model to initialize CUDA/TensorRT execution context and prevent segfaults on tracking + print("[INFO] Melakukan warm-up model YOLO...") + dummy_frame = np.zeros((720, 1280, 3), dtype=np.uint8) + _ = shared_model(dummy_frame, imgsz=640, device=device, verbose=False) + print("[INFO] Warm-up model selesai.") + + truck_detector = TruckDetector(shared_model, 0.45) + tracker = ByteTrackTracker(shared_model, 0.45) + stabilizer = BboxStabilizer( + ema_alpha=0.35, + max_hold_frames=10, + max_height_ratio=1.5, + min_height_ratio=0.70, + ) + + # ================================================================ + # HARDCODED COORDINATES FOR LOCAL (1280x720) + # Truck detector hanya untuk batch lifecycle (deteksi truk datang/pergi) + # Area di bawah ini FIXED, tidak tergantung deteksi truk. + # ================================================================ + + # Calculate scale factors from native 1920x1080 to 1280x720 + scale_x = 1280.0 / 1920.0 + scale_y = 720.0 / 1080.0 + + # 1. Detection Area (4-point Polygon) + detection_poly_pts = [ + [int(574 * scale_x), int(50 * scale_y)], + [int(586 * scale_x), int(1077 * scale_y)], + [int(1418 * scale_x), int(1076 * scale_y)], + [int(1397 * scale_x), int(50 * scale_y)], + ] + detection_polygon = Polygon(detection_poly_pts) + + # 2. Count Line coordinates + static_line_y = int(330 * scale_y) + static_line_x_start = int(577 * scale_x) + static_line_x_end = int(1401 * scale_x) + + # 3. Truck Area (4-point Polygon for presence check) + truck_poly_pts = [ + [int(600 * scale_x), int(385 * scale_y)], + [int(609 * scale_x), int(1076 * scale_y)], + [int(1404 * scale_x), int(1078 * scale_y)], + [int(1381 * scale_x), int(343 * scale_y)], + ] + truck_polygon = Polygon(truck_poly_pts) + + from src.truck_roi import TruckROI + static_roi = TruckROI( + x1=int(600 * scale_x), + y1=int(343 * scale_y), + x2=int(1404 * scale_x), + y2=int(1078 * scale_y), + line_y=static_line_y, + confidence=1.0 + ) + + DUPLICATE_CIRCLE_RADIUS = DUPLICATE_CIRCLE_RADIUS_REF + MIN_VALID_AREA = MIN_VALID_AREA_REF + JARAK_TOLERANSI_DUPLIKAT = JARAK_TOLERANSI_DUPLIKAT_REF + MAX_REID_TRANSIT_DISTANCE = MAX_REID_TRANSIT_DISTANCE_REF + + counter = LineCrossCounter( + line_y=static_line_y, + line_x_start=static_line_x_start, + line_x_end=static_line_x_end, + margin=20, + dedup_radius=float(DUPLICATE_CIRCLE_RADIUS), + ) + batch_mgr = BatchLifecycleManager( + stabilize_seconds=0.0, # Start batch instantly when triggered by crossing + stabilize_threshold_px=9999.0, # Disable displacement threshold check + sack_idle_timeout=10.0, # Waiting state timeout + min_batch_duration=5.0, # Short min duration + truck_gone_tolerance=15.0, # Time to wait (in seconds) after all sacks disappear before closing batch + ) + dashboard = DashboardOverlay() + + global active_batch_info + active_batch_info = None + save_active_batch_state() + + frame_idx = 0 + last_time = time.time() + current_fps = 0.0 + + TRUCK_DET_INTERVAL_IDLE = 5 # Check truck every 5 frames when IDLE + TRUCK_DET_INTERVAL_STABILIZING = 1 # Check truck every frame when STABILIZING + + try: + while cap.isOpened(): + ret, frame = cap.read() + if not ret: + if is_stream: + time.sleep(0.01) + continue + else: + break + + if frame is not None: + frame = cv2.resize(frame, (1280, 720)) + + timestamp = time.time() + frame_idx += 1 + + if max_frames is not None and frame_idx > max_frames: + break + + # Track previous state for transition detection + prev_active = batch_mgr.is_active + prev_state = batch_mgr.state + + # ================================================================ + # STATE-DRIVEN MODEL SWITCHING + # ================================================================ + + tracked_sacks = [] + events = [] + + # Run tracker and stabilizer for sacks on every frame + if INFERENCE_STRIDE <= 1 or frame_idx % INFERENCE_STRIDE == 0 or 'last_raw_tracked_all' not in locals(): + raw_tracked_all = tracker.update(frame, []) + last_raw_tracked_all = raw_tracked_all + else: + raw_tracked_all = last_raw_tracked_all + + # Filter sack detections (confidence >= 0.50) + raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name == "sack" and d.confidence >= 0.50] + # Filter truck detections for batch lifecycle guard (confidence >= 0.45) + raw_tracked_trucks = [d for d in raw_tracked_all if d.class_name == "truck" and d.confidence >= 0.45] + stable = stabilizer.update(raw_tracked_sacks) + + # Filter using Detection Area (4-point Polygon) + stable = [ + d for d in stable + if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0)) + ] + + # Count sacks currently visible in the bottom 70% of truck area (for batch start/end condition) + min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3] + truck_height = max_ty - min_ty + truck_cutoff_y = min_ty + 0.30 * truck_height + + sacks_in_truck_area = 0 + for d in stable: + cx = (d.bbox[0] + d.bbox[2]) / 2.0 + cy = (d.bbox[1] + d.bbox[3]) / 2.0 + if truck_polygon.contains(Point(cx, cy)) and cy >= truck_cutoff_y: + sacks_in_truck_area += 1 + + # Cek apakah truk masih terdeteksi di area truk (untuk batch lifecycle guard) + truck_in_area = any( + truck_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0)) + for d in raw_tracked_trucks + ) + + # Run line crossing counter on every frame + tracked_sacks = _filter_sacks_in_roi(stable, static_roi) + events = counter.update(tracked_sacks) + has_crossing = len(events) > 0 + + # --- Sack-driven Batch Lifecycle Transitions --- + if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"): + # Trigger batch start ONLY when a sack actually crosses the line + batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp) + + if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"): + # Toleransi dinamis berdasarkan jumlah karung terhitung + # (toleransi ini hanya aktif setelah truk TIDAK terdeteksi di kamera) + current_count = counter.loading_count + if current_count < 20: + batch_mgr._truck_gone_tolerance = 60.0 # 60 detik jika < 20 karung + elif current_count >= 40: + batch_mgr._truck_gone_tolerance = 30.0 # 30 detik jika >= 40 karung + else: + batch_mgr._truck_gone_tolerance = 45.0 # 45 detik jika di antara 20 - 39 + + batch_mgr.update_sacks( + has_crossing_event=has_crossing, + sacks_in_area_count=sacks_in_truck_area, + timestamp=timestamp, + loading_count=counter.loading_count, + unloading_count=counter.unloading_count, + ) + + # If waiting, check if sacks are completely gone to finalize batch + if batch_mgr.state == "WAITING_FOR_ACTIVITY": + # Batch tetap terbuka selama truk ATAU karung masih terdeteksi di area + # Ini mencegah batch ditutup prematur saat karung di blind spot kamera + activity_detected = sacks_in_truck_area > 0 or truck_in_area + batch_mgr.update_truck(activity_detected, None, timestamp) + + # Process crossing events + for ev in events: + print(f"[KARUNG] Karung #{ev['track_id']} masuk.") + print(f"[TOTAL] Total karung saat ini: {counter.loading_count}.") + + if 'cx' in ev and 'cy' in ev: + counted_sack_positions.append((ev['cx'], ev['cy'], time.time(), ev['track_id'])) + + if active_batch_info is not None: + active_batch_info["count"] = counter.loading_count + active_batch_info["last_detection_time"] = datetime.now().isoformat() + save_active_batch_state() + + # ================================================================ + # BATCH TRANSITION HANDLING + # ================================================================ + + # ROI and counting line are always visible + roi = static_roi + + # Batch just started (STABILIZING -> COUNTING) + if batch_mgr.is_active and not prev_active: + counting_date = get_counting_date() + last_batch = get_last_batch_info(counting_date) + + # Disable batch resume/merge logic. Every session is a brand new batch. + should_resume = False + + if should_resume: + batch_num = last_batch["batch_number"] + prev_count = last_batch["count"] + start_iso = last_batch["start_time"] + + # Set the counter's starting count + counter._loading_count = prev_count + counter._unloading_count = 0 # Assuming loading session + + # Resume the batch in the lifecycle manager + try: + start_dt = datetime.fromisoformat(start_iso) + start_ts = start_dt.timestamp() + except Exception: + start_ts = timestamp + + batch_mgr.resume_batch( + batch_id=batch_num, + start_time=start_ts, + loading_count=prev_count, + unloading_count=0 + ) + + active_batch_info = { + "counting_date": counting_date, + "batch_number": batch_num, + "count": prev_count, + "start_time": start_iso, + "last_detection_time": datetime.now().isoformat() + } + save_active_batch_state() + print(f"[BATCH] Melanjutkan sesi batch #{batch_num} (selisih waktu: {gap_seconds:.1f}s < {BATCH_MERGE_THRESHOLD_SECONDS}s). Mulai dari {prev_count} karung.") + else: + batch_num = get_next_batch_number(counting_date) + now_iso = datetime.now().isoformat() + active_batch_info = { + "counting_date": counting_date, + "batch_number": batch_num, + "count": 0, + "start_time": now_iso, + "last_detection_time": now_iso + } + save_active_batch_state() + print(f"[BATCH] Sesi batch #{batch_num} dimulai.") + + system_state = STATE_COUNTING_SACKS + + # Batch just ended (WAITING -> IDLE, truck left) + elif not batch_mgr.is_active and prev_active: + final_count = counter.loading_count + start_iso = active_batch_info["start_time"] if active_batch_info else datetime.now().isoformat() + end_iso = datetime.now().isoformat() + batch_num = active_batch_info["batch_number"] if active_batch_info else 0 + finalize_batch(final_count, start_iso, end_iso) + print(f"[BATCH] Truk pergi. Sesi batch #{batch_num} selesai. Total karung: {final_count}.") + system_state = STATE_WAITING_FOR_TRUCK + + # Reset counter and trackers for next batch + counter.reset() + stabilizer.reset() + + # Update system_state for display + if batch_mgr.state == "TRUCK_STABILIZING" and prev_state != "TRUCK_STABILIZING": + system_state = "TRUCK_STABILIZING" + elif batch_mgr.state == "WAITING_FOR_ACTIVITY" and prev_state != "WAITING_FOR_ACTIVITY": + system_state = "WAITING_FOR_ACTIVITY" + elif batch_mgr.state == "COUNTING_SACKS" and prev_state == "WAITING_FOR_ACTIVITY": + system_state = STATE_COUNTING_SACKS # Resumed from waiting + + # Sync counts to metrics so APIs get correct results + metrics['total_masuk'] = counter.loading_count + metrics['total_keluar'] = counter.unloading_count + + # 4. Draw Dashboard visualization overlay + viz = dashboard.draw( + frame=frame, + detections=stable if (batch_mgr.is_active and SHOW_ALL_BBOXES and 'stable' in dir()) else tracked_sacks, + roi=roi, + loading_count=counter.loading_count, + unloading_count=counter.unloading_count, + batch_id=batch_mgr.current_batch_id, + history=batch_mgr.history, + system_state=batch_mgr.state, + batch_duration=batch_mgr.batch_duration, + idle_timer=batch_mgr.time_since_last_sack_activity, + stabilize_progress=batch_mgr.stabilize_progress, + waiting_duration=batch_mgr.waiting_duration, + ) + + # Draw active Duplicate Radius Circles on viz frame (terkini 3.0 detik) + if 'counted_sack_positions' in globals() and counted_sack_positions: + rad_vis = DUPLICATE_CIRCLE_RADIUS if ('DUPLICATE_CIRCLE_RADIUS' in globals() and DUPLICATE_CIRCLE_RADIUS > 0) else 60 + now_t = time.time() + # Clean up expired entries in-place to avoid memory accumulation + counted_sack_positions[:] = [p for p in counted_sack_positions if len(p) >= 3 and (now_t - p[2]) <= 3.0] + for pos_item in counted_sack_positions: + px, py = pos_item[0], pos_item[1] + tid = pos_item[3] if len(pos_item) > 3 else 0 + cv2.circle(viz, (int(px), int(py)), int(rad_vis), (0, 255, 255), 2, lineType=cv2.LINE_AA) + cv2.circle(viz, (int(px), int(py)), 4, (0, 255, 0), -1) + cv2.putText(viz, f"DEDUP #{tid}", (int(px) - 25, max(15, int(py) - int(rad_vis) - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1) + + cv2.putText(viz, f"RADIUS DEDUP: {rad_vis}px", (viz.shape[1] - 270, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2) + + # Write live frame to RAM disk for Flask port 5000 + if frame_idx % 2 == 0: + write_live_frame(viz) + + # Compute FPS every 25 frames + if frame_idx % 25 == 0: + elapsed = time.time() - last_time + current_fps = 25.0 / elapsed if elapsed > 0 else 0 + last_time = time.time() + try: + status_file = os.getenv('LIVE_STATUS_FILE', '/dev/shm/jetson-counter/live_status.json' if os.name != 'nt' else 'd:/Belajar/menghitung karung/live_status.json') + os.makedirs(os.path.dirname(status_file), exist_ok=True) + with open(status_file, 'w') as f: + json.dump({"fps": round(current_fps, 1)}, f) + except Exception: + pass + + finally: + # Membersihkan dan menutup semua resource + if saver_thread is not None: + save_queue.put(None) + saver_thread.join(timeout=2.0) + if saver is not None: + saver.release() + cap.release() + cv2.destroyAllWindows() + + net_count = metrics['total_masuk'] - metrics['total_keluar'] + final_results = { + "total_masuk_truck": metrics['total_masuk'], + "total_keluar_truck": metrics['total_keluar'], + "net_karung_di_truck": net_count + } + + with open(output_json_path, 'w') as f: + json.dump(final_results, f, indent=4) + print("\n" + "=" * 50) + print("PROSES SELESAI!") + print(final_results) + + +if __name__ == "__main__": + MODEL_FILE = COMBINED_MODEL_PATH + + # Default to local sample video 0727.mp4 on Windows + SOURCE_INPUT = "0727s7.mp4" if (os.path.exists("0727s7.mp4") and os.name == 'nt') else "rtsp://192.168.192.96:8554/cam" + OUTPUT_JSON = "hasil_perhitungan.json" + + try: + run_prediction( + model_path=MODEL_FILE, + source_path=SOURCE_INPUT, + output_json_path=OUTPUT_JSON, + max_frames=None + ) + except KeyboardInterrupt: + print("\n" + "=" * 50) + print("[INFO] Program dihentikan secara manual (Ctrl+C).") + print("Membersihkan resource dan menyimpan hasil perhitungan terakhir...") + + # Simpan hasil perhitungan parsial sebelum keluar + final_results = { + "total_masuk_truck": metrics['total_masuk'], + "total_keluar_truck": metrics['total_keluar'], + "net_karung_di_truck": metrics['total_masuk'] - metrics['total_keluar'] + } + with open(OUTPUT_JSON, 'w') as f: + json.dump(final_results, f, indent=4) + + print("Hasil akhir yang disimpan:") + print(final_results) + print("=" * 50) \ No newline at end of file diff --git a/algoritma-batch/src/counting.py b/algoritma-batch/src/counting.py index ff6ac33..c6ff62f 100644 --- a/algoritma-batch/src/counting.py +++ b/algoritma-batch/src/counting.py @@ -10,14 +10,43 @@ Counting logic (Low-FPS robust): Loading: track had state ABOVE, now detected BELOW the zone Unloading: track had state BELOW, now detected ABOVE the zone (if needed) - 3-Layer deduplication: + Deduplication / recovery layers: Layer 1: State guard — must have been ABOVE before counting - Layer 2: Spatial dedup radius — same position can't trigger twice - Layer 3: Track ID — one track_id can only be counted once per direction + Layer 2: Entry circle — must have travelled `entry_travel_min` from first + appearance, so a ghost bbox that blinks into existence next to the + line cannot count + Layer 3: Track hand-off — when a track dies, its state is parked at its + predicted position; a new id born nearby inherits it + Layer 4: Track ID — one track_id can only be counted once per direction This approach is immune to low FPS because it doesn't require detecting the exact frame of crossing. It only needs the track to have been seen ABOVE the line at ANY point in its lifetime. + +Why hand-off replaced the spatial dedup circles +----------------------------------------------- +The circles were meant to stop one physical sack being counted twice under two +track ids. They did not: a blocked track simply retried on the next frame and +counted as soon as it drifted out of the circle, late and at a displaced +position, which then seeded the next circle in the wrong place. Worse, they +could permanently swallow a genuine second sack that crossed the same spot +within the circle's lifetime — an under-count that fires hardest when the line +is busiest. + +Hand-off addresses the real problem. An id switch now *inherits* the previous +track's history, so the same sack keeps its "already counted" verdict (no double +count) and a sack occluded above the line keeps its "was above" evidence (no +lost count). The circles are redundant once that works, and are off by default — +`spatial_dedup=True` restores the old behaviour if a camera needs it. + +`handoff_radius` is the parameter that now matters most. The default of 100 is +the value tuned against the real camera. Treat it as provisional: these frames +hold ~44 sacks, so a newborn track is nearly always close to one that just +vanished, and at 100 px roughly 84% of tracks inherit — loose enough that a +wrong adoption is plausible. Wrong in one direction suppresses a real sack, +wrong in the other invents a count. Settle it against a clip with a +hand-counted total; the per-track trace written by the harness says which tracks +inherited and whether they counted. """ from __future__ import annotations @@ -26,6 +55,18 @@ import time from src.interfaces import Detection +MAX_SPEED_PX_S = 1500.0 +"""Ceiling on the velocity used to project a hand-off token forward. + +Velocity is measured from wall-clock time between two observations, so two +frames delivered back to back — a decoder hiccup, or the threaded reader handing +over a backlog — divide a normal 30 px step by a near-zero interval and produce a +speed of millions of px/s. The projection then lands off-screen and the hand-off +silently stops working, which is worse than not projecting at all.""" + +MAX_PROJECTION_S = 0.5 +"""And never extrapolate further than this, however long the token has sat.""" + class LineCrossCounter: """Counts sacks crossing a horizontal zone using y1 (top edge). @@ -44,14 +85,29 @@ class LineCrossCounter: line_y: int, line_x_start: int, line_x_end: int, - margin: int = 20, + margin: int = 5, dedup_radius: float = 60.0, + entry_travel_min: float | None = None, + handoff_radius: float = 100.0, + handoff_ttl: float = 0.8, + track_ttl: float = 3.0, + unload_confirm_frames: int = 3, + spatial_dedup: bool = False, ) -> None: self._line_y = line_y self._line_x_start = line_x_start self._line_x_end = line_x_end self._margin = margin self._dedup_radius = dedup_radius + # Ghost rejection and spatial dedup want opposite tuning — one wants to + # be large, the other small — so they no longer share a number. + self._entry_travel_min = ( + dedup_radius if entry_travel_min is None else entry_travel_min) + self._handoff_radius = handoff_radius + self._handoff_ttl = handoff_ttl + self._track_ttl = track_ttl + self._unload_confirm = max(1, unload_confirm_frames) + self._spatial_dedup = spatial_dedup self._loading_count = 0 self._unloading_count = 0 @@ -65,9 +121,25 @@ class LineCrossCounter: self._counted: dict[int, set[str]] = {} # track_id -> initial coordinates (cx, y1) when first tracked self._entry_points: dict[int, tuple[float, float]] = {} - # list of active deduplication circles + # track_id -> whether this track has exited its entry circle + self._has_exited_entry_circle: dict[int, bool] = {} + # track_id -> consecutive frames observed "above" (unloading needs a + # sustained signal; a worker lifting a sack 25px must not cancel a count) + self._above_streak: dict[int, int] = {} + # track_id -> (cx, y1, seen_at) and velocity for hand-off prediction + self._last_pos: dict[int, tuple[float, float, float]] = {} + self._velocity: dict[int, tuple[float, float]] = {} + # parked state from tracks that vanished, keyed by nothing — matched + # geometrically against newborn ids + self._handoff: list[dict] = [] + # list of active deduplication circles (legacy, off by default) self._dedup_circles: list[dict] = [] + # Per-track diagnostics, drained by the caller. This is what tells you + # whether a miss was the model, the tracker or this file. + self._trace: dict[int, dict] = {} + self._finished: list[dict] = [] + @property def entry_points(self) -> dict[int, tuple[float, float]]: return self._entry_points @@ -100,25 +172,78 @@ class LineCrossCounter: def line_x_end(self, value: int) -> None: self._line_x_end = value - def update(self, detections: list[Detection]) -> list[dict]: - """Process detections, return list of crossing events. + # -- hand-off ---------------------------------------------------------- - Hybrid approach: - - Tracks zone state per frame (above/below/in-band) - - BUT uses accumulated history (has_been_above) for counting decision - - A track counts as "loading" when: - 1. It has been seen ABOVE the line at any previous point - 2. Its current y1 is now BELOW the line - 3. It hasn't been counted for loading yet - 4. It passes spatial dedup check + def _park(self, tid: int, now_t: float) -> None: + """Park a vanished track's history so a new id can inherit it.""" + cx, y1, seen_at = self._last_pos[tid] + vx, vy = self._velocity.get(tid, (0.0, 0.0)) + self._handoff.append({ + "x": cx, "y": y1, "vx": vx, "vy": vy, + "seen_at": seen_at, "parked_at": now_t, "from": tid, + "above": self._has_been_above.get(tid, False), + "below": self._has_been_below.get(tid, False), + "counted": set(self._counted.get(tid, set())), + "exited": self._has_exited_entry_circle.get(tid, False), + }) + + def _claim(self, cx: float, y1: float, now_t: float) -> dict | None: + """Best parked state for a track just born at (cx, y1), if any. + + The token is projected forward at the dead track's last velocity: a sack + occluded above the line keeps falling while nobody can see it, so its + last observed position is not where it reappears. """ + best, best_dist = None, None + for token in self._handoff: + dt = min(max(0.0, now_t - token["seen_at"]), MAX_PROJECTION_S) + px = token["x"] + token["vx"] * dt + py = token["y"] + token["vy"] * dt + dist = ((cx - px) ** 2 + (y1 - py) ** 2) ** 0.5 + if dist <= self._handoff_radius and (best_dist is None or dist < best_dist): + best, best_dist = token, dist + if best is not None: + self._handoff.remove(best) + return best + + def _retire(self, tid: int) -> None: + record = self._trace.pop(tid, None) + if record is not None: + record["counted"] = sorted(self._counted.get(tid, set())) + record["ever_above"] = self._has_been_above.get(tid, False) + record["ever_below"] = self._has_been_below.get(tid, False) + record["exited_entry_circle"] = self._has_exited_entry_circle.get(tid, False) + record["verdict"] = _verdict(record) + self._finished.append(record) + for store in (self._state, self._has_been_above, self._has_been_below, + self._counted, self._entry_points, self._has_exited_entry_circle, + self._above_streak, self._last_pos, self._velocity): + store.pop(tid, None) + + def drain_traces(self) -> list[dict]: + """Hand over every finished track's trajectory summary, once.""" + out, self._finished = self._finished, [] + return out + + def update(self, detections: list[Detection]) -> list[dict]: + """Process detections, return list of crossing events.""" now_t = time.time() events: list[dict] = [] upper = self._line_y - self._margin lower = self._line_y + self._margin - # Clean up expired dedup circles (older than 3.0 seconds) self._dedup_circles = [c for c in self._dedup_circles if (now_t - c["time"]) <= 3.0] + self._handoff = [t for t in self._handoff + if (now_t - t["parked_at"]) <= self._handoff_ttl] + + # Withdraw tokens for every track that is alive this frame, before any + # claim is considered — otherwise a newborn processed earlier in the + # loop could inherit from a track that returns later in the same frame. + # Leaving these claimable let an unrelated newborn adopt the history of + # a track that never died: 96 of 111 tracks "inherited" on a real clip. + seen: set[int] = {d.track_id for d in detections if d.track_id is not None} + if self._handoff: + self._handoff = [t for t in self._handoff if t["from"] not in seen] for det in detections: if det.track_id is None: @@ -130,6 +255,48 @@ class LineCrossCounter: if tid not in self._entry_points: self._entry_points[tid] = (cx, y1) + self._has_exited_entry_circle[tid] = False + self._trace[tid] = { + "track_id": tid, "born_at": now_t, "frames": 0, + "entry": [round(cx, 1), round(y1, 1)], + "y1_min": y1, "y1_max": y1, "inherited_from": None, + } + # Layer 3: adopt a vanished track's history if this is the same + # physical sack under a new id. + token = self._claim(cx, y1, now_t) + if token is not None: + self._has_been_above[tid] = token["above"] + self._has_been_below[tid] = token["below"] + self._counted[tid] = set(token["counted"]) + self._has_exited_entry_circle[tid] = token["exited"] + self._trace[tid]["inherited_from"] = token["from"] + + # Velocity in px/sec, for predicting where an occluded track went. + if tid in self._last_pos: + px, py, pt = self._last_pos[tid] + dt = now_t - pt + if dt > 1e-3: + vx, vy = (cx - px) / dt, (y1 - py) / dt + speed = (vx * vx + vy * vy) ** 0.5 + if speed > MAX_SPEED_PX_S: + scale = MAX_SPEED_PX_S / speed + vx, vy = vx * scale, vy * scale + self._velocity[tid] = (vx, vy) + self._last_pos[tid] = (cx, y1, now_t) + + record = self._trace.get(tid) + if record is not None: + record["frames"] += 1 + record["y1_min"] = min(record["y1_min"], y1) + record["y1_max"] = max(record["y1_max"], y1) + record["last"] = [round(cx, 1), round(y1, 1)] + + # Layer 2: proven movement, not a ghost that blinked into existence. + if not self._has_exited_entry_circle.get(tid, False): + entry_cx, entry_y1 = self._entry_points[tid] + dist_from_entry = ((cx - entry_cx) ** 2 + (y1 - entry_y1) ** 2) ** 0.5 + if dist_from_entry > self._entry_travel_min: + self._has_exited_entry_circle[tid] = True # Skip if centroid X outside counting bounds if cx < self._line_x_start or cx > self._line_x_end: @@ -145,71 +312,82 @@ class LineCrossCounter: else: new_state = self._state.get(tid) # in band: hold - prev_state = self._state.get(tid) self._state[tid] = new_state # Track zone history — CRITICAL for low-FPS robustness - # Once a track has been seen above/below, it stays recorded forever if new_state == "above": self._has_been_above[tid] = True - elif new_state == "below": - self._has_been_below[tid] = True + self._above_streak[tid] = self._above_streak.get(tid, 0) + 1 + else: + self._above_streak[tid] = 0 + if new_state == "below": + self._has_been_below[tid] = True + + has_exited = self._has_exited_entry_circle.get(tid, False) - # --- HYBRID COUNTING LOGIC --- - # Loading: track was EVER above, NOW below (entered truck from top) - # This works even if the track jumped over the line between frames is_loading = ( new_state == "below" and self._has_been_above.get(tid, False) + and has_exited and "loading" not in counted_dirs ) - # Unloading: track was EVER below, NOW above (left truck) + # Unloading is deliberately harder to trigger than loading. A sack + # being repositioned by hand drifts above the band for a frame or + # two, and that used to cancel a real count outright (net 0 for a + # sack that is sitting in the truck). is_unloading = ( new_state == "above" and self._has_been_below.get(tid, False) + and has_exited + and self._above_streak.get(tid, 0) >= self._unload_confirm and "unloading" not in counted_dirs ) - if is_loading or is_unloading: - # Check spatial distance against all active dedup circles - is_duplicate = False - for circle in self._dedup_circles: - dist = ((cx - circle["x"]) ** 2 + (y1 - circle["y"]) ** 2) ** 0.5 - if dist <= self._dedup_radius: - is_duplicate = True - break + if not (is_loading or is_unloading): + continue - if is_duplicate: + if self._spatial_dedup: + duplicate = any( + ((cx - c["x"]) ** 2 + (y1 - c["y"]) ** 2) ** 0.5 <= self._dedup_radius + for c in self._dedup_circles + ) + if duplicate: + # Decide once. Retrying every frame turned this guard into a + # delay: the track counted anyway as soon as it drifted out. + counted_dirs.add("loading" if is_loading else "unloading") + if record is not None: + record["suppressed"] = True continue + self._dedup_circles.append( + {"x": cx, "y": y1, "time": now_t, "track_id": tid}) - # Add this coordinate to the active dedup circles - self._dedup_circles.append({ - "x": cx, - "y": y1, - "time": now_t, - "track_id": tid - }) + direction = "loading" if is_loading else "unloading" + if is_loading: + self._loading_count += 1 + else: + self._unloading_count += 1 + # The verdict is the track's *last* direction, not a permanent + # tally. A sack that is genuinely taken back out and loaded again + # has to be able to count again, or net drifts below the truth by + # one for every such sack. Re-counting a direction still costs a + # full opposite crossing (and unloading needs a sustained one), so + # this cannot be reached by jitter. + counted_dirs.discard("unloading" if is_loading else "loading") + counted_dirs.add(direction) + events.append({"track_id": tid, "direction": direction, "cx": cx, "cy": y1}) - if is_loading: - self._loading_count += 1 - counted_dirs.add("loading") - events.append({ - "track_id": tid, - "direction": "loading", - "cx": cx, - "cy": y1 - }) - - elif is_unloading: - self._unloading_count += 1 - counted_dirs.add("unloading") - events.append({ - "track_id": tid, - "direction": "unloading", - "cx": cx, - "cy": y1 - }) + # Tracks missing this frame: park their state immediately so a new id + # arriving next frame can inherit it, and retire them once they are + # clearly gone. + for tid in list(self._last_pos.keys()): + if tid in seen: + continue + gone_for = now_t - self._last_pos[tid][2] + if not any(t["from"] == tid for t in self._handoff): + self._park(tid, now_t) + if gone_for > self._track_ttl: + self._retire(tid) return events @@ -229,9 +407,27 @@ class LineCrossCounter: """Reset all counters (new batch).""" self._loading_count = 0 self._unloading_count = 0 - self._state.clear() - self._has_been_above.clear() - self._has_been_below.clear() - self._counted.clear() - self._entry_points.clear() + for store in (self._state, self._has_been_above, self._has_been_below, + self._counted, self._entry_points, self._has_exited_entry_circle, + self._above_streak, self._last_pos, self._velocity, self._trace): + store.clear() + self._handoff.clear() self._dedup_circles.clear() + self._finished.clear() + + +def _verdict(record: dict) -> str: + """Why this track did or did not count — the bucket a miss belongs to.""" + if record["counted"]: + return "counted:" + ",".join(record["counted"]) + if record.get("suppressed"): + return "suppressed_by_dedup" + if not record["ever_above"] and not record["ever_below"]: + return "never_left_band" + if not record["ever_above"]: + return "born_below_line" # tracker lost it above → lost count + if not record["ever_below"]: + return "never_reached_below" # still in flight, or track died early + if not record["exited_entry_circle"]: + return "blocked_by_entry_circle" + return "uncounted_unknown" diff --git a/algoritma-batch/src/h264_converter.py b/algoritma-batch/src/h264_converter.py new file mode 100644 index 0000000..2d3ed09 --- /dev/null +++ b/algoritma-batch/src/h264_converter.py @@ -0,0 +1,39 @@ +import os +import subprocess +import threading + + +def post_process_h264(video_path: str, log_fn=print) -> None: + """Asynchronously convert recorded mp4v video to H.264 / AAC MP4 with faststart. + + When OpenCV finishes recording a batch video with 'mp4v', this function converts + the file in-place to H.264 so HTML5 web browsers can stream and preview it natively. + """ + if not video_path or not os.path.isfile(video_path): + return + + def _worker(): + tmp_path = video_path.replace(".mp4", "_h264.mp4") if video_path.endswith(".mp4") else video_path + "_h264.mp4" + try: + cmd = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-i", video_path, + "-c:v", "libx264", "-crf", "18", "-preset", "fast", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", + "-c:a", "aac", "-f", "mp4", + tmp_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode == 0 and os.path.isfile(tmp_path): + os.replace(tmp_path, video_path) + log_fn(f"H.264 AUTO-CONVERT DONE -> {video_path}") + else: + if os.path.isfile(tmp_path): + os.remove(tmp_path) + log_fn(f"H.264 AUTO-CONVERT FAILED -> {result.stderr}") + except Exception as exc: + if os.path.isfile(tmp_path): + os.remove(tmp_path) + log_fn(f"H.264 AUTO-CONVERT ERROR -> {exc}") + + threading.Thread(target=_worker, daemon=True).start() diff --git a/algoritma-batch/test_batch_logic.py b/algoritma-batch/test_batch_logic.py new file mode 100644 index 0000000..9dc3b16 --- /dev/null +++ b/algoritma-batch/test_batch_logic.py @@ -0,0 +1,685 @@ +import os +import cv2 +import numpy as np +import time +import json +import sqlite3 +from datetime import datetime, timedelta +from shapely.geometry import Point, Polygon +import torch +from ultralytics import YOLO + +# Import repo rafan modules +from src.tracking import ByteTrackTracker +from src.stabilizer import BboxStabilizer +from src.truck_roi import TruckROI +from src.counting import LineCrossCounter +from src.batch import BatchLifecycleManager, BatchRecord + +# ===================================================================== +# 1. KONFIGURASI PENGUJIAN (TEST CONFIGURATION) +# ===================================================================== +# Aktifkan MODE CEPAT agar Anda tidak perlu menunggu 5 menit untuk pengujian. +# Jika True: Timeout dipercepat agar transisi status bisa dipantau secara instan. +# Jika False: Menggunakan parameter waktu rill yang sama seperti di production. +FAST_TEST_MODE = True + +# Lokasi File lokal di folder "D:\untuk asus\algoritma batch" +MODEL_PATH = r"D:\untuk asus\algoritma batch\v1-best.pt" +VIDEO_PATH = r"0727.mp4" +DB_PATH = r"D:\untuk asus\algoritma batch\batch_test.db" +STATE_FILE = r"D:\untuk asus\algoritma batch\current_batch_test.json" +ARCHIVE_OUTPUT = r"D:\untuk asus\algoritma batch\archive_output" + +CAMERA_NAME = "CC1" +OBJECT_LABEL = "karung-pakan" +DAILY_CUTOFF_TIME = "00:00" + +# --- Parameter State Machine --- +# STRATEGI AKHIR BATCH: Gabungan deteksi TRUK + KARUNG +# Batch berakhir HANYA jika TIDAK ADA truk DAN TIDAK ADA karung terdeteksi +# selama TRUCK_GONE_TOLERANCE detik. Ini mengatasi blind spot CCTV: +# - Truk kapasitas besar (700 karung) bisa menyimpan karung di area blind spot +# - Selama truk masih terlihat, batch tetap terbuka meskipun karung tidak terlihat +# - Batch baru berakhir saat truk benar-benar pergi (keluar dari frame) +SACK_IDLE_TIMEOUT = 5.0 # Jeda sebelum masuk WAITING_FOR_ACTIVITY +MIN_BATCH_DURATION = 2.0 # Durasi minimal batch +TRUCK_GONE_TOLERANCE = 5.0 # Toleransi 5 detik setelah truk+karung hilang +print(f"[INFO] Strategi akhir batch: Truk+Karung gabungan, toleransi {TRUCK_GONE_TOLERANCE}s") + + +# ===================================================================== +# 2. INISIALISASI DATABASE & STATE BATCH +# ===================================================================== +active_batch_info = None + +def get_counting_date(dt=None): + if dt is None: + dt = datetime.now() + try: + cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%H:%M").time() + except Exception: + cutoff = datetime.strptime("20:00", "%H:%M").time() + + if cutoff.hour == 0 and cutoff.minute == 0: + return dt.date().isoformat() + + if dt.time() < cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + + +def init_db(): + try: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + batch_number INTEGER NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + count INTEGER NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, batch_number, camera_name, object_label) + ) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS daily_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + total_count INTEGER NOT NULL DEFAULT 0, + total_batches INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, camera_name, object_label) + ) + """) + conn.commit() + conn.close() + print(f"[DB Info] Database SQLite siap di: {DB_PATH}") + except Exception as e: + print(f"[DB Error] Gagal inisialisasi database: {e}") + + +def get_next_batch_number(counting_date): + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute(""" + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) + row = cur.fetchone() + conn.close() + return row[0] + 1 + except Exception as e: + print(f"[DB Error] Gagal mendapatkan batch_number: {e}") + return 1 + + +def save_active_batch_state(): + global active_batch_info + if active_batch_info is None: + try: + if os.path.exists(STATE_FILE): + os.remove(STATE_FILE) + except Exception: + pass + return + try: + with open(STATE_FILE, 'w', encoding='utf-8') as f: + json.dump(active_batch_info, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}") + + +def finalize_batch(final_count, start_time_iso, end_time_iso): + global active_batch_info + if active_batch_info is None: + return + if final_count == 0: + print(f"\n[BATCH] Batch #{active_batch_info.get('batch_number', 0)} dengan hitungan 0 diabaikan (tidak disimpan ke database).") + active_batch_info = None + save_active_batch_state() + return + + counting_date = active_batch_info["counting_date"] + batch_num = active_batch_info["batch_number"] + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + # 1. Simpan data batch + cur.execute(""" + INSERT OR REPLACE INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso)) + + # 2. Update ringkasan harian + cur.execute(""" + SELECT SUM(count), COUNT(id) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) + sum_row = cur.fetchone() + tot_count = sum_row[0] if sum_row[0] is not None else 0 + tot_batches = sum_row[1] if sum_row[1] is not None else 0 + + cur.execute(""" + INSERT OR REPLACE INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches)) + + conn.commit() + conn.close() + print(f"\n[DB Info] Batch #{batch_num} sukses disimpan ke DB. Total karung: {final_count}") + except Exception as e: + print(f"[DB Error] Gagal menyimpan batch: {e}") + + active_batch_info = None + save_active_batch_state() + + +# ===================================================================== +# 3. FUNGSI GAMBAR VISUALISASI HUD & POLIGON +# ===================================================================== +def draw_visualization(frame, stable_sacks, roi, counter, batch_mgr, sacks_in_truck, current_tolerance, trucks=None): + h, w = frame.shape[:2] + out = frame.copy() + + # 1. Warna Status + state_colors = { + "IDLE": (0, 165, 255), # Oranye + "TRUCK_STABILIZING": (0, 230, 255), # Kuning + "COUNTING_SACKS": (0, 200, 0), # Hijau + "WAITING_FOR_ACTIVITY": (255, 200, 100) # Biru Muda + } + state_labels = { + "IDLE": "WAITING FOR FIRST SACK CROSSING", + "TRUCK_STABILIZING": "STABILIZING SESSION...", + "COUNTING_SACKS": "BATCH RUNNING - COUNTING ACTIVE", + "WAITING_FOR_ACTIVITY": "PAUSED - WAITING FOR SACKS OR DEPARTURE" + } + + current_state = batch_mgr.state + lbl_color = state_colors.get(current_state, (140, 140, 140)) + lbl_text = state_labels.get(current_state, "UNKNOWN") + + # 2. Gambar Zona Deteksi Sacks (Detection Area) + scale_x = 1280.0 / 1920.0 + scale_y = 720.0 / 1080.0 + + detection_poly_pts = np.array([ + [int(574 * scale_x), int(50 * scale_y)], + [int(586 * scale_x), int(1077 * scale_y)], + [int(1418 * scale_x), int(1076 * scale_y)], + [int(1397 * scale_x), int(50 * scale_y)], + ], dtype=np.int32) + cv2.polylines(out, [detection_poly_pts], True, (255, 0, 255), 1, lineType=cv2.LINE_AA) + + # 3. Gambar ZONA_TRUCK (Tempat monitor sacks) + truck_poly_pts = np.array([ + [int(600 * scale_x), int(385 * scale_y)], + [int(609 * scale_x), int(1076 * scale_y)], + [int(1404 * scale_x), int(1078 * scale_y)], + [int(1381 * scale_x), int(343 * scale_y)], + ], dtype=np.int32) + truck_color = (0, 255, 0) if current_state == "COUNTING_SACKS" else (0, 140, 255) + cv2.polylines(out, [truck_poly_pts], True, truck_color, 2, lineType=cv2.LINE_AA) + + # Isian Transparan Zona Truck + overlay = out.copy() + cv2.fillPoly(overlay, [truck_poly_pts], truck_color) + cv2.addWeighted(overlay, 0.08, out, 0.92, 0, out) + + # Label Zona Truk + cv2.putText(out, "ZONA TRUK (PRESENCE MONITOR)", (truck_poly_pts[0][0] + 10, truck_poly_pts[0][1] - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, truck_color, 1, cv2.LINE_AA) + + # 4. Gambar Line Crossing Zone + if roi is not None: + y = roi.line_y + cv2.line(out, (roi.x1, y), (roi.x2, y), (255, 0, 255), 2, lineType=cv2.LINE_AA) + margin = 20 + cv2.rectangle(overlay, (roi.x1, y - margin), (roi.x2, y + margin), (255, 0, 255), -1) + cv2.addWeighted(overlay, 0.1, out, 0.9, 0, out) + cv2.putText(out, f"COUNT LINE Y={y}", (roi.x1 + 10, y - margin - 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA) + + # 5a. Gambar TRUCK bounding boxes (hijau) + if trucks: + for det in trucks: + x1, y1, x2, y2 = [int(v) for v in det.bbox] + tid = det.track_id + lbl = f"truck" + if tid is not None: + lbl += f" #{tid}" + lbl += f" {det.confidence:.0%}" + cv2.rectangle(out, (x1, y1), (x2, y2), (0, 200, 0), 2) + cv2.putText(out, lbl, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1, cv2.LINE_AA) + + # 5b. Gambar Sacks terdeteksi + for det in stable_sacks: + x1, y1, x2, y2 = [int(v) for v in det.bbox] + cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2) + tid = det.track_id + + lbl = f"sack" + if tid is not None: + lbl += f" #{tid}" + lbl += f" {det.confidence:.0%}" + + # Tandai karung + cv2.rectangle(out, (x1, y1), (x2, y2), (0, 255, 255), 2) + # Garis pemicu (y1 - bagian atas karung) + cv2.line(out, (x1, y1), (x2, y1), (0, 255, 0), 3) + # Centroid dot + cv2.circle(out, (cx, cy), 4, (0, 255, 255), -1) + cv2.putText(out, lbl, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + # 6. Gambar Dedup Circles (Radius Anti-Double Count) + now_t = time.time() + for circle in counter._dedup_circles: + if (now_t - circle["time"]) <= 3.0: + cx, cy = int(circle["x"]), int(circle["y"]) + cv2.circle(out, (cx, cy), int(counter._dedup_radius), (0, 255, 255), 2, lineType=cv2.LINE_AA) + cv2.circle(out, (cx, cy), 4, (0, 255, 0), -1) + cv2.putText(out, f"DEDUP #{circle['track_id']}", (cx - 25, cy - int(counter._dedup_radius) - 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA) + + # 7. Gambar Stats HUD Panel (Top-Left) + cv2.rectangle(out, (10, 10), (340, 175), (0, 0, 0), -1) + cv2.rectangle(out, (10, 10), (340, 175), (255, 255, 255), 1) + + batch_num_text = f"Batch #{batch_mgr.current_batch_id}" if batch_mgr.is_active else "IDLE" + cv2.putText(out, f"SACK COUNTING BATCH SYSTEM", (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA) + cv2.putText(out, f"Active Session: {batch_num_text}", (20, 55), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 230, 255), 2, cv2.LINE_AA) + + cv2.putText(out, f"Sacks Counted (IN): {counter.loading_count}", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2, cv2.LINE_AA) + cv2.putText(out, f"Sacks In Truck: {sacks_in_truck}", (20, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 0), 2, cv2.LINE_AA) + + mode_lbl = "FAST TEST MODE (ACCELERATED)" if FAST_TEST_MODE else "NORMAL MODE" + cv2.putText(out, f"Mode: {mode_lbl}", (20, 145), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 165, 255), 1, cv2.LINE_AA) + cv2.putText(out, f"Tolerance Limit: {current_tolerance:.0f}s", (20, 162), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA) + + # 8. Gambar Sesi Riwayat Batch Sebelumnya + if batch_mgr.history: + y_hist = 200 + cv2.putText(out, "RECENT BATCH HISTORY:", (20, y_hist), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 230, 255), 1, cv2.LINE_AA) + for rec in batch_mgr.history[-3:]: + y_hist += 20 + h_text = f"Batch #{rec.batch_id} -> Count: {rec.loading_count} sacks ({rec.duration_seconds:.1f}s)" + cv2.putText(out, h_text, (20, y_hist), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA) + + # 9. Gambar Status Bar di Bagian Bawah + bar_h = 40 + bar_y = h - bar_h + cv2.rectangle(out, (0, bar_y), (w, h), (20, 20, 20), -1) + cv2.line(out, (0, bar_y), (w, bar_y), (255, 255, 255), 1) + + # Dot Indikator Status + cv2.circle(out, (25, bar_y + bar_h // 2), 7, lbl_color, -1) + cv2.circle(out, (25, bar_y + bar_h // 2), 7, (255, 255, 255), 1) + + # Label Status + cv2.putText(out, f"STATUS: {current_state} - {lbl_text}", (45, bar_y + bar_h // 2 + 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, lbl_color, 2, cv2.LINE_AA) + + # 10. Tambah Detail Timer di Status Bar + if current_state == "COUNTING_SACKS": + dur = batch_mgr.batch_duration + idle = batch_mgr.time_since_last_sack_activity + timer_text = f"Duration: {dur:.1f}s | Idle Time: {idle:.1f}s / {SACK_IDLE_TIMEOUT:.1f}s" + cv2.putText(out, timer_text, (w - 400, bar_y + bar_h // 2 + 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA) + + elif current_state == "WAITING_FOR_ACTIVITY": + waiting = batch_mgr.waiting_duration + time_left = max(0.0, current_tolerance - waiting) + timer_text = f"Waiting: {waiting:.1f}s | Time Left: {time_left:.1f}s / {current_tolerance:.1f}s" + cv2.putText(out, timer_text, (w - 400, bar_y + bar_h // 2 + 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 200, 255), 1, cv2.LINE_AA) + + return out + + +# ===================================================================== +# 4. FUNGSI UTAMA PENJALANAN PREDIKSI & ALGORITMA BATCH +# ===================================================================== +def run_batch_simulation(): + # Inisialisasi Database + init_db() + + # Device inferensi + device = 'cuda' if torch.cuda.is_available() else 'cpu' + print(f"[INFO] Menggunakan device: {device}") + + # load model YOLO + print(f"[INFO] Membuka model: {MODEL_PATH}") + model = YOLO(MODEL_PATH) + + # Warm-up model + print("[INFO] Warm-up model YOLO...") + dummy_frame = np.zeros((720, 1280, 3), dtype=np.uint8) + _ = model(dummy_frame, imgsz=640, device=device, verbose=False) + + # Setup trackers, stabilizers, counters, dll. + tracker = ByteTrackTracker(model, conf=0.25) + stabilizer = BboxStabilizer( + ema_alpha=0.35, + max_hold_frames=10, + max_height_ratio=1.5, + min_height_ratio=0.70, + ) + + # Skala koordinat + scale_x = 1280.0 / 1920.0 + scale_y = 720.0 / 1080.0 + + # Poligon untuk verifikasi sacks di area + detection_poly_pts = [ + [int(574 * scale_x), int(50 * scale_y)], + [int(586 * scale_x), int(1077 * scale_y)], + [int(1418 * scale_x), int(1076 * scale_y)], + [int(1397 * scale_x), int(50 * scale_y)], + ] + detection_polygon = Polygon(detection_poly_pts) + + truck_poly_pts = [ + [int(600 * scale_x), int(385 * scale_y)], + [int(609 * scale_x), int(1076 * scale_y)], + [int(1404 * scale_x), int(1078 * scale_y)], + [int(1381 * scale_x), int(343 * scale_y)], + ] + truck_polygon = Polygon(truck_poly_pts) + + static_line_y = int(330 * scale_y) + static_line_x_start = int(577 * scale_x) + static_line_x_end = int(1401 * scale_x) + + static_roi = TruckROI( + x1=int(600 * scale_x), + y1=int(343 * scale_y), + x2=int(1404 * scale_x), + y2=int(1078 * scale_y), + line_y=static_line_y, + confidence=1.0 + ) + + counter = LineCrossCounter( + line_y=static_line_y, + line_x_start=static_line_x_start, + line_x_end=static_line_x_end, + margin=20, + dedup_radius=60.0 + ) + + # Setup Batch Manager + # CATATAN: truck_gone_tolerance awal harus > 0 agar TRUCK_STABILIZING tidak langsung + # kembali ke IDLE di frame berikutnya saat has_crossing=False. + # Toleransi 0.0s (instan) hanya diterapkan saat WAITING_FOR_ACTIVITY. + batch_mgr = BatchLifecycleManager( + stabilize_seconds=0.0, # Start batch secara instan ketika ada karung melintasi garis + stabilize_threshold_px=9999.0, # Matikan displacement threshold + sack_idle_timeout=SACK_IDLE_TIMEOUT, + min_batch_duration=MIN_BATCH_DURATION, + truck_gone_tolerance=3.0, # Toleransi awal 3s untuk TRUCK_STABILIZING + ) + + # Membuka file video + print(f"[INFO] Membuka file video: {VIDEO_PATH}") + cap = cv2.VideoCapture(VIDEO_PATH) + if not cap.isOpened(): + print(f"[ERROR] Gagal membuka video: {VIDEO_PATH}") + return + + fps = cap.get(cv2.CAP_PROP_FPS) + if fps <= 0 or np.isnan(fps): + fps = 25.0 + + frame_idx = 0 + + # --- Video Recording per Batch --- + video_writer = None + video_path = None + video_frame_count = 0 + + global active_batch_info + + print("\n" + "=" * 50) + print("MULAILAH MENYAKSIKAN ALGORITMA BATCH BEKERJA") + print("Tekan 'q' pada jendela video untuk berhenti.") + print("=" * 50 + "\n") + + try: + while cap.isOpened(): + ret, frame = cap.read() + if not ret: + print("[INFO] Video selesai dibaca.") + break + + # Resize ke 1280x720 untuk koordinat statis + frame = cv2.resize(frame, (1280, 720)) + timestamp = time.time() + frame_idx += 1 + + # Simpan state status batch sebelum update untuk melacak transisi + prev_active = batch_mgr.is_active + prev_state = batch_mgr.state + + # 1. Update Tracker YOLO (menggunakan custom config tracker.yaml) + raw_tracked_all = tracker.update(frame, []) + + # Hanya proses objek yang pusatnya berada di dalam area deteksi (poligon ungu) + raw_tracked_filtered = [ + d for d in raw_tracked_all + if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0)) + ] + + # Filter per class (model v1: {0:'truck', 1:'sack'}) + raw_tracked_sacks = [d for d in raw_tracked_filtered if d.class_name == "sack"] + raw_tracked_trucks = [d for d in raw_tracked_filtered if d.class_name == "truck"] + + # 2. Update Stabilizer Bounding Box + stable = stabilizer.update(raw_tracked_sacks) + + # 4. Hitung jumlah karung di dalam ZONA TRUCK (hanya y >= cutoff) + min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3] + truck_height = max_ty - min_ty + truck_cutoff_y = min_ty + 0.30 * truck_height + + sacks_in_truck_area = 0 + for d in stable: + cx = (d.bbox[0] + d.bbox[2]) / 2.0 + cy = (d.bbox[1] + d.bbox[3]) / 2.0 + if truck_polygon.contains(Point(cx, cy)) and cy >= truck_cutoff_y: + sacks_in_truck_area += 1 + + # 5. Filter ROI & hitung penyeberangan garis + tracked_sacks_in_roi = [d for d in stable if static_roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0)] + events = counter.update(tracked_sacks_in_roi) + has_crossing = len(events) > 0 + + # ================================================================ + # LOGIKA ALGORITMA PENENTUAN BATCH (STATE MACHINE TRANSITIONS) + # ================================================================ + current_tolerance = TRUCK_GONE_TOLERANCE + + # A. Mulai Batch (Start Batch) - Jika status IDLE atau STABILIZING + if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"): + # Picu transisi batch start saat terdeteksi event penyeberangan karung + batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp) + + # B. Jalankan Monitoring Batch & Penutupan Dinamis + if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"): + # Perbarui aktivitas karung di batch manager + batch_mgr.update_sacks( + has_crossing_event=has_crossing, + sacks_in_area_count=sacks_in_truck_area, + timestamp=timestamp, + loading_count=counter.loading_count, + unloading_count=counter.unloading_count, + ) + + # STRATEGI AKHIR BATCH: Gabungan Truk + Karung + # Batch tetap terbuka selama SALAH SATU kondisi terpenuhi: + # 1. Ada karung terdeteksi di area truk, ATAU + # 2. Ada truk yang terdeteksi di frame + # Batch berakhir HANYA jika KEDUANYA hilang selama TRUCK_GONE_TOLERANCE detik + if batch_mgr.state == "WAITING_FOR_ACTIVITY": + # Sinkronkan _truck_last_seen agar countdown toleransi + # mulai dari saat WAITING dimulai, bukan dari TRUCK_STABILIZING + if batch_mgr._truck_last_seen < batch_mgr._waiting_since: + batch_mgr._truck_last_seen = batch_mgr._waiting_since + + truck_still_present = len(raw_tracked_trucks) > 0 + anything_detected = (sacks_in_truck_area > 0) or truck_still_present + batch_mgr._truck_gone_tolerance = TRUCK_GONE_TOLERANCE + batch_mgr.update_truck(anything_detected, None, timestamp) + + if frame_idx % 30 == 0: # Log setiap 30 frame + print(f"[WAITING] truck={truck_still_present} sacks={sacks_in_truck_area} → keep_open={anything_detected}") + + current_tolerance = TRUCK_GONE_TOLERANCE + + # Tangani event penyeberangan karung + for ev in events: + print(f"[{datetime.now().strftime('%H:%M:%S')}] [KARUNG] Karung #{ev['track_id']} terhitung melintasi garis. Total saat ini: {counter.loading_count}") + if active_batch_info is not None: + active_batch_info["count"] = counter.loading_count + active_batch_info["last_detection_time"] = datetime.now().isoformat() + save_active_batch_state() + + # C. Transisi: Batch Baru Saja Terbuka + if batch_mgr.is_active and not prev_active: + counting_date = get_counting_date() + batch_num = get_next_batch_number(counting_date) + now_iso = datetime.now().isoformat() + + active_batch_info = { + "counting_date": counting_date, + "batch_number": batch_num, + "count": 0, + "start_time": now_iso, + "last_detection_time": now_iso + } + save_active_batch_state() + print(f"\n>>> [TRANSISI BATCH] Batch #{batch_num} DIMULAI pada tanggal {counting_date} (Status: COUNTING_SACKS) <<<") + + # Mulai rekam video untuk batch ini + date_folder = os.path.join(ARCHIVE_OUTPUT, counting_date) + os.makedirs(date_folder, exist_ok=True) + timestamp_str = datetime.now().strftime("%H-%M-%S") + video_path = os.path.join(date_folder, f"batch_{batch_num}_{timestamp_str}.mp4") + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + video_writer = cv2.VideoWriter(video_path, fourcc, 10.0, (1280, 720)) + video_frame_count = 0 + if video_writer.isOpened(): + print(f"[RECORD] Mulai merekam -> {video_path}") + else: + print(f"[RECORD ERROR] Gagal membuka VideoWriter!") + video_writer = None + + # D. Transisi: Batch Baru Saja Selesai/Tutup (Truk Pergi) + elif not batch_mgr.is_active and prev_active: + final_count = counter.loading_count + start_iso = active_batch_info["start_time"] if active_batch_info else datetime.now().isoformat() + end_iso = datetime.now().isoformat() + batch_num = active_batch_info["batch_number"] if active_batch_info else 0 + + print(f"\n>>> [TRANSISI BATCH] Truk pergi setelah status WAITING. Batch #{batch_num} SELESAI. Total karung: {final_count} <<<") + finalize_batch(final_count, start_iso, end_iso) + + # Simpan video rekaman batch + if video_writer is not None: + video_writer.release() + if video_frame_count > 0: + print(f"[RECORD] Video batch #{batch_num} disimpan: {video_path} ({video_frame_count} frames)") + else: + # Hapus file kosong + try: + os.remove(video_path) + except Exception: + pass + video_writer = None + video_frame_count = 0 + + # Reset penghitung lokal dan stabilizer ID + counter.reset() + stabilizer.reset() + + # E. Logging Transisi Status + if batch_mgr.state != prev_state: + print(f"[{datetime.now().strftime('%H:%M:%S')}] [STATE MACHINE] Transisi Status: {prev_state} -> {batch_mgr.state}") + + # ================================================================ + # TULIS FRAME KE VIDEO (jika batch aktif) + # ================================================================ + if batch_mgr.is_active and video_writer is not None and video_writer.isOpened(): + video_writer.write(frame) + video_frame_count += 1 + + # ================================================================ + # VISUALISASI + # ================================================================ + visualized_frame = draw_visualization( + frame=frame, + stable_sacks=tracked_sacks_in_roi, + roi=static_roi, + counter=counter, + batch_mgr=batch_mgr, + sacks_in_truck=sacks_in_truck_area, + current_tolerance=current_tolerance, + trucks=raw_tracked_trucks, + ) + + # Tampilkan frame di window OpenCV + cv2.imshow("Pengujian Algoritma Batch", visualized_frame) + + # Dengarkan tombol key + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + print("[INFO] Pengujian dihentikan secara manual oleh pengguna.") + break + + finally: + # Bersihkan resource + if video_writer is not None: + video_writer.release() + if video_frame_count > 0 and video_path: + print(f"[RECORD] Video batch terakhir disimpan: {video_path} ({video_frame_count} frames)") + cap.release() + cv2.destroyAllWindows() + + print("\n" + "=" * 50) + print("SIMULASI PENGUJIAN SELESAI!") + if active_batch_info is not None: + # Simpan batch yang masih terbuka saat program ditutup + final_count = counter.loading_count + start_iso = active_batch_info["start_time"] + end_iso = datetime.now().isoformat() + batch_num = active_batch_info["batch_number"] + print(f"[INFO] Menyimpan batch aktif terakhir #{batch_num} sebelum menutup program...") + finalize_batch(final_count, start_iso, end_iso) + + # Cetak hasil histori batch uji coba + print("Histori Batch Terhitung Selama Simulasi:") + if batch_mgr.history: + for rec in batch_mgr.history: + print(f" - Batch #{rec.batch_id}: Mulai={datetime.fromtimestamp(rec.start_time).strftime('%H:%M:%S')}, Selesai={datetime.fromtimestamp(rec.end_time).strftime('%H:%M:%S')}, Jumlah Karung={rec.loading_count}") + else: + print(" - Tidak ada batch selesai yang tersimpan (atau semua batch bernilai 0).") + print("=" * 50) + + +if __name__ == "__main__": + run_batch_simulation() diff --git a/backend/api/batches.py b/backend/api/batches.py index 9f46ae7..9dfc5cd 100644 --- a/backend/api/batches.py +++ b/backend/api/batches.py @@ -34,6 +34,16 @@ class AutolabelRequest(BaseModel): min_box_frac: float = 0.0 resume: bool = False append: bool = False + custom_model_path: Optional[str] = None + +class PreviewRequest(BaseModel): + frame_id: int + engine: str + threshold: float = autolabel.DEFAULT_THRESHOLD + iou_threshold: float = autolabel.DEFAULT_IOU + min_box_frac: float = 0.0 + target_class_names: Optional[list[str]] = None + custom_model_path: Optional[str] = None @router.post("/api/projects/{project_id}/batches") @@ -94,9 +104,10 @@ def start_autolabel(batch_id: int, request: AutolabelRequest) -> dict: engine_list = request.engines if (request.engines and len(request.engines) > 0) else [request.engine] return autolabel.start(batch_id, request.threshold, request.iou_threshold, request.min_box_frac, resume=request.resume, append=request.append, - engines=engine_list, class_ids=request.class_ids, + engine=request.engine, engines=engine_list, class_ids=request.class_ids, engine_classes=request.engine_classes, - target_class_names=request.target_class_names) + target_class_names=request.target_class_names, + custom_model_path=request.custom_model_path) except batch_store.BatchError as exc: raise HTTPException(400, str(exc)) @router.post("/api/batches/inspect-model") @@ -113,7 +124,7 @@ async def inspect_model(file: UploadFile = File(...)) -> dict: except Exception as exc: if os.path.exists(staged_path): os.unlink(staged_path) - raise HTTPException(400, f"Could not inspect model: {exc}") + raise HTTPException(400, f"Invalid model: {exc}") @router.post("/api/batches/{batch_id}/autolabel-with-model") @@ -121,7 +132,7 @@ async def autolabel_with_model( batch_id: int, file: UploadFile = File(...), threshold: float = Form(0.35), - iou_threshold: float = Form(0.8), + iou_threshold: float = Form(0.0), selected_classes: str = Form("[]"), append: bool = Form(True), ) -> dict: @@ -139,6 +150,7 @@ async def autolabel_with_model( threshold=threshold, iou_threshold=iou_threshold, append=append, + engine="custom", custom_model_path=staged_path, target_class_names=target_classes, ) @@ -148,13 +160,39 @@ async def autolabel_with_model( raise HTTPException(400, f"Auto-annotation failed to start: {exc}") +@router.post("/api/batches/{batch_id}/preview") +def preview_autolabel(batch_id: int, request: PreviewRequest) -> dict: + from backend import autolabel, jobs + + if not jobs.gpu_lock.acquire(timeout=20): + busy = jobs.running_types() + kind = busy[0] if busy else "background" + raise HTTPException(409, f"The GPU is busy with a {kind} job — wait for it to finish") + try: + shapes = autolabel.preview_frame( + batch_id=batch_id, + frame_id=request.frame_id, + engine=request.engine, + threshold=request.threshold, + iou_threshold=request.iou_threshold, + min_box_frac=request.min_box_frac, + target_class_names=request.target_class_names, + custom_model_path=request.custom_model_path + ) + return {"shapes": shapes} + except Exception as exc: + raise HTTPException(400, str(exc)) + finally: + jobs.gpu_lock.release() + @router.post("/api/sam3/playground-test") async def sam3_playground_test( file: UploadFile = File(...), prompts: str = Form(...), threshold: float = Form(0.35), - iou_threshold: float = Form(0.8), + iou_threshold: float = Form(0.0), ) -> dict: + from PIL import Image from backend import labeling from backend.sam3_engine import get_engine @@ -169,12 +207,20 @@ async def sam3_playground_test( if not prompt_list: raise HTTPException(400, "At least one text prompt is required") + from backend import jobs + + if not jobs.gpu_lock.acquire(timeout=20): + busy = jobs.running_types() + kind = busy[0] if busy else "background" + raise HTTPException(409, f"The GPU is busy with a {kind} job — wait for it to finish") try: engine = get_engine() raw_dets = engine.detect(image, prompt_list, threshold) kept_dets = labeling.deduplicate(raw_dets, iou_threshold=iou_threshold) except Exception as exc: raise HTTPException(500, f"SAM3 inference failed: {exc}") + finally: + jobs.gpu_lock.release() results = [] for det in kept_dets: @@ -213,10 +259,18 @@ def approve_all_batch_frames(batch_id: int) -> dict: return {"approved_count": updated} -@router.post("/api/batches/{batch_id}/approve") -def approve_batch(batch_id: int) -> dict: +class ApproveRequest(BaseModel): + dataset_id: Optional[int] = None + dataset_name: str = "" + + +@router.post("/api/batches/{batch_ids}/approve") +def approve_batch(batch_ids: str, request: ApproveRequest = ApproveRequest()) -> dict: + """`batch_ids` is one id or a comma-separated selection — one merge, one + dataset, however many batches Data Prep was tuned against (REQ-131).""" try: - return dataset.approve(batch_id) + return dataset.approve(batch_ids, dataset_id=request.dataset_id, + dataset_name=request.dataset_name) except dataset.DatasetError as exc: raise HTTPException(400, str(exc)) @@ -227,17 +281,6 @@ def dataset_summary(project_id: int) -> dict: return dataset.summary(project_id) -@router.get("/api/projects/{project_id}/dataset/download") -def dataset_download(project_id: int): - project = project_or_404(project_id) - try: - path = dataset.zip_path(project) - except dataset.DatasetError as exc: - raise HTTPException(400, str(exc)) - return FileResponse(path, media_type="application/zip", - filename=f"{project['slug']}-dataset.zip") - - @router.get("/api/frames/{frame_id}/image") def frame_image(frame_id: int, w: int = 0): path = batch_store.frame_path(frame_id) diff --git a/backend/api/counting_bench.py b/backend/api/counting_bench.py new file mode 100644 index 0000000..1066547 --- /dev/null +++ b/backend/api/counting_bench.py @@ -0,0 +1,164 @@ +"""Counting bench routes: the scored table and the background recount (REQ-150…153).""" + +from typing import List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from backend import archive_index, counting_bench, jobs +from backend.api.common import project_or_404 + +router = APIRouter(tags=["counting-bench"]) + + +class GroundTruthRequest(BaseModel): + video_rel: str + # Null clears it, which has to be distinguishable from "not sent" — a + # cleared row must drop back out of the accuracy totals. + ground_truth: Optional[int] = None + + +class ClockRequest(BaseModel): + video_rel: str + # ISO "2026-08-13 08:27:28"; null clears a manual entry and lets a rescan + # read the overlay again. + started_at: Optional[str] = None + + +class ScanRequest(BaseModel): + rescan: bool = False + + +class CountRequest(BaseModel): + video_rels: List[str] = [] + model_path: str = "" + model_version_id: Optional[int] = None + params: Optional[dict] = None + recount: bool = False + all_videos: bool = False + + +@router.get("/api/projects/{project_id}/counting-bench") +def listing(project_id: int, date: Optional[str] = None) -> dict: + project_or_404(project_id) + try: + payload = counting_bench.listing(project_id, date) + except counting_bench.CountingBenchError as exc: + raise HTTPException(400, str(exc)) + payload["defaults"] = counting_bench.DEFAULTS + running = [job for job in (j.to_dict() for j in jobs.listing(project_id)) + if job["status"] in ("queued", "running")] + payload["active_job"] = next((j for j in running if j["type"] == "count"), None) + payload["scan_job"] = next((j for j in running if j["type"] == "clock-scan"), None) + return payload + + +class TruckScanRequest(BaseModel): + model_path: str = "" + model_version_id: Optional[int] = None + rescan: bool = False + + +@router.post("/api/projects/{project_id}/archive/truck-scan") +def truck_scan(project_id: int, body: TruckScanRequest) -> dict: + """Check every recording actually contains a truck (REQ-166).""" + project_or_404(project_id) + from backend.api.live_count import available_models + + path = body.model_path + models = available_models(project_id)["models"] + if not path and body.model_version_id is not None: + path = next((m["path"] for m in models + if m["version_id"] == body.model_version_id), "") + # No model named: the newest trained version, which is what "v4" means. + if not path: + path = models[0]["path"] if models else "" + if not path: + raise HTTPException(400, "This project has no model to detect trucks with") + try: + return archive_index.queue_truck_scan(project_id, path, rescan=body.rescan) + except archive_index.ArchiveIndexError as exc: + raise HTTPException(400, str(exc)) + + +@router.get("/api/projects/{project_id}/archive/cycles") +def archive_cycles(project_id: int) -> dict: + """The archive grouped into 06:00-to-05:59 cycles (REQ-165).""" + project_or_404(project_id) + try: + return {"cycles": archive_index.cycles(project_id)} + except archive_index.ArchiveIndexError as exc: + raise HTTPException(400, str(exc)) + + +@router.get("/api/projects/{project_id}/archive/cycles/{cycle}") +def archive_cycle_videos(project_id: int, cycle: str) -> dict: + project_or_404(project_id) + try: + return {"videos": archive_index.cycle_videos(project_id, cycle)} + except archive_index.ArchiveIndexError as exc: + raise HTTPException(400, str(exc)) + + +@router.post("/api/projects/{project_id}/counting-bench/scan-clock") +def scan_clock(project_id: int, body: ScanRequest) -> dict: + """Read the burned-in timestamp off every recording (REQ-160).""" + project_or_404(project_id) + try: + return archive_index.queue_scan(project_id, rescan=body.rescan) + except archive_index.ArchiveIndexError as exc: + raise HTTPException(400, str(exc)) + + +@router.patch("/api/projects/{project_id}/counting-bench/clock") +def set_clock(project_id: int, body: ClockRequest) -> dict: + """Hand-correct a start time the reader could not get right.""" + import datetime + + project_or_404(project_id) + stamp = None + if body.started_at: + for shape in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"): + try: + stamp = datetime.datetime.strptime(body.started_at, shape) + break + except ValueError: + continue + if stamp is None: + raise HTTPException(400, "Use the form 2026-08-13 08:27:28") + return archive_index.set_manual( + project_id, body.video_rel, + stamp.strftime("%Y-%m-%d %H:%M:%S") if stamp else None) + + +@router.patch("/api/projects/{project_id}/counting-bench/ground-truth") +def set_ground_truth(project_id: int, body: GroundTruthRequest) -> dict: + project_or_404(project_id) + if body.ground_truth is not None and body.ground_truth < 0: + raise HTTPException(400, "A ground truth count cannot be negative") + return counting_bench.set_ground_truth(project_id, body.video_rel, body.ground_truth) + + +@router.post("/api/projects/{project_id}/counting-bench/run") +def run(project_id: int, body: CountRequest) -> dict: + project_or_404(project_id) + + path = body.model_path + if not path and body.model_version_id is not None: + from backend.api.live_count import available_models + for item in available_models(project_id)["models"]: + if item["version_id"] == body.model_version_id: + path = item["path"] + break + if not path: + raise HTTPException(400, "Pick a model to count with") + + rels = body.video_rels + if body.all_videos: + rels = [row["video_rel"] for row in counting_bench.listing(project_id)["rows"]] + + try: + return counting_bench.queue(project_id, rels, path, + params=body.params, recount=body.recount) + except counting_bench.CountingBenchError as exc: + raise HTTPException(400, str(exc)) diff --git a/backend/api/datasets.py b/backend/api/datasets.py new file mode 100644 index 0000000..cd21b4e --- /dev/null +++ b/backend/api/datasets.py @@ -0,0 +1,101 @@ +"""Named dataset routes: list, rename, delete, download (REQ-110…113).""" + +import os +from typing import List, Optional + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from backend import dataset, datasets +from backend.api.common import project_or_404 + +router = APIRouter(tags=["datasets"]) + + +class CreateRequest(BaseModel): + name: str = "" + note: str = "" + + +class RenameRequest(BaseModel): + name: Optional[str] = None + note: Optional[str] = None + + +class CombineRequest(BaseModel): + dataset_ids: List[int] + + +@router.get("/api/projects/{project_id}/datasets") +def list_datasets(project_id: int) -> dict: + project_or_404(project_id) + return {"datasets": datasets.listing(project_id)} + + +@router.post("/api/projects/{project_id}/datasets") +def create_dataset(project_id: int, body: CreateRequest) -> dict: + project_or_404(project_id) + return datasets.create(project_id, name=body.name, note=body.note) + + +@router.get("/api/datasets/{dataset_id}") +def get_dataset(dataset_id: int) -> dict: + found = datasets.get(dataset_id) + if found is None: + raise HTTPException(404, "No such dataset") + return found + + +@router.patch("/api/datasets/{dataset_id}") +def rename_dataset(dataset_id: int, body: RenameRequest) -> dict: + if datasets.get(dataset_id) is None: + raise HTTPException(404, "No such dataset") + return datasets.rename(dataset_id, name=body.name, note=body.note) + + +@router.delete("/api/datasets/{dataset_id}") +def delete_dataset(dataset_id: int) -> dict: + if not datasets.delete(dataset_id): + raise HTTPException(404, "No such dataset") + return {"deleted": True} + + +@router.post("/api/projects/{project_id}/datasets/combine-preview") +def combine_preview(project_id: int, body: CombineRequest) -> dict: + """What a run over these datasets would actually see. + + The totals of two datasets do not add up when they share frames, and being + handed 4,000 images after picking two datasets of 3,000 is the kind of + surprise that makes people distrust the numbers. + """ + project_or_404(project_id) + items = datasets.combined_items(project_id, body.dataset_ids) + report = datasets.overlap_report(project_id, body.dataset_ids, len(items)) + report["splits"] = { + "train": sum(1 for item in items if item["split"] == "train"), + "val": sum(1 for item in items if item["split"] == "val"), + } + return report + + +@router.post("/api/datasets/{dataset_id}/resync") +def resync_dataset(dataset_id: int) -> dict: + try: + return dataset.resync(dataset_id) + except datasets.DatasetError as exc: + raise HTTPException(400, str(exc)) + + +@router.get("/api/datasets/{dataset_id}/download") +def download_dataset(dataset_id: int): + found = datasets.get(dataset_id) + if found is None: + raise HTTPException(404, "No such dataset") + project = project_or_404(found["project_id"]) + try: + path = dataset.zip_path(project, dataset_id) + except datasets.DatasetError as exc: + raise HTTPException(400, str(exc)) + return FileResponse(path, media_type="application/zip", + filename=os.path.basename(path)) diff --git a/backend/api/live_count.py b/backend/api/live_count.py new file mode 100644 index 0000000..243ef20 --- /dev/null +++ b/backend/api/live_count.py @@ -0,0 +1,142 @@ +"""Live counting routes: start/stop a session and watch it as MJPEG.""" + +import os +import time + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Optional + +from backend import library, live_count, training +from backend.api.common import project_or_404 + +router = APIRouter(tags=["live-count"]) + + +class StartRequest(BaseModel): + # Either a raw source (RTSP URL or absolute path) or an archive-relative + # path like "2026-08-13/batch001.mp4", which the backend resolves — the + # frontend never needs to know where the archive is mounted. + source: str = "" + source_rel: Optional[str] = None + model_path: Optional[str] = None + model_version_id: Optional[int] = None + line_y: int = 266 + line_x_start: int = 469 + line_x_end: int = 910 + conf: float = 0.35 + dedup_radius: float = 60.0 + margin: int = 5 + imgsz: int = 640 + # Ghost rejection and spatial dedup pull in opposite directions, so they are + # separate dials now (REQ-140). + entry_travel_min: float = 60.0 + handoff_radius: float = 100.0 + unload_confirm_frames: int = 3 + min_area_scale: float = 1.0 + spatial_dedup: bool = False + + +@router.get("/api/projects/{project_id}/live-count/models") +def available_models(project_id: int) -> dict: + """Weights this project can count with: its trained versions, then its base.""" + project = project_or_404(project_id) + out = [] + for version in training.listing(project_id): + if version.get("weights_path") and os.path.isfile(version["weights_path"]): + out.append({ + "label": f"v{version['version']}", + "path": version["weights_path"], + "version_id": version["id"], + }) + base = project.get("base_model_path") + if base and os.path.isfile(base): + out.append({"label": "base model", "path": base, "version_id": None}) + return {"models": out} + + +@router.post("/api/projects/{project_id}/live-count/start") +def start(project_id: int, request: StartRequest) -> dict: + project = project_or_404(project_id) + + source = request.source + if request.source_rel: + try: + source = library.resolve(project["video_root"], request.source_rel) + except library.LibraryError as exc: + raise HTTPException(400, str(exc)) + if not source: + raise HTTPException(400, "Pick a video or enter a stream URL") + + path = request.model_path + if not path and request.model_version_id is not None: + for item in available_models(project_id)["models"]: + if item["version_id"] == request.model_version_id: + path = item["path"] + break + if not path: + raise HTTPException(400, "Pick a model to count with") + try: + return live_count.start( + source=source, model_path=path, line_y=request.line_y, + line_x_start=request.line_x_start, line_x_end=request.line_x_end, + conf=request.conf, dedup_radius=request.dedup_radius, + margin=request.margin, imgsz=request.imgsz, + entry_travel_min=request.entry_travel_min, + handoff_radius=request.handoff_radius, + unload_confirm_frames=request.unload_confirm_frames, + min_area_scale=request.min_area_scale, + spatial_dedup=request.spatial_dedup, + ) + except live_count.LiveCountError as exc: + raise HTTPException(400, str(exc)) + + +class LineRequest(BaseModel): + line_y: Optional[int] = None + line_x_start: Optional[int] = None + line_x_end: Optional[int] = None + + +@router.patch("/api/live-count/line") +def move_line(request: LineRequest) -> dict: + """Reposition the counting line mid-session, without losing the counts.""" + try: + return live_count.move_line(request.line_y, request.line_x_start, request.line_x_end) + except live_count.LiveCountError as exc: + raise HTTPException(400, str(exc)) + + +@router.post("/api/live-count/stop") +def stop() -> dict: + return live_count.stop() + + +@router.get("/api/live-count/status") +def status() -> dict: + return live_count.status() + + +@router.get("/api/live-count/stream") +def stream(): + """MJPEG of the annotated frames. Ends when the session does.""" + def frames(): + blank_streak = 0 + while True: + jpeg = live_count.snapshot() + if jpeg is None: + blank_streak += 1 + if blank_streak > 100 or not live_count.status().get("running"): + return + time.sleep(0.1) + continue + blank_streak = 0 + yield (b"--frame\r\nContent-Type: image/jpeg\r\n" + b"Content-Length: " + str(len(jpeg)).encode() + b"\r\n\r\n" + + jpeg + b"\r\n") + time.sleep(0.05) + + return StreamingResponse(frames(), + media_type="multipart/x-mixed-replace; boundary=frame", + headers={"Cache-Control": "no-store"}) diff --git a/backend/api/models.py b/backend/api/models.py index 757374c..fcf5450 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -20,6 +20,8 @@ class TrainRequest(BaseModel): device: Optional[Union[int, str]] = None batch_ids: Optional[list] = None class_ids: Optional[list] = None + dataset_ids: Optional[list] = None + base_dataset_ids: Optional[list] = None @router.get("/api/hardware") @@ -36,6 +38,8 @@ def start_training(project_id: int, request: TrainRequest) -> dict: {"batch": request.batch, "imgsz": request.imgsz, "device": request.device}, batch_ids=request.batch_ids, class_ids=request.class_ids, + dataset_ids=request.dataset_ids, + base_dataset_ids=request.base_dataset_ids, ) except training.TrainingError as exc: raise HTTPException(400, str(exc)) diff --git a/backend/api/projects.py b/backend/api/projects.py index e966851..14dbbb0 100644 --- a/backend/api/projects.py +++ b/backend/api/projects.py @@ -173,7 +173,16 @@ def stream_video(project_id: int, rel: str, request: Request): except library.LibraryError as exc: raise HTTPException(404, str(exc)) + parts = rel.split("/", 1) + if len(parts) == 2: + date_label, filename = parts + b_label = library.batch_label(filename) + preview_path = os.path.join(config.project_dir(project["slug"]), "previews", f"{date_label}_{b_label}.mp4") + if os.path.isfile(preview_path): + path = preview_path + media = VIDEO_MEDIA.get(os.path.splitext(path)[1].lower(), "application/octet-stream") + size = os.path.getsize(path) header = request.headers.get("range") if not header or not header.startswith("bytes="): diff --git a/backend/api/review.py b/backend/api/review.py index 076b638..007901c 100644 --- a/backend/api/review.py +++ b/backend/api/review.py @@ -21,6 +21,15 @@ class AnnotationPatch(BaseModel): geometry: Optional[Dict[str, Any]] = None +class BulkRequest(BaseModel): + annotation_ids: List[int] + + +class BulkReclassRequest(BaseModel): + annotation_ids: List[int] + class_id: int + + class StatusRequest(BaseModel): status: str @@ -67,6 +76,20 @@ def delete_annotation(annotation_id: int) -> dict: return {"deleted": review_store.delete(annotation_id)} +@router.post("/api/annotations/bulk-delete") +def bulk_delete(request: BulkRequest) -> dict: + return {"deleted": review_store.delete_many(request.annotation_ids)} + + +@router.post("/api/annotations/bulk-reclass") +def bulk_reclass(request: BulkReclassRequest) -> dict: + try: + return {"updated": review_store.reclass_many(request.annotation_ids, + request.class_id)} + except review_store.ReviewError as exc: + raise HTTPException(400, str(exc)) + + @router.post("/api/frames/{frame_id}/assist") def assist(frame_id: int, request: AssistRequest) -> dict: try: diff --git a/backend/api/triage.py b/backend/api/triage.py new file mode 100644 index 0000000..fe0bfa8 --- /dev/null +++ b/backend/api/triage.py @@ -0,0 +1,222 @@ +"""Triage routes: rules, hand overrides, and the per-batch shape view (REQ-100…108).""" + +import io +import os +import shutil + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import List, Optional + +from backend import augment +from backend import batches as batch_store +from backend import triage + +router = APIRouter() + + +class AugmentRequest(BaseModel): + settings: dict + + +@router.get("/api/projects/{project_id}/base-datasets") +def list_base_datasets(project_id: int) -> dict: + from backend import base_dataset + return {"base_datasets": base_dataset.listing(project_id)} + + +@router.delete("/api/base-datasets/{base_id}") +def delete_base_dataset(base_id: int) -> dict: + from backend import base_dataset, projects + record = base_dataset.get(base_id) + if record is None: + raise HTTPException(404, "No such base dataset") + project = projects.get(record["project_id"]) + if not base_dataset.delete(base_id, project["slug"]): + raise HTTPException(404, "No such base dataset") + return {"deleted": True} + + +@router.get("/api/projects/{project_id}/augment") +def get_augment(project_id: int) -> dict: + try: + return augment.get(project_id) + except augment.AugmentError as exc: + raise HTTPException(404, str(exc)) + + +@router.put("/api/projects/{project_id}/augment") +def put_augment(project_id: int, body: AugmentRequest) -> dict: + try: + return augment.save(project_id, body.settings) + except augment.AugmentError as exc: + raise HTTPException(400, str(exc)) + + +class Rule(BaseModel): + name: str = "" + predicate: dict = {} + action: str + target_class: Optional[int] = None + + +class RuleList(BaseModel): + rules: List[Rule] + + +class OverrideRequest(BaseModel): + annotation_ids: List[int] + verdict: str + target_class: Optional[int] = None + + +class ClearRequest(BaseModel): + annotation_ids: List[int] + + +@router.get("/api/projects/{project_id}/triage/rules") +def get_rules(project_id: int) -> dict: + return {"rules": triage.rules(project_id)} + + +@router.put("/api/projects/{project_id}/triage/rules") +def put_rules(project_id: int, body: RuleList) -> dict: + try: + stored = triage.replace_rules(project_id, [item.model_dump() for item in body.rules]) + except triage.TriageError as exc: + raise HTTPException(400, str(exc)) + return {"rules": stored} + + +@router.get("/api/batches/{batch_ids}/triage/summary") +def batch_summary(batch_ids: str) -> dict: + """`batch_ids` is one id or a comma-separated selection (REQ-130).""" + try: + return triage.batch_summary(batch_ids) + except triage.TriageError as exc: + raise HTTPException(404, str(exc)) + + +@router.get("/api/batches/{batch_ids}/triage/shapes") +def batch_page(batch_ids: str, sort: str = "score", offset: int = 0, limit: int = 120) -> dict: + try: + return triage.batch_page(batch_ids, sort=sort, offset=offset, limit=min(limit, 500)) + except triage.TriageError as exc: + raise HTTPException(400, str(exc)) + + +@router.post("/api/triage/overrides") +def set_overrides(body: OverrideRequest) -> dict: + try: + triage.set_overrides(body.annotation_ids, body.verdict, body.target_class) + except triage.TriageError as exc: + raise HTTPException(400, str(exc)) + return {"updated": len(body.annotation_ids)} + + +@router.delete("/api/triage/overrides") +def clear_overrides(body: ClearRequest) -> dict: + return {"cleared": triage.clear_overrides(body.annotation_ids)} + + +@router.get("/api/batches/{batch_ids}/triage/suggest") +def suggest(batch_ids: str) -> dict: + try: + return triage.suggest(batch_ids) + except triage.TriageError as exc: + raise HTTPException(404, str(exc)) + + +@router.post("/api/batches/{batch_ids}/triage/simulate") +def simulate(batch_ids: str, body: RuleList) -> dict: + try: + return triage.simulate(batch_ids, [item.model_dump() for item in body.rules]) + except triage.TriageError as exc: + raise HTTPException(400, str(exc)) + + +@router.get("/api/projects/{project_id}/triage/preview") +def preview(project_id: int) -> dict: + return triage.preview(project_id) + + +@router.get("/api/projects/{project_id}/export") +def export_annotated(project_id: int, batch_ids: str = "", approved_only: bool = False, + include_empty: bool = False): + """Download annotated frames as a YOLO zip, merged or not — the user's own backup.""" + from fastapi.responses import FileResponse + + from backend import export + + ids = [int(part) for part in batch_ids.split(",") if part.strip().isdigit()] + try: + path = export.build_zip(project_id, ids or None, + approved_only=approved_only, include_empty=include_empty) + except export.ExportError as exc: + raise HTTPException(400, str(exc)) + return FileResponse(path, media_type="application/zip", filename=os.path.basename(path)) + + +@router.post("/api/projects/{project_id}/import") +async def import_annotated(project_id: int, file: UploadFile = File(...), + batch_label: str = Form("")) -> dict: + """Load a previously exported zip back in, as a new batch to keep working on.""" + import tempfile + + from backend import export + + staged = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + try: + shutil.copyfileobj(file.file, staged) + staged.close() + return export.restore_zip(project_id, staged.name, batch_label=batch_label) + except export.ExportError as exc: + raise HTTPException(400, str(exc)) + except Exception as exc: + raise HTTPException(400, f"Could not read that zip: {exc}") + finally: + if os.path.exists(staged.name): + os.unlink(staged.name) + + +@router.get("/api/annotations/{annotation_id}/crop") +def crop(annotation_id: int, pad: float = 0.08): + """The shape itself, cropped out of its frame — the crop grid judges objects, + not whole frames (REQ-106).""" + from PIL import Image + + from backend import db, review + + with db.cursor() as cur: + cur.execute("SELECT frame_id, geometry FROM annotations WHERE id = ?", (annotation_id,)) + row = cur.fetchone() + if row is None: + raise HTTPException(404, "No such annotation") + + import json + box = review.to_box(json.loads(row["geometry"])) + path = batch_store.frame_path(row["frame_id"]) + if not path or not os.path.isfile(path): + raise HTTPException(404, "The frame image is missing") + + with Image.open(path) as handle: + image = handle.convert("RGB") + width, height = image.size + x0, y0, x1, y1 = box + px, py = (x1 - x0) * pad, (y1 - y0) * pad + crop_box = ( + max(0, int((x0 - px) * width)), + max(0, int((y0 - py) * height)), + min(width, int((x1 + px) * width)), + min(height, int((y1 + py) * height)), + ) + if crop_box[2] <= crop_box[0] or crop_box[3] <= crop_box[1]: + raise HTTPException(400, "This shape has no area to crop") + cropped = image.crop(crop_box) + cropped.thumbnail((192, 192)) + buffer = io.BytesIO() + cropped.save(buffer, format="JPEG", quality=80) + buffer.seek(0) + return StreamingResponse(buffer, media_type="image/jpeg", + headers={"Cache-Control": "public, max-age=86400"}) diff --git a/backend/archive_index.py b/backend/archive_index.py new file mode 100644 index 0000000..8553b5f --- /dev/null +++ b/backend/archive_index.py @@ -0,0 +1,448 @@ +"""When each recording actually happened, and which working day it belongs to. + +The archive's folders are wrong about both. `2026-08-13/batch001.mp4` was +recorded at 00:07, which under a 06:00-to-06:00 shift belongs to the working day +of 2026-08-12 — and `2026-08-07/batch4.mp4` was recorded the previous evening +entirely. Sampling the archive, roughly a quarter of the files land on a +different working day once their real timestamp is read (REQ-160…163). + +Nothing on disk is touched. The archive is mounted read-only and is the user's +own data; this builds an index beside it instead, and every page groups and +orders by the index rather than by the folder name. The original path stays the +file's identity, so results already recorded against it survive. +""" + +import os +import time +from typing import List, Optional + +from backend import db, jobs, library, projects, video_clock + +CUTOFF_HOUR = 6 +"""A working day runs 06:00 to 06:00 (REQ-161).""" + +MIN_CONFIDENCE = 0.10 +MIN_AGREEING = 2 +"""Below either of these a reading is kept but flagged: it is a guess, not a +measurement, and one misread digit is what puts a recording on the wrong day.""" + + +class ArchiveIndexError(Exception): + pass + + +def _trusted(confidence: float, agreeing: int) -> bool: + return confidence >= MIN_CONFIDENCE and agreeing >= MIN_AGREEING + + +def store(project_id: int, video_rel: str, started_at: Optional[str], + confidence: float = 0.0, agreeing: int = 0, source: str = "ocr", + error: str = "") -> None: + """`started_at` is wall-clock text, 'YYYY-MM-DD HH:MM:SS'. + + Never an epoch. The overlay has no timezone, so converting it to one makes + the answer depend on which timezone the process happens to run in — the + backend container is UTC and the browser is not. + """ + working = "" + if started_at: + working = video_clock.working_day(_as_datetime(started_at), CUTOFF_HOUR) + with db.cursor() as cur: + cur.execute( + """INSERT INTO video_clock (project_id, video_rel, folder_date, started_at, + working_day, confidence, agreeing, source, error, + read_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, video_rel) DO UPDATE SET + started_at = excluded.started_at, working_day = excluded.working_day, + confidence = excluded.confidence, agreeing = excluded.agreeing, + source = excluded.source, error = excluded.error, + read_at = excluded.read_at""", + (project_id, video_rel, video_rel.split("/")[0], started_at, working, + confidence, agreeing, source, error, time.time()), + ) + + +def _as_datetime(text: str): + import datetime + + return datetime.datetime.strptime(text, "%Y-%m-%d %H:%M:%S") + + +def set_manual(project_id: int, video_rel: str, started_at: Optional[str]) -> dict: + """A hand-entered start time. Outranks any reading and is never overwritten + by a later scan — the whole point is that it is the one the user verified.""" + store(project_id, video_rel, started_at, confidence=1.0, agreeing=99, + source="manual" if started_at is not None else "none") + return index(project_id).get(video_rel, {}) + + +def _sidecar(project: dict, rel: str) -> Optional[dict]: + """Waktu asli yang ditulis perekam di sebelah videonya (REQ-170). + + File baru datang dari MediaMTX, jadi waktunya sudah pasti dari server dan + tidak perlu dibaca OCR sama sekali. Kalau ada sidecar, ia menang atas + pembacaan overlay: sumbernya server, bukan tebakan dari piksel. + """ + import json + + try: + path = library.resolve(project["video_root"], rel) + except Exception: + return None + sidecar = os.path.splitext(path)[0] + ".json" + if not os.path.isfile(sidecar): + return None + try: + with open(sidecar, encoding="utf-8") as handle: + payload = json.load(handle) + started = payload.get("started_at") + if not started: + return None + _as_datetime(started) # tolak isi yang tidak berbentuk waktu + return {"started_at": started, + "working_day": video_clock.working_day(_as_datetime(started), CUTOFF_HOUR), + "source": payload.get("source") or "sidecar", + "trusted": True, "confidence": 1.0, "agreeing": 99, + "folder_date": rel.split("/")[0], "error": ""} + except (OSError, ValueError, KeyError): + return None + + +def index(project_id: int) -> dict: + """Everything known about the archive's timestamps, keyed by video path.""" + with db.cursor() as cur: + cur.execute("SELECT * FROM video_clock WHERE project_id = ?", (project_id,)) + rows = [dict(row) for row in cur.fetchall()] + + out = {} + for row in rows: + row["trusted"] = bool(row["source"] == "manual" + or _trusted(row["confidence"], row["agreeing"])) + out[row["video_rel"]] = row + return out + + +def assign_batch_numbers(rows: List[dict]) -> List[dict]: + """Number the recordings 1..N inside each working day, by real start time. + + Rows without a known start keep their folder grouping and sort last within + it: an unreadable recording must not silently take position 1 and push + everything else along. + """ + known = [r for r in rows if r.get("started_at")] + unknown = [r for r in rows if not r.get("started_at")] + + known.sort(key=lambda r: (r["working_day"], r["started_at"])) + counters: dict = {} + for row in known: + day = row["working_day"] + counters[day] = counters.get(day, 0) + 1 + row["batch_no"] = counters[day] + + # Dipanggil dari dua tempat dengan nama kunci berbeda: tabel Counting + # Accuracy memakai `video_rel`, daftar arsip memakai `rel`. + def path_of(row): + return row.get("video_rel") or row.get("rel") or "" + + for row in unknown: + row["working_day"] = row.get("working_day") or path_of(row).split("/")[0] + row["batch_no"] = None + unknown.sort(key=path_of) + return known + unknown + + +def cycles(project_id: int) -> List[dict]: + """The archive as a list of cycles, newest first (REQ-165). + + A cycle is one 06:00-to-05:59 shift, so it always covers two calendar dates + and is named after the one it starts on. Recordings whose start time is not + known yet fall back to their folder name, so nothing disappears from the + archive just because its overlay could not be read. + """ + project = projects.get(project_id) + if project is None: + raise ArchiveIndexError("No such project") + known = index(project_id) + + buckets: dict = {} + for day in library.list_dates(project["video_root"]): + for name in _video_names(project, day["date"]): + rel = f"{day['date']}/{name}" + timing = _sidecar(project, rel) or known.get(rel) or {} + cycle = timing.get("working_day") or day["date"] + bucket = buckets.setdefault(cycle, {"cycle": cycle, "video_count": 0, + "flagged": 0, "first_start": None}) + bucket["video_count"] += 1 + if not timing.get("started_at") or not timing.get("trusted"): + bucket["flagged"] += 1 + start = timing.get("started_at") + if start and (bucket["first_start"] is None or start < bucket["first_start"]): + bucket["first_start"] = start + + return sorted(buckets.values(), key=lambda b: b["cycle"], reverse=True) + + +def _video_names(project: dict, date: str) -> List[str]: + """Filenames only — `library.list_videos` runs ffprobe on every file, which + is far too much work just to count what is in a cycle.""" + import os as _os + + from backend import video as video_module + + folder = _os.path.join(library._effective_root(project["video_root"]), date) + try: + return [f for f in _os.listdir(folder) + if f.lower().endswith(video_module.VIDEO_EXTS)] + except OSError: + return [] + + +def cycle_videos(project_id: int, cycle: str) -> List[dict]: + """Every recording in one cycle, in the order it was actually made.""" + project = projects.get(project_id) + if project is None: + raise ArchiveIndexError("No such project") + known = index(project_id) + + # A cycle normally draws from two folders, but a moved recording can come + # from any of them. Ask the index which folders actually contribute rather + # than running ffprobe across the whole archive to find out. + folders = {row["folder_date"] for row in known.values() + if row.get("working_day") == cycle} + folders.add(cycle) + # Berkas baru belum tentu ada di indeks; sidecar-nya bisa memindahkannya ke + # siklus ini dari folder tanggal sebelah. + for day in library.list_dates(project["video_root"]): + if day["date"] in folders: + continue + for name in _video_names(project, day["date"]): + side = _sidecar(project, f"{day['date']}/{name}") + if side and side["working_day"] == cycle: + folders.add(day["date"]) + break + + rows = [] + for day in library.list_dates(project["video_root"]): + if day["date"] not in folders: + continue + for video in library.list_videos(project["video_root"], day["date"], project_id): + rel = video["rel"] + timing = _sidecar(project, rel) or known.get(rel) or {} + if (timing.get("working_day") or day["date"]) != cycle: + continue + rows.append({ + **video, + "folder_date": day["date"], + "working_day": timing.get("working_day") or "", + "started_at": timing.get("started_at"), + "clock_trusted": bool(timing.get("trusted")), + "clock_error": timing.get("error") or "", + "moved": bool(timing.get("working_day") + and timing["working_day"] != day["date"]), + "truck_hits": timing.get("truck_hits"), + "truck_samples": timing.get("truck_samples"), + }) + return assign_batch_numbers(rows) + + +TRUCK_SAMPLES = 12 +"""Frames sampled per recording for the truck check (REQ-166). + +Enough to answer "is there a truck in this recording at all", which is the +assumption the whole batch numbering rests on: recording starts when a truck +arrives and stops when it leaves, so one file is one batch. Reading every frame +to time the truck's arrival precisely would cost hours of GPU for an answer the +recording trigger already gives. +""" + + +def store_truck(project_id: int, video_rel: str, hits: int, samples: int, + model_path: str) -> None: + """One statement, one cursor. + + This used to UPDATE and then call `store()` for the missing-row case — which + opened a second connection while the first still held a write transaction, + and SQLite answered "database is locked" eight recordings into the scan. + """ + label = os.path.basename(os.path.dirname(model_path)) + with db.cursor() as cur: + cur.execute( + """INSERT INTO video_clock (project_id, video_rel, folder_date, + truck_hits, truck_samples, truck_model, + truck_checked_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, video_rel) DO UPDATE SET + truck_hits = excluded.truck_hits, + truck_samples = excluded.truck_samples, + truck_model = excluded.truck_model, + truck_checked_at = excluded.truck_checked_at""", + (project_id, video_rel, video_rel.split("/")[0], hits, samples, + label, time.time()), + ) + + +def count_truck_frames(path: str, model, conf: float = 0.45, + samples: int = TRUCK_SAMPLES) -> tuple: + """How many of `samples` evenly spaced frames show a truck.""" + import cv2 + + capture = cv2.VideoCapture(path) + if not capture.isOpened(): + raise ArchiveIndexError(f"Could not open {path}") + total = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + hits = taken = 0 + try: + for index in range(samples): + # Spread across the middle 90%: the very first and last frames of a + # trigger-started recording can catch the truck half out of shot. + position = int(total * (0.05 + 0.9 * index / max(1, samples - 1))) + capture.set(cv2.CAP_PROP_POS_FRAMES, position) + ok, frame = capture.read() + if not ok or frame is None: + continue + taken += 1 + result = model.predict(frame, conf=conf, verbose=False)[0] + names = model.names + if any(names[int(box.cls[0])] == "truck" for box in result.boxes): + hits += 1 + finally: + capture.release() + return hits, taken + + +def queue_truck_scan(project_id: int, model_path: str, rescan: bool = False) -> dict: + project = projects.get(project_id) + if project is None: + raise ArchiveIndexError("No such project") + if not os.path.isfile(model_path): + raise ArchiveIndexError(f"Model not found: {model_path}") + return jobs.create( + "truck-scan", + params={"model_path": model_path, "rescan": rescan}, + project_id=project_id, + message="checking each recording for a truck", + ).to_dict() + + +@jobs.handler("truck-scan") +def _run_truck_scan(job) -> None: + import numpy as np + from ultralytics import YOLO + + project = projects.get(job.project_id) + model_path = job.params["model_path"] + rescan = bool(job.params.get("rescan")) + known = index(job.project_id) + + todo = [] + for day in library.list_dates(project["video_root"]): + for name in _video_names(project, day["date"]): + rel = f"{day['date']}/{name}" + row = known.get(rel) or {} + if not rescan and row.get("truck_samples"): + continue + todo.append(rel) + + job.progress(0, len(todo)) + job.log(f"Checking {len(todo)} recording(s) for a truck, " + f"{TRUCK_SAMPLES} frames each, with {os.path.basename(model_path)}") + + model = YOLO(model_path) + model(np.zeros((720, 1280, 3), dtype=np.uint8), imgsz=640, verbose=False) + + empty = broken = 0 + for position, rel in enumerate(todo): + if job.cancelled: + job.log(f"Cancelled after {position} recording(s)") + return + try: + path = library.resolve(project["video_root"], rel) + hits, taken = count_truck_frames(path, model) + except Exception as exc: + store_truck(job.project_id, rel, 0, 0, model_path) + job.log(f"{rel}: {exc}") + broken += 1 + job.progress(position + 1, len(todo)) + continue + + store_truck(job.project_id, rel, hits, taken, model_path) + if taken and hits == 0: + empty += 1 + job.log(f"{rel}: no truck in any of {taken} sampled frames — " + "this recording may not be a batch") + job.progress(position + 1, len(todo), rel) + + job.log(f"Done. {empty} recording(s) with no truck, {broken} unreadable") + + +def queue_scan(project_id: int, rescan: bool = False) -> dict: + project = projects.get(project_id) + if project is None: + raise ArchiveIndexError("No such project") + return jobs.create( + "clock-scan", + params={"rescan": rescan}, + project_id=project_id, + message="reading timestamps from the archive", + ).to_dict() + + +@jobs.handler("clock-scan") +def _run_scan(job) -> None: + project = projects.get(job.project_id) + rescan = bool(job.params.get("rescan")) + existing = index(job.project_id) + + todo = [] + for day in library.list_dates(project["video_root"]): + for video in library.list_videos(project["video_root"], day["date"]): + rel = video["rel"] + known = existing.get(rel) + # A hand-entered time is never re-read; a rescan redoes the rest. + if known and (known["source"] == "manual" + or (not rescan and known["started_at"])): + continue + todo.append(rel) + + job.progress(0, len(todo)) + job.log(f"Reading the timestamp overlay from {len(todo)} recording(s)") + read = flagged = failed = 0 + + for position, rel in enumerate(todo): + if job.cancelled: + job.log(f"Cancelled after {position} recording(s)") + return + side = _sidecar(project, rel) + if side is not None: + store(job.project_id, rel, side["started_at"], confidence=1.0, + agreeing=99, source=side["source"]) + read += 1 + job.progress(position + 1, len(todo), rel) + continue + try: + path = library.resolve(project["video_root"], rel) + result = video_clock.read_video_start(path) + except Exception as exc: + store(job.project_id, rel, None, error=f"{type(exc).__name__}: {exc}") + job.log(f"{rel}: {exc}") + failed += 1 + job.progress(position + 1, len(todo)) + continue + + if result["start"] is None: + store(job.project_id, rel, None, error=result.get("error", "unreadable")) + failed += 1 + else: + confidence, agreeing = result["confidence"], result.get("agreeing", 1) + store(job.project_id, rel, result["start"].strftime("%Y-%m-%d %H:%M:%S"), + confidence=confidence, agreeing=agreeing) + if _trusted(confidence, agreeing): + read += 1 + else: + flagged += 1 + job.log(f"{rel}: {result['start']} — low confidence " + f"({confidence}, {agreeing} frame(s) agreed), needs review") + job.progress(position + 1, len(todo), f"{rel}") + + job.log(f"Read {read}, flagged {flagged} for review, {failed} unreadable") diff --git a/backend/augment.py b/backend/augment.py new file mode 100644 index 0000000..7e1f50d --- /dev/null +++ b/backend/augment.py @@ -0,0 +1,90 @@ +"""Augmentation settings, stored per project and passed to Ultralytics (REQ-110…113). + +Ultralytics augments during training whether or not we ask it to. Before this +module, `training.py` passed no augmentation arguments at all, so every run used +library defaults invisibly. MEDIUM below *is* that default set — a project that +has never been touched trains exactly as it did, only now it is written down. + +Validation is never augmented; that is Ultralytics' own behaviour and REQ-112 +only requires that we do not defeat it. +""" + +import json +from typing import Optional + +from backend import db + +# name -> (minimum, maximum). Bounds are Ultralytics' own accepted ranges. +FIELDS = { + "fliplr": (0.0, 1.0), + "flipud": (0.0, 1.0), + "degrees": (0.0, 180.0), + "translate": (0.0, 1.0), + "scale": (0.0, 1.0), + "hsv_h": (0.0, 1.0), + "hsv_s": (0.0, 1.0), + "hsv_v": (0.0, 1.0), + "mosaic": (0.0, 1.0), +} + +OFF = {name: 0.0 for name in FIELDS} + +LIGHT = {"fliplr": 0.5, "flipud": 0.0, "degrees": 0.0, "translate": 0.05, + "scale": 0.2, "hsv_h": 0.010, "hsv_s": 0.4, "hsv_v": 0.3, "mosaic": 0.0} + +# Ultralytics' defaults, spelled out. +MEDIUM = {"fliplr": 0.5, "flipud": 0.0, "degrees": 0.0, "translate": 0.1, + "scale": 0.5, "hsv_h": 0.015, "hsv_s": 0.7, "hsv_v": 0.4, "mosaic": 1.0} + +AGGRESSIVE = {"fliplr": 0.5, "flipud": 0.1, "degrees": 10.0, "translate": 0.2, + "scale": 0.9, "hsv_h": 0.020, "hsv_s": 0.9, "hsv_v": 0.5, "mosaic": 1.0} + +PRESETS = {"off": OFF, "light": LIGHT, "medium": MEDIUM, "aggressive": AGGRESSIVE} + + +class AugmentError(Exception): + pass + + +def normalise(incoming: Optional[dict]) -> dict: + """Fill in missing keys from MEDIUM and reject out-of-range values.""" + settings = dict(MEDIUM) + for name, value in (incoming or {}).items(): + if name not in FIELDS: + raise AugmentError(f"'{name}' is not an augmentation setting") + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise AugmentError(f"'{name}' must be a number") + low, high = FIELDS[name] + if not low <= value <= high: + raise AugmentError(f"'{name}' must be between {low} and {high}") + settings[name] = float(value) + return settings + + +def preset_name(settings: dict) -> str: + """Which preset these settings match, or 'custom'.""" + for name, preset in PRESETS.items(): + if all(abs(settings[field] - preset[field]) < 1e-9 for field in FIELDS): + return name + return "custom" + + +def get(project_id: int) -> dict: + with db.cursor() as cur: + cur.execute("SELECT augment FROM projects WHERE id = ?", (project_id,)) + row = cur.fetchone() + if row is None: + raise AugmentError("No such project") + stored = json.loads(row[0]) if row[0] else None + settings = normalise(stored) + return {"settings": settings, "preset": preset_name(settings)} + + +def save(project_id: int, incoming: dict) -> dict: + settings = normalise(incoming) + with db.cursor() as cur: + cur.execute("UPDATE projects SET augment = ? WHERE id = ?", + (json.dumps(settings), project_id)) + if cur.rowcount == 0: + raise AugmentError("No such project") + return {"settings": settings, "preset": preset_name(settings)} diff --git a/backend/autolabel.py b/backend/autolabel.py index da07011..2657481 100644 --- a/backend/autolabel.py +++ b/backend/autolabel.py @@ -9,11 +9,14 @@ keep the user's own corrections out of the way. import os from typing import List, Optional +from PIL import Image from backend import batches, db, jobs, labeling, projects, review from backend.batches import BatchError DEFAULT_THRESHOLD = 0.35 -DEFAULT_IOU = 0.8 +DEFAULT_IOU = 0.0 + + def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD, @@ -88,11 +91,15 @@ def _run_autolabel(job) -> None: custom_path = job.params.get("custom_model_path") target_class_names = job.params.get("target_class_names") + engine_classes = job.params.get("engine_classes") + if not target_class_names and isinstance(engine_classes, dict): + c_names = engine_classes.get(selected_engine) or engine_classes.get("sam3") or [] + if isinstance(c_names, list) and len(c_names) > 0: + target_class_names = c_names if selected_engine == "sam3" and not custom_path: allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None if allowed_classes_set: - sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set] # Add any new target class names that aren't in project classes yet existing_names = {c["name"].strip().lower() for c in project["classes"]} for name in target_class_names: @@ -100,11 +107,9 @@ def _run_autolabel(job) -> None: try: updated_proj = projects.add_class(project["id"], name=name.strip(), prompt=name.strip()) project["classes"] = updated_proj["classes"] - for new_c in project["classes"]: - if new_c["name"].strip().lower() == name.strip().lower() and new_c not in sam3_target_classes: - sam3_target_classes.append(new_c) - except Exception: - pass + except Exception as exc: + job.log(f"Warning adding class '{name}': {exc}") + sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set] else: sam3_target_classes = [c for c in project["classes"]] @@ -149,6 +154,8 @@ def _run_autolabel(job) -> None: try: frame_file = os.path.join(directory, frame["filename"]) + fw = max(1, frame.get("width") or 1) + fh = max(1, frame.get("height") or 1) all_raw_detections = [] if yolo_model is not None: @@ -157,27 +164,41 @@ def _run_autolabel(job) -> None: model_names = results[0].names for box in results[0].boxes: cls_idx = int(box.cls[0].item()) - cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower() - - if allowed_classes_set is not None and cls_name not in allowed_classes_set: - continue + raw_cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower() + + target_class_id = name_to_class_id.get(raw_cls_name) + if target_class_id is None: + for item in project["classes"]: + if item["class_id"] == cls_idx: + target_class_id = item["class_id"] + break + if target_class_id is None and 0 <= cls_idx < len(project["classes"]): + target_class_id = project["classes"][cls_idx]["class_id"] - target_class_id = name_to_class_id.get(cls_name) if target_class_id is None: continue + target_cls_obj = next((c for c in project["classes"] if c["class_id"] == target_class_id), None) + proj_cls_name = target_cls_obj["name"].strip().lower() if target_cls_obj else "" + + if allowed_classes_set is not None: + if (raw_cls_name not in allowed_classes_set and + proj_cls_name not in allowed_classes_set and + str(target_class_id) not in allowed_classes_set): + continue + score = float(box.conf[0].item()) xyxyn = box.xyxyn[0].tolist() all_raw_detections.append(labeling.Detection( class_id=target_class_id, - class_name=cls_name, - box=[xyxyn[0]*frame["width"], xyxyn[1]*frame["height"], xyxyn[2]*frame["width"], xyxyn[3]*frame["height"]], + class_name=proj_cls_name or raw_cls_name, + box=[xyxyn[0]*fw, xyxyn[1]*fh, xyxyn[2]*fw, xyxyn[3]*fh], score=score, mask=None )) - elif selected_engine == "sam3" and sam3_target_classes: - prompts = [c["prompt"] for c in sam3_target_classes] + if selected_engine == "sam3" and sam3_target_classes: + prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes] 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) @@ -189,15 +210,17 @@ def _run_autolabel(job) -> None: det.class_id = real_cls["class_id"] det.class_name = real_cls["name"] all_raw_detections.append(det) + elif res.error: + job.log(f"[SAM3 ERROR] {frame['filename']}: {res.error}") kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh) items = [] for det in kept: if project["label_type"] == "bbox" or det.mask is None: - geom = review.bbox(det.box[0]/frame["width"], det.box[1]/frame["height"], det.box[2]/frame["width"], det.box[3]/frame["height"]) + geom = review.bbox(det.box[0]/fw, det.box[1]/fh, det.box[2]/fw, det.box[3]/fh) items.append({"class_id": det.class_id, "geometry": geom, "score": det.score}) else: - for geometry in _geometries(det, frame["width"], frame["height"], project["label_type"]): + for geometry in _geometries(det, fw, fh, project["label_type"]): items.append({"class_id": det.class_id, "geometry": geometry, "score": det.score}) if job.params.get("append"): @@ -211,6 +234,8 @@ def _run_autolabel(job) -> None: job.log(f"[ERROR] {frame['filename']}: {exc}") job.progress(index + 1, len(frames)) + + # "Every frame failed" is not a finished job with no findings — it is a # broken run, and reporting `done` for it would be the system lying about # its own state. An empty frame is fine (REQ-033); an errored one is not. @@ -234,3 +259,125 @@ def _reset_reviewed(batch_id: int) -> None: "AND review_status = 'approved'", (batch_id,), ) + + +def preview_frame( + batch_id: int, + frame_id: int, + engine: str, + threshold: float = DEFAULT_THRESHOLD, + iou_threshold: float = DEFAULT_IOU, + min_box_frac: float = 0.0, + target_class_names: Optional[List[str]] = None, + custom_model_path: Optional[str] = None +) -> List[dict]: + batch = batches.get(batch_id) + if not batch: + raise ValueError("No such batch") + project = projects.get(batch["project_id"]) + + frame = next((f for f in batches.frames(batch_id) if f["id"] == frame_id), None) + if not frame: + raise ValueError("Frame not found") + + directory = batches.frames_dir(batch["project_slug"], batch_id) + frame_file = os.path.join(directory, frame["filename"]) + fw = max(1, frame.get("width") or 1) + fh = max(1, frame.get("height") or 1) + + yolo_model = None + sam3_target_classes = [] + + if engine == "sam3" and not custom_model_path: + allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None + if allowed_classes_set: + sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set] + else: + sam3_target_classes = [c for c in project["classes"]] + + prompts = [c["prompt"] for c in sam3_target_classes] + if prompts: + from backend.sam3_engine import get_engine + get_engine() + else: + from ultralytics import YOLO + if custom_model_path and os.path.isfile(custom_model_path): + m_path = custom_model_path + else: + m_path = projects.training_start_point(project) + with db.cursor() as cur: + cur.execute("SELECT weights_path FROM model_versions WHERE project_id = ? ORDER BY version DESC LIMIT 1", (project["id"],)) + row = cur.fetchone() + if row and os.path.isfile(row[0]): + m_path = row[0] + yolo_model = YOLO(m_path) + + 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 + + all_raw_detections = [] + + if yolo_model is not None: + results = yolo_model.predict(frame_file, conf=threshold, verbose=False) + if results and len(results) > 0: + model_names = results[0].names + for box in results[0].boxes: + cls_idx = int(box.cls[0].item()) + raw_cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower() + + target_class_id = name_to_class_id.get(raw_cls_name) + if target_class_id is None: + for item in project["classes"]: + if item["class_id"] == cls_idx: + target_class_id = item["class_id"] + break + if target_class_id is None and 0 <= cls_idx < len(project["classes"]): + target_class_id = project["classes"][cls_idx]["class_id"] + + if target_class_id is None: + continue + + target_cls_obj = next((c for c in project["classes"] if c["class_id"] == target_class_id), None) + proj_cls_name = target_cls_obj["name"].strip().lower() if target_cls_obj else "" + + if allowed_classes_set is not None: + if (raw_cls_name not in allowed_classes_set and + proj_cls_name not in allowed_classes_set and + str(target_class_id) not in allowed_classes_set): + continue + + score = float(box.conf[0].item()) + xyxyn = box.xyxyn[0].tolist() + all_raw_detections.append(labeling.Detection( + class_id=target_class_id, + class_name=proj_cls_name or raw_cls_name, + box=[xyxyn[0]*fw, xyxyn[1]*fh, xyxyn[2]*fw, xyxyn[3]*fh], + score=score, + mask=None + )) + + if engine == "sam3" and sam3_target_classes: + prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes] + res = labeling.label_image( + frame_file, frame["filename"], prompts, threshold, + iou_threshold=iou_threshold, min_box_frac=min_box_frac + ) + if not res.error and res.detections: + for det in res.detections: + if 0 <= det.class_id < len(sam3_target_classes): + real_cls = sam3_target_classes[det.class_id] + det.class_id = real_cls["class_id"] + det.class_name = real_cls["name"] + all_raw_detections.append(det) + + kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold) + items = [] + for det in kept: + if project["label_type"] == "bbox" or det.mask is None: + geom = review.bbox(det.box[0]/fw, det.box[1]/fh, det.box[2]/fw, det.box[3]/fh) + items.append({"class_id": det.class_id, "geometry": geom, "score": det.score}) + else: + for geometry in _geometries(det, fw, fh, project["label_type"]): + items.append({"class_id": det.class_id, "geometry": geometry, "score": det.score}) + + return items diff --git a/backend/base_dataset.py b/backend/base_dataset.py new file mode 100644 index 0000000..b281b0a --- /dev/null +++ b/backend/base_dataset.py @@ -0,0 +1,220 @@ +"""Base datasets: externally-labelled images that ride along with training. + +A base dataset is not a batch and never becomes one. It has no frames, no +review, no triage — it is a folder of images and YOLO label files that the user +already trusts, registered against a project and offered as a checkbox next to +the project's own datasets (REQ-120…123). + +It stays outside `dataset_items` on purpose. That table keys on `frame_id`, and +inventing frame rows for images this app never extracted would put fake batches +in front of the user for the rest of the project's life. Instead the rows are +handed straight to `dataset._build_selected_tree`, which only ever wanted an +image path and a label path. +""" + +import json +import os +import shutil +import time +from typing import List, Optional + +from backend import config, db + +IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".webp") + + +class BaseDatasetError(Exception): + pass + + +def root_dir(project_slug: str, base_id: int) -> str: + return os.path.join(config.project_dir(project_slug), "base_datasets", str(base_id)) + + +def listing(project_id: int) -> List[dict]: + with db.cursor() as cur: + cur.execute( + """SELECT id, project_id, name, source, image_count, box_count, classes, created_at + FROM base_datasets WHERE project_id = ? ORDER BY created_at DESC""", + (project_id,), + ) + return [_row(r) for r in cur.fetchall()] + + +def get(base_id: int) -> Optional[dict]: + with db.cursor() as cur: + cur.execute( + """SELECT id, project_id, name, source, image_count, box_count, classes, created_at + FROM base_datasets WHERE id = ?""", + (base_id,), + ) + row = cur.fetchone() + return _row(row) if row else None + + +def _row(row) -> dict: + return { + "id": row[0], "project_id": row[1], "name": row[2], "source": row[3], + "image_count": row[4], "box_count": row[5], + "classes": json.loads(row[6] or "[]"), "created_at": row[7], + } + + +def delete(base_id: int, project_slug: str) -> bool: + with db.cursor() as cur: + cur.execute("DELETE FROM base_datasets WHERE id = ?", (base_id,)) + removed = cur.rowcount > 0 + if removed: + shutil.rmtree(root_dir(project_slug, base_id), ignore_errors=True) + return removed + + +def rows(project_id: int, base_ids: List[int], project_slug: str) -> List[dict]: + """Image/label pairs for the run's `selected/` tree. + + Everything is `train`. A base dataset must not contribute validation images: + the base-vs-new comparison is only meaningful measured on this project's own + val split, and REQ-052 keeps that split stable (REQ-122). + """ + if not base_ids: + return [] + out = [] + for base_id in base_ids: + record = get(base_id) + if record is None or record["project_id"] != project_id: + continue + root = root_dir(project_slug, base_id) + images_dir = os.path.join(root, "images") + labels_dir = os.path.join(root, "labels") + if not os.path.isdir(images_dir): + continue + for name in sorted(os.listdir(images_dir)): + if not name.lower().endswith(IMAGE_EXTS): + continue + out.append({ + "frame_id": None, + "split": "train", + "source_image": os.path.join(images_dir, name), + "source_label": os.path.join(labels_dir, os.path.splitext(name)[0] + ".txt"), + }) + return out + + +def import_tree(project_id: int, project_slug: str, source_dir: str, name: str, + keep_class_ids: List[int], on_progress=None) -> dict: + """Adopt an unpacked YOLO export, keeping only `keep_class_ids`. + + Class ids are kept as they are — the caller has already checked that the + export numbers its classes the same way the project does. A label line for a + class we are not keeping is dropped; an image left with no lines at all is + dropped with it, because an empty label is a claim that the image contains + none of the kept classes, and here it only means "the box was a truck". + """ + pairs = _collect(source_dir) + if not pairs: + raise BaseDatasetError(f"No image/label pairs found under {source_dir}") + + keep = set(keep_class_ids) + with db.cursor() as cur: + cur.execute( + """INSERT INTO base_datasets (project_id, name, source, classes, created_at) + VALUES (?, ?, ?, ?, ?)""", + (project_id, name, os.path.basename(source_dir.rstrip("/")), + json.dumps(sorted(keep)), time.time()), + ) + base_id = cur.lastrowid + + root = root_dir(project_slug, base_id) + images_dir = os.path.join(root, "images") + labels_dir = os.path.join(root, "labels") + os.makedirs(images_dir, exist_ok=True) + os.makedirs(labels_dir, exist_ok=True) + + images = boxes = skipped = 0 + for index, (image_path, label_path) in enumerate(pairs): + lines = [] + if os.path.isfile(label_path): + with open(label_path, encoding="utf-8") as handle: + for line in handle.read().splitlines(): + if not line.strip(): + continue + parts = line.split() + try: + class_id = int(parts[0]) + except (ValueError, IndexError): + continue + if class_id not in keep: + continue + normalised = _to_bbox(parts) + if normalised is not None: + lines.append(normalised) + if not lines: + skipped += 1 + continue + + stem = os.path.basename(image_path) + shutil.copyfile(image_path, os.path.join(images_dir, stem)) + with open(os.path.join(labels_dir, os.path.splitext(stem)[0] + ".txt"), + "w", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + images += 1 + boxes += len(lines) + if on_progress is not None and index % 25 == 0: + on_progress(index + 1, len(pairs)) + + with db.cursor() as cur: + cur.execute("UPDATE base_datasets SET image_count = ?, box_count = ? WHERE id = ?", + (images, boxes, base_id)) + + return {**get(base_id), "skipped": skipped, "candidates": len(pairs)} + + +def _to_bbox(parts: List[str]) -> Optional[str]: + """Normalise one YOLO label line to `class cx cy w h`. + + Roboflow exports segmentation polygons when the source project was drawn + that way, and a detect model reads the first four numbers of such a line as + a box — which lands somewhere near the first two polygon vertices and is + nowhere near the object. Polygons are collapsed to their bounding box, which + is the honest projection of a mask onto a bbox dataset. + """ + class_id, coords = parts[0], parts[1:] + if len(coords) == 4: + return " ".join([class_id] + coords) + if len(coords) < 6 or len(coords) % 2 != 0: + return None + try: + values = [float(v) for v in coords] + except ValueError: + return None + xs, ys = values[0::2], values[1::2] + x0, x1 = min(xs), max(xs) + y0, y1 = min(ys), max(ys) + width, height = x1 - x0, y1 - y0 + if width <= 0 or height <= 0: + return None + return (f"{class_id} {(x0 + x1) / 2:.6f} {(y0 + y1) / 2:.6f} " + f"{width:.6f} {height:.6f}") + + +def _collect(source_dir: str) -> List[tuple]: + """Every image under the tree, paired with its sibling label file. + + Handles both a flat `images/`+`labels/` pair and the split layout Roboflow + exports (`train/images`, `valid/labels`, …). Deduplicated by file name, so + re-importing an export that overlaps an earlier one cannot double-weight the + same picture. + """ + seen = {} + for current, _dirs, files in os.walk(source_dir): + if os.path.basename(current) != "images": + continue + labels = os.path.join(os.path.dirname(current), "labels") + for name in sorted(files): + if not name.lower().endswith(IMAGE_EXTS): + continue + if name in seen: + continue + seen[name] = (os.path.join(current, name), + os.path.join(labels, os.path.splitext(name)[0] + ".txt")) + return [seen[key] for key in sorted(seen)] diff --git a/backend/batches.py b/backend/batches.py index 9ee76fc..8ccedd9 100644 --- a/backend/batches.py +++ b/backend/batches.py @@ -209,6 +209,8 @@ def _run_extract(job) -> None: job.log(f"Extracted {len(names)} frame(s) at {width}×{height}") + + def update(batch_id: int, patch: dict) -> dict: batch = get(batch_id) if batch is None: @@ -236,9 +238,32 @@ def update(batch_id: int, patch: dict) -> dict: def delete(batch_id: int) -> bool: import shutil + from backend import dataset + batch = get(batch_id) if batch is None: return False + + # The FK cascade takes the frames and dataset_items rows, but the copies this + # batch put in the master dataset live in a sibling tree and would survive as + # orphans — and data.yaml trains and validates on those directories, so the + # model would keep learning from a batch the user believes is gone. + with db.cursor() as cur: + cur.execute( + """SELECT d.image_rel, d.label_rel, d.dataset_id FROM dataset_items d + JOIN frames f ON f.id = d.frame_id + WHERE f.batch_id = ?""", + (batch_id,), + ) + merged_files = cur.fetchall() + for image_rel, label_rel, dataset_id in merged_files: + root = dataset.dataset_dir(batch["project_slug"], dataset_id) + for rel in (image_rel, label_rel): + try: + os.unlink(os.path.join(root, rel)) + except FileNotFoundError: + pass + with db.cursor() as cur: cur.execute("DELETE FROM batches WHERE id = ?", (batch_id,)) shutil.rmtree(batch_dir(batch["project_slug"], batch_id), ignore_errors=True) diff --git a/backend/counting_bench.py b/backend/counting_bench.py new file mode 100644 index 0000000..d0391d6 --- /dev/null +++ b/backend/counting_bench.py @@ -0,0 +1,319 @@ +"""Batch counting bench: run the counter over archive videos and score it (REQ-150…153). + +This is the offline twin of `live_count`. Same model, same tracker, stabiliser +and `LineCrossCounter`, same defaults — but no MJPEG stream, no annotated frame, +no JPEG encode. Rendering is most of the per-frame cost once the model is warm, +so dropping it is what makes counting a 30-minute video practical. + +The point is measurement, not watching: a row per video, the counter's numbers +beside a ground truth you type in, and the signed error between them. A model +that counts 98 where you counted 100 is a different problem from one that counts +103, and a single accuracy percentage hides which of the two you have. +""" + +import os +import time +from typing import List, Optional + +from backend import archive_index, db, jobs, library, projects + +# Defaults are the values dialled in against the real camera. A run records the +# parameters it used, so a row always says what produced it. +DEFAULTS = { + "line_y": 266, "line_x_start": 469, "line_x_end": 910, + "margin": 5, "conf": 0.35, "imgsz": 640, + "entry_travel_min": 60.0, "handoff_radius": 100.0, + "unload_confirm_frames": 3, "min_area_scale": 1.0, + "dedup_radius": 60.0, "spatial_dedup": False, +} + + +class CountingBenchError(Exception): + pass + + +def _split_rel(rel: str) -> tuple: + date_label = rel.split("/")[0] if "/" in rel else "" + return date_label, library.batch_label(os.path.basename(rel)) + + +# ---- rows ---------------------------------------------------------------- + +def listing(project_id: int, date: Optional[str] = None) -> dict: + """Every archive video with whatever has been measured for it. + + Videos with no run yet are still rows — the table is the work list, so a + video nobody has counted has to be visible in it. + """ + project = projects.get(project_id) + if project is None: + raise CountingBenchError("No such project") + + with db.cursor() as cur: + cur.execute("SELECT * FROM count_runs WHERE project_id = ?", (project_id,)) + stored = {row["video_rel"]: dict(row) for row in cur.fetchall()} + + dates = [d["date"] for d in library.list_dates(project["video_root"])] + + # Folder names are not when a recording happened, so the grouping and the + # ordering both come from the timestamp index instead (REQ-160…163). + clock = archive_index.index(project_id) + + rows = [] + for day in dates: + # No `project_id`: that argument makes the library kick off an H.264 + # preview transcode per video, and this table never plays anything. + for video in library.list_videos(project["video_root"], day): + rel = video["rel"] + run = stored.get(rel) + timing = clock.get(rel) or {} + rows.append({ + "video_rel": rel, + "folder_date": day, + "date_label": timing.get("working_day") or day, + "working_day": timing.get("working_day") or "", + "started_at": timing.get("started_at"), + "clock_source": timing.get("source") or "", + "clock_trusted": bool(timing.get("trusted")), + "clock_error": timing.get("error") or "", + "batch_label": library.batch_label(os.path.basename(rel)), + "duration": video.get("duration"), + "loading": run["loading"] if run else None, + "unloading": run["unloading"] if run else None, + "net": run["net"] if run else None, + "ground_truth": run["ground_truth"] if run else None, + "frames": run["frames"] if run else 0, + "seconds": round(run["seconds"], 1) if run else 0, + "counted_at": run["counted_at"] if run else None, + "error": run["error"] if run else "", + "model_path": run["model_path"] if run else "", + }) + + rows = archive_index.assign_batch_numbers(rows) + if date: + rows = [r for r in rows if r["date_label"] == date] + return {"rows": rows, "totals": totals(rows), + "unindexed": sum(1 for r in rows if not r.get("started_at"))} + + +def totals(rows: List[dict]) -> dict: + """Only rows with a ground truth score. An unmeasured video is not a + perfect one, and letting it into the denominator would say it was.""" + scored = [r for r in rows + if r["ground_truth"] is not None and r["loading"] is not None] + predicted = sum(r["loading"] for r in scored) + truth = sum(r["ground_truth"] for r in scored) + return { + "counted_videos": sum(1 for r in rows if r["loading"] is not None), + "total_videos": len(rows), + "scored_videos": len(scored), + "predicted": predicted, + "ground_truth": truth, + "delta": predicted - truth, + "accuracy": round(100.0 * (1 - abs(predicted - truth) / truth), 2) if truth else None, + } + + +def set_ground_truth(project_id: int, video_rel: str, value: Optional[int]) -> dict: + """Record what you actually counted. Kept even when no run exists yet, so + the truth can be entered while the recount is still queued.""" + date_label, batch_label = _split_rel(video_rel) + with db.cursor() as cur: + cur.execute( + """INSERT INTO count_runs (project_id, video_rel, date_label, batch_label, + ground_truth) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(project_id, video_rel) + DO UPDATE SET ground_truth = excluded.ground_truth""", + (project_id, video_rel, date_label, batch_label, value), + ) + cur.execute("SELECT * FROM count_runs WHERE project_id = ? AND video_rel = ?", + (project_id, video_rel)) + return dict(cur.fetchone()) + + +def _store(project_id: int, video_rel: str, result: dict, params: dict, + model_path: str) -> None: + import json + + date_label, batch_label = _split_rel(video_rel) + with db.cursor() as cur: + cur.execute( + """INSERT INTO count_runs (project_id, video_rel, date_label, batch_label, + loading, unloading, net, frames, seconds, params, + model_path, error, counted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, video_rel) DO UPDATE SET + loading = excluded.loading, unloading = excluded.unloading, + net = excluded.net, frames = excluded.frames, + seconds = excluded.seconds, params = excluded.params, + model_path = excluded.model_path, error = excluded.error, + counted_at = excluded.counted_at""", + (project_id, video_rel, date_label, batch_label, + result.get("loading"), result.get("unloading"), result.get("net"), + result.get("frames", 0), result.get("seconds", 0.0), + json.dumps(params), model_path, result.get("error", ""), time.time()), + ) + + +# ---- the run itself ------------------------------------------------------ + +def count_video(path: str, model, params: dict, should_cancel=None, + on_progress=None) -> dict: + """Count one video end to end. No drawing, no encoding — just the numbers.""" + import cv2 + + # Imported first: `live_count` is what puts `algoritma-batch` on sys.path, so + # the `src.*` imports below only resolve once it has been loaded. + from backend.live_count import _too_small + from src.counting import LineCrossCounter + from src.stabilizer import BboxStabilizer + from src.tracking import ByteTrackTracker + + settings = {**DEFAULTS, **params} + capture = cv2.VideoCapture(path) + if not capture.isOpened(): + raise CountingBenchError(f"Could not open {path}") + total_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + + tracker = ByteTrackTracker(model, settings["conf"]) + stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10, + max_height_ratio=1.5, min_height_ratio=0.70) + counter = LineCrossCounter( + line_y=settings["line_y"], line_x_start=settings["line_x_start"], + line_x_end=settings["line_x_end"], margin=settings["margin"], + dedup_radius=settings["dedup_radius"], + entry_travel_min=settings["entry_travel_min"], + handoff_radius=settings["handoff_radius"], + unload_confirm_frames=settings["unload_confirm_frames"], + spatial_dedup=settings["spatial_dedup"], + ) + + started = time.time() + frames = 0 + try: + while True: + if should_cancel is not None and should_cancel(): + break + ok, frame = capture.read() + if not ok or frame is None: + break + frame = cv2.resize(frame, (1280, 720)) + detections = [d for d in tracker.update(frame, []) if d.class_name == "sack"] + stable = stabilizer.update(detections) + inside = [ + d for d in stable + if not _too_small(d.bbox, settings["min_area_scale"]) + and settings["line_x_start"] <= (d.bbox[0] + d.bbox[2]) / 2 <= settings["line_x_end"] + ] + counter.update(inside) + counter.drain_traces() # bounded memory; traces are the live view's job + frames += 1 + if on_progress is not None and frames % 50 == 0: + on_progress(frames, total_frames) + finally: + capture.release() + + return { + "loading": counter.loading_count, + "unloading": counter.unloading_count, + "net": counter.net_count, + "frames": frames, + "seconds": round(time.time() - started, 1), + } + + +def queue(project_id: int, video_rels: List[str], model_path: str, + params: Optional[dict] = None, recount: bool = False) -> dict: + """Queue one job for the whole selection (REQ-152). + + One job rather than one per video: they share a model load, and the GPU can + only run them one at a time anyway. + """ + project = projects.get(project_id) + if project is None: + raise CountingBenchError("No such project") + if not video_rels: + raise CountingBenchError("Pick at least one video to count") + if not os.path.isfile(model_path): + raise CountingBenchError(f"Model not found: {model_path}") + + if not recount: + with db.cursor() as cur: + cur.execute( + """SELECT video_rel FROM count_runs + WHERE project_id = ? AND loading IS NOT NULL""", + (project_id,), + ) + done = {row[0] for row in cur.fetchall()} + video_rels = [rel for rel in video_rels if rel not in done] + if not video_rels: + raise CountingBenchError( + "Every video in this selection has already been counted — " + "tick 'recount' to run them again") + + job = jobs.create( + "count", + params={"video_rels": video_rels, "model_path": model_path, + "params": {**DEFAULTS, **(params or {})}}, + project_id=project_id, + message=f"{len(video_rels)} video(s)", + ) + return job.to_dict() + + +@jobs.handler("count") +def _run_count(job) -> None: + from ultralytics import YOLO + import numpy as np + + project = projects.get(job.project_id) + rels = job.params["video_rels"] + settings = job.params.get("params") or DEFAULTS + model_path = job.params["model_path"] + + job.progress(0, len(rels)) + job.log(f"Counting {len(rels)} video(s) with {os.path.basename(model_path)}") + + model = YOLO(model_path) + # Same warm-up as the live path: the first CUDA call inside the tracker has + # been seen to segfault without it. + model(np.zeros((720, 1280, 3), dtype=np.uint8), imgsz=settings.get("imgsz", 640), + verbose=False) + + for index, rel in enumerate(rels): + if job.cancelled: + job.log(f"Cancelled after {index} video(s)") + return + try: + path = library.resolve(project["video_root"], rel) + except library.LibraryError as exc: + _store(job.project_id, rel, {"error": str(exc)}, settings, model_path) + job.log(f"{rel}: {exc}") + job.progress(index + 1, len(rels)) + continue + + def report(done, total, rel=rel, index=index): + job.progress(index, len(rels), f"{rel} — {done}/{total or '?'} frames") + + try: + result = count_video(path, model, settings, + should_cancel=lambda: job.cancelled, + on_progress=report) + except Exception as exc: + _store(job.project_id, rel, {"error": f"{type(exc).__name__}: {exc}"}, + settings, model_path) + job.log(f"{rel}: FAILED {exc}") + job.progress(index + 1, len(rels)) + continue + + # A cancel mid-video leaves a partial count, which would read as a real + # measurement of a video that was never finished. + if job.cancelled: + job.log(f"Cancelled during {rel} — its count was not saved") + return + _store(job.project_id, rel, result, settings, model_path) + rate = result["frames"] / result["seconds"] if result["seconds"] else 0 + job.log(f"{rel}: in {result['loading']} out {result['unloading']} " + f"net {result['net']} ({result['frames']} frames, {rate:.0f} fps)") + job.progress(index + 1, len(rels)) diff --git a/backend/dataset.py b/backend/dataset.py index 85441b0..f150186 100644 --- a/backend/dataset.py +++ b/backend/dataset.py @@ -11,48 +11,115 @@ Label files are plain YOLO: segment class_id x1 y1 x2 y2 … (normalized polygon) """ +import hashlib import os import shutil import time from typing import List, Optional -from backend import batches, config, db, jobs, projects, review +from backend import batches, config, datasets, db, jobs, projects, review, triage + +DatasetError = datasets.DatasetError -class DatasetError(Exception): - pass +def dataset_dir(project_slug: str, dataset_id: int) -> str: + return datasets.dataset_root(project_slug, dataset_id) -def dataset_dir(project_slug: str) -> str: - return os.path.join(config.project_dir(project_slug), "dataset") +def runs_dir(project_slug: str) -> str: + """Where a training run's assembled view lives. + + It sits outside any one dataset because a run may combine several, and the + combined view belongs to the run, not to any of its sources. + """ + return os.path.join(config.project_dir(project_slug), "runs") -def approve(batch_id: int) -> dict: - """Sign a batch off and queue its merge (REQ-045, REQ-050).""" - batch = batches.get(batch_id) - if batch is None: - raise DatasetError("No such batch") - if batch["status"] == "merged": - raise DatasetError("This batch is already in the master dataset") - if batch["review"]["pending"] > 0: - raise DatasetError( - f"{batch['review']['pending']} frame(s) still need a decision before this " - "batch can be approved" +def approve(batch_ids, dataset_id: Optional[int] = None, + dataset_name: str = "") -> dict: + """Sign a selection off and queue one merge into one named dataset (REQ-131). + + The rules in force right now are frozen onto the target dataset (REQ-132): + the merge runs under them, and a later rule edit cannot rewrite what this + dataset claims to be. + + Without `dataset_id` a new dataset is created, so merging the same batches + again never collides with the earlier result — it produces a second dataset + holding them as they look now. + """ + ids = triage.as_ids(batch_ids) + if not ids: + raise DatasetError("Pick at least one batch to merge") + selected = [] + for batch_id in ids: + batch = batches.get(batch_id) + if batch is None: + raise DatasetError("No such batch") + if batch["review"]["approved"] == 0: + raise DatasetError( + f"No frame in {batch['date_label']}/{batch['batch_label']} is approved " + "— there is nothing to merge") + selected.append(batch) + if len({batch["project_id"] for batch in selected}) > 1: + raise DatasetError("Those batches are not all in the same project") + project_id = selected[0]["project_id"] + + # Frames that are not approved — rejected or never looked at — are simply + # left behind. Only what the user signed off on enters the dataset, so a + # partly-reviewed batch can be merged for the part that is done. + with db.cursor() as cur: + placeholders = ",".join("?" for _ in ids) + cur.execute( + f"""SELECT 1 FROM jobs WHERE batch_id IN ({placeholders}) AND type = 'merge' + AND status IN ('queued', 'running')""", + ids, ) - if batch["review"]["approved"] == 0: - raise DatasetError("Every frame was rejected — there is nothing to merge") + if cur.fetchone() is not None: + raise DatasetError("A merge for one of these batches is already queued") - batches.set_status(batch_id, "approved") + resolver = triage.Resolver(project_id) + if dataset_id is None: + target = datasets.create(project_id, name=dataset_name, + rule_version=resolver.version(), rules=resolver.rules) + dataset_id = target["id"] + else: + target = datasets.get(dataset_id) + if target is None: + raise DatasetError("No such dataset") + if all(_unmerged_approved(batch["id"], dataset_id) == 0 for batch in selected): + raise DatasetError( + f"Every approved frame of this selection is already in \u201c{target['name']}\u201d") + # An existing dataset keeps the rules it was cut under; a second merge + # into it must not re-cut the frames already there under new ones. + if not target["rules"]: + datasets.snapshot_rules(dataset_id, resolver.rules, resolver.version()) + + for batch in selected: + batches.set_status(batch["id"], "approved") + labels = ", ".join(f"{b['date_label']}/{b['batch_label']}" for b in selected) job = jobs.create( "merge", - params={"batch_id": batch_id}, - project_id=batch["project_id"], - batch_id=batch_id, - message=f"{batch['date_label']}/{batch['batch_label']}", + params={"batch_ids": ids, "dataset_id": dataset_id}, + project_id=project_id, + batch_id=ids[0], + message=labels, ) return job.to_dict() +def _unmerged_approved(batch_id: int, dataset_id: int) -> int: + """Approved frames of this batch not yet in *this* dataset.""" + with db.cursor() as cur: + cur.execute( + """SELECT COUNT(*) FROM frames f + LEFT JOIN dataset_items d + ON d.frame_id = f.id AND d.dataset_id = ? + WHERE f.batch_id = ? AND f.review_status = 'approved' AND d.id IS NULL""", + (dataset_id, batch_id), + ) + return cur.fetchone()[0] + + def _label_line(class_id: int, geometry: dict, label_type: str) -> str: if label_type == "bbox": x0, y0, x1, y1 = review.to_box(geometry) @@ -66,106 +133,245 @@ def _label_line(class_id: int, geometry: dict, label_type: str) -> str: return f"{class_id} {coords}" -def _next_split(cur, project_id: int, val_every: int) -> str: - """Continue the every-Nth pattern from wherever the last merge left off.""" +def split_for(project_id: int, batch_id: int, stem: str, val_every: int) -> str: + """Which split a frame belongs to, derived from its identity rather than from + how many rows happen to precede it. + + A positional every-Nth rule makes membership depend on insertion history, so + deleting or re-merging a batch silently reshuffles every later frame — and a + frame that was in `val` for the last comparison could land in `train` for the + next one. Hashing the identity makes the stable-val-split invariant true by + construction: the same frame always lands in the same split, whatever else + happened to the dataset. Rows already in `dataset_items` keep the split they + were recorded with; nothing recomputes them. + """ if val_every <= 0: return "train" - cur.execute("SELECT COUNT(*) FROM dataset_items WHERE project_id = ?", (project_id,)) - position = cur.fetchone()[0] - return "val" if position % val_every == val_every - 1 else "train" + digest = hashlib.sha1(f"{project_id}/{batch_id}/{stem}".encode("utf-8")).hexdigest() + return "val" if int(digest[:8], 16) % val_every == 0 else "train" -def sync_labels(project_id: int, selected_class_ids: Optional[List[int]] = None) -> dict: - """Re-sync label files on disk for all merged frames in the project dataset.""" - project = projects.get(project_id) - root = dataset_dir(project["slug"]) +def resync(dataset_id: int) -> dict: + """Rewrite one dataset's labels from the current annotations and rules. + + This used to run automatically before every training run, which quietly + undid the triage applied at merge: a dataset merged under "reclass small + boxes" had its labels rebuilt from the raw annotations on the next run, so + the files stopped matching the `rule_version` stamped on them. It is now a + deliberate act, and it re-stamps that version so the dataset never claims a + rule set it is not in. + + Only frames a human signed off on are written. A merged frame whose batch was + auto-annotated again drops back to `pending`, and rewriting its label from + fresh model output would push predictions nobody checked into the dataset. + """ + target = datasets.get(dataset_id) + if target is None: + raise DatasetError("No such dataset") + project = projects.get(target["project_id"]) + resolver = triage.Resolver(project["id"]) + root = dataset_dir(project["slug"], dataset_id) + with db.cursor() as cur: cur.execute( - "SELECT d.frame_id, d.label_rel FROM dataset_items d WHERE d.project_id = ?", - (project_id,), + """SELECT d.frame_id, d.label_rel FROM dataset_items d + JOIN frames f ON f.id = d.frame_id + WHERE d.dataset_id = ? AND f.review_status = 'approved'""", + (dataset_id,), ) items = cur.fetchall() - class_map = None - if selected_class_ids is not None and len(selected_class_ids) > 0: - class_map = {cid: idx for idx, cid in enumerate(sorted(selected_class_ids))} - - synced_files = 0 - total_lines = 0 + written = 0 + emptied = 0 for frame_id, label_rel in items: annotations = review.listing(frame_id) - if class_map is not None: - annotations = [a for a in annotations if a["class_id"] in class_map] - - lines = [] - for item in annotations: - mapped_cid = class_map[item["class_id"]] if class_map is not None else item["class_id"] - lines.append(_label_line(mapped_cid, item["geometry"], project["label_type"])) - + resolved = resolver.resolve_shapes(annotations) + if resolved is None: + # Every shape was dropped. The image stays in the dataset but an + # empty label would claim it is empty, so the file is left as it was + # and the count is reported (REQ-104). + emptied += 1 + continue + lines = [_label_line(item["class_id"], item["geometry"], project["label_type"]) + for item in resolved] path = os.path.join(root, label_rel) os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - f.write("\n".join(lines) + ("\n" if lines else "")) - synced_files += 1 - total_lines += len(lines) + _write_atomic(path, "\n".join(lines) + ("\n" if lines else "")) + written += 1 - return {"synced_files": synced_files, "total_lines": total_lines} + # Resync is the one deliberate way an existing dataset adopts today's rules, + # so the snapshot moves with the labels (REQ-132). + datasets.snapshot_rules(dataset_id, resolver.rules, resolver.version()) + + return {"labels_written": written, "frames_left_alone": emptied, + "rule_version": resolver.version()} -def write_data_yaml(project: dict, batch_ids: list = None, selected_class_ids: Optional[List[int]] = None) -> str: - """Rebuild data.yaml from the project's classes (REQ-051).""" - sync_labels(project["id"], selected_class_ids=selected_class_ids) - root = dataset_dir(project["slug"]) - os.makedirs(root, exist_ok=True) - counts = summary(project["id"])["splits"] +def _write_atomic(path: str, text: str) -> None: + """Write via temp file + rename, so a training run never reads a half-written + label file or a truncated data.yaml.""" + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(text) + os.replace(tmp, path) + + +def _build_selected_tree(run_root: str, rows: list, class_map: Optional[dict]) -> tuple: + """Materialise the run's view of the chosen datasets under `runs/selected/`. + + Labels are copied from what each dataset holds on disk — not re-derived from + the live annotations. A dataset is the snapshot of a batch as it was merged, + under the triage rules recorded in its `rule_version`; re-resolving here + would train on today's rules while the dataset claims yesterday's, and two + runs over the same dataset could then disagree. Change the rules and merge + again into a new dataset, or resync this one on purpose. + + The only thing this does apply is a per-run class filter, which renumbers ids + into a contiguous 0..k-1 space. That contradicts `project_classes`, so it + cannot be written back into the dataset's own label files. + """ + selected_root = os.path.join(run_root, "selected") + if os.path.isdir(selected_root): + shutil.rmtree(selected_root) + listed = {"train": [], "val": []} + excluded = 0 + for row in rows: + split, source_image, source_label = row["split"], row["source_image"], row["source_label"] + lines = _read_label(source_label) + + if class_map is not None: + kept = [] + for line in lines: + head, _, rest = line.partition(" ") + try: + current = int(head) + except ValueError: + continue + if current in class_map: + kept.append(f"{class_map[current]} {rest}") + # A frame that had shapes but none of the chosen classes is not a + # negative sample of those classes — it is a frame full of things the + # run was told to ignore, and an empty label would teach exactly that. + if lines and not kept: + excluded += 1 + continue + lines = kept + + stem = os.path.basename(source_image) + image_dst = os.path.join(selected_root, "images", split, stem) + label_dst = os.path.join(selected_root, "labels", split, + os.path.splitext(stem)[0] + ".txt") + os.makedirs(os.path.dirname(image_dst), exist_ok=True) + os.makedirs(os.path.dirname(label_dst), exist_ok=True) + if not os.path.exists(image_dst): + os.symlink(source_image, image_dst) + _write_atomic(label_dst, "\n".join(lines) + ("\n" if lines else "")) + listed[split].append(image_dst) + listed["excluded"] = excluded + return selected_root, listed + + +def _read_label(path: str) -> List[str]: + if not os.path.isfile(path): + return [] + with open(path, encoding="utf-8") as handle: + return [line for line in handle.read().splitlines() if line.strip()] + + +def write_data_yaml(project: dict, dataset_ids: List[int], batch_ids: list = None, + selected_class_ids: Optional[List[int]] = None, + require_val: bool = False, + base_dataset_ids: Optional[List[int]] = None) -> str: + """Assemble the chosen datasets into one data.yaml for a run (REQ-051, REQ-110). + + Always via the `selected/` tree of symlinks, even for a single dataset with + no filters. The alternative — pointing YOLO at a dataset folder directly — + only works while a run uses exactly one dataset, and it puts a per-run class + renumbering into the shared label files. One assembly path is easier to + trust than two that diverge the moment a second dataset is picked. + """ + if not dataset_ids and not base_dataset_ids: + raise DatasetError("Pick at least one dataset to train on") + run_root = runs_dir(project["slug"]) + os.makedirs(run_root, exist_ok=True) target_classes = project["classes"] + class_map = None if selected_class_ids is not None and len(selected_class_ids) > 0: target_classes = [c for c in project["classes"] if c["class_id"] in selected_class_ids] + class_map = {cid: idx for idx, cid in enumerate(sorted(selected_class_ids))} names = ", ".join(f"'{item['name']}'" for item in target_classes) + items = datasets.combined_items(project["id"], dataset_ids) if batch_ids: - with db.cursor() as cur: - placeholders = ",".join("?" for _ in batch_ids) - cur.execute( - f"""SELECT d.image_rel, d.split FROM dataset_items d - JOIN frames f ON f.id = d.frame_id - WHERE d.project_id = ? AND f.batch_id IN ({placeholders})""", - [project["id"]] + list(batch_ids), - ) - rows = cur.fetchall() + keep = _frames_of_batches(set(batch_ids)) + items = [item for item in items if item["frame_id"] in keep] - train_files = [row[0] for row in rows if row[1] == "train"] - val_files = [row[0] for row in rows if row[1] == "val"] or train_files + rows = [] + for item in items: + root = dataset_dir(project["slug"], item["dataset_id"]) + rows.append({ + "frame_id": item["frame_id"], + "split": item["split"], + "source_image": os.path.join(root, item["image_rel"]), + "source_label": os.path.join(root, item["label_rel"]), + }) - train_txt = os.path.join(root, "selected_train.txt") - val_txt = os.path.join(root, "selected_val.txt") - with open(train_txt, "w", encoding="utf-8") as handle: - handle.write("\n".join(os.path.join(root, rel) for rel in train_files) + "\n") - with open(val_txt, "w", encoding="utf-8") as handle: - handle.write("\n".join(os.path.join(root, rel) for rel in val_files) + "\n") + # Base datasets are appended, never merged into the dedupe above: they carry + # no frame_id, and they are always train-only (REQ-122). + if base_dataset_ids: + from backend import base_dataset + rows.extend(base_dataset.rows(project["id"], base_dataset_ids, project["slug"])) - path = os.path.join(root, "selected_data.yaml") - with open(path, "w", encoding="utf-8") as handle: - handle.write(f"path: {root}\n") - handle.write(f"train: {train_txt}\n") - handle.write(f"val: {val_txt}\n\n") - handle.write(f"nc: {len(project['classes'])}\n") - handle.write(f"names: [{names}]\n") - return path + selected_root, listed = _build_selected_tree(run_root, rows, class_map) + if require_val: + _require_val(len(listed["val"]), "the selected dataset(s)") - path = os.path.join(root, "data.yaml") - with open(path, "w", encoding="utf-8") as handle: - handle.write(f"path: {root}\n") - handle.write("train: images/train\n") - handle.write(f"val: images/{'val' if counts['val'] > 0 else 'train'}\n\n") - handle.write(f"nc: {len(project['classes'])}\n") - handle.write(f"names: [{names}]\n") + train_txt = os.path.join(run_root, "selected_train.txt") + val_txt = os.path.join(run_root, "selected_val.txt") + _write_atomic(train_txt, "\n".join(listed["train"]) + "\n") + _write_atomic(val_txt, "\n".join(listed["val"]) + "\n") + + path = os.path.join(run_root, "selected_data.yaml") + _write_atomic(path, + f"path: {selected_root}\n" + f"train: {train_txt}\n" + f"val: {val_txt}\n\n" + f"nc: {len(target_classes)}\n" + f"names: [{names}]\n") return path +def _frames_of_batches(batch_ids: set) -> set: + with db.cursor() as cur: + cur.execute( + f"SELECT id FROM frames WHERE batch_id IN ({','.join('?' for _ in batch_ids)})", + list(batch_ids), + ) + return {row[0] for row in cur.fetchall()} + + +def _require_val(count: int, subject: str) -> None: + """Refuse to build a dataset with an empty val split. + + Falling back to the training images produces a base-vs-new mAP measured on + data the model was fitted to — a number that looks fine and means nothing. + For a system whose whole purpose is answering "did retraining help?", this + has to fail loudly. + """ + if count == 0: + raise DatasetError( + f"There are no validation images in {subject}, so a base-vs-new comparison " + "would be measured on the training images. Merge more frames, or lower the " + "project's val_every." + ) + + def summary(project_id: int) -> dict: + """Counts only. The per-shape size analytics that used to live here walked + every annotation in the project on every page load — Data Prep already + serves that, per batch, from `triage`.""" with db.cursor() as cur: cur.execute( "SELECT split, COUNT(*) FROM dataset_items WHERE project_id = ? GROUP BY split", @@ -185,7 +391,12 @@ def summary(project_id: int) -> dict: (project_id,), ) merged = [dict(row) for row in cur.fetchall()] - return {"splits": splits, "total": splits["train"] + splits["val"], "batches": merged} + + return { + "splits": splits, + "total": splits["train"] + splits["val"], + "batches": merged, + } def drop_class_from_labels(project: dict, class_id: int) -> dict: @@ -196,16 +407,15 @@ def drop_class_from_labels(project: dict, class_id: int) -> dict: class that is now `1` — labels that quietly name the wrong thing are worse than labels that are missing. """ - root = dataset_dir(project["slug"]) with db.cursor() as cur: - cur.execute("SELECT label_rel FROM dataset_items WHERE project_id = ?", + cur.execute("SELECT label_rel, dataset_id FROM dataset_items WHERE project_id = ?", (project["id"],)) - label_files = [row[0] for row in cur.fetchall()] + label_files = [(row[0], row[1]) for row in cur.fetchall()] rewritten = 0 dropped = 0 - for rel in label_files: - path = os.path.join(root, rel) + for rel, dataset_id in label_files: + path = os.path.join(dataset_dir(project["slug"], dataset_id), rel) if not os.path.isfile(path): continue with open(path, encoding="utf-8") as handle: @@ -240,74 +450,128 @@ def drop_class_from_labels(project: dict, class_id: int) -> dict: return {"label_files_rewritten": rewritten, "dataset_lines_removed": dropped} -def zip_path(project: dict) -> str: - """Zip the master dataset for download (REQ-054).""" - root = dataset_dir(project["slug"]) +def zip_path(project: dict, dataset_id: int) -> str: + """Zip one dataset for download (REQ-054).""" + root = dataset_dir(project["slug"], dataset_id) if not os.path.isdir(os.path.join(root, "images")): - raise DatasetError("This project's dataset is still empty") - archive = os.path.join(config.project_dir(project["slug"]), "dataset") + raise DatasetError("This dataset is still empty") + archive = os.path.join(config.project_dir(project["slug"]), f"dataset-{dataset_id}") return shutil.make_archive(archive, "zip", root) @jobs.handler("merge") def _run_merge(job) -> None: - batch = batches.get(job.params["batch_id"]) - if batch is None: - raise DatasetError("The batch disappeared before the merge started") - project = projects.get(batch["project_id"]) - root = dataset_dir(project["slug"]) + ids = job.params.get("batch_ids") or [job.params["batch_id"]] + selected = [batches.get(bid) for bid in ids] + if any(batch is None for batch in selected): + raise DatasetError("A batch disappeared before the merge started") + project = projects.get(selected[0]["project_id"]) + dataset_id = job.params["dataset_id"] + target = datasets.get(dataset_id) + if target is None: + raise DatasetError("The target dataset disappeared before the merge started") + root = dataset_dir(project["slug"], dataset_id) for split in ("train", "val"): os.makedirs(os.path.join(root, "images", split), exist_ok=True) os.makedirs(os.path.join(root, "labels", split), exist_ok=True) - frames = [f for f in batches.frames(batch["id"]) if f["review_status"] == "approved"] - source_dir = batches.frames_dir(project["slug"], batch["id"]) - job.progress(0, len(frames)) - job.log(f"Merging {len(frames)} approved frame(s) into the master dataset") + work = [] + for batch in selected: + frames = [f for f in batches.frames(batch["id"]) if f["review_status"] == "approved"] + work.extend((batch, frame) for frame in frames) + job.progress(0, len(work)) + job.log(f"Merging {len(work)} approved frame(s) from {len(selected)} batch(es) " + f"into \u201c{target['name']}\u201d") + + # Triage gates the merge (REQ-104), under the rules frozen onto this dataset + # when it was created (REQ-132) — not under whatever the project says now. + resolver = triage.Resolver(project["id"], frozen=target["rules"]) + gating = bool(resolver.rules or resolver.overrides) + if gating: + job.log(f"Applying {len(resolver.rules)} triage rule(s), version {resolver.version()}") added = {"train": 0, "val": 0} skipped = 0 - for index, frame in enumerate(frames): + triaged_out = 0 + cancelled = False + for index, (batch, frame) in enumerate(work): if job.cancelled: job.log(f"Cancelled after {index} frame(s)") + cancelled = True break + annotations = review.listing(frame["id"]) + if gating: + resolved = resolver.resolve_shapes(annotations) + if resolved is None: + triaged_out += 1 + job.progress(index + 1, len(work)) + continue + annotations = resolved + with db.cursor() as cur: - cur.execute("SELECT 1 FROM dataset_items WHERE frame_id = ?", (frame["id"],)) + cur.execute("SELECT 1 FROM dataset_items WHERE dataset_id = ? AND frame_id = ?", + (dataset_id, frame["id"])) if cur.fetchone() is not None: skipped += 1 - job.progress(index + 1, len(frames)) + job.progress(index + 1, len(work)) continue - split = _next_split(cur, project["id"], project["val_every"]) stem = f"{batch['id']}__{os.path.splitext(frame['filename'])[0]}" + # A frame's split is decided once for the whole project and every + # later dataset inherits it. Letting each dataset re-decide would put + # the same image in `val` for one run and `train` for the next, so a + # base-vs-new mAP would be measured on images the new model had been + # fitted to. The hash agrees with itself, but rows merged before the + # hash existed carry a positional split — those have to be honoured, + # not recomputed. + cur.execute( + "SELECT split FROM dataset_items WHERE frame_id = ? LIMIT 1", + (frame["id"],), + ) + previous = cur.fetchone() + split = previous[0] if previous else split_for( + project["id"], batch["id"], stem, project["val_every"]) image_rel = f"images/{split}/{stem}.jpg" label_rel = f"labels/{split}/{stem}.txt" - shutil.copyfile(os.path.join(source_dir, frame["filename"]), - os.path.join(root, image_rel)) + shutil.copyfile( + os.path.join(batches.frames_dir(project["slug"], batch["id"]), frame["filename"]), + os.path.join(root, image_rel)) lines = [_label_line(item["class_id"], item["geometry"], project["label_type"]) - for item in review.listing(frame["id"])] + for item in annotations] # An approved frame with nothing on it is a negative sample, and an # empty .txt is how YOLO spells that (REQ-033). with open(os.path.join(root, label_rel), "w", encoding="utf-8") as handle: handle.write("\n".join(lines) + ("\n" if lines else "")) cur.execute( - """INSERT INTO dataset_items (project_id, frame_id, split, image_rel, - label_rel, added_at) - VALUES (?, ?, ?, ?, ?, ?)""", - (project["id"], frame["id"], split, image_rel, label_rel, time.time()), + """INSERT INTO dataset_items (project_id, dataset_id, frame_id, split, + image_rel, label_rel, added_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (project["id"], dataset_id, frame["id"], split, image_rel, label_rel, + time.time()), ) added[split] += 1 - job.progress(index + 1, len(frames)) + job.progress(index + 1, len(work)) + + if cancelled: + # Leaving them 'merged' would be a lie: the frames after the break point + # have no dataset_items rows and no files, and approve() refuses to + # re-merge a merged batch, so they could never be added. The per-frame + # dataset_items guard already makes re-running the merge idempotent. + job.log("Batches left approved — re-approve them to finish the merge") + return with db.cursor() as cur: - cur.execute("UPDATE batches SET status = 'merged', merged_at = ? WHERE id = ?", - (time.time(), batch["id"])) + cur.executemany( + "UPDATE batches SET status = 'merged', merged_at = ? WHERE id = ?", + [(time.time(), bid) for bid in ids], + ) - path = write_data_yaml(projects.get(project["id"])) - totals = summary(project["id"])["splits"] + totals = datasets.get(dataset_id)["splits"] job.log(f"Added {added['train']} train / {added['val']} val" - + (f", skipped {skipped} already merged" if skipped else "")) - job.log(f"Master dataset now {totals['train']} train / {totals['val']} val — {path}") + + (f", skipped {skipped} already in this dataset" if skipped else "") + + (f", held back {triaged_out} by triage" if triaged_out else "")) + job.log(f"\u201c{target['name']}\u201d now holds " + f"{totals['train']} train / {totals['val']} val") diff --git a/backend/datasets.py b/backend/datasets.py new file mode 100644 index 0000000..4a9bdfd --- /dev/null +++ b/backend/datasets.py @@ -0,0 +1,220 @@ +"""Named master datasets — several per project, each a full standalone copy. + +One project used to have exactly one master dataset, so merging a batch twice +was a conflict. Now a merge targets a *named* dataset, and the same batch can go +into as many as you like: "batch7+8 strict rules" and "batch7+8 after I fixed +the annotations" are two datasets holding the same frames with different labels. + +Each dataset owns its files under + + data/projects//datasets//{images,labels}/{train,val} + +Images are copied rather than shared, so a dataset folder can be moved or +archived on its own without silently losing pixels. + +Combining datasets for a training run is "newest wins": if a frame appears in +two of them, the one created later is treated as the correction of the earlier, +and the frame is emitted once. Emitting it twice would hand the model two +contradictory labels for the same image. +""" + +import json +import os +import shutil +import time +from typing import List, Optional + +from backend import config, db + + +class DatasetError(Exception): + pass + + +def dataset_root(project_slug: str, dataset_id: int) -> str: + return os.path.join(config.project_dir(project_slug), "datasets", str(dataset_id)) + + +def adopt_legacy_tree() -> int: + """Move a pre-rename project's files under the dataset that adopted its rows. + + `_migrate_dataset_items` gave the old rows a home in the `datasets` table but + left the pixels at `/dataset/`, so the adopting dataset points at a + directory that does not exist and a training run would find no images. + Idempotent: a dataset whose root already exists is left alone. + """ + moved = 0 + with db.cursor() as cur: + cur.execute( + """SELECT s.id, p.slug FROM datasets s + JOIN projects p ON p.id = s.project_id + WHERE s.note = 'Adopted from the original single dataset'""", + ) + adopted = [(row[0], row[1]) for row in cur.fetchall()] + + for dataset_id, slug in adopted: + root = dataset_root(slug, dataset_id) + legacy = os.path.join(config.project_dir(slug), "dataset") + if os.path.isdir(root) or not os.path.isdir(os.path.join(legacy, "images")): + continue + os.makedirs(os.path.dirname(root), exist_ok=True) + for name in ("images", "labels"): + source = os.path.join(legacy, name) + if os.path.isdir(source): + os.makedirs(root, exist_ok=True) + shutil.move(source, os.path.join(root, name)) + # The rest of the legacy tree is a stale data.yaml and the old + # `selected/` symlinks, both rebuilt per run now. + shutil.rmtree(legacy, ignore_errors=True) + moved += 1 + return moved + + +def create(project_id: int, name: str = "", note: str = "", + rule_version: Optional[str] = None, rules: Optional[List[dict]] = None) -> dict: + label = (name or "").strip() or time.strftime("Master Dataset %Y-%m-%d %H:%M") + with db.cursor() as cur: + cur.execute( + """INSERT INTO datasets (project_id, name, note, rule_version, rules_json, + created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (project_id, label, note, rule_version, + json.dumps(rules) if rules is not None else None, time.time()), + ) + dataset_id = cur.lastrowid + return get(dataset_id) + + +def get(dataset_id: int) -> Optional[dict]: + with db.cursor() as cur: + cur.execute("SELECT * FROM datasets WHERE id = ?", (dataset_id,)) + row = cur.fetchone() + if row is None: + return None + return _with_counts(cur, dict(row)) + + +def listing(project_id: int) -> List[dict]: + with db.cursor() as cur: + cur.execute( + "SELECT * FROM datasets WHERE project_id = ? ORDER BY created_at DESC", + (project_id,), + ) + rows = [dict(row) for row in cur.fetchall()] + return [_with_counts(cur, row) for row in rows] + + +def _with_counts(cur, row: dict) -> dict: + cur.execute( + """SELECT split, COUNT(*) FROM dataset_items + WHERE dataset_id = ? GROUP BY split""", + (row["id"],), + ) + splits = {"train": 0, "val": 0} + for split, count in cur.fetchall(): + splits[split] = count + cur.execute( + """SELECT b.id, b.date_label, b.batch_label, COUNT(d.id) + FROM dataset_items d + JOIN frames f ON f.id = d.frame_id + JOIN batches b ON b.id = f.batch_id + WHERE d.dataset_id = ? GROUP BY b.id ORDER BY b.id""", + (row["id"],), + ) + batches = [{"id": r[0], "date_label": r[1], "batch_label": r[2], "images": r[3]} + for r in cur.fetchall()] + row["splits"] = splits + row["total"] = splits["train"] + splits["val"] + row["batches"] = batches + row["rules"] = json.loads(row.pop("rules_json") or "[]") + return row + + +def snapshot_rules(dataset_id: int, rules: List[dict], rule_version: str) -> None: + """Freeze the rules a dataset was cut under (REQ-132).""" + with db.cursor() as cur: + cur.execute("UPDATE datasets SET rules_json = ?, rule_version = ? WHERE id = ?", + (json.dumps(rules), rule_version, dataset_id)) + + +def rename(dataset_id: int, name: str = None, note: str = None) -> dict: + fields, args = [], [] + if name is not None: + fields.append("name = ?") + args.append(name.strip()) + if note is not None: + fields.append("note = ?") + args.append(note) + if fields: + args.append(dataset_id) + with db.cursor() as cur: + cur.execute(f"UPDATE datasets SET {', '.join(fields)} WHERE id = ?", args) + return get(dataset_id) + + +def delete(dataset_id: int) -> bool: + dataset = get(dataset_id) + if dataset is None: + return False + with db.cursor() as cur: + cur.execute("SELECT slug FROM projects WHERE id = ?", (dataset["project_id"],)) + row = cur.fetchone() + if row is not None: + shutil.rmtree(dataset_root(row["slug"], dataset_id), ignore_errors=True) + with db.cursor() as cur: + cur.execute("DELETE FROM datasets WHERE id = ?", (dataset_id,)) + return True + + +def combined_items(project_id: int, dataset_ids: List[int]) -> List[dict]: + """Frames from these datasets, newest dataset winning on a repeated frame. + + A frame in two datasets means the later one is a correction — a rule change + or a fixed annotation. Training on both copies would teach the model that + the same pixels are two different things. + """ + if not dataset_ids: + return [] + with db.cursor() as cur: + placeholders = ",".join("?" for _ in dataset_ids) + cur.execute( + f"""SELECT d.frame_id, d.split, d.image_rel, d.label_rel, d.dataset_id, + s.created_at, s.name + FROM dataset_items d + JOIN datasets s ON s.id = d.dataset_id + WHERE d.project_id = ? AND d.dataset_id IN ({placeholders}) + ORDER BY s.created_at ASC, d.id ASC""", + [project_id] + list(dataset_ids), + ) + rows = cur.fetchall() + + # Ordered oldest first, so a later dataset simply overwrites the entry. + winner = {} + for row in rows: + winner[row["frame_id"]] = { + "frame_id": row["frame_id"], + "split": row["split"], + "image_rel": row["image_rel"], + "label_rel": row["label_rel"], + "dataset_id": row["dataset_id"], + "dataset_name": row["name"], + } + return list(winner.values()) + + +def overlap_report(project_id: int, dataset_ids: List[int], total_unique: int) -> dict: + """How many frames the chosen datasets share, so the user is told rather + than quietly given fewer images than the totals suggest.""" + if len(dataset_ids) < 2: + return {"shared_frames": 0, "total_unique": total_unique} + with db.cursor() as cur: + placeholders = ",".join("?" for _ in dataset_ids) + cur.execute( + f"""SELECT COUNT(*) FROM ( + SELECT frame_id FROM dataset_items + WHERE project_id = ? AND dataset_id IN ({placeholders}) + GROUP BY frame_id HAVING COUNT(DISTINCT dataset_id) > 1)""", + [project_id] + list(dataset_ids), + ) + shared = cur.fetchone()[0] + return {"shared_frames": shared, "total_unique": total_unique} diff --git a/backend/db.py b/backend/db.py index 215d3af..701c01e 100644 --- a/backend/db.py +++ b/backend/db.py @@ -83,14 +83,80 @@ SCHEMA = [ ) """, """ + CREATE TABLE IF NOT EXISTS datasets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + rule_version TEXT, + rules_json TEXT, + created_at REAL NOT NULL + ) + """, + """ CREATE TABLE IF NOT EXISTS dataset_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE UNIQUE, + dataset_id INTEGER NOT NULL REFERENCES datasets(id) ON DELETE CASCADE, + frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, split TEXT NOT NULL CHECK (split IN ('train', 'val')), image_rel TEXT NOT NULL, label_rel TEXT NOT NULL, - added_at REAL NOT NULL + added_at REAL NOT NULL, + UNIQUE (dataset_id, frame_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS base_datasets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + image_count INTEGER NOT NULL DEFAULT 0, + box_count INTEGER NOT NULL DEFAULT 0, + classes TEXT NOT NULL DEFAULT '[]', + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS video_clock ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + video_rel TEXT NOT NULL, + folder_date TEXT NOT NULL DEFAULT '', + -- Wall-clock text exactly as the camera burned it in, e.g. + -- '2026-08-06 20:12:42'. Deliberately not an epoch: the overlay carries + -- no timezone, and storing it as one made the backend (UTC) and the + -- browser (UTC+7) disagree by 7 hours, which moved recordings across + -- the 06:00 boundary into the wrong working day. + started_at TEXT, + working_day TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 0, + agreeing INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'ocr', + error TEXT NOT NULL DEFAULT '', + read_at REAL, + UNIQUE (project_id, video_rel) + ) + """, + """ + CREATE TABLE IF NOT EXISTS count_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + video_rel TEXT NOT NULL, + date_label TEXT NOT NULL, + batch_label TEXT NOT NULL, + loading INTEGER, + unloading INTEGER, + net INTEGER, + ground_truth INTEGER, + frames INTEGER NOT NULL DEFAULT 0, + seconds REAL NOT NULL DEFAULT 0, + params TEXT NOT NULL DEFAULT '{}', + model_path TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + counted_at REAL, + UNIQUE (project_id, video_rel) ) """, """ @@ -111,7 +177,9 @@ SCHEMA = [ id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, batch_id INTEGER REFERENCES batches(id) ON DELETE CASCADE, - type TEXT NOT NULL CHECK (type IN ('extract', 'autolabel', 'merge', 'train')), + type TEXT NOT NULL CHECK (type IN ('extract', 'autolabel', 'merge', 'train', + 'count', 'clock-scan', + 'truck-scan')), status TEXT NOT NULL CHECK (status IN ( 'queued', 'running', 'done', 'failed', 'cancelled')), params TEXT NOT NULL DEFAULT '{}', @@ -125,11 +193,35 @@ SCHEMA = [ finished_at REAL ) """, + """ + CREATE TABLE IF NOT EXISTS triage_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + stage TEXT NOT NULL DEFAULT 'dataprep', + position INTEGER NOT NULL, + name TEXT NOT NULL, + predicate TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('keep', 'ignore', 'reclass')), + target_class INTEGER, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS annotation_overrides ( + annotation_id INTEGER PRIMARY KEY REFERENCES annotations(id) ON DELETE CASCADE, + verdict TEXT NOT NULL CHECK (verdict IN ('keep', 'ignore', 'reclass')), + target_class INTEGER, + decided_at REAL NOT NULL + ) + """, "CREATE INDEX IF NOT EXISTS idx_frames_batch ON frames(batch_id, idx)", + "CREATE INDEX IF NOT EXISTS idx_triage_rules_project ON triage_rules(project_id, stage, position)", "CREATE INDEX IF NOT EXISTS idx_annotations_frame ON annotations(frame_id)", "CREATE INDEX IF NOT EXISTS idx_batches_project ON batches(project_id)", "CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs(project_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_dataset_items_project ON dataset_items(project_id)", + "CREATE INDEX IF NOT EXISTS idx_count_runs_project ON count_runs(project_id, date_label)", + "CREATE INDEX IF NOT EXISTS idx_video_clock_project ON video_clock(project_id, working_day)", ] @@ -167,6 +259,164 @@ def migrate() -> None: cur.execute("ALTER TABLE projects ADD COLUMN secondary_model_name TEXT") if "secondary_model_classes" not in cols: cur.execute("ALTER TABLE projects ADD COLUMN secondary_model_classes TEXT") + # REQ-110: augmentation settings, null until the user changes them. + if "augment" not in cols: + cur.execute("ALTER TABLE projects ADD COLUMN augment TEXT") + # REQ-107: what rule set a run's numbers were measured under. + cur.execute("PRAGMA table_info(model_versions)") + version_cols = [column[1] for column in cur.fetchall()] + if "rule_version" not in version_cols: + cur.execute("ALTER TABLE model_versions ADD COLUMN rule_version TEXT") + # REQ-113: and what augmentation it trained under. + if "augment" not in version_cols: + cur.execute("ALTER TABLE model_versions ADD COLUMN augment TEXT") + + # REQ-132: the triage rules this dataset was actually cut under, frozen + # at merge time. Editing project rules afterwards must not rewrite what + # an existing dataset claims. + cur.execute("PRAGMA table_info(datasets)") + dataset_cols = [column[1] for column in cur.fetchall()] + if "rules_json" not in dataset_cols: + cur.execute("ALTER TABLE datasets ADD COLUMN rules_json TEXT") + + _migrate_dataset_items(cur) + _backfill_dataset_rules(cur) + _migrate_job_types(cur) + _migrate_clock_column(cur) + _migrate_truck_columns(cur) + + +def _migrate_dataset_items(cur) -> None: + """Move a single-dataset project onto named datasets. + + The old table pinned `frame_id UNIQUE`, so one frame could live in exactly + one dataset — which is the constraint that made merging the same batch twice + a conflict. SQLite cannot drop a constraint, so the table is rebuilt and the + existing rows are adopted by a "Master Dataset #1" per project. Nothing is + discarded: a row without a home would be a merged frame the app can no + longer see. + """ + cur.execute("PRAGMA table_info(dataset_items)") + columns = [column[1] for column in cur.fetchall()] + if not columns or "dataset_id" in columns: + return + + import time + + cur.execute("SELECT DISTINCT project_id FROM dataset_items") + projects_with_items = [row[0] for row in cur.fetchall()] + home = {} + for project_id in projects_with_items: + cur.execute( + """INSERT INTO datasets (project_id, name, note, created_at) + VALUES (?, 'Master Dataset #1', 'Adopted from the original single dataset', ?)""", + (project_id, time.time()), + ) + home[project_id] = cur.lastrowid + + cur.execute("ALTER TABLE dataset_items RENAME TO dataset_items_old") + cur.execute(""" + CREATE TABLE dataset_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + dataset_id INTEGER NOT NULL REFERENCES datasets(id) ON DELETE CASCADE, + frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, + split TEXT NOT NULL CHECK (split IN ('train', 'val')), + image_rel TEXT NOT NULL, + label_rel TEXT NOT NULL, + added_at REAL NOT NULL, + UNIQUE (dataset_id, frame_id) + ) + """) + for project_id, dataset_id in home.items(): + cur.execute( + """INSERT INTO dataset_items (project_id, dataset_id, frame_id, split, + image_rel, label_rel, added_at) + SELECT project_id, ?, frame_id, split, image_rel, label_rel, added_at + FROM dataset_items_old WHERE project_id = ?""", + (dataset_id, project_id), + ) + cur.execute("DROP TABLE dataset_items_old") + + +def _migrate_clock_column(cur) -> None: + """Drop a `video_clock` that still stores epochs. + + The table is a cache of what the reader saw; rebuilding it is one rescan, and + that is cheaper and safer than converting epochs whose timezone is exactly + the thing that was wrong. + """ + cur.execute("PRAGMA table_info(video_clock)") + columns = {row[1]: row[2] for row in cur.fetchall()} + if columns.get("started_at", "TEXT").upper() == "REAL": + cur.execute("DROP TABLE video_clock") + for statement in SCHEMA: + if "CREATE TABLE IF NOT EXISTS video_clock" in statement: + cur.execute(statement) + break + + +def _migrate_truck_columns(cur) -> None: + """Truck-presence check per recording (REQ-166).""" + cur.execute("PRAGMA table_info(video_clock)") + columns = {row[1] for row in cur.fetchall()} + for name, kind in (("truck_hits", "INTEGER"), ("truck_samples", "INTEGER"), + ("truck_model", "TEXT"), ("truck_checked_at", "REAL")): + if name not in columns: + cur.execute(f"ALTER TABLE video_clock ADD COLUMN {name} {kind}") + + +def _migrate_job_types(cur) -> None: + """Let the jobs table accept the 'count' type (REQ-150). + + The allowed set is a CHECK constraint written into the table definition, and + SQLite cannot alter one — the table has to be rebuilt. Rows are carried over + verbatim; the only change is which type strings are legal. + """ + cur.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'jobs'") + row = cur.fetchone() + if row is None or "'truck-scan'" in row[0]: + return + cur.execute("ALTER TABLE jobs RENAME TO jobs_old") + for statement in SCHEMA: + if "CREATE TABLE IF NOT EXISTS jobs " in statement: + cur.execute(statement) + break + cur.execute("""INSERT INTO jobs (id, project_id, batch_id, type, status, params, progress, + total, message, error, log, created_at, started_at, + finished_at) + SELECT id, project_id, batch_id, type, status, params, progress, total, + message, error, log, created_at, started_at, finished_at + FROM jobs_old""") + cur.execute("DROP TABLE jobs_old") + + +def _backfill_dataset_rules(cur) -> None: + """Datasets merged before REQ-132 have no snapshot. Give them the project's + current rules — that is what they were cut under, unless the rules changed + in between, and an empty snapshot would claim "no rules" for all of them. + """ + import json + + cur.execute("SELECT id, project_id FROM datasets WHERE rules_json IS NULL") + stale = cur.fetchall() + if not stale: + return + by_project: dict = {} + for dataset_id, project_id in stale: + if project_id not in by_project: + cur.execute( + """SELECT name, predicate, action, target_class FROM triage_rules + WHERE project_id = ? AND stage = 'dataprep' ORDER BY position""", + (project_id,), + ) + by_project[project_id] = json.dumps([ + {"name": row[0], "predicate": json.loads(row[1]), + "action": row[2], "target_class": row[3]} + for row in cur.fetchall() + ]) + cur.execute("UPDATE datasets SET rules_json = ? WHERE id = ?", + (by_project[project_id], dataset_id)) def healthy() -> bool: diff --git a/backend/export.py b/backend/export.py new file mode 100644 index 0000000..fe23622 --- /dev/null +++ b/backend/export.py @@ -0,0 +1,303 @@ +"""Export annotated frames as a plain YOLO dataset the user can keep. + +This is deliberately independent of the master dataset. A batch can be exported +the moment it has annotations — before review, before merge — because the point +is that the user owns a copy of their own work and does not have to trust this +application to keep it. + +Layout inside the zip: + + images/__.jpg + labels/__.txt + data.yaml + classes.txt + MANIFEST.json what was exported, and what was skipped +""" + +import json +import os +import shutil +import tempfile +import time +from typing import List, Optional + +from backend import batches, config, db, projects, review + + +class ExportError(Exception): + pass + + +def _label_line(class_id: int, geometry: dict, label_type: str) -> str: + from backend.dataset import _label_line as build + return build(class_id, geometry, label_type) + + +def annotated_frames(project_id: int, batch_ids: Optional[List[int]] = None, + approved_only: bool = False) -> list: + where = "b.project_id = ?" + args: list = [project_id] + if batch_ids: + where += f" AND b.id IN ({','.join('?' for _ in batch_ids)})" + args += list(batch_ids) + if approved_only: + where += " AND f.review_status = 'approved'" + with db.cursor() as cur: + cur.execute( + f"""SELECT f.id, f.filename, f.review_status, b.id AS batch_id, + b.batch_label, b.date_label + FROM frames f JOIN batches b ON b.id = f.batch_id + WHERE {where} + ORDER BY b.id, f.idx""", + args, + ) + return [dict(row) for row in cur.fetchall()] + + +def build_zip(project_id: int, batch_ids: Optional[List[int]] = None, + approved_only: bool = False, include_empty: bool = False) -> str: + """Write the zip and return its path. + + `include_empty` decides whether frames with no annotation are exported as + negative samples. Off by default: an empty label file is a claim that the + image contains nothing, and for a frame that was simply never annotated that + claim is false. + """ + project = projects.get(project_id) + if project is None: + raise ExportError("No such project") + + frames = annotated_frames(project_id, batch_ids, approved_only) + if not frames: + raise ExportError("There is nothing to export — no frames match that selection") + + staging = tempfile.mkdtemp(prefix="export-") + images_dir = os.path.join(staging, "images") + labels_dir = os.path.join(staging, "labels") + os.makedirs(images_dir) + os.makedirs(labels_dir) + + written = 0 + skipped_empty = 0 + missing = 0 + shapes = 0 + per_batch: dict = {} + + for frame in frames: + annotations = review.listing(frame["id"]) + if not annotations and not include_empty: + skipped_empty += 1 + continue + + source = os.path.join( + batches.frames_dir(project["slug"], frame["batch_id"]), frame["filename"]) + if not os.path.isfile(source): + missing += 1 + continue + + stem = f"{frame['batch_id']}__{os.path.splitext(frame['filename'])[0]}" + shutil.copyfile(source, os.path.join(images_dir, f"{stem}.jpg")) + lines = [_label_line(item["class_id"], item["geometry"], project["label_type"]) + for item in annotations] + with open(os.path.join(labels_dir, f"{stem}.txt"), "w", encoding="utf-8") as handle: + handle.write("\n".join(lines) + ("\n" if lines else "")) + + written += 1 + shapes += len(lines) + label = f"{frame['date_label']}/{frame['batch_label']}" + per_batch[label] = per_batch.get(label, 0) + 1 + + if written == 0: + shutil.rmtree(staging, ignore_errors=True) + raise ExportError( + "Every matching frame was empty or missing its image, so the export would " + "contain no labels" + ) + + names = ", ".join(f"'{item['name']}'" for item in project["classes"]) + with open(os.path.join(staging, "data.yaml"), "w", encoding="utf-8") as handle: + handle.write("path: .\ntrain: images\nval: images\n\n") + handle.write(f"nc: {len(project['classes'])}\n") + handle.write(f"names: [{names}]\n") + + with open(os.path.join(staging, "classes.txt"), "w", encoding="utf-8") as handle: + handle.write("\n".join(item["name"] for item in project["classes"]) + "\n") + + manifest = { + "project": project["name"], + "label_type": project["label_type"], + "exported_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "approved_only": approved_only, + "include_empty": include_empty, + "images": written, + "shapes": shapes, + "per_batch": per_batch, + "skipped_without_annotations": skipped_empty, + "skipped_image_missing": missing, + "classes": [{"class_id": c["class_id"], "name": c["name"]} for c in project["classes"]], + } + with open(os.path.join(staging, "MANIFEST.json"), "w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2) + + project_root = config.project_dir(project["slug"]) + # Each export is ~200 MB. Without this, every click leaves another copy + # behind and the project folder grows without bound. + for stale in os.listdir(project_root): + if stale.startswith("export-") and stale.endswith(".zip"): + try: + os.unlink(os.path.join(project_root, stale)) + except OSError: + pass + + stamp = time.strftime("%Y%m%d-%H%M%S") + path = shutil.make_archive(os.path.join(project_root, f"export-{stamp}"), "zip", staging) + shutil.rmtree(staging, ignore_errors=True) + return path + + +# ---- restore ------------------------------------------------------------ + +def _safe_members(archive) -> list: + """Only plain files under images/ and labels/. + + A zip can name `../../etc/passwd`; extracting one blindly writes wherever it + says. Every member is checked rather than trusted. + """ + picked = [] + for member in archive.infolist(): + if member.is_dir(): + continue + name = member.filename.replace("\\", "/") + if name.startswith("/") or ".." in name.split("/"): + continue + if name.startswith(("images/", "labels/")) or name == "MANIFEST.json": + picked.append((name, member)) + return picked + + +def _points_from_label(parts: List[str], label_type: str) -> Optional[dict]: + values = [float(v) for v in parts] + if label_type == "bbox": + if len(values) != 4: + return None + cx, cy, w, h = values + return {"type": "bbox", + "points": [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2]} + if len(values) < 6 or len(values) % 2: + return None + return {"type": "polygon", + "points": [[values[i], values[i + 1]] for i in range(0, len(values), 2)]} + + +def restore_zip(project_id: int, zip_path: str, batch_label: str = "") -> dict: + """Load an exported zip back in as a fresh batch, ready to keep reviewing. + + The frames land in a new batch rather than being merged back into the ones + they came from: the originals may still exist, and silently overwriting a + batch the user is working in would destroy the very work this feature is + meant to protect. + """ + import zipfile + + from PIL import Image + + project = projects.get(project_id) + if project is None: + raise ExportError("No such project") + + by_name = {item["name"]: item["class_id"] for item in project["classes"]} + stamp = time.strftime("%Y%m%d-%H%M%S") + label = batch_label or f"restored-{stamp}" + + with zipfile.ZipFile(zip_path) as archive: + members = _safe_members(archive) + names = {name for name, _ in members} + if not any(name.startswith("images/") for name in names): + raise ExportError("This zip has no images/ folder — is it an export from this app?") + + manifest = {} + if "MANIFEST.json" in names: + manifest = json.loads(archive.read("MANIFEST.json")) + source_type = manifest.get("label_type", project["label_type"]) + if source_type != project["label_type"]: + raise ExportError( + f"This export holds {source_type} labels but the project is " + f"{project['label_type']} — importing it would produce wrong shapes" + ) + + # Classes come back by name, so an id that shifted since the export does + # not silently relabel every shape. + remap = {} + for item in manifest.get("classes", []): + if item["name"] in by_name: + remap[item["class_id"]] = by_name[item["name"]] + else: + raise ExportError( + f"The export uses class '{item['name']}', which this project does not " + "have. Add the class first, then import." + ) + + with db.cursor() as cur: + cur.execute( + """INSERT INTO batches (project_id, video_path, date_label, batch_label, + start_sec, end_sec, fps, status, created_at) + VALUES (?, '', 'restored', ?, 0, 0, 0, 'extracted', ?)""", + (project_id, label, time.time()), + ) + batch_id = cur.lastrowid + + target_dir = batches.frames_dir(project["slug"], batch_id) + os.makedirs(target_dir, exist_ok=True) + + restored, shapes, skipped = 0, 0, 0 + image_members = sorted(n for n in names if n.startswith("images/")) + for index, name in enumerate(image_members): + stem = os.path.splitext(os.path.basename(name))[0] + if not stem: + continue + filename = f"{stem}.jpg" + destination = os.path.join(target_dir, filename) + with archive.open(name) as source, open(destination, "wb") as handle: + shutil.copyfileobj(source, handle) + + try: + with Image.open(destination) as image: + width, height = image.size + except Exception: + os.unlink(destination) + skipped += 1 + continue + + with db.cursor() as cur: + cur.execute( + """INSERT INTO frames (batch_id, idx, filename, width, height, review_status) + VALUES (?, ?, ?, ?, ?, 'pending')""", + (batch_id, index, filename, width, height), + ) + frame_id = cur.lastrowid + + label_name = f"labels/{stem}.txt" + if label_name in names: + for line in archive.read(label_name).decode("utf-8").splitlines(): + parts = line.split() + if len(parts) < 5: + continue + geometry = _points_from_label(parts[1:], project["label_type"]) + if geometry is None: + continue + class_id = remap.get(int(parts[0]), int(parts[0])) + cur.execute( + """INSERT INTO annotations (frame_id, class_id, geometry, score, + source, created_at) + VALUES (?, ?, ?, 1.0, 'manual', ?)""", + (frame_id, class_id, json.dumps(geometry), time.time()), + ) + shapes += 1 + restored += 1 + + with db.cursor() as cur: + cur.execute("UPDATE batches SET frame_count = ?, status = 'reviewing' WHERE id = ?", + (restored, batch_id)) + + return {"batch_id": batch_id, "batch_label": label, "frames": restored, + "shapes": shapes, "skipped": skipped} diff --git a/backend/hardware.py b/backend/hardware.py index df744a7..a5a8ff0 100644 --- a/backend/hardware.py +++ b/backend/hardware.py @@ -47,12 +47,12 @@ def defaults(epochs: int = 50) -> dict: elif vram < 6: settings = {"batch": 16, "imgsz": 640, "device": 0, "workers": 4} note = f"{vram} GB of VRAM: batch 16, 640 px." - elif vram <= 16: + elif vram < 15: settings = {"batch": 32, "imgsz": 640, "device": 0, "workers": 8} note = f"{vram} GB of VRAM: optimized batch 32, 640 px." else: - settings = {"batch": 32, "imgsz": 768, "device": 0, "workers": 8} - note = f"{vram} GB of VRAM: room for larger batches and 768 px." + settings = {"batch": 64, "imgsz": 640, "device": 0, "workers": 8} + note = f"{vram} GB of VRAM: max throughput batch 64, 640 px." return {**info, **settings, "epochs": epochs, "note": note} diff --git a/backend/jobs.py b/backend/jobs.py index 18e2827..b12b9bd 100644 --- a/backend/jobs.py +++ b/backend/jobs.py @@ -27,11 +27,11 @@ from backend import db MAX_LOG_LINES = 500 PROGRESS_FLUSH_SECONDS = 0.5 -JOB_TYPES = ("extract", "autolabel", "merge", "train") -GPU_JOB_TYPES = ("autolabel", "train") +JOB_TYPES = ("extract", "autolabel", "merge", "train", "count", "clock-scan", + "truck-scan") +GPU_JOB_TYPES = ("autolabel", "train", "count", "truck-scan") """`extract` is ffmpeg and `merge` is file copying — neither touches the card, so neither should be able to block an interactive assist.""" - gpu_lock = threading.Lock() """Held for the duration of any GPU work. The job worker takes it around a handler; the interactive assist route takes it around one SAM3 call. One card, diff --git a/backend/labeling.py b/backend/labeling.py index fbdb43d..f0ff939 100644 --- a/backend/labeling.py +++ b/backend/labeling.py @@ -43,7 +43,11 @@ def _iou(box_a: List[float], box_b: List[float]) -> float: 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: + return detections + by_class: dict[int, List[Detection]] = {} + for det in detections: by_class.setdefault(det.class_id, []).append(det) diff --git a/backend/library.py b/backend/library.py index 8b4f3f2..ba3c127 100644 --- a/backend/library.py +++ b/backend/library.py @@ -8,7 +8,8 @@ import os import re from typing import List, Optional -from backend import config, db, video +from backend import config, db, projects, video + class LibraryError(Exception): @@ -70,6 +71,7 @@ def list_videos(video_root: str, date: str, project_id: Optional[int] = None) -> if not os.path.isdir(folder): raise LibraryError(f"No such date in the archive: {date}") + project = projects.get(project_id) if project_id is not None else None used = _usage(project_id) videos = [] for filename in sorted( @@ -77,14 +79,17 @@ def list_videos(video_root: str, date: str, project_id: Optional[int] = None) -> key=_batch_sort_key, ): path = os.path.join(folder, filename) + rel = f"{date}/{filename}" entry = { - "rel": f"{date}/{filename}", + "rel": rel, "filename": filename, "batch_label": batch_label(filename), "used_count": used.get(os.path.realpath(path), 0), } try: entry.update(video.probe(path)) + if project is not None: + ensure_video_preview(project, rel) except video.VideoError as exc: # A file ffprobe cannot read still belongs in the list, flagged — # hiding it would look like the archive is missing recordings. @@ -94,7 +99,65 @@ def list_videos(video_root: str, date: str, project_id: Optional[int] = None) -> return videos +import threading + +_conversion_queue = set() +_conversion_lock = threading.Lock() +#: Serialises preview transcodes. Each one saturates several cores on its own. +_conversion_slot = threading.Semaphore(1) + + +def ensure_video_preview(project: dict, rel: str) -> None: + """Asynchronously convert video to H.264 if it's not natively web-supported.""" + rel_key = rel.replace("/", "_") + base, _ = os.path.splitext(rel_key) + preview_filename = f"{base}.mp4" + preview_dir = os.path.join(config.project_dir(project["slug"]), "previews") + preview_path = os.path.join(preview_dir, preview_filename) + + if os.path.isfile(preview_path): + return + + try: + full_path = resolve(project["video_root"], rel) + info = video.probe(full_path) + if info.get("codec_name") == "h264" and full_path.lower().endswith(".mp4"): + return + except Exception: + return + + with _conversion_lock: + if preview_path in _conversion_queue: + return + _conversion_queue.add(preview_path) + + def _worker(): + # One conversion at a time. The queue above only stops the *same* file + # being converted twice; it never bounded how many ran at once, so + # opening a date folder with 28 videos started 28 simultaneous x264 + # encodes. That pinned every core, drove load average past 250, and + # starved everything else in the process — inference included. + with _conversion_slot: + try: + if os.path.isfile(preview_path): + return + os.makedirs(preview_dir, exist_ok=True) + # A preview only has to be watchable in a browser, so it is not + # worth `-preset medium -crf 18`: veryfast/23 encodes several + # times faster for a difference nobody scrubbing footage sees. + video.convert_to_h264(full_path, output_path=preview_path, + crf=23, preset="veryfast") + except Exception as exc: + print(f"[PREVIEW CONVERSION ERROR] {rel}: {exc}") + finally: + with _conversion_lock: + _conversion_queue.discard(preview_path) + + threading.Thread(target=_worker, daemon=True).start() + + def _usage(project_id: Optional[int]) -> dict: + """How many batches already came out of each video path (REQ-012).""" if project_id is None: return {} diff --git a/backend/live_count.py b/backend/live_count.py new file mode 100644 index 0000000..17cf188 --- /dev/null +++ b/backend/live_count.py @@ -0,0 +1,467 @@ +"""Live counting test bench: point a trained model at an RTSP stream and watch it count. + +This is a **test harness**, not the production counter. It reuses the real +pipeline pieces from `algoritma-batch` — ByteTrack, the bbox stabiliser and the +line-cross counter with its spatial dedup — so what you see here is what +`predict.py` would do. What it deliberately leaves out is everything stateful: +no batch lifecycle, no SQLite, no truck-presence state machine. The question it +answers is "does this model count correctly on this camera", and those parts +only get in the way of answering it. + +One session at a time, holding the GPU lock, because the GPU is shared with +training and auto-annotation (REQ-065, REQ-070). +""" + +import os +import sys +import threading +import time +from typing import Optional + +import cv2 +import numpy as np + +from backend import config, jobs + +# `algoritma-batch/src` is copied to /app/src in the image; in a source checkout +# it still lives under algoritma-batch/. Both are made importable as `src.*`. +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +for candidate in ("/app", os.path.join(_REPO, "algoritma-batch")): + if os.path.isdir(os.path.join(candidate, "src")) and candidate not in sys.path: + sys.path.insert(0, candidate) + + +class LiveCountError(Exception): + pass + + +class Session: + """One running counter. Owns a capture thread and the latest rendered frame.""" + + def __init__(self, source: str, model_path: str, line_y: int, + line_x_start: int, line_x_end: int, conf: float, + dedup_radius: float, margin: int, imgsz: int, + entry_travel_min: float, handoff_radius: float, + unload_confirm_frames: int, min_area_scale: float, + spatial_dedup: bool): + self.source = source + self.model_path = model_path + self.line_y = line_y + self.line_x_start = line_x_start + self.line_x_end = line_x_end + self.conf = conf + self.dedup_radius = dedup_radius + self.margin = margin + self.imgsz = imgsz + self.entry_travel_min = entry_travel_min + self.handoff_radius = handoff_radius + self.unload_confirm_frames = unload_confirm_frames + self.min_area_scale = min_area_scale + self.spatial_dedup = spatial_dedup + + self.started_at = time.time() + self.error = "" + self.stopping = False + self.frames = 0 + self.fps = 0.0 + self.loading = 0 + self.unloading = 0 + self.tracked = 0 + self.ignored = 0 + self.too_small = 0 + self.traced = 0 + self.trace_path = os.path.join( + config.DATA_DIR, "live-count", f"session-{int(self.started_at)}.jsonl") + self.events: list[dict] = [] + + self._jpeg: Optional[bytes] = None + self._counter = None # set once the worker builds it + self._lock = threading.Lock() + self._thread = threading.Thread(target=self._run, name="live-count", daemon=True) + + # -- public ----------------------------------------------------------- + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self.stopping = True + self._thread.join(timeout=15) + + def snapshot(self) -> Optional[bytes]: + with self._lock: + return self._jpeg + + def move_line(self, line_y: Optional[int] = None, line_x_start: Optional[int] = None, + line_x_end: Optional[int] = None) -> dict: + """Reposition the counting line without restarting. + + Placing a line correctly means watching the stream while you move it, + which is impossible if moving it requires a restart — and a restart + throws the counts away. Already-counted tracks keep their verdict; the + counter only decides for a track the first time it crosses. + """ + if line_y is not None: + self.line_y = max(0, min(720, int(line_y))) + if line_x_start is not None: + self.line_x_start = max(0, min(1280, int(line_x_start))) + if line_x_end is not None: + self.line_x_end = max(0, min(1280, int(line_x_end))) + if self.line_x_end < self.line_x_start: + self.line_x_start, self.line_x_end = self.line_x_end, self.line_x_start + if self._counter is not None: + self._counter.line_y = self.line_y + self._counter.line_x_start = self.line_x_start + self._counter.line_x_end = self.line_x_end + return {"y": self.line_y, "x_start": self.line_x_start, "x_end": self.line_x_end} + + def status(self) -> dict: + return { + "running": self._thread.is_alive(), + "source": self.source, + "model_path": self.model_path, + "error": self.error, + "frames": self.frames, + "fps": round(self.fps, 1), + "loading": self.loading, + "unloading": self.unloading, + "net": self.loading - self.unloading, + "tracked": self.tracked, + "ignored": self.ignored, + "elapsed": round(time.time() - self.started_at, 1), + "line": {"y": self.line_y, "x_start": self.line_x_start, "x_end": self.line_x_end}, + "too_small": self.too_small, + "traced": self.traced, + "trace_path": self.trace_path, + "events": self.events[-25:], + } + + # -- worker ----------------------------------------------------------- + + def _run(self) -> None: + # The GPU is shared. Waiting here rather than failing means "start" is + # safe to press while a training run is finishing. + if not jobs.gpu_lock.acquire(timeout=30): + busy = jobs.running_types() + self.error = f"GPU busy with a {busy[0] if busy else 'background'} job" + return + capture = None + try: + from ultralytics import YOLO + from src.counting import LineCrossCounter + from src.stabilizer import BboxStabilizer + from src.tracking import ByteTrackTracker + + model = YOLO(self.model_path) + # Warm-up: the first CUDA call inside the tracker has been seen to + # segfault without it (same reason predict.py does this). + model(np.zeros((720, 1280, 3), dtype=np.uint8), imgsz=self.imgsz, verbose=False) + + tracker = ByteTrackTracker(model, self.conf) + stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10, + max_height_ratio=1.5, min_height_ratio=0.70) + counter = LineCrossCounter( + line_y=self.line_y, line_x_start=self.line_x_start, + line_x_end=self.line_x_end, margin=self.margin, + dedup_radius=self.dedup_radius, + entry_travel_min=self.entry_travel_min, + handoff_radius=self.handoff_radius, + unload_confirm_frames=self.unload_confirm_frames, + spatial_dedup=self.spatial_dedup, + ) + self._counter = counter + + capture = _open(self.source) + if capture is None or not capture.isOpened(): + raise LiveCountError(f"Could not open source: {self.source}") + + tick = time.time() + since = 0 + while not self.stopping: + ok, frame = capture.read() + if not ok or frame is None: + # A file simply ends. On a stream this only means the + # decoder has not produced a new frame yet, so wait briefly + # — long enough not to spin, short enough not to become the + # new frame-rate ceiling. + if _is_stream(self.source): + time.sleep(0.005) + continue + break + + frame = cv2.resize(frame, (1280, 720)) + detections = [d for d in tracker.update(frame, []) if d.class_name == "sack"] + stable = stabilizer.update(detections) + inside, outside = [], [] + small = 0 + for det in stable: + x1, y1, x2, y2 = det.bbox + centre_x = (x1 + x2) / 2 + # Perspective-aware area gate, the curve `predict.py` uses: + # a box that small at that depth is a fragment, not a sack. + if _too_small(det.bbox, self.min_area_scale): + small += 1 + outside.append(det) + elif self.line_x_start <= centre_x <= self.line_x_end: + inside.append(det) + else: + outside.append(det) + self.too_small = small + for event in counter.update(inside): + self.events.append({ + "track_id": event.get("track_id"), + "direction": event.get("direction", "loading"), + "at": round(time.time() - self.started_at, 1), + }) + + self._write_traces(counter.drain_traces()) + self.loading = counter.loading_count + self.unloading = counter.unloading_count + self.tracked = len(inside) + self.ignored = len(outside) + self.frames += 1 + since += 1 + + now = time.time() + if now - tick >= 1.0: + self.fps = since / (now - tick) + tick, since = now, 0 + + self._render(frame, inside, outside, counter) + except Exception as exc: # surfaced in status(), not swallowed + self.error = f"{type(exc).__name__}: {exc}" + finally: + # The lock is released no matter what tearing down the capture does. + # It was the other order once, and one exception in release() leaked + # the GPU for the lifetime of the process. + try: + if capture is not None: + capture.release() + except Exception as exc: + if not self.error: + self.error = f"capture release failed: {exc}" + finally: + jobs.gpu_lock.release() + + def _write_traces(self, traces: list) -> None: + """Append finished tracks to a JSONL, one object per track. + + This is the file that answers "was that the model, the tracker or the + counter" on a clip with a known count: every track that ever existed + lands here with its trajectory and the reason it did or did not count. + """ + if not traces: + return + import json + try: + os.makedirs(os.path.dirname(self.trace_path), exist_ok=True) + with open(self.trace_path, "a", encoding="utf-8") as handle: + for record in traces: + handle.write(json.dumps(record, default=str) + "\n") + self.traced += len(traces) + except OSError as exc: + if not self.error: + self.error = f"trace write failed: {exc}" + + def _render(self, frame, detections, ignored, counter) -> None: + height, width = frame.shape[:2] + + # Shade what the region excludes. Without this the neighbouring truck's + # sacks simply vanish from the overlay, and "are they being ignored?" + # looks identical to "is the model missing them?". + if self.line_x_start > 0 or self.line_x_end < width: + shade = frame.copy() + if self.line_x_start > 0: + cv2.rectangle(shade, (0, 0), (self.line_x_start, height), (0, 0, 0), -1) + if self.line_x_end < width: + cv2.rectangle(shade, (self.line_x_end, 0), (width, height), (0, 0, 0), -1) + cv2.addWeighted(shade, 0.55, frame, 0.45, 0, frame) + + # Ignored detections stay visible, in grey, so the region can be judged. + for det in ignored: + x1, y1, x2, y2 = (int(v) for v in det.bbox) + cv2.rectangle(frame, (x1, y1), (x2, y2), (130, 130, 130), 1) + + for edge in (self.line_x_start, self.line_x_end): + if 0 < edge < width: + cv2.line(frame, (edge, 0), (edge, height), (255, 0, 255), 2) + cv2.putText(frame, "IGNORED", (max(4, self.line_x_start - 92), height - 14), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA) + cv2.putText(frame, "IGNORED", (min(width - 88, self.line_x_end + 8), height - 14), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA) + + for det in detections: + x1, y1, x2, y2 = (int(v) for v in det.bbox) + counted = bool(counter.counted_tracks.get(det.track_id)) + colour = (74, 222, 128) if counted else (248, 191, 113) + cv2.rectangle(frame, (x1, y1), (x2, y2), colour, 2) + cv2.putText(frame, f"#{det.track_id} {det.confidence:.2f}", (x1, max(14, y1 - 6)), + cv2.FONT_HERSHEY_SIMPLEX, 0.45, colour, 1, cv2.LINE_AA) + + cv2.line(frame, (self.line_x_start, self.line_y), (self.line_x_end, self.line_y), + (0, 255, 255), 2) + for edge in (self.line_y - self.margin, self.line_y + self.margin): + cv2.line(frame, (self.line_x_start, edge), (self.line_x_end, edge), + (0, 160, 160), 1) + + panel = f"IN {self.loading} OUT {self.unloading} NET {self.loading - self.unloading}" + cv2.rectangle(frame, (12, 12), (12 + 9 * len(panel) + 20, 84), (0, 0, 0), -1) + cv2.putText(frame, panel, (24, 46), cv2.FONT_HERSHEY_SIMPLEX, 0.8, + (74, 222, 128), 2, cv2.LINE_AA) + cv2.putText(frame, f"{self.fps:.1f} fps {self.tracked} tracked {self.ignored} ignored", + (24, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (200, 200, 200), 1, cv2.LINE_AA) + + ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 75]) + if ok: + with self._lock: + self._jpeg = buffer.tobytes() + + +def _too_small(bbox, scale: float) -> bool: + """`predict.py`'s perspective curve: a sack at the top of the frame is + genuinely smaller in pixels than the same sack at the bottom, so one flat + threshold either lets fragments through up close or discards real sacks far + away. Interpolated in the 1280x720 space the pipeline works in. `scale` of 0 + turns the gate off.""" + if scale <= 0: + return False + x1, y1, x2, y2 = bbox + centre_y = (y1 + y2) / 2.0 + top_y, bottom_y = 133.0, 720.0 + top_area, bottom_area = 3556.0, 11111.0 + if centre_y <= top_y: + minimum = top_area + elif centre_y >= bottom_y: + minimum = bottom_area + else: + ratio = (centre_y - top_y) / (bottom_y - top_y) + minimum = top_area + ratio * (bottom_area - top_area) + return (x2 - x1) * (y2 - y1) < minimum * scale + + +def _is_stream(source: str) -> bool: + return str(source).startswith(("rtsp://", "rtmp://", "http://", "https://")) + + +class _ThreadedStream: + """Decode in a background thread and always hand out the newest frame. + + A plain VideoCapture.read() on RTSP is blocking, and decoding 1080p costs + more than inference does — measured here at 6.7 fps end-to-end against 200 + fps for the model itself. Worse, reading slower than the camera sends builds + a backlog, so the picture drifts further behind real time the longer it + runs. Dropping stale frames keeps latency flat, which is what a counting + test needs to mean anything. `predict.py` does the same thing. + """ + + def __init__(self, source: str): + self._capture = cv2.VideoCapture(source) + try: + self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1) + except Exception: + pass + self._frame = None + self._lock = threading.Lock() + self._running = True + self._thread = threading.Thread(target=self._pump, daemon=True) + if self._capture.isOpened(): + self._thread.start() + + def _pump(self) -> None: + while self._running: + ok, frame = self._capture.read() + if not ok: + time.sleep(0.01) + continue + with self._lock: + self._frame = frame + + def isOpened(self) -> bool: + return self._capture.isOpened() + + def read(self): + with self._lock: + if self._frame is None: + return False, None + frame, self._frame = self._frame, None + return True, frame + + def release(self) -> None: + self._running = False + # The thread is only started when the capture opened, so a failed + # source would otherwise raise "cannot join thread before it is + # started" here — inside the caller's finally, skipping the GPU lock + # release and wedging every later session on "GPU busy". + if self._thread.is_alive(): + self._thread.join(timeout=2) + self._capture.release() + + +def _open(source: str): + if _is_stream(source): + os.environ.setdefault( + "OPENCV_FFMPEG_CAPTURE_OPTIONS", + "rtsp_transport;tcp|buffer_size;20480000|max_delay;500000", + ) + return _ThreadedStream(source) + return cv2.VideoCapture(source) + + +# ---- module-level single session ---------------------------------------- + +_session: Optional[Session] = None +_guard = threading.Lock() + + +def start(source: str, model_path: str, line_y: int, line_x_start: int, line_x_end: int, + conf: float = 0.35, dedup_radius: float = 60.0, margin: int = 5, + imgsz: int = 640, entry_travel_min: float = 60.0, + handoff_radius: float = 100.0, unload_confirm_frames: int = 3, + min_area_scale: float = 1.0, spatial_dedup: bool = False) -> dict: + global _session + with _guard: + if _session is not None and _session.status()["running"]: + raise LiveCountError("A counting session is already running — stop it first") + if not os.path.isfile(model_path): + raise LiveCountError(f"Model not found: {model_path}") + if not _is_stream(source) and not os.path.isfile(source): + raise LiveCountError(f"Source not found: {source}") + _session = Session(source, model_path, line_y, line_x_start, line_x_end, + conf, dedup_radius, margin, imgsz, entry_travel_min, + handoff_radius, unload_confirm_frames, min_area_scale, + spatial_dedup) + _session.start() + time.sleep(0.4) # let an immediate failure surface in the response + return _session.status() + + +def stop() -> dict: + global _session + with _guard: + if _session is None: + return {"running": False} + _session.stop() + report = _session.status() + _session = None + return report + + +def move_line(line_y=None, line_x_start=None, line_x_end=None) -> dict: + if _session is None: + raise LiveCountError("No counting session is running") + return _session.move_line(line_y, line_x_start, line_x_end) + + +def status() -> dict: + if _session is None: + # Same shape as a live session, so callers never branch on presence. + return {"running": False, "loading": 0, "unloading": 0, "net": 0, "frames": 0, + "fps": 0.0, "tracked": 0, "ignored": 0, "too_small": 0, "traced": 0, + "trace_path": "", "elapsed": 0.0, "events": [], + "error": "", "source": "", "model_path": "", + "line": {"y": 0, "x_start": 0, "x_end": 1280}} + return _session.status() + + +def snapshot() -> Optional[bytes]: + return _session.snapshot() if _session is not None else None diff --git a/backend/main.py b/backend/main.py index 12439f9..22670c3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,14 +14,20 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from backend import config, db, jobs -from backend.api import batches, jobs as job_routes, models, projects, review +from backend import archive_index, config, db, jobs +from backend.api import (batches, counting_bench as counting_bench_routes, + datasets as dataset_routes, jobs as job_routes, + live_count as live_count_routes, models, projects, review, triage) @asynccontextmanager async def lifespan(_app: FastAPI): config.ensure_dirs() db.migrate() + from backend import datasets as dataset_store + adopted = dataset_store.adopt_legacy_tree() + if adopted: + print(f"[startup] moved {adopted} legacy dataset tree(s) under datasets//") from backend import projects as project_store project_store.ensure_seed_project() interrupted = jobs.recover() @@ -43,6 +49,10 @@ app.include_router(batches.router) app.include_router(review.router) app.include_router(models.router) app.include_router(job_routes.router) +app.include_router(triage.router) +app.include_router(dataset_routes.router) +app.include_router(live_count_routes.router) +app.include_router(counting_bench_routes.router) @app.get("/api/health") diff --git a/backend/projects.py b/backend/projects.py index 8305640..23ff4fb 100644 --- a/backend/projects.py +++ b/backend/projects.py @@ -252,12 +252,9 @@ def add_class(project_id: int, name: str, prompt: Optional[str] = None) -> dict: (project_id, next_id, clean, (prompt or clean).strip()), ) - updated = get(project_id) - if updated["dataset"]["train"] + updated["dataset"]["val"] > 0: - from backend import dataset - - dataset.write_data_yaml(updated) - return updated + # No data.yaml to refresh here any more: it is assembled per training run + # from the datasets that run picks, so it always reflects the current classes. + return get(project_id) def delete_class(project_id: int, class_id: int) -> dict: @@ -305,7 +302,6 @@ def delete_class(project_id: int, class_id: int) -> dict: report = dataset.drop_class_from_labels(project, class_id) updated = get(project_id) - dataset.write_data_yaml(updated) return { "project": updated, diff --git a/backend/review.py b/backend/review.py index 237449a..36a1652 100644 --- a/backend/review.py +++ b/backend/review.py @@ -220,6 +220,45 @@ def delete(annotation_id: int) -> bool: return cur.rowcount > 0 +def delete_many(annotation_ids: List[int]) -> int: + """Drop a marquee's worth of shapes in one statement. + + One DELETE per shape would be one request per shape from the editor, and a + marquee over a dense frame routinely covers a hundred (REQ-045a).""" + if not annotation_ids: + return 0 + marks = ",".join("?" * len(annotation_ids)) + with db.cursor() as cur: + cur.execute(f"DELETE FROM annotations WHERE id IN ({marks})", annotation_ids) + return cur.rowcount + + +def reclass_many(annotation_ids: List[int], class_id: int) -> int: + if not annotation_ids: + return 0 + with db.cursor() as cur: + cur.execute( + """SELECT DISTINCT b.project_id FROM annotations a + JOIN frames f ON f.id = a.frame_id + JOIN batches b ON b.id = f.batch_id + WHERE a.id IN (%s)""" % ",".join("?" * len(annotation_ids)), + annotation_ids, + ) + projects = [row[0] for row in cur.fetchall()] + for project_id in projects: + _check_class(project_id, class_id) + + marks = ",".join("?" * len(annotation_ids)) + with db.cursor() as cur: + # Same rule as update(): a hand decision makes these the user's shapes, + # so a re-run of auto-annotation leaves them alone (REQ-034). + cur.execute( + f"UPDATE annotations SET class_id = ?, source = 'manual' WHERE id IN ({marks})", + [class_id, *annotation_ids], + ) + return cur.rowcount + + def replace_auto(frame_id: int, items: List[dict]) -> int: """Swap this frame's automatic shapes for a fresh set, leaving manual ones.""" with db.cursor() as cur: diff --git a/backend/sam3_engine.py b/backend/sam3_engine.py index 869a166..f735171 100644 --- a/backend/sam3_engine.py +++ b/backend/sam3_engine.py @@ -37,6 +37,8 @@ class Detection: mask: Optional[np.ndarray] = None # bool array, (H, W) at original image size + + class Sam3Engine: def __init__(self, checkpoint_path: Optional[str] = None): # SAM3 is CUDA-only in practice: `PositionEmbeddingSine` precomputes its @@ -67,13 +69,14 @@ class Sam3Engine: 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.""" - self.processor.confidence_threshold = threshold + processor = Sam3Processor(self.model, device=self.device) + processor.confidence_threshold = threshold detections: List[Detection] = [] with torch.autocast(self.device, dtype=self.autocast_dtype): - state = self.processor.set_image(image) + state = processor.set_image(image) for class_id, prompt in enumerate(prompts): - output = self.processor.set_text_prompt(prompt=prompt, state=state) + output = processor.set_text_prompt(prompt=prompt, state=state) masks, boxes, scores = output["masks"], output["boxes"], output["scores"] if masks.shape[0] == 0: continue @@ -95,11 +98,12 @@ class Sam3Engine: ) ) - del state - if self.device == "cuda": - torch.cuda.empty_cache() + del state return detections + + + # ---- interactive / exemplar prompting ------------------------------ def open_state(self, image: Image.Image): diff --git a/backend/test.py b/backend/test.py new file mode 100644 index 0000000..db2b6f6 --- /dev/null +++ b/backend/test.py @@ -0,0 +1,18 @@ +import json +from urllib import request + +data = json.dumps({ + "frame_id": 26, # Need a valid frame_id! Let's get one from the db + "engine": "sam3", + "threshold": 0.35, + "iou_threshold": 0.8, + "min_box_frac": 0.0, + "target_class_names": ["sack"] +}).encode('utf-8') + +req = request.Request('http://localhost:8000/api/batches/5/preview', data=data, headers={'Content-Type': 'application/json'}) +try: + with request.urlopen(req) as response: + print(response.read().decode('utf-8')) +except Exception as e: + print("Error:", getattr(e, 'read', lambda: str(e))()) diff --git a/backend/test_preview.py b/backend/test_preview.py new file mode 100644 index 0000000..782fcea --- /dev/null +++ b/backend/test_preview.py @@ -0,0 +1,21 @@ +import json +from urllib import request, error + +data = json.dumps({ + "frame_id": 1, + "engine": "base_model", + "threshold": 0.35, + "iou_threshold": 0.8, + "min_box_frac": 0.0, + "target_class_names": [] +}).encode('utf-8') + +req = request.Request('http://127.0.0.1:8000/api/batches/1/preview', data=data, headers={'Content-Type': 'application/json'}) +try: + with request.urlopen(req) as response: + print(response.read().decode('utf-8')) +except error.HTTPError as e: + print(f"HTTPError: {e.code}") + print(e.read().decode('utf-8')) +except Exception as e: + print(f"Error: {e}") diff --git a/backend/training.py b/backend/training.py index c5340f8..f244722 100644 --- a/backend/training.py +++ b/backend/training.py @@ -12,7 +12,8 @@ import shutil import time from typing import Optional -from backend import config, dataset, db, evaluate, hardware, jobs, projects +from backend import (augment, base_dataset, config, dataset, datasets, db, evaluate, + hardware, jobs, projects) PRETRAINED = {"bbox": "yolo11n.pt", "polygon": "yolo11n-seg.pt"} @@ -25,20 +26,84 @@ def models_dir(project_slug: str) -> str: return os.path.join(config.project_dir(project_slug), "models") -def start(project_id: int, epochs: int = 50, overrides: Optional[dict] = None, batch_ids: Optional[list] = None, class_ids: Optional[list] = None) -> dict: +#: Rough bytes one decoded 640px training image occupies in the RAM cache, +#: measured against the run that OOMed: 10.2 GB across 15,774 images. +_BYTES_PER_CACHED_IMAGE = 700_000 + + +def _cache_mode(train_images: int, job) -> object: + """Pick Ultralytics' `cache` argument for the memory this host actually has. + + RAM caching is a large speedup and worth taking when it fits. It is only + taken with three times the headroom the raw estimate asks for: the run that + died had ~10 GB of cache on a 30 GB host and still lost, because the + dataloader workers fork after the cache is built and their copy-on-write + pages are what turn "just fits" into a kill. Two-times headroom would have + green-lit exactly the run that failed. + """ + needed = train_images * _BYTES_PER_CACHED_IMAGE + try: + import psutil + available = psutil.virtual_memory().available + except Exception: + available = 0 + + if available == 0: + job.log(f"Image cache: disk (cannot read free memory; {train_images} images)") + return "disk" + if needed * 3 <= available: + job.log(f"Image cache: RAM (~{needed / 1e9:.1f} GB of " + f"{available / 1e9:.1f} GB free)") + return "ram" + job.log(f"Image cache: disk (RAM cache would need ~{needed / 1e9:.1f} GB, " + f"only {available / 1e9:.1f} GB free)") + return "disk" + + +def start(project_id: int, epochs: int = 50, overrides: Optional[dict] = None, + batch_ids: Optional[list] = None, class_ids: Optional[list] = None, + dataset_ids: Optional[list] = None, + base_dataset_ids: Optional[list] = None) -> dict: project = projects.get(project_id) if project is None: raise TrainingError("No such project") - counts = dataset.summary(project_id)["splits"] + + bases = list(base_dataset_ids or []) + + # No dataset picked means "everything this project has", which is what the + # single-dataset app always did. Base datasets are opt-in, so an empty pick + # never silently drags them in. + chosen = list(dataset_ids or []) + if not chosen and not bases: + chosen = [item["id"] for item in datasets.listing(project_id)] + if not chosen and not bases: + raise TrainingError( + "This project has no dataset yet — approve and merge a batch before training" + ) + + items = datasets.combined_items(project_id, chosen) if chosen else [] + base_train = sum(base_dataset.get(bid)["image_count"] for bid in bases + if base_dataset.get(bid) is not None) + counts = {"train": sum(1 for i in items if i["split"] == "train") + base_train, + "val": sum(1 for i in items if i["split"] == "val")} if counts["train"] == 0: raise TrainingError( - "The master dataset is empty — approve and merge a batch before training" + "The chosen dataset(s) hold no training images — merge a batch before training" + ) + # A base dataset is train-only, so it can never supply the val split that + # REQ-063's base-vs-new comparison is measured on. + if counts["val"] == 0: + raise TrainingError( + "Nothing to validate on — a base dataset only contributes training images, " + "so pick at least one of this project's own datasets too" ) settings = hardware.resolve(overrides, epochs) job = jobs.create( "train", - params={"project_id": project_id, "settings": settings, "batch_ids": batch_ids, "class_ids": class_ids}, + params={"project_id": project_id, "settings": settings, "batch_ids": batch_ids, + "class_ids": class_ids, "dataset_ids": chosen, + "base_dataset_ids": bases}, project_id=project_id, message=f"{counts['train']} train / {counts['val']} val", ) @@ -103,7 +168,10 @@ def _run_train(job) -> None: settings = job.params["settings"] batch_ids = job.params.get("batch_ids") class_ids = job.params.get("class_ids") - data_yaml = dataset.write_data_yaml(project, batch_ids=batch_ids, selected_class_ids=class_ids) + dataset_ids = job.params.get("dataset_ids") or [] + data_yaml = dataset.write_data_yaml(project, dataset_ids, batch_ids=batch_ids, + selected_class_ids=class_ids, require_val=True, + base_dataset_ids=job.params.get("base_dataset_ids") or []) # SAM3 and a training run must not hold VRAM at the same time (REQ-065). from backend.sam3_engine import release_engine @@ -130,6 +198,8 @@ def _run_train(job) -> None: epoch = getattr(trainer, 'epoch', 0) + 1 total = getattr(trainer, 'epochs', settings["epochs"]) job.progress(epoch, total, f"epoch {epoch}/{total}") + if job.cancelled: + trainer.stop_training = True model.add_callback("on_fit_epoch_end", on_epoch) job.progress(0, settings["epochs"]) @@ -138,6 +208,22 @@ def _run_train(job) -> None: if torch.cuda.is_available(): torch.backends.cudnn.benchmark = True + # REQ-110: explicit rather than inherited. An untouched project gets MEDIUM, + # which is Ultralytics' own default set, so this changes nothing by itself. + augmentation = augment.get(project["id"]) + job.log(f"Augmentation: {augmentation['preset']} — " + + ", ".join(f"{k}={v:g}" for k, v in sorted(augmentation["settings"].items()))) + + # `cache="ram"` used to be hardcoded. It holds the whole training set in + # memory, which was invisible at a few hundred images and fatal at fifteen + # thousand: the run below died mid-epoch with no traceback, killed by the + # host OOM killer, because 10 GB of cache plus per-worker copies did not fit + # in 30 GB. RAM caching is now earned, not assumed (CLAUDE.md §9). + train_list = os.path.join(os.path.dirname(data_yaml), "selected_train.txt") + with open(train_list, encoding="utf-8") as handle: + train_images = sum(1 for line in handle if line.strip()) + cache_mode = _cache_mode(train_images, job) + keep_run_dir = False try: model.train( @@ -145,9 +231,10 @@ def _run_train(job) -> None: epochs=settings["epochs"], imgsz=settings["imgsz"], batch=settings["batch"], + **augmentation["settings"], device=settings["device"], workers=settings.get("workers", 8), - cache="ram", + cache=cache_mode, project=os.path.join(out_dir, "runs"), name="train", exist_ok=True, @@ -175,10 +262,11 @@ def _run_train(job) -> None: cur.execute( """INSERT INTO model_versions (project_id, version, weights_path, parent_model_path, metrics, base_metrics, - created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)""", + created_at, augment) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (project["id"], version, weights, project["base_model_path"], - json.dumps(comparison["new"]), json.dumps(comparison["base"]), time.time()), + json.dumps(comparison["new"]), json.dumps(comparison["base"]), time.time(), + json.dumps(augmentation["settings"])), ) new = comparison["new"] diff --git a/backend/triage.py b/backend/triage.py new file mode 100644 index 0000000..437c525 --- /dev/null +++ b/backend/triage.py @@ -0,0 +1,531 @@ +"""Triage: deciding what each SAM3 shape is actually worth (REQ-100…108). + +A shape is never rewritten. Its verdict is *resolved* every time it is needed: + + manual override > first matching rule > keep + +so `annotations.class_id` keeps whatever SAM3 said, and any rule can be re-cut +later against the original output. That is the whole reason rules are evaluated +at training time rather than baked in at merge (REQ-102). + +A verdict is one of: + + keep the shape trains as its own class + reclass -> class_id the shape trains as a different class (REQ-105) + ignore the box is dropped; its image still trains (REQ-104) + +`ignore` drops the box rather than the image because these frames are dense — +around 44 shapes each. Excluding the whole image was measured against a real +batch and cost 96% of it (1,882 of 1,950 frames) to remove 10% of the boxes. +Dropping four boxes out of forty-four leaves the image overwhelmingly correct; +dropping the image leaves nothing to train on. + +The exception is a frame that loses *every* shape it had: an empty label file +says "there is nothing here", and for a frame that was full of sacks that is a +lie the model will learn. Those images are excluded. +""" + +import hashlib +import json +import time +from typing import List, Optional + +from backend import db, review + + +class TriageError(Exception): + pass + + +# ---- rules --------------------------------------------------------------- + +def rules(project_id: int, stage: str = "dataprep") -> List[dict]: + with db.cursor() as cur: + cur.execute( + """SELECT * FROM triage_rules WHERE project_id = ? AND stage = ? + ORDER BY position""", + (project_id, stage), + ) + return [_rule_dict(row) for row in cur.fetchall()] + + +def _rule_dict(row) -> dict: + return { + "id": row["id"], + "stage": row["stage"], + "position": row["position"], + "name": row["name"], + "predicate": json.loads(row["predicate"]), + "action": row["action"], + "target_class": row["target_class"], + } + + +RANGE_FIELDS = ("score", "area_pct", "aspect") + + +def _validate_predicate(name, predicate: dict) -> None: + """Reject a malformed rule here rather than inside a merge job. + + A bad predicate used to be stored happily and only raise when the resolver + reached it — by which time the batch had been flipped to `approved` and the + user was looking at a failed job quoting a Python unpacking error. + """ + label = name or "this rule" + for field, bounds in predicate.items(): + if field == "class_id": + if bounds is not None and not isinstance(bounds, int): + raise TriageError(f"{label}: class_id must be a class number") + continue + if field not in RANGE_FIELDS: + raise TriageError( + f"{label}: '{field}' is not something a rule can test " + f"(use {', '.join(RANGE_FIELDS)} or class_id)") + if bounds is None: + continue + if not isinstance(bounds, (list, tuple)) or len(bounds) != 2: + raise TriageError(f"{label}: '{field}' needs a [minimum, maximum] pair") + low, high = bounds + for edge in (low, high): + if edge is not None and not isinstance(edge, (int, float)): + raise TriageError(f"{label}: '{field}' bounds must be numbers or blank") + if low is not None and high is not None and low > high: + raise TriageError( + f"{label}: '{field}' minimum {low} is above its maximum {high}, " + "so the rule can never match") + + +def replace_rules(project_id: int, incoming: List[dict], stage: str = "dataprep") -> List[dict]: + """Store the whole ordered list — the UI edits it as one thing (REQ-100).""" + for item in incoming: + if item.get("action") not in ("keep", "ignore", "reclass"): + raise TriageError(f"Unknown action: {item.get('action')}") + if item["action"] == "reclass" and item.get("target_class") is None: + raise TriageError(f"Rule '{item.get('name')}' reclassifies but names no target class") + _validate_predicate(item.get("name"), item.get("predicate") or {}) + with db.cursor() as cur: + cur.execute("DELETE FROM triage_rules WHERE project_id = ? AND stage = ?", + (project_id, stage)) + for position, item in enumerate(incoming): + cur.execute( + """INSERT INTO triage_rules (project_id, stage, position, name, predicate, + action, target_class, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (project_id, stage, position, item.get("name") or f"rule {position + 1}", + json.dumps(item.get("predicate") or {}), item["action"], + item.get("target_class"), time.time()), + ) + return rules(project_id, stage) + + +# ---- overrides ----------------------------------------------------------- + +def set_overrides(annotation_ids: List[int], verdict: str, + target_class: Optional[int] = None) -> int: + """A hand decision outranks every rule, now and after any rule edit (REQ-103).""" + if verdict not in ("keep", "ignore", "reclass"): + raise TriageError(f"Unknown verdict: {verdict}") + if verdict == "reclass" and target_class is None: + raise TriageError("A reclass override needs a target class") + now = time.time() + with db.cursor() as cur: + cur.executemany( + """INSERT INTO annotation_overrides (annotation_id, verdict, target_class, decided_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(annotation_id) DO UPDATE SET + verdict = excluded.verdict, + target_class = excluded.target_class, + decided_at = excluded.decided_at""", + [(aid, verdict, target_class, now) for aid in annotation_ids], + ) + return cur.rowcount + + +def clear_overrides(annotation_ids: List[int]) -> int: + if not annotation_ids: + return 0 + with db.cursor() as cur: + placeholders = ",".join("?" for _ in annotation_ids) + cur.execute(f"DELETE FROM annotation_overrides WHERE annotation_id IN ({placeholders})", + annotation_ids) + return cur.rowcount + + +# ---- resolution ---------------------------------------------------------- + +def metrics(geometry: dict) -> dict: + """The signals a rule can test, all derived from the box.""" + x0, y0, x1, y1 = review.to_box(geometry) + width = max(0.0, x1 - x0) + height = max(0.0, y1 - y0) + return { + "area_pct": round(width * height * 100.0, 4), + "aspect": round(width / height, 4) if height > 0 else 0.0, + } + + +def _in_range(value: float, bounds) -> bool: + low, high = bounds + return (low is None or value >= low) and (high is None or value <= high) + + +def _matches(predicate: dict, shape: dict) -> bool: + if "class_id" in predicate and predicate["class_id"] is not None: + if shape["class_id"] != predicate["class_id"]: + return False + for field in ("score", "area_pct", "aspect"): + if predicate.get(field) is not None and not _in_range(shape[field], predicate[field]): + return False + return True + + +class Resolver: + """Holds a project's rules and overrides so a whole dataset can be resolved + without re-reading them per shape.""" + + def __init__(self, project_id: int, stage: str = "dataprep", + frozen: Optional[List[dict]] = None): + # `frozen` is a dataset's snapshot (REQ-132): the merge that cut it runs + # under those rules, not under whatever the project says today. + self.rules = list(frozen) if frozen is not None else rules(project_id, stage) + with db.cursor() as cur: + cur.execute("SELECT annotation_id, verdict, target_class FROM annotation_overrides") + self.overrides = {row[0]: (row[1], row[2]) for row in cur.fetchall()} + + def verdict(self, shape: dict) -> dict: + """Resolve one shape. `shape` needs id, class_id, score, area_pct, aspect.""" + override = self.overrides.get(shape["id"]) + if override is not None: + verdict, target = override + return {"verdict": verdict, "target_class": target, "source": "manual"} + for rule in self.rules: + if _matches(rule["predicate"], shape): + return {"verdict": rule["action"], "target_class": rule["target_class"], + "source": rule["name"]} + return {"verdict": "keep", "target_class": None, "source": "default"} + + def resolve_shapes(self, annotations: list) -> Optional[list]: + """Apply verdicts to one frame's annotations. + + Returns the surviving annotations with their effective class, or None + when the frame must not train at all — which now happens only if every + shape was dropped. + """ + kept = [] + for item in annotations: + shape = {"id": item["id"], "class_id": item["class_id"], + "score": float(item.get("score") or 1.0), + **metrics(item["geometry"])} + effective = self.effective_class(shape) + if effective is None: + continue + kept.append({**item, "class_id": effective}) + if annotations and not kept: + return None + return kept + + def effective_class(self, shape: dict) -> Optional[int]: + """The class this shape trains as, or None when the box is dropped.""" + resolved = self.verdict(shape) + if resolved["verdict"] == "ignore": + return None + if resolved["verdict"] == "reclass": + return resolved["target_class"] + return shape["class_id"] + + def version(self) -> str: + """A short hash of what this resolver would do (REQ-107). + + Two runs with the same version measured the same thing; two runs with + different versions did not, because a rule edit can change which images + are in the val set. + """ + payload = json.dumps( + {"rules": self.rules, "overrides": sorted(self.overrides.items())}, + sort_keys=True, default=str, + ) + return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12] + + +def shapes_for_frames(frame_ids: List[int]) -> List[dict]: + """Every annotation on these frames, with its signals and resolved verdict.""" + if not frame_ids: + return [] + with db.cursor() as cur: + placeholders = ",".join("?" for _ in frame_ids) + cur.execute( + f"""SELECT a.id, a.frame_id, a.class_id, a.score, a.source, a.geometry + FROM annotations a WHERE a.frame_id IN ({placeholders})""", + frame_ids, + ) + rows = cur.fetchall() + + shapes = [] + for row in rows: + geometry = json.loads(row["geometry"]) + shape = { + "id": row["id"], + "frame_id": row["frame_id"], + "class_id": row["class_id"], + "score": round(float(row["score"]), 4), + "origin": row["source"], + "box": review.to_box(geometry), + **metrics(geometry), + } + shapes.append(shape) + return shapes + + +SCATTER_POINTS = 4000 +"""How many dots the scatter gets. A real batch runs to ~85k shapes; every one of +them as an SVG circle locks the browser, and a boundary between two clusters is +just as visible in a few thousand points. The verdict tallies are still counted +over every shape, so the numbers are never a sample.""" + + +def as_ids(batch_ids) -> List[int]: + """One batch or many — Data Prep now tunes a whole selection at once (REQ-130).""" + if isinstance(batch_ids, int): + return [batch_ids] + if isinstance(batch_ids, str): + return [int(part) for part in batch_ids.split(",") if part.strip().lstrip("-").isdigit()] + return list(batch_ids) + + +def _resolved_shapes(batch_ids): + from backend import batches + + ids = as_ids(batch_ids) + found = [batches.get(bid) for bid in ids] + if not ids or any(batch is None for batch in found): + raise TriageError("No such batch") + if len({batch["project_id"] for batch in found}) > 1: + raise TriageError("Those batches are not all in the same project") + batch = found[0] + with db.cursor() as cur: + placeholders = ",".join("?" for _ in ids) + cur.execute( + f"SELECT id FROM frames WHERE batch_id IN ({placeholders}) ORDER BY batch_id, idx", + ids, + ) + frame_ids = [row[0] for row in cur.fetchall()] + + resolver = Resolver(batch["project_id"]) + shapes = shapes_for_frames(frame_ids) + for shape in shapes: + shape.update(resolver.verdict(shape)) + return batch, frame_ids, shapes, resolver + + +def batch_summary(batch_ids) -> dict: + """Verdict tallies over the whole selection, plus a sample to plot (REQ-106).""" + ids = as_ids(batch_ids) + batch, frame_ids, shapes, resolver = _resolved_shapes(ids) + + counts = {"keep": 0, "ignore": 0, "reclass": 0, "manual": 0} + per_frame = {} + for shape in shapes: + counts[shape["verdict"]] += 1 + if shape["source"] == "manual": + counts["manual"] += 1 + total, dropped = per_frame.get(shape["frame_id"], (0, 0)) + per_frame[shape["frame_id"]] = (total + 1, dropped + (shape["verdict"] == "ignore")) + + # Only a frame that loses everything is held back; the rest keep training + # with their surviving boxes. + ignored_frames = {fid for fid, (total, dropped) in per_frame.items() if total == dropped} + + # An even stride rather than a random draw: the sample is stable across + # reloads, so points do not jump around while the user is reading the plot. + stride = max(1, len(shapes) // SCATTER_POINTS) + sample = [ + {k: shape[k] for k in ("id", "class_id", "score", "area_pct", "aspect", "verdict", "source")} + for shape in shapes[::stride][:SCATTER_POINTS] + ] + + return { + "batch_ids": ids, + "project_id": batch["project_id"], + "status": batch["status"], + "merged": batch["status"] == "merged", + "frame_count": len(frame_ids), + "total_shapes": len(shapes), + "counts": counts, + # What merging this batch would do right now (REQ-104). + "frames_held_back": len(ignored_frames), + "frames_would_merge": len(frame_ids) - len(ignored_frames), + "sample": sample, + "sampled": len(sample) < len(shapes), + "rule_version": resolver.version(), + } + + +def batch_page(batch_ids, sort: str = "score", offset: int = 0, limit: int = 120) -> dict: + """One page of shapes for the crop grid, sorted server-side so the client + never holds the whole batch.""" + if sort not in ("score", "area_pct"): + raise TriageError(f"Cannot sort by {sort}") + _, _, shapes, _ = _resolved_shapes(batch_ids) + shapes.sort(key=lambda shape: shape[sort]) + page = shapes[offset:offset + limit] + for shape in page: + shape.pop("box", None) + return {"total": len(shapes), "offset": offset, "limit": limit, "shapes": page} + + +def _percentile(values: list, fraction: float) -> float: + if not values: + return 0.0 + return values[min(len(values) - 1, int(len(values) * fraction))] + + +def suggest(batch_ids) -> dict: + """Presets with thresholds read off this batch's own distribution. + + Asking someone to invent "score below 0.45" from nothing is guesswork. The + same question is easy when the number comes from their data and the effect + is stated: "the weakest 10% of detections — 8,552 shapes". + """ + _, frame_ids, shapes, _ = _resolved_shapes(batch_ids) + if not shapes: + return {"presets": [], "stats": {}} + + scores = sorted(shape["score"] for shape in shapes) + areas = sorted(shape["area_pct"] for shape in shapes) + total = len(shapes) + + def impact(predicate: dict) -> dict: + matched = [s for s in shapes if _matches(predicate, s)] + frames = {s["frame_id"] for s in matched} + return {"shapes": len(matched), "frames": len(frames)} + + presets = [] + + weak = round(_percentile(scores, 0.10), 3) + presets.append({ + "key": "drop-weakest", + "title": "Ignore the weakest detections", + "blurb": f"SAM3 scored these below {weak} — the bottom 10% of this batch.", + "rule": {"name": "low confidence", "predicate": {"score": [None, weak]}, "action": "ignore"}, + "impact": impact({"score": [None, weak]}), + }) + + specks = round(_percentile(areas, 0.05), 3) + presets.append({ + "key": "drop-specks", + "title": "Ignore tiny specks", + "blurb": f"Boxes smaller than {specks}% of the frame — usually noise, not objects.", + "rule": {"name": "specks", "predicate": {"area_pct": [None, specks]}, "action": "ignore"}, + "impact": impact({"area_pct": [None, specks]}), + }) + + median_area = round(_percentile(areas, 0.50), 3) + presets.append({ + "key": "split-by-size", + "title": "Split by size into a second class", + "blurb": f"Everything under {median_area}% area (half this batch) becomes another class — " + "pick which one. Size tracks distance from the camera as much as object type, " + "so check the crops before trusting it.", + "rule": {"name": "small ones", "predicate": {"area_pct": [None, median_area]}, + "action": "reclass", "target_class": None}, + "impact": impact({"area_pct": [None, median_area]}), + "needs_target": True, + }) + + tall = round(_percentile(sorted(s["aspect"] for s in shapes), 0.15), 3) + presets.append({ + "key": "odd-shapes", + "title": "Ignore oddly-shaped boxes", + "blurb": f"Aspect ratio under {tall} — long thin slivers, usually a bad mask.", + "rule": {"name": "slivers", "predicate": {"aspect": [None, tall]}, "action": "ignore"}, + "impact": impact({"aspect": [None, tall]}), + }) + + return { + "presets": presets, + "stats": { + "total_shapes": total, + "total_frames": len(frame_ids), + "score": {"p05": round(_percentile(scores, 0.05), 3), + "p50": round(_percentile(scores, 0.50), 3), + "p95": round(_percentile(scores, 0.95), 3)}, + "area_pct": {"p05": round(_percentile(areas, 0.05), 3), + "p50": round(_percentile(areas, 0.50), 3), + "p95": round(_percentile(areas, 0.95), 3)}, + }, + } + + +def simulate(batch_ids, candidate_rules: List[dict]) -> dict: + """What these rules would do, without saving them. + + Editing a threshold and seeing the number move is the whole difference + between tuning a filter and guessing at one. + """ + _, frame_ids, shapes, _ = _resolved_shapes(batch_ids) + with db.cursor() as cur: + cur.execute("SELECT annotation_id, verdict, target_class FROM annotation_overrides") + manual = {row[0]: (row[1], row[2]) for row in cur.fetchall()} + + counts = {"keep": 0, "ignore": 0, "reclass": 0} + per_rule = [0] * len(candidate_rules) + per_frame = {} + + for shape in shapes: + if shape["id"] in manual: + verdict = manual[shape["id"]][0] + else: + verdict = "keep" + for index, rule in enumerate(candidate_rules): + if _matches(rule.get("predicate") or {}, shape): + verdict = rule["action"] + per_rule[index] += 1 + break + counts[verdict] += 1 + total, dropped = per_frame.get(shape["frame_id"], (0, 0)) + per_frame[shape["frame_id"]] = (total + 1, dropped + (verdict == "ignore")) + + ignored_frames = {fid for fid, (total, dropped) in per_frame.items() if total == dropped} + + return { + "total_shapes": len(shapes), + "counts": counts, + "per_rule": per_rule, + "frames_held_back": len(ignored_frames), + "frames_would_merge": len(frame_ids) - len(ignored_frames), + } + + +def preview(project_id: int) -> dict: + """What the current rules would do to the whole merged dataset.""" + with db.cursor() as cur: + cur.execute( + "SELECT DISTINCT frame_id FROM dataset_items WHERE project_id = ?", (project_id,)) + frame_ids = [row[0] for row in cur.fetchall()] + + resolver = Resolver(project_id) + shapes = shapes_for_frames(frame_ids) + counts = {"keep": 0, "ignore": 0, "reclass": 0} + per_class: dict = {} + per_frame = {} + for shape in shapes: + resolved = resolver.verdict(shape) + counts[resolved["verdict"]] += 1 + total, dropped = per_frame.get(shape["frame_id"], (0, 0)) + per_frame[shape["frame_id"]] = (total + 1, dropped + (resolved["verdict"] == "ignore")) + if resolved["verdict"] == "ignore": + continue + effective = resolver.effective_class(shape) + per_class[effective] = per_class.get(effective, 0) + 1 + + excluded_images = {fid for fid, (total, dropped) in per_frame.items() if total == dropped} + + return { + "total_shapes": len(shapes), + "total_images": len(frame_ids), + **counts, + "excluded_images": len(excluded_images), + "trainable_images": len(frame_ids) - len(excluded_images), + "per_class": per_class, + "rule_version": resolver.version(), + } diff --git a/backend/video.py b/backend/video.py index 32eacc0..b508db3 100644 --- a/backend/video.py +++ b/backend/video.py @@ -9,6 +9,7 @@ import json import os import shutil import subprocess +import tempfile from typing import Callable, List, Optional VIDEO_EXTS = (".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v") @@ -44,7 +45,7 @@ def probe(path: str) -> dict: result = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=width,height,avg_frame_rate", + "-show_entries", "stream=width,height,avg_frame_rate,codec_name", "-show_entries", "format=duration", "-of", "json", path], capture_output=True, text=True, @@ -59,12 +60,14 @@ def probe(path: str) -> dict: "width": int(streams[0].get("width") or 0), "height": int(streams[0].get("height") or 0), "fps": _parse_fps(streams[0].get("avg_frame_rate")), + "codec_name": str(streams[0].get("codec_name") or ""), "size": stat.st_size, } _probe_cache[key] = info return info + def _parse_fps(value: Optional[str]) -> float: # ffprobe reports "30000/1001", and "0/0" for streams it cannot work out. if not value or "/" not in value: @@ -111,23 +114,30 @@ def extract_frames( "-vf", f"fps={fps}", "-q:v", "2", os.path.join(out_dir, "%06d.jpg"), ] - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + # stderr goes to a file, not a pipe: a damaged recording emits decode errors + # without end, and nothing reads the pipe until ffmpeg exits — so a 64KB + # buffer would fill and block ffmpeg forever, hanging the whole job queue. + with tempfile.TemporaryFile("w+") as stderr_log: + process = subprocess.Popen(command, stdout=subprocess.DEVNULL, + stderr=stderr_log, text=True) - expected = frame_count(start_sec, end_sec, fps) - while process.poll() is None: - if should_stop is not None and should_stop(): - process.terminate() - process.wait(timeout=10) - raise VideoError("cancelled") - if on_progress is not None: - on_progress(min(_written(out_dir), expected)) - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - pass + expected = frame_count(start_sec, end_sec, fps) + while process.poll() is None: + if should_stop is not None and should_stop(): + process.terminate() + process.wait(timeout=10) + raise VideoError("cancelled") + if on_progress is not None: + on_progress(min(_written(out_dir), expected)) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + pass - if process.returncode != 0: - raise VideoError((process.stderr.read() or "ffmpeg failed").strip().splitlines()[-1]) + if process.returncode != 0: + stderr_log.seek(0) + lines = stderr_log.read().strip().splitlines() + raise VideoError(lines[-1] if lines else "ffmpeg failed") return sorted(name for name in os.listdir(out_dir) if name.endswith(".jpg")) @@ -137,3 +147,56 @@ def _written(out_dir: str) -> int: return sum(1 for name in os.listdir(out_dir) if name.endswith(".jpg")) except OSError: return 0 + + +def convert_to_h264( + input_path: str, + output_path: Optional[str] = None, + crf: int = 18, + preset: str = "medium", +) -> str: + """Transcode input video to browser-supported H.264 / AAC MP4. + + Uses CRF 18 for visually lossless compression (no perceptible visual loss, + significantly reduced file size), yuv420p for universal browser playback, + and +faststart for instant web streaming. + """ + if not available(): + raise VideoError("ffmpeg is not installed in this environment") + + if not os.path.isfile(input_path): + raise VideoError(f"Input video file not found: {input_path}") + + if not output_path: + base, _ = os.path.splitext(input_path) + output_path = f"{base}_h264.mp4" + + temp_output_path = output_path.replace(".mp4", "_tmp.mp4") if output_path.endswith(".mp4") else output_path + ".tmp.mp4" + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + + command = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-i", input_path, + "-c:v", "libx264", + "-crf", str(crf), + "-preset", preset, + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + "-c:a", "aac", + "-b:a", "128k", + "-f", "mp4", + temp_output_path, + ] + + + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + if os.path.isfile(temp_output_path): + os.remove(temp_output_path) + err_msg = result.stderr.strip().splitlines()[-1] if result.stderr else "ffmpeg conversion failed" + raise VideoError(err_msg) + + os.replace(temp_output_path, output_path) + return output_path + + diff --git a/backend/video_clock.py b/backend/video_clock.py new file mode 100644 index 0000000..bc712a0 --- /dev/null +++ b/backend/video_clock.py @@ -0,0 +1,249 @@ +"""Read the burned-in timestamp from a recording's overlay (REQ-160). + +The archive's folder names do not say when a recording was made. Neither does +its mtime: `2026-08-13/batch003.mp4` is 38 minutes long but the next file's +mtime is 13 minutes later, because those are file *copy* times, not recording +times. The only trustworthy clock is the one the camera burns into the top-right +of every frame, in a fixed font at a fixed position: + + 2026-08-13 08:27:28 + +So this reads it. Not a general OCR — 12 glyphs (0-9, '-', ':') in one typeface +at one size, which a nearest-template match handles exactly and without adding +an OCR dependency to the image. + +Isolating the text uses the one thing that distinguishes it from the wall and +the sacks behind it: it is bright *and* outlined in black. A plain brightness +threshold picks up a lit wall and merges glyphs together; requiring a dark pixel +within a few px of every bright one does not. + +One caveat this module deliberately does not paper over: video time is not real +time. Measured on batch003, 1,940 seconds of video covers 783 seconds of wall +clock — the camera records at roughly 10 fps and stores at 25. So a file's +duration says nothing about when it ended, and only the *start* timestamp is +trusted here. +""" + +import os +from typing import Optional + +# The overlay's box in a 1280x720 frame. Glyphs are found inside it by column +# runs rather than at fixed offsets: '1', '-' and ':' are narrower than a digit, +# so a slot grid measured off one timestamp clips the wider glyphs of the next. +# Where the overlay sits in a 1280x720 frame. Nearly every recording puts it in +# the same rows; the one 1080p file in the archive lands ~10 px lower once +# scaled down, so a second band is tried for it rather than detecting the band +# per frame — detection was flakier than the two fixed guesses it replaced. +CANDIDATE_BANDS = ((28, 60), (38, 74), (20, 54)) +CROP_LEFT, CROP_RIGHT = 950, 1268 +SCALE = 3 +GLYPH_SIZE = (24, 32) +GLYPH_COUNT = 18 # "YYYY-MM-DDHH:MM:SS" without the space +MIN_RUN_WIDTH = 4 +TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "assets", "clock_glyphs.npz") + +_templates = None +_keys = None + + +class ClockError(Exception): + pass + + +def _load(): + global _templates, _keys + if _templates is None: + import numpy as np + + data = np.load(TEMPLATE_PATH) + _keys = list(data.keys()) + _templates = np.stack([data[k] for k in _keys]) + return _templates, _keys + + +def _mask(frame, band=CANDIDATE_BANDS[0]): + """The overlay's glyphs in one candidate band, isolated from the scene. + + Bright alone is not enough — a lit wall clears any brightness threshold and + merges the glyphs into one blob. What separates the text is that every + stroke is outlined in black, so a bright pixel only counts when a dark one + sits within a few pixels of it. + """ + import cv2 + import numpy as np + + if frame.shape[0] != 720 or frame.shape[1] != 1280: + frame = cv2.resize(frame, (1280, 720)) + grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + roi = grey[band[0]:band[1], CROP_LEFT:CROP_RIGHT] + roi = cv2.resize(roi, None, fx=SCALE, fy=SCALE, interpolation=cv2.INTER_CUBIC) + bright = (roi > 195).astype(np.uint8) + dark = (roi < 90).astype(np.uint8) + near_dark = cv2.dilate(dark, np.ones((13, 13), np.uint8)) + return ((bright & near_dark) * 255).astype(np.uint8) + + +def _glyphs(binary): + """Cut the strip into one tight image per glyph, left to right.""" + import cv2 + import numpy as np + + columns = binary.sum(axis=0) // 255 + runs, start = [], None + for index, value in enumerate(columns): + if value > 0 and start is None: + start = index + elif value == 0 and start is not None: + if index - start >= MIN_RUN_WIDTH: + runs.append((start, index)) + start = None + if start is not None: + runs.append((start, len(columns))) + + out = [] + for left, right in runs: + column = binary[:, left:right] + rows = np.where(column.sum(axis=1) > 0)[0] + if len(rows) == 0: + continue + tight = column[rows[0]:rows[-1] + 1, :] + out.append(cv2.resize(tight, GLYPH_SIZE, interpolation=cv2.INTER_AREA).astype(np.float32)) + return out + + +def _decode(patches) -> tuple: + import numpy as np + + templates, keys = _load() + chars, worst = [], 0.0 + for patch in patches: + distances = ((templates - patch) ** 2).sum(axis=(1, 2)) + order = np.argsort(distances) + best, runner_up = distances[order[0]], distances[order[1]] + chars.append(keys[int(order[0])]) + worst = max(worst, best / max(1.0, runner_up)) + return "".join(chars), round(1.0 - min(1.0, worst), 3) + + +def read_frame(frame) -> tuple: + """Decode one frame's overlay. Returns (text, confidence). + + Confidence is the worst per-glyph separation across the strip — the distance + to the best template over the distance to the runner-up. A glyph that matches + its own template several times better than any other is safe; one that barely + wins is what a wrong digit looks like. Every candidate band is tried and the + best *parseable* reading wins, so a misplaced band scores itself out rather + than silently recording the wrong hour. + """ + best = ("", 0.0) + for band in CANDIDATE_BANDS: + patches = _glyphs(_mask(frame, band)) + if len(patches) != GLYPH_COUNT: + continue + text, confidence = _decode(patches) + if parse(text) is not None and confidence > best[1]: + best = (text, confidence) + return best + + +def _shaped(text: str) -> bool: + if len(text) != 18: + return False + for got, want in zip(text, "dddd-dd-dddd:dd:dd"): + if want == "d" and not got.isdigit(): + return False + if want != "d" and got != want: + return False + return True + + +MIN_YEAR, MAX_YEAR = 2015, 2100 + + +def parse(text: str): + """The decoded string as a datetime, or None if it is not a real one. + + The year range matters: a single misread digit turned 2026 into 7026 on one + recording, and `strptime` accepts that happily. A timestamp outside these + bounds is a decoding failure, not a recording from the far future. + """ + import datetime + + if not _shaped(text): + return None + try: + stamp = datetime.datetime.strptime(text, "%Y-%m-%d%H:%M:%S") + except ValueError: + return None + return stamp if MIN_YEAR <= stamp.year <= MAX_YEAR else None + + +def read_video_start(path: str, probe_seconds=(2, 8, 20, 45)) -> dict: + """When the recording in `path` started, read off its own overlay. + + Several frames are sampled rather than one. A single frame can be caught + mid-transition or behind a passing sack, and a lone unverifiable reading is + exactly the kind of thing that would silently reassign a video to the wrong + working day. A reading is accepted only when two frames agree, after + subtracting the video-time offset between them. + """ + import cv2 + import datetime + + capture = cv2.VideoCapture(path) + if not capture.isOpened(): + raise ClockError(f"Could not open {path}") + fps = capture.get(cv2.CAP_PROP_FPS) or 25.0 + + readings = [] + try: + for offset in probe_seconds: + capture.set(cv2.CAP_PROP_POS_FRAMES, int(offset * fps)) + ok, frame = capture.read() + if not ok or frame is None: + continue + text, confidence = read_frame(frame) + stamp = parse(text) + if stamp is not None and confidence > 0.0: + readings.append({"at": offset, "stamp": stamp, + "text": text, "confidence": confidence}) + finally: + capture.release() + + if not readings: + return {"start": None, "confidence": 0.0, "readings": [], + "error": "no readable timestamp overlay"} + + # Video time runs slower than the wall clock on these recordings, so two + # readings cannot be checked by assuming a 1:1 offset. What they must agree + # on is the ordering and a sane elapsed span. + first = readings[0] + agreed = [r for r in readings[1:] + if 0 <= (r["stamp"] - first["stamp"]).total_seconds() <= r["at"] * 2] + span = (readings[-1]["stamp"] - first["stamp"]).total_seconds() + + return { + "start": first["stamp"], + "confidence": round(min(r["confidence"] for r in readings), 3), + "agreeing": len(agreed) + 1, + "readings": [{"at": r["at"], "text": r["text"]} for r in readings], + "rate": round(span / max(1, readings[-1]["at"] - first["at"]), 2) if span else None, + "error": "" if agreed else "only one frame produced a usable reading", + } + + +def working_day(stamp, cutoff_hour: int = 6) -> str: + """The working day a recording belongs to (REQ-161). + + A shift runs 06:00 to 06:00, so anything before the cutoff belongs to the + previous calendar day. `predict.py` has the same idea in `get_counting_date`, + with a different cutoff. + """ + import datetime + + if stamp is None: + return "" + day = stamp.date() + if stamp.hour < cutoff_hour: + day = day - datetime.timedelta(days=1) + return day.isoformat() diff --git a/docs/annotation-policy.md b/docs/annotation-policy.md new file mode 100644 index 0000000..7377554 --- /dev/null +++ b/docs/annotation-policy.md @@ -0,0 +1,72 @@ +# Annotation policy — sack counting + +Why this document exists: the model is not the product, **the count** is. Every rule below +is derived from how `algoritma-batch/src/counting.py` turns boxes into counts. Change the +counter and this policy has to change with it. + +## The one fact that drives everything + +`LineCrossCounter` counts on **`y1` — the top edge of the box** (`counting.py`, "Uses y1 +(top edge) of the stabilized sack bounding box"). A track is counted when it was seen ABOVE +the zone and later BELOW it. + +So the top edge of every box you draw is a **measurement instrument**. Anything that moves +`y1` for a reason other than the sack moving is a counting error you baked into the dataset. + +## The rule + +| Situation | Annotate? | Box | +| --- | --- | --- | +| Fully visible | Yes | Visible extent | +| Partly occluded, **top edge visible** | Yes | Visible extent | +| **Top edge hidden** (head, arm, another sack over the top) | **No** | — | +| Under ~40% visible | No | — | +| Fully hidden | **Never** | — | + +**If you cannot see where the sack's top edge is, do not annotate it.** + +Reasoning: a head over the top of a sack drags `y1` down 50–100 px. The sack reads as lower +than it is and can trip `above → below` early — a phantom count caused by annotation, not by +the model. Leaving it unannotated costs nothing, because `cfg/tracker.yaml` sets +`track_buffer: 60` (~2.4 s at 25 fps) specifically to "survive worker occlusion". The tracker +coasts through the gap and keeps the ID. + +Never annotate a sack you cannot see. Labeling invisible objects teaches the model to +hallucinate, which produces ghost tracks and overcounting. + +## Consistency beats correctness + +Either occlusion convention can work. **Mixing them cannot.** A model trained on "sometimes +we box occluded sacks, sometimes not" learns to fire at unstable confidence on ambiguous +evidence — which is how you get ID switches (overcount) and dropped tracks (undercount) at +the same time. Pick the table above and apply it identically, every session. + +## Don't let triage delete the hard examples + +SAM3's confidence correlates with occlusion: low score usually means *partly hidden*, not +*wrong*. A blanket "ignore the weakest 10%" rule therefore deletes exactly the occluded +examples and trains a model that only knows easy, fully-visible sacks. It will then fail +where workers stand — which is where the counting line is. + +Use triage to remove boxes that are **wrong**: + +- slivers and long thin masks (low aspect ratio) +- duplicates of the same sack +- background objects that are not sacks +- boxes so tiny they cannot be a sack at this camera distance + +Do **not** use it to remove boxes that are merely **hard**. Check the crop grid before +saving any `ignore` rule — if the crops show real sacks, the threshold is too aggressive. + +## "Half sack" is usually "occluded sack" + +A sack that looks half-sized on screen is most often a *full* sack that is partly hidden, or +one further from the camera. Both are normal sacks. Only split a second class if the crop +grid shows a genuinely different object, and remember that box area tracks distance from the +camera at least as much as it tracks object size. + +## Before trusting any of this + +There is no ground truth yet. Hand-count 2–3 videos per camera and store the numbers, so a +retrained model can be measured against something real. Until then, mAP moves are not +evidence that the *count* improved — and the count is the product. diff --git a/docs/audit-2026-08-07.md b/docs/audit-2026-08-07.md new file mode 100644 index 0000000..3ad1e31 --- /dev/null +++ b/docs/audit-2026-08-07.md @@ -0,0 +1,181 @@ +# Audit — reTraining, 2026-08-07 + +Method: 4 mapping agents over the codebase, 5 bug-hunting agents by dimension, adversarial +verification of each finding. 22 findings survived verification; 2 CRITICALs come from a +dimension whose verifiers were cut short by a usage limit and are marked *unverified* — both +were confirmed by hand afterwards. + +--- + +## 1. How the system actually works + +**Archive → batch.** `backend/library.py:90` lists the video archive and, per file, calls +`ensure_video_preview`, which spawns a bare daemon thread running an ffmpeg H.264 transcode +(`library.py:132-142`) for anything not already previewed. Trim range → `backend/batches.py` +extracts frames into `data/projects//batches//`, numbered per batch from 1 +(`batches.py:174,200-204`). + +**Auto-annotate.** `backend/autolabel.py:29` starts a job; `_run_autolabel` loads SAM3 +(`sam3_engine.py`) or a YOLO model, runs per frame, and writes shapes with `source='auto'` +via `review.replace_auto` / `append_auto` (`review.py:223,237`). Unknown class names in the +request are *silently added to the project* (`autolabel.py:100-106`). The SAM3 +`set_image`-once-per-image invariant holds — verified in the engine loop. + +**Review.** `frontend/src/pages/ReviewPage.jsx` holds all shapes in one `annotations` array. +Canvas gestures write `{local:true}` updates during the drag, then commit a PATCH on +pointerup (`AnnotationCanvas.jsx:128-141`). Human edits flip `source` to `'manual'` +(`review.py:208`). Approve/reject → `frames.review_status`. + +**Merge.** `dataset.approve()` (`dataset.py:30`) sets status `approved` and queues a `merge` +job. `_run_merge` copies each approved frame into `dataset/images/{split}/__.jpg` +plus a label `.txt` (`dataset.py:380-391`), guarded per-frame by a `dataset_items` row. Split +comes from `_next_split` (`dataset.py:69-75`), which is **positional** — every Nth row by +`COUNT(*) FROM dataset_items` goes to val. + +**Train & compare.** `training.py:106` calls `write_data_yaml`, which *always* calls +`sync_labels` (`dataset.py:117`) — rewriting every merged frame's label file from the live +`annotations` table. Fine-tune runs from the base model; `evaluate.compare` (`evaluate.py:39`) +runs `YOLO.val(split='val')` for base and new against the same `data.yaml`. + +--- + +## 2. Critical bugs + +### C1 — The job queue was deleted; everything now runs concurrently *(uncommitted)* +`backend/jobs.py:152`. `create()` was changed to spawn one thread per job instead of enqueuing +on the single worker. `_queue`, `_worker`, `_worker_lock` and `import queue` are now dead code, +while the module docstring still claims "one worker". Two merges, or a merge and a train, now +touch `dataset/` simultaneously. This is the root cause of C2, H1 and H2. +**Fix:** revert to the single-worker queue (`git diff backend/jobs.py`), delete the dead +`_start_job`. + +### C2 — `autolabel` no longer takes `gpu_lock` *(uncommitted)* +`backend/jobs.py:31`. `GPU_JOB_TYPES` was narrowed to `("train",)` on the theory that +per-frame inference can safely run in parallel. It cannot: N parallel SAM3 jobs each hold a +full backbone in VRAM. Worse, `release_engine()` (`sam3_engine.py:229-246`) only clears the +module global — it cannot free VRAM held by a *running* autolabel job, so a training run that +starts mid-autolabel OOMs. The synchronous routes `preview_autolabel` +(`api/batches.py:163`) and `/api/sam3/playground-test` (`api/batches.py:183`) take no lock +either, while `review.assist` (`review.py:302`) correctly does. +**Fix:** put `autolabel` back in `GPU_JOB_TYPES`; wrap both synchronous routes in +`gpu_lock.acquire(timeout=...)` the way `review.assist` does. + +### C3 — `custom_model_path` is a raw client-supplied filesystem path +`backend/autolabel.py:127`, `api/batches.py:37,46`. `inspect-model` writes the upload to a +`NamedTemporaryFile(delete=False)` and returns **the server path to the browser** +(`api/batches.py:123`), which the client posts back. A stale or wrong path does not error — +it falls back to a different model, so the batch is labelled by weights the user did not pick. +**Fix:** return an opaque staging id, keep the id→path map server-side, and raise +`BatchError('staged model expired, re-upload')` instead of falling back. + +--- + +## 3. Everything else, ranked + +| Sev | Area | Location | Issue | Fix | +|---|---|---|---|---| +| H | dataset | `dataset.py:117` | `sync_labels` rewrites **merged** labels from live annotations with no `review_status` filter — re-running auto-annotate on a merged batch pushes unreviewed model output into the master dataset on the next training start | Filter to `review_status='approved'`, or snapshot labels at merge time | +| H | dataset | `dataset.py:91` | Class-filtered training rewrites the *shared* master labels with remapped 0..k-1 ids, contradicting `project_classes` | Write remapped labels to a separate `labels_selected/` tree | +| H | dataset | `batches.py:239` | Deleting a batch drops DB rows (FK cascade) but leaves `dataset/images/**` + `labels/**` orphans on disk, which `data.yaml` still trains on; also shifts `_next_split` positions → train/val leakage on re-import | Unlink the batch's `dataset_items` files before deleting; make split content-derived (hash) not positional | +| H | dataset | `dataset.py:30` | Double-approve queues two concurrent merge jobs for the same batch | Reject approve when a non-terminal merge job exists; `INSERT … ON CONFLICT DO NOTHING` | +| H | dataset | `dataset.py:117` | Label + `data.yaml` writes are truncate-in-place, not atomic — a training run reads a half-written dataset | `os.replace` from temp files; snapshot the file list before training | +| H | library | `library.py:92` | Listing the archive spawns one unbounded ffmpeg `-preset medium -crf 18` thread per non-H.264 video | Route through `jobs.create` or a 1–2 worker pool; transcode lazily on playback | +| H | frontend | `ReviewSidebar.jsx:86` | Sidebar trash button deletes the **previously** selected shape (stale closure: `setSelectedId` then `removeSelected` in one tick) | Pass the id explicitly: `removeAnnotation(item.id)` | +| H | frontend | `ReviewPage.jsx:239` | Capture-phase keydown ignores modifiers — Ctrl/Cmd+A/C/X/S/T/N all fire review shortcuts and `preventDefault()` | Early-return when `ctrlKey \|\| metaKey \|\| altKey` | +| H | frontend | `ReviewPage.jsx:93` | Index advances even when the status POST fails; optimistic status never reverted | Revert in `catch`, don't advance on rejection | +| H | api | `api/batches.py:117` | `inspect-model` leaks its staged `.pt` on every success and on modal cancel | Server-owned staging dir keyed by id, deleted on job completion + startup sweep | +| H | api | `dataset.py:275` | `datasetSummary` returns **every annotation in the project** as JSON on two page loads | Return histograms only; gate raw shapes behind `?detail=shapes` | +| H | frontend | `AutoAnnotateModal.jsx:82` | Preview errors (VRAM OOM, bad model, missing frame) are all swallowed to `console.error` and render as "no detections" | Add an error surface; distinguish 4xx config from 5xx inference | +| M | dataset | `dataset.py:402` | Cancelled merge still marks the batch `merged`; approved frames are then permanently unmergeable | Only set `merged` when the loop completed | +| M | dataset | `dataset.py:140,162` | Empty val set silently falls back to the **training** images — base-vs-new mAP is then measured on trained data | Refuse to train/compare with an empty val set | +| M | dataset | `dataset.py:154` | `selected_data.yaml` writes `nc` from the full class list while `names` holds the subset | `nc: len(target_classes)` in both branches | +| M | autolabel | `autolabel.py:100` | A labelling job silently creates project classes from client-supplied names (violates REQ-003's "no drift as a side effect") | Make class creation explicit; preview should report unknown names | +| M | jobs | `jobs.py:194` | `cancel()` flushes a whole-row snapshot, racing the handler thread's own flush → progress/log resurrection | Targeted `UPDATE … WHERE status='queued'` + append-only log | +| M | jobs | `jobs.py:66` | Cancellation is process-local; `recover()` marks such jobs `failed`, never `cancelled` | Persist a `cancel_requested` column | +| M | api | `api/batches.py:163` | Synchronous GPU inference in request handlers, no lock, no VRAM check | Same `gpu_lock` pattern as `review.assist` | +| M | api | `api/batches.py:140` | Staged uploaded `.pt` never deleted after a successful job | Stage under `data/projects//uploads/`, delete in `finally` | +| M | api | `api/projects.py:129` | `DELETE` returns 200 for a nonexistent project (same at `api/review.py:65`) | 404 when `delete()` returns False | +| M | api | `api/batches.py:261` | Dataset download link is a plain ``, so backend errors render as a raw JSON page | Disable when empty, or fetch via blob | +| M | frontend | `AnnotationCanvas.jsx:114` | Alt-click delete-vertex is a **no-op** — `updateShape` discards the passed geometry on commit and re-PATCHes the old one | Use the passed geometry on commit | +| M | frontend | `ReviewPage.jsx:71` | The 2 s job poll overwrites in-flight drag edits — shapes snap back mid-gesture | Skip the refetch while a gesture is active | +| M | frontend | `ReviewPage.jsx:93` | Rapid A/X approvals capture the same `frame` twice → one frame PATCHed twice, the next skipped unreviewed | Derive the frame inside the functional `setIndex` | +| M | frontend | `ReviewPage.jsx:129` | Failed geometry/class/delete requests are never rolled back — canvas and server diverge silently | Snapshot and restore in `catch` | +| M | frontend | `ReviewPage.jsx:198` | "Track 5 Frames" / `[T]` is dead code — it reads `geometry.coordinates`, which this app never produces | Use `geometry.points`, or delete (propagation is a stated non-goal) | +| M | frontend | `AutoAnnotateModal.jsx:294` | Custom-model chips list *project* classes, not the inspected model's | Render `customModelClasses` when `engine === 'custom'` | +| M | ops | `Dockerfile:1` | No root `.dockerignore` — 38 GB `data/`, 5.2 GB `.venv` and `.env` are all sent as build context | Add one | +| M | frontend | `AnnotationCanvas.jsx:163` | Escape clears the drag but leaves the shape visually moved and uncommitted | Restore `drag.start` before clearing | +| L | frontend | `ReviewPage.jsx:243` | Arrow-key nav during a drag silently drops the edit | Ignore nav keys mid-gesture, or key the canvas on `frame.id` | +| L | frontend | `ReviewPage.jsx:112` | SAM3 assist doesn't bump `annotation_count` | Add the `patchFrameLocally` call | +| L | frontend | `Filmstrip.jsx:7` | Every frame rendered, no virtualization, full reconcile every 2 s | Window it + `React.memo` | +| L | ops | `start.sh:7` | `rm -f docker-compose.override.yml` deletes a **tracked** file and silently drops your `./backend` bind mount | Untrack it; generate `docker-compose.gpu.yml` and pass via `-f` | +| L | ops | `README.md:18` | References a `.env.example` that doesn't exist; `HF_TOKEN` is required for gated SAM3 weights | Commit one | +| L | ops | `requirements.txt` | torch/torchvision/ultralytics/fastapi unpinned — CLAUDE.md §6's "cannot drift" claim is false | Pin or add a uv lockfile | +| L | api | `api.js:83` | `autolabelWithModel` is orphaned; its `iou_threshold` default (0.8) contradicts the endpoint's (0.0) | Delete both, or route the custom-model flow through it | + +Also: **`docker-compose.override.yml` is committed and pins `nvidia.com/gpu=all`** — Compose +auto-merges it, so a CPU-only host cannot start, and README tells users to run +`docker compose up` directly, bypassing `start.sh`. This violates CLAUDE.md §9 head-on. + +--- + +## 4. The dirty working tree + +| File | Verdict | Reasoning | +|---|---|---| +| `backend/jobs.py` | **Revert** | Source of C1 and C2. The parallel-jobs change is the single most damaging uncommitted edit. | +| `docker-compose.override.yml` | **Untrack** | Should never have been committed; `start.sh` regenerates it and destroys your bind mount each run | +| `backend/test.py`, `backend/test_preview.py` | **Delete** | Scratch scripts in the package dir; not tests, not imported | +| `algoritma-batch/batch_video_cropper.py.bak` | **Delete** | Backup file in git's way | +| `scratch/` | **Gitignore** | | +| `frontend/src/pages/BatchesPage.jsx`, `DataPrepPage.jsx`, `AutoAnnotateModal.jsx` | **Finish** | Reachable from the app and carrying real bugs (rows above). Not abandoned. | +| `scripts/transcode_archive.py`, `algoritma-batch/src/h264_converter.py` | **Finish** | Directly relevant to the H `library.py:92` finding — the right home for that work | +| `algoritma-batch/predict.py`, `test_batch_logic.py` | **Review** | Unreferenced by the app; decide whether `algoritma-batch/` is still in scope | + +--- + +## 5. Workflow gaps — where the design is wrong + +1. **The positional val split is the design's weakest point.** `_next_split` counting rows + means the split depends on *insertion order and history*. Any deletion, any re-merge, any + reordering shifts it. The invariant in CLAUDE.md §8 is stated but not enforced by the + mechanism. It should be content-derived: `hash(project_id + batch + stem) % 5 == 0 → val`. + Then it is stable by construction and no code path can violate it. + +2. **Master labels are regenerated from live annotations on every training start.** This is + the reason three separate HIGH findings exist. A merged dataset should be *immutable* — + merging means "snapshot these labels". Regeneration should be an explicit repair action, + not a side effect of pressing Train. + +3. **The base-vs-new comparison silently degrades instead of refusing.** Empty val → validate + on train images. Deleted batch → phantom images in the val dir. Class filter → remapped + ids. Every one of these produces a number that *looks* fine. For a system whose entire + purpose is "did retraining help?", the comparison should be fail-loud. + +4. **No visibility into a running job beyond a progress bar,** and cancellation doesn't + survive restart. For jobs that run tens of minutes this is the main source of "rough". + +5. **No resume.** A cancelled or crashed merge leaves the batch in a state that can never be + merged again (M, `dataset.py:402`). + +--- + +## 6. Recommended order of work + +1. **Revert the `jobs.py` parallelism experiment.** → verify: start two autolabel jobs on + different batches; expect the second to sit `queued` until the first finishes, and + `nvidia-smi` to show one SAM3 backbone resident, not two. +2. **Restore `gpu_lock` on autolabel + the two synchronous GPU routes.** → verify: start an + autolabel job, then hit Preview; expect "GPU is busy with a autolabel job", not an OOM. +3. **Stop `sync_labels` from touching merged labels** (filter to `approved`; move the + class-filter remap into `labels_selected/`). → verify: merge a batch, edit an annotation in + review, press Train; expect the merged `.txt` on disk to be unchanged. +4. **Make the val split content-derived** and backfill existing `dataset_items`. → verify: + delete a batch, re-import, re-merge; expect every frame to land in the same split as before. +5. **Clean up batch deletion** — unlink the dataset files. → verify: delete a merged batch; + expect `find dataset/images -name '__*'` to return nothing. +6. **Fail loudly on empty val** and fix `nc`. → verify: train a project with no val items; + expect a clear error, not a silent train-on-train run. +7. **Fix the two review-editor HIGHs** (sidebar delete, modifier keys) and the no-op + delete-vertex. → verify: keyboard-only pass over 20 frames; Ctrl+S must not approve. +8. **Untrack `docker-compose.override.yml`, add `.dockerignore` and `.env.example`, + pin requirements.** → verify: `docker compose build` on a CPU-only host succeeds. diff --git a/docs/design.md b/docs/design.md index 72948e1..9ce6ef0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -168,7 +168,11 @@ GET /api/batches/{id} # status + review progress (REQ- GET /api/batches/{id}/frames # frames + statuses POST /api/batches/{id}/autolabel # {threshold} → job (REQ-030,032,034) DELETE /api/batches/{id}/classes/{class_id}/annotations # clear all shapes of class in batch (REQ-046) -POST /api/batches/{id}/approve # → merge job (REQ-050) +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 +GET /api/batches/{ids}/triage/suggest +POST /api/batches/{ids}/triage/simulate GET /api/frames/{id}/image?w=… # frame image / thumbnail GET /api/frames/{id}/annotations @@ -209,7 +213,10 @@ disk, and rewrites `data.yaml`. The renumbering is the whole job: a YOLO label i index, so a class list and a set of label files that disagree do not fail loudly — they train a model on the wrong names. Refused for a project's last class. -**merge (REQ-050…053).** For every `approved` frame not yet in `dataset_items`: assign a +**merge (REQ-050…053, REQ-131…132).** One job covers the whole selected set of batches, and +`datasets.rules_json` holds the triage rules frozen at the moment the merge was confirmed — +the resolver is built from that snapshot, never from the project's live rules. For every +`approved` frame not yet in `dataset_items`: assign a split (continuing the round-robin), copy the JPEG to `dataset/images//`, write the YOLO `.txt` from the frame's annotations, record the row. Finally rewrite `data.yaml`. Frames with no annotations produce an empty `.txt` (REQ-033). diff --git a/docs/proposal-dataprep-triage.md b/docs/proposal-dataprep-triage.md new file mode 100644 index 0000000..4aa2ca8 --- /dev/null +++ b/docs/proposal-dataprep-triage.md @@ -0,0 +1,146 @@ +# Proposal — Data Prep: outlier filter + augmentation + +Status: **scope approved 2026-08-13**. Supersedes the rules-engine proposal agreed on +2026-08-07, which was never implemented into `./requirements.md`. If accepted as written, +REQ-100…REQ-105 and REQ-110…REQ-113 move into `./requirements.md`. + +What changed from the 2026-08-07 draft, and why: + +- The `reclass` action, the ordered rule list, and the preset suggestions are **dropped**. + In practice the only decision being made on this page is "is this box junk?" — a + reclass target and first-match-wins ordering were machinery for a decision nobody was + making. Both `triage_rules` and `annotation_overrides` were empty when this was decided, + so nothing was lost. +- Data Prep gains **augmentation settings**, which the pipeline previously left entirely to + Ultralytics' defaults. + +--- + +## Part 1 — Outlier filter + +A shape's verdict is still resolved, never stored destructively: + +``` +manual override (if any) > outlier filter > default: keep +``` + +Verdict is one of `keep` · `ignore`. `annotations.class_id` is never rewritten, so a +filter can be re-cut at any time. + +The filter is expressed over the same three signals the resolver already computes — +`score`, `area_pct`, `aspect` — as a keep-range per signal. Anything outside an enabled +range is `ignore`. + +### Requirements + +- **REQ-100** — A project has one **outlier filter**: an optional keep-range + (`[min, max]`, either edge blank) over each of `score`, `area_pct` and `aspect`. A shape + falling outside any enabled range resolves to `ignore`. +- **REQ-101** — The filter is **stage-scoped** to `dataprep`. Editing it never alters what + the batches or review stages display; it changes only what the stages after it consume. +- **REQ-102** — The filter is applied **at merge time**, against the live annotations. It is + never baked into the master dataset, and `annotations.class_id` is never rewritten. +- **REQ-103** — The user can **override any individual shape** by hand (`keep`/`ignore`). + A manual override outranks the filter and survives any later filter edit. +- **REQ-104** — A shape resolving to `ignore` drops that box. A frame that loses **every** + box is held back from the dataset — an image is never trained on with a known object + left unlabeled. +- **REQ-105** — The Data Prep page shows, for one batch: a scatter of score × area with + rectangular selection, and a grid of cropped shape thumbnails. Selecting in either + assigns a manual verdict in bulk. Filter edits update the kept/ignored counts live, + before anything is saved. + +Storage reuses the existing `triage_rules` table: the UI emits the filter as +`ignore` rules and reads them back. No schema change, no second code path in the resolver. + +## Part 2 — Augmentation + +Ultralytics augments during training whether or not we ask it to. `backend/training.py` +passes no augmentation arguments, so every run so far has used library defaults +(`mosaic=1.0`, `fliplr=0.5`, HSV jitter, `scale=0.5`) — invisibly, and unrecorded. + +- **REQ-110** — A project stores **augmentation settings**: `fliplr`, `flipud`, `degrees`, + `translate`, `scale`, `hsv_h`, `hsv_s`, `hsv_v`, `mosaic`. They are passed to + `model.train()` on every run. +- **REQ-111** — The UI offers presets — **Off**, **Light**, **Medium**, **Aggressive** — + and lets any single value be adjusted afterwards. A project with no stored settings uses + **Medium**, which reproduces Ultralytics' defaults, so behaviour does not change until + the user changes it. +- **REQ-112** — Augmentation applies to **training images only**. Validation is never + augmented, so a base-vs-new mAP comparison stays a like-for-like measurement + (this is Ultralytics' own behaviour; the requirement is that we must not defeat it). +- **REQ-113** — Each stored model version records the augmentation settings it trained + under, so two runs can be told apart. + +### Schema + +```sql +ALTER TABLE projects ADD COLUMN augment TEXT; -- JSON, null = Medium preset +ALTER TABLE model_versions ADD COLUMN augment TEXT; -- REQ-113: what this run used +``` + +### API + +``` +GET /api/projects/{id}/augment -> {settings, preset} +PUT /api/projects/{id}/augment <- {settings} +``` + +## Part 3 — Base datasets + +Externally-labelled images the user already trusts, registered against a project and +offered as a checkbox beside the project's own datasets. Not a batch, and never becomes +one: no frames, no review, no triage. + +- **REQ-120** — A project may register any number of **base datasets**, each a folder of + images with YOLO label files, imported from an unpacked export. Class ids are kept as + they are; the importer only drops classes the user did not ask to keep. +- **REQ-121** — A base dataset is **opt-in per training run**. Selecting no dataset at all + never silently pulls one in. +- **REQ-122** — A base dataset contributes **training images only**. It can never supply + validation images, because REQ-063's base-vs-new comparison is only meaningful measured + on this project's own stable val split (REQ-052). A run with base datasets but no project + dataset is refused — there would be nothing to validate on. +- **REQ-123** — Labels are normalised to the project's `label_type` on import. A + segmentation polygon imported into a `bbox` project is collapsed to its bounding box, + because a detect model reads the first four numbers of a polygon line as a box and would + otherwise train on nonsense. + +### Schema + +```sql +CREATE TABLE base_datasets ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + image_count INTEGER NOT NULL DEFAULT 0, + box_count INTEGER NOT NULL DEFAULT 0, + classes TEXT NOT NULL DEFAULT '[]', + created_at REAL NOT NULL +); +``` + +They stay out of `dataset_items` deliberately: that table keys on `frame_id`, and inventing +frame rows for images this app never extracted would put fake batches in front of the user +forever. The rows go straight to `dataset._build_selected_tree`, which only ever needed an +image path and a label path. + +### API + +``` +GET /api/projects/{id}/base-datasets -> [base_dataset] +DELETE /api/base-datasets/{id} +``` + +Import is an offline call to `base_dataset.import_tree` — a 150 MB export is not an HTTP +request worth holding open. + +--- + +## Open risk + +REQ-104 is unchanged from the earlier draft and carries the same interaction: excluding +whole frames means the val set is a function of the filter. The earlier REQ-107 +(`rule_version` stamped on each run) already exists in the schema and keeps that honest — +it is retained even though the rules engine around it is gone. diff --git a/docs/requirements.md b/docs/requirements.md index 303e2b3..a403699 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -130,6 +130,95 @@ changes. - **REQ-054** — The master dataset can be downloaded as a `.zip` (e.g. to import into Roboflow or train on another machine). +## F2. Data Prep as the merge gate + +- **REQ-130** — The Batches page supports multi-select. "Prepare & Merge Selected" opens + Data Prep scoped to exactly those batches (`#/projects/{id}/data-prep?batches=1,2,3`). + No dataset exists at this point. +- **REQ-131** — Data Prep is the merge gate. Filters and augmentation are tuned against the + selected batches' shapes; "Confirm merge" names or picks the target dataset and queues + **one** merge job for the whole selection. There is no path from Batches or Review + straight to a dataset. +- **REQ-132** — The rules in force when a merge is confirmed are **snapshotted onto the + dataset** (`datasets.rules_json`). The merge runs under the snapshot, and later edits to + the project's rules never rewrite an existing dataset. Only an explicit Resync adopts + today's rules — and it re-stamps the snapshot with them. + +## F3. Counting correctness + +- **REQ-140** — Ghost rejection (`entry_travel_min`) and spatial dedup + (`dedup_radius`) are separate parameters. They pull in opposite directions, so one + number cannot serve both. +- **REQ-141** — A track that vanishes parks its history; a new track id born within + `handoff_radius` of its velocity-projected position inherits it. This is what keeps an + ID switch at the counting line from either losing a count (the sack's "was above" + evidence dies with the old id) or duplicating one (the new id has no "already counted" + verdict). +- **REQ-142** — A counted direction is the track's *last* verdict, not a permanent one. A + sack genuinely taken back out and reloaded counts again; unloading requires + `unload_confirm_frames` sustained frames above the band, so repositioning by hand cannot + cancel a real count. +- **REQ-143** — Per-track state is evicted once a track has been gone for `track_ttl`, so a + long shift does not grow state without bound. +- **REQ-144** — Every finished track is written to a per-session JSONL with its trajectory + and the reason it did or did not count, so a miss can be attributed to the model, the + tracker, or the counter. + +## F4. Counting accuracy bench + +- **REQ-150** — A page lists every archive video as a row: date, batch, length, and the + counter's `counted in` / `counted out` / `net` for it. Videos never counted are still + rows — the table is the work list. +- **REQ-151** — Each row has an editable **ground truth** (what a human counted). The + scored figure is the signed delta `counted_in - ground_truth`, so over- and under-counting + stay distinguishable. Accuracy is `1 - |delta| / ground_truth`. +- **REQ-152** — Accuracy totals are computed **only** over rows where a ground truth is + filled in. An uncounted or unscored video never enters the denominator. +- **REQ-153** — Counting runs as a queued background job over a selection of videos, or all + of them, holding the GPU lock. It renders nothing — no annotated frame, no JPEG encode — + which is what makes counting a 30-minute video practical. A run records the parameters and + model it used. + +## F5. Real recording times and working days + +- **REQ-160** — Each recording's start time is read from the timestamp the camera burns into + the top-right of every frame. Folder names and file mtimes are both unreliable: mtimes are + file *copy* times, not recording times. +- **REQ-161** — A **working day runs 06:00 to 06:00**. A recording that started before 06:00 + belongs to the previous working day. A recording spanning the boundary is assigned by its + start. +- **REQ-162** — Recordings are numbered 1..N within a working day, ordered by real start + time. The original file path stays the file's identity and is shown beside the number, so + results already recorded against it survive. +- **REQ-164** — The counting table is grouped into collapsible **cycles**, newest first, with + the recordings inside each one in the order they were made. A cycle's header carries its + video count, its AI/ground-truth totals, signed delta and accuracy, and how many of its rows + have an unverified start time. Only the newest cycle is expanded by default. +- **REQ-165** — The Video Archive page browses the archive **by cycle**, not by folder. The + left-hand list holds cycles newest first; the table shows the recordings of the selected + cycle in the order they were made, with their cycle batch number and the time read from the + overlay. A recording pulled in from another folder is marked with the folder it sits in. +- **REQ-166** — Each recording is checked for a truck with the project's newest model, + sampling a handful of frames rather than the whole file. The recording trigger is truck + arrival and departure, so one file is one batch — this check is what proves that assumption + per file, and flags any recording where it does not hold. +- **REQ-167** — The production counter's counting day turns over at the same hour as the + archive's cycles, 06:00, so `batch_number` on the Jetson and the batch order in Video + Archive mean the same thing. It stays overridable per deployment via `DAILY_CUTOFF_TIME`. +- **REQ-168** — The recorder writes each archive file at the frame rate the stream actually + delivers, and paces writes against the wall clock, so a file's duration equals the real + duration of the recording however unevenly the capture loop runs. +- **REQ-170** — Recording happens once, on the streaming server, and is stored as a rolling + buffer. The truck detector does not encode video: when a session ends it downloads that time + range as a **copy**, so the archive keeps the camera's own codec, resolution and frame rate. + Each clip carries a sidecar with the server's start time, which the app trusts over reading + the burned-in overlay. Archive folder names remain calendar dates; the app derives cycles + from the real start time, so the folder name is never read as a date. +- **REQ-163** — Nothing in the archive is moved, renamed or written to; it is mounted + read-only. The grouping lives in an index beside it. Timestamps that could not be read, or + were read with low confidence, are flagged and can be hand-entered; a hand-entered time + outranks any reading and is never overwritten by a rescan. + ## G. Training & evaluation - **REQ-060** — The user starts training from the project page. Training **fine-tunes from diff --git a/docs/tasks.md b/docs/tasks.md index abbee9c..0e801f9 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -821,11 +821,225 @@ Automatically skip empty initial frames when opening the Review Editor on a batc Verified: Frontend built and re-deployed cleanly. Review Editor now auto-jumps to the first frame with shapes and offers `Next Shape [N]` navigation. +## 23. Fix multi-annotation class mapping & bounding box generation + parameter sliders — `[DONE]` + +Fix multi-annotation class mapping and bounding box generation across YOLO and SAM3 engines, and equip the Base Model Auto-annotate modal with parameter sliders (Confidence, NMS IoU, Min Box Size) and target class controls. + +**Files.** `backend/autolabel.py`, `frontend/src/pages/LibraryPage.jsx`. + +**Steps.** + +1. `backend/autolabel.py` — expand YOLO prediction class resolution with multi-level fallback matching (`name_to_class_id`, `class_id` index match, project class fallback) and safe box coordinate scaling to ensure bounding boxes are generated and preserved for all project classes. +2. `backend/autolabel.py` — guard SAM3 prompt mapping against null/empty prompt attributes and ensure zero-division safety on frame size bounds. +3. `frontend/src/pages/LibraryPage.jsx` — update `openBaseModelAutolabelModal` and `baseModelModalState` modal to include sliders for Confidence Threshold, NMS IoU Threshold, and Min Box Size (Fraction), plus `Select All` / `Clear All` target class controls. + +**Verify.** + +1. Compile `backend/autolabel.py` with `uv run python -m py_compile backend/autolabel.py`. +2. Build frontend with `npm --prefix frontend run build`. + +Verified: `backend/autolabel.py` compiled cleanly and frontend built with zero errors. Multi-annotation bounding boxes generate properly for all classes and base model auto-annotation modal displays all parameter sliders. + + --- +## Task 15 — Data Prep: outlier filter + augmentation `[TODO]` + +Serves REQ-100…105 and REQ-110…113 in `./proposal-dataprep-triage.md` (scope approved +2026-08-13). Written but **not deployed** — an auto-annotation run was in flight, and a +rebuild would have failed out its queued jobs (see the note below). + +1. Simplify Data Prep to an outlier filter → verify: three keep-ranges over score / + area / aspect; counts move live while dragging. **Done in code.** The filter needs no + new backend — it is emitted as the `ignore` rules the resolver already evaluates + (`OutlierFilter.toRules`/`fromRules`, round-trip tested). +2. Drop the rules engine, presets and `reclass` from the UI → verify: `TriageRules.jsx` + and `TriagePresets.jsx` deleted, frontend builds. **Done in code.** Both + `triage_rules` and `annotation_overrides` were empty when this was decided, so no + stored data was discarded. +3. Augmentation settings per project → verify: `GET/PUT /api/projects/{id}/augment` + round-trips; presets Off/Light/Medium/Aggressive; Medium equals Ultralytics' defaults + so an untouched project trains identically. **Done in code**, unit-checked offline. +4. Pass augmentation to `model.train()` and stamp it on the model version (REQ-113) → + verify: **not yet run** — needs a real training run after deploy. + +Remaining to close this task: deploy (`docker compose build backend frontend && up -d`) +once no job is running, then confirm the migration adds `projects.augment` and +`model_versions.augment`, and that a training run logs its augmentation preset. + +## Task — Data Prep becomes the merge gate (REQ-130…132) + +1. `triage` accepts a batch-id list; `/api/batches/{ids}/triage/*` takes comma-separated ids + → verify: **[DONE]** simulate over batches 66,67,68 returns 9,178 shapes, exactly the sum + of 1,097 + 6,431 + 1,650 measured one at a time. +2. `datasets.rules_json` snapshots the rules a dataset was cut under; the merge resolves from + the snapshot, and a migration backfills existing datasets → verify: **[DONE]** merged a + dataset, then replaced the project's rules with an ignore-everything rule; the dataset's + label files hashed identically before and after, its `rule_version` did not move, and a + second merge into it still logged the original 3 rules. +3. `dataset.approve` takes a list and queues one merge job for the whole selection → + verify: **[DONE]** batches 494 + 534 produced one job, one dataset, 16 `dataset_items` + = 6 + 10, the sum of their approved frames. +4. Batches multi-select → Data Prep (`?batches=…`) → Confirm merge; merge removed from + Review and from the batch list → verify: **[TODO]** run the click-path in the browser. +5. Docs updated → verify: **[DONE]** REQ-130…132 in `./requirements.md`, merge section and + route table in `./design.md`. + +## Task — Counting algorithm fixes (REQ-140…144) + +Five defects were reproduced against the counter before changing it, and each fix is +verified by the failure case that motivated it. + +1. Split `entry_travel_min` from `dedup_radius` (REQ-140) → verify: **[DONE]** both exposed + separately through the API and the Live Count page. +2. Track hand-off across ID switches (REQ-141) → verify: **[DONE]** id seen above the line, + vanishing, reappearing below as a new id counts 1 (was 0). Same sack switching id *after* + being counted still counts 1, not 2. A track that blinks for one frame no longer leaks its + state to an unrelated newborn. +3. Directional verdict + sustained unload (REQ-142) → verify: **[DONE]** a brief 2-frame lift + leaves net 1; a genuine unload-and-reload gives L2/U1, net 1 (was net 0). +4. Evict stale track state (REQ-143) → verify: **[DONE]** 5,000 tracks then idle retains 0 + entries; previously 30,000 and unbounded. +5. Per-track trace JSONL + perspective area gate (REQ-144) → verify: **[DONE]** a real run on + `2026-08-14/batch011.mp4` at 124 fps wrote one record per finished track with its verdict. +6. Camera-tuned defaults: line 266, x 469…910, margin 5, entry travel 60, hand-off 100, + unload confirm 3, min area 1.0, conf 0.35 → verify: **[DONE]** the ten-case failure suite + passes at these defaults, including a burst-frame case that exposed unbounded velocity in + the hand-off projection (now clamped to 1500 px/s and 0.5 s of extrapolation). + +**Open — needs the hand-counted clip.** On real footage 84% of tracks inherit via hand-off at +`handoff_radius=100`, because these frames are dense enough that a newborn track is nearly +always near one that just vanished. 100 is the value tuned against the camera and is now the +default, but the right value is a measurement, not a guess: run a clip with a known total and +read the verdict histogram +in the trace file. `never_reached_below` dominating means the tracker is fragmenting (not the +counter); `born_below_line` means counts are being lost to ID switches the hand-off radius is +too tight to recover. + +## Task — Counting accuracy bench (REQ-150…153) + +1. `count_runs` table + `count` job type → verify: **[DONE]** migration rebuilt the `jobs` + table to accept the new type (SQLite cannot alter a CHECK constraint); all 928 existing + job rows preserved. +2. Headless counter reusing the live pipeline → verify: **[DONE]** 21,544 frames of + `2026-08-14/batch011.mp4` in 147 s = **146 fps**, against 124 fps through the live view. + Rendering was the difference. +3. Scored table with editable ground truth → verify: **[DONE]** setting a ground truth, + clearing it, and the totals excluding unscored rows all round-trip through the API. +4. Background job over a selection or all videos → verify: **[DONE]** queued one video, the + job reported `7150/21544 frames` mid-run and stored in 169 / out 8 / net 161 on finish. +5. Page + route + sidebar entry → verify: **[DONE]** frontend builds; listing serves 222 rows + in 0.18 s once ffprobe is warm (7.7 s cold). + +**Sizing.** The archive is 129 hours across 222 videos. At the measured 146 fps a full +recount is roughly **22 GPU-hours**, so "Count all" is an overnight job, not an interactive +one. It is resumable — already-counted videos are skipped unless `recount` is ticked — and +cancelling mid-video discards that video's partial count rather than storing it as a result. + +## Task — Real recording times, 06:00 working days (REQ-160…163) + +1. Read the burned-in overlay without adding an OCR dependency → verify: **[DONE]** 12 glyph + templates matched per frame; decodes frames it was never trained on exactly, at + confidence 0.75–0.87. +2. Reject bad reads rather than trust them → verify: **[DONE]** a misread that produced the + year 7026 is rejected by the year-range check; low confidence or fewer than two agreeing + frames flags the row for review instead of silently regrouping it. +3. Working-day grouping and renumbering → verify: **[DONE]** scanned all 224 recordings; + **29 land on a different working day** than their folder. Working day 2026-08-13 now starts + at 08:27 because the 00:07 and 00:22 recordings moved to 08-12. +4. Nothing written to the archive → verify: **[DONE]** the mount is `:ro`; the index lives in + `video_clock` and the file path stays the row's identity, so existing counts survived. + +**Timezone.** Start times are stored as wall-clock **text**, never an epoch. Storing an epoch +made the backend (UTC) and the browser (UTC+7) disagree by seven hours, which moved recordings +across the 06:00 boundary into the wrong working day — `2026-08-07/batch4` read 20:12:42 and +displayed as 03:12:42 the next day. Caught by cross-checking one file against the video. + +5. Group the table into collapsible cycles (REQ-164) → verify: **[DONE]** 10 cycles render + newest first; `Siklus 13 Agt 2026` holds 28 recordings running 08:27 → 01:19 the next + morning, which is the midnight crossing the grouping exists to make readable. A cycle + header selects all of its rows for a recount in one click. + +6. Video Archive browses by cycle (REQ-165) → verify: **[DONE]** `Siklus 13 Agt 2026` lists + 28 recordings running 08:27 through midnight to 01:19, with `batch001…003` from the + *2026-08-14* folder correctly appearing as #26–28 of the 13 Agt cycle and flagged with + their folder. Listing the cycles costs 0.13 s because it counts filenames instead of + running ffprobe on the whole archive. + +7. Truck check with v4 (REQ-166) → verify: **[DONE]** scanned 226 recordings, 12 frames each, + in ~5 minutes. **225 contain a truck** (136 in every sampled frame, 89 in some), so the + "one file is one batch" premise holds. One recording — `2026-08-07/batch027.mp4` — shows no + truck in any sampled frame and is flagged in the table. Three files will not open at all. + A first attempt died after 8 recordings with `database is locked`: the writer opened a + second connection inside an open write transaction. Now a single UPSERT on one cursor. + +8. Align the production counter to the 06:00 cycle (REQ-167) → verify: **[DONE]** + `predict.py`'s `DAILY_CUTOFF_TIME` default moved from `20:00` to `06:00`; at `06:00` its + `get_counting_date()` agrees with the app's `working_day()` on 8 of 8 boundary cases, at + `20:00` it disagreed on 3. `algoritma-batch/migrate_cutoff_0600.py` re-files existing rows: + tested against a replica of the Jetson schema, 9 batches split across two counting dates + by the old cutoff collapse into one day numbered #1–#8, `daily_summaries` is rebuilt, the + unique key holds, a timestamped backup is written, a second run is a no-op, and a row with + an unparseable `start_time` is left alone rather than failing the migration. + +**The recorder is `algoritma-batch/batch_video_cropper.py`, in this repo**, running 24/7 on +this machine (pid seen at 187 min CPU). It reads `rtsp://192.168.192.96:8554/cam`, uses +`BatchLifecycleManager` + `v3-best.pt` to detect a truck arriving and leaving, and writes +`~/reTraining/data/archive/{date}/batch{NNN}.mp4` — one file per truck session, which is what +makes "one file is one batch" true. + +9. Correct the recorder's frame rate (REQ-168) → verify: **[DONE]** `VIDEO_FPS = 10.0` was + hard-coded while the camera delivers 25, so every archived file claimed a duration 2.49x + too long (batch003: 22,968 frames, overlay says 15.4 minutes, file says 38.3). The rate now + comes from the stream and writes are paced against the wall clock. Recorded 30 s from the + live production stream with the loop deliberately starved to ~3.7 fps: the file came out + **31.56 s against 31.9 s real, 1.1% off**; the old code would have produced 11.9 s. + The camera is **25 fps, not 60** — RTSP metadata, the HLS playlist (`FRAME-RATE=25.000`) + and the measured delivery rate (24.8 fps) all agree. + +**Decided, not a defect:** the recorder keeps `DAILY_CUTOFF_TIME = "00:00"`, so folder names +stay calendar dates. The user's call — what matters is that the app is right, and it is: cycles +are derived from each recording's real start time, so a file sitting in the 15 Aug folder but +recorded at 01:00 appears under the 14 Aug cycle. Nothing downstream reads the folder name as a +date. Note if this is ever revisited: this script's `get_counting_date()` returns *tomorrow* +after the cutoff, unlike `predict.py`'s, so the function would need aligning, not just the +constant. + +10. Record once on the Jetson, cut sessions on the ASUS (REQ-170) → verify: **[DONE]** MediaMTX + on the Jetson now records 24/7 (`record: yes`, `playback: yes`, 15-minute segments, 24-hour + buffer — 18 GB of its 36 GB free; 48 h would have needed 37 GB and did not fit). The + recorder no longer re-encodes: on session end it downloads that exact time range as a copy. + Fetch verified against the live stream — asked for 16:10:53 +45 s, the clip's burned-in + overlay reads 16:10:52 → 16:11:37, exactly 45 s, 1125 frames at 25 fps, 1 s off from the + camera's own clock. A simulated 40 s session produced a 46 s clip whose sidecar + (`16:14:45`, from the server) matches the overlay to the second. Files are now **HEVC + 1920x1080 copies, ~4.7x smaller** than the old 1280x720 mpeg4 re-encodes. +11. Video Archive stays current without a scan (REQ-170) → verify: **[DONE]** the recorder + writes a `.json` sidecar beside each clip and the app reads it live, so a new session + appears in the right cycle with a server-accurate time and no OCR at all. + +**The camera cannot do 60 fps.** `FPSMax=25` on every stream format of the DH-IPC-HFW1230, and +it already runs at that (1080p, H.265, 2048 kbps CBR). The "not smooth" impression came from +the broken timebase, not the frame rate. + +**Mistake to record:** while testing the fetch, a test clip was copied over +`data/archive/2026-08-14/batch007.mp4`, destroying a real 09:20:08 truck recording. It had never +been used for frame extraction or counting, so no dataset or annotation was affected, and the +test clip and its index row were removed. The file itself is gone from this machine; the rsync +history suggests a copy may exist on 192.168.192.105/.106. + +**Worth checking on the Jetson:** `BATCH_MERGE_THRESHOLD_SECONDS` defaults to 300, so a truck +arriving within five minutes of the last batch *continues* it instead of starting a new one. +Video Archive counts one file as one batch, so if trucks really do turn around that fast the +two will disagree. + +**Open — 19 recordings need a human.** 8 are unreadable (3 of them will not open at all: +`2026-08-06/batch4`, `2026-08-06/batch9`, `2026-08-14/batch016` — likely truncated) and 11 were +read with low confidence. Both are flagged amber in the table and accept a hand-typed time. + ## Known open points - *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4abc3cd..b57675e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,11 +1,17 @@ -import { Component, useEffect, useState } from 'react' +import { Component, useCallback, useEffect, useState } from 'react' + import { api } from './api' import Sidebar from './components/Sidebar' import ProjectsPage from './pages/ProjectsPage' import LibraryPage from './pages/LibraryPage' +import BatchesPage from './pages/BatchesPage' +import CountingBenchPage from './pages/CountingBenchPage' +import DataPrepPage from './pages/DataPrepPage' +import DatasetsPage from './pages/DatasetsPage' import TrimPage from './pages/TrimPage' import ReviewPage from './pages/ReviewPage' import ModelsPage from './pages/ModelsPage' +import LiveCountPage from './pages/LiveCountPage' import Sam3PlaygroundPage from './pages/Sam3PlaygroundPage' import './roboflow.css' @@ -28,7 +34,17 @@ function parseRoute(hash) { if (parts[2] === 'trim' && parts[3]) { return { name: 'trim', projectId, rel: decodeURIComponent(parts[3]) } } + if (parts[2] === 'batches') return { name: 'batches', projectId } + if (parts[2] === 'data-prep') { + // `?batches=1,2,3` is the selection being prepared for merge (REQ-130). + const batchIds = (query.get('batches') || '') + .split(',').map(Number).filter((id) => Number.isInteger(id) && id > 0) + return { name: 'data-prep', projectId, batchIds } + } + if (parts[2] === 'datasets') return { name: 'datasets', projectId } if (parts[2] === 'models') return { name: 'models', projectId } + if (parts[2] === 'live-count') return { name: 'live-count', projectId } + if (parts[2] === 'counting-bench') return { name: 'counting-bench', projectId } if (parts[2] === 'review') { const batchId = query.get('batch') ? Number(query.get('batch')) : null return { name: 'review', projectId, batchId } @@ -39,6 +55,8 @@ function parseRoute(hash) { return { name: 'projects' } } + + export function navigate(path) { window.location.hash = path } @@ -97,15 +115,22 @@ export default function App() { const [theme, toggleTheme] = useTheme() const [currentProject, setCurrentProject] = useState(null) + const handleProject = useCallback((p) => { + if (!p) return + setCurrentProject((prev) => (prev?.id === p.id && prev?.name === p.name ? prev : p)) + }, []) + useEffect(() => { if (route.projectId) { - api.getProject(route.projectId).then(setCurrentProject).catch(() => {}) - } else if (!currentProject) { - api.listProjects().then((list) => { - if (list.length > 0) setCurrentProject(list[0]) + api.getProject(route.projectId).then(handleProject).catch(() => {}) + } else { + api.listProjects().then((payload) => { + if (payload?.projects && payload.projects.length > 0) { + handleProject(payload.projects[0]) + } }).catch(() => {}) } - }, [route.projectId, route.name, currentProject]) + }, [route.projectId, route.name, handleProject]) return ( @@ -123,21 +148,48 @@ export default function App() { {route.name === 'library' && ( setCurrentProject(p)} + onProject={handleProject} /> )} + {route.name === 'batches' && ( + + )} + {route.name === 'data-prep' && ( + + )} + + {route.name === 'datasets' && } {route.name === 'trim' && } {route.name === 'review' && ( setCurrentProject(p)} + onProject={handleProject} /> )} {route.name === 'models' && ( setCurrentProject(p)} + onProject={handleProject} + /> + )} + {route.name === 'counting-bench' && ( + + )} + {route.name === 'live-count' && ( + )} {route.name === 'sam3-playground' && } @@ -147,3 +199,4 @@ export default function App() { ) } + diff --git a/frontend/src/api.js b/frontend/src/api.js index 85b82a4..28f3600 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -14,15 +14,29 @@ async function request(path, { method = 'GET', body, form } = {}) { const response = await fetch(`/api${path}`, options) const text = await response.text() - const payload = text ? JSON.parse(text) : null + let payload = null + if (text) { + try { + payload = JSON.parse(text) + } catch { + payload = null + } + } if (!response.ok) { const detail = payload?.detail - throw new Error(typeof detail === 'string' ? detail : `${response.status} ${response.statusText}`) + throw new Error( + typeof detail === 'string' + ? detail + : text && text.length < 120 + ? text + : `${response.status} ${response.statusText}` + ) } return payload } + export const api = { health: () => request('/health'), @@ -57,6 +71,7 @@ export const api = { deleteBatch: (id) => request(`/batches/${id}`, { method: 'DELETE' }), listFrames: (id) => request(`/batches/${id}/frames`), frameUrl: (id, width) => `/api/frames/${id}/image${width ? `?w=${width}` : ''}`, + preview: (batchId, body) => request(`/batches/${batchId}/preview`, { method: 'POST', body }), startAutolabel: (batchId, body) => request(`/batches/${batchId}/autolabel`, { method: 'POST', body: body ?? {} }), @@ -94,14 +109,115 @@ export const api = { request(`/frames/${frameId}/annotations`, { method: 'POST', body }), patchAnnotation: (id, body) => request(`/annotations/${id}`, { method: 'PATCH', body }), deleteAnnotation: (id) => request(`/annotations/${id}`, { method: 'DELETE' }), + bulkDeleteAnnotations: (annotationIds) => + request('/annotations/bulk-delete', { method: 'POST', body: { annotation_ids: annotationIds } }), + bulkReclassAnnotations: (annotationIds, classId) => + request('/annotations/bulk-reclass', { + method: 'POST', + body: { annotation_ids: annotationIds, class_id: classId }, + }), assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }), setFrameStatus: (frameId, status) => request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }), approveAllBatchFrames: (batchId) => request(`/batches/${batchId}/approve-all`, { method: 'POST' }), - approveBatch: (batchId) => request(`/batches/${batchId}/approve`, { method: 'POST' }), + // `batchIds` is one id or an array — one merge, one dataset (REQ-131). + approveBatch: (batchIds, { datasetId = null, datasetName = '' } = {}) => + request(`/batches/${[].concat(batchIds).join(',')}/approve`, { + method: 'POST', + body: { dataset_id: datasetId, dataset_name: datasetName }, + }), datasetSummary: (projectId) => request(`/projects/${projectId}/dataset`), - datasetDownloadUrl: (projectId) => `/api/projects/${projectId}/dataset/download`, + + // Named datasets (REQ-110…113) + listDatasets: (projectId) => request(`/projects/${projectId}/datasets`), + createDataset: (projectId, body) => + request(`/projects/${projectId}/datasets`, { method: 'POST', body }), + renameDataset: (id, body) => request(`/datasets/${id}`, { method: 'PATCH', body }), + deleteDataset: (id) => request(`/datasets/${id}`, { method: 'DELETE' }), + combinePreview: (projectId, datasetIds) => + request(`/projects/${projectId}/datasets/combine-preview`, { + method: 'POST', + body: { dataset_ids: datasetIds }, + }), + resyncDataset: (id) => request(`/datasets/${id}/resync`, { method: 'POST' }), + datasetZipUrl: (id) => `/api/datasets/${id}/download`, + + // Live counting test bench + liveCountModels: (projectId) => request(`/projects/${projectId}/live-count/models`), + liveCountStart: (projectId, body) => + request(`/projects/${projectId}/live-count/start`, { method: 'POST', body }), + liveCountStop: () => request('/live-count/stop', { method: 'POST' }), + liveCountMoveLine: (body) => request('/live-count/line', { method: 'PATCH', body }), + liveCountStatus: () => request('/live-count/status'), + // `key` busts the browser cache so a restarted session gets a fresh connection. + liveCountStreamUrl: (key = 0) => `/api/live-count/stream?k=${key}`, + + // Counting bench: scored table of archive videos (REQ-150…153) + countingBench: (projectId, date = '') => + request(`/projects/${projectId}/counting-bench${date ? `?date=${date}` : ''}`), + setGroundTruth: (projectId, videoRel, groundTruth) => + request(`/projects/${projectId}/counting-bench/ground-truth`, { + method: 'PATCH', body: { video_rel: videoRel, ground_truth: groundTruth }, + }), + runCountingBench: (projectId, body) => + request(`/projects/${projectId}/counting-bench/run`, { method: 'POST', body }), + archiveCycles: (projectId) => request(`/projects/${projectId}/archive/cycles`), + truckScan: (projectId, body = {}) => + request(`/projects/${projectId}/archive/truck-scan`, { method: 'POST', body }), + archiveCycleVideos: (projectId, cycle) => + request(`/projects/${projectId}/archive/cycles/${cycle}`), + scanClock: (projectId, rescan = false) => + request(`/projects/${projectId}/counting-bench/scan-clock`, { method: 'POST', body: { rescan } }), + setClock: (projectId, videoRel, startedAt) => + request(`/projects/${projectId}/counting-bench/clock`, { + method: 'PATCH', body: { video_rel: videoRel, started_at: startedAt }, + }), + + // Base datasets (REQ-130…123) + listBaseDatasets: (projectId) => request(`/projects/${projectId}/base-datasets`), + deleteBaseDataset: (baseId) => request(`/base-datasets/${baseId}`, { method: 'DELETE' }), + + // Augmentation (REQ-110…113) + getAugment: (projectId) => request(`/projects/${projectId}/augment`), + saveAugment: (projectId, settings) => + request(`/projects/${projectId}/augment`, { method: 'PUT', body: { settings } }), + + // Triage (REQ-100…105) + triageRules: (projectId) => request(`/projects/${projectId}/triage/rules`), + saveTriageRules: (projectId, rules) => + request(`/projects/${projectId}/triage/rules`, { method: 'PUT', body: { rules } }), + triageSummary: (batchIds) => request(`/batches/${[].concat(batchIds).join(',')}/triage/summary`), + triageShapes: (batchIds, { sort = 'score', offset = 0, limit = 120 } = {}) => + request(`/batches/${[].concat(batchIds).join(',')}/triage/shapes` + + `?sort=${sort}&offset=${offset}&limit=${limit}`), + triagePreview: (projectId) => request(`/projects/${projectId}/triage/preview`), + triageSuggest: (batchIds) => request(`/batches/${[].concat(batchIds).join(',')}/triage/suggest`), + triageSimulate: (batchIds, rules) => + request(`/batches/${[].concat(batchIds).join(',')}/triage/simulate`, + { method: 'POST', body: { rules } }), + setTriageOverrides: (annotationIds, verdict, targetClass = null) => + request('/triage/overrides', { + method: 'POST', + body: { annotation_ids: annotationIds, verdict, target_class: targetClass }, + }), + clearTriageOverrides: (annotationIds) => + request('/triage/overrides', { method: 'DELETE', body: { annotation_ids: annotationIds } }), + cropUrl: (annotationId) => `/api/annotations/${annotationId}/crop`, + importAnnotations: (projectId, zipFile, batchLabel = '') => { + const form = new FormData() + form.append('file', zipFile) + form.append('batch_label', batchLabel) + return request(`/projects/${projectId}/import`, { method: 'POST', form }) + }, + exportUrl: (projectId, { batchIds = [], approvedOnly = false, includeEmpty = false } = {}) => { + const params = new URLSearchParams() + if (batchIds.length) params.set('batch_ids', batchIds.join(',')) + if (approvedOnly) params.set('approved_only', 'true') + if (includeEmpty) params.set('include_empty', 'true') + const query = params.toString() + return `/api/projects/${projectId}/export${query ? `?${query}` : ''}` + }, hardware: () => request('/hardware'), startTraining: (projectId, body) => diff --git a/frontend/src/app.css b/frontend/src/app.css index 5a5f0fa..43b95c8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -476,6 +476,51 @@ main.page { filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.85)); } +/* Select mode: the cursor promises a marquee, and a shape is a target to tick + rather than something to drag (REQ-045a). */ +.canvas-wrap svg.selecting { cursor: cell; } +.canvas-wrap svg.selecting .shape rect, +.canvas-wrap svg.selecting .shape polygon { cursor: pointer; } + +.canvas-wrap .shape.marked rect, +.canvas-wrap .shape.marked polygon { + fill: rgba(56, 189, 248, 0.3); + stroke-width: 3; +} + +.canvas-wrap .draft.marquee { + fill: rgba(56, 189, 248, 0.12); + stroke-width: 1.5; + stroke-dasharray: 5 3; + vector-effect: non-scaling-stroke; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 16px; +} + +.modal { + width: 100%; + background: var(--surface, #18181b); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 20px; + max-height: 85vh; + overflow-y: auto; +} + +.mode-switch { + display: inline-flex; + gap: 2px; +} + .canvas-wrap .handle { cursor: nwse-resize; stroke: #ffffff; diff --git a/frontend/src/components/AnnotationCanvas.jsx b/frontend/src/components/AnnotationCanvas.jsx index 98478f3..5448dd5 100644 --- a/frontend/src/components/AnnotationCanvas.jsx +++ b/frontend/src/components/AnnotationCanvas.jsx @@ -32,15 +32,22 @@ function normalise([x0, y0, x1, y1]) { return [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)] } +function overlaps(geometry, [mx0, my0, mx1, my1]) { + const [x0, y0, x1, y1] = boxPoints(geometry) + return x0 <= mx1 && x1 >= mx0 && y0 <= my1 && y1 >= my0 +} + export default function AnnotationCanvas({ frame, imageUrl, annotations, selectedId, activeClass, assistMode, classes, - onSelect, onCreate, onUpdate, onAssist, + mode = 'draw', selectedIds, onSelect, onCreate, onUpdate, onAssist, onMarquee, }) { const wrapRef = useRef(null) const svgRef = useRef(null) const [displayWidth, setDisplayWidth] = useState(0) const [draft, setDraft] = useState(null) // box being drawn const [drag, setDrag] = useState(null) // move/resize in progress + const selecting = mode === 'select' + const marked = selectedIds instanceof Set ? selectedIds : new Set(selectedIds ?? []) useLayoutEffect(() => { const element = wrapRef.current @@ -65,16 +72,25 @@ export default function AnnotationCanvas({ ] } + const additive = useRef(false) + function startDraw(event) { if (event.button !== 0) return const [x, y] = pointAt(event) - onSelect(null) + additive.current = event.shiftKey + if (!selecting) onSelect(null) setDraft([x, y, x, y]) event.currentTarget.setPointerCapture(event.pointerId) } function startMove(event, annotation) { event.stopPropagation() + // Select mode never moves a shape: a stray drag on top of a box would + // silently edit the geometry the user was only trying to tick off. + if (selecting) { + onMarquee?.([annotation.id], { toggle: true }) + return + } const [x, y] = pointAt(event) onSelect(annotation.id) setDrag({ kind: 'move', id: annotation.id, origin: [x, y], @@ -147,6 +163,15 @@ export default function AnnotationCanvas({ if (draft) { const [x0, y0, x1, y1] = normalise(draft) setDraft(null) + if (selecting) { + const isDrag = x1 - x0 >= MIN_SIZE || y1 - y0 >= MIN_SIZE + const hits = isDrag + ? annotations.filter((a) => overlaps(a.geometry, [x0, y0, x1, y1])).map((a) => a.id) + : [] + // A click on bare canvas clears; a drag replaces unless Shift adds. + onMarquee?.(hits, { add: additive.current }) + return + } if (x1 - x0 >= MIN_SIZE && y1 - y0 >= MIN_SIZE) { if (assistMode) onAssist([x0, y0, x1, y1]) else onCreate({ type: 'bbox', points: [x0, y0, x1, y1] }) @@ -184,7 +209,7 @@ export default function AnnotationCanvas({ ref={svgRef} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" - className={assistMode ? 'assist' : undefined} + className={[assistMode ? 'assist' : '', selecting ? 'selecting' : ''].filter(Boolean).join(' ') || undefined} onPointerDown={startDraw} onPointerMove={onPointerMove} onPointerUp={onPointerUp} @@ -198,6 +223,8 @@ export default function AnnotationCanvas({ scale={scale} handle={handle} selected={annotation.id === selectedId} + marked={marked.has(annotation.id)} + readOnly={selecting} classes={classes} onStartMove={startMove} onStartResize={startResize} @@ -211,10 +238,10 @@ export default function AnnotationCanvas({ const [x0, y0, x1, y1] = normalise(draft) return ( ) })()} diff --git a/frontend/src/components/AugmentationPanel.jsx b/frontend/src/components/AugmentationPanel.jsx new file mode 100644 index 0000000..ee6789c --- /dev/null +++ b/frontend/src/components/AugmentationPanel.jsx @@ -0,0 +1,126 @@ +import React from 'react' + +/* Augmentation settings (REQ-110…113). + * + * Ultralytics augments regardless; before this panel the values were simply + * library defaults nobody could see. "Medium" reproduces those defaults exactly, + * so opening this page and saving nothing changes nothing. */ + +export const PRESETS = { + off: { fliplr: 0, flipud: 0, degrees: 0, translate: 0, scale: 0, hsv_h: 0, hsv_s: 0, hsv_v: 0, mosaic: 0 }, + light: { fliplr: 0.5, flipud: 0, degrees: 0, translate: 0.05, scale: 0.2, hsv_h: 0.010, hsv_s: 0.4, hsv_v: 0.3, mosaic: 0 }, + medium: { fliplr: 0.5, flipud: 0, degrees: 0, translate: 0.1, scale: 0.5, hsv_h: 0.015, hsv_s: 0.7, hsv_v: 0.4, mosaic: 1.0 }, + aggressive: { fliplr: 0.5, flipud: 0.1, degrees: 10, translate: 0.2, scale: 0.9, hsv_h: 0.020, hsv_s: 0.9, hsv_v: 0.5, mosaic: 1.0 }, +} + +const PRESET_LABELS = [ + ['off', 'Off', 'No augmentation at all.'], + ['light', 'Light', 'Horizontal flip and mild colour shift.'], + ['medium', 'Medium', "Ultralytics' defaults — what you have been training with."], + ['aggressive', 'Aggressive', 'Adds rotation, vertical flip and heavy scaling.'], +] + +const CONTROLS = [ + { key: 'fliplr', label: 'Horizontal flip', hint: 'Chance of mirroring left–right.', min: 0, max: 1, step: 0.05, pct: true }, + { key: 'flipud', label: 'Vertical flip', hint: 'Rarely useful for fixed cameras.', min: 0, max: 1, step: 0.05, pct: true }, + { key: 'degrees', label: 'Rotation', hint: 'Maximum rotation either way.', min: 0, max: 45, step: 1, unit: '°' }, + { key: 'translate', label: 'Shift', hint: 'Maximum shift, as a fraction of the frame.', min: 0, max: 0.5, step: 0.01, pct: true }, + { key: 'scale', label: 'Zoom', hint: 'Random zoom in or out.', min: 0, max: 1, step: 0.05, pct: true }, + { key: 'hsv_h', label: 'Hue', hint: 'Colour shift. Keep small or sacks change colour.', min: 0, max: 0.1, step: 0.005, pct: true }, + { key: 'hsv_s', label: 'Saturation', hint: 'Colour intensity jitter.', min: 0, max: 1, step: 0.05, pct: true }, + { key: 'hsv_v', label: 'Brightness', hint: 'Helps across day and night footage.', min: 0, max: 1, step: 0.05, pct: true }, + { key: 'mosaic', label: 'Mosaic', hint: 'Stitches 4 images into one. Strong, but distorts scale.', min: 0, max: 1, step: 0.1, pct: true }, +] + +function matchPreset(settings) { + for (const [name, preset] of Object.entries(PRESETS)) { + if (CONTROLS.every((c) => Math.abs((settings[c.key] ?? 0) - preset[c.key]) < 1e-9)) return name + } + return 'custom' +} + +export default function AugmentationPanel({ settings, onChange, dirty, saving, onSave }) { + const [open, setOpen] = React.useState(false) + const active = matchPreset(settings) + + return ( +
+
+

Augmentation

+ + Applied to training images only — validation is never augmented, so mAP stays comparable. + +
+ +
+ {PRESET_LABELS.map(([key, label, hint]) => { + const on = active === key + return ( + + ) + })} +
+ +
+ + {active === 'custom' && custom} + {dirty && unsaved} + +
+ + {open && ( +
+ {CONTROLS.map((control) => { + const value = settings[control.key] ?? 0 + const shown = control.pct ? `${Math.round(value * 100)}%` : `${value}${control.unit ?? ''}` + return ( +
+
+ {control.label} + {shown} +
+ onChange({ ...settings, [control.key]: Number(e.target.value) })} + style={{ width: '100%', cursor: 'pointer' }} + aria-label={control.label} + /> +

{control.hint}

+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/AutoAnnotateModal.jsx b/frontend/src/components/AutoAnnotateModal.jsx new file mode 100644 index 0000000..8785ac9 --- /dev/null +++ b/frontend/src/components/AutoAnnotateModal.jsx @@ -0,0 +1,335 @@ +import React, { useState, useEffect, useRef } from 'react' +import { api } from '../api' + +function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value) + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + return () => { + clearTimeout(handler) + } + }, [value, delay]) + return debouncedValue +} + +// Shared with MassAutoAnnotateModal: draws detection boxes/polygons over a +// frame in a 0..10000 viewBox. +export function PreviewShapes({ shapes, project }) { + return shapes.map((shape, i) => { + if (!shape.geometry || !shape.geometry.points) return null + const classObj = project.classes.find(c => c.class_id === shape.class_id) + const className = classObj?.name || 'Unknown' + const color = ['#38bdf8', '#34d399', '#f472b6', '#a78bfa', '#fbbf24'][shape.class_id % 5] || '#fff' + + let minX = 1, minY = 1, maxX = 0, maxY = 0 + if (shape.geometry.type === 'bbox') { + const [left, top, right, bottom] = shape.geometry.points + minX = left; minY = top; maxX = right; maxY = bottom; + } else { + shape.geometry.points.forEach(pt => { + if (pt[0] < minX) minX = pt[0] + if (pt[1] < minY) minY = pt[1] + if (pt[0] > maxX) maxX = pt[0] + if (pt[1] > maxY) maxY = pt[1] + }) + } + + const x0 = minX * 10000 + const y0 = minY * 10000 + const bw = (maxX - minX) * 10000 + const bh = (maxY - minY) * 10000 + + return ( + + {shape.geometry.type === 'polygon' && ( + `${pt[0] * 10000},${pt[1] * 10000}`).join(' ')} + fill={color} + fillOpacity={0.35} + stroke={color} + strokeWidth="10" + /> + )} + + 300 ? y0 - 100 : y0 + 300} + fill={color} + fontSize="240" + fontWeight="bold" + style={{ textShadow: '10px 10px 10px #000, -10px -10px 10px #000, 10px -10px 10px #000, -10px 10px 10px #000' }} + > + {className} {shape.score ? `${(shape.score * 100).toFixed(1)}%` : ''} + + + ) + }) +} + +export default function AutoAnnotateModal({ + batch, + project, + engine, + customModelStagedPath = null, + customModelClasses = [], + onClose, + onSuccess +}) { + const [threshold, setThreshold] = useState(0.35) + const [iouThreshold, setIouThreshold] = useState(0.0) + const [minBoxFrac, setMinBoxFrac] = useState(0.0) + + + // Class selection state + const [selectedClasses, setSelectedClasses] = useState(() => { + if (engine === 'base_model') { + return project.classes.map(c => c.name) + } else if (engine === 'sam3') { + return project.classes.map(c => c.name) + } else if (engine === 'custom') { + return customModelClasses.slice() + } + return [] + }) + + // Preview state + const [frames, setFrames] = useState([]) + const [frameIndex, setFrameIndex] = useState(0) + const [previewShapes, setPreviewShapes] = useState([]) + const [isLoadingPreview, setIsLoadingPreview] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Fetch frames on mount + useEffect(() => { + api.listFrames(batch.id).then(res => { + if (res && res.frames) { + setFrames(res.frames) + setFrameIndex(Math.floor(res.frames.length / 2)) + } + }).catch(err => { + console.error("Failed to load frames for preview:", err) + }) + }, [batch.id]) + + const currentFrame = frames[frameIndex] + + const handlePreview = () => { + const frame = currentFrame + if (!frame) return + + let isMounted = true + setIsLoadingPreview(true) + api.preview(batch.id, { + frame_id: frame.id, + engine, + threshold, + iou_threshold: iouThreshold, + min_box_frac: minBoxFrac, + target_class_names: selectedClasses, + custom_model_path: customModelStagedPath + }).then(res => { + if (isMounted && res.shapes) { + setPreviewShapes(res.shapes) + } + }).catch(err => { + console.error("Preview failed:", err) + setPreviewShapes([]) + }).finally(() => { + setIsLoadingPreview(false) + }) + } + + // Clear shapes when frame changes + useEffect(() => { + setPreviewShapes([]) + }, [frameIndex]) + + const handleStart = async () => { + setIsSubmitting(true) + try { + await api.startAutolabel(batch.id, { + resume: false, + append: true, + engine, + threshold, + iou_threshold: iouThreshold, + min_box_frac: minBoxFrac, + target_class_names: selectedClasses, + custom_model_path: customModelStagedPath + }) + onSuccess() + } catch (err) { + alert(err.message) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ + {/* Left Side: Preview */} +
+

Preview

+ +
+ {currentFrame ? ( +
+ Preview Frame + + + + {isLoadingPreview && ( +
+ Inferring... +
+ )} +
+ +
+
+ ) : ( +
+ Loading frames... +
+ )} +
+ +
+
+ Preview Frame ({frameIndex + 1} / {frames.length}): +
+ setFrameIndex(parseInt(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+
+ + {/* Right Side: Tuning Parameters */} +
+

+ Auto-annotate: {engine === 'sam3' ? 'SAM3' : engine === 'custom' ? 'Custom YOLO' : 'Base Model'} +

+

+ Tune the parameters and check the live preview on the left. +

+ +
+
+ Confidence Threshold: + {threshold} +
+ setThreshold(parseFloat(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+ +
+
+ NMS IoU Threshold: + {iouThreshold} +
+ setIouThreshold(parseFloat(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+ +
+
+ Min Box Size (Fraction of Frame): + {(minBoxFrac * 100).toFixed(1)}% +
+ setMinBoxFrac(parseFloat(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+ +
+ Target Classes: +
+ {project.classes.map(c => { + const isSelected = selectedClasses.includes(c.name) + return ( + + ) + })} +
+
+ +
+ + +
+
+ +
+
+ ) +} + diff --git a/frontend/src/components/Icons.jsx b/frontend/src/components/Icons.jsx index bfecd70..ef2e12f 100644 --- a/frontend/src/components/Icons.jsx +++ b/frontend/src/components/Icons.jsx @@ -136,3 +136,21 @@ export const ZapIcon = (props) => ( ) +export const ScissorsIcon = (props) => ( + + + + + + + +) + +export const SearchIcon = (props) => ( + + + + +) + + diff --git a/frontend/src/components/MassAutoAnnotateModal.jsx b/frontend/src/components/MassAutoAnnotateModal.jsx new file mode 100644 index 0000000..9d1f657 --- /dev/null +++ b/frontend/src/components/MassAutoAnnotateModal.jsx @@ -0,0 +1,336 @@ +import React, { useEffect, useState } from 'react' +import { api } from '../api' +import { PreviewShapes } from './AutoAnnotateModal' + +const ENGINES = [ + { id: 'sam3', label: 'SAM3' }, + { id: 'base_model', label: 'Base Model' }, + { id: 'custom', label: 'Custom YOLO (.pt)' }, +] + +export default function MassAutoAnnotateModal({ batches, project, onClose, onSuccess }) { + const [engine, setEngine] = useState('sam3') + const [customPath, setCustomPath] = useState(null) + const [customName, setCustomName] = useState('') + const [customClasses, setCustomClasses] = useState([]) + + const [threshold, setThreshold] = useState(0.35) + const [iouThreshold, setIouThreshold] = useState(0.0) + const [minBoxFrac, setMinBoxFrac] = useState(0.0) + const [append, setAppend] = useState(true) + + const available = engine === 'custom' ? customClasses : project.classes.map(c => c.name) + const [selectedClasses, setSelectedClasses] = useState(project.classes.map(c => c.name)) + + const [selectedIds, setSelectedIds] = useState(() => batches.map(b => b.id)) + + const [previewBatchId, setPreviewBatchId] = useState(batches[0]?.id ?? null) + const [frames, setFrames] = useState([]) + const [frameIndex, setFrameIndex] = useState(0) + const [previewShapes, setPreviewShapes] = useState([]) + const [isLoadingPreview, setIsLoadingPreview] = useState(false) + + const [progress, setProgress] = useState(null) + const [error, setError] = useState('') + + // Switching engine changes which class names are meaningful. + useEffect(() => { + setSelectedClasses(engine === 'custom' ? customClasses.slice() : project.classes.map(c => c.name)) + }, [engine, customClasses, project]) + + useEffect(() => { + setPreviewShapes([]) + if (previewBatchId == null) return + api.listFrames(previewBatchId).then(res => { + const list = res?.frames || [] + setFrames(list) + setFrameIndex(Math.floor(list.length / 2)) + }).catch(() => setFrames([])) + }, [previewBatchId]) + + useEffect(() => { setPreviewShapes([]) }, [frameIndex]) + + async function pickCustomModel() { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.pt' + input.onchange = async (e) => { + const file = e.target.files?.[0] + if (!file) return + setError('') + try { + const info = await api.inspectModel(file) + setCustomPath(info.staged_path) + setCustomName(info.filename) + setCustomClasses(info.classes || []) + } catch (exc) { + setError(exc.message) + } + } + input.click() + } + + function runPreview() { + const frame = frames[frameIndex] + if (!frame) return + setIsLoadingPreview(true) + api.preview(previewBatchId, { + frame_id: frame.id, + engine, + threshold, + iou_threshold: iouThreshold, + min_box_frac: minBoxFrac, + target_class_names: selectedClasses, + custom_model_path: customPath, + }).then(res => setPreviewShapes(res.shapes || [])) + .catch(exc => { setError(exc.message); setPreviewShapes([]) }) + .finally(() => setIsLoadingPreview(false)) + } + + function toggleClass(name) { + setSelectedClasses(prev => + prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]) + } + + function toggleBatch(id) { + setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) + } + + async function start() { + setError('') + setProgress({ done: 0, total: selectedIds.length, failed: 0 }) + let done = 0 + let failed = 0 + // Submitted one at a time so a single rejection doesn't abort the rest — + // Promise.all would drop every remaining batch on the first bad one. The + // backend GPU lock serialises the actual work anyway. + for (const id of selectedIds) { + try { + await api.startAutolabel(id, { + resume: false, + append, + engine, + threshold, + iou_threshold: iouThreshold, + min_box_frac: minBoxFrac, + target_class_names: selectedClasses, + custom_model_path: customPath, + }) + } catch { + failed += 1 + } + done += 1 + setProgress({ done, total: selectedIds.length, failed }) + } + onSuccess(`Queued ${done - failed} auto-annotation job(s)` + + (failed ? `, ${failed} failed to queue` : '')) + } + + const busy = progress !== null + const canStart = !busy && selectedIds.length > 0 && selectedClasses.length > 0 + && (engine !== 'custom' || customPath) + + return ( +
+
+ + {/* Preview */} +
+

Preview

+ + + +
+ {frames[frameIndex] ? ( +
+ Preview frame + + + + {isLoadingPreview && ( +
+ Inferring… +
+ )} +
+ ) : ( +
+ {frames.length === 0 ? 'No extracted frames in this batch yet' : 'Loading frames…'} +
+ )} +
+ +
+ + Frame {frames.length ? frameIndex + 1 : 0} / {frames.length} + + setFrameIndex(parseInt(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> + +
+
+ + {/* Settings */} +
+

+ Mass auto-annotate — {selectedIds.length} of {batches.length} batch(es) +

+

+ Every selected batch is queued with these settings. The GPU lock runs them one at a time. +

+ + {error &&

{error}

} + +
+ Engine: +
+ {ENGINES.map(e => ( + + ))} +
+ {engine === 'custom' && ( +
+ + + {customName || 'no model selected'} + +
+ )} +
+ + v.toFixed(2)} /> + v.toFixed(2)} /> + `${(v * 100).toFixed(1)}%`} /> + + + +
+ Target Classes: +
+ {available.length === 0 && ( + Load a model to list its classes. + )} + {available.map(name => { + const on = selectedClasses.includes(name) + return ( + + ) + })} +
+
+ +
+
+ Batches: + + + + +
+
+ {batches.map(b => ( + + ))} +
+
+ + {progress && ( +

+ Queueing {progress.done} / {progress.total} + {progress.failed ? ` · ${progress.failed} failed` : ''} +

+ )} + +
+ + +
+
+
+
+ ) +} + +function Slider({ label, value, min, max, step, color, onChange, format }) { + return ( +
+
+ {label}: + {format(value)} +
+ onChange(parseFloat(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+ ) +} diff --git a/frontend/src/components/MergeTargetModal.jsx b/frontend/src/components/MergeTargetModal.jsx new file mode 100644 index 0000000..cdd4cc0 --- /dev/null +++ b/frontend/src/components/MergeTargetModal.jsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react' +import { api } from '../api' +import { AlertIcon } from './Icons' + +/* Where does this batch's approved frames go? + * + * Asked every merge rather than inferred, because "which dataset did that batch + * end up in" is not recoverable later without checking the database. */ + +export default function MergeTargetModal({ projectId, batchLabel, unreviewedBatches = 0, + unreviewedFrames = 0, onClose, onConfirm }) { + const [rows, setRows] = useState([]) + const [choice, setChoice] = useState('new') + const [name, setName] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [acknowledged, setAcknowledged] = useState(false) + + useEffect(() => { + api.listDatasets(projectId) + .then((payload) => { + setRows(payload.datasets) + if (payload.datasets.length) setChoice(String(payload.datasets[0].id)) + }) + .catch((exc) => setError(exc.message)) + }, [projectId]) + + async function confirm() { + setBusy(true); setError('') + try { + await onConfirm(choice === 'new' + ? { datasetName: name.trim() } + : { datasetId: Number(choice) }) + onClose() + } catch (exc) { setError(exc.message); setBusy(false) } + } + + return ( +
+
event.stopPropagation()} style={{ maxWidth: 480 }}> +

Merge {batchLabel}

+

+ Approved frames are copied into the dataset you pick. The filter you just tuned is + applied as they are merged, and frozen onto the dataset — editing it later will not + rewrite this one. +

+ + {error &&

{error}

} + + {unreviewedBatches > 0 && ( +
+ + {unreviewedBatches} batch(es) have not been reviewed. + + + Their {unreviewedFrames.toLocaleString()} frame(s) will be approved exactly as + auto-annotation left them and merged — nobody has checked these boxes by hand. + Wrong boxes become training labels. + + +
+ )} + +
+ {rows.map((row) => ( + + ))} + + + {choice === 'new' && ( + setName(event.target.value)} + style={{ marginLeft: 24 }} + /> + )} +
+ +
+ + + +
+
+
+ ) +} diff --git a/frontend/src/components/OutlierFilter.jsx b/frontend/src/components/OutlierFilter.jsx new file mode 100644 index 0000000..89518b7 --- /dev/null +++ b/frontend/src/components/OutlierFilter.jsx @@ -0,0 +1,174 @@ +import React from 'react' + +/* The outlier filter (REQ-100). Three keep-ranges, one per signal; anything + * outside an enabled range is ignored. + * + * There is no new backend for this. A keep-range is stored as the `ignore` + * rules for its two tails, which the existing resolver already understands — + * so the sliders are just a friendlier face on machinery that was already + * there. `toRules`/`fromRules` are the whole translation. */ + +export const SIGNALS = [ + { key: 'score', label: 'Confidence', hint: 'How sure SAM3 was. Low scores are usually junk.', + min: 0, max: 1, step: 0.01, format: (v) => v.toFixed(2) }, + { key: 'area_pct', label: 'Box area', hint: 'Percent of the frame. Catches specks and full-frame boxes.', + min: 0, max: 100, step: 0.1, format: (v) => `${v.toFixed(1)}%` }, + { key: 'aspect', label: 'Aspect ratio', hint: 'Width ÷ height. Catches slivers.', + min: 0, max: 10, step: 0.1, format: (v) => v.toFixed(1) }, +] + +export const EMPTY = SIGNALS.reduce( + (acc, s) => ({ ...acc, [s.key]: { on: false, min: s.min, max: s.max } }), {}) + +/** Filter -> the `ignore` rules the resolver evaluates. */ +export function toRules(filter) { + const rules = [] + for (const signal of SIGNALS) { + const range = filter[signal.key] + if (!range?.on) continue + if (range.min > signal.min) { + rules.push({ + name: `${signal.label} below ${signal.format(range.min)}`, + predicate: { [signal.key]: [null, range.min] }, + action: 'ignore', + target_class: null, + }) + } + if (range.max < signal.max) { + rules.push({ + name: `${signal.label} above ${signal.format(range.max)}`, + predicate: { [signal.key]: [range.max, null] }, + action: 'ignore', + target_class: null, + }) + } + } + return rules +} + +/** Stored rules -> filter, so a reload shows what was saved. */ +export function fromRules(rules) { + const filter = JSON.parse(JSON.stringify(EMPTY)) + for (const rule of rules || []) { + if (rule.action !== 'ignore') continue + for (const signal of SIGNALS) { + const bounds = rule.predicate?.[signal.key] + if (!bounds) continue + const [low, high] = bounds + filter[signal.key].on = true + // [null, x] is the low tail, so x is the minimum we keep. + if (low === null || low === undefined) filter[signal.key].min = high + else filter[signal.key].max = low + } + } + return filter +} + +export default function OutlierFilter({ filter, onChange, counts, heldBack = 0, frameCount = 0, + dirty, saving, onSave, onReset }) { + function update(key, patch) { + onChange({ ...filter, [key]: { ...filter[key], ...patch } }) + } + + const total = (counts?.keep ?? 0) + (counts?.ignore ?? 0) + const dropped = counts?.ignore ?? 0 + const droppedPct = total > 0 ? (dropped / total) * 100 : 0 + + return ( +
+
+

Outlier filter

+ + Boxes outside an enabled range are dropped at merge time. Nothing is rewritten. + +
+ +
+ {SIGNALS.map((signal) => { + const range = filter[signal.key] + return ( +
+ +

{signal.hint}

+ +
+
+ keep from + {signal.format(range.min)} +
+ update(signal.key, { min: Math.min(Number(e.target.value), range.max) })} + style={{ width: '100%', cursor: 'pointer' }} + aria-label={`${signal.label} minimum`} + /> +
+ up to + {signal.format(range.max)} +
+ update(signal.key, { max: Math.max(Number(e.target.value), range.min) })} + style={{ width: '100%', cursor: 'pointer' }} + aria-label={`${signal.label} maximum`} + /> +
+
+ ) + })} +
+ +
+ + {counts?.keep ?? 0} + kept + + + {dropped} + dropped{total > 0 ? ` · ${droppedPct.toFixed(1)}%` : ''} + + + 0 ? '#fbbf24' : undefined }}>{heldBack} + + {' '}frame{heldBack === 1 ? '' : 's'} held back + {frameCount > 0 ? ` of ${frameCount}` : ''} — lost every box + + + {dirty && unsaved} + + + + +
+
+ ) +} diff --git a/frontend/src/components/ReviewSidebar.jsx b/frontend/src/components/ReviewSidebar.jsx index a99eb5d..2184d91 100644 --- a/frontend/src/components/ReviewSidebar.jsx +++ b/frontend/src/components/ReviewSidebar.jsx @@ -11,7 +11,7 @@ export default function ReviewSidebar({ annotations, selectedId, setSelectedId, - removeSelected, + removeAnnotation, project, jumpToNextAnnotated, batchAnnotationCount, @@ -75,7 +75,7 @@ export default function ReviewSidebar({
  • diff --git a/frontend/src/components/Shape.jsx b/frontend/src/components/Shape.jsx index c006cea..4a38398 100644 --- a/frontend/src/components/Shape.jsx +++ b/frontend/src/components/Shape.jsx @@ -15,33 +15,36 @@ function boxPoints(geometry) { } export default function Shape({ - annotation, width, height, scale, handle, selected, classes, + annotation, width, height, scale, handle, selected, marked, readOnly, classes, onStartMove, onStartResize, onStartVertex, onStartMidpoint, onDeleteVertex, }) { const colour = classColor(annotation.class_id) const [x0, y0, x1, y1] = boxPoints(annotation.geometry) const isPolygon = annotation.geometry.type === 'polygon' - const className = classes?.[annotation.class_id]?.name || `Class ${annotation.class_id + 1}` + const className = classes?.find?.((c) => c.class_id === annotation.class_id)?.name || classes?.[annotation.class_id]?.name || `Class ${annotation.class_id + 1}` + // Select mode shows what is ticked, not what is editable — handles there + // would invite a drag that silently rewrites geometry. + const editable = selected && !readOnly return ( - + {isPolygon ? ( `${px * width},${py * height}`).join(' ')} - stroke={colour} + stroke={marked ? '#38bdf8' : colour} onPointerDown={(event) => onStartMove(event, annotation)} /> ) : ( onStartMove(event, annotation)} /> )} - {selected && ( + {editable && ( )} - {selected && !isPolygon && CORNERS.map(([corner, cx, cy]) => ( + {editable && !isPolygon && CORNERS.map(([corner, cx, cy]) => ( ))} - {selected && isPolygon && (() => { + {editable && isPolygon && (() => { const points = annotation.geometry.points const vertices = points.map(([px, py], i) => ( Drag
    Add box / resize / move
    +
    +
    V
    +
    Draw ⇄ Select mode
    +
    +
    +
    Shift+Drag
    +
    Add to selection (Select mode)
    +
    Hold S
    SAM3 assisted shape
    @@ -28,8 +36,12 @@ export default function ShortcutsPanel() {
    Approve / reject
    -
    U
    -
    Next unreviewed
    +
    U N
    +
    Next unreviewed / next shape
    +
    +
    +
    C T
    +
    Copy prev / Track 5 frames
    diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 7e840cc..96acefb 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -9,24 +9,25 @@ import { BarChartIcon, SunIcon, MoonIcon, - ChevronRightIcon, + CpuIcon, + LayersIcon, + ZapIcon, } from './Icons' + export default function Sidebar({ route, currentProject, theme, onToggleTheme }) { const [health, setHealth] = useState(null) - const [collapsed, setCollapsed] = useState(() => localStorage.getItem('sidebar_collapsed') === 'true') useEffect(() => { - api.health().then(setHealth).catch(() => {}) + let timer + const fetchHealth = () => { + api.health().then(setHealth).catch(() => {}) + } + fetchHealth() + timer = setInterval(fetchHealth, 3000) + return () => clearInterval(timer) }, []) - const toggleCollapse = () => { - setCollapsed((prev) => { - const next = !prev - localStorage.setItem('sidebar_collapsed', String(next)) - return next - }) - } const pId = currentProject?.id || route.projectId || 1 @@ -36,73 +37,122 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme }) } return ( -
    -
    -
    {collapsed ? 'DE' : 'Dataset Enrichment'}
    - +
    +
    handleNav(e, '/projects')} style={{ cursor: 'pointer' }}> + + Dataset Enrichment +
    -
    - - - -
    + handleNav(e, `/projects/${pId}/data-prep`)} + className={`topbar-item ${route.name === 'data-prep' ? 'active' : ''}`} + > + + Data Preparation + -
    - - {health && !collapsed && ( -
    -
    - GPU: - {health.gpu ? health.gpu.replace('NVIDIA GeForce ', '').replace(' Laptop GPU', '') : 'N/A'} -
    -
    VRAM: {health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'}
    -
    SAM3: {health.sam3_ready ? 'Ready' : 'Not Ready'}
    -
    FFmpeg: {health.ffmpeg ? 'OK' : 'Error'}
    + + + handleNav(e, `/projects/${pId}/datasets`)} + className={`topbar-item ${route.name === 'datasets' ? 'active' : ''}`} + > + + Datasets + + + handleNav(e, `/projects/${pId}/models`)} + className={`topbar-item ${route.name === 'models' ? 'active' : ''}`} + > + + Models & Training + + + handleNav(e, `/projects/${pId}/live-count`)} + className={`topbar-item ${route.name === 'live-count' ? 'active' : ''}`} + > + + Live Counting + + + handleNav(e, `/projects/${pId}/counting-bench`)} + className={`topbar-item ${route.name === 'counting-bench' ? 'active' : ''}`} + > + + Counting Accuracy + + + handleNav(e, '/sam3-playground')} + className={`topbar-item ${route.name === 'sam3-playground' ? 'active' : ''}`} + > + + SAM3 Playground + + + +
    + {health && ( +
    + + GPU: {health.gpu ? health.gpu.replace('NVIDIA GeForce ', '').replace(' Laptop GPU', '') : 'CPU'} + + + VRAM: {health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'} + + + SAM3: {health.sam3_ready ? 'Ready' : 'Off'} +
    )} + +
    -
    +
    ) } + diff --git a/frontend/src/components/TriageCropGrid.jsx b/frontend/src/components/TriageCropGrid.jsx new file mode 100644 index 0000000..ed3aa40 --- /dev/null +++ b/frontend/src/components/TriageCropGrid.jsx @@ -0,0 +1,178 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { api } from '../api' + +/* A wall of cropped shapes (REQ-106). Judging "is this a half sack?" means + * looking at the object, not at the frame it came from. + * + * Paged from the server: a real batch holds ~85k shapes, so the client never + * gets to hold them all. Sorting happens server-side for the same reason — + * "worst 120 by score" has to be chosen from the whole batch, not from a page. */ + +const VERDICT_COLOR = { keep: '#4ade80', ignore: '#f87171', reclass: '#c084fc' } +const PAGE = 120 + +/** Same rule as the scatter: judge the crop by the sliders as they stand now, + * not by what was last saved. */ +function droppedBy(filter, shape) { + if (!filter) return false + if (shape.source === 'manual') return shape.verdict === 'ignore' + for (const key of ['score', 'area_pct', 'aspect']) { + const range = filter[key] + if (!range?.on) continue + if (shape[key] < range.min || shape[key] > range.max) return true + } + return false +} + +export default function TriageCropGrid({ batchIds, selectedIds, onSelect, classes, reloadKey, + filter = null }) { + const [sortBy, setSortBy] = useState('score') + const [shapes, setShapes] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const fetchPage = useCallback( + async (offset, replace) => { + if (!batchIds?.length) return + setLoading(true) + try { + const page = await api.triageShapes(batchIds, { sort: sortBy, offset, limit: PAGE }) + setTotal(page.total) + setShapes((rows) => (replace ? page.shapes : [...rows, ...page.shapes])) + setError('') + } catch (exc) { + setError(exc.message) + } finally { + setLoading(false) + } + }, + [String(batchIds), sortBy], + ) + + useEffect(() => { fetchPage(0, true) }, [fetchPage, reloadKey]) + + function toggle(id, event) { + if (event.shiftKey) { + onSelect(selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id]) + } else { + onSelect(selectedIds.includes(id) && selectedIds.length === 1 ? [] : [id]) + } + } + + const className = (id) => classes?.find((c) => c.class_id === id)?.name ?? `class ${id}` + + return ( +
    +
    + + Showing {shapes.length} of {total} · click to select, shift-click to add + +
    + {[ + { key: 'score', label: 'lowest score' }, + { key: 'area_pct', label: 'smallest area' }, + ].map((mode) => ( + + ))} +
    +
    + + {error &&

    {error}

    } + + {total === 0 && !loading ? ( +

    No shapes on this batch — run auto-annotation first.

    + ) : ( +
    + {shapes.map((shape) => { + const chosen = selectedIds.includes(shape.id) + const dropped = droppedBy(filter, shape) + return ( + + ) + })} +
    + )} + + {shapes.length < total && ( + + )} +
    + ) +} diff --git a/frontend/src/components/TriageScatter.jsx b/frontend/src/components/TriageScatter.jsx new file mode 100644 index 0000000..fcd591c --- /dev/null +++ b/frontend/src/components/TriageScatter.jsx @@ -0,0 +1,220 @@ +import React, { useMemo, useRef, useState } from 'react' + +/* Score x area, one dot per shape, drag a rectangle to select (REQ-106). + * + * Area is on a log scale: box sizes span three orders of magnitude, and on a + * linear axis every shape piles into the left edge where no boundary is + * visible. */ + +const VERDICT_COLOR = { keep: '#4ade80', ignore: '#f87171', reclass: '#c084fc' } + +const W = 760 +const H = 320 +const PAD = { left: 52, right: 16, top: 16, bottom: 40 } +const PLOT_W = W - PAD.left - PAD.right +const PLOT_H = H - PAD.top - PAD.bottom + +const MIN_AREA = 0.001 +const logArea = (pct) => Math.log10(Math.max(MIN_AREA, pct)) +const AREA_TICKS = [0.001, 0.01, 0.1, 1, 10, 100] +const LO = logArea(MIN_AREA) +const HI = logArea(100) + +/** Would the pending filter drop this shape? Mirrors the server's resolver, but + * runs on the slider position rather than on what was saved — otherwise the + * plot sits still while the thresholds move, which is the one thing it is here + * to show. */ +function droppedBy(filter, shape) { + if (!filter) return false + for (const key of ['score', 'area_pct', 'aspect']) { + const range = filter[key] + if (!range?.on) continue + const value = shape[key] + if (value < range.min || value > range.max) return true + } + return false +} + +export default function TriageScatter({ shapes, selectedIds, onSelect, filter = null }) { + const svgRef = useRef(null) + const [drag, setDrag] = useState(null) + + const points = useMemo( + () => + shapes.map((shape) => ({ + shape, + // A hand decision outranks the filter, so it keeps its own colour. + dropped: shape.source === 'manual' ? shape.verdict === 'ignore' : droppedBy(filter, shape), + manual: shape.source === 'manual', + x: PAD.left + ((logArea(shape.area_pct) - LO) / (HI - LO)) * PLOT_W, + y: PAD.top + (1 - shape.score) * PLOT_H, + })), + [shapes, filter], + ) + + // The cut lines, in plot coordinates. Only for the two axes actually drawn. + const bands = useMemo(() => { + if (!filter) return [] + const out = [] + const scoreY = (s) => PAD.top + (1 - Math.min(1, Math.max(0, s))) * PLOT_H + const areaX = (a) => PAD.left + + ((logArea(Math.min(100, Math.max(MIN_AREA, a))) - LO) / (HI - LO)) * PLOT_W + if (filter.score?.on) { + if (filter.score.min > 0) out.push({ x: PAD.left, y: scoreY(filter.score.min), w: PLOT_W, h: PAD.top + PLOT_H - scoreY(filter.score.min) }) + if (filter.score.max < 1) out.push({ x: PAD.left, y: PAD.top, w: PLOT_W, h: scoreY(filter.score.max) - PAD.top }) + } + if (filter.area_pct?.on) { + if (filter.area_pct.min > 0) out.push({ x: PAD.left, y: PAD.top, w: areaX(filter.area_pct.min) - PAD.left, h: PLOT_H }) + if (filter.area_pct.max < 100) out.push({ x: areaX(filter.area_pct.max), y: PAD.top, w: PAD.left + PLOT_W - areaX(filter.area_pct.max), h: PLOT_H }) + } + return out.filter((b) => b.w > 0.5 && b.h > 0.5) + }, [filter]) + + function toLocal(event) { + const rect = svgRef.current.getBoundingClientRect() + return { + x: ((event.clientX - rect.left) / rect.width) * W, + y: ((event.clientY - rect.top) / rect.height) * H, + } + } + + function onPointerDown(event) { + if (event.button !== 0) return + const origin = toLocal(event) + setDrag({ origin, current: origin }) + svgRef.current.setPointerCapture(event.pointerId) + } + + function onPointerMove(event) { + if (!drag) return + setDrag((state) => ({ ...state, current: toLocal(event) })) + } + + function onPointerUp(event) { + if (!drag) return + svgRef.current.releasePointerCapture?.(event.pointerId) + const box = rectOf(drag) + // A click rather than a drag: clear the selection instead of selecting nothing. + if (box.width < 3 && box.height < 3) { + onSelect([]) + setDrag(null) + return + } + const hit = points + .filter((p) => p.x >= box.x && p.x <= box.x + box.width && p.y >= box.y && p.y <= box.y + box.height) + .map((p) => p.shape.id) + onSelect(event.shiftKey ? [...new Set([...selectedIds, ...hit])] : hit) + setDrag(null) + } + + const marquee = drag ? rectOf(drag) : null + + return ( +
    + + + + {AREA_TICKS.map((tick) => { + const x = PAD.left + ((logArea(tick) - LO) / (HI - LO)) * PLOT_W + return ( + + + + {tick < 1 ? tick : `${tick}%`} + + + ) + })} + + {[0, 0.25, 0.5, 0.75, 1].map((score) => { + const y = PAD.top + (1 - score) * PLOT_H + return ( + + + + {score.toFixed(2)} + + + ) + })} + + + Box area (% of frame, log scale) + + + SAM3 score + + + {/* Shaded where the pending filter cuts, drawn under the dots. */} + {bands.map((band, index) => ( + + ))} + + {points.map(({ shape, x, y, dropped, manual }) => { + const chosen = selectedIds.includes(shape.id) + const colour = filter + ? (dropped ? VERDICT_COLOR.ignore : VERDICT_COLOR.keep) + : (VERDICT_COLOR[shape.verdict] ?? '#a1a1aa') + return ( + + {`${dropped ? 'dropped' : 'kept'}${manual ? ' (by hand)' : ''}\nscore ${shape.score} · area ${shape.area_pct}% · aspect ${shape.aspect}`} + + ) + })} + + {marquee && ( + + )} + +

    + Drag to select · shift-drag to add · click to clear. + {filter + ? ' Red shading is what the current sliders drop; gold ring means decided by hand.' + : ' Colour is the resolved verdict.'} +

    +
    + ) +} + +function rectOf({ origin, current }) { + return { + x: Math.min(origin.x, current.x), + y: Math.min(origin.y, current.y), + width: Math.abs(current.x - origin.x), + height: Math.abs(current.y - origin.y), + } +} diff --git a/frontend/src/pages/BatchesPage.jsx b/frontend/src/pages/BatchesPage.jsx new file mode 100644 index 0000000..0da26a1 --- /dev/null +++ b/frontend/src/pages/BatchesPage.jsx @@ -0,0 +1,173 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api' +import { AlertIcon, DatabaseIcon, UploadIcon, ZapIcon } from '../components/Icons' +import { BatchList, ActiveJobsBanner } from './LibraryPage' +import MassAutoAnnotateModal from '../components/MassAutoAnnotateModal' + +export default function BatchesPage({ projectId, onProject }) { + const [project, setProject] = useState(null) + const [batches, setBatches] = useState([]) + const [jobs, setJobs] = useState([]) + const [error, setError] = useState('') + const [importing, setImporting] = useState(false) + const [notice, setNotice] = useState('') + const [massOpen, setMassOpen] = useState(false) + const importInput = useRef(null) + + async function handleImport(event) { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + setImporting(true) + setError('') + setNotice('') + try { + const result = await api.importAnnotations(projectId, file) + setNotice( + `Restored "${result.batch_label}" — ${result.frames} frame(s), ${result.shapes} annotation(s)` + + (result.skipped ? `, ${result.skipped} skipped` : ''), + ) + loadBatches() + } catch (exc) { + setError(exc.message) + } finally { + setImporting(false) + } + } + + const loadBatches = useCallback(() => { + api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {}) + }, [projectId]) + + const loadJobs = useCallback(() => { + api.listJobs(projectId).then((payload) => setJobs(payload.jobs)).catch(() => {}) + }, [projectId]) + + useEffect(() => { + let cancelled = false + setError('') + Promise.all([api.getProject(projectId), api.listBatches(projectId), api.listJobs(projectId)]) + .then(([loadedProject, batchPayload, jobPayload]) => { + if (cancelled) return + setProject(loadedProject) + onProject?.(loadedProject) + setBatches(batchPayload.batches) + setJobs(jobPayload.jobs) + }) + .catch((exc) => !cancelled && setError(exc.message)) + return () => { cancelled = true } + }, [projectId]) + + const activeJobs = jobs.filter((j) => ['queued', 'running'].includes(j.status)) + const prevActiveCount = useRef(0) + + useEffect(() => { + if (prevActiveCount.current > 0 && activeJobs.length === 0) { + loadBatches() + loadJobs() + } + prevActiveCount.current = activeJobs.length + + if (activeJobs.length === 0) return + const timer = setInterval(() => { + loadJobs() + loadBatches() + }, 2000) + return () => clearInterval(timer) + }, [activeJobs.length, loadBatches, loadJobs]) + + async function cancelJob(jobId) { + try { + await api.cancelJob(jobId) + loadJobs() + } catch (exc) { + setError(exc.message) + } + } + + if (error) { + return

    {error}

    + } + if (!project) return

    Loading…

    + + return ( + <> +
    +
    +

    Batches ({batches.length})

    +

    {project.name}

    +
    +
    + + + {batches.length > 0 && ( + <> + + Download Annotations (.zip) + + + + )} +
    +
    + + {notice && ( +

    {notice}

    + )} + + + + {batches.length === 0 ? ( +

    No extracted batches in this project yet. Go to Video Archive to trim frames into batches.

    + ) : ( + { loadBatches(); loadJobs(); }} + onError={setError} + /> + )} + + {massOpen && ( + setMassOpen(false)} + onSuccess={(message) => { + setMassOpen(false) + setNotice(message) + loadBatches() + loadJobs() + }} + /> + )} + + ) +} diff --git a/frontend/src/pages/CountingBenchPage.jsx b/frontend/src/pages/CountingBenchPage.jsx new file mode 100644 index 0000000..d7fd887 --- /dev/null +++ b/frontend/src/pages/CountingBenchPage.jsx @@ -0,0 +1,551 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api' +import { AlertIcon, CheckIcon, DatabaseIcon, ZapIcon } from '../components/Icons' + +/* Counting accuracy (REQ-150…153). + * + * One row per archive video: what the counter predicted, what you actually + * counted, and the signed difference between them. The recount runs headless in + * a background job — no annotated frame, no JPEG encode — because the point here + * is the number, not watching it happen. + * + * Delta is deliberately signed. Counting 103 where the truth is 100 is a + * different failure from counting 97, and an absolute accuracy percentage hides + * which one you have. */ + +function fmtDelta(row) { + if (row.ground_truth == null || row.loading == null) return null + return row.loading - row.ground_truth +} + +function deltaColour(delta) { + if (delta === 0) return '#4ade80' + return delta > 0 ? '#fbbf24' : '#f87171' +} + +// The stored value is wall-clock text with no timezone, so it is shown as-is. +// Parsing it into a Date would re-apply the browser's offset and shift every +// recording by hours — which is exactly the bug this replaced. +function fmtClock(startedAt) { + if (!startedAt) return null + return String(startedAt).slice(11) || String(startedAt) +} + +const BULAN = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', + 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des'] + +/* A cycle is 06:00 to 05:59 the next morning, so it always straddles midnight + * and therefore two calendar dates. It is labelled by the date it *starts*. */ +function labelSiklus(day) { + if (!day) return 'Tanpa siklus' + const [y, m, d] = day.split('-').map(Number) + return `Siklus ${d} ${BULAN[m - 1]} ${y}` +} + +function ringkasSiklus(rows) { + const scored = rows.filter((r) => r.ground_truth != null && r.loading != null) + const ai = scored.reduce((sum, r) => sum + r.loading, 0) + const gt = scored.reduce((sum, r) => sum + r.ground_truth, 0) + return { + videos: rows.length, + counted: rows.filter((r) => r.loading != null).length, + scored: scored.length, + ai, + gt, + delta: ai - gt, + accuracy: gt ? Math.round((1 - Math.abs(ai - gt) / gt) * 1000) / 10 : null, + // Rows whose start time is missing or was read with low confidence: their + // place in this cycle is a guess, so the cycle's totals are too. + flagged: rows.filter((r) => !r.started_at || !r.clock_trusted).length, + } +} + +function fmtDuration(seconds) { + if (!seconds) return '—' + const m = Math.floor(seconds / 60) + return `${m}m` +} + +export default function CountingBenchPage({ projectId, onProject }) { + const [rows, setRows] = useState([]) + const [totals, setTotals] = useState(null) + const [models, setModels] = useState([]) + const [modelPath, setModelPath] = useState('') + const [activeJob, setActiveJob] = useState(null) + const [scanJob, setScanJob] = useState(null) + const [unindexed, setUnindexed] = useState(0) + const [selected, setSelected] = useState([]) + const [dateFilter, setDateFilter] = useState('') + const [recount, setRecount] = useState(false) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [draft, setDraft] = useState({}) + const [collapsed, setCollapsed] = useState({}) + const pollRef = useRef(null) + + const load = useCallback(async () => { + try { + const payload = await api.countingBench(projectId) + setRows(payload.rows) + setTotals(payload.totals) + setActiveJob(payload.active_job) + setScanJob(payload.scan_job) + setUnindexed(payload.unindexed ?? 0) + } catch (exc) { + setError(exc.message) + } + }, [projectId]) + + useEffect(() => { + api.getProject(projectId).then((p) => onProject?.(p)).catch(() => {}) + api.liveCountModels(projectId) + .then((payload) => { + setModels(payload.models) + if (payload.models.length) setModelPath(payload.models[0].path) + }) + .catch((exc) => setError(exc.message)) + load() + }, [projectId, load]) + + // Only while something is running — this table is otherwise static, and a + // poll per second against 222 rows is wasted work. + useEffect(() => { + if (!activeJob && !scanJob) { + clearInterval(pollRef.current) + return undefined + } + pollRef.current = setInterval(load, 2000) + return () => clearInterval(pollRef.current) + }, [activeJob, scanJob, load]) + + const dates = [...new Set(rows.map((r) => r.date_label))] + const visible = dateFilter ? rows.filter((r) => r.date_label === dateFilter) : rows + const allVisibleSelected = visible.length > 0 + && visible.every((r) => selected.includes(r.video_rel)) + + // Newest cycle first; inside a cycle the recordings run forwards in time, the + // order they were actually made in. + const cycles = [...new Set(visible.map((r) => r.date_label))] + .sort((a, b) => b.localeCompare(a)) + .map((day) => ({ + day, + rows: visible.filter((r) => r.date_label === day) + .sort((a, b) => (a.batch_no ?? 1e9) - (b.batch_no ?? 1e9)), + })) + + // Only the newest cycle is open to begin with — 11 cycles expanded at once is + // the wall of rows this grouping exists to avoid. + const openState = collapsed + const isOpen = (day) => (day in openState ? openState[day] : day === cycles[0]?.day) + const toggleCycle = (day) => setCollapsed((c) => ({ ...c, [day]: !isOpen(day) })) + + function toggle(rel) { + setSelected((current) => current.includes(rel) + ? current.filter((r) => r !== rel) + : [...current, rel]) + } + + function toggleCycleRows(cycleRows) { + const rels = cycleRows.map((r) => r.video_rel) + const allIn = rels.every((rel) => selected.includes(rel)) + setSelected((current) => allIn + ? current.filter((rel) => !rels.includes(rel)) + : [...new Set([...current, ...rels])]) + } + + function toggleAllVisible() { + setSelected((current) => allVisibleSelected + ? current.filter((rel) => !visible.some((r) => r.video_rel === rel)) + : [...new Set([...current, ...visible.map((r) => r.video_rel)])]) + } + + async function saveGroundTruth(rel, raw) { + const trimmed = String(raw ?? '').trim() + const value = trimmed === '' ? null : Number(trimmed) + if (value !== null && (!Number.isInteger(value) || value < 0)) { + setError('Ground truth must be a whole number of sacks') + return + } + try { + await api.setGroundTruth(projectId, rel, value) + setDraft((d) => { const next = { ...d }; delete next[rel]; return next }) + await load() + } catch (exc) { + setError(exc.message) + } + } + + async function run(allVideos) { + setBusy(true) + setError('') + try { + const job = await api.runCountingBench(projectId, { + model_path: modelPath, + video_rels: allVideos ? [] : selected, + all_videos: allVideos, + recount, + }) + setActiveJob(job) + setSelected([]) + } catch (exc) { + setError(exc.message) + } finally { + setBusy(false) + } + } + + async function scanClock(rescan) { + setBusy(true) + setError('') + try { + setScanJob(await api.scanClock(projectId, rescan)) + } catch (exc) { + setError(exc.message) + } finally { + setBusy(false) + } + } + + async function saveClock(rel, raw) { + const value = String(raw ?? '').trim() + try { + await api.setClock(projectId, rel, value || null) + await load() + } catch (exc) { + setError(exc.message) + } + } + + async function cancel() { + try { + await api.cancelJob(activeJob.id) + await load() + } catch (exc) { setError(exc.message) } + } + + return ( + <> +
    +
    +

    Counting accuracy

    +

    + What the counter predicted against what you actually counted, per video. +

    +
    + + Live counting + +
    + + {error && ( +

    + {error} + +

    + )} + + {totals && ( +
    + + + + + 0 ? `+${totals.delta}` : totals.delta} + accent={deltaColour(totals.delta)} /> + +
    + )} + +
    + + + + + + + + + + + +
    + + {unindexed > 0 && !scanJob && ( +

    + {unindexed} recording(s) have no verified start time yet, so they + are still grouped by their folder name. Folder names are not when a recording happened — + press Read timestamps to read the clock burned into each video and + regroup by the 06:00-to-06:00 working day. +

    + )} + + {scanJob && ( +
    +
    + + Reading timestamps from the archive… + + {scanJob.progress}/{scanJob.total || '—'} + +
    +
    + +
    +

    {scanJob.message}

    +
    + )} + + {activeJob && ( +
    +
    + + Counting in the background… + + {activeJob.progress}/{activeJob.total || '—'} video(s) + + + +
    +
    + +
    +

    {activeJob.message}

    +
    + )} + +
    + + + + + + + + + + + + + + + + {cycles.map(({ day, rows: cycleRows }) => { + const sum = ringkasSiklus(cycleRows) + const open = isOpen(day) + const allPicked = cycleRows.every((r) => selected.includes(r.video_rel)) + return ( + + toggleCycle(day)} + style={{ cursor: 'pointer', background: 'rgba(255,255,255,0.045)', + borderTop: '1px solid rgba(255,255,255,0.10)' }} + > + + + + + {open && cycleRows.map((row) => { + const delta = fmtDelta(row) + const value = draft[row.video_rel] ?? (row.ground_truth ?? '') + return ( + + + + + + + + + + + + + ) + })} + + ) + })} +
    + + #RecordedFileLengthCounted inCounted outNetGround truthDelta
    e.stopPropagation()}> + toggleCycleRows(cycleRows)} + title="Pilih semua video di siklus ini" + style={{ cursor: 'pointer' }} /> + +
    + + {open ? '▼' : '▶'} + + {labelSiklus(day)} + + {sum.videos} video + {sum.counted < sum.videos && ` · ${sum.counted} terhitung`} + + + {sum.flagged > 0 && ( + + {sum.flagged} perlu dicek + + )} + {sum.scored === 0 ? ( + + belum ada ground truth + + ) : ( + <> + + AI {sum.ai.toLocaleString()} + + + GT {sum.gt.toLocaleString()} + + + Δ{sum.delta > 0 ? `+${sum.delta}` : sum.delta} + + + {sum.accuracy}% + + {sum.scored < sum.videos && ( + + dari {sum.scored} video + + )} + + )} +
    +
    + toggle(row.video_rel)} style={{ cursor: 'pointer' }} /> + + {row.batch_no ?? —} + + { + const v = e.target.value.trim() + if (!v) return + const day = row.working_day || row.folder_date + saveClock(row.video_rel, `${day} ${v.length === 5 ? v + ':00' : v}`) + }} + onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }} + style={{ + width: 92, padding: '3px 6px', fontSize: '0.8rem', fontFamily: 'monospace', + background: 'rgba(0,0,0,0.35)', + color: row.clock_trusted ? '#f4f4f5' : '#fbbf24', + border: `1px solid ${row.clock_trusted ? 'rgba(255,255,255,0.15)' : 'rgba(250,204,21,0.45)'}`, + borderRadius: 4, cursor: 'text', + }} + /> + + {row.folder_date}/{row.batch_label} + {row.error && ( + + failed + + )} + + {fmtDuration(row.duration)} + + {row.loading ?? —} + + {row.unloading ?? '—'} + + {row.net ?? '—'} + + setDraft((d) => ({ ...d, [row.video_rel]: e.target.value }))} + onBlur={(e) => saveGroundTruth(row.video_rel, e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }} + style={{ + width: 82, textAlign: 'right', padding: '3px 6px', fontSize: '0.82rem', + background: 'rgba(0,0,0,0.35)', color: '#f4f4f5', + border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, cursor: 'text', + }} + /> + + {delta == null ? '—' : (delta > 0 ? `+${delta}` : delta)} +
    + + {visible.length === 0 && ( +

    + No videos in the archive for this filter. +

    + )} +
    + +

    + One cycle runs 06:00 to 05:59 the next morning, so it always covers two calendar + dates; it is labelled by the date it starts. Cycles come from the timestamp burned + into each recording, not from its folder name — a recording made at 00:07 belongs to + the cycle that started the previous morning. The original file path is shown beside it and stays the file's + identity; nothing in the archive is moved or renamed. A start time in amber was read + with low confidence or not at all — type the time you can see in the video to correct + it. Counting itself runs headless and holds the GPU, so it queues behind training and + auto-annotation. Accuracy is computed only over videos where you filled in a ground + truth. +

    + + ) +} + +function Stat({ label, value, accent, hint }) { + return ( +
    +
    {label}
    +
    {value}
    + {hint &&
    {hint}
    } +
    + ) +} diff --git a/frontend/src/pages/DataPrepPage.jsx b/frontend/src/pages/DataPrepPage.jsx new file mode 100644 index 0000000..a256e3e --- /dev/null +++ b/frontend/src/pages/DataPrepPage.jsx @@ -0,0 +1,407 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { api } from '../api' + +import { AlertIcon, CheckIcon, DatabaseIcon, RocketIcon } from '../components/Icons' +import AugmentationPanel from '../components/AugmentationPanel' +import MergeTargetModal from '../components/MergeTargetModal' +import OutlierFilter, { EMPTY, fromRules, toRules } from '../components/OutlierFilter' +import TriageCropGrid from '../components/TriageCropGrid' +import TriageScatter from '../components/TriageScatter' + +/* Data Prep does two things and nothing else (REQ-100…105, REQ-110…113): + * throw out the boxes that are obviously junk, and decide how hard to augment + * what is left. Verdicts resolve as `manual > filter > keep`. + * + * It is also the merge gate (REQ-131): Batches sends a selection here with + * `?batches=1,2,3`, the filter is tuned against exactly those batches, and only + * "Confirm merge" creates the dataset — under a frozen copy of these rules + * (REQ-132). Arriving without a selection, the page still browses one batch. */ + +export default function DataPrepPage({ projectId, batchIds = [], onProject }) { + const [project, setProject] = useState(null) + const [projects, setProjects] = useState([]) + const [batches, setBatches] = useState([]) + const [batchId, setBatchId] = useState(null) + const [merging, setMerging] = useState(false) + const [merged, setMerged] = useState('') + const [view, setView] = useState(null) + + const [filter, setFilter] = useState(EMPTY) + const [savedFilter, setSavedFilter] = useState(EMPTY) + const [simulation, setSimulation] = useState(null) + const [savingFilter, setSavingFilter] = useState(false) + + const [augment, setAugment] = useState(null) + const [savedAugment, setSavedAugment] = useState(null) + const [savingAugment, setSavingAugment] = useState(false) + + const [selectedIds, setSelectedIds] = useState([]) + const [reloadKey, setReloadKey] = useState(0) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const [loadedProject, loadedBatches, loadedRules, loadedAugment] = await Promise.all([ + api.getProject(projectId), + api.listBatches(projectId), + api.triageRules(projectId), + api.getAugment(projectId), + ]) + if (cancelled) return + setProject(loadedProject) + onProject?.(loadedProject) + const annotated = (loadedBatches.batches ?? loadedBatches).filter((b) => b.annotation_count > 0) + setBatches(annotated) + setBatchId(annotated[0]?.id ?? null) + const restored = fromRules(loadedRules.rules) + setFilter(restored) + setSavedFilter(restored) + setAugment(loadedAugment.settings) + setSavedAugment(loadedAugment.settings) + } catch (exc) { + if (!cancelled) setError(exc.message) + } + })() + return () => { cancelled = true } + }, [projectId]) + + useEffect(() => { + let cancelled = false + api.listProjects().then((payload) => { if (!cancelled) setProjects(payload.projects ?? payload) }).catch(() => {}) + return () => { cancelled = true } + }, []) + + // The selection from Batches wins; without one the page browses a single batch. + const gating = batchIds.length > 0 + const scope = gating ? batchIds : (batchId ? [batchId] : []) + const scopeKey = scope.join(',') + + const loadBatch = useCallback(async () => { + if (!scopeKey) return + try { + setView(await api.triageSummary(scopeKey)) + setSelectedIds([]) + setReloadKey((n) => n + 1) + } catch (exc) { + setError(exc.message) + } + }, [scopeKey]) + + useEffect(() => { loadBatch() }, [loadBatch]) + + // Debounced: the point is watching the counts move as you drag a slider, and a + // request per pixel would just queue up behind itself. + useEffect(() => { + if (!scopeKey) return + let cancelled = false + const timer = setTimeout(() => { + api.triageSimulate(scopeKey, toRules(filter)) + .then((data) => { if (!cancelled) setSimulation(data) }) + .catch(() => { if (!cancelled) setSimulation(null) }) + }, 300) + return () => { cancelled = true; clearTimeout(timer) } + }, [scopeKey, filter]) + + const filterDirty = JSON.stringify(filter) !== JSON.stringify(savedFilter) + const augmentDirty = augment && JSON.stringify(augment) !== JSON.stringify(savedAugment) + // Live while dragging; the saved counts once it settles. Both are server-side + // over every shape in the batch — the scatter plots a sample, these never are. + // `simulate` reports no manual tally, so that one always comes from the summary. + const counts = simulation?.counts ?? view?.counts ?? { keep: 0, ignore: 0 } + const manualCount = view?.counts?.manual ?? 0 + const heldBack = simulation?.frames_held_back ?? view?.frames_held_back ?? 0 + + async function saveFilter() { + setSavingFilter(true) + try { + await api.saveTriageRules(projectId, toRules(filter)) + setSavedFilter(filter) + await loadBatch() + } catch (exc) { + setError(exc.message) + } finally { + setSavingFilter(false) + } + } + + async function saveAugment() { + setSavingAugment(true) + try { + const stored = await api.saveAugment(projectId, augment) + setAugment(stored.settings) + setSavedAugment(stored.settings) + } catch (exc) { + setError(exc.message) + } finally { + setSavingAugment(false) + } + } + + // A batch nobody reviewed has zero approved frames, so a merge would copy + // nothing. The modal says outright that these get approved as they stand. + const unreviewedBatches = scope + .map((id) => batches.find((row) => row.id === id)) + .filter((batch) => batch && (batch.review?.approved ?? 0) === 0) + + async function confirmMerge(target) { + for (const batch of unreviewedBatches) { + await api.approveAllBatchFrames(batch.id) + } + const job = await api.approveBatch(scope, target) + setMerged(`Merge queued into dataset #${job.params.dataset_id}. ` + + 'It appears on the Datasets page when the job finishes.') + } + + async function applyVerdict(verdict) { + if (selectedIds.length === 0) return + setBusy(true) + try { + await api.setTriageOverrides(selectedIds, verdict, null) + await loadBatch() + } catch (exc) { + setError(exc.message) + } finally { + setBusy(false) + } + } + + async function clearDecisions() { + if (selectedIds.length === 0) return + setBusy(true) + try { + await api.clearTriageOverrides(selectedIds) + await loadBatch() + } catch (exc) { + setError(exc.message) + } finally { + setBusy(false) + } + } + + if (error && !project) { + return

    {error}

    + } + if (!project) return

    Loading Data Preparation…

    + + return ( + <> +
    +
    +

    Data Preparation

    +

    {project.name} · drop the junk, then set augmentation

    +
    +
    + {projects.length > 1 && ( + + )} + + Datasets + +
    +
    + + {error && ( +

    + {error} + +

    + )} + + setFilter(EMPTY)} + /> + + {augment && ( + + )} + +
    +
    +

    Check the boundary

    +
    + {gating ? ( + + {scope.length} batch(es) selected —{' '} + {scope.map((id) => { + const batch = batches.find((row) => row.id === id) + return batch ? `${batch.date_label}/${batch.batch_label}` : `#${id}` + }).join(', ')} + + ) : ( + <> + + + + )} + {manualCount > 0 && ( + {manualCount} decided by hand + )} +
    +
    + + {batches.length === 0 ? ( +

    No auto-annotated batches yet. Run auto-annotation on a batch first.

    + ) : ( + <> + + {view?.sampled && ( +

    + Plotting {(view.sample ?? []).length} of {view.total_shapes} shapes — enough to see + where the clusters split. The counts above cover all of them. +

    + )} + +
    + {selectedIds.length} selected + — decide by hand (outranks the filter): + + + +
    + + + + )} +
    + +
    +

    + {gating ? 'Confirm the merge' : 'Ready to train'} +

    +

    + {gating + ? 'The dataset is cut now, from these rules. A dropped box leaves its image in the ' + + 'dataset; only a frame that loses every box is held back. The rules are frozen ' + + 'onto the dataset, so editing them later never rewrites it. Augmentation is read ' + + 'fresh at the start of every training run.' + : 'Nothing here is destructive on its own — the rules are applied when a selection of ' + + 'batches is merged into a dataset, from the Batches page. Augmentation is read ' + + 'fresh at the start of every training run.'} +

    + + {merged && ( +

    + {merged} +

    + )} + +
    + {gating && !merged && ( + + )} + {!gating && ( + + Pick batches to merge + + )} + + Datasets + + + Models & training + +
    +
    + + {merging && ( + sum + (b.review?.pending ?? 0), 0)} + onClose={() => setMerging(false)} + onConfirm={confirmMerge} + /> + )} + + + ) +} diff --git a/frontend/src/pages/DatasetsPage.jsx b/frontend/src/pages/DatasetsPage.jsx new file mode 100644 index 0000000..8e7961b --- /dev/null +++ b/frontend/src/pages/DatasetsPage.jsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../api' +import { AlertIcon } from '../components/Icons' + +/* Named datasets: what a merge writes into, and what a training run picks from. + * + * The card is deliberately blunt about which batches are inside. "Dataset #3" + * tells you nothing six weeks later; "batch9 + batch12, 908 images, 186 val" + * is the thing you actually choose between. */ + +export default function DatasetsPage({ projectId }) { + const [rows, setRows] = useState([]) + const [picked, setPicked] = useState([]) + const [combined, setCombined] = useState(null) + const [name, setName] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + const reload = useCallback(async () => { + try { + const payload = await api.listDatasets(projectId) + setRows(payload.datasets) + } catch (exc) { setError(exc.message) } + }, [projectId]) + + useEffect(() => { reload() }, [reload]) + + useEffect(() => { + if (picked.length === 0) { setCombined(null); return } + let cancelled = false + api.combinePreview(projectId, picked) + .then((payload) => !cancelled && setCombined(payload)) + .catch(() => {}) + return () => { cancelled = true } + }, [projectId, picked]) + + function toggle(id) { + setPicked((current) => + current.includes(id) ? current.filter((row) => row !== id) : [...current, id]) + } + + async function create() { + setBusy(true); setError('') + try { + await api.createDataset(projectId, { name }) + setName('') + await reload() + } catch (exc) { setError(exc.message) } finally { setBusy(false) } + } + + async function remove(row) { + if (!window.confirm( + `Delete “${row.name}” and its ${row.total} image file(s)?\n\n` + + 'The frames and annotations stay — only this dataset\'s copy of them goes.')) return + try { + await api.deleteDataset(row.id) + setPicked((current) => current.filter((id) => id !== row.id)) + await reload() + } catch (exc) { setError(exc.message) } + } + + async function resync(row) { + if (!window.confirm( + `Rebuild the labels in “${row.name}” from the current annotations and the current ` + + 'triage rules?\n\nThis is the only thing that changes an already-merged dataset. Its ' + + 'rule version is re-stamped, so a model trained on it before this point was measured ' + + 'on different labels.')) return + try { + const report = await api.resyncDataset(row.id) + setError('') + window.alert( + `${report.labels_written} label file(s) rewritten, now at rule version ${report.rule_version}.` + + (report.frames_left_alone + ? `\n\n${report.frames_left_alone} frame(s) would have lost every box and were left as they were.` + : '')) + await reload() + } catch (exc) { setError(exc.message) } + } + + async function rename(row) { + const next = window.prompt('Rename this dataset', row.name) + if (next == null || next.trim() === row.name) return + try { + await api.renameDataset(row.id, { name: next.trim() }) + await reload() + } catch (exc) { setError(exc.message) } + } + + const total = rows.reduce((sum, row) => sum + row.total, 0) + + return ( + <> +
    +
    +

    Datasets

    +

    + {rows.length} dataset{rows.length === 1 ? '' : 's'} · {total.toLocaleString()} image + {total === 1 ? '' : 's'} in total +

    +
    + + setName(event.target.value)} + style={{ width: 240, marginRight: 8 }} + /> + +
    + + {error &&

    {error}

    } + +

    + A dataset is what Approve batch writes into, and triage rules are applied + at that moment — after which the dataset is frozen. Editing rules in Data Preparation does + not reach back into it; merge the batch again into a new dataset to compare rule sets, or + press Resync to deliberately rebuild one in place. A frame's train/val + split is decided once per project and every dataset inherits it — otherwise a base-vs-new + mAP would be measured on images the new model had already been trained on. +

    + + {picked.length > 0 && combined && ( +
    +
    + {picked.length} selected + + {combined.total_unique.toLocaleString()} unique images · + {' '}{combined.splits.train.toLocaleString()} train / {combined.splits.val.toLocaleString()} val + + {combined.shared_frames > 0 && ( + + {combined.shared_frames.toLocaleString()} frame(s) appear in more than one — counted + once, taking the labels from the newest dataset. + + )} +
    +
    + )} + +
    + {rows.map((row) => { + const on = picked.includes(row.id) + return ( +
    + + + {row.note &&

    {row.note}

    } + +
    + {row.batches.length === 0 + ? Empty — nothing merged into it yet + : row.batches.map((b) => ( + + {b.batch_label} ({b.images}) + + ))} +
    + + {row.rule_version && ( +

    + triage rules {row.rule_version} +

    + )} + +
    + + + + Download + + + +
    +
    + ) + })} +
    + + {rows.length === 0 && ( +

    + No dataset yet. Approve a batch in Batches, or create an empty one to merge into. +

    + )} + + ) +} diff --git a/frontend/src/pages/LibraryPage.jsx b/frontend/src/pages/LibraryPage.jsx index 788a57d..48837f5 100644 --- a/frontend/src/pages/LibraryPage.jsx +++ b/frontend/src/pages/LibraryPage.jsx @@ -1,7 +1,26 @@ +import AutoAnnotateModal from '../components/AutoAnnotateModal' import React, { useCallback, useEffect, useRef, useState } from 'react' import { api, formatDuration } from '../api' import { navigate } from '../App' -import { AlertIcon } from '../components/Icons' +import { AlertIcon, CheckIcon, FolderIcon, ScissorsIcon, SearchIcon } from '../components/Icons' + + + +const BULAN = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', + 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des'] + +/* A cycle runs 06:00 to 05:59 the next morning, so it always spans two calendar + * dates. It is named after the date it starts on (REQ-165). */ +function labelSiklus(day) { + if (!day) return 'Tanpa siklus' + const [y, m, d] = String(day).split('-').map(Number) + return `Siklus ${d} ${BULAN[m - 1]} ${y}` +} + +function jamRekam(startedAt) { + if (!startedAt) return null + return String(startedAt).slice(11, 19) +} function megabytes(bytes) { if (!bytes) return '—' @@ -10,10 +29,9 @@ function megabytes(bytes) { if (mb >= 10) return `${Math.round(mb)} MB` if (mb >= 1) return `${mb.toFixed(1)} MB` return `${Math.round(bytes / 1024)} KB` -} - -function ActiveJobsBanner({ jobs, onCancel }) { +}export function ActiveJobsBanner({ jobs, onCancel }) { if (!jobs || jobs.length === 0) return null + return (

    Active System Tasks ({jobs.length})

    @@ -41,22 +59,15 @@ function ActiveJobsBanner({ jobs, onCancel }) { ) } -function BatchList({ project, batches, activeJobs, onChanged, onError }) { +export function BatchList({ project, batches, activeJobs, onChanged, onError }) { const [busyId, setBusyId] = useState(null) + const [selectedIds, setSelectedIds] = useState([]) + const [appendChoiceBatch, setAppendChoiceBatch] = useState(null) - const [appendModalState, setAppendModalState] = useState(null) - const [sam3AppendState, setSam3AppendState] = useState(null) - const [customPromptInput, setCustomPromptInput] = useState('') - const [baseModelModalState, setBaseModelModalState] = useState(null) + const [autoAnnotateConfig, setAutoAnnotateConfig] = useState(null) function openBaseModelAutolabelModal(batch) { - const projectClasses = project?.classes?.map((c) => c.name) || [] - setBaseModelModalState({ - batch, - selectedClasses: [...projectClasses], - threshold: 0.35, - iouThreshold: 0.8, - }) + setAutoAnnotateConfig({ batch, project, engine: 'base_model' }) } async function resetAutoAnnotations(batch) { @@ -83,14 +94,12 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) { setBusyId(batch.id) try { const info = await api.inspectModel(file) - setAppendModalState({ + setAutoAnnotateConfig({ batch, - file, - filename: info.filename, - classes: info.classes || [], - selectedClasses: info.classes || [], - threshold: 0.35, - iouThreshold: 0.8, + project, + engine: 'custom', + customModelStagedPath: info.staged_path, + customModelClasses: info.classes || [] }) } catch (err) { onError(err.message) @@ -103,14 +112,7 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) { function openSam3AppendModal(batch) { setAppendChoiceBatch(null) - const projectClasses = project?.classes?.map((c) => c.name) || [] - setSam3AppendState({ - batch, - selectedClasses: [...projectClasses], - threshold: 0.35, - iouThreshold: 0.8, - }) - setCustomPromptInput('') + setAutoAnnotateConfig({ batch, project, engine: 'sam3' }) } async function deleteBatch(batch) { @@ -140,13 +142,63 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) { } } + // A batch needs frames to have anything to merge. It does not need *reviewed* + // frames: gating on `review.approved > 0` made the button permanently dead for + // an auto-annotated project, because auto-annotation never approves anything — + // that is the review stage's job. Unreviewed batches are selectable; Data Prep + // is where their frames get approved as-is, and it says so. + const approvable = batches.filter((batch) => (batch.frame_count ?? 0) > 0) + const approvableSelected = selectedIds.filter((id) => + approvable.some((batch) => batch.id === id)) + + // Merging is no longer possible from here: the selection goes to Data Prep, + // which is the gate that creates the dataset (REQ-131). + function prepareSelected() { + if (approvableSelected.length === 0) return + navigate(`/projects/${project.id}/data-prep?batches=${approvableSelected.join(',')}`) + } + if (batches.length === 0) return null return ( -
    - - + <> +
    +
    +

    Batches ({batches.length})

    +
    + {selectedIds.length > 0 && ( + + {approvableSelected.length} of {selectedIds.length} selected can be merged + + )} + +
    +
    +
    + + + @@ -159,6 +211,26 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) { return ( +
    + 0 && approvableSelected.length === approvable.length} + onChange={(event) => + setSelectedIds(event.target.checked ? approvable.map((b) => b.id) : []) + } + disabled={approvable.length === 0} + style={{ cursor: approvable.length === 0 ? 'not-allowed' : 'pointer' }} + /> + BatchRangeFramesReviewed ShapesStatus
    + 0 + ? `${batch.review.approved} approved frame(s) would be merged` + : `Not reviewed — its ${batch.review?.pending ?? batch.frame_count} frame(s) would be approved as-is and merged`} + onChange={(event) => + setSelectedIds((current) => + event.target.checked + ? [...current, batch.id] + : current.filter((id) => id !== batch.id)) + } + style={{ cursor: (batch.frame_count ?? 0) === 0 ? 'not-allowed' : 'pointer' }} + /> + editBatch(batch)} title="Click to rename batch"> {batch.date_label} · {batch.batch_label} @@ -178,14 +250,9 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
    -
    +
    {/* Choice Modal: SAM3 vs Custom YOLO */} {appendChoiceBatch && ( @@ -217,12 +285,31 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) { justifyContent: 'center', zIndex: 9999 }}>
    -

    Select Engine to Append Annotations

    +

    Select Engine to Auto-annotate

    - Choose how you want to detect and append new classes to batch {appendChoiceBatch.batch_label}: + Choose how you want to detect objects in batch {appendChoiceBatch.batch_label}. (All detections will be appended):

    + {/* Base Model Card */} + {project.base_model_path && ( +
    { openBaseModelAutolabelModal(appendChoiceBatch); setAppendChoiceBatch(null); }} + > +
    +

    📦 Project Base Model

    + Select > +
    +

    + Detect objects using the primary base model trained for this project. +

    +
    + )} {/* SAM3 Card */}
    )} - {/* SAM3 Append Modal */} - {sam3AppendState && ( -
    -
    -

    Append Annotations with SAM3

    -

    - Select target text prompts to detect with SAM3: -

    - -
    -
    - Confidence Threshold: - {sam3AppendState.threshold} -
    - setSam3AppendState({ ...sam3AppendState, threshold: parseFloat(e.target.value) })} - style={{ width: '100%', cursor: 'pointer' }} - /> -
    - -
    -
    - NMS IoU Threshold: - {sam3AppendState.iouThreshold ?? 0.8} -
    - setSam3AppendState({ ...sam3AppendState, iouThreshold: parseFloat(e.target.value) })} - style={{ width: '100%', cursor: 'pointer' }} - /> -
    - -
    - Target Prompts to Detect: -
    - {sam3AppendState.selectedClasses.map((clsName) => ( - - ))} -
    - - {/* Add Custom SAM3 Prompt */} -
    - setCustomPromptInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && customPromptInput.trim()) { - const val = customPromptInput.trim().toLowerCase() - if (!sam3AppendState.selectedClasses.includes(val)) { - setSam3AppendState({ - ...sam3AppendState, - selectedClasses: [...sam3AppendState.selectedClasses, val] - }) - } - setCustomPromptInput('') - } - }} - style={{ flex: 1, padding: '6px 10px', fontSize: '0.82rem', background: '#09090b', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, color: '#fff' }} - /> - -
    -
    - -
    - - -
    -
    -
    + {autoAnnotateConfig && ( + setAutoAnnotateConfig(null)} + onSuccess={() => { + setAutoAnnotateConfig(null) + onChanged() + }} + /> )} - - {/* YOLO Custom Model Append Modal */} - {appendModalState && ( -
    -
    -

    Append Annotations with Custom Model

    -

    - Model file: {appendModalState.filename} -

    - -
    -
    - Confidence Threshold: - {appendModalState.threshold} -
    - setAppendModalState({ ...appendModalState, threshold: parseFloat(e.target.value) })} - style={{ width: '100%', cursor: 'pointer' }} - /> -
    - -
    -
    - NMS IoU Threshold: - {appendModalState.iouThreshold ?? 0.8} -
    - setAppendModalState({ ...appendModalState, iouThreshold: parseFloat(e.target.value) })} - style={{ width: '100%', cursor: 'pointer' }} - /> -
    - -
    -
    - Select Classes to Append: - - {appendModalState.selectedClasses.length} of {appendModalState.classes.length} selected - -
    - - {appendModalState.classes.length === 0 ? ( -

    No embedded class names found in model file. All predictions will be appended.

    - ) : ( -
    - {appendModalState.classes.map((clsName) => { - const isChecked = appendModalState.selectedClasses.includes(clsName) - return ( - - ) - })} -
    - )} -
    - -
    - - -
    -
    -
    - )} - - {/* Base Model Auto-annotate Modal */} - {baseModelModalState && ( -
    -
    -

    Auto-annotate Batch (Base Model)

    -

    - Select target classes to detect using the project's base model: -

    - -
    -
    - Confidence Threshold: - {baseModelModalState.threshold} -
    - setBaseModelModalState({ ...baseModelModalState, threshold: parseFloat(e.target.value) })} - style={{ width: '100%', cursor: 'pointer' }} - /> -
    - -
    -
    - NMS IoU Threshold: - {baseModelModalState.iouThreshold ?? 0.8} -
    - setBaseModelModalState({ ...baseModelModalState, iouThreshold: parseFloat(e.target.value) })} - style={{ width: '100%', cursor: 'pointer' }} - /> -
    - -
    - Target Classes to Detect: -
    - {project?.classes?.map((cls) => { - const isChecked = baseModelModalState.selectedClasses.includes(cls.name) - return ( - - ) - })} -
    -
    - -
    - - -
    -
    -
    - )} -
    + ) } export default function LibraryPage({ projectId, onProject }) { const [project, setProject] = useState(null) const [dates, setDates] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [truckBusy, setTruckBusy] = useState(false) const [selected, setSelected] = useState(null) const [videos, setVideos] = useState(null) const [batches, setBatches] = useState([]) const [jobs, setJobs] = useState([]) const [error, setError] = useState('') + const loadBatches = useCallback(() => { api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {}) }, [projectId]) @@ -659,13 +391,13 @@ export default function LibraryPage({ projectId, onProject }) { useEffect(() => { let cancelled = false setError('') - Promise.all([api.getProject(projectId), api.listDates(projectId), api.listBatches(projectId), api.listJobs(projectId)]) + Promise.all([api.getProject(projectId), api.archiveCycles(projectId), api.listBatches(projectId), api.listJobs(projectId)]) .then(([loadedProject, library, batchPayload, jobPayload]) => { if (cancelled) return setProject(loadedProject) onProject?.(loadedProject) - setDates(library.dates) - setSelected(library.dates[0]?.date ?? null) + setDates(library.cycles) + setSelected(library.cycles[0]?.cycle ?? null) setBatches(batchPayload.batches) setJobs(jobPayload.jobs) }) @@ -677,7 +409,7 @@ export default function LibraryPage({ projectId, onProject }) { if (!selected) return let cancelled = false setVideos(null) - api.listVideos(projectId, selected) + api.archiveCycleVideos(projectId, selected) .then((payload) => !cancelled && setVideos(payload.videos)) .catch((exc) => !cancelled && setError(exc.message)) return () => { cancelled = true } @@ -716,92 +448,202 @@ export default function LibraryPage({ projectId, onProject }) { } if (!project) return

    Loading…

    + async function handleAutoAnnotateAllHeader() { + if (!window.confirm(`Start SAM3 Auto-Annotation in parallel for ALL ${batches.length} batch(es)?`)) return + try { + await Promise.all( + batches.map(b => api.startAutolabel(b.id, { + resume: false, + append: true, + engine: 'sam3', + threshold: 0.35, + iou_threshold: 0.0, + min_box_frac: 0.0, + target_class_names: project.classes.map(c => c.name) + })) + ) + loadBatches() + loadJobs() + } catch (exc) { + setError(exc.message) + } + } + return ( <> -
    +
    -

    {project.name}

    +

    Video Archive

    {project.video_root}

    +

    + Satu siklus = 06:00 sampai 05:59 pagi berikutnya, jadi selalu melewati tengah + malam dan mencakup dua tanggal. Urutan batch diambil dari jam yang tertulis di + video, bukan dari nama foldernya — file di disk tidak dipindah. +

    +
    - {batches.length > 0 && ( - <> -

    Batches

    - { loadBatches(); loadJobs(); }} onError={setError} /> -

    Archive

    - - )} {dates.length === 0 ? (

    No date folders in this archive yet. Expected layout: <date>/<batch>.mp4

    ) : ( -
    +
    -
    +
    {videos === null ? (

    Reading video metadata…

    ) : videos.length === 0 ? ( -

    No videos in {selected}.

    +

    Tidak ada rekaman di {labelSiklus(selected)}.

    ) : ( - - - - - - - - - - - - - {videos.map((item) => ( - - - - - - - - + <> +
    +

    + {labelSiklus(selected)} — {videos.length} rekaman +

    +
    + setSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '5px 10px 5px 30px', + fontSize: '0.8rem', + background: '#09090b', + border: '1px solid rgba(255,255,255,0.12)', + borderRadius: 6, + color: '#f4f4f5' + }} + /> +
    + +
    +
    +
    + +
    BatchDurationResolutionFPSSizeUsed -
    {item.batch_label}{formatDuration(item.duration)} - {item.width ? `${item.width}×${item.height}` : unreadable} - {item.fps || '—'}{megabytes(item.size)} - {item.used_count > 0 - ? {item.used_count} batch{item.used_count > 1 ? 'es' : ''} - : —} - - -
    + + + + + + + + + + + + - ))} - -
    BatchDirekamFileDurationResolutionFPSSizeTrukStatus
    +
  • + {item.batch_no ?? —} + + {jamRekam(item.started_at) ?? '—'} + + {item.batch_label} + {item.moved && ( + + {item.folder_date} + + )} + {formatDuration(item.duration)} + {item.width ? `${item.width}×${item.height}` : unreadable} + {item.fps || '—'}{megabytes(item.size)} + {item.truck_samples + ? (item.truck_hits > 0 + ? + {item.truck_hits}/{item.truck_samples} + + : + tanpa truk + ) + : belum dicek} + + {item.used_count > 0 + ? {item.used_count} batch{item.used_count > 1 ? 'es' : ''} + : Unused} + + +
    + )}
    @@ -809,3 +651,4 @@ export default function LibraryPage({ projectId, onProject }) { ) } + diff --git a/frontend/src/pages/LiveCountPage.jsx b/frontend/src/pages/LiveCountPage.jsx new file mode 100644 index 0000000..1524167 --- /dev/null +++ b/frontend/src/pages/LiveCountPage.jsx @@ -0,0 +1,412 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api' +import { AlertIcon } from '../components/Icons' + +/* Live counting test bench. + * + * Point a trained model at an RTSP camera (or a local file) and watch it count. + * It runs the same tracker, stabiliser and line-cross counter the production + * script uses, so a number here means the same thing there. What it leaves out + * is the batch lifecycle and its database — this answers "does the model count + * correctly", not "how many sacks today". */ + +// `live` fields can be moved during a session — placing a counting line means +// watching the stream while you move it, and a restart would throw the counts away. +const FIELDS = [ + { key: 'line_y', label: 'Counting line Y', min: 0, max: 720, step: 1, live: true, + hint: 'Sacks are counted as they cross this line. Click the video to place it.' }, + { key: 'line_x_start', label: 'Line start X', min: 0, max: 1280, step: 1, live: true, + hint: 'Ignore anything left of this.' }, + { key: 'line_x_end', label: 'Line end X', min: 0, max: 1280, step: 1, live: true, + hint: 'Ignore anything right of this.' }, + { key: 'margin', label: 'Band margin (px)', min: 0, max: 120, step: 1, + hint: 'Dead band around the line, so jitter alone never counts.' }, + { key: 'entry_travel_min', label: 'Entry travel min (px)', min: 0, max: 200, step: 1, + hint: 'A track must move this far from where it first appeared before it can count. ' + + 'Raise it to kill ghost boxes that blink into existence next to the line.' }, + { key: 'handoff_radius', label: 'Hand-off radius (px)', min: 0, max: 300, step: 5, + hint: 'When a track dies, a new track born this close to where it was heading inherits ' + + 'its history — this is what stops an ID switch at the line losing the count. ' + + 'The most sensitive dial here: too large and unrelated sacks adopt each other. ' + + 'Calibrate against a clip with a known count.' }, + { key: 'unload_confirm_frames', label: 'Unload confirm (frames)', min: 1, max: 15, step: 1, + hint: 'Frames a sack must stay above the band before it counts as unloaded. Stops a ' + + 'worker repositioning a sack from cancelling a real count.' }, + { key: 'min_area_scale', label: 'Min area scale', min: 0, max: 2, step: 0.1, + hint: 'Perspective-aware size gate: boxes too small for their depth are fragments, not ' + + 'sacks. 0 turns it off.' }, + { key: 'conf', label: 'Confidence', min: 0.05, max: 0.95, step: 0.05, + hint: 'Detector threshold.' }, +] + +export default function LiveCountPage({ projectId, onProject }) { + const [models, setModels] = useState([]) + const [modelPath, setModelPath] = useState('') + // Archive video by default: a local file decodes at ~100 fps, an RTSP camera + // at ~6 because OpenCV decodes 1080p on the CPU. Testing counting accuracy is + // far quicker against a file. + const [mode, setMode] = useState('file') + const [source, setSource] = useState('rtsp://192.168.192.96:8554/cam') + const [dates, setDates] = useState([]) + const [date, setDate] = useState('') + const [videos, setVideos] = useState([]) + const [videoRel, setVideoRel] = useState('') + // Defaults are the settings that were dialled in against the real camera — + // a fresh session starts where the last tuning session left off. + const [cfg, setCfg] = useState({ + line_y: 266, line_x_start: 469, line_x_end: 910, + margin: 5, dedup_radius: 60, conf: 0.35, + entry_travel_min: 60, handoff_radius: 100, unload_confirm_frames: 3, + min_area_scale: 1.0, + }) + const [status, setStatus] = useState({ running: false }) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [streamKey, setStreamKey] = useState(0) + const [placing, setPlacing] = useState('line_y') + const pollRef = useRef(null) + + useEffect(() => { + api.getProject(projectId).then((p) => onProject?.(p)).catch(() => {}) + api.liveCountModels(projectId) + .then((payload) => { + setModels(payload.models) + if (payload.models.length) setModelPath(payload.models[0].path) + }) + .catch((exc) => setError(exc.message)) + api.listDates(projectId) + .then((payload) => { + setDates(payload.dates) + if (payload.dates.length) setDate(payload.dates[0].date) + }) + .catch(() => {}) + }, [projectId]) + + useEffect(() => { + if (!date) return + setVideoRel('') + api.listVideos(projectId, date) + .then((payload) => { + setVideos(payload.videos) + if (payload.videos.length) setVideoRel(payload.videos[0].rel) + }) + .catch(() => setVideos([])) + }, [projectId, date]) + + const poll = useCallback(() => { + api.liveCountStatus() + .then((s) => { + setStatus(s) + if (s.error) setError(s.error) + }) + .catch(() => {}) + }, []) + + useEffect(() => { + poll() + pollRef.current = setInterval(poll, 1000) + return () => clearInterval(pollRef.current) + }, [poll]) + + async function start() { + setError('') + setBusy(true) + try { + const picked = mode === 'file' + ? { source_rel: videoRel } + : { source } + const s = await api.liveCountStart(projectId, { ...picked, model_path: modelPath, ...cfg }) + setStatus(s) + if (s.error) setError(s.error) + else setStreamKey((n) => n + 1) + } catch (exc) { + setError(exc.message) + } finally { + setBusy(false) + } + } + + /* While a session runs the backend owns the line, so a change has to be sent + * there. Fire-and-forget: the next status poll confirms it, and a dropped + * request during a fast drag is corrected by the one after it. */ + function setField(key, value) { + const next = { ...cfg, [key]: value } + setCfg(next) + const field = FIELDS.find((f) => f.key === key) + if (running && field?.live) { + api.liveCountMoveLine({ + line_y: next.line_y, + line_x_start: next.line_x_start, + line_x_end: next.line_x_end, + }).catch(() => {}) + } + } + + // Click on the stream to place whichever edge is armed. Placing by eye beats + // guessing a pixel value on a slider. + function placeOnClick(event) { + if (!running) return + const rect = event.currentTarget.getBoundingClientRect() + if (!rect.height || !rect.width) return + if (placing === 'line_y') { + const y = Math.round(((event.clientY - rect.top) / rect.height) * 720) + setField('line_y', Math.max(0, Math.min(720, y))) + } else { + const x = Math.round(((event.clientX - rect.left) / rect.width) * 1280) + setField(placing, Math.max(0, Math.min(1280, x))) + } + } + + async function stop() { + setBusy(true) + try { + setStatus(await api.liveCountStop()) + } catch (exc) { + setError(exc.message) + } finally { + setBusy(false) + } + } + + const running = Boolean(status.running) + + return ( + <> +
    +
    +

    Live counting test

    +

    + Run a trained model against a camera and watch it count, before trusting it in production. +

    +
    +
    + + {error && ( +

    + {error} + +

    + )} + +
    +
    +
    + +
    + {[['file', 'Archive video'], ['stream', 'RTSP stream']].map(([id, label]) => ( + + ))} +
    + + {mode === 'file' ? ( + <> + + +

    + Decodes at ~100 fps, so a whole video is checked in seconds. +

    + + ) : ( + <> + setSource(e.target.value)} + disabled={running} + className="mono" + style={{ width: '100%', fontSize: '0.8rem' }} + /> +

    + Real time, but capped near 6 fps — OpenCV decodes this camera's 1080p on the CPU. +

    + + )} +
    + +
    + + +
    + + {FIELDS.map((f) => { + const locked = running && !f.live + return ( +
    +
    + + {f.label} + {running && f.live && ( + live + )} + + {cfg[f.key]} +
    + setField(f.key, Number(e.target.value))} + style={{ width: '100%', cursor: locked ? 'not-allowed' : 'pointer' }} + /> +

    {f.hint}

    +
    + ) + })} + +
    + {running ? ( + + ) : ( + + )} +
    +

    + Holds the GPU while running, so training and auto-annotation wait their turn. + The line can be moved mid-session; the other settings need a restart. +

    +
    + +
    +
    + + + + + + + + +
    + + {running && status.trace_path && ( +

    + Every finished track and the reason it did or did not count is being written to{' '} + {status.trace_path} — that file is what separates a + model miss from a tracker miss from a counter miss. +

    + )} + + {running && ( +
    + Click the video to place: + {[ + ['line_y', 'Counting line'], + ['line_x_start', 'Left edge'], + ['line_x_end', 'Right edge'], + ].map(([key, label]) => ( + + ))} + + Shaded areas are ignored — {status.ignored ?? 0} detection(s) outside the region now. + +
    + )} + +
    + {running ? ( + Live counting + ) : ( +

    + Not running. Set a source and press Start. +

    + )} +
    + + {(status.events?.length ?? 0) > 0 && ( +
    +

    Recent counts

    +
    + {status.events.slice().reverse().map((ev, i) => ( + + #{ev.track_id} · {ev.direction} · {ev.at}s + + ))} +
    +
    + )} +
    +
    + + ) +} + +function Stat({ label, value, accent }) { + return ( +
    + + {label} + +
    {value}
    +
    + ) +} diff --git a/frontend/src/pages/ModelsPage.jsx b/frontend/src/pages/ModelsPage.jsx index 2ec8ed4..0106f56 100644 --- a/frontend/src/pages/ModelsPage.jsx +++ b/frontend/src/pages/ModelsPage.jsx @@ -90,20 +90,29 @@ export default function ModelsPage({ projectId, onProject }) { const [job, setJob] = useState(null) const [error, setError] = useState('') - const [selectedBatchIds, setSelectedBatchIds] = useState([]) const [selectedClassIds, setSelectedClassIds] = useState([]) + const [datasetRows, setDatasetRows] = useState([]) + const [selectedDatasetIds, setSelectedDatasetIds] = useState([]) + const [baseRows, setBaseRows] = useState([]) + // Opt-in, never preselected: a base dataset is somebody else's labels. + const [selectedBaseIds, setSelectedBaseIds] = useState([]) + const [combined, setCombined] = useState(null) const load = useCallback(async () => { const [loadedProject, loadedSummary, modelPayload, hw, jobsPayload] = await Promise.all([ api.getProject(projectId), api.datasetSummary(projectId), api.listModels(projectId), api.hardware(), api.listJobs(projectId), ]) + const datasetPayload = await api.listDatasets(projectId) + setDatasetRows(datasetPayload.datasets) + setSelectedDatasetIds(datasetPayload.datasets.map((d) => d.id)) + const basePayload = await api.listBaseDatasets(projectId).catch(() => ({ base_datasets: [] })) + setBaseRows(basePayload.base_datasets) setProject(loadedProject) onProject?.(loadedProject) setSummary(loadedSummary) setModels(modelPayload.models) setHardware(hw) - setSelectedBatchIds(loadedSummary.batches.map((b) => b.id)) setSelectedClassIds(loadedProject.classes?.map((c) => c.class_id) || []) const activeJob = jobsPayload.jobs?.find((j) => ['running', 'queued'].includes(j.status)) if (activeJob) setJob(activeJob) @@ -111,6 +120,15 @@ export default function ModelsPage({ projectId, onProject }) { useEffect(() => { load().catch((exc) => setError(exc.message)) }, [load]) + useEffect(() => { + if (selectedDatasetIds.length === 0) { setCombined(null); return } + let cancelled = false + api.combinePreview(projectId, selectedDatasetIds) + .then((payload) => !cancelled && setCombined(payload)) + .catch(() => {}) + return () => { cancelled = true } + }, [projectId, selectedDatasetIds]) + useEffect(() => { if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) { if (job?.status === 'done') load().catch(() => {}) @@ -127,7 +145,10 @@ export default function ModelsPage({ projectId, onProject }) { try { setJob(await api.startTraining(projectId, { epochs: Number(epochs), - batch_ids: selectedBatchIds.length > 0 ? selectedBatchIds : null, + dataset_ids: selectedDatasetIds, + base_dataset_ids: selectedBaseIds, + // No batch filter: the chosen datasets already carry their batches, and + // filtering again could only subtract from them. class_ids: selectedClassIds.length > 0 ? selectedClassIds : null, })) } catch (exc) { @@ -135,20 +156,6 @@ export default function ModelsPage({ projectId, onProject }) { } } - const toggleBatchSelect = (id) => { - setSelectedBatchIds((prev) => - prev.includes(id) ? prev.filter((bId) => bId !== id) : [...prev, id] - ) - } - - const toggleSelectAllBatches = () => { - if (selectedBatchIds.length === summary.batches.length) { - setSelectedBatchIds([]) - } else { - setSelectedBatchIds(summary.batches.map((b) => b.id)) - } - } - const toggleClassSelect = (classId) => { setSelectedClassIds((prev) => prev.includes(classId) ? prev.filter((cId) => cId !== classId) : [...prev, classId] @@ -171,75 +178,65 @@ export default function ModelsPage({ projectId, onProject }) {

    - Download dataset + Manage datasets
    {error &&

    {error}

    } -
    -

    Base Model Configuration

    -

    Upload a base YOLO model checkpoint (.pt) to use for fine-tuning baseline and auto-annotation:

    - -
    -

    Base Model

    -

    - Used as fine-tuning starting point and baseline benchmark. -

    -
    - Status: {project.base_model_path ? 'Custom model.pt loaded' : 'Default yolo11n.pt'} -
    -
    - Base Model Classes: {project.classes?.map((c) => c.name).join(', ')} -
    - { - const file = e.target.files?.[0] - if (!file) return - try { - await api.uploadBaseModel(projectId, file) - load() - } catch (err) { - setError(err.message) - } - }} - /> - -
    -
    - -
    - -
    - {models.length === 0 && !running && ( -

    No trained versions yet.

    - )} - {models.map((version) => ( - - ))} -
    - - +
    +
    ) } + diff --git a/frontend/src/pages/ReviewPage.jsx b/frontend/src/pages/ReviewPage.jsx index 06e89e4..c45f3f8 100644 --- a/frontend/src/pages/ReviewPage.jsx +++ b/frontend/src/pages/ReviewPage.jsx @@ -14,6 +14,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } const [index, setIndex] = useState(0) const [annotations, setAnnotations] = useState([]) const [selectedId, setSelectedId] = useState(null) + const [mode, setMode] = useState('draw') + const [markedIds, setMarkedIds] = useState([]) const [activeClass, setActiveClass] = useState(0) const [assistMode, setAssistMode] = useState(false) const [busy, setBusy] = useState(false) @@ -78,6 +80,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } let cancelled = false api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message)) setSelectedId(null) + setMarkedIds([]) return () => { cancelled = true } }, [frame?.id]) @@ -124,19 +127,84 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } return } if (!commit) return - const current = annotations.find((row) => row.id === id) - if (!current) return - try { await api.patchAnnotation(id, { geometry: current.geometry }) } catch (exc) { setError(exc.message) } + const previous = annotations.find((row) => row.id === id) + if (!previous) return + // A commit may carry its own geometry (delete-vertex sends the shortened + // polygon); falling back to the row's geometry covers drag/resize, which + // already wrote through the {local:true} path. + const next = geometry ?? previous.geometry + setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry: next } : row))) + try { + await api.patchAnnotation(id, { geometry: next }) + } catch (exc) { + setAnnotations((rows) => rows.map((row) => (row.id === id ? previous : row))) + setError(exc.message) + } } - const removeSelected = useCallback(async () => { - if (selectedId == null || !frame) return - const id = selectedId + const removeAnnotation = useCallback(async (id) => { + if (id == null || !frame) return + const previous = annotations.find((row) => row.id === id) + if (!previous) return setAnnotations((rows) => rows.filter((row) => row.id !== id)) - setSelectedId(null) + setSelectedId((current) => (current === id ? null : current)) setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: Math.max(0, (row.annotation_count ?? 1) - 1) } : row))) - try { await api.deleteAnnotation(id) } catch (exc) { setError(exc.message) } - }, [selectedId, frame]) + try { + await api.deleteAnnotation(id) + } catch (exc) { + setAnnotations((rows) => [...rows, previous]) + setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: (row.annotation_count ?? 0) + 1 } : row))) + setError(exc.message) + } + }, [annotations, frame]) + + const removeSelected = useCallback(() => removeAnnotation(selectedId), [removeAnnotation, selectedId]) + + const onMarquee = useCallback((ids, { add = false, toggle = false } = {}) => { + setMarkedIds((current) => { + if (toggle) { + const id = ids[0] + return current.includes(id) ? current.filter((row) => row !== id) : [...current, id] + } + if (!add) return ids + return [...new Set([...current, ...ids])] + }) + }, []) + + const markAll = useCallback(() => setMarkedIds(annotations.map((row) => row.id)), [annotations]) + + const removeMarked = useCallback(async () => { + if (!markedIds.length || !frame) return + if (!window.confirm(`Delete ${markedIds.length} selected shape(s) on this frame?`)) return + const previous = annotations + const doomed = new Set(markedIds) + setAnnotations((rows) => rows.filter((row) => !doomed.has(row.id))) + setMarkedIds([]) + patchFrameLocally(frame.id, { + annotation_count: Math.max(0, (frame.annotation_count ?? doomed.size) - doomed.size), + }) + try { + await api.bulkDeleteAnnotations([...doomed]) + setBatch(await api.getBatch(batchId)) + } catch (exc) { + setAnnotations(previous) + patchFrameLocally(frame.id, { annotation_count: previous.length }) + setError(exc.message) + } + }, [markedIds, annotations, frame, batchId]) + + const reclassMarked = useCallback(async (classId) => { + if (!markedIds.length) return + const previous = annotations + const targets = new Set(markedIds) + setAnnotations((rows) => rows.map((row) => (targets.has(row.id) ? { ...row, class_id: classId } : row))) + try { + await api.bulkReclassAnnotations([...targets], classId) + } catch (exc) { + setAnnotations(previous) + setError(exc.message) + } + }, [markedIds, annotations]) const reclass = useCallback(async (classId) => { setActiveClass(classId) @@ -147,8 +215,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } } catch (exc) { setError(exc.message) } }, [selectedId]) - async function approveBatch() { - try { await api.approveBatch(batchId); navigate(`/projects/${batch.project_id}/models`) } catch (exc) { setError(exc.message) } + // Review signs frames off; it no longer merges. Data Prep is the gate that + // turns a selection of batches into a dataset (REQ-131). + function toDataPrep() { + navigate(`/projects/${batch.project_id}/data-prep?batches=${batchId}`) } async function jumpToPending() { @@ -169,17 +239,92 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } } }, [frames, index]) + const copyPrevious = useCallback(async () => { + if (!frames?.length || index === 0 || !frame) return + const prevFrame = frames[index - 1] + if (!prevFrame || (prevFrame.annotation_count ?? 0) === 0) return + try { + const payload = await api.frameAnnotations(prevFrame.id) + if (!payload.annotations?.length) return + + const created = await Promise.all( + payload.annotations.map(a => api.addAnnotation(frame.id, { class_id: a.class_id, geometry: a.geometry })) + ) + + setAnnotations(rows => [...rows, ...created]) + patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + created.length }) + if (created.length > 0) setSelectedId(created[0].id) + } catch (exc) { + setError(exc.message) + } + }, [frames, index, frame]) + + const trackForward = useCallback(async () => { + if (selectedId == null || !frames?.length) return + const current = annotations.find((a) => a.id === selectedId) + if (!current) return + + let box = null + if (current.geometry?.type === 'polygon' && current.geometry.coordinates?.[0]) { + const pts = current.geometry.coordinates[0] + let minX = 1, minY = 1, maxX = 0, maxY = 0 + for (const [x, y] of pts) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + box = [minX, minY, maxX, maxY] + } + if (!box) return + + setBusy(true) + setError('') + try { + let updatedCount = 0 + for (let i = 1; i <= 5; i++) { + const nextIdx = index + i + if (nextIdx >= frames.length) break + const targetFrame = frames[nextIdx] + + await api.assist(targetFrame.id, { box, class_id: current.class_id }) + updatedCount++ + patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 }) + } + } catch (exc) { + setError(exc.message) + } finally { + setBusy(false) + } + }, [annotations, selectedId, index, frames]) + const stateRef = useRef({}) - stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode } + stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds } useEffect(() => { function onKeyDown(event) { if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return - const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode } = stateRef.current + // Ctrl/Cmd/Alt combos belong to the browser and the OS: without this, + // Ctrl+S approves the frame and Ctrl+A/C/X/N/T all fire review actions. + if (event.ctrlKey || event.metaKey || event.altKey) return + const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds } = stateRef.current const key = event.key - const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N'].includes(key) + const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N', 'c', 'C', 't', 'T', 'v', 'V', 'Escape'].includes(key) if (isShortcutKey) { event.preventDefault(); event.stopPropagation() } + // In select mode the marquee owns Delete and the digits, otherwise a + // 40-box selection would be thrown away by one keystroke meant for it. + if (mode === 'select') { + if (key === 'v' || key === 'V') { setMode?.('draw'); setMarkedIds?.([]); return } + if (key === 'Escape') { setMarkedIds?.([]); return } + if ((key === 'Delete' || key === 'Backspace') && markedIds?.length) { removeMarked?.(); return } + if (/^[1-9]$/.test(key) && markedIds?.length) { + const classId = Number(key) - 1 + if (project && classId < project.classes.length) reclassMarked?.(classId) + return + } + } else if (key === 'v' || key === 'V') { setMode?.('select'); return } + if (key === 's' || key === 'S') setAssistMode?.(true) else if (key === 'ArrowLeft') setIndex((i) => Math.max(0, i - 1)) else if (key === 'ArrowRight') setIndex((i) => Math.min((frames?.length || 1) - 1, i + 1)) @@ -187,6 +332,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } else if (key === 'x' || key === 'X') setStatus?.('rejected') else if (key === 'u' || key === 'U') jumpToPending?.() else if (key === 'n' || key === 'N') jumpToNextAnnotated?.() + else if (key === 'c' || key === 'C') copyPrevious?.() + else if (key === 't' || key === 'T') trackForward?.() else if (key === 'Delete' || key === 'Backspace') removeSelected?.() else if (/^[1-9]$/.test(key)) { const classId = Number(key) - 1 @@ -249,10 +396,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } @@ -283,13 +430,82 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } activeClass={activeClass} assistMode={assistMode} classes={classesList} + mode={mode} + selectedIds={markedIds} onSelect={setSelectedId} onCreate={createShape} onUpdate={updateShape} onAssist={assist} + onMarquee={onMarquee} /> )} +
    +
    + {[['draw', 'Draw'], ['select', 'Select']].map(([value, label]) => ( + + ))} +
    + {mode === 'select' && ( + <> + + + {markedIds.length + ? `${markedIds.length} selected · Shift-drag adds · Esc clears` + : 'Drag across the boxes you want'} + + + )} +
    + + {mode === 'select' && markedIds.length > 0 && ( +
    + + {markedIds.length} shape{markedIds.length === 1 ? '' : 's'} + + reclass to + {classesList.map((item) => ( + + ))} + + + +
    + )} +
    @@ -306,6 +522,26 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } > 🏷️ Next Shape [N] + + {busy && asking SAM3…} @@ -327,7 +563,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } annotations={annotations} selectedId={selectedId} setSelectedId={setSelectedId} - removeSelected={removeSelected} + removeAnnotation={removeAnnotation} project={project} jumpToNextAnnotated={jumpToNextAnnotated} batchAnnotationCount={batch?.annotation_count ?? 0} diff --git a/frontend/src/roboflow.css b/frontend/src/roboflow.css index 7f0bba2..0b45711 100644 --- a/frontend/src/roboflow.css +++ b/frontend/src/roboflow.css @@ -2,6 +2,7 @@ .roboflow-layout { display: flex; + flex-direction: column; width: 100vw; height: 100vh; overflow: hidden; @@ -9,95 +10,97 @@ color: var(--text, #f3f4f6); } -.roboflow-sidebar { - width: 260px; - background: rgba(17, 24, 39, 0.65); - border-right: 1px solid rgba(255, 255, 255, 0.12); - display: flex; - flex-direction: column; - padding: 16px 0; - flex-shrink: 0; - transition: width 0.2s ease, padding 0.2s ease; -} - -.roboflow-sidebar.collapsed { - width: 64px; -} - -.sidebar-header { - padding: 0 20px; - margin-bottom: 24px; +.roboflow-topbar { + height: 48px; + background: rgba(17, 24, 39, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.12); display: flex; align-items: center; justify-content: space-between; + padding: 0 16px; + flex-shrink: 0; + z-index: 100; } -.roboflow-sidebar.collapsed .sidebar-header { - padding: 0 12px; - justify-content: center; -} - -.sidebar-collapse-btn { - background: none; - border: none; - color: var(--text-faint, #9ca3af); - cursor: pointer; - padding: 4px; - font-size: 12px; - border-radius: 4px; - transition: background 0.15s, color 0.15s; -} - -.sidebar-collapse-btn:hover { - background: rgba(255, 255, 255, 0.1); - color: var(--text); -} - -.sidebar-logo { - font-weight: 700; - font-size: 16px; - color: var(--text); - white-space: nowrap; -} - -.roboflow-sidebar.collapsed .sidebar-item { - padding: 12px 0; - text-align: center; - display: flex; - justify-content: center; -} - -.sidebar-icon { - font-size: 16px; - margin-right: 8px; -} - -.roboflow-sidebar.collapsed .sidebar-icon { - margin-right: 0; -} - -.sidebar-section { - margin-bottom: 24px; -} - -.sidebar-section-title { - padding: 0 20px; - font-size: 11px; - font-weight: 600; - color: var(--text-faint, #9ca3af); - margin-bottom: 8px; - letter-spacing: 0.05em; -} - -.sidebar-item { +.topbar-logo { display: flex; align-items: center; - padding: 8px 20px; - color: var(--text-muted, #d1d5db); + padding: 4px 8px; +} + +.topbar-nav { + display: flex; + align-items: center; + gap: 4px; +} + +.topbar-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + color: #a1a1aa; text-decoration: none; - font-size: 13px; + font-size: 0.82rem; + font-weight: 500; + border-radius: 6px; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: all 0.15s ease; +} + +.topbar-item:hover { + color: #f4f4f5; + background: rgba(255, 255, 255, 0.08); +} + +.topbar-item.active { + color: #ffffff; + background: rgba(255, 255, 255, 0.14); + font-weight: 600; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-health { + display: flex; + align-items: center; + gap: 6px; +} + +.health-badge { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + color: #a1a1aa; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.health-badge.ready { + background: rgba(34, 197, 94, 0.12); + color: #4ade80; + border-color: rgba(34, 197, 94, 0.25); +} + +.topbar-theme-toggle { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #f4f4f5; + padding: 5px 8px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + transition: background 0.15s; +} + +.topbar-theme-toggle:hover { + background: rgba(255, 255, 255, 0.16); } .sidebar-item:hover { @@ -154,12 +157,16 @@ .roboflow-main { flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; + height: calc(100vh - 48px); + max-height: calc(100vh - 48px); + overflow-y: auto !important; + overflow-x: hidden; + padding: 16px 20px; position: relative; + box-sizing: border-box; } + /* Models page engine cards */ .select-engine-section { margin-bottom: 32px; diff --git a/requirements.txt b/requirements.txt index a2b6a25..cf20caf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,6 @@ pycocotools # sam3/model_builder.py still imports pkg_resources, which setuptools 81+ dropped setuptools<81 +psutil +shapely +lap diff --git a/scratch/refactor.py b/scratch/refactor.py new file mode 100644 index 0000000..f0c95a3 --- /dev/null +++ b/scratch/refactor.py @@ -0,0 +1,69 @@ +import re + +def main(): + path = '/home/asus/reTraining/frontend/src/pages/LibraryPage.jsx' + with open(path, 'r') as f: + content = f.read() + + # 1. Add import AutoAnnotateModal + if "import AutoAnnotateModal" not in content: + content = content.replace("import React,", "import AutoAnnotateModal from '../components/AutoAnnotateModal'\nimport React,") + + # 2. Replace state definitions + # Replace appendModalState, sam3AppendState, customPromptInput, baseModelModalState with autoAnnotateConfig + content = re.sub(r'const \[appendModalState.*?\n', '', content) + content = re.sub(r'const \[sam3AppendState.*?\n', '', content) + content = re.sub(r'const \[customPromptInput.*?\n', '', content) + content = re.sub(r'const \[baseModelModalState.*?\n', ' const [autoAnnotateConfig, setAutoAnnotateConfig] = useState(null)\n', content) + + # 3. Replace openBaseModelAutolabelModal + base_model_func = """ function openBaseModelAutolabelModal(batch) { + setAutoAnnotateConfig({ batch, project, engine: 'base_model' }) + }""" + content = re.sub(r' function openBaseModelAutolabelModal.*?\}', base_model_func, content, flags=re.DOTALL) + + # 4. Replace openSam3AppendModal + sam3_func = """ function openSam3AppendModal(batch) { + setAppendChoiceBatch(null) + setAutoAnnotateConfig({ batch, project, engine: 'sam3' }) + }""" + content = re.sub(r' function openSam3AppendModal.*?\}', sam3_func, content, flags=re.DOTALL) + + # 5. Modify openFilePickerForYolo + # We replace from "const info = await api.inspectModel(file)" to the end of the try block. + # Actually let's just replace setAppendModalState( ... ) + yolo_replacement = """ setAutoAnnotateConfig({ + batch, + project, + engine: 'custom', + customModelStagedPath: info.staged_path, + customModelClasses: info.classes || [] + })""" + content = re.sub(r' setAppendModalState\(\{[\s\S]*?\}\)', yolo_replacement, content, flags=re.DOTALL) + + # 6. Remove all 3 modals from the JSX, replace with AutoAnnotateModal + + # Let's find the start of SAM3 Append Modal + sam3_idx = content.find("{/* SAM3 Append Modal */}") + if sam3_idx != -1: + # Find the end of the Fragment "" + end_idx = content.find(" \n )\n}", sam3_idx) + if end_idx != -1: + new_jsx = """ {autoAnnotateConfig && ( + setAutoAnnotateConfig(null)} + onSuccess={() => { + setAutoAnnotateConfig(null) + onChanged() + }} + /> + )} +""" + content = content[:sam3_idx] + new_jsx + content[end_idx:] + + with open(path, 'w') as f: + f.write(content) + +if __name__ == '__main__': + main() diff --git a/scripts/transcode_archive.py b/scripts/transcode_archive.py new file mode 100755 index 0000000..3df96e5 --- /dev/null +++ b/scripts/transcode_archive.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Transcode video archive files to browser-compatible H.264 MP4 format.""" + +import os +import sys +import subprocess +import shutil +from pathlib import Path + +VIDEO_EXTS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v"} + +def probe_codec(file_path: Path) -> str: + cmd = [ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=codec_name", + "-of", "default=noprint_wrappers=1:nokey=1", str(file_path) + ] + res = subprocess.run(cmd, capture_output=True, text=True) + return res.stdout.strip().lower() + +def transcode_video(file_path: Path): + tmp_path = file_path.with_name(f"_transcoded_{file_path.stem}.mp4") + cmd = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-i", str(file_path), + "-c:v", "libx264", "-preset", "fast", "-crf", "22", + "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", + str(tmp_path) + ] + res = subprocess.run(cmd) + if res.returncode == 0 and tmp_path.exists() and tmp_path.stat().st_size > 0: + # Save as .mp4 + target_path = file_path.with_suffix(".mp4") + if target_path != file_path and file_path.exists(): + file_path.unlink() + shutil.move(str(tmp_path), str(target_path)) + print(f" ✅ Transcoded -> {target_path}") + else: + if tmp_path.exists(): + tmp_path.unlink() + print(f" ❌ Failed to transcode {file_path}") + +def process_archive(archive_dir: Path): + if not archive_dir.exists(): + print(f"Archive directory {archive_dir} does not exist.") + return + + print(f"Scanning {archive_dir} for non-H.264 videos...") + for root, _, files in os.walk(archive_dir): + for file in sorted(files): + file_path = Path(root) / file + if file_path.suffix.lower() in VIDEO_EXTS and not file.startswith("_transcoded_"): + codec = probe_codec(file_path) + if not codec: + print(f"⚠️ Could not probe codec or unreadable: {file_path}") + continue + if codec != "h264": + print(f"🔄 Converting {file_path} (current codec: {codec})...") + transcode_video(file_path) + else: + print(f" ✓ Already H.264: {file_path}") + +if __name__ == "__main__": + target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/archive") + process_archive(target) diff --git a/start.sh b/start.sh index 583f78d..a2b9fe4 100755 --- a/start.sh +++ b/start.sh @@ -50,16 +50,25 @@ else echo "ℹ️ No NVIDIA GPU detected. Running in CPU-only mode." fi -echo "📦 Starting containers..." -docker compose up -d --build +if docker info &> /dev/null; then + echo "📦 Starting containers..." + docker compose up -d --build + PORT=${WEB_PORT:-8080} +else + echo "⚠️ Docker is not accessible (or requires root permissions)." + echo "🚀 Starting app in local development mode..." + uv run uvicorn backend.main:app --host 0.0.0.0 --port 8000 & + npm --prefix frontend run dev -- --host 0.0.0.0 & + PORT=5173 +fi # Get local IP for convenience LOCAL_IP=$(hostname -I | awk '{print $1}' || echo "localhost") -PORT=${WEB_PORT:-8080} echo "" echo "==========================================================" -echo "✅ App is successfully running!" +echo "✅ App is running!" echo "🌐 Access it locally at: http://localhost:$PORT" echo "📱 Access it on your network at: http://$LOCAL_IP:$PORT" echo "==========================================================" +