Spaces:
Sleeping
Sleeping
Commit ·
c476eae
1
Parent(s): f403983
fix: model paths (.pth), landmark normalization, WS URL, GPU fallback; add ModelSelector; mobile layout improvements
Browse files- backend/app/config.py +6 -5
- backend/app/main.py +37 -3
- backend/app/schemas.py +10 -1
- fly.toml +0 -42
- frontend/src/App.tsx +117 -14
- frontend/src/components/ModelSelector.tsx +135 -0
- frontend/src/components/PredictionHUD.tsx +94 -82
- frontend/src/components/WebcamFeed.tsx +1 -1
- frontend/src/hooks/useMediaPipe.ts +24 -8
- frontend/src/hooks/useWebSocket.ts +28 -6
- frontend/src/lib/landmarkUtils.ts +6 -4
- frontend/src/types.ts +3 -1
backend/app/config.py
CHANGED
|
@@ -24,11 +24,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent # repo root
|
|
| 24 |
WEIGHTS_DIR = os.getenv("WEIGHTS_DIR", str(BASE_DIR))
|
| 25 |
|
| 26 |
# Individual model paths (relative to repo root)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
| 32 |
|
| 33 |
# ---------------------------------------------------------------------------
|
| 34 |
# Inference thresholds
|
|
|
|
| 24 |
WEIGHTS_DIR = os.getenv("WEIGHTS_DIR", str(BASE_DIR))
|
| 25 |
|
| 26 |
# Individual model paths (relative to repo root)
|
| 27 |
+
# Note: the actual files on disk use .pth extension (identical content to .pkl)
|
| 28 |
+
PIPELINE_A_MODEL = os.path.join(WEIGHTS_DIR, "Mediapipe_XGBoost", "model.pth")
|
| 29 |
+
PIPELINE_B_AE = os.path.join(WEIGHTS_DIR, "CNN_Autoencoder_LightGBM", "autoencoder_model.pth")
|
| 30 |
+
PIPELINE_B_LGBM = os.path.join(WEIGHTS_DIR, "CNN_Autoencoder_LightGBM", "lgbm_model.pth")
|
| 31 |
+
PIPELINE_C_CNN = os.path.join(WEIGHTS_DIR, "CNN_PreTrained", "cnn_model.pth")
|
| 32 |
+
PIPELINE_C_SVM = os.path.join(WEIGHTS_DIR, "CNN_PreTrained", "svm_model.pth")
|
| 33 |
|
| 34 |
# ---------------------------------------------------------------------------
|
| 35 |
# Inference thresholds
|
backend/app/main.py
CHANGED
|
@@ -118,8 +118,10 @@ async def global_exception_handler(request: Request, exc: Exception):
|
|
| 118 |
def _run_ensemble(
|
| 119 |
landmarks: list[float],
|
| 120 |
image_b64: str | None = None,
|
|
|
|
| 121 |
) -> PredictionResponse:
|
| 122 |
store = get_model_store()
|
|
|
|
| 123 |
result = ensemble.run(
|
| 124 |
landmarks,
|
| 125 |
image_input=image_b64,
|
|
@@ -128,7 +130,7 @@ def _run_ensemble(
|
|
| 128 |
lgbm_model=store.lgbm_model,
|
| 129 |
cnn_model=store.cnn_model,
|
| 130 |
svm_model=store.svm_model,
|
| 131 |
-
pipeline_mode=
|
| 132 |
confidence_threshold=config.CONFIDENCE_THRESHOLD,
|
| 133 |
secondary_threshold=config.SECONDARY_THRESHOLD,
|
| 134 |
)
|
|
@@ -157,6 +159,30 @@ def _available_pipelines() -> list[str]:
|
|
| 157 |
return pipelines
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
# ---------------------------------------------------------------------------
|
| 161 |
# REST endpoints
|
| 162 |
# ---------------------------------------------------------------------------
|
|
@@ -190,7 +216,11 @@ async def health():
|
|
| 190 |
@app.post("/api/predict", response_model=PredictionResponse)
|
| 191 |
async def predict_landmarks(body: LandmarkMessage):
|
| 192 |
"""REST fallback: send 63 landmark floats, receive prediction."""
|
| 193 |
-
return _run_ensemble(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
|
| 196 |
@app.post("/api/predict/image", response_model=PredictionResponse)
|
|
@@ -232,7 +262,11 @@ async def ws_landmarks(ws: WebSocket):
|
|
| 232 |
msg = LandmarkMessage(**data)
|
| 233 |
session_id = msg.session_id
|
| 234 |
|
| 235 |
-
response = _run_ensemble(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
await ws.send_text(response.model_dump_json())
|
| 237 |
|
| 238 |
except ValueError as ve:
|
|
|
|
| 118 |
def _run_ensemble(
|
| 119 |
landmarks: list[float],
|
| 120 |
image_b64: str | None = None,
|
| 121 |
+
model_mode: str | None = None,
|
| 122 |
) -> PredictionResponse:
|
| 123 |
store = get_model_store()
|
| 124 |
+
effective_mode = _resolve_pipeline_mode(model_mode)
|
| 125 |
result = ensemble.run(
|
| 126 |
landmarks,
|
| 127 |
image_input=image_b64,
|
|
|
|
| 130 |
lgbm_model=store.lgbm_model,
|
| 131 |
cnn_model=store.cnn_model,
|
| 132 |
svm_model=store.svm_model,
|
| 133 |
+
pipeline_mode=effective_mode,
|
| 134 |
confidence_threshold=config.CONFIDENCE_THRESHOLD,
|
| 135 |
secondary_threshold=config.SECONDARY_THRESHOLD,
|
| 136 |
)
|
|
|
|
| 159 |
return pipelines
|
| 160 |
|
| 161 |
|
| 162 |
+
def _resolve_pipeline_mode(requested_mode: str | None) -> str:
|
| 163 |
+
"""
|
| 164 |
+
Resolve a per-request pipeline mode safely.
|
| 165 |
+
Falls back to configured default when requested mode is unavailable.
|
| 166 |
+
"""
|
| 167 |
+
default_mode = config.PIPELINE_MODE
|
| 168 |
+
if requested_mode is None:
|
| 169 |
+
return default_mode
|
| 170 |
+
|
| 171 |
+
available = set(_available_pipelines())
|
| 172 |
+
if requested_mode == "ensemble":
|
| 173 |
+
return "ensemble"
|
| 174 |
+
if requested_mode in available:
|
| 175 |
+
return requested_mode
|
| 176 |
+
|
| 177 |
+
logger.warning(
|
| 178 |
+
"Requested mode '%s' is unavailable. Falling back to '%s'. Available: %s",
|
| 179 |
+
requested_mode,
|
| 180 |
+
default_mode,
|
| 181 |
+
sorted(available),
|
| 182 |
+
)
|
| 183 |
+
return default_mode
|
| 184 |
+
|
| 185 |
+
|
| 186 |
# ---------------------------------------------------------------------------
|
| 187 |
# REST endpoints
|
| 188 |
# ---------------------------------------------------------------------------
|
|
|
|
| 216 |
@app.post("/api/predict", response_model=PredictionResponse)
|
| 217 |
async def predict_landmarks(body: LandmarkMessage):
|
| 218 |
"""REST fallback: send 63 landmark floats, receive prediction."""
|
| 219 |
+
return _run_ensemble(
|
| 220 |
+
body.landmarks,
|
| 221 |
+
image_b64=body.image_b64,
|
| 222 |
+
model_mode=body.model_mode,
|
| 223 |
+
)
|
| 224 |
|
| 225 |
|
| 226 |
@app.post("/api/predict/image", response_model=PredictionResponse)
|
|
|
|
| 262 |
msg = LandmarkMessage(**data)
|
| 263 |
session_id = msg.session_id
|
| 264 |
|
| 265 |
+
response = _run_ensemble(
|
| 266 |
+
msg.landmarks,
|
| 267 |
+
image_b64=msg.image_b64,
|
| 268 |
+
model_mode=msg.model_mode,
|
| 269 |
+
)
|
| 270 |
await ws.send_text(response.model_dump_json())
|
| 271 |
|
| 272 |
except ValueError as ve:
|
backend/app/schemas.py
CHANGED
|
@@ -3,7 +3,7 @@ Pydantic request / response schemas for SanketSetu backend.
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
-
from typing import List, Optional
|
| 7 |
|
| 8 |
from pydantic import BaseModel, Field, field_validator
|
| 9 |
|
|
@@ -20,6 +20,14 @@ class LandmarkMessage(BaseModel):
|
|
| 20 |
"""
|
| 21 |
landmarks: List[float] = Field(..., min_length=63, max_length=63)
|
| 22 |
session_id: str = Field(default="default")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@field_validator("landmarks")
|
| 25 |
@classmethod
|
|
@@ -45,6 +53,7 @@ class EnsembleMessage(BaseModel):
|
|
| 45 |
landmarks: List[float] = Field(..., min_length=63, max_length=63)
|
| 46 |
image_b64: Optional[str] = Field(default=None)
|
| 47 |
session_id: str = Field(default="default")
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
# ---------------------------------------------------------------------------
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
+
from typing import List, Optional, Literal
|
| 7 |
|
| 8 |
from pydantic import BaseModel, Field, field_validator
|
| 9 |
|
|
|
|
| 20 |
"""
|
| 21 |
landmarks: List[float] = Field(..., min_length=63, max_length=63)
|
| 22 |
session_id: str = Field(default="default")
|
| 23 |
+
model_mode: Optional[Literal["A", "B", "C", "ensemble"]] = Field(
|
| 24 |
+
default=None,
|
| 25 |
+
description="Optional per-request model mode override",
|
| 26 |
+
)
|
| 27 |
+
image_b64: Optional[str] = Field(
|
| 28 |
+
default=None,
|
| 29 |
+
description="Optional base-64 hand image used when model_mode='C' or ensemble needs C fallback",
|
| 30 |
+
)
|
| 31 |
|
| 32 |
@field_validator("landmarks")
|
| 33 |
@classmethod
|
|
|
|
| 53 |
landmarks: List[float] = Field(..., min_length=63, max_length=63)
|
| 54 |
image_b64: Optional[str] = Field(default=None)
|
| 55 |
session_id: str = Field(default="default")
|
| 56 |
+
model_mode: Optional[Literal["A", "B", "C", "ensemble"]] = Field(default=None)
|
| 57 |
|
| 58 |
|
| 59 |
# ---------------------------------------------------------------------------
|
fly.toml
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
# fly.toml — Fly.io deployment for SanketSetu backend
|
| 2 |
-
# Deploy: flyctl deploy --dockerfile Dockerfile (from repo root)
|
| 3 |
-
|
| 4 |
-
app = "sanketsetu-backend"
|
| 5 |
-
primary_region = "maa" # Mumbai — closest to India
|
| 6 |
-
|
| 7 |
-
[build]
|
| 8 |
-
dockerfile = "Dockerfile"
|
| 9 |
-
|
| 10 |
-
[http_service]
|
| 11 |
-
internal_port = 8000
|
| 12 |
-
force_https = true
|
| 13 |
-
auto_stop_machines = "stop"
|
| 14 |
-
auto_start_machines = true
|
| 15 |
-
min_machines_running = 0
|
| 16 |
-
processes = ["app"]
|
| 17 |
-
|
| 18 |
-
[http_service.concurrency]
|
| 19 |
-
type = "requests"
|
| 20 |
-
soft_limit = 10
|
| 21 |
-
hard_limit = 25
|
| 22 |
-
|
| 23 |
-
[vm]
|
| 24 |
-
size = "shared-cpu-2x" # 2 shared vCPUs, 512 MB RAM
|
| 25 |
-
memory = "1gb"
|
| 26 |
-
|
| 27 |
-
[[vm.mounts]]
|
| 28 |
-
# Optional: mount model weights externally to keep image small
|
| 29 |
-
# source = "sanketsetu_models"
|
| 30 |
-
# destination = "/models"
|
| 31 |
-
|
| 32 |
-
[env]
|
| 33 |
-
KERAS_BACKEND = "tensorflow"
|
| 34 |
-
TF_CPP_MIN_LOG_LEVEL = "3"
|
| 35 |
-
CUDA_VISIBLE_DEVICES = ""
|
| 36 |
-
TF_ENABLE_ONEDNN_OPTS = "0"
|
| 37 |
-
OMP_NUM_THREADS = "4"
|
| 38 |
-
PIPELINE_MODE = "ensemble"
|
| 39 |
-
CORS_ORIGINS = "https://sanketsetu.vercel.app,http://localhost:5173"
|
| 40 |
-
|
| 41 |
-
# Secrets to set via flyctl:
|
| 42 |
-
# flyctl secrets set CONFIDENCE_THRESHOLD=0.70
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/App.tsx
CHANGED
|
@@ -10,19 +10,64 @@ import { WebcamFeed } from './components/WebcamFeed'
|
|
| 10 |
import { PredictionHUD } from './components/PredictionHUD'
|
| 11 |
import { OnboardingGuide } from './components/OnboardingGuide'
|
| 12 |
import { Calibration } from './components/Calibration'
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
type AppStage = 'onboarding' | 'calibration' | 'running'
|
| 15 |
|
| 16 |
function App() {
|
| 17 |
// ── Stage management ─────────────────────────────────────────
|
| 18 |
const showOnboarding = !localStorage.getItem('sanketsetu-onboarded')
|
| 19 |
-
const [stage, setStage] = useState<AppStage>(showOnboarding ? 'onboarding' : '
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const handleOnboardingDone = () => {
|
| 22 |
localStorage.setItem('sanketsetu-onboarded', '1')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
setStage('calibration')
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
// ── Webcam ───────────────────────────────────────────────────
|
| 27 |
const { videoRef, isReady, error, facingMode, switchCamera } = useWebcam()
|
| 28 |
|
|
@@ -47,13 +92,21 @@ function App() {
|
|
| 47 |
|
| 48 |
// ── WebSocket ────────────────────────────────────────────────
|
| 49 |
const { lastPrediction, isConnected, latency, lowBandwidth, sendLandmarks } = useWebSocket()
|
|
|
|
| 50 |
|
| 51 |
// Send landmarks on every new frame
|
| 52 |
useEffect(() => {
|
| 53 |
if (stage === 'running' && landmarks) {
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
-
}, [landmarks,
|
| 57 |
|
| 58 |
// Was the last prediction recently (within 1.5s)?
|
| 59 |
const lastPredTs = useRef(0)
|
|
@@ -75,6 +128,18 @@ function App() {
|
|
| 75 |
)}
|
| 76 |
</AnimatePresence>
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
{/* ── Calibration overlay ────────────────────────────────── */}
|
| 79 |
<AnimatePresence>
|
| 80 |
{stage === 'calibration' && (
|
|
@@ -118,23 +183,24 @@ function App() {
|
|
| 118 |
</AnimatePresence>
|
| 119 |
|
| 120 |
{/* ── Header ─────────────────────────────────────────────── */}
|
| 121 |
-
<header className="flex items-center justify-between px-6 py-4">
|
| 122 |
-
<div className="flex items-center gap-3">
|
| 123 |
-
<Hand size={
|
| 124 |
-
<h1 className="text-xl font-bold tracking-wide" style={{ color: '#e2e8f0' }}>
|
| 125 |
Sanket<span className="glow-text">Setu</span>
|
| 126 |
-
<span className="ml-2 text-
|
| 127 |
</h1>
|
| 128 |
</div>
|
| 129 |
-
<div className="flex items-center gap-
|
| 130 |
-
{mpLoading && <span>Loading AI…</span>}
|
| 131 |
-
{
|
| 132 |
-
|
|
|
|
| 133 |
</div>
|
| 134 |
</header>
|
| 135 |
|
| 136 |
{/* ── Main content ───────────────────────────────────────── */}
|
| 137 |
-
<main className="flex-1 flex flex-col lg:flex-row items-start justify-center gap-6 px-4 pb-8 lg:px-8">
|
| 138 |
|
| 139 |
{/* Webcam panel */}
|
| 140 |
<motion.div
|
|
@@ -166,6 +232,7 @@ function App() {
|
|
| 166 |
isConnected={isConnected}
|
| 167 |
latency={latency}
|
| 168 |
lowBandwidth={lowBandwidth}
|
|
|
|
| 169 |
/>
|
| 170 |
</motion.div>
|
| 171 |
|
|
@@ -174,4 +241,40 @@ function App() {
|
|
| 174 |
)
|
| 175 |
}
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
export default App
|
|
|
|
| 10 |
import { PredictionHUD } from './components/PredictionHUD'
|
| 11 |
import { OnboardingGuide } from './components/OnboardingGuide'
|
| 12 |
import { Calibration } from './components/Calibration'
|
| 13 |
+
import { ModelSelector } from './components/ModelSelector'
|
| 14 |
+
import type { ModelMode } from './types'
|
| 15 |
|
| 16 |
+
type AppStage = 'onboarding' | 'model-select' | 'calibration' | 'running'
|
| 17 |
|
| 18 |
function App() {
|
| 19 |
// ── Stage management ─────────────────────────────────────────
|
| 20 |
const showOnboarding = !localStorage.getItem('sanketsetu-onboarded')
|
| 21 |
+
const [stage, setStage] = useState<AppStage>(showOnboarding ? 'onboarding' : 'model-select')
|
| 22 |
+
const savedModel = localStorage.getItem('sanketsetu-model-mode') as ModelMode | null
|
| 23 |
+
const [selectedModel, setSelectedModel] = useState<ModelMode>(savedModel ?? 'ensemble')
|
| 24 |
+
const [availableModes, setAvailableModes] = useState<Set<ModelMode>>(new Set(['ensemble']))
|
| 25 |
|
| 26 |
const handleOnboardingDone = () => {
|
| 27 |
localStorage.setItem('sanketsetu-onboarded', '1')
|
| 28 |
+
setStage('model-select')
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const handleModelContinue = () => {
|
| 32 |
+
localStorage.setItem('sanketsetu-model-mode', selectedModel)
|
| 33 |
setStage('calibration')
|
| 34 |
}
|
| 35 |
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
let active = true
|
| 38 |
+
const healthUrl = `${resolveBackendHttpBase()}/health`
|
| 39 |
+
|
| 40 |
+
const loadAvailability = async () => {
|
| 41 |
+
try {
|
| 42 |
+
const res = await fetch(healthUrl)
|
| 43 |
+
if (!res.ok) return
|
| 44 |
+
const data = (await res.json()) as { pipelines_available?: string[] }
|
| 45 |
+
if (!active) return
|
| 46 |
+
|
| 47 |
+
const next = new Set<ModelMode>(['ensemble'])
|
| 48 |
+
for (const mode of data.pipelines_available ?? []) {
|
| 49 |
+
if (mode === 'A' || mode === 'B' || mode === 'C') {
|
| 50 |
+
next.add(mode)
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
setAvailableModes(next)
|
| 54 |
+
} catch {
|
| 55 |
+
// Keep local defaults when backend health is unavailable.
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
loadAvailability()
|
| 60 |
+
return () => {
|
| 61 |
+
active = false
|
| 62 |
+
}
|
| 63 |
+
}, [])
|
| 64 |
+
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
if (selectedModel !== 'ensemble' && !availableModes.has(selectedModel)) {
|
| 67 |
+
setSelectedModel('ensemble')
|
| 68 |
+
}
|
| 69 |
+
}, [availableModes, selectedModel])
|
| 70 |
+
|
| 71 |
// ── Webcam ───────────────────────────────────────────────────
|
| 72 |
const { videoRef, isReady, error, facingMode, switchCamera } = useWebcam()
|
| 73 |
|
|
|
|
| 92 |
|
| 93 |
// ── WebSocket ────────────────────────────────────────────────
|
| 94 |
const { lastPrediction, isConnected, latency, lowBandwidth, sendLandmarks } = useWebSocket()
|
| 95 |
+
const imageCanvasRef = useRef<HTMLCanvasElement | null>(null)
|
| 96 |
|
| 97 |
// Send landmarks on every new frame
|
| 98 |
useEffect(() => {
|
| 99 |
if (stage === 'running' && landmarks) {
|
| 100 |
+
let imageB64: string | undefined
|
| 101 |
+
if (selectedModel === 'C' && videoRef.current) {
|
| 102 |
+
imageB64 = captureVideoFrame(videoRef.current, imageCanvasRef)
|
| 103 |
+
}
|
| 104 |
+
sendLandmarks(landmarks, {
|
| 105 |
+
modelMode: selectedModel,
|
| 106 |
+
imageB64,
|
| 107 |
+
})
|
| 108 |
}
|
| 109 |
+
}, [landmarks, selectedModel, sendLandmarks, stage, videoRef])
|
| 110 |
|
| 111 |
// Was the last prediction recently (within 1.5s)?
|
| 112 |
const lastPredTs = useRef(0)
|
|
|
|
| 128 |
)}
|
| 129 |
</AnimatePresence>
|
| 130 |
|
| 131 |
+
{/* ── Model selector overlay ─────────────────────────────── */}
|
| 132 |
+
<AnimatePresence>
|
| 133 |
+
{stage === 'model-select' && (
|
| 134 |
+
<ModelSelector
|
| 135 |
+
selectedMode={selectedModel}
|
| 136 |
+
availableModes={availableModes}
|
| 137 |
+
onSelectMode={setSelectedModel}
|
| 138 |
+
onContinue={handleModelContinue}
|
| 139 |
+
/>
|
| 140 |
+
)}
|
| 141 |
+
</AnimatePresence>
|
| 142 |
+
|
| 143 |
{/* ── Calibration overlay ────────────────────────────────── */}
|
| 144 |
<AnimatePresence>
|
| 145 |
{stage === 'calibration' && (
|
|
|
|
| 183 |
</AnimatePresence>
|
| 184 |
|
| 185 |
{/* ── Header ─────────────────────────────────────────────── */}
|
| 186 |
+
<header className="flex items-center justify-between px-3 py-3 sm:px-6 sm:py-4">
|
| 187 |
+
<div className="flex items-center gap-2 sm:gap-3">
|
| 188 |
+
<Hand size={20} style={{ color: '#00f5d4' }} />
|
| 189 |
+
<h1 className="text-base sm:text-xl font-bold tracking-wide" style={{ color: '#e2e8f0' }}>
|
| 190 |
Sanket<span className="glow-text">Setu</span>
|
| 191 |
+
<span className="hidden sm:inline ml-2 text-sm font-normal text-slate-500">| સંકેત-સેતુ</span>
|
| 192 |
</h1>
|
| 193 |
</div>
|
| 194 |
+
<div className="flex items-center gap-2 text-slate-500 text-xs">
|
| 195 |
+
{mpLoading && <span className="hidden sm:inline">Loading AI…</span>}
|
| 196 |
+
{mpLoading && <span className="sm:hidden">AI…</span>}
|
| 197 |
+
{mpError && <span className="text-rose-400 text-xs max-w-[120px] truncate">{mpError}</span>}
|
| 198 |
+
<Settings size={16} className="cursor-pointer hover:text-slate-300 transition-colors" />
|
| 199 |
</div>
|
| 200 |
</header>
|
| 201 |
|
| 202 |
{/* ── Main content ───────────────────────────────────────── */}
|
| 203 |
+
<main className="flex-1 flex flex-col lg:flex-row items-stretch lg:items-start justify-center gap-3 sm:gap-6 px-2 sm:px-4 pb-4 sm:pb-8 lg:px-8">
|
| 204 |
|
| 205 |
{/* Webcam panel */}
|
| 206 |
<motion.div
|
|
|
|
| 232 |
isConnected={isConnected}
|
| 233 |
latency={latency}
|
| 234 |
lowBandwidth={lowBandwidth}
|
| 235 |
+
selectedModel={selectedModel}
|
| 236 |
/>
|
| 237 |
</motion.div>
|
| 238 |
|
|
|
|
| 241 |
)
|
| 242 |
}
|
| 243 |
|
| 244 |
+
function captureVideoFrame(
|
| 245 |
+
video: HTMLVideoElement,
|
| 246 |
+
canvasRef: { current: HTMLCanvasElement | null },
|
| 247 |
+
): string | undefined {
|
| 248 |
+
if (!video.videoWidth || !video.videoHeight) return undefined
|
| 249 |
+
|
| 250 |
+
if (!canvasRef.current) {
|
| 251 |
+
canvasRef.current = document.createElement('canvas')
|
| 252 |
+
}
|
| 253 |
+
const canvas = canvasRef.current
|
| 254 |
+
canvas.width = 128
|
| 255 |
+
canvas.height = 128
|
| 256 |
+
|
| 257 |
+
const ctx = canvas.getContext('2d')
|
| 258 |
+
if (!ctx) return undefined
|
| 259 |
+
|
| 260 |
+
// Center-crop to square before resizing to model input size.
|
| 261 |
+
const side = Math.min(video.videoWidth, video.videoHeight)
|
| 262 |
+
const sx = (video.videoWidth - side) / 2
|
| 263 |
+
const sy = (video.videoHeight - side) / 2
|
| 264 |
+
ctx.drawImage(video, sx, sy, side, side, 0, 0, 128, 128)
|
| 265 |
+
|
| 266 |
+
return canvas.toDataURL('image/jpeg', 0.85).replace(/^data:image\/jpeg;base64,/, '')
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
function resolveBackendHttpBase(): string {
|
| 270 |
+
const envWs = import.meta.env.VITE_WS_URL as string | undefined
|
| 271 |
+
if (envWs) {
|
| 272 |
+
return envWs
|
| 273 |
+
.replace(/^wss:\/\//i, 'https://')
|
| 274 |
+
.replace(/^ws:\/\//i, 'http://')
|
| 275 |
+
}
|
| 276 |
+
if (import.meta.env.DEV) return 'http://localhost:8000'
|
| 277 |
+
return window.location.origin
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
export default App
|
frontend/src/components/ModelSelector.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from 'framer-motion'
|
| 2 |
+
import { Brain, Gauge, Layers, Aperture } from 'lucide-react'
|
| 3 |
+
import type { ComponentType } from 'react'
|
| 4 |
+
import type { ModelMode } from '../types'
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
selectedMode: ModelMode
|
| 8 |
+
availableModes: Set<ModelMode>
|
| 9 |
+
onSelectMode: (mode: ModelMode) => void
|
| 10 |
+
onContinue: () => void
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
type ModeMeta = {
|
| 14 |
+
key: ModelMode
|
| 15 |
+
title: string
|
| 16 |
+
subtitle: string
|
| 17 |
+
details: string
|
| 18 |
+
icon: ComponentType<{ size?: number }>
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const MODE_OPTIONS: ModeMeta[] = [
|
| 22 |
+
{
|
| 23 |
+
key: 'ensemble',
|
| 24 |
+
title: 'Ensemble (Recommended)',
|
| 25 |
+
subtitle: 'Balanced accuracy and reliability',
|
| 26 |
+
details: 'Starts with A, falls back to B and C when confidence is low.',
|
| 27 |
+
icon: Layers,
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
key: 'A',
|
| 31 |
+
title: 'Pipeline A',
|
| 32 |
+
subtitle: 'Fastest response',
|
| 33 |
+
details: 'XGBoost using hand landmarks only.',
|
| 34 |
+
icon: Gauge,
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
key: 'B',
|
| 38 |
+
title: 'Pipeline B',
|
| 39 |
+
subtitle: 'Stronger landmark reasoning',
|
| 40 |
+
details: 'Autoencoder embeddings with LightGBM.',
|
| 41 |
+
icon: Brain,
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
key: 'C',
|
| 45 |
+
title: 'Pipeline C',
|
| 46 |
+
subtitle: 'Image-based fallback model',
|
| 47 |
+
details: 'CNN features with SVM using webcam snapshots.',
|
| 48 |
+
icon: Aperture,
|
| 49 |
+
},
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
export function ModelSelector({
|
| 53 |
+
selectedMode,
|
| 54 |
+
availableModes,
|
| 55 |
+
onSelectMode,
|
| 56 |
+
onContinue,
|
| 57 |
+
}: Props) {
|
| 58 |
+
const canContinue = selectedMode === 'ensemble' || availableModes.has(selectedMode)
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div className="fixed inset-0 z-40 flex items-center justify-center px-4">
|
| 62 |
+
<motion.div
|
| 63 |
+
initial={{ opacity: 0, y: 20 }}
|
| 64 |
+
animate={{ opacity: 1, y: 0 }}
|
| 65 |
+
className="w-full max-w-4xl rounded-2xl p-4 sm:p-6"
|
| 66 |
+
style={{
|
| 67 |
+
background: 'rgba(5,8,22,0.92)',
|
| 68 |
+
backdropFilter: 'blur(16px)',
|
| 69 |
+
border: '1px solid rgba(255,255,255,0.12)',
|
| 70 |
+
boxShadow: '0 12px 36px rgba(0,0,0,0.45)',
|
| 71 |
+
}}
|
| 72 |
+
>
|
| 73 |
+
<h2 className="text-2xl sm:text-3xl font-bold glow-text text-center">Choose Recognition Model</h2>
|
| 74 |
+
<p className="text-slate-400 text-center mt-2 text-sm sm:text-base">
|
| 75 |
+
Select how predictions should be generated for this session.
|
| 76 |
+
</p>
|
| 77 |
+
|
| 78 |
+
<div className="mt-5 grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 79 |
+
{MODE_OPTIONS.map((option) => {
|
| 80 |
+
const Icon = option.icon
|
| 81 |
+
const selected = selectedMode === option.key
|
| 82 |
+
const available = option.key === 'ensemble' || availableModes.has(option.key)
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<button
|
| 86 |
+
key={option.key}
|
| 87 |
+
type="button"
|
| 88 |
+
onClick={() => onSelectMode(option.key)}
|
| 89 |
+
className="text-left rounded-xl p-4 transition-all"
|
| 90 |
+
style={{
|
| 91 |
+
background: selected ? 'rgba(0,245,212,0.12)' : 'rgba(255,255,255,0.04)',
|
| 92 |
+
border: selected
|
| 93 |
+
? '1px solid rgba(0,245,212,0.55)'
|
| 94 |
+
: '1px solid rgba(255,255,255,0.10)',
|
| 95 |
+
opacity: available ? 1 : 0.5,
|
| 96 |
+
}}
|
| 97 |
+
>
|
| 98 |
+
<div className="flex items-center justify-between gap-2">
|
| 99 |
+
<div className="flex items-center gap-2">
|
| 100 |
+
<Icon size={16} />
|
| 101 |
+
<span className="font-semibold text-slate-100">{option.title}</span>
|
| 102 |
+
</div>
|
| 103 |
+
{!available && <span className="text-xs text-rose-300">Unavailable</span>}
|
| 104 |
+
</div>
|
| 105 |
+
<p className="text-sm text-slate-300 mt-2">{option.subtitle}</p>
|
| 106 |
+
<p className="text-xs text-slate-500 mt-1">{option.details}</p>
|
| 107 |
+
</button>
|
| 108 |
+
)
|
| 109 |
+
})}
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3">
|
| 113 |
+
<p className="text-xs text-slate-500">
|
| 114 |
+
Tip: Ensemble is best for most users. Use A for low-latency demos.
|
| 115 |
+
</p>
|
| 116 |
+
<button
|
| 117 |
+
type="button"
|
| 118 |
+
onClick={onContinue}
|
| 119 |
+
disabled={!canContinue}
|
| 120 |
+
className="px-5 py-2.5 rounded-lg font-semibold disabled:cursor-not-allowed"
|
| 121 |
+
style={{
|
| 122 |
+
background: canContinue ? 'rgba(0,245,212,0.22)' : 'rgba(148,163,184,0.2)',
|
| 123 |
+
color: canContinue ? '#99f6e4' : '#94a3b8',
|
| 124 |
+
border: canContinue
|
| 125 |
+
? '1px solid rgba(0,245,212,0.45)'
|
| 126 |
+
: '1px solid rgba(148,163,184,0.25)',
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
+
Continue
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
</motion.div>
|
| 133 |
+
</div>
|
| 134 |
+
)
|
| 135 |
+
}
|
frontend/src/components/PredictionHUD.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
import { useEffect, useRef, useState } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { Cpu, Zap, Clock } from 'lucide-react';
|
| 4 |
-
import type { PredictionResponse } from '../types';
|
| 5 |
|
| 6 |
interface Props {
|
| 7 |
prediction: PredictionResponse | null;
|
| 8 |
isConnected: boolean;
|
| 9 |
latency: number;
|
| 10 |
lowBandwidth?: boolean;
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
const PIPELINE_COLORS: Record<string, string> = {
|
|
@@ -21,6 +22,7 @@ const PIPELINE_LABELS: Record<string, string> = {
|
|
| 21 |
A: 'XGBoost',
|
| 22 |
B: 'AE + LGBM',
|
| 23 |
C: 'CNN + SVM',
|
|
|
|
| 24 |
};
|
| 25 |
|
| 26 |
function confidenceColor(c: number) {
|
|
@@ -47,7 +49,7 @@ function ConfidenceBar({ value }: { value: number }) {
|
|
| 47 |
* Floating HUD panel that shows the current sign prediction, confidence,
|
| 48 |
* active pipeline, latency and a rolling history of the last 10 signs.
|
| 49 |
*/
|
| 50 |
-
export function PredictionHUD({ prediction, isConnected, latency, lowBandwidth = false }: Props) {
|
| 51 |
const [history, setHistory] = useState<PredictionResponse[]>([]);
|
| 52 |
const prevSignRef = useRef<string | null>(null);
|
| 53 |
|
|
@@ -62,9 +64,10 @@ export function PredictionHUD({ prediction, isConnected, latency, lowBandwidth =
|
|
| 62 |
const pipelineKey = prediction?.pipeline ?? 'A';
|
| 63 |
const pipelineColor = PIPELINE_COLORS[pipelineKey] ?? 'text-slate-400';
|
| 64 |
const pipelineLabel = PIPELINE_LABELS[pipelineKey] ?? pipelineKey;
|
|
|
|
| 65 |
|
| 66 |
return (
|
| 67 |
-
<div className="glass glow-border flex flex-col gap-4 p-5 min-w-[260px] max-w-xs
|
| 68 |
{/* Connection status */}
|
| 69 |
<div className="flex items-center justify-between text-xs text-slate-400">
|
| 70 |
<span className="flex items-center gap-1.5">
|
|
@@ -90,90 +93,99 @@ export function PredictionHUD({ prediction, isConnected, latency, lowBandwidth =
|
|
| 90 |
</span>
|
| 91 |
</div>
|
| 92 |
|
| 93 |
-
{/* Main sign
|
| 94 |
-
<div className="flex flex-col
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
{/*
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
<
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</div>
|
| 130 |
-
|
| 131 |
-
</div>
|
| 132 |
-
)}
|
| 133 |
-
</div>
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
|
|
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
</div>
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { Cpu, Zap, Clock } from 'lucide-react';
|
| 4 |
+
import type { ModelMode, PredictionResponse } from '../types';
|
| 5 |
|
| 6 |
interface Props {
|
| 7 |
prediction: PredictionResponse | null;
|
| 8 |
isConnected: boolean;
|
| 9 |
latency: number;
|
| 10 |
lowBandwidth?: boolean;
|
| 11 |
+
selectedModel: ModelMode;
|
| 12 |
}
|
| 13 |
|
| 14 |
const PIPELINE_COLORS: Record<string, string> = {
|
|
|
|
| 22 |
A: 'XGBoost',
|
| 23 |
B: 'AE + LGBM',
|
| 24 |
C: 'CNN + SVM',
|
| 25 |
+
ensemble: 'A -> B -> C fallback',
|
| 26 |
};
|
| 27 |
|
| 28 |
function confidenceColor(c: number) {
|
|
|
|
| 49 |
* Floating HUD panel that shows the current sign prediction, confidence,
|
| 50 |
* active pipeline, latency and a rolling history of the last 10 signs.
|
| 51 |
*/
|
| 52 |
+
export function PredictionHUD({ prediction, isConnected, latency, lowBandwidth = false, selectedModel }: Props) {
|
| 53 |
const [history, setHistory] = useState<PredictionResponse[]>([]);
|
| 54 |
const prevSignRef = useRef<string | null>(null);
|
| 55 |
|
|
|
|
| 64 |
const pipelineKey = prediction?.pipeline ?? 'A';
|
| 65 |
const pipelineColor = PIPELINE_COLORS[pipelineKey] ?? 'text-slate-400';
|
| 66 |
const pipelineLabel = PIPELINE_LABELS[pipelineKey] ?? pipelineKey;
|
| 67 |
+
const selectedLabel = PIPELINE_LABELS[selectedModel] ?? selectedModel;
|
| 68 |
|
| 69 |
return (
|
| 70 |
+
<div className="glass glow-border flex flex-col gap-3 sm:gap-4 p-3 sm:p-5 w-full lg:min-w-[260px] lg:max-w-xs">
|
| 71 |
{/* Connection status */}
|
| 72 |
<div className="flex items-center justify-between text-xs text-slate-400">
|
| 73 |
<span className="flex items-center gap-1.5">
|
|
|
|
| 93 |
</span>
|
| 94 |
</div>
|
| 95 |
|
| 96 |
+
{/* Main content: sign + history side by side on mobile, stacked on lg */}
|
| 97 |
+
<div className="flex lg:flex-col gap-3">
|
| 98 |
+
{/* Main sign display */}
|
| 99 |
+
<div className="flex flex-col items-center justify-center gap-1 py-1 sm:py-2 min-w-[80px] sm:min-w-0">
|
| 100 |
+
<AnimatePresence mode="popLayout">
|
| 101 |
+
{prediction ? (
|
| 102 |
+
<motion.div
|
| 103 |
+
key={prediction.sign}
|
| 104 |
+
initial={{ opacity: 0, scale: 0.6, y: 10 }}
|
| 105 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 106 |
+
exit={{ opacity: 0, scale: 0.4, y: -10 }}
|
| 107 |
+
transition={{ type: 'spring', stiffness: 300, damping: 22 }}
|
| 108 |
+
className="glow-text font-bold select-none leading-none"
|
| 109 |
+
style={{ color: '#00f5d4', fontSize: 'clamp(2.5rem, 10vw, 4.5rem)' }}
|
| 110 |
+
>
|
| 111 |
+
{prediction.sign}
|
| 112 |
+
</motion.div>
|
| 113 |
+
) : (
|
| 114 |
+
<motion.div
|
| 115 |
+
key="placeholder"
|
| 116 |
+
initial={{ opacity: 0 }}
|
| 117 |
+
animate={{ opacity: 0.3 }}
|
| 118 |
+
exit={{ opacity: 0 }}
|
| 119 |
+
className="font-bold text-slate-500 select-none leading-none"
|
| 120 |
+
style={{ fontSize: 'clamp(2rem, 8vw, 3.5rem)' }}
|
| 121 |
+
>
|
| 122 |
+
?
|
| 123 |
+
</motion.div>
|
| 124 |
+
)}
|
| 125 |
+
</AnimatePresence>
|
| 126 |
+
<span className="text-[10px] text-slate-500 mt-0.5">Current sign</span>
|
| 127 |
+
</div>
|
| 128 |
|
| 129 |
+
{/* Right column on mobile: confidence + pipeline + history */}
|
| 130 |
+
<div className="flex flex-1 flex-col gap-2 justify-center">
|
| 131 |
+
{/* Confidence bar */}
|
| 132 |
+
{prediction && (
|
| 133 |
+
<div className="w-full">
|
| 134 |
+
<div className="flex justify-between text-xs text-slate-400 mb-1">
|
| 135 |
+
<span>Confidence</span>
|
| 136 |
+
<span style={{ color: confidenceColor(prediction.confidence) }}>
|
| 137 |
+
{Math.round(prediction.confidence * 100)}%
|
| 138 |
+
</span>
|
| 139 |
+
</div>
|
| 140 |
+
<ConfidenceBar value={prediction.confidence} />
|
| 141 |
</div>
|
| 142 |
+
)}
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
{/* Pipeline badge */}
|
| 145 |
+
{prediction && (
|
| 146 |
+
<div className="flex items-center gap-1.5 text-xs">
|
| 147 |
+
<Cpu size={12} className={pipelineColor} />
|
| 148 |
+
<span className={pipelineColor}>Pipeline {pipelineKey}</span>
|
| 149 |
+
<span className="text-slate-500">·</span>
|
| 150 |
+
<span className="text-slate-400">{pipelineLabel}</span>
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
|
| 154 |
+
<div className="text-xs text-slate-500">
|
| 155 |
+
Selected mode: <span className="text-slate-300">{selectedModel} ({selectedLabel})</span>
|
| 156 |
+
</div>
|
| 157 |
|
| 158 |
+
{/* History */}
|
| 159 |
+
<div>
|
| 160 |
+
<p className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
| 161 |
+
<Zap size={11} /> Recent signs
|
| 162 |
+
</p>
|
| 163 |
+
<div className="flex flex-wrap gap-1">
|
| 164 |
+
<AnimatePresence>
|
| 165 |
+
{history.map((h, i) => (
|
| 166 |
+
<motion.span
|
| 167 |
+
key={`${h.sign}-${i}`}
|
| 168 |
+
initial={{ opacity: 0, scale: 0.5 }}
|
| 169 |
+
animate={{ opacity: 1 - i * 0.08, scale: 1 }}
|
| 170 |
+
exit={{ opacity: 0, scale: 0.3 }}
|
| 171 |
+
transition={{ duration: 0.2 }}
|
| 172 |
+
className="px-1.5 py-0.5 rounded-full font-semibold"
|
| 173 |
+
style={{
|
| 174 |
+
background: 'rgba(0,245,212,0.08)',
|
| 175 |
+
border: '1px solid rgba(0,245,212,0.2)',
|
| 176 |
+
color: '#00f5d4',
|
| 177 |
+
fontSize: i === 0 ? '1rem' : '0.75rem',
|
| 178 |
+
}}
|
| 179 |
+
>
|
| 180 |
+
{h.sign}
|
| 181 |
+
</motion.span>
|
| 182 |
+
))}
|
| 183 |
+
</AnimatePresence>
|
| 184 |
+
{history.length === 0 && (
|
| 185 |
+
<span className="text-xs text-slate-600 italic">None yet</span>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
</div>
|
| 190 |
</div>
|
| 191 |
</div>
|
frontend/src/components/WebcamFeed.tsx
CHANGED
|
@@ -49,7 +49,7 @@ export function WebcamFeed({
|
|
| 49 |
ref={containerRef}
|
| 50 |
className="relative rounded-2xl overflow-hidden w-full max-w-2xl"
|
| 51 |
style={{
|
| 52 |
-
aspectRatio: '16/9',
|
| 53 |
border: '1px solid rgba(0,245,212,0.2)',
|
| 54 |
boxShadow: '0 0 30px rgba(0,245,212,0.08)',
|
| 55 |
background: '#0a0a1a',
|
|
|
|
| 49 |
ref={containerRef}
|
| 50 |
className="relative rounded-2xl overflow-hidden w-full max-w-2xl"
|
| 51 |
style={{
|
| 52 |
+
aspectRatio: window.innerWidth < 640 ? '4/3' : '16/9',
|
| 53 |
border: '1px solid rgba(0,245,212,0.2)',
|
| 54 |
boxShadow: '0 0 30px rgba(0,245,212,0.08)',
|
| 55 |
background: '#0a0a1a',
|
frontend/src/hooks/useMediaPipe.ts
CHANGED
|
@@ -47,14 +47,30 @@ export function useMediaPipe(): MediaPipeState {
|
|
| 47 |
(async () => {
|
| 48 |
try {
|
| 49 |
const vision = await FilesetResolver.forVisionTasks(WASM_URL);
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
if (!cancelled) {
|
| 59 |
landmarkerRef.current = hl;
|
| 60 |
setIsLoading(false);
|
|
|
|
| 47 |
(async () => {
|
| 48 |
try {
|
| 49 |
const vision = await FilesetResolver.forVisionTasks(WASM_URL);
|
| 50 |
+
|
| 51 |
+
// Try GPU first for best performance; fall back to CPU if unavailable.
|
| 52 |
+
let hl: HandLandmarker | null = null;
|
| 53 |
+
try {
|
| 54 |
+
hl = await HandLandmarker.createFromOptions(vision, {
|
| 55 |
+
baseOptions: { modelAssetPath: MODEL_URL, delegate: 'GPU' },
|
| 56 |
+
runningMode: 'VIDEO',
|
| 57 |
+
numHands: 1,
|
| 58 |
+
minHandDetectionConfidence: 0.4,
|
| 59 |
+
minHandPresenceConfidence: 0.4,
|
| 60 |
+
minTrackingConfidence: 0.4,
|
| 61 |
+
});
|
| 62 |
+
} catch {
|
| 63 |
+
console.warn('GPU delegate unavailable, falling back to CPU.');
|
| 64 |
+
hl = await HandLandmarker.createFromOptions(vision, {
|
| 65 |
+
baseOptions: { modelAssetPath: MODEL_URL, delegate: 'CPU' },
|
| 66 |
+
runningMode: 'VIDEO',
|
| 67 |
+
numHands: 1,
|
| 68 |
+
minHandDetectionConfidence: 0.4,
|
| 69 |
+
minHandPresenceConfidence: 0.4,
|
| 70 |
+
minTrackingConfidence: 0.4,
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
if (!cancelled) {
|
| 75 |
landmarkerRef.current = hl;
|
| 76 |
setIsLoading(false);
|
frontend/src/hooks/useWebSocket.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
import { useEffect, useRef, useState, useCallback } from 'react';
|
| 2 |
-
import type { PredictionResponse } from '../types';
|
| 3 |
|
| 4 |
-
// Derive WebSocket base URL
|
| 5 |
-
//
|
| 6 |
function _defaultWsUrl(): string {
|
| 7 |
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL as string;
|
| 8 |
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
|
|
|
|
|
|
|
|
| 9 |
return `${proto}://${window.location.host}`;
|
| 10 |
}
|
| 11 |
const WS_URL = _defaultWsUrl();
|
|
@@ -20,7 +23,14 @@ export interface WebSocketState {
|
|
| 20 |
isConnected: boolean;
|
| 21 |
latency: number;
|
| 22 |
lowBandwidth: boolean;
|
| 23 |
-
sendLandmarks: (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
/**
|
|
@@ -92,7 +102,14 @@ export function useWebSocket(): WebSocketState {
|
|
| 92 |
}, [connect]);
|
| 93 |
|
| 94 |
/** Throttled send — adapts to 5fps in low-bandwidth mode (latency > 500ms) */
|
| 95 |
-
const sendLandmarks = useCallback((
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
const ws = wsRef.current;
|
| 97 |
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
| 98 |
|
|
@@ -103,7 +120,12 @@ export function useWebSocket(): WebSocketState {
|
|
| 103 |
lastSendTime.current = now;
|
| 104 |
|
| 105 |
inflightTs.current = now;
|
| 106 |
-
ws.send(JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}, [lowBandwidth]);
|
| 108 |
|
| 109 |
return { lastPrediction, isConnected, latency, lowBandwidth, sendLandmarks };
|
|
|
|
| 1 |
import { useEffect, useRef, useState, useCallback } from 'react';
|
| 2 |
+
import type { ModelMode, PredictionResponse } from '../types';
|
| 3 |
|
| 4 |
+
// Derive WebSocket base URL.
|
| 5 |
+
// Priority: VITE_WS_URL env var → dev fallback (port 8000) → same host (production).
|
| 6 |
function _defaultWsUrl(): string {
|
| 7 |
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL as string;
|
| 8 |
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
| 9 |
+
// In Vite dev mode the frontend is served on 5173 but FastAPI runs on 8000.
|
| 10 |
+
if (import.meta.env.DEV) return `${proto}://localhost:8000`;
|
| 11 |
+
// In production the backend is co-located (HF Spaces Docker).
|
| 12 |
return `${proto}://${window.location.host}`;
|
| 13 |
}
|
| 14 |
const WS_URL = _defaultWsUrl();
|
|
|
|
| 23 |
isConnected: boolean;
|
| 24 |
latency: number;
|
| 25 |
lowBandwidth: boolean;
|
| 26 |
+
sendLandmarks: (
|
| 27 |
+
landmarks: number[],
|
| 28 |
+
options?: {
|
| 29 |
+
sessionId?: string;
|
| 30 |
+
modelMode?: ModelMode;
|
| 31 |
+
imageB64?: string;
|
| 32 |
+
},
|
| 33 |
+
) => void;
|
| 34 |
}
|
| 35 |
|
| 36 |
/**
|
|
|
|
| 102 |
}, [connect]);
|
| 103 |
|
| 104 |
/** Throttled send — adapts to 5fps in low-bandwidth mode (latency > 500ms) */
|
| 105 |
+
const sendLandmarks = useCallback((
|
| 106 |
+
landmarks: number[],
|
| 107 |
+
options?: {
|
| 108 |
+
sessionId?: string;
|
| 109 |
+
modelMode?: ModelMode;
|
| 110 |
+
imageB64?: string;
|
| 111 |
+
},
|
| 112 |
+
) => {
|
| 113 |
const ws = wsRef.current;
|
| 114 |
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
| 115 |
|
|
|
|
| 120 |
lastSendTime.current = now;
|
| 121 |
|
| 122 |
inflightTs.current = now;
|
| 123 |
+
ws.send(JSON.stringify({
|
| 124 |
+
landmarks,
|
| 125 |
+
session_id: options?.sessionId ?? 'browser',
|
| 126 |
+
model_mode: options?.modelMode,
|
| 127 |
+
image_b64: options?.imageB64,
|
| 128 |
+
}));
|
| 129 |
}, [lowBandwidth]);
|
| 130 |
|
| 131 |
return { lastPrediction, isConnected, latency, lowBandwidth, sendLandmarks };
|
frontend/src/lib/landmarkUtils.ts
CHANGED
|
@@ -19,18 +19,20 @@ export interface RawLandmark {
|
|
| 19 |
|
| 20 |
/**
|
| 21 |
* Convert MediaPipe NormalizedLandmark[] (21 points) to a flat 63-element
|
| 22 |
-
* array
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
*/
|
| 24 |
export function normaliseLandmarks(raw: RawLandmark[]): number[] {
|
| 25 |
if (raw.length !== 21) {
|
| 26 |
throw new Error(`Expected 21 landmarks, got ${raw.length}`);
|
| 27 |
}
|
| 28 |
|
| 29 |
-
const wrist = raw[0];
|
| 30 |
-
|
| 31 |
const flat: number[] = [];
|
| 32 |
for (const lm of raw) {
|
| 33 |
-
flat.push(lm.x
|
| 34 |
}
|
| 35 |
return flat; // length 63
|
| 36 |
}
|
|
|
|
| 19 |
|
| 20 |
/**
|
| 21 |
* Convert MediaPipe NormalizedLandmark[] (21 points) to a flat 63-element
|
| 22 |
+
* array of raw [0,1]-normalised coordinates as expected by the trained models.
|
| 23 |
+
*
|
| 24 |
+
* The XGBoost, Autoencoder+LGBM models were trained directly on the raw
|
| 25 |
+
* MediaPipe landmark coordinates (x, y, z per landmark, no wrist-centering).
|
| 26 |
+
* Sending wrist-subtracted coords produces incorrect predictions.
|
| 27 |
*/
|
| 28 |
export function normaliseLandmarks(raw: RawLandmark[]): number[] {
|
| 29 |
if (raw.length !== 21) {
|
| 30 |
throw new Error(`Expected 21 landmarks, got ${raw.length}`);
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
| 33 |
const flat: number[] = [];
|
| 34 |
for (const lm of raw) {
|
| 35 |
+
flat.push(lm.x, lm.y, lm.z);
|
| 36 |
}
|
| 37 |
return flat; // length 63
|
| 38 |
}
|
frontend/src/types.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
| 2 |
* Shared TypeScript types for SanketSetu frontend.
|
| 3 |
*/
|
| 4 |
|
|
|
|
|
|
|
| 5 |
export interface PredictionResponse {
|
| 6 |
sign: string;
|
| 7 |
confidence: number;
|
| 8 |
-
pipeline: string;
|
| 9 |
label_index: number;
|
| 10 |
probabilities?: number[];
|
| 11 |
latency_ms?: number;
|
|
|
|
| 2 |
* Shared TypeScript types for SanketSetu frontend.
|
| 3 |
*/
|
| 4 |
|
| 5 |
+
export type ModelMode = 'A' | 'B' | 'C' | 'ensemble'
|
| 6 |
+
|
| 7 |
export interface PredictionResponse {
|
| 8 |
sign: string;
|
| 9 |
confidence: number;
|
| 10 |
+
pipeline: ModelMode | `${'A' | 'B' | 'C'}+${string}`;
|
| 11 |
label_index: number;
|
| 12 |
probabilities?: number[];
|
| 13 |
latency_ms?: number;
|