devrajsinh2012 commited on
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 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
- PIPELINE_A_MODEL = os.path.join(WEIGHTS_DIR, "Mediapipe_XGBoost", "model.pkl")
28
- PIPELINE_B_AE = os.path.join(WEIGHTS_DIR, "CNN_Autoencoder_LightGBM", "autoencoder_model.pkl")
29
- PIPELINE_B_LGBM = os.path.join(WEIGHTS_DIR, "CNN_Autoencoder_LightGBM", "lgbm_model.pkl")
30
- PIPELINE_C_CNN = os.path.join(WEIGHTS_DIR, "CNN_PreTrained", "cnn_model.pkl")
31
- PIPELINE_C_SVM = os.path.join(WEIGHTS_DIR, "CNN_PreTrained", "svm_model.pkl")
 
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=config.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(body.landmarks)
 
 
 
 
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(msg.landmarks)
 
 
 
 
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' : 'calibration')
 
 
 
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
- sendLandmarks(landmarks)
 
 
 
 
 
 
 
55
  }
56
- }, [landmarks, stage, sendLandmarks])
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={22} style={{ color: '#00f5d4' }} />
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-base font-normal text-slate-500">| સંકેત-સેતુ</span>
127
  </h1>
128
  </div>
129
- <div className="flex items-center gap-3 text-slate-500 text-xs">
130
- {mpLoading && <span>Loading AI…</span>}
131
- {mpError && <span className="text-rose-400">{mpError}</span>}
132
- <Settings size={18} className="cursor-pointer hover:text-slate-300 transition-colors" />
 
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 w-full">
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 display */}
94
- <div className="flex flex-col items-center gap-1 py-2">
95
- <AnimatePresence mode="popLayout">
96
- {prediction ? (
97
- <motion.div
98
- key={prediction.sign}
99
- initial={{ opacity: 0, scale: 0.6, y: 10 }}
100
- animate={{ opacity: 1, scale: 1, y: 0 }}
101
- exit={{ opacity: 0, scale: 0.4, y: -10 }}
102
- transition={{ type: 'spring', stiffness: 300, damping: 22 }}
103
- className="glow-text text-7xl font-bold select-none"
104
- style={{ color: '#00f5d4' }}
105
- >
106
- {prediction.sign}
107
- </motion.div>
108
- ) : (
109
- <motion.div
110
- key="placeholder"
111
- initial={{ opacity: 0 }}
112
- animate={{ opacity: 0.3 }}
113
- exit={{ opacity: 0 }}
114
- className="text-5xl font-bold text-slate-500 select-none"
115
- >
116
- ?
117
- </motion.div>
118
- )}
119
- </AnimatePresence>
 
 
 
 
 
120
 
121
- {/* Confidence bar */}
122
- {prediction && (
123
- <div className="w-full px-2 mt-1">
124
- <div className="flex justify-between text-xs text-slate-400 mb-1">
125
- <span>Confidence</span>
126
- <span style={{ color: confidenceColor(prediction.confidence) }}>
127
- {Math.round(prediction.confidence * 100)}%
128
- </span>
 
 
 
 
129
  </div>
130
- <ConfidenceBar value={prediction.confidence} />
131
- </div>
132
- )}
133
- </div>
134
 
135
- {/* Pipeline badge */}
136
- {prediction && (
137
- <div className="flex items-center gap-1.5 text-xs">
138
- <Cpu size={12} className={pipelineColor} />
139
- <span className={pipelineColor}>Pipeline {pipelineKey}</span>
140
- <span className="text-slate-500">·</span>
141
- <span className="text-slate-400">{pipelineLabel}</span>
142
- </div>
143
- )}
144
 
145
- {/* Divider */}
146
- <div className="border-t" style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
 
147
 
148
- {/* History */}
149
- <div className="flex flex-col gap-1">
150
- <p className="text-xs text-slate-500 mb-1 flex items-center gap-1">
151
- <Zap size={11} /> Recent signs
152
- </p>
153
- <div className="flex flex-wrap gap-1.5">
154
- <AnimatePresence>
155
- {history.map((h, i) => (
156
- <motion.span
157
- key={`${h.sign}-${i}`}
158
- initial={{ opacity: 0, scale: 0.5 }}
159
- animate={{ opacity: 1 - i * 0.08, scale: 1 }}
160
- exit={{ opacity: 0, scale: 0.3 }}
161
- transition={{ duration: 0.2 }}
162
- className="px-2 py-0.5 rounded-full text-sm font-semibold"
163
- style={{
164
- background: 'rgba(0,245,212,0.08)',
165
- border: '1px solid rgba(0,245,212,0.2)',
166
- color: '#00f5d4',
167
- fontSize: i === 0 ? '1.1rem' : '0.85rem',
168
- }}
169
- >
170
- {h.sign}
171
- </motion.span>
172
- ))}
173
- </AnimatePresence>
174
- {history.length === 0 && (
175
- <span className="text-xs text-slate-600 italic">None yet</span>
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
- const hl = await HandLandmarker.createFromOptions(vision, {
51
- baseOptions: { modelAssetPath: MODEL_URL, delegate: 'GPU' },
52
- runningMode: 'VIDEO',
53
- numHands: 1,
54
- minHandDetectionConfidence: 0.5,
55
- minHandPresenceConfidence: 0.5,
56
- minTrackingConfidence: 0.5,
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 from the current page origin so the hook works
5
- // on any deployment (HF Space, Vercel + backend, localhost) without extra config.
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: (landmarks: number[], sessionId?: string) => void;
 
 
 
 
 
 
 
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((landmarks: number[], sessionId = 'browser') => {
 
 
 
 
 
 
 
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({ landmarks, session_id: sessionId }));
 
 
 
 
 
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, then subtract the wrist position to centre the hand.
 
 
 
 
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 - wrist.x, lm.y - wrist.y, lm.z - wrist.z);
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;