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:
1 parent
f5be7880b0
commit
fd608848bf
16 files changed
+288
-18
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user