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.
182 lines
16 KiB
Markdown
182 lines
16 KiB
Markdown
# 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/<slug>/batches/<id>/`, 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}/<batch>__<stem>.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/<slug>/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 `<a href download>`, 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 '<id>__*'` 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.
|