Kexin-251202 commited on
Commit
d2f1e91
·
verified ·
1 Parent(s): 1ebd3ae

Upload 7 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 1. 使用官方 Python 3.9 镜像
2
+ FROM python:3.9
3
+
4
+ # 2. 设置工作目录
5
+ WORKDIR /code
6
+
7
+ # 3. 安装 OpenCV 需要的系统依赖 (必须要这一步,否则 cv2 会报错)
8
+ RUN apt-get update && apt-get install -y libgl1-mesa-glx
9
+
10
+ # 4. 复制依赖文件并安装 Python 库
11
+ COPY ./requirements.txt /code/requirements.txt
12
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
13
+
14
+ # 5. 复制所有代码 (main.py 和 static 文件夹) 到容器
15
+ COPY . /code
16
+
17
+ # 6. 【重要】解决 Hugging Face 的权限问题
18
+ # HF 运行在非 root 用户下,我们需要把目录权限设为 777
19
+ # 这样程序才能自动创建和写入 focus_guard.db 数据库文件
20
+ RUN chmod 777 /code
21
+
22
+ # 7. 启动命令
23
+ # 注意:Hugging Face 强制要求端口为 7860
24
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.responses import FileResponse
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from pydantic import BaseModel
6
+ from typing import Optional, List, Any
7
+ import base64
8
+ import cv2
9
+ import numpy as np
10
+ import aiosqlite
11
+ import json
12
+ from datetime import datetime, timedelta
13
+ import math
14
+ import os
15
+ from pathlib import Path
16
+
17
+ # Initialize FastAPI app
18
+ app = FastAPI(title="Focus Guard API")
19
+
20
+ # Add CORS middleware
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"],
24
+ allow_credentials=True,
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+ # Global variables
30
+ model = None
31
+ db_path = "focus_guard.db"
32
+
33
+ # ================ DATABASE MODELS ================
34
+
35
+ async def init_database():
36
+ """Initialize SQLite database with required tables"""
37
+ async with aiosqlite.connect(db_path) as db:
38
+ # FocusSessions table
39
+ await db.execute("""
40
+ CREATE TABLE IF NOT EXISTS focus_sessions (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ start_time TIMESTAMP NOT NULL,
43
+ end_time TIMESTAMP,
44
+ duration_seconds INTEGER DEFAULT 0,
45
+ focus_score REAL DEFAULT 0.0,
46
+ total_frames INTEGER DEFAULT 0,
47
+ focused_frames INTEGER DEFAULT 0,
48
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
49
+ )
50
+ """)
51
+
52
+ # FocusEvents table
53
+ await db.execute("""
54
+ CREATE TABLE IF NOT EXISTS focus_events (
55
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ session_id INTEGER NOT NULL,
57
+ timestamp TIMESTAMP NOT NULL,
58
+ is_focused BOOLEAN NOT NULL,
59
+ confidence REAL NOT NULL,
60
+ detection_data TEXT,
61
+ FOREIGN KEY (session_id) REFERENCES focus_sessions (id)
62
+ )
63
+ """)
64
+
65
+ # UserSettings table
66
+ await db.execute("""
67
+ CREATE TABLE IF NOT EXISTS user_settings (
68
+ id INTEGER PRIMARY KEY CHECK (id = 1),
69
+ sensitivity INTEGER DEFAULT 6,
70
+ notification_enabled BOOLEAN DEFAULT 1,
71
+ notification_threshold INTEGER DEFAULT 30,
72
+ frame_rate INTEGER DEFAULT 30,
73
+ model_name TEXT DEFAULT 'yolov8n.pt'
74
+ )
75
+ """)
76
+
77
+ # Insert default settings if not exists
78
+ await db.execute("""
79
+ INSERT OR IGNORE INTO user_settings (id, sensitivity, notification_enabled, notification_threshold, frame_rate, model_name)
80
+ VALUES (1, 6, 1, 30, 30, 'yolov8n.pt')
81
+ """)
82
+
83
+ await db.commit()
84
+
85
+ # ================ PYDANTIC MODELS ================
86
+
87
+ class SessionCreate(BaseModel):
88
+ pass
89
+
90
+ class SessionEnd(BaseModel):
91
+ session_id: int
92
+
93
+ class SettingsUpdate(BaseModel):
94
+ sensitivity: Optional[int] = None
95
+ notification_enabled: Optional[bool] = None
96
+ notification_threshold: Optional[int] = None
97
+ frame_rate: Optional[int] = None
98
+
99
+ # ================ YOLO MODEL LOADING ================
100
+
101
+ def load_yolo_model():
102
+ """Load YOLOv8 model with optimizations for CPU"""
103
+ global model
104
+ try:
105
+ # Fix PyTorch 2.6+ weights_only issue
106
+ os.environ['TORCH_LOAD_WEIGHTS_ONLY'] = '0'
107
+
108
+ import torch
109
+ if hasattr(torch.serialization, 'add_safe_globals'):
110
+ try:
111
+ from ultralytics.nn.tasks import DetectionModel
112
+ import torch.nn as nn
113
+ torch.serialization.add_safe_globals([
114
+ DetectionModel,
115
+ nn.modules.container.Sequential,
116
+ ])
117
+ except Exception as e:
118
+ print(f" Safe globals setup: {e}")
119
+
120
+ from ultralytics import YOLO
121
+
122
+ model_path = "models/yolov8n.pt"
123
+
124
+ if not os.path.exists(model_path):
125
+ print(f"Model file {model_path} not found, downloading yolov8n.pt...")
126
+ model_path = "yolov8n.pt"
127
+
128
+ model = YOLO(model_path)
129
+
130
+ try:
131
+ model.fuse()
132
+ print("[OK] Model layers fused for optimization")
133
+ except Exception as e:
134
+ print(f" Model fusion skipped: {e}")
135
+
136
+ # Warm up
137
+ print("Warming up model...")
138
+ dummy_img = np.zeros((416, 416, 3), dtype=np.uint8)
139
+ model(dummy_img, imgsz=416, conf=0.4, iou=0.45, max_det=5, classes=[0], verbose=False)
140
+
141
+ print("[OK] YOLOv8 model loaded and warmed up successfully")
142
+ return True
143
+ except Exception as e:
144
+ print(f"[ERROR] Failed to load YOLOv8 model: {e}")
145
+ import traceback
146
+ traceback.print_exc()
147
+ return False
148
+
149
+ # ================ FOCUS DETECTION ALGORITHM ================
150
+
151
+ def is_user_focused(detections, frame_shape, sensitivity=6):
152
+ persons = [d for d in detections if d.get('class') == 0]
153
+
154
+ if not persons:
155
+ return False, 0.0, {'reason': 'no_person', 'count': 0}
156
+
157
+ best_person = max(persons, key=lambda x: x.get('confidence', 0))
158
+ bbox = best_person['bbox']
159
+ conf = best_person['confidence']
160
+
161
+ base_threshold = 0.8
162
+ sensitivity_adjustment = (sensitivity - 6) * 0.02
163
+ confidence_threshold = base_threshold + sensitivity_adjustment
164
+ confidence_threshold = max(0.5, min(0.95, confidence_threshold))
165
+
166
+ is_focused = conf >= confidence_threshold
167
+
168
+ h, w = frame_shape[0], frame_shape[1]
169
+ bbox_center_x = (bbox[0] + bbox[2]) / 2
170
+ bbox_center_y = (bbox[1] + bbox[3]) / 2
171
+
172
+ center_x_norm = bbox_center_x / w if w > 0 else 0.5
173
+ center_y_norm = bbox_center_y / h if h > 0 else 0.5
174
+
175
+ in_frame = (0.2 <= center_x_norm <= 0.8) and (0.15 <= center_y_norm <= 0.85)
176
+
177
+ position_factor = 1.0 if in_frame else 0.7
178
+ final_score = conf * position_factor
179
+
180
+ if len(persons) > 1:
181
+ final_score *= 0.9
182
+ reason = f"person_detected_multi_{len(persons)}"
183
+ else:
184
+ reason = "person_detected" if is_focused else "low_confidence"
185
+
186
+ metadata = {
187
+ 'bbox': bbox,
188
+ 'detection_confidence': round(conf, 3),
189
+ 'confidence_threshold': round(confidence_threshold, 3),
190
+ 'center_position': [round(center_x_norm, 3), round(center_y_norm, 3)],
191
+ 'in_frame': in_frame,
192
+ 'person_count': len(persons),
193
+ 'reason': reason
194
+ }
195
+
196
+ return is_focused and in_frame, final_score, metadata
197
+
198
+ def parse_yolo_results(results):
199
+ detections = []
200
+ if results and len(results) > 0:
201
+ result = results[0]
202
+ boxes = result.boxes
203
+ if boxes is not None and len(boxes) > 0:
204
+ for box in boxes:
205
+ xyxy = box.xyxy[0].cpu().numpy()
206
+ conf = float(box.conf[0].cpu().numpy())
207
+ cls = int(box.cls[0].cpu().numpy())
208
+ detection = {
209
+ 'bbox': [float(x) for x in xyxy],
210
+ 'confidence': conf,
211
+ 'class': cls,
212
+ 'class_name': result.names[cls] if hasattr(result, 'names') else str(cls)
213
+ }
214
+ detections.append(detection)
215
+ return detections
216
+
217
+ # ================ DATABASE OPERATIONS ================
218
+
219
+ async def create_session():
220
+ async with aiosqlite.connect(db_path) as db:
221
+ cursor = await db.execute(
222
+ "INSERT INTO focus_sessions (start_time) VALUES (?)",
223
+ (datetime.now().isoformat(),)
224
+ )
225
+ await db.commit()
226
+ return cursor.lastrowid
227
+
228
+ async def end_session(session_id: int):
229
+ async with aiosqlite.connect(db_path) as db:
230
+ cursor = await db.execute(
231
+ "SELECT start_time, total_frames, focused_frames FROM focus_sessions WHERE id = ?",
232
+ (session_id,)
233
+ )
234
+ row = await cursor.fetchone()
235
+
236
+ if not row:
237
+ return None
238
+
239
+ start_time_str, total_frames, focused_frames = row
240
+ start_time = datetime.fromisoformat(start_time_str)
241
+ end_time = datetime.now()
242
+ duration = (end_time - start_time).total_seconds()
243
+ focus_score = focused_frames / total_frames if total_frames > 0 else 0.0
244
+
245
+ await db.execute("""
246
+ UPDATE focus_sessions
247
+ SET end_time = ?, duration_seconds = ?, focus_score = ?
248
+ WHERE id = ?
249
+ """, (end_time.isoformat(), int(duration), focus_score, session_id))
250
+
251
+ await db.commit()
252
+
253
+ return {
254
+ 'session_id': session_id,
255
+ 'start_time': start_time_str,
256
+ 'end_time': end_time.isoformat(),
257
+ 'duration_seconds': int(duration),
258
+ 'focus_score': round(focus_score, 3),
259
+ 'total_frames': total_frames,
260
+ 'focused_frames': focused_frames
261
+ }
262
+
263
+ async def store_focus_event(session_id: int, is_focused: bool, confidence: float, metadata: dict):
264
+ async with aiosqlite.connect(db_path) as db:
265
+ await db.execute("""
266
+ INSERT INTO focus_events (session_id, timestamp, is_focused, confidence, detection_data)
267
+ VALUES (?, ?, ?, ?, ?)
268
+ """, (session_id, datetime.now().isoformat(), is_focused, confidence, json.dumps(metadata)))
269
+
270
+ await db.execute(f"""
271
+ UPDATE focus_sessions
272
+ SET total_frames = total_frames + 1,
273
+ focused_frames = focused_frames + {1 if is_focused else 0}
274
+ WHERE id = ?
275
+ """, (session_id,))
276
+ await db.commit()
277
+
278
+ # ================ STARTUP/SHUTDOWN ================
279
+
280
+ @app.on_event("startup")
281
+ async def startup_event():
282
+ print(" Starting Focus Guard API...")
283
+ await init_database()
284
+ print("[OK] Database initialized")
285
+ load_yolo_model()
286
+
287
+ @app.on_event("shutdown")
288
+ async def shutdown_event():
289
+ print(" Shutting down Focus Guard API...")
290
+
291
+ # ================ WEBSOCKET ================
292
+
293
+ @app.websocket("/ws/video")
294
+ async def websocket_endpoint(websocket: WebSocket):
295
+ await websocket.accept()
296
+ session_id = None
297
+ frame_count = 0
298
+ last_inference_time = 0
299
+ min_inference_interval = 0.1
300
+
301
+ try:
302
+ async with aiosqlite.connect(db_path) as db:
303
+ cursor = await db.execute("SELECT sensitivity FROM user_settings WHERE id = 1")
304
+ row = await cursor.fetchone()
305
+ sensitivity = row[0] if row else 6
306
+
307
+ while True:
308
+ data = await websocket.receive_json()
309
+
310
+ if data['type'] == 'frame':
311
+ from time import time
312
+ current_time = time()
313
+ if current_time - last_inference_time < min_inference_interval:
314
+ await websocket.send_json({'type': 'ack', 'frame_count': frame_count})
315
+ continue
316
+ last_inference_time = current_time
317
+
318
+ try:
319
+ img_data = base64.b64decode(data['image'])
320
+ nparr = np.frombuffer(img_data, np.uint8)
321
+ frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
322
+
323
+ if frame is None: continue
324
+ frame = cv2.resize(frame, (640, 480))
325
+
326
+ if model is not None:
327
+ results = model(frame, imgsz=416, conf=0.4, iou=0.45, max_det=5, classes=[0], verbose=False)
328
+ detections = parse_yolo_results(results)
329
+ else:
330
+ detections = []
331
+
332
+ is_focused, confidence, metadata = is_user_focused(detections, frame.shape, sensitivity)
333
+
334
+ if session_id:
335
+ await store_focus_event(session_id, is_focused, confidence, metadata)
336
+
337
+ await websocket.send_json({
338
+ 'type': 'detection',
339
+ 'focused': is_focused,
340
+ 'confidence': round(confidence, 3),
341
+ 'detections': detections,
342
+ 'frame_count': frame_count
343
+ })
344
+ frame_count += 1
345
+ except Exception as e:
346
+ print(f"Error processing frame: {e}")
347
+ await websocket.send_json({'type': 'error', 'message': str(e)})
348
+
349
+ elif data['type'] == 'start_session':
350
+ session_id = await create_session()
351
+ await websocket.send_json({'type': 'session_started', 'session_id': session_id})
352
+
353
+ elif data['type'] == 'end_session':
354
+ if session_id:
355
+ summary = await end_session(session_id)
356
+ await websocket.send_json({'type': 'session_ended', 'summary': summary})
357
+ session_id = None
358
+
359
+ except WebSocketDisconnect:
360
+ if session_id: await end_session(session_id)
361
+ except Exception as e:
362
+ if websocket.client_state.value == 1: await websocket.close()
363
+
364
+ # ================ API ENDPOINTS ================
365
+
366
+ @app.post("/api/sessions/start")
367
+ async def api_start_session():
368
+ session_id = await create_session()
369
+ return {"session_id": session_id}
370
+
371
+ @app.post("/api/sessions/end")
372
+ async def api_end_session(data: SessionEnd):
373
+ summary = await end_session(data.session_id)
374
+ if not summary: raise HTTPException(status_code=404, detail="Session not found")
375
+ return summary
376
+
377
+ @app.get("/api/sessions")
378
+ async def get_sessions(filter: str = "all", limit: int = 50, offset: int = 0):
379
+ async with aiosqlite.connect(db_path) as db:
380
+ db.row_factory = aiosqlite.Row
381
+
382
+ # NEW: If importing/exporting all, remove limit if special flag or high limit
383
+ # For simplicity: if limit is -1, return all
384
+ limit_clause = "LIMIT ? OFFSET ?"
385
+ params = []
386
+
387
+ base_query = "SELECT * FROM focus_sessions"
388
+ where_clause = ""
389
+
390
+ if filter == "today":
391
+ date_filter = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
392
+ where_clause = " WHERE start_time >= ?"
393
+ params.append(date_filter.isoformat())
394
+ elif filter == "week":
395
+ date_filter = datetime.now() - timedelta(days=7)
396
+ where_clause = " WHERE start_time >= ?"
397
+ params.append(date_filter.isoformat())
398
+ elif filter == "month":
399
+ date_filter = datetime.now() - timedelta(days=30)
400
+ where_clause = " WHERE start_time >= ?"
401
+ params.append(date_filter.isoformat())
402
+ elif filter == "all":
403
+ # Just ensure we only get completed sessions or all sessions
404
+ where_clause = " WHERE end_time IS NOT NULL"
405
+
406
+ query = f"{base_query}{where_clause} ORDER BY start_time DESC"
407
+
408
+ # Handle Limit for Exports
409
+ if limit == -1:
410
+ # No limit clause for export
411
+ pass
412
+ else:
413
+ query += f" {limit_clause}"
414
+ params.extend([limit, offset])
415
+
416
+ cursor = await db.execute(query, tuple(params))
417
+ rows = await cursor.fetchall()
418
+ return [dict(row) for row in rows]
419
+
420
+ # --- NEW: Import Endpoint ---
421
+ @app.post("/api/import")
422
+ async def import_sessions(sessions: List[dict]):
423
+ count = 0
424
+ try:
425
+ async with aiosqlite.connect(db_path) as db:
426
+ for session in sessions:
427
+ # Use .get() to handle potential missing fields from older versions or edits
428
+ await db.execute("""
429
+ INSERT INTO focus_sessions (start_time, end_time, duration_seconds, focus_score, total_frames, focused_frames, created_at)
430
+ VALUES (?, ?, ?, ?, ?, ?, ?)
431
+ """, (
432
+ session.get('start_time'),
433
+ session.get('end_time'),
434
+ session.get('duration_seconds', 0),
435
+ session.get('focus_score', 0.0),
436
+ session.get('total_frames', 0),
437
+ session.get('focused_frames', 0),
438
+ session.get('created_at', session.get('start_time'))
439
+ ))
440
+ count += 1
441
+ await db.commit()
442
+ return {"status": "success", "count": count}
443
+ except Exception as e:
444
+ print(f"Import Error: {e}")
445
+ return {"status": "error", "message": str(e)}
446
+
447
+ # --- NEW: Clear History Endpoint ---
448
+ @app.delete("/api/history")
449
+ async def clear_history():
450
+ try:
451
+ async with aiosqlite.connect(db_path) as db:
452
+ # Delete events first (foreign key good practice)
453
+ await db.execute("DELETE FROM focus_events")
454
+ await db.execute("DELETE FROM focus_sessions")
455
+ await db.commit()
456
+ return {"status": "success", "message": "History cleared"}
457
+ except Exception as e:
458
+ return {"status": "error", "message": str(e)}
459
+
460
+ @app.get("/api/sessions/{session_id}")
461
+ async def get_session(session_id: int):
462
+ async with aiosqlite.connect(db_path) as db:
463
+ db.row_factory = aiosqlite.Row
464
+ cursor = await db.execute("SELECT * FROM focus_sessions WHERE id = ?", (session_id,))
465
+ row = await cursor.fetchone()
466
+ if not row: raise HTTPException(status_code=404, detail="Session not found")
467
+ session = dict(row)
468
+ cursor = await db.execute("SELECT * FROM focus_events WHERE session_id = ? ORDER BY timestamp", (session_id,))
469
+ events = [dict(r) for r in await cursor.fetchall()]
470
+ session['events'] = events
471
+ return session
472
+
473
+ @app.get("/api/settings")
474
+ async def get_settings():
475
+ async with aiosqlite.connect(db_path) as db:
476
+ db.row_factory = aiosqlite.Row
477
+ cursor = await db.execute("SELECT * FROM user_settings WHERE id = 1")
478
+ row = await cursor.fetchone()
479
+ if row: return dict(row)
480
+ else: return {'sensitivity': 6, 'notification_enabled': True, 'notification_threshold': 30, 'frame_rate': 30, 'model_name': 'yolov8n.pt'}
481
+
482
+ @app.put("/api/settings")
483
+ async def update_settings(settings: SettingsUpdate):
484
+ async with aiosqlite.connect(db_path) as db:
485
+ cursor = await db.execute("SELECT id FROM user_settings WHERE id = 1")
486
+ exists = await cursor.fetchone()
487
+ if not exists:
488
+ await db.execute("INSERT INTO user_settings (id, sensitivity) VALUES (1, 6)")
489
+ await db.commit()
490
+
491
+ updates = []
492
+ params = []
493
+ if settings.sensitivity is not None:
494
+ updates.append("sensitivity = ?")
495
+ params.append(max(1, min(10, settings.sensitivity)))
496
+ if settings.notification_enabled is not None:
497
+ updates.append("notification_enabled = ?")
498
+ params.append(settings.notification_enabled)
499
+ if settings.notification_threshold is not None:
500
+ updates.append("notification_threshold = ?")
501
+ params.append(max(5, min(300, settings.notification_threshold)))
502
+ if settings.frame_rate is not None:
503
+ updates.append("frame_rate = ?")
504
+ params.append(max(5, min(60, settings.frame_rate)))
505
+
506
+ if updates:
507
+ query = f"UPDATE user_settings SET {', '.join(updates)} WHERE id = 1"
508
+ await db.execute(query, params)
509
+ await db.commit()
510
+ return {"status": "success", "updated": len(updates) > 0}
511
+
512
+ @app.get("/api/stats/summary")
513
+ async def get_stats_summary():
514
+ async with aiosqlite.connect(db_path) as db:
515
+ cursor = await db.execute("SELECT COUNT(*) FROM focus_sessions WHERE end_time IS NOT NULL")
516
+ total_sessions = (await cursor.fetchone())[0]
517
+ cursor = await db.execute("SELECT SUM(duration_seconds) FROM focus_sessions WHERE end_time IS NOT NULL")
518
+ total_focus_time = (await cursor.fetchone())[0] or 0
519
+ cursor = await db.execute("SELECT AVG(focus_score) FROM focus_sessions WHERE end_time IS NOT NULL")
520
+ avg_focus_score = (await cursor.fetchone())[0] or 0.0
521
+ cursor = await db.execute("SELECT DISTINCT DATE(start_time) as session_date FROM focus_sessions WHERE end_time IS NOT NULL ORDER BY session_date DESC")
522
+ dates = [row[0] for row in await cursor.fetchall()]
523
+
524
+ streak_days = 0
525
+ if dates:
526
+ current_date = datetime.now().date()
527
+ for i, date_str in enumerate(dates):
528
+ session_date = datetime.fromisoformat(date_str).date()
529
+ expected_date = current_date - timedelta(days=i)
530
+ if session_date == expected_date: streak_days += 1
531
+ else: break
532
+ return {
533
+ 'total_sessions': total_sessions,
534
+ 'total_focus_time': int(total_focus_time),
535
+ 'avg_focus_score': round(avg_focus_score, 3),
536
+ 'streak_days': streak_days
537
+ }
538
+
539
+ @app.get("/health")
540
+ async def health_check():
541
+ return {"status": "healthy", "model_loaded": model is not None, "database": os.path.exists(db_path)}
542
+
543
+ # ================ STATIC FILES (SPA SUPPORT) ================
544
+
545
+ # 1. Mount the assets folder (JS/CSS built by Vite/React)
546
+ if os.path.exists("static/assets"):
547
+ app.mount("/assets", StaticFiles(directory="static/assets"), name="assets")
548
+
549
+ # 2. Catch-all route for SPA (React Router)
550
+ # This ensures that if you refresh /customise, it serves index.html instead of 404
551
+ @app.get("/{full_path:path}")
552
+ async def serve_react_app(full_path: str, request: Request):
553
+ # Skip API and WS routes
554
+ if full_path.startswith("api") or full_path.startswith("ws"):
555
+ raise HTTPException(status_code=404, detail="Not Found")
556
+
557
+ # Serve index.html for any other route
558
+ if os.path.exists("static/index.html"):
559
+ return FileResponse("static/index.html")
560
+ else:
561
+ return {"message": "React app not found. Please run 'npm run build' and copy dist to static."}
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ websockets
4
+ numpy
5
+ opencv-python-headless
6
+ aiosqlite
7
+ ultralytics
8
+ python-multipart
9
+ jinja2
static/assets/index-DERnMkkp.js ADDED
The diff for this file is too large to render. See raw diff
 
static/assets/index-DjIeJJNR.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media(prefers-color-scheme:light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}html,body,#root{width:100%;height:100%;margin:0;padding:0}.app-container{width:100%;min-height:100vh;display:flex;flex-direction:column;background-color:#f9f9f9}body{font-family:Nunito,sans-serif;background-color:#f9f9f9;overflow-x:hidden;overflow-y:auto}.hidden{display:none!important}#top-menu{height:60px;background-color:#fff;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px #0000000d;position:fixed;top:0;width:100%;z-index:1000}.menu-btn{background:none;border:none;font-family:Nunito,sans-serif;font-size:16px;color:#333;padding:10px 20px;cursor:pointer;transition:background-color .2s}.menu-btn:hover{background-color:#f0f0f0;border-radius:4px}.menu-btn.active{font-weight:700;color:#007bff;background-color:#eef7ff;border-radius:4px}.separator{width:1px;height:20px;background-color:#555;margin:0 5px}.page{min-height:calc(100vh - 60px);width:100%;padding-top:60px;padding-bottom:40px;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;overflow-y:auto}#page-a{justify-content:center;margin-top:-40px;flex:1}#page-a h1{font-size:80px;margin:0 0 10px;color:#000;text-align:center}#page-a p{color:#666;font-size:20px;margin-bottom:40px;text-align:center}.btn-main{background-color:#007bff;color:#fff;border:none;padding:15px 50px;font-size:20px;font-family:Nunito,sans-serif;border-radius:30px;cursor:pointer;transition:transform .2s ease}.btn-main:hover{transform:scale(1.1)}#page-b{justify-content:space-evenly;padding-bottom:20px;min-height:calc(100vh - 60px)}#display-area{width:60%;height:50vh;min-height:300px;border:2px solid #ddd;border-radius:12px;background-color:#fff;display:flex;align-items:center;justify-content:center;color:#555;font-size:24px;position:relative;overflow:hidden}#display-area video{width:100%;height:100%;object-fit:cover}#timeline-area{width:60%;height:80px;position:relative;display:flex;flex-direction:column;justify-content:flex-end}.timeline-label{position:absolute;top:0;left:0;color:#888;font-size:14px}#timeline-line{width:100%;height:2px;background-color:#87ceeb}#control-panel{display:flex;gap:20px;width:60%;justify-content:space-between}.action-btn{flex:1;padding:12px 0;border:none;border-radius:12px;font-size:16px;font-family:Nunito,sans-serif;font-weight:700;cursor:pointer;color:#fff;transition:opacity .2s}.action-btn:hover{opacity:.9}.action-btn.green{background-color:#28a745}.action-btn.yellow{background-color:#ffce0b}.action-btn.blue{background-color:#326ed6}.action-btn.red{background-color:#dc3545}#frame-control{display:flex;align-items:center;gap:15px;color:#333;font-weight:700}#frame-slider{width:200px;cursor:pointer}#frame-input{width:50px;padding:5px;border:1px solid #ccc;border-radius:5px;text-align:center;font-family:Nunito,sans-serif}.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;width:80%;margin:40px auto}.stat-card{background:#fff;padding:30px;border-radius:12px;text-align:center;box-shadow:0 2px 10px #0000001a}.stat-number{font-size:48px;font-weight:700;color:#007bff;margin-bottom:10px}.stat-label{font-size:16px;color:#666}.achievements-section{width:80%;margin:0 auto}.achievements-section h2{color:#333;margin-bottom:20px}.badges-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.badge{background:#fff;padding:30px 20px;border-radius:12px;text-align:center;box-shadow:0 2px 10px #0000001a;transition:transform .2s}.badge:hover{transform:translateY(-5px)}.badge.locked{opacity:.4;filter:grayscale(100%)}.badge-icon{font-size:64px;margin-bottom:15px}.badge-name{font-size:16px;font-weight:700;color:#333}.records-controls{display:flex;gap:10px;margin:20px auto;width:80%;justify-content:center}.filter-btn{padding:10px 20px;border:2px solid #007BFF;background:#fff;color:#007bff;border-radius:8px;cursor:pointer;font-family:Nunito,sans-serif;font-weight:600;transition:all .2s}.filter-btn:hover{background:#e7f3ff}.filter-btn.active{background:#007bff;color:#fff}.chart-container{width:80%;background:#fff;padding:30px;border-radius:12px;margin:20px auto;box-shadow:0 2px 10px #0000001a}#focus-chart{display:block;margin:0 auto;max-width:100%}.sessions-list{width:80%;margin:20px auto}.sessions-list h2{color:#333;margin-bottom:15px}#sessions-table{width:100%;background:#fff;border-collapse:collapse;border-radius:12px;overflow:hidden;box-shadow:0 2px 10px #0000001a}#sessions-table th{background:#007bff;color:#fff;padding:15px;text-align:left;font-weight:600}#sessions-table td{padding:12px 15px;border-bottom:1px solid #eee}#sessions-table tr:last-child td{border-bottom:none}#sessions-table tbody tr:hover{background:#f8f9fa}.btn-view{padding:6px 12px;background:#007bff;color:#fff;border:none;border-radius:5px;cursor:pointer;font-family:Nunito,sans-serif;transition:background .2s}.btn-view:hover{background:#0056b3}.settings-container{width:60%;max-width:800px;margin:20px auto}.setting-group{background:#fff;padding:30px;border-radius:12px;margin-bottom:20px;box-shadow:0 2px 10px #0000001a}.setting-group h2{margin-top:0;color:#333;font-size:20px;margin-bottom:20px;border-bottom:2px solid #007BFF;padding-bottom:10px}.setting-item{margin-bottom:25px}.setting-item:last-child{margin-bottom:0}.setting-item label{display:block;margin-bottom:8px;color:#333;font-weight:600}.slider-group{display:flex;align-items:center;gap:15px}.slider-group input[type=range]{flex:1}.slider-group span{min-width:40px;text-align:center;font-weight:700;color:#007bff;font-size:18px}.setting-description{font-size:14px;color:#666;margin-top:5px;font-style:italic}input[type=checkbox]{margin-right:10px;cursor:pointer}input[type=number]{width:100px;padding:8px;border:1px solid #ccc;border-radius:5px;font-family:Nunito,sans-serif}.setting-group .action-btn{display:inline-block;width:48%;margin:15px 1%;text-align:center;box-sizing:border-box}#save-settings{display:block;margin:20px auto}.help-container{width:70%;max-width:900px;margin:20px auto}.help-section{background:#fff;padding:30px;border-radius:12px;margin-bottom:20px;box-shadow:0 2px 10px #0000001a}.help-section h2{color:#007bff;margin-top:0;margin-bottom:15px}.help-section ol,.help-section ul{line-height:1.8;color:#333}.help-section p{line-height:1.6;color:#333}details{margin:15px 0;cursor:pointer;padding:10px;background:#f8f9fa;border-radius:5px}summary{font-weight:700;padding:5px;color:#007bff}details[open] summary{margin-bottom:10px;border-bottom:1px solid #ddd;padding-bottom:10px}details p{margin:10px 0 0}.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:#000000b3;display:flex;align-items:center;justify-content:center;z-index:2000}.modal-content{background:#fff;padding:40px;border-radius:16px;box-shadow:0 10px 40px #0000004d;max-width:500px;width:90%}.modal-content h2{margin-top:0;color:#333;text-align:center;margin-bottom:30px}.summary-stats{margin-bottom:30px}.summary-item{display:flex;justify-content:space-between;padding:15px 0;border-bottom:1px solid #eee}.summary-item:last-child{border-bottom:none}.summary-label{font-weight:600;color:#666}.summary-value{font-weight:700;color:#007bff;font-size:18px}.modal-content .btn-main{display:block;margin:0 auto;padding:12px 40px}.timeline-block{transition:opacity .2s;border-radius:2px}.timeline-block:hover{opacity:.7}@media(max-width:1200px){.stats-grid,.badges-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:768px){.stats-grid,.badges-grid{grid-template-columns:1fr;width:90%}.settings-container,.help-container,.chart-container,.sessions-list,.records-controls{width:90%}#control-panel{width:90%;flex-wrap:wrap}#display-area,#timeline-area{width:90%}#frame-control{width:90%;flex-direction:column}}.session-result-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#000000d9;display:flex;flex-direction:column;justify-content:center;align-items:center;color:#fff;z-index:10;animation:fadeIn .5s ease;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.session-result-overlay h3{font-size:32px;margin-bottom:30px;color:#4cd137;text-transform:uppercase;letter-spacing:2px}.session-result-overlay .result-item{display:flex;justify-content:space-between;width:200px;margin-bottom:15px;font-size:20px;border-bottom:1px solid rgba(255,255,255,.2);padding-bottom:5px}.session-result-overlay .label{color:#ccc;font-weight:400}.session-result-overlay .value{color:#fff;font-weight:700;font-family:Courier New,monospace}@keyframes fadeIn{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}
static/index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>my-ai-app</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700&display=swap" rel="stylesheet">
9
+ <script type="module" crossorigin src="/assets/index-DERnMkkp.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-DjIeJJNR.css">
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ </body>
15
+ </html>
static/vite.svg ADDED