From fd608848bfb3c49fe77fa3e2a80deeef4896427d Mon Sep 17 00:00:00 2001 From: asus Date: Wed, 7 Oct 2026 11:55:00 +0700 Subject: [PATCH] feat: reveal all hidden shapes with Shift+H, release SAM3 VRAM on idle and on demand (REQ-191, REQ-192) --- .env.example | 7 +++ backend/api/sam3.py | 25 ++++++++ backend/main.py | 4 +- backend/sam3_engine.py | 72 ++++++++++++++++++++-- docker-compose.yml | 2 + docs/design.md | 12 +++- docs/requirements.md | 17 +++++ docs/tasks.md | 53 ++++++++++++++++ docs/ui-spec.md | 12 +++- frontend/src/api.js | 1 + frontend/src/app.css | 17 +++++ frontend/src/components/ReviewSidebar.jsx | 10 ++- frontend/src/components/ShortcutsPanel.jsx | 4 ++ frontend/src/components/Sidebar.jsx | 38 +++++++++++- frontend/src/pages/ReviewPage.jsx | 22 +++++-- frontend/src/roboflow.css | 14 +++++ 16 files changed, 290 insertions(+), 20 deletions(-) create mode 100644 backend/api/sam3.py diff --git a/.env.example b/.env.example index 804978a..73f45e4 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,13 @@ API_PORT=9010 # dev server from another device. 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) ----------------------- # 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 diff --git a/backend/api/sam3.py b/backend/api/sam3.py new file mode 100644 index 0000000..638f55b --- /dev/null +++ b/backend/api/sam3.py @@ -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() diff --git a/backend/main.py b/backend/main.py index 22670c3..fa4eeea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,8 @@ from fastapi.middleware.cors import CORSMiddleware 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) + live_count as live_count_routes, models, projects, review, + sam3, triage) @asynccontextmanager @@ -47,6 +48,7 @@ app.add_middleware( app.include_router(projects.router) app.include_router(batches.router) app.include_router(review.router) +app.include_router(sam3.router) app.include_router(models.router) app.include_router(job_routes.router) app.include_router(triage.router) diff --git a/backend/sam3_engine.py b/backend/sam3_engine.py index c88fa3c..07336ca 100644 --- a/backend/sam3_engine.py +++ b/backend/sam3_engine.py @@ -1,7 +1,9 @@ """SAM3 text-prompted detection, wrapped for reuse across labeling jobs. 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 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 sys import threading +import time from dataclasses import dataclass, field 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_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 class Detection: @@ -73,6 +83,7 @@ class Sam3Engine: `thresholds` overrides the confidence per prompt (REQ-181); still one `set_image` for the whole call — only the grounding head sees the change.""" + _touch() processor = Sam3Processor(self.model, device=self.device) processor.confidence_threshold = threshold @@ -124,6 +135,7 @@ class Sam3Engine: every class because `state["geometric_prompt"]` survives `set_text_prompt` — without the reset, one class's boxes would leak into the next class. """ + _touch() processor = Sam3Processor(self.model, device=self.device) processor.confidence_threshold = threshold @@ -152,6 +164,7 @@ class Sam3Engine: def open_state(self, image: Image.Image): """Run the vision backbone once and hand back the reusable state.""" + _touch() with torch.autocast(self.device, dtype=self.autocast_dtype): return self.processor.set_image(image) @@ -166,8 +179,9 @@ class Sam3Engine: Exemplars are boxes in normalized cxcywh with a positive/negative flag. 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 exemplars = exemplars or [] @@ -239,6 +253,15 @@ class Sam3Engine: _engine: Optional[Sam3Engine] = None _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: @@ -262,6 +285,8 @@ def get_engine() -> Sam3Engine: raise RuntimeError( f"{exc} (Available VRAM: {curr_free:.1f} GB)" ) from exc + _touch() + _ensure_watcher() return _engine @@ -271,11 +296,13 @@ def engine_is_loaded() -> 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 - training run needs, so the two must never be loaded at once. The next job - that needs SAM3 rebuilds it from the local cache in about 12 seconds. + training run needs, so the two must never be loaded at once. Training calls + 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 import gc @@ -288,3 +315,38 @@ def release_engine() -> bool: if torch.cuda.is_available(): torch.cuda.empty_cache() 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() diff --git a/docker-compose.yml b/docker-compose.yml index d93819f..a1bcd2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: environment: HF_TOKEN: ${HF_TOKEN:-} 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 VIDEO_ARCHIVE: /videos CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:9000,http://localhost:9010} diff --git a/docs/design.md b/docs/design.md index fc6ffa6..0e88d76 100644 --- a/docs/design.md +++ b/docs/design.md @@ -116,7 +116,7 @@ box. | 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 | | `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 | @@ -158,6 +158,7 @@ namespaces stay distinguishable. ``` 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 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 `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 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 @@ -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 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 `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 diff --git a/docs/requirements.md b/docs/requirements.md index 895d8b8..398f82c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -276,6 +276,13 @@ changes. 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 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 @@ -479,3 +486,13 @@ changes. 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 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. diff --git a/docs/tasks.md b/docs/tasks.md index 7f7395b..a04d17a 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1565,6 +1565,59 @@ read it. 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. +## 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 - *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 79206ec..8c9bc78 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -201,7 +201,9 @@ falling back to `route.projectId` and then `1`: ``` GPU: | CPU if none VRAM: 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 — @@ -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 | | `Del` / `Backspace` | delete the selected shape | delete **all marked** shapes (confirmed) | | `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 | | hold `S` | SAM3 assist mode while held | — | | `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 **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. -- **Shortcuts** — a static `
` 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 `
` 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. --- @@ -1554,7 +1559,8 @@ di-deep-link — jangan dihapus. ### Shell 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. ### Design token sekarang (boleh diganti, maknanya harus bertahan) diff --git a/frontend/src/api.js b/frontend/src/api.js index 7f8ebcb..61bbd92 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -39,6 +39,7 @@ async function request(path, { method = 'GET', body, form, signal } = {}) { export const api = { health: () => request('/health'), + releaseSam3: () => request('/sam3/release', { method: 'POST' }), listProjects: () => request('/projects'), getProject: (id) => request(`/projects/${id}`), diff --git a/frontend/src/app.css b/frontend/src/app.css index e749054..e93128c 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -700,6 +700,23 @@ main.page { .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-chip { diff --git a/frontend/src/components/ReviewSidebar.jsx b/frontend/src/components/ReviewSidebar.jsx index 337816f..61ff5df 100644 --- a/frontend/src/components/ReviewSidebar.jsx +++ b/frontend/src/components/ReviewSidebar.jsx @@ -14,6 +14,7 @@ export default function ReviewSidebar({ onToggleHide, hiddenShapeIds, overriddenShapeIds, + revealAll, onRestoreShape, annotations, selectedId, @@ -76,7 +77,14 @@ export default function ReviewSidebar({
-

Shapes on this frame ({annotations.length})

+

+ Shapes on this frame ({annotations.length}) + {revealAll && ( + + revealing hidden + + )} +

{annotations.length === 0 ? (

diff --git a/frontend/src/components/ShortcutsPanel.jsx b/frontend/src/components/ShortcutsPanel.jsx index 250085f..f452eef 100644 --- a/frontend/src/components/ShortcutsPanel.jsx +++ b/frontend/src/components/ShortcutsPanel.jsx @@ -47,6 +47,10 @@ export default function ShortcutsPanel() {

H
Hide / show selected shapes
+
+
Shift+H
+
Reveal all hidden shapes (press again to hide)
+
← →
Prev / next frame
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 96acefb..93f40ed 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -17,6 +17,7 @@ import { export default function Sidebar({ route, currentProject, theme, onToggleTheme }) { const [health, setHealth] = useState(null) + const [notice, setNotice] = useState('') useEffect(() => { let timer @@ -28,6 +29,18 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme }) 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 @@ -138,9 +151,28 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme }) VRAM: {health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'} - - SAM3: {health.sam3_ready ? 'Ready' : 'Off'} - + {notice && {notice}} + {!health.sam3_ready ? ( + + SAM3: Off + + ) : health.model_loaded ? ( + + ) : ( + + SAM3: Idle + + )}
)} diff --git a/frontend/src/pages/ReviewPage.jsx b/frontend/src/pages/ReviewPage.jsx index 8c19835..3629d46 100644 --- a/frontend/src/pages/ReviewPage.jsx +++ b/frontend/src/pages/ReviewPage.jsx @@ -26,6 +26,9 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } const [hiddenClasses, setHiddenClasses] = useState(() => new Set()) const [hiddenShapeIds, setHiddenShapeIds] = 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 stored = Number(localStorage.getItem('trackFrames')) 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 // 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) => - !hiddenShapeIds.has(row.id) && - (!hiddenClasses.has(row.class_id) || overriddenShapeIds.has(row.id)) + revealAll || + (!hiddenShapeIds.has(row.id) && + (!hiddenClasses.has(row.class_id) || overriddenShapeIds.has(row.id))) const visibleAnnotations = annotations.filter(isShapeVisible) @@ -459,7 +464,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } }, [annotations, selectedId, index, frames, busy, trackFrames, project]) 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(() => { 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+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, 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 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() } @@ -494,7 +499,13 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } else if (key === 'n' || key === 'N') jumpToNextAnnotated?.() else if (key === 'c' || key === 'C') copyPrevious?.() 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 (/^[1-9]$/.test(key)) { const classId = Number(key) - 1 @@ -810,6 +821,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } onToggleHide={toggleHide} hiddenShapeIds={hiddenShapeIds} overriddenShapeIds={overriddenShapeIds} + revealAll={revealAll} onRestoreShape={restoreShape} annotations={annotations} selectedId={selectedId} diff --git a/frontend/src/roboflow.css b/frontend/src/roboflow.css index 0b45711..4ec1ec7 100644 --- a/frontend/src/roboflow.css +++ b/frontend/src/roboflow.css @@ -87,6 +87,20 @@ 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 { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.12);