feat: reveal all hidden shapes with Shift+H, release SAM3 VRAM on idle and on demand (REQ-191, REQ-192)

This commit is contained in:
asus committed 2026-10-07 11:55:00 +07:00
1 parent f5be7880b0
commit fd608848bf
16 files changed
+288 -18

No files matched your search

+7
View File
@@ -39,6 +39,13 @@ API_PORT=9010
# dev server from another device. # dev server from another device.
CORS_ORIGINS=http://localhost:5173,http://localhost:9000,http://localhost:9010,http://localhost:8080,http://localhost:8000 CORS_ORIGINS=http://localhost:5173,http://localhost:9000,http://localhost:9010,http://localhost:8080,http://localhost:8000
# ---- GPU --------------------------------------------------------------------
# Seconds SAM3 may sit unused before the app drops it and gives the VRAM back
# (REQ-192). Default 1200 (20 min) when unset; 0 keeps it resident forever.
# The next SAM3 call reloads it from cache in ~12 seconds either way.
# SAM3_IDLE_UNLOAD_S=1200
# ---- recorder (algoritma-batch/batch_video_cropper.py) ----------------------- # ---- recorder (algoritma-batch/batch_video_cropper.py) -----------------------
# Only needed if you run the 24/7 truck-session recorder. It reads the RTSP # Only needed if you run the 24/7 truck-session recorder. It reads the RTSP
# stream to detect sessions, then downloads each session from the recording # stream to detect sessions, then downloads each session from the recording
+25
View File
@@ -0,0 +1,25 @@
"""SAM3 engine control routes (REQ-192)."""
from fastapi import APIRouter, HTTPException
from backend import jobs
router = APIRouter(prefix="/api/sam3", tags=["sam3"])
@router.post("/release")
def release() -> dict:
"""Free the SAM3 model's VRAM now — never while the card is busy.
The same non-blocking `gpu_lock` rule as an interactive assist (REQ-065):
a job or assist in flight wins, and the caller is told to try again.
"""
if not jobs.gpu_lock.acquire(blocking=False):
raise HTTPException(409, "GPU busy — assist or job in flight, try again in a moment")
try:
from backend import sam3_engine
released = sam3_engine.release_engine()
return {"released": released, "model_loaded": sam3_engine.engine_is_loaded()}
finally:
jobs.gpu_lock.release()
+3 -1
View File
@@ -17,7 +17,8 @@ from fastapi.middleware.cors import CORSMiddleware
from backend import archive_index, config, db, jobs from backend import archive_index, config, db, jobs
from backend.api import (batches, counting_bench as counting_bench_routes, from backend.api import (batches, counting_bench as counting_bench_routes,
datasets as dataset_routes, jobs as job_routes, datasets as dataset_routes, jobs as job_routes,
live_count as live_count_routes, models, projects, review, triage) live_count as live_count_routes, models, projects, review,
sam3, triage)
@asynccontextmanager @asynccontextmanager
@@ -47,6 +48,7 @@ app.add_middleware(
app.include_router(projects.router) app.include_router(projects.router)
app.include_router(batches.router) app.include_router(batches.router)
app.include_router(review.router) app.include_router(review.router)
app.include_router(sam3.router)
app.include_router(models.router) app.include_router(models.router)
app.include_router(job_routes.router) app.include_router(job_routes.router)
app.include_router(triage.router) app.include_router(triage.router)
+66 -4
View File
@@ -1,7 +1,9 @@
"""SAM3 text-prompted detection, wrapped for reuse across labeling jobs. """SAM3 text-prompted detection, wrapped for reuse across labeling jobs.
The model is expensive to build (weights come from the gated HuggingFace repo The model is expensive to build (weights come from the gated HuggingFace repo
`facebook/sam3`), so it is loaded once per process and kept resident. `facebook/sam3`), so it is loaded once per process and kept resident — until it
has sat unused for `SAM3_IDLE_UNLOAD_S` seconds (REQ-192), the model is dropped
so the card is free for anything else.
The important performance detail: `Sam3Processor.set_image()` runs the vision The important performance detail: `Sam3Processor.set_image()` runs the vision
backbone, while `set_text_prompt()` only runs the (much cheaper) grounding head backbone, while `set_text_prompt()` only runs the (much cheaper) grounding head
@@ -12,6 +14,7 @@ once per image and loop the prompts over that same state.
import os import os
import sys import sys
import threading import threading
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
@@ -25,6 +28,13 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..",
from sam3.model.sam3_image_processor import Sam3Processor from sam3.model.sam3_image_processor import Sam3Processor
from sam3.model_builder import build_sam3_image_model from sam3.model_builder import build_sam3_image_model
IDLE_UNLOAD_S = float(os.environ.get("SAM3_IDLE_UNLOAD_S") or 1200)
"""Seconds without any SAM3 use before the watcher frees the model (REQ-192).
Default 20 minutes. `0` disables it: the model then stays resident until
training releases it (REQ-065) or the process exits.
"""
@dataclass @dataclass
class Detection: class Detection:
@@ -73,6 +83,7 @@ class Sam3Engine:
`thresholds` overrides the confidence per prompt (REQ-181); still one `thresholds` overrides the confidence per prompt (REQ-181); still one
`set_image` for the whole call — only the grounding head sees the change.""" `set_image` for the whole call — only the grounding head sees the change."""
_touch()
processor = Sam3Processor(self.model, device=self.device) processor = Sam3Processor(self.model, device=self.device)
processor.confidence_threshold = threshold processor.confidence_threshold = threshold
@@ -124,6 +135,7 @@ class Sam3Engine:
every class because `state["geometric_prompt"]` survives `set_text_prompt` every class because `state["geometric_prompt"]` survives `set_text_prompt`
— without the reset, one class's boxes would leak into the next class. — without the reset, one class's boxes would leak into the next class.
""" """
_touch()
processor = Sam3Processor(self.model, device=self.device) processor = Sam3Processor(self.model, device=self.device)
processor.confidence_threshold = threshold processor.confidence_threshold = threshold
@@ -152,6 +164,7 @@ class Sam3Engine:
def open_state(self, image: Image.Image): def open_state(self, image: Image.Image):
"""Run the vision backbone once and hand back the reusable state.""" """Run the vision backbone once and hand back the reusable state."""
_touch()
with torch.autocast(self.device, dtype=self.autocast_dtype): with torch.autocast(self.device, dtype=self.autocast_dtype):
return self.processor.set_image(image) return self.processor.set_image(image)
@@ -168,6 +181,7 @@ class Sam3Engine:
The prompt set is always replayed from empty because SAM3 only supports The prompt set is always replayed from empty because SAM3 only supports
appending geometric prompts — that's how undo is implemented. appending geometric prompts — that's how undo is implemented.
""" """
_touch()
self.processor.confidence_threshold = threshold self.processor.confidence_threshold = threshold
exemplars = exemplars or [] exemplars = exemplars or []
@@ -239,6 +253,15 @@ class Sam3Engine:
_engine: Optional[Sam3Engine] = None _engine: Optional[Sam3Engine] = None
_engine_lock = threading.Lock() _engine_lock = threading.Lock()
_last_used = 0.0
_watcher_lock = threading.Lock()
_watcher_running = False
def _touch() -> None:
"""Note SAM3 is in use, so the idle watcher counts from now (REQ-192)."""
global _last_used
_last_used = time.monotonic()
def get_engine() -> Sam3Engine: def get_engine() -> Sam3Engine:
@@ -262,6 +285,8 @@ def get_engine() -> Sam3Engine:
raise RuntimeError( raise RuntimeError(
f"{exc} (Available VRAM: {curr_free:.1f} GB)" f"{exc} (Available VRAM: {curr_free:.1f} GB)"
) from exc ) from exc
_touch()
_ensure_watcher()
return _engine return _engine
@@ -271,11 +296,13 @@ def engine_is_loaded() -> bool:
def release_engine() -> bool: def release_engine() -> bool:
"""Drop the model and free its VRAM (REQ-065). """Drop the model and free its VRAM (REQ-065, REQ-192).
SAM3 holds ~3.4 GB resident. On a 6 GB card that is most of the memory a SAM3 holds ~3.4 GB resident. On a 6 GB card that is most of the memory a
training run needs, so the two must never be loaded at once. The next job training run needs, so the two must never be loaded at once. Training calls
that needs SAM3 rebuilds it from the local cache in about 12 seconds. this before it starts; the idle watcher and `POST /api/sam3/release` call it
too, and the next call that needs SAM3 rebuilds it from the local cache in
about 12 seconds.
""" """
global _engine global _engine
import gc import gc
@@ -288,3 +315,38 @@ def release_engine() -> bool:
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
return True return True
def _ensure_watcher() -> None:
"""Start the one idle-unload thread, once (REQ-192)."""
global _watcher_running
with _watcher_lock:
if _watcher_running or IDLE_UNLOAD_S <= 0:
return
_watcher_running = True
threading.Thread(target=_watch_idle, name="sam3-idle-unload", daemon=True).start()
def _watch_idle() -> None:
"""Free the model after IDLE_UNLOAD_S seconds without use (REQ-192).
Takes `jobs.gpu_lock` without blocking: a job or an interactive assist holds
it for as long as it is on the card, so a busy GPU just postpones the
release to the next tick — same one-holder rule as REQ-065.
"""
from backend import jobs
tick = max(1.0, min(60.0, IDLE_UNLOAD_S))
while True:
time.sleep(tick)
if _engine is None:
continue
with _engine_lock:
if _engine is None or time.monotonic() - _last_used < IDLE_UNLOAD_S:
continue
if not jobs.gpu_lock.acquire(blocking=False):
continue
try:
release_engine()
finally:
jobs.gpu_lock.release()
+2
View File
@@ -5,6 +5,8 @@ services:
environment: environment:
HF_TOKEN: ${HF_TOKEN:-} HF_TOKEN: ${HF_TOKEN:-}
HF_HUB_DISABLE_XET: "1" HF_HUB_DISABLE_XET: "1"
# Seconds of SAM3 idle before its VRAM is released (REQ-192); 1200 = 20 min, 0 = never.
SAM3_IDLE_UNLOAD_S: ${SAM3_IDLE_UNLOAD_S:-}
APP_DATA_DIR: /data APP_DATA_DIR: /data
VIDEO_ARCHIVE: /videos VIDEO_ARCHIVE: /videos
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:9000,http://localhost:9010} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:9000,http://localhost:9010}
+10 -2
View File
@@ -116,7 +116,7 @@ box.
| Module | Role | Status | | Module | Role | Status |
|---|---|---| |---|---|---|
| `sam3_engine.py` | SAM3 singleton, `open_state`/`apply_prompts`/`segment_at` | reused, plus a `release()` for REQ-065 | | `sam3_engine.py` | SAM3 singleton, `open_state`/`apply_prompts`/`segment_at` | reused, plus `release_engine()` for REQ-065 and the idle-unload watcher (REQ-192): a daemon thread, started on first `get_engine()`, that takes the non-blocking `gpu_lock` and releases after `SAM3_IDLE_UNLOAD_S` seconds without use |
| `labeling.py` | per-frame detection + cross-class greedy NMS with per-class IoU override and the container containment carve-out (REQ-031, REQ-184) | reused; the folder-walking half went with the old flow | | `labeling.py` | per-frame detection + cross-class greedy NMS with per-class IoU override and the container containment carve-out (REQ-031, REQ-184) | reused; the folder-walking half went with the old flow |
| `exporters.py` | ~~YOLO label writing~~ | **deleted** — `dataset.py` writes labels, `mask_to_polygons` moved to `review.py` | | `exporters.py` | ~~YOLO label writing~~ | **deleted** — `dataset.py` writes labels, `mask_to_polygons` moved to `review.py` |
| `sessions.py` | ~~exemplar/tap interaction~~ | **deleted** — see below | | `sessions.py` | ~~exemplar/tap interaction~~ | **deleted** — see below |
@@ -158,6 +158,7 @@ namespaces stay distinguishable.
``` ```
GET /api/health REQ-073 GET /api/health REQ-073
POST /api/sam3/release # free SAM3's VRAM now; 409 while the GPU is busy (REQ-192)
GET /api/projects REQ-001 GET /api/projects REQ-001
POST /api/projects REQ-001,002,004,005 POST /api/projects REQ-001,002,004,005
@@ -379,7 +380,7 @@ rejection. Parsing is `parseClassYaml` in the same file — a strict subset read
what Copy emits, no YAML dependency, all-or-nothing with a `line N: …` error. Because what Copy emits, no YAML dependency, all-or-nothing with a `line N: …` error. Because
`ClassParamsTable` is shared, the mass modal carries both buttons too. `ClassParamsTable` is shared, the mass modal carries both buttons too.
**Review sidebar class rows (REQ-180, REQ-183, REQ-185).** Each class row's frame-clear `×` **Review sidebar class rows (REQ-180, REQ-183, REQ-185, REQ-191).** Each class row's frame-clear `×`
(REQ-180) gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it (REQ-180) gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it
survives frame changes, resets when the review page is left, and never touches stored data. survives frame changes, resets when the review page is left, and never touches stored data.
Hidden shapes are never handed to `AnnotationCanvas` — not drawn, not clickable, not Hidden shapes are never handed to `AnnotationCanvas` — not drawn, not clickable, not
@@ -393,6 +394,13 @@ keeps its real per-frame count. Creating a shape while its class is hidden (draw
copy) auto-overrides it so the user sees what they just made; reclassing into a hidden class copy) auto-overrides it so the user sees what they just made; reclassing into a hidden class
dims it unless it already carries an override; toggling a class eye clears that class's overrides. dims it unless it already carries an override; toggling a class eye clears that class's overrides.
A third, independent piece of state is `revealAll` (REQ-191): a boolean **overlay** in
`ReviewPage`, toggled by `Shift+H`, that makes `isShapeVisible` answer "yes" for every shape.
It reads neither Set and writes neither Set, which is what makes the second `Shift+H` restore
the previous hidden state exactly; a plain `H` while it is on only clears the overlay, so the
keystroke edits the real hidden state from then on. The sidebar shows a `revealing hidden` chip
in the "Shapes on this frame" header while it is on (REQ-191).
**Exemplar-driven labeling in review (REQ-173, REQ-174, REQ-175).** In `draw` mode a drag on **Exemplar-driven labeling in review (REQ-173, REQ-174, REQ-175).** In `draw` mode a drag on
`AnnotationCanvas` is an exemplar, not a rectangle: `onExemplar(box, positive)` where `AnnotationCanvas` is an exemplar, not a rectangle: `onExemplar(box, positive)` where
`positive` is `!event.shiftKey`. `hooks/useExemplarPool.js` keeps the pool in a ref as well `positive` is `!event.shiftKey`. `hooks/useExemplarPool.js` keeps the pool in a ref as well
+17
View File
@@ -276,6 +276,13 @@ changes.
auto-overridden so the user sees what they just made. Like the class eye, this is session auto-overridden so the user sees what they just made. Like the class eye, this is session
state: it survives frame changes, resets when the review page is left, and never touches state: it survives frame changes, resets when the review page is left, and never touches
stored data. stored data.
- **REQ-191** — `Shift+H` in the review editor toggles a **reveal-all overlay**: every hidden
shape — `H`-hidden shapes and shapes of hidden classes alike — is drawn on the canvas again
while nothing about what is actually hidden changes. A second `Shift+H` turns the overlay
off and the previous hidden state is back exactly as it was. While the overlay is on, the
"Shapes on this frame" panel shows a chip saying shapes are being revealed, and a plain `H`
drops an active overlay first instead of hiding anything, so from then on the keystroke
edits the real hidden state. Session-only, like the hide state itself.
## E4. Live counting preview ## E4. Live counting preview
@@ -479,3 +486,13 @@ changes.
folder has moved once already and everything in it must survive the next move), which folder has moved once already and everything in it must survive the next move), which
strengthens the never-hardcoded rule of REQ-072. Training, preview, the batch job, model strengthens the never-hardcoded rule of REQ-072. Training, preview, the batch job, model
download and live counting all reach the same weights through this one resolution. download and live counting all reach the same weights through this one resolution.
- **REQ-192** — The SAM3 model's ~4 GB of VRAM is not held forever. After
`SAM3_IDLE_UNLOAD_S` seconds (default 1200 = 20 minutes; `0` never; an environment
variable, per REQ-072) without any SAM3 use, the model is released — through the same
release path training already uses (REQ-065) and under the same one-holder rule: a job or
an interactive assist in flight postpones the release to the next check, it is never
interrupted. `POST /api/sam3/release` frees it immediately instead of waiting, and answers
`409` while the GPU is busy. `/api/health`'s `model_loaded` field reports the state, and the
topbar badge shows `SAM3: Loaded` (green, clickable to release) / `SAM3: Idle` (not in VRAM,
loads on next use) / `SAM3: Off` (not enough free VRAM to load). The next call after a
release rebuilds the model from the local cache in about 12 seconds.
+53
View File
@@ -1565,6 +1565,59 @@ read it.
frame already holding an overlapping truck → banner `skipped K overlapping truck`, dot, count frame already holding an overlapping truck → banner `skipped K overlapping truck`, dot, count
unchanged, while a frame holding a truck elsewhere gets its own second box. unchanged, while a frame holding a truck elsewhere gets its own second box.
## Task — Reveal all hidden shapes with Shift+H (REQ-191) `[DONE]`
- `frontend/src/pages/ReviewPage.jsx` — third piece of hide state, `revealAll`, is a boolean
**overlay**: `isShapeVisible` answers yes for everything while it is on, and neither
`hiddenShapeIds` nor `hiddenClasses`/`overriddenShapeIds` is touched, which is what makes the
second `Shift+H` restore the previous hidden state exactly. In the keydown handler,
`Shift+H` toggles the overlay, a plain `H` clears an active overlay first (so it edits the
real hidden state) and otherwise falls through to `toggleHideSelected()`.
- `frontend/src/components/ReviewSidebar.jsx` — `revealing hidden` chip (SVG `EyeIcon`, accent
pill, `title` naming `Shift+H`) in the *Shapes on this frame* header while the overlay is on;
dimming of rows is unchanged, so the chip and the dims together say what is actually hidden.
- `frontend/src/components/ShortcutsPanel.jsx` — `Shift+H` row next to `H`.
- `frontend/src/app.css` — `.reveal-chip` on `--accent` tokens (works in both themes).
- Docs: `requirements.md` REQ-191, `design.md` sidebar block, `ui-spec.md` keyboard table +
shapes-panel bullet.
→ verify: **[DONE]** `npm run build` green; `git diff --check` clean. **Still needs a browser
pass:** hide a class and one shape, `Shift+H` → both drawn + chip up, `Shift+H` again → back
to the same dims, plain `H` → overlay drops and only then hides the selection.
## Task — Free SAM3's VRAM on idle and on demand (REQ-192) `[DONE]`
- Question answered first: nothing freed the model before — `release_engine()` had exactly one
caller (`backend/training.py`, REQ-065), so the ~3.9 GB stayed in the card until a training
run or the process exited. The YOLO loads elsewhere are transient locals, no leak found.
- `backend/sam3_engine.py` — `IDLE_UNLOAD_S` (env `SAM3_IDLE_UNLOAD_S`, default 1200, `0`
disables), `_touch()` stamped by `get_engine()` and by the four model methods that actually
run inference (so a long autolabel job counts as use, not as idle), and one daemon watcher
started on first `get_engine()`: every `min(60 s, IDLE_UNLOAD_S)` it releases the engine,
but only after taking `jobs.gpu_lock` **without blocking** — a job or assist in flight just
postpones the release, never gets interrupted (same one-holder rule as REQ-065).
- `backend/api/sam3.py` — `POST /api/sam3/release`: same non-blocking `gpu_lock`, `409` while
the GPU is busy, `{"released", "model_loaded"}` otherwise; wired in `backend/main.py`.
- `docker-compose.yml` + `.env.example` — the variable is passed through, so the container
sees a value set in `.env` (empty/unset → Python falls back to 1200).
- `frontend/src/components/Sidebar.jsx` + `api.js` + `roboflow.css` — badge is now three-state:
`SAM3: Loaded` (green **button**, click releases, optimistic flip, 3 s poll confirms) /
`SAM3: Idle` / `SAM3: Off`; a failed release shows its `detail` for 4 s instead of failing
silently.
- Docs: `requirements.md` REQ-192, `design.md` module row + API contract, `ui-spec.md` badge
block, `.env.example` GPU section.
→ verify: **[DONE]** `uv run python /tmp/opencode/test_idle_unload.py` — ALL PASS: unload after
idle, release postponed while `gpu_lock` held then fires, `0` disables the watcher, endpoint
answers `409` when busy and `{"released": true, "model_loaded": false}` when free; app imports
with `/api/sam3/release` in the OpenAPI paths; `npm run build` green; `git diff --check` clean.
**Live run against the rebuilt container:** assist on a batch frame loads the model (VRAM free
7.39 → 3.15 GB), `POST /api/sam3/release` answers `{"released": true, "model_loaded": false}`
and VRAM goes back to 7.32 GB (+4.2 GB freed), the next assist rebuilds in 6 s and works,
badge strings present in the served `index-BGbzE_yy.js`. Container env shows
`SAM3_IDLE_UNLOAD_S` empty → falls back to the 1200 s default as designed. **Watcher verified
live too:** with `SAM3_IDLE_UNLOAD_S=10` on one container run, a loaded model (3.15 GB free)
went to `model_loaded: false` / 7.32 GB free on its own within the 10 s tick, then the
container was put back on the default 1200 s.
## Known open points ## Known open points
- *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume - *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume
+9 -3
View File
@@ -201,7 +201,9 @@ falling back to `route.projectId` and then `1`:
``` ```
GPU: <name, stripped of "NVIDIA GeForce" / "Laptop GPU"> | CPU if none GPU: <name, stripped of "NVIDIA GeForce" / "Laptop GPU"> | CPU if none
VRAM: <free>GB | N/A VRAM: <free>GB | N/A
SAM3: Ready | Off ← green when ready SAM3: Loaded | Idle | Off ← Loaded = green button, click frees the VRAM (REQ-192)
Idle = green, not in VRAM, loads on next use (~12 s)
Off = not enough free VRAM to load
``` ```
Icons are SVG (Heroicons/Lucide-style), never emoji. *(Some emoji survive inside page bodies — Icons are SVG (Heroicons/Lucide-style), never emoji. *(Some emoji survive inside page bodies —
@@ -1171,6 +1173,7 @@ Handlers are attached in the **capture** phase on `document`, and:
| `1`–`9` | set active class; reclass the selected shape if any | reclass **all marked** shapes | | `1`–`9` | set active class; reclass the selected shape if any | reclass **all marked** shapes |
| `Del` / `Backspace` | delete the selected shape | delete **all marked** shapes (confirmed) | | `Del` / `Backspace` | delete the selected shape | delete **all marked** shapes (confirmed) |
| `H` | hide/show the selected shape(s); no-op with nothing selected | same | | `H` | hide/show the selected shape(s); no-op with nothing selected | same |
| `Shift`+`H` | toggle the reveal-all overlay — draws every hidden shape without touching the hidden state; a plain `H` while it is on just turns it off | same |
| `Esc` | cancel the gesture in flight | clear the marked set | | `Esc` | cancel the gesture in flight | clear the marked set |
| hold `S` | SAM3 assist mode while held | — | | hold `S` | SAM3 assist mode while held | — |
| `Enter` | apply the exemplar preview (panel open) | — | | `Enter` | apply the exemplar preview (panel open) | — |
@@ -1213,7 +1216,9 @@ network.
hide; click still selects it and the trash still deletes it. Empty means the frame has hide; click still selects it and the trash still deletes it. Empty means the frame has
**zero shapes**, not zero visible ones: *"None on this frame — drag to draw a shape."* **zero shapes**, not zero visible ones: *"None on this frame — drag to draw a shape."*
plus, if the batch has shapes elsewhere, a *Jump to Frame with Shapes [N]* button. plus, if the batch has shapes elsewhere, a *Jump to Frame with Shapes [N]* button.
- **Shortcuts** — a static `<dl>` of the keyboard map (it also lists `H`). Keep it visible; While the reveal-all overlay is on (REQ-191), the header carries a small `revealing hidden`
chip — accent-coloured pill, SVG eye icon, `title` explaining `Shift+H` turns it off.
- **Shortcuts** — a static `<dl>` of the keyboard map (it also lists `H` and `Shift`+`H`). Keep it visible;
this is a keyboard-first screen and the panel is the discovery mechanism. this is a keyboard-first screen and the panel is the discovery mechanism.
--- ---
@@ -1554,7 +1559,8 @@ di-deep-link — jangan dihapus.
### Shell ### Shell
Top bar tetap 48 px: wordmark kiri → nav urut pipeline di tengah → kanan badge health Top bar tetap 48 px: wordmark kiri → nav urut pipeline di tengah → kanan badge health
(`GPU: …`, `VRAM: … GB`, `SAM3: Ready/Off` hijau saat siap, polling 3 detik) + toggle tema. (`GPU: …`, `VRAM: … GB`, `SAM3: Loaded/Idle/Off` — `Loaded` hijau dan bisa diklik untuk
melepas VRAM (REQ-192), polling 3 detik) + toggle tema.
Error boundary membungkus area halaman, di-key per rute. Error boundary membungkus area halaman, di-key per rute.
### Design token sekarang (boleh diganti, maknanya harus bertahan) ### Design token sekarang (boleh diganti, maknanya harus bertahan)
+1
View File
@@ -39,6 +39,7 @@ async function request(path, { method = 'GET', body, form, signal } = {}) {
export const api = { export const api = {
health: () => request('/health'), health: () => request('/health'),
releaseSam3: () => request('/sam3/release', { method: 'POST' }),
listProjects: () => request('/projects'), listProjects: () => request('/projects'),
getProject: (id) => request(`/projects/${id}`), getProject: (id) => request(`/projects/${id}`),
+17
View File
@@ -700,6 +700,23 @@ main.page {
.side-panel { padding: 12px 14px; display: grid; gap: 10px; } .side-panel { padding: 12px 14px; display: grid; gap: 10px; }
.reveal-chip {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 2px 7px;
border: 1px solid var(--accent);
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
vertical-align: middle;
}
.class-list, .shape-list { display: grid; gap: 4px; } .class-list, .shape-list { display: grid; gap: 4px; }
.class-chip { .class-chip {
+9 -1
View File
@@ -14,6 +14,7 @@ export default function ReviewSidebar({
onToggleHide, onToggleHide,
hiddenShapeIds, hiddenShapeIds,
overriddenShapeIds, overriddenShapeIds,
revealAll,
onRestoreShape, onRestoreShape,
annotations, annotations,
selectedId, selectedId,
@@ -76,7 +77,14 @@ export default function ReviewSidebar({
</div> </div>
<div className="panel side-panel"> <div className="panel side-panel">
<h2>Shapes on this frame ({annotations.length})</h2> <h2>
Shapes on this frame ({annotations.length})
{revealAll && (
<span className="reveal-chip" title="Revealing H-hidden shapes and hidden classes — press Shift+H to go back">
<EyeIcon size={11} /> revealing hidden
</span>
)}
</h2>
{annotations.length === 0 ? ( {annotations.length === 0 ? (
<div> <div>
<p className="hint" style={{ marginBottom: 8 }}> <p className="hint" style={{ marginBottom: 8 }}>
@@ -47,6 +47,10 @@ export default function ShortcutsPanel() {
<dt><kbd>H</kbd></dt> <dt><kbd>H</kbd></dt>
<dd>Hide / show selected shapes</dd> <dd>Hide / show selected shapes</dd>
</div> </div>
<div>
<dt><kbd>Shift</kbd>+<kbd>H</kbd></dt>
<dd>Reveal all hidden shapes (press again to hide)</dd>
</div>
<div> <div>
<dt><kbd>←</kbd> <kbd>→</kbd></dt> <dt><kbd>←</kbd> <kbd>→</kbd></dt>
<dd>Prev / next frame</dd> <dd>Prev / next frame</dd>
+34 -2
View File
@@ -17,6 +17,7 @@ import {
export default function Sidebar({ route, currentProject, theme, onToggleTheme }) { export default function Sidebar({ route, currentProject, theme, onToggleTheme }) {
const [health, setHealth] = useState(null) const [health, setHealth] = useState(null)
const [notice, setNotice] = useState('')
useEffect(() => { useEffect(() => {
let timer let timer
@@ -28,6 +29,18 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme })
return () => clearInterval(timer) return () => clearInterval(timer)
}, []) }, [])
// REQ-192: free the SAM3 model's VRAM on demand; the 3 s health poll is what
// confirms it, this only flips the badge straight away when it worked.
const releaseSam3 = async () => {
try {
await api.releaseSam3()
setHealth((current) => (current ? { ...current, model_loaded: false } : current))
} catch (exc) {
setNotice(exc.message)
setTimeout(() => setNotice(''), 4000)
}
}
const pId = currentProject?.id || route.projectId || 1 const pId = currentProject?.id || route.projectId || 1
@@ -138,9 +151,28 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme })
<span className="health-badge"> <span className="health-badge">
VRAM: {health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'} VRAM: {health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'}
</span> </span>
<span className={`health-badge ${health.sam3_ready ? 'ready' : ''}`}> {notice && <span className="health-badge">{notice}</span>}
SAM3: {health.sam3_ready ? 'Ready' : 'Off'} {!health.sam3_ready ? (
<span className="health-badge" title="Not enough free VRAM for SAM3 — free the card first">
SAM3: Off
</span> </span>
) : health.model_loaded ? (
<button
type="button"
className="health-badge ready"
title="SAM3 is in VRAM (~3.9 GB) — click to free it now"
onClick={releaseSam3}
>
SAM3: Loaded
</button>
) : (
<span
className="health-badge ready"
title="SAM3 not in VRAM — it loads on the next use (~12 s)"
>
SAM3: Idle
</span>
)}
</div> </div>
)} )}
+17 -5
View File
@@ -26,6 +26,9 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
const [hiddenClasses, setHiddenClasses] = useState(() => new Set()) const [hiddenClasses, setHiddenClasses] = useState(() => new Set())
const [hiddenShapeIds, setHiddenShapeIds] = useState(() => new Set()) const [hiddenShapeIds, setHiddenShapeIds] = useState(() => new Set())
const [overriddenShapeIds, setOverriddenShapeIds] = useState(() => new Set()) const [overriddenShapeIds, setOverriddenShapeIds] = useState(() => new Set())
// REQ-191: overlay only — Shift+H shows everything without touching the
// real hidden sets, a second press restores them as they were.
const [revealAll, setRevealAll] = useState(false)
const [trackFrames, setTrackFrames] = useState(() => { const [trackFrames, setTrackFrames] = useState(() => {
const stored = Number(localStorage.getItem('trackFrames')) const stored = Number(localStorage.getItem('trackFrames'))
return Number.isFinite(stored) && stored >= 1 ? Math.min(100, Math.round(stored)) : 5 return Number.isFinite(stored) && stored >= 1 ? Math.min(100, Math.round(stored)) : 5
@@ -230,9 +233,11 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
// REQ-185: neither the shape's own hide nor its class's hide (without an // REQ-185: neither the shape's own hide nor its class's hide (without an
// override) may reach the canvas. Session-only, never persisted. // override) may reach the canvas. Session-only, never persisted.
// REQ-191: while revealAll is on, both kinds of hidden shape come back.
const isShapeVisible = (row) => const isShapeVisible = (row) =>
!hiddenShapeIds.has(row.id) && revealAll ||
(!hiddenClasses.has(row.class_id) || overriddenShapeIds.has(row.id)) (!hiddenShapeIds.has(row.id) &&
(!hiddenClasses.has(row.class_id) || overriddenShapeIds.has(row.id)))
const visibleAnnotations = annotations.filter(isShapeVisible) const visibleAnnotations = annotations.filter(isShapeVisible)
@@ -459,7 +464,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
}, [annotations, selectedId, index, frames, busy, trackFrames, project]) }, [annotations, selectedId, index, frames, busy, trackFrames, project])
const stateRef = useRef({}) const stateRef = useRef({})
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected } stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected, revealAll, setRevealAll }
useEffect(() => { useEffect(() => {
function onKeyDown(event) { function onKeyDown(event) {
@@ -467,7 +472,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
// Ctrl/Cmd/Alt combos belong to the browser and the OS: without this, // 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. // Ctrl+S approves the frame and Ctrl+A/C/X/N/T all fire review actions.
if (event.ctrlKey || event.metaKey || event.altKey) return 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, toggleHideSelected } = stateRef.current const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected, revealAll, setRevealAll } = stateRef.current
const key = event.key 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', 'c', 'C', 't', 'T', 'v', 'V', 'h', 'H', 'Escape'].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', 'h', 'H', 'Escape'].includes(key)
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() } if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
@@ -494,7 +499,13 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
else if (key === 'n' || key === 'N') jumpToNextAnnotated?.() else if (key === 'n' || key === 'N') jumpToNextAnnotated?.()
else if (key === 'c' || key === 'C') copyPrevious?.() else if (key === 'c' || key === 'C') copyPrevious?.()
else if (key === 't' || key === 'T') trackForward?.(false) else if (key === 't' || key === 'T') trackForward?.(false)
else if (key === 'h' || key === 'H') toggleHideSelected?.() else if (key === 'h' || key === 'H') {
// REQ-191: Shift+H toggles the reveal-all overlay; plain H first
// drops an active overlay so it edits the real hidden state.
if (event.shiftKey) setRevealAll?.((on) => !on)
else if (revealAll) setRevealAll?.(false)
else toggleHideSelected?.()
}
else if (key === 'Delete' || key === 'Backspace') removeSelected?.() else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
else if (/^[1-9]$/.test(key)) { else if (/^[1-9]$/.test(key)) {
const classId = Number(key) - 1 const classId = Number(key) - 1
@@ -810,6 +821,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
onToggleHide={toggleHide} onToggleHide={toggleHide}
hiddenShapeIds={hiddenShapeIds} hiddenShapeIds={hiddenShapeIds}
overriddenShapeIds={overriddenShapeIds} overriddenShapeIds={overriddenShapeIds}
revealAll={revealAll}
onRestoreShape={restoreShape} onRestoreShape={restoreShape}
annotations={annotations} annotations={annotations}
selectedId={selectedId} selectedId={selectedId}
+14
View File
@@ -87,6 +87,20 @@
border-color: rgba(34, 197, 94, 0.25); border-color: rgba(34, 197, 94, 0.25);
} }
/* REQ-192: the Loaded badge is a button (click frees the VRAM) — give it the
affordances a span doesn't need. */
button.health-badge {
font-family: inherit;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
button.health-badge:hover { background: rgba(255, 255, 255, 0.14); }
button.health-badge.ready:hover { background: rgba(34, 197, 94, 0.22); }
button.health-badge:focus-visible {
outline: 2px solid #a855f7;
outline-offset: 2px;
}
.topbar-theme-toggle { .topbar-theme-toggle {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);