volfiros commited on
Commit
ff91fbf
·
1 Parent(s): 98f83eb

feat: websocket endpoint, frontend utilities, face detection and pulse hook

Browse files
backend/main.py CHANGED
@@ -1,15 +1,49 @@
1
- from fastapi import FastAPI
 
 
2
  from fastapi.staticfiles import StaticFiles
3
  from fastapi.responses import FileResponse
 
 
4
 
5
  app = FastAPI()
6
 
 
 
 
 
 
 
 
7
  @app.get("/health")
8
  async def health():
9
  return {"status": "ok"}
10
 
11
- app.mount("/assets", StaticFiles(directory="static/assets"), name="assets")
12
 
13
- @app.get("/{full_path:path}")
14
- async def serve_frontend(full_path: str):
15
- return FileResponse("static/index.html")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
4
  from fastapi.staticfiles import StaticFiles
5
  from fastapi.responses import FileResponse
6
+ from pydantic import BaseModel
7
+ from .signal_processor import SignalProcessor
8
 
9
  app = FastAPI()
10
 
11
+
12
+ class FrameData(BaseModel):
13
+ r: float
14
+ g: float
15
+ b: float
16
+
17
+
18
  @app.get("/health")
19
  async def health():
20
  return {"status": "ok"}
21
 
 
22
 
23
+ @app.websocket("/ws")
24
+ async def websocket_endpoint(websocket: WebSocket):
25
+ await websocket.accept()
26
+ processor = SignalProcessor()
27
+ try:
28
+ while True:
29
+ try:
30
+ data = await websocket.receive_json()
31
+ frame = FrameData(**data)
32
+ result = processor.process(frame.r, frame.g, frame.b)
33
+ if processor.just_computed:
34
+ await websocket.send_json(result)
35
+ except WebSocketDisconnect:
36
+ break
37
+ except Exception as e:
38
+ logging.getLogger("uvicorn").exception("WebSocket processing error")
39
+ await websocket.send_json({"error": str(e), "status": "error"})
40
+ finally:
41
+ del processor
42
+
43
+
44
+ if os.path.isdir("static"):
45
+ app.mount("/assets", StaticFiles(directory="static/assets"), name="assets")
46
+
47
+ @app.get("/{full_path:path}")
48
+ async def serve_frontend(full_path: str):
49
+ return FileResponse("static/index.html")
backend/signal_processor.py CHANGED
@@ -24,6 +24,7 @@ class SignalProcessor:
24
  "waveform": [],
25
  "status": "buffering",
26
  }
 
27
 
28
  def append(self, r: float, g: float, b: float) -> None:
29
  self.r_buffer.append(r)
@@ -88,6 +89,7 @@ class SignalProcessor:
88
 
89
  n = len(self.r_buffer)
90
  if n < self.min_calibrate_samples:
 
91
  self._last_result = {
92
  "bpm": 0,
93
  "confidence": 0.0,
@@ -97,9 +99,11 @@ class SignalProcessor:
97
  return self._last_result
98
 
99
  if now - self.last_compute_time < self.compute_interval:
 
100
  return self._last_result
101
 
102
  self.last_compute_time = now
 
103
 
104
  r_arr = np.array(self.r_buffer)
105
  g_arr = np.array(self.g_buffer)
 
24
  "waveform": [],
25
  "status": "buffering",
26
  }
27
+ self.just_computed: bool = False
28
 
29
  def append(self, r: float, g: float, b: float) -> None:
30
  self.r_buffer.append(r)
 
89
 
90
  n = len(self.r_buffer)
91
  if n < self.min_calibrate_samples:
92
+ self.just_computed = True
93
  self._last_result = {
94
  "bpm": 0,
95
  "confidence": 0.0,
 
99
  return self._last_result
100
 
101
  if now - self.last_compute_time < self.compute_interval:
102
+ self.just_computed = False
103
  return self._last_result
104
 
105
  self.last_compute_time = now
106
+ self.just_computed = True
107
 
108
  r_arr = np.array(self.r_buffer)
109
  g_arr = np.array(self.g_buffer)
frontend/src/hooks/useFaceDetection.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect, useState, useCallback } from "react";
2
+ import { FaceDetector, FilesetResolver } from "@mediapipe/tasks-vision";
3
+
4
+ export interface DetectedFace {
5
+ leftEye: { x: number; y: number };
6
+ rightEye: { x: number; y: number };
7
+ boundingBox: { x: number; y: number; width: number; height: number };
8
+ }
9
+
10
+ export function useFaceDetection() {
11
+ const detectorRef = useRef<FaceDetector | null>(null);
12
+ const [loading, setLoading] = useState(true);
13
+ const [error, setError] = useState<string | null>(null);
14
+
15
+ useEffect(() => {
16
+ let cancelled = false;
17
+
18
+ async function init() {
19
+ try {
20
+ const vision = await FilesetResolver.forVisionTasks(
21
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm"
22
+ );
23
+ if (cancelled) return;
24
+ const detector = await FaceDetector.createFromOptions(vision, {
25
+ baseOptions: {
26
+ modelAssetPath:
27
+ "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite",
28
+ delegate: "GPU",
29
+ },
30
+ runningMode: "VIDEO",
31
+ });
32
+ if (cancelled) {
33
+ detector.close();
34
+ return;
35
+ }
36
+ detectorRef.current = detector;
37
+ setLoading(false);
38
+ } catch (e) {
39
+ if (!cancelled) {
40
+ setError(e instanceof Error ? e.message : "Failed to load face detector");
41
+ setLoading(false);
42
+ }
43
+ }
44
+ }
45
+
46
+ init();
47
+ return () => {
48
+ cancelled = true;
49
+ detectorRef.current?.close();
50
+ };
51
+ }, []);
52
+
53
+ const detectFace = useCallback(
54
+ (video: HTMLVideoElement, timestampMs: number): DetectedFace | null => {
55
+ if (!detectorRef.current) return null;
56
+ const result = detectorRef.current.detectForVideo(video, timestampMs);
57
+ if (!result.detections || result.detections.length === 0) return null;
58
+
59
+ const detection = result.detections[0];
60
+ const keypoints = detection.keypoints;
61
+ if (!keypoints || keypoints.length < 2) return null;
62
+
63
+ const bb = detection.boundingBox;
64
+ if (!bb) return null;
65
+
66
+ const leftEye = keypoints[0];
67
+ const rightEye = keypoints[1];
68
+
69
+ return {
70
+ leftEye: { x: leftEye.x, y: leftEye.y },
71
+ rightEye: { x: rightEye.x, y: rightEye.y },
72
+ boundingBox: { x: bb.originX, y: bb.originY, width: bb.width, height: bb.height },
73
+ };
74
+ },
75
+ []
76
+ );
77
+
78
+ return { detectFace, loading, error };
79
+ }
frontend/src/hooks/usePulseCam.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect, useState, useCallback } from "react";
2
+ import {
3
+ WS_URL,
4
+ HEALTH_CHECK_URL,
5
+ COLD_START_MAX_WAIT_MS,
6
+ COLD_START_CHECK_INTERVAL_MS,
7
+ COLD_START_ABORT_TIMEOUT_MS,
8
+ MAX_WS_RETRIES,
9
+ WS_RETRY_BASE_MS,
10
+ type Phase,
11
+ type MeasurementStatus,
12
+ type WSResponse,
13
+ } from "../lib/constants";
14
+
15
+ async function waitForServer(maxWaitMs = COLD_START_MAX_WAIT_MS): Promise<boolean> {
16
+ const start = Date.now();
17
+ while (Date.now() - start < maxWaitMs) {
18
+ try {
19
+ const res = await fetch(HEALTH_CHECK_URL, {
20
+ signal: AbortSignal.timeout(COLD_START_ABORT_TIMEOUT_MS),
21
+ });
22
+ if (res.ok) return true;
23
+ } catch {}
24
+ await new Promise((r) => setTimeout(r, COLD_START_CHECK_INTERVAL_MS));
25
+ }
26
+ return false;
27
+ }
28
+
29
+ export function usePulseCam() {
30
+ const [phase, setPhase] = useState<Phase>("idle");
31
+ const [bpm, setBpm] = useState(0);
32
+ const [confidence, setConfidence] = useState(0);
33
+ const [waveform, setWaveform] = useState<number[]>([]);
34
+ const [status, setStatus] = useState<MeasurementStatus>("buffering");
35
+ const wsRef = useRef<WebSocket | null>(null);
36
+ const intentionalStop = useRef(false);
37
+ const retryCount = useRef(0);
38
+ const reconnectScheduled = useRef(false);
39
+
40
+ const connect = useCallback(() => {
41
+ if (intentionalStop.current) return;
42
+ const ws = new WebSocket(WS_URL);
43
+ wsRef.current = ws;
44
+
45
+ ws.onopen = () => {
46
+ setPhase("measuring");
47
+ retryCount.current = 0;
48
+ };
49
+
50
+ ws.onmessage = (event) => {
51
+ try {
52
+ const raw = JSON.parse(event.data);
53
+ if (raw.error) {
54
+ setStatus("error");
55
+ return;
56
+ }
57
+ const data: WSResponse = raw;
58
+ setBpm(data.bpm);
59
+ setConfidence(data.confidence);
60
+ setWaveform(data.waveform);
61
+ setStatus(data.status);
62
+ } catch (e) {
63
+ console.error("Failed to parse WebSocket message:", e);
64
+ }
65
+ };
66
+
67
+ ws.onerror = () => {
68
+ if (intentionalStop.current) return;
69
+ if (retryCount.current < MAX_WS_RETRIES) {
70
+ const delay = WS_RETRY_BASE_MS * Math.pow(2, retryCount.current);
71
+ retryCount.current++;
72
+ reconnectScheduled.current = true;
73
+ setTimeout(() => {
74
+ reconnectScheduled.current = false;
75
+ connect();
76
+ }, delay);
77
+ } else {
78
+ setPhase("error");
79
+ }
80
+ };
81
+
82
+ ws.onclose = () => {
83
+ if (intentionalStop.current) return;
84
+ if (!reconnectScheduled.current) {
85
+ setTimeout(() => connect(), 3000);
86
+ }
87
+ };
88
+ }, []);
89
+
90
+ const sendFrame = useCallback((r: number, g: number, b: number) => {
91
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
92
+ wsRef.current.send(JSON.stringify({ r, g, b }));
93
+ }
94
+ }, []);
95
+
96
+ const start = useCallback(async () => {
97
+ intentionalStop.current = false;
98
+ wsRef.current?.close();
99
+ wsRef.current = null;
100
+ setPhase("checking");
101
+
102
+ const isUp = await waitForServer();
103
+ if (!isUp) {
104
+ setPhase("error");
105
+ return;
106
+ }
107
+
108
+ setPhase("connecting");
109
+ connect();
110
+ }, [connect]);
111
+
112
+ const stop = useCallback(() => {
113
+ intentionalStop.current = true;
114
+ wsRef.current?.close();
115
+ wsRef.current = null;
116
+ setPhase("idle");
117
+ setBpm(0);
118
+ setConfidence(0);
119
+ setWaveform([]);
120
+ setStatus("buffering");
121
+ }, []);
122
+
123
+ useEffect(() => {
124
+ return () => {
125
+ intentionalStop.current = true;
126
+ wsRef.current?.close();
127
+ };
128
+ }, []);
129
+
130
+ return { phase, bpm, confidence, waveform, status, sendFrame, start, stop };
131
+ }
frontend/src/lib/constants.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const FPS = 30;
2
+ export const BUFFER_SECONDS = 15;
3
+ export const COLD_START_MAX_WAIT_MS = 90000;
4
+ export const COLD_START_CHECK_INTERVAL_MS = 3000;
5
+ export const COLD_START_ABORT_TIMEOUT_MS = 5000;
6
+ export const HEALTH_CHECK_URL = `${window.location.protocol}//${window.location.host}/health`;
7
+ export const WS_URL = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
8
+ export const MAX_WS_RETRIES = 5;
9
+ export const WS_RETRY_BASE_MS = 2000;
10
+ export const ROI_WIDTH_RATIO = 0.8;
11
+ export const ROI_HEIGHT_RATIO = 0.4;
12
+ export const ROI_OFFSET_UP_RATIO = 0.4;
13
+
14
+ export type Phase =
15
+ | "idle"
16
+ | "checking"
17
+ | "waking"
18
+ | "connecting"
19
+ | "measuring"
20
+ | "error";
21
+
22
+ export type MeasurementStatus =
23
+ | "buffering"
24
+ | "calibrating"
25
+ | "measuring"
26
+ | "poor_signal"
27
+ | "error";
28
+
29
+ export interface WSResponse {
30
+ bpm: number;
31
+ confidence: number;
32
+ waveform: number[];
33
+ status: MeasurementStatus;
34
+ }
frontend/src/lib/extractROI.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ROI_WIDTH_RATIO, ROI_HEIGHT_RATIO, ROI_OFFSET_UP_RATIO } from "./constants";
2
+
3
+ interface Keypoint {
4
+ x: number;
5
+ y: number;
6
+ }
7
+
8
+ export function computeForeheadROI(
9
+ leftEye: Keypoint,
10
+ rightEye: Keypoint,
11
+ canvasWidth: number,
12
+ canvasHeight: number
13
+ ): { x: number; y: number; width: number; height: number } {
14
+ const midX = (leftEye.x + rightEye.x) / 2;
15
+ const midY = (leftEye.y + rightEye.y) / 2;
16
+ const interEyeDist = Math.sqrt(
17
+ (rightEye.x - leftEye.x) ** 2 + (rightEye.y - leftEye.y) ** 2
18
+ );
19
+
20
+ const width = interEyeDist * ROI_WIDTH_RATIO;
21
+ const height = interEyeDist * ROI_HEIGHT_RATIO;
22
+ const offsetX = midX * canvasWidth;
23
+ const offsetY = midY * canvasHeight - interEyeDist * ROI_OFFSET_UP_RATIO * canvasHeight;
24
+
25
+ const x = Math.max(0, offsetX - width * canvasWidth / 2);
26
+ const y = Math.max(0, offsetY - height * canvasHeight);
27
+ const w = width * canvasWidth;
28
+ const h = height * canvasHeight;
29
+
30
+ return {
31
+ x,
32
+ y,
33
+ width: Math.min(w, canvasWidth - x),
34
+ height: Math.min(h, canvasHeight - y),
35
+ };
36
+ }
37
+
38
+ export function extractRGBMeans(
39
+ ctx: CanvasRenderingContext2D,
40
+ roi: { x: number; y: number; width: number; height: number }
41
+ ): { r: number; g: number; b: number } {
42
+ const x = Math.floor(roi.x);
43
+ const y = Math.floor(roi.y);
44
+ const w = Math.floor(roi.width);
45
+ const h = Math.floor(roi.height);
46
+
47
+ if (w <= 0 || h <= 0) return { r: 0, g: 0, b: 0 };
48
+
49
+ const imageData = ctx.getImageData(x, y, w, h);
50
+ const data = imageData.data;
51
+ let totalR = 0;
52
+ let totalG = 0;
53
+ let totalB = 0;
54
+ const pixelCount = data.length / 4;
55
+
56
+ for (let i = 0; i < data.length; i += 4) {
57
+ totalR += data[i];
58
+ totalG += data[i + 1];
59
+ totalB += data[i + 2];
60
+ }
61
+
62
+ return {
63
+ r: totalR / pixelCount,
64
+ g: totalG / pixelCount,
65
+ b: totalB / pixelCount,
66
+ };
67
+ }