Kexin-251202 commited on
Commit
9fcc92f
·
verified ·
1 Parent(s): 3298d3e

Upload 12 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ GAP_Large_project-fea-ui/focus_guard.db filter=lfs diff=lfs merge=lfs -text
GAP_Large_project-fea-ui/.DS_Store ADDED
Binary file (6.15 kB). View file
 
GAP_Large_project-fea-ui/Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies for OpenCV
6
+ RUN apt-get update && apt-get install -y \
7
+ libglib2.0-0 \
8
+ libsm6 \
9
+ libxext6 \
10
+ libxrender-dev \
11
+ libgomp1 \
12
+ libgthread-2.0-0 \
13
+ libgl1 \
14
+ libglib2.0-0 \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ COPY . .
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
GAP_Large_project-fea-ui/README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: FOCUS GUARD
3
+ colorFrom: green
4
+ colorTo: red
5
+ sdk: docker
6
+ pinned: false
7
+ ---
8
+
9
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
GAP_Large_project-fea-ui/app.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
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
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
+ # Set environment variable to allow loading YOLO weights
107
+ os.environ['TORCH_LOAD_WEIGHTS_ONLY'] = '0'
108
+
109
+ import torch
110
+ if hasattr(torch.serialization, 'add_safe_globals'):
111
+ # PyTorch 2.6+ compatibility - add required classes
112
+ try:
113
+ from ultralytics.nn.tasks import DetectionModel
114
+ import torch.nn as nn
115
+ torch.serialization.add_safe_globals([
116
+ DetectionModel,
117
+ nn.modules.container.Sequential,
118
+ ])
119
+ except Exception as e:
120
+ print(f" Safe globals setup: {e}")
121
+
122
+ from ultralytics import YOLO
123
+
124
+ model_path = "models/yolov8n.pt"
125
+
126
+ # Check if model file exists, if not use yolov8n (will download)
127
+ if not os.path.exists(model_path):
128
+ print(f"Model file {model_path} not found, downloading yolov8n.pt...")
129
+ model_path = "yolov8n.pt" # This will trigger auto-download
130
+
131
+ # Load model (ultralytics handles weights_only internally in newer versions)
132
+ model = YOLO(model_path)
133
+
134
+ # Optimize for CPU
135
+ try:
136
+ model.fuse() # Fuse Conv2d + BatchNorm layers
137
+ print("[OK] Model layers fused for optimization")
138
+ except Exception as e:
139
+ print(f" Model fusion skipped: {e}")
140
+
141
+ # Warm up model with dummy inference
142
+ print("Warming up model...")
143
+ dummy_img = np.zeros((416, 416, 3), dtype=np.uint8)
144
+ model(dummy_img, imgsz=416, conf=0.4, iou=0.45, max_det=5, classes=[0], verbose=False)
145
+
146
+ print("[OK] YOLOv8 model loaded and warmed up successfully")
147
+ return True
148
+ except Exception as e:
149
+ print(f"[ERROR] Failed to load YOLOv8 model: {e}")
150
+ print(" The app will run without detection features")
151
+ import traceback
152
+ traceback.print_exc()
153
+ return False
154
+
155
+ # ================ FOCUS DETECTION ALGORITHM ================
156
+
157
+ def is_user_focused(detections, frame_shape, sensitivity=6):
158
+ """
159
+ Determine if user is focused based on YOLOv8 detections
160
+
161
+ Simple logic: Detects person with confidence >= 80% (0.8)
162
+
163
+ Args:
164
+ detections: List of detection dictionaries
165
+ frame_shape: Tuple of (height, width, channels)
166
+ sensitivity: Integer 1-10, higher = stricter criteria (adjusts confidence threshold)
167
+
168
+ Returns:
169
+ Tuple of (is_focused: bool, confidence: float, metadata: dict)
170
+ """
171
+ # Filter person detections (class 0 in COCO dataset)
172
+ persons = [d for d in detections if d.get('class') == 0]
173
+
174
+ if not persons:
175
+ return False, 0.0, {'reason': 'no_person', 'count': 0}
176
+
177
+ # Find person with highest confidence
178
+ best_person = max(persons, key=lambda x: x.get('confidence', 0))
179
+ bbox = best_person['bbox'] # [x1, y1, x2, y2]
180
+ conf = best_person['confidence']
181
+
182
+ # Calculate confidence threshold based on sensitivity
183
+ # sensitivity 6 (default) = 0.8 threshold
184
+ # sensitivity 1 (lowest) = 0.5 threshold
185
+ # sensitivity 10 (highest) = 0.9 threshold
186
+ base_threshold = 0.8
187
+ sensitivity_adjustment = (sensitivity - 6) * 0.02 # ±0.08 range
188
+ confidence_threshold = base_threshold + sensitivity_adjustment
189
+ confidence_threshold = max(0.5, min(0.95, confidence_threshold)) # Clamp to 0.5-0.95
190
+
191
+ # Simple focus determination: confidence >= threshold
192
+ is_focused = conf >= confidence_threshold
193
+
194
+ # Optional: Check if person is somewhat centered (loose requirement)
195
+ h, w = frame_shape[0], frame_shape[1]
196
+ bbox_center_x = (bbox[0] + bbox[2]) / 2
197
+ bbox_center_y = (bbox[1] + bbox[3]) / 2
198
+
199
+ # Normalize to 0-1 range
200
+ center_x_norm = bbox_center_x / w if w > 0 else 0.5
201
+ center_y_norm = bbox_center_y / h if h > 0 else 0.5
202
+
203
+ # Check if person is in frame (not at extreme edges)
204
+ # Allow very loose centering: 20%-80% horizontal, 15%-85% vertical
205
+ in_frame = (0.2 <= center_x_norm <= 0.8) and (0.15 <= center_y_norm <= 0.85)
206
+
207
+ # Reduce focus score if person is at extreme edge
208
+ position_factor = 1.0 if in_frame else 0.7
209
+ final_score = conf * position_factor
210
+
211
+ # Also reduce if multiple persons detected
212
+ if len(persons) > 1:
213
+ final_score *= 0.9
214
+ reason = f"person_detected_multi_{len(persons)}"
215
+ else:
216
+ reason = "person_detected" if is_focused else "low_confidence"
217
+
218
+ metadata = {
219
+ 'bbox': bbox,
220
+ 'detection_confidence': round(conf, 3),
221
+ 'confidence_threshold': round(confidence_threshold, 3),
222
+ 'center_position': [round(center_x_norm, 3), round(center_y_norm, 3)],
223
+ 'in_frame': in_frame,
224
+ 'person_count': len(persons),
225
+ 'reason': reason
226
+ }
227
+
228
+ return is_focused and in_frame, final_score, metadata
229
+
230
+ def parse_yolo_results(results):
231
+ """Parse YOLOv8 results into a list of detections"""
232
+ detections = []
233
+
234
+ if results and len(results) > 0:
235
+ result = results[0]
236
+ boxes = result.boxes
237
+
238
+ if boxes is not None and len(boxes) > 0:
239
+ for box in boxes:
240
+ # Get box coordinates
241
+ xyxy = box.xyxy[0].cpu().numpy()
242
+ conf = float(box.conf[0].cpu().numpy())
243
+ cls = int(box.cls[0].cpu().numpy())
244
+
245
+ detection = {
246
+ 'bbox': [float(x) for x in xyxy],
247
+ 'confidence': conf,
248
+ 'class': cls,
249
+ 'class_name': result.names[cls] if hasattr(result, 'names') else str(cls)
250
+ }
251
+ detections.append(detection)
252
+
253
+ return detections
254
+
255
+ # ================ DATABASE OPERATIONS ================
256
+
257
+ async def create_session():
258
+ """Create a new focus session"""
259
+ async with aiosqlite.connect(db_path) as db:
260
+ cursor = await db.execute(
261
+ "INSERT INTO focus_sessions (start_time) VALUES (?)",
262
+ (datetime.now().isoformat(),)
263
+ )
264
+ await db.commit()
265
+ return cursor.lastrowid
266
+
267
+ async def end_session(session_id: int):
268
+ """End a focus session and calculate statistics"""
269
+ async with aiosqlite.connect(db_path) as db:
270
+ # Get session data
271
+ cursor = await db.execute(
272
+ "SELECT start_time, total_frames, focused_frames FROM focus_sessions WHERE id = ?",
273
+ (session_id,)
274
+ )
275
+ row = await cursor.fetchone()
276
+
277
+ if not row:
278
+ return None
279
+
280
+ start_time_str, total_frames, focused_frames = row
281
+ start_time = datetime.fromisoformat(start_time_str)
282
+ end_time = datetime.now()
283
+ duration = (end_time - start_time).total_seconds()
284
+
285
+ # Calculate focus score
286
+ focus_score = focused_frames / total_frames if total_frames > 0 else 0.0
287
+
288
+ # Update session
289
+ await db.execute("""
290
+ UPDATE focus_sessions
291
+ SET end_time = ?, duration_seconds = ?, focus_score = ?
292
+ WHERE id = ?
293
+ """, (end_time.isoformat(), int(duration), focus_score, session_id))
294
+
295
+ await db.commit()
296
+
297
+ return {
298
+ 'session_id': session_id,
299
+ 'start_time': start_time_str,
300
+ 'end_time': end_time.isoformat(),
301
+ 'duration_seconds': int(duration),
302
+ 'focus_score': round(focus_score, 3),
303
+ 'total_frames': total_frames,
304
+ 'focused_frames': focused_frames
305
+ }
306
+
307
+ async def store_focus_event(session_id: int, is_focused: bool, confidence: float, metadata: dict):
308
+ """Store a focus detection event"""
309
+ async with aiosqlite.connect(db_path) as db:
310
+ await db.execute("""
311
+ INSERT INTO focus_events (session_id, timestamp, is_focused, confidence, detection_data)
312
+ VALUES (?, ?, ?, ?, ?)
313
+ """, (session_id, datetime.now().isoformat(), is_focused, confidence, json.dumps(metadata)))
314
+
315
+ # Update session frame counts
316
+ await db.execute(f"""
317
+ UPDATE focus_sessions
318
+ SET total_frames = total_frames + 1,
319
+ focused_frames = focused_frames + {1 if is_focused else 0}
320
+ WHERE id = ?
321
+ """, (session_id,))
322
+
323
+ await db.commit()
324
+
325
+ # ================ STARTUP/SHUTDOWN EVENTS ================
326
+
327
+ @app.on_event("startup")
328
+ async def startup_event():
329
+ """Initialize database and load model on startup"""
330
+ print(" Starting Focus Guard API...")
331
+ await init_database()
332
+ print("[OK] Database initialized")
333
+ load_yolo_model()
334
+
335
+ @app.on_event("shutdown")
336
+ async def shutdown_event():
337
+ """Cleanup on shutdown"""
338
+ print(" Shutting down Focus Guard API...")
339
+
340
+ # ================ STATIC FILES ================
341
+
342
+ app.mount("/static", StaticFiles(directory="static"), name="static")
343
+
344
+ @app.get("/")
345
+ async def read_index():
346
+ return FileResponse("static/index.html")
347
+
348
+ # ================ WEBSOCKET ENDPOINT ================
349
+
350
+ @app.websocket("/ws/video")
351
+ async def websocket_endpoint(websocket: WebSocket):
352
+ await websocket.accept()
353
+ session_id = None
354
+ frame_count = 0
355
+ last_inference_time = 0
356
+ min_inference_interval = 0.1 # Max 10 FPS server-side
357
+
358
+ try:
359
+ # Get user settings
360
+ async with aiosqlite.connect(db_path) as db:
361
+ cursor = await db.execute("SELECT sensitivity FROM user_settings WHERE id = 1")
362
+ row = await cursor.fetchone()
363
+ sensitivity = row[0] if row else 6
364
+
365
+ while True:
366
+ # Receive data from client
367
+ data = await websocket.receive_json()
368
+
369
+ if data['type'] == 'frame':
370
+ from time import time
371
+ current_time = time()
372
+
373
+ # Rate limiting
374
+ if current_time - last_inference_time < min_inference_interval:
375
+ # Skip inference, just acknowledge
376
+ await websocket.send_json({
377
+ 'type': 'ack',
378
+ 'frame_count': frame_count
379
+ })
380
+ continue
381
+
382
+ last_inference_time = current_time
383
+
384
+ try:
385
+ # Decode base64 image
386
+ img_data = base64.b64decode(data['image'])
387
+ nparr = np.frombuffer(img_data, np.uint8)
388
+ frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
389
+
390
+ if frame is None:
391
+ continue
392
+
393
+ # Resize for faster inference
394
+ frame = cv2.resize(frame, (640, 480))
395
+
396
+ # YOLOv8 inference
397
+ if model is not None:
398
+ results = model(
399
+ frame,
400
+ imgsz=416,
401
+ conf=0.4,
402
+ iou=0.45,
403
+ max_det=5,
404
+ classes=[0], # Only person class
405
+ verbose=False
406
+ )
407
+ detections = parse_yolo_results(results)
408
+ else:
409
+ # Fallback if model not loaded
410
+ detections = []
411
+
412
+ # Determine focus status
413
+ is_focused, confidence, metadata = is_user_focused(
414
+ detections, frame.shape, sensitivity
415
+ )
416
+
417
+ # Store event in database if session active
418
+ if session_id:
419
+ await store_focus_event(session_id, is_focused, confidence, metadata)
420
+
421
+ # Send results back to client
422
+ response = {
423
+ 'type': 'detection',
424
+ 'focused': is_focused,
425
+ 'confidence': round(confidence, 3),
426
+ 'detections': detections,
427
+ 'frame_count': frame_count
428
+ }
429
+
430
+ await websocket.send_json(response)
431
+ frame_count += 1
432
+
433
+ except Exception as e:
434
+ print(f"Error processing frame: {e}")
435
+ await websocket.send_json({
436
+ 'type': 'error',
437
+ 'message': str(e)
438
+ })
439
+
440
+ elif data['type'] == 'start_session':
441
+ session_id = await create_session()
442
+ await websocket.send_json({
443
+ 'type': 'session_started',
444
+ 'session_id': session_id
445
+ })
446
+
447
+ elif data['type'] == 'end_session':
448
+ if session_id:
449
+ summary = await end_session(session_id)
450
+ await websocket.send_json({
451
+ 'type': 'session_ended',
452
+ 'summary': summary
453
+ })
454
+ session_id = None
455
+
456
+ except WebSocketDisconnect:
457
+ if session_id:
458
+ await end_session(session_id)
459
+ print(f"WebSocket disconnected (session: {session_id})")
460
+ except Exception as e:
461
+ print(f"WebSocket error: {e}")
462
+ if websocket.client_state.value == 1: # CONNECTED
463
+ await websocket.close()
464
+
465
+ # ================ REST API ENDPOINTS ================
466
+
467
+ @app.post("/api/sessions/start")
468
+ async def api_start_session():
469
+ """Start a new focus session"""
470
+ session_id = await create_session()
471
+ return {"session_id": session_id}
472
+
473
+ @app.post("/api/sessions/end")
474
+ async def api_end_session(data: SessionEnd):
475
+ """End a focus session"""
476
+ summary = await end_session(data.session_id)
477
+ if not summary:
478
+ raise HTTPException(status_code=404, detail="Session not found")
479
+ return summary
480
+
481
+ @app.get("/api/sessions")
482
+ async def get_sessions(filter: str = "all", limit: int = 50, offset: int = 0):
483
+ """Get focus sessions with optional filtering"""
484
+ async with aiosqlite.connect(db_path) as db:
485
+ db.row_factory = aiosqlite.Row
486
+
487
+ # Build query based on filter
488
+ if filter == "today":
489
+ date_filter = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
490
+ query = "SELECT * FROM focus_sessions WHERE start_time >= ? ORDER BY start_time DESC LIMIT ? OFFSET ?"
491
+ params = (date_filter.isoformat(), limit, offset)
492
+ elif filter == "week":
493
+ date_filter = datetime.now() - timedelta(days=7)
494
+ query = "SELECT * FROM focus_sessions WHERE start_time >= ? ORDER BY start_time DESC LIMIT ? OFFSET ?"
495
+ params = (date_filter.isoformat(), limit, offset)
496
+ elif filter == "month":
497
+ date_filter = datetime.now() - timedelta(days=30)
498
+ query = "SELECT * FROM focus_sessions WHERE start_time >= ? ORDER BY start_time DESC LIMIT ? OFFSET ?"
499
+ params = (date_filter.isoformat(), limit, offset)
500
+ else:
501
+ query = "SELECT * FROM focus_sessions WHERE end_time IS NOT NULL ORDER BY start_time DESC LIMIT ? OFFSET ?"
502
+ params = (limit, offset)
503
+
504
+ cursor = await db.execute(query, params)
505
+ rows = await cursor.fetchall()
506
+
507
+ sessions = [dict(row) for row in rows]
508
+ return sessions
509
+
510
+ @app.get("/api/sessions/{session_id}")
511
+ async def get_session(session_id: int):
512
+ """Get detailed session information"""
513
+ async with aiosqlite.connect(db_path) as db:
514
+ db.row_factory = aiosqlite.Row
515
+ cursor = await db.execute("SELECT * FROM focus_sessions WHERE id = ?", (session_id,))
516
+ row = await cursor.fetchone()
517
+
518
+ if not row:
519
+ raise HTTPException(status_code=404, detail="Session not found")
520
+
521
+ session = dict(row)
522
+
523
+ # Get events
524
+ cursor = await db.execute(
525
+ "SELECT * FROM focus_events WHERE session_id = ? ORDER BY timestamp",
526
+ (session_id,)
527
+ )
528
+ events = [dict(r) for r in await cursor.fetchall()]
529
+ session['events'] = events
530
+
531
+ return session
532
+
533
+ @app.get("/api/settings")
534
+ async def get_settings():
535
+ """Get user settings"""
536
+ async with aiosqlite.connect(db_path) as db:
537
+ db.row_factory = aiosqlite.Row
538
+ cursor = await db.execute("SELECT * FROM user_settings WHERE id = 1")
539
+ row = await cursor.fetchone()
540
+
541
+ if row:
542
+ return dict(row)
543
+ else:
544
+ return {
545
+ 'sensitivity': 6,
546
+ 'notification_enabled': True,
547
+ 'notification_threshold': 30,
548
+ 'frame_rate': 30,
549
+ 'model_name': 'yolov8n.pt'
550
+ }
551
+
552
+ @app.put("/api/settings")
553
+ async def update_settings(settings: SettingsUpdate):
554
+ """Update user settings"""
555
+ async with aiosqlite.connect(db_path) as db:
556
+ # First ensure the record exists
557
+ cursor = await db.execute("SELECT id FROM user_settings WHERE id = 1")
558
+ exists = await cursor.fetchone()
559
+
560
+ if not exists:
561
+ # Insert default record if it doesn't exist
562
+ await db.execute("""
563
+ INSERT INTO user_settings (id, sensitivity, notification_enabled, notification_threshold, frame_rate, model_name)
564
+ VALUES (1, 6, 1, 30, 30, 'yolov8n.pt')
565
+ """)
566
+ await db.commit()
567
+ print("[OK] Created default user_settings record")
568
+
569
+ # Now update with provided values
570
+ updates = []
571
+ params = []
572
+
573
+ if settings.sensitivity is not None:
574
+ updates.append("sensitivity = ?")
575
+ params.append(max(1, min(10, settings.sensitivity)))
576
+
577
+ if settings.notification_enabled is not None:
578
+ updates.append("notification_enabled = ?")
579
+ params.append(settings.notification_enabled)
580
+
581
+ if settings.notification_threshold is not None:
582
+ updates.append("notification_threshold = ?")
583
+ params.append(max(5, min(300, settings.notification_threshold)))
584
+
585
+ if settings.frame_rate is not None:
586
+ updates.append("frame_rate = ?")
587
+ params.append(max(5, min(60, settings.frame_rate)))
588
+
589
+ if updates:
590
+ query = f"UPDATE user_settings SET {', '.join(updates)} WHERE id = 1"
591
+ await db.execute(query, params)
592
+ await db.commit()
593
+ print(f"[OK] Settings updated: {settings.model_dump(exclude_none=True)}")
594
+
595
+ return {"status": "success", "updated": len(updates) > 0}
596
+
597
+ @app.get("/api/stats/summary")
598
+ async def get_stats_summary():
599
+ """Get overall statistics summary"""
600
+ async with aiosqlite.connect(db_path) as db:
601
+ # Total sessions
602
+ cursor = await db.execute("SELECT COUNT(*) FROM focus_sessions WHERE end_time IS NOT NULL")
603
+ total_sessions = (await cursor.fetchone())[0]
604
+
605
+ # Total focus time
606
+ cursor = await db.execute("SELECT SUM(duration_seconds) FROM focus_sessions WHERE end_time IS NOT NULL")
607
+ total_focus_time = (await cursor.fetchone())[0] or 0
608
+
609
+ # Average focus score
610
+ cursor = await db.execute("SELECT AVG(focus_score) FROM focus_sessions WHERE end_time IS NOT NULL")
611
+ avg_focus_score = (await cursor.fetchone())[0] or 0.0
612
+
613
+ # Streak calculation (consecutive days with sessions)
614
+ cursor = await db.execute("""
615
+ SELECT DISTINCT DATE(start_time) as session_date
616
+ FROM focus_sessions
617
+ WHERE end_time IS NOT NULL
618
+ ORDER BY session_date DESC
619
+ """)
620
+ dates = [row[0] for row in await cursor.fetchall()]
621
+
622
+ streak_days = 0
623
+ if dates:
624
+ current_date = datetime.now().date()
625
+ for i, date_str in enumerate(dates):
626
+ session_date = datetime.fromisoformat(date_str).date()
627
+ expected_date = current_date - timedelta(days=i)
628
+ if session_date == expected_date:
629
+ streak_days += 1
630
+ else:
631
+ break
632
+
633
+ return {
634
+ 'total_sessions': total_sessions,
635
+ 'total_focus_time': int(total_focus_time),
636
+ 'avg_focus_score': round(avg_focus_score, 3),
637
+ 'streak_days': streak_days
638
+ }
639
+
640
+ # ================ HEALTH CHECK ================
641
+
642
+ @app.get("/health")
643
+ async def health_check():
644
+ """Health check endpoint"""
645
+ return {
646
+ "status": "healthy",
647
+ "model_loaded": model is not None,
648
+ "database": os.path.exists(db_path)
649
+ }
GAP_Large_project-fea-ui/assets/README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: FOCUS GUARD
3
+ colorFrom: green
4
+ colorTo: red
5
+ sdk: docker
6
+ pinned: false
7
+ ---
8
+
9
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
GAP_Large_project-fea-ui/focus_guard.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb042116505f0019d73e87a8262be3ef313b2b1f1471c3fd9bd1d61ee34d73ea
3
+ size 1105920
GAP_Large_project-fea-ui/models/yolov8n.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:31e20dde3def09e2cf938c7be6fe23d9150bbbe503982af13345706515f2ef95
3
+ size 6534387
GAP_Large_project-fea-ui/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ websockets==12.0
4
+ opencv-python-headless==4.8.1.78
5
+ ultralytics==8.0.206
6
+ numpy==1.24.3
7
+ Pillow==10.1.0
8
+ python-multipart==0.0.6
9
+ aiosqlite==0.19.0
10
+ pydantic==2.4.2
11
+ torch==2.5.1
12
+ torchvision==0.20.1
GAP_Large_project-fea-ui/static/index.html ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Focus Guard</title>
6
+ <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700&display=swap" rel="stylesheet">
7
+ <link rel="stylesheet" href="/static/styles.css?v=2">
8
+ </head>
9
+ <body>
10
+ <h1>Focus Guard</h1>
11
+
12
+ <!-- Top Menu -->
13
+ <nav id="top-menu">
14
+ <button id="menu-start" class="menu-btn">Start Focus</button>
15
+ <div class="separator"></div>
16
+ <button id="menu-achievement" class="menu-btn">My Achievement</button>
17
+ <div class="separator"></div>
18
+ <button id="menu-records" class="menu-btn">My Records</button>
19
+ <div class="separator"></div>
20
+ <button id="menu-customise" class="menu-btn">Customise</button>
21
+ <div class="separator"></div>
22
+ <button id="menu-help" class="menu-btn">Help</button>
23
+ </nav>
24
+ <!-- Page A -->
25
+ <main id="page-a" class="page">
26
+ <h1>Focus Guard</h1>
27
+ <p>Your productivity monitor assistant.</p>
28
+ <button id="start-button" class="btn-main">Start</button>
29
+ </main>
30
+ <!-- Page B -->
31
+ <main id="page-b" class="page hidden">
32
+ <!-- 1. Camera / Display Area -->
33
+ <section id="display-area">
34
+ <p id="display-placeholder">Nothing</p>
35
+ </section>
36
+
37
+ <!-- 2. Timeline Area -->
38
+ <section id="timeline-area">
39
+ <div class="timeline-label">Timeline</div>
40
+ <div id="timeline-visuals"></div>
41
+ <div id="timeline-line"></div>
42
+ </section>
43
+
44
+ <!-- 3. Control Buttons -->
45
+ <section id="control-panel">
46
+ <button id="btn-cam-start" class="action-btn green">Start</button>
47
+ <button id="btn-floating" class="action-btn yellow">Floating Window</button>
48
+ <button id="btn-models" class="action-btn blue">Models</button>
49
+ <button id="btn-cam-stop" class="action-btn red">Stop</button>
50
+ </section>
51
+
52
+ <!-- 4. Frame Control -->
53
+ <section id="frame-control">
54
+ <label for="frame-slider">Frame</label>
55
+ <input type="range" id="frame-slider" min="1" max="60" value="30">
56
+ <input type="number" id="frame-input" value="30">
57
+ </section>
58
+ </main>
59
+
60
+ <!-- Page C - My Achievement -->
61
+ <main id="page-c" class="page hidden">
62
+ <h1>My Achievement</h1>
63
+
64
+ <div class="stats-grid">
65
+ <div class="stat-card">
66
+ <div class="stat-number" id="total-sessions">0</div>
67
+ <div class="stat-label">Total Sessions</div>
68
+ </div>
69
+ <div class="stat-card">
70
+ <div class="stat-number" id="total-hours">0h</div>
71
+ <div class="stat-label">Total Focus Time</div>
72
+ </div>
73
+ <div class="stat-card">
74
+ <div class="stat-number" id="avg-focus">0%</div>
75
+ <div class="stat-label">Average Focus</div>
76
+ </div>
77
+ <div class="stat-card">
78
+ <div class="stat-number" id="current-streak">0</div>
79
+ <div class="stat-label">Day Streak</div>
80
+ </div>
81
+ </div>
82
+
83
+ <div class="achievements-section">
84
+ <h2>Badges</h2>
85
+ <div id="badges-container" class="badges-grid"></div>
86
+ </div>
87
+ </main>
88
+
89
+ <!-- Page D - My Records -->
90
+ <main id="page-d" class="page hidden">
91
+ <h1>My Records</h1>
92
+
93
+ <div class="records-controls">
94
+ <button id="filter-today" class="filter-btn active">Today</button>
95
+ <button id="filter-week" class="filter-btn">This Week</button>
96
+ <button id="filter-month" class="filter-btn">This Month</button>
97
+ <button id="filter-all" class="filter-btn">All Time</button>
98
+ </div>
99
+
100
+ <div class="chart-container">
101
+ <canvas id="focus-chart"></canvas>
102
+ </div>
103
+
104
+ <div class="sessions-list">
105
+ <h2>Recent Sessions</h2>
106
+ <table id="sessions-table">
107
+ <thead>
108
+ <tr>
109
+ <th>Date</th>
110
+ <th>Duration</th>
111
+ <th>Focus Score</th>
112
+ <th>Action</th>
113
+ </tr>
114
+ </thead>
115
+ <tbody id="sessions-tbody"></tbody>
116
+ </table>
117
+ </div>
118
+ </main>
119
+
120
+ <!-- Page E - Customise -->
121
+ <main id="page-e" class="page hidden">
122
+ <h1>Customise</h1>
123
+
124
+ <div class="settings-container">
125
+ <div class="setting-group">
126
+ <h2>Detection Settings</h2>
127
+
128
+ <div class="setting-item">
129
+ <label for="sensitivity-slider">Detection Sensitivity</label>
130
+ <div class="slider-group">
131
+ <input type="range" id="sensitivity-slider" min="1" max="10" value="6">
132
+ <span id="sensitivity-value">6</span>
133
+ </div>
134
+ <p class="setting-description">Higher values require stricter focus criteria</p>
135
+ </div>
136
+
137
+ <div class="setting-item">
138
+ <label for="default-framerate">Default Frame Rate</label>
139
+ <div class="slider-group">
140
+ <input type="range" id="default-framerate" min="5" max="60" value="30">
141
+ <span id="framerate-value">30</span> FPS
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ <div class="setting-group">
147
+ <h2>Notifications</h2>
148
+
149
+ <div class="setting-item">
150
+ <label>
151
+ <input type="checkbox" id="enable-notifications" checked>
152
+ Enable distraction notifications
153
+ </label>
154
+ </div>
155
+
156
+ <div class="setting-item">
157
+ <label for="notification-threshold">Alert after (seconds)</label>
158
+ <input type="number" id="notification-threshold" value="30" min="5" max="300">
159
+ </div>
160
+ </div>
161
+
162
+ <div class="setting-group">
163
+ <h2>Data Management</h2>
164
+
165
+ <button id="export-data" class="action-btn blue">Export Data</button>
166
+ <button id="clear-history" class="action-btn red">Clear History</button>
167
+ </div>
168
+
169
+ <button id="save-settings" class="btn-main">Save Settings</button>
170
+ </div>
171
+ </main>
172
+
173
+ <!-- Page F - Help -->
174
+ <main id="page-f" class="page hidden">
175
+ <h1>Help</h1>
176
+
177
+ <div class="help-container">
178
+ <section class="help-section">
179
+ <h2>How to Use Focus Guard</h2>
180
+ <ol>
181
+ <li>Click "Start" or navigate to "Start Focus" in the menu</li>
182
+ <li>Allow camera access when prompted</li>
183
+ <li>Click the green "Start" button to begin monitoring</li>
184
+ <li>Position yourself in front of the camera</li>
185
+ <li>The system will track your focus in real-time</li>
186
+ <li>Click "Stop" when you're done to save the session</li>
187
+ </ol>
188
+ </section>
189
+
190
+ <section class="help-section">
191
+ <h2>What is "Focused"?</h2>
192
+ <p>The system considers you focused when:</p>
193
+ <ul>
194
+ <li>You are clearly visible in the camera frame</li>
195
+ <li>You are centered in the view</li>
196
+ <li>Your face is directed toward the screen</li>
197
+ <li>No other people are detected in the frame</li>
198
+ </ul>
199
+ </section>
200
+
201
+ <section class="help-section">
202
+ <h2>Adjusting Settings</h2>
203
+ <p><strong>Frame Rate:</strong> Lower values reduce CPU usage but update less frequently. Recommended: 15-30 FPS.</p>
204
+ <p><strong>Sensitivity:</strong> Higher values require stricter focus criteria. Adjust based on your setup.</p>
205
+ </section>
206
+
207
+ <section class="help-section">
208
+ <h2>Privacy & Data</h2>
209
+ <p>All video processing happens in real-time. No video frames are stored - only detection metadata (focus status, timestamps) is saved in your local database.</p>
210
+ </section>
211
+
212
+ <section class="help-section">
213
+ <h2>FAQ</h2>
214
+ <details>
215
+ <summary>Why is my focus score low?</summary>
216
+ <p>Ensure good lighting, center yourself in the camera frame, and adjust sensitivity settings in the Customise page.</p>
217
+ </details>
218
+ <details>
219
+ <summary>Can I use this without a camera?</summary>
220
+ <p>No, camera access is required for focus detection.</p>
221
+ </details>
222
+ <details>
223
+ <summary>Does this work on mobile?</summary>
224
+ <p>The app works on mobile browsers but performance may vary due to processing requirements.</p>
225
+ </details>
226
+ <details>
227
+ <summary>Is my data private?</summary>
228
+ <p>Yes! All processing happens locally. Video frames are analyzed in real-time and never stored. Only metadata is saved.</p>
229
+ </details>
230
+ </section>
231
+
232
+ <section class="help-section">
233
+ <h2>Technical Info</h2>
234
+ <p><strong>Model:</strong> YOLOv8n (Nano)</p>
235
+ <p><strong>Detection:</strong> Real-time person detection with pose analysis</p>
236
+ <p><strong>Storage:</strong> SQLite local database</p>
237
+ <p><strong>Framework:</strong> FastAPI + Native JavaScript</p>
238
+ </section>
239
+ </div>
240
+ </main>
241
+
242
+ <script src="/static/script.js?v=2"></script>
243
+ </body>
244
+ </html>
GAP_Large_project-fea-ui/static/script.js ADDED
@@ -0,0 +1,794 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ================ PAGE NAVIGATION ================
2
+
3
+ const pages = {
4
+ 'page-a': document.getElementById('page-a'),
5
+ 'page-b': document.getElementById('page-b'),
6
+ 'page-c': document.getElementById('page-c'), // Achievement
7
+ 'page-d': document.getElementById('page-d'), // Records
8
+ 'page-e': document.getElementById('page-e'), // Customise
9
+ 'page-f': document.getElementById('page-f') // Help
10
+ };
11
+
12
+ function showPage(pageId) {
13
+ for (const key in pages) {
14
+ pages[key].classList.add('hidden');
15
+ }
16
+ if (pages[pageId]) {
17
+ pages[pageId].classList.remove('hidden');
18
+ }
19
+ }
20
+
21
+ // ================ VIDEO MANAGER CLASS ================
22
+
23
+ class VideoManager {
24
+ constructor() {
25
+ this.videoElement = null;
26
+ this.canvasElement = null;
27
+ this.ctx = null;
28
+ this.captureCanvas = null;
29
+ this.captureCtx = null;
30
+ this.stream = null;
31
+ this.ws = null;
32
+ this.isStreaming = false;
33
+ this.sessionId = null;
34
+ this.frameRate = 30;
35
+ this.frameInterval = null;
36
+ this.renderLoopId = null;
37
+
38
+ // Status smoothing for stable display
39
+ this.currentStatus = false; // Default: not focused
40
+ this.previousStatus = false; // Track previous status to detect changes
41
+ this.statusBuffer = []; // Buffer for last N frames
42
+ this.bufferSize = 5; // Number of frames to average (smaller = more responsive)
43
+
44
+ // Latest detection data for rendering
45
+ this.latestDetectionData = null;
46
+ this.lastConfidence = 0;
47
+ this.detectionHoldMs = 30;
48
+ }
49
+
50
+ async initCamera() {
51
+ try {
52
+ this.stream = await navigator.mediaDevices.getUserMedia({
53
+ video: {
54
+ width: { ideal: 640 },
55
+ height: { ideal: 480 },
56
+ facingMode: 'user'
57
+ }
58
+ });
59
+
60
+ this.videoElement = document.createElement('video');
61
+ this.videoElement.srcObject = this.stream;
62
+ this.videoElement.autoplay = true;
63
+ this.videoElement.playsInline = true;
64
+
65
+ this.canvasElement = document.createElement('canvas');
66
+ this.canvasElement.width = 640;
67
+ this.canvasElement.height = 480;
68
+ this.ctx = this.canvasElement.getContext('2d');
69
+
70
+ this.captureCanvas = document.createElement('canvas');
71
+ this.captureCanvas.width = 640;
72
+ this.captureCanvas.height = 480;
73
+ this.captureCtx = this.captureCanvas.getContext('2d');
74
+
75
+ const displayArea = document.getElementById('display-area');
76
+ displayArea.innerHTML = '';
77
+ displayArea.appendChild(this.canvasElement);
78
+
79
+ await this.videoElement.play();
80
+ this.startRenderLoop();
81
+
82
+ return true;
83
+ } catch (error) {
84
+ console.error('Camera init error:', error);
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ connectWebSocket() {
90
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
91
+ const wsUrl = `${protocol}//${window.location.host}/ws/video`;
92
+
93
+ this.ws = new WebSocket(wsUrl);
94
+
95
+ this.ws.onopen = () => {
96
+ console.log('WebSocket connected');
97
+ this.startSession();
98
+ };
99
+
100
+ this.ws.onmessage = (event) => {
101
+ const data = JSON.parse(event.data);
102
+ this.handleServerMessage(data);
103
+ };
104
+
105
+ this.ws.onerror = (error) => {
106
+ console.error('WebSocket error:', error);
107
+ };
108
+
109
+ this.ws.onclose = () => {
110
+ console.log('WebSocket closed');
111
+ };
112
+ }
113
+
114
+ startStreaming() {
115
+ this.isStreaming = true;
116
+ this.connectWebSocket();
117
+
118
+ this.frameInterval = setInterval(() => {
119
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
120
+ this.captureAndSendFrame();
121
+ }
122
+ }, 1000 / this.frameRate);
123
+ }
124
+
125
+ captureAndSendFrame() {
126
+ if (!this.videoElement || !this.captureCanvas || !this.captureCtx) return;
127
+
128
+ this.captureCtx.drawImage(this.videoElement, 0, 0, 640, 480);
129
+
130
+ const imageData = this.captureCanvas.toDataURL('image/jpeg', 0.8);
131
+ const base64Data = imageData.split(',')[1];
132
+
133
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
134
+ this.ws.send(JSON.stringify({
135
+ type: 'frame',
136
+ image: base64Data
137
+ }));
138
+ }
139
+ }
140
+
141
+ handleServerMessage(data) {
142
+ switch (data.type) {
143
+ case 'detection':
144
+ // Update status with smoothing
145
+ this.updateStatus(data.focused);
146
+
147
+ // Render with smoothed status
148
+ this.renderDetections(data);
149
+
150
+ // Update timeline and notifications with smoothed status
151
+ timeline.addEvent(this.currentStatus);
152
+ checkDistraction(this.currentStatus);
153
+ break;
154
+ case 'session_started':
155
+ this.sessionId = data.session_id;
156
+ console.log('Session started:', this.sessionId);
157
+ break;
158
+ case 'session_ended':
159
+ console.log('Session ended:', data.summary);
160
+ showSessionSummary(data.summary);
161
+ break;
162
+ case 'ack':
163
+ // Frame acknowledged but not processed
164
+ break;
165
+ case 'error':
166
+ console.error('Server error:', data.message);
167
+ break;
168
+ }
169
+ }
170
+
171
+ updateStatus(newFocused) {
172
+ // Add to buffer
173
+ this.statusBuffer.push(newFocused);
174
+
175
+ // Keep buffer size limited
176
+ if (this.statusBuffer.length > this.bufferSize) {
177
+ this.statusBuffer.shift();
178
+ }
179
+
180
+ // Don't update status until buffer is full (prevents initial flickering)
181
+ if (this.statusBuffer.length < this.bufferSize) {
182
+ return false; // Status hasn't changed
183
+ }
184
+
185
+ // Calculate majority vote with moderate thresholds for better responsiveness
186
+ const focusedCount = this.statusBuffer.filter(f => f).length;
187
+ const focusedRatio = focusedCount / this.statusBuffer.length;
188
+
189
+ // Store previous status
190
+ this.previousStatus = this.currentStatus;
191
+
192
+ // Moderate thresholds: quicker to change, still avoids rapid flipping
193
+ // For 8 frames: need 6+ focused to become FOCUSED, or 6+ not focused to become NOT FOCUSED
194
+ if (focusedRatio >= 0.75) {
195
+ this.currentStatus = true;
196
+ } else if (focusedRatio <= 0.25) {
197
+ this.currentStatus = false;
198
+ }
199
+ // Between 0.25-0.75: keep current status (hysteresis to avoid jitter)
200
+
201
+ // Log only when status actually changes
202
+ const statusChanged = this.currentStatus !== this.previousStatus;
203
+ if (statusChanged) {
204
+ console.log(`Status changed: ${this.previousStatus ? 'FOCUSED' : 'NOT FOCUSED'} -> ${this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED'} (ratio: ${focusedRatio.toFixed(2)})`);
205
+ }
206
+
207
+ // Return whether status changed
208
+ return statusChanged;
209
+ }
210
+
211
+ renderDetections(data) {
212
+ this.latestDetectionData = {
213
+ detections: data.detections || [],
214
+ confidence: data.confidence || 0,
215
+ focused: data.focused,
216
+ timestamp: performance.now()
217
+ };
218
+ this.lastConfidence = data.confidence || 0;
219
+ }
220
+
221
+ startRenderLoop() {
222
+ if (this.renderLoopId) return;
223
+
224
+ const render = () => {
225
+ if (this.videoElement && this.ctx) {
226
+ this.ctx.drawImage(this.videoElement, 0, 0, 640, 480);
227
+
228
+ const now = performance.now();
229
+ const latest = this.latestDetectionData;
230
+ const hasFresh = latest && (now - latest.timestamp) <= this.detectionHoldMs;
231
+
232
+ // Draw detection boxes using last known data (prevents flicker)
233
+ if (hasFresh && latest.detections.length > 0) {
234
+ latest.detections.forEach(det => {
235
+ const [x1, y1, x2, y2] = det.bbox;
236
+ this.ctx.strokeStyle = this.currentStatus ? '#00FF00' : '#FF0000';
237
+ this.ctx.lineWidth = 3;
238
+ this.ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
239
+
240
+ this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000';
241
+ this.ctx.font = '16px Nunito';
242
+ const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`;
243
+ this.ctx.fillText(label, x1, y1 - 5);
244
+ });
245
+ }
246
+
247
+ const statusText = this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED';
248
+ this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000';
249
+ this.ctx.font = 'bold 24px Nunito';
250
+ this.ctx.fillText(statusText, 10, 30);
251
+
252
+ this.ctx.font = '16px Nunito';
253
+ this.ctx.fillText(`Confidence: ${(this.lastConfidence * 100).toFixed(1)}%`, 10, 55);
254
+ }
255
+
256
+ this.renderLoopId = requestAnimationFrame(render);
257
+ };
258
+
259
+ this.renderLoopId = requestAnimationFrame(render);
260
+ }
261
+
262
+ stopRenderLoop() {
263
+ if (this.renderLoopId) {
264
+ cancelAnimationFrame(this.renderLoopId);
265
+ this.renderLoopId = null;
266
+ }
267
+ }
268
+
269
+ startSession() {
270
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
271
+ this.ws.send(JSON.stringify({ type: 'start_session' }));
272
+ }
273
+ }
274
+
275
+ stopStreaming() {
276
+ this.isStreaming = false;
277
+ this.stopRenderLoop();
278
+
279
+ if (this.frameInterval) {
280
+ clearInterval(this.frameInterval);
281
+ this.frameInterval = null;
282
+ }
283
+
284
+ if (this.ws) {
285
+ this.ws.send(JSON.stringify({ type: 'end_session' }));
286
+ this.ws.close();
287
+ this.ws = null;
288
+ }
289
+
290
+ if (this.stream) {
291
+ this.stream.getTracks().forEach(track => track.stop());
292
+ this.stream = null;
293
+ }
294
+
295
+ if (this.canvasElement && this.ctx) {
296
+ this.ctx.clearRect(0, 0, 640, 480);
297
+ }
298
+
299
+ // Reset status
300
+ this.currentStatus = false;
301
+ this.statusBuffer = [];
302
+ this.latestDetectionData = null;
303
+ this.lastConfidence = 0;
304
+ }
305
+
306
+ setFrameRate(rate) {
307
+ this.frameRate = Math.max(1, Math.min(60, rate));
308
+
309
+ if (this.isStreaming && this.frameInterval) {
310
+ clearInterval(this.frameInterval);
311
+ this.frameInterval = setInterval(() => {
312
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
313
+ this.captureAndSendFrame();
314
+ }
315
+ }, 1000 / this.frameRate);
316
+ }
317
+ }
318
+ }
319
+
320
+ // ================ TIMELINE MANAGER CLASS ================
321
+
322
+ class TimelineManager {
323
+ constructor(maxEvents = 60) {
324
+ this.events = [];
325
+ this.maxEvents = maxEvents;
326
+ this.container = document.getElementById('timeline-visuals');
327
+ }
328
+
329
+ addEvent(isFocused) {
330
+ const timestamp = Date.now();
331
+ this.events.push({ timestamp, isFocused });
332
+
333
+ if (this.events.length > this.maxEvents) {
334
+ this.events.shift();
335
+ }
336
+
337
+ this.render();
338
+ }
339
+
340
+ render() {
341
+ if (!this.container) return;
342
+
343
+ this.container.innerHTML = '';
344
+
345
+ this.events.forEach((event, index) => {
346
+ const block = document.createElement('div');
347
+ block.className = 'timeline-block';
348
+ block.style.backgroundColor = event.isFocused ? '#00FF00' : '#FF0000';
349
+ block.style.width = '10px';
350
+ block.style.height = '20px';
351
+ block.style.display = 'inline-block';
352
+ block.style.marginRight = '2px';
353
+ block.title = event.isFocused ? 'Focused' : 'Distracted';
354
+ this.container.appendChild(block);
355
+ });
356
+ }
357
+
358
+ clear() {
359
+ this.events = [];
360
+ this.render();
361
+ }
362
+ }
363
+
364
+ // ================ NOTIFICATION SYSTEM ================
365
+
366
+ let distractionStartTime = null;
367
+ let notificationTimeout = null;
368
+ let currentSettings = null;
369
+
370
+ async function loadCurrentSettings() {
371
+ try {
372
+ const response = await fetch('/api/settings');
373
+ currentSettings = await response.json();
374
+ } catch (error) {
375
+ console.error('Failed to load settings:', error);
376
+ currentSettings = {
377
+ notification_enabled: true,
378
+ notification_threshold: 30
379
+ };
380
+ }
381
+ }
382
+
383
+ function checkDistraction(isFocused) {
384
+ if (!currentSettings || !currentSettings.notification_enabled) return;
385
+
386
+ if (!isFocused) {
387
+ if (!distractionStartTime) {
388
+ distractionStartTime = Date.now();
389
+ }
390
+
391
+ const distractionDuration = (Date.now() - distractionStartTime) / 1000;
392
+
393
+ if (distractionDuration >= currentSettings.notification_threshold && !notificationTimeout) {
394
+ sendNotification('Focus Guard Alert', 'You seem distracted. Time to refocus!');
395
+ notificationTimeout = setTimeout(() => {
396
+ notificationTimeout = null;
397
+ }, 60000);
398
+ }
399
+ } else {
400
+ distractionStartTime = null;
401
+ }
402
+ }
403
+
404
+ async function sendNotification(title, body) {
405
+ if ('Notification' in window) {
406
+ if (Notification.permission === 'granted') {
407
+ new Notification(title, { body });
408
+ } else if (Notification.permission !== 'denied') {
409
+ const permission = await Notification.requestPermission();
410
+ if (permission === 'granted') {
411
+ new Notification(title, { body });
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ // ================ SESSION SUMMARY MODAL ================
418
+
419
+ function showSessionSummary(summary) {
420
+ const modal = document.createElement('div');
421
+ modal.className = 'modal-overlay';
422
+ modal.innerHTML = `
423
+ <div class="modal-content">
424
+ <h2>Session Complete!</h2>
425
+ <div class="summary-stats">
426
+ <div class="summary-item">
427
+ <span class="summary-label">Duration:</span>
428
+ <span class="summary-value">${formatDuration(summary.duration_seconds)}</span>
429
+ </div>
430
+ <div class="summary-item">
431
+ <span class="summary-label">Focus Score:</span>
432
+ <span class="summary-value">${(summary.focus_score * 100).toFixed(1)}%</span>
433
+ </div>
434
+ <div class="summary-item">
435
+ <span class="summary-label">Total Frames:</span>
436
+ <span class="summary-value">${summary.total_frames}</span>
437
+ </div>
438
+ <div class="summary-item">
439
+ <span class="summary-label">Focused Frames:</span>
440
+ <span class="summary-value">${summary.focused_frames}</span>
441
+ </div>
442
+ </div>
443
+ <button class="btn-main" onclick="closeModal()">Close</button>
444
+ </div>
445
+ `;
446
+
447
+ document.body.appendChild(modal);
448
+ }
449
+
450
+ function closeModal() {
451
+ const modal = document.querySelector('.modal-overlay');
452
+ if (modal) {
453
+ modal.remove();
454
+ }
455
+ }
456
+
457
+ function formatDuration(seconds) {
458
+ const hours = Math.floor(seconds / 3600);
459
+ const minutes = Math.floor((seconds % 3600) / 60);
460
+ const secs = seconds % 60;
461
+
462
+ if (hours > 0) {
463
+ return `${hours}h ${minutes}m ${secs}s`;
464
+ } else if (minutes > 0) {
465
+ return `${minutes}m ${secs}s`;
466
+ } else {
467
+ return `${secs}s`;
468
+ }
469
+ }
470
+
471
+ // ================ GLOBAL INSTANCES ================
472
+
473
+ const videoManager = new VideoManager();
474
+ const timeline = new TimelineManager();
475
+
476
+ // ================ EVENT LISTENERS ================
477
+
478
+ // Page navigation
479
+ document.getElementById('menu-start').addEventListener('click', () => showPage('page-b'));
480
+ document.getElementById('menu-achievement').addEventListener('click', () => {
481
+ showPage('page-c');
482
+ loadAchievements();
483
+ });
484
+ document.getElementById('menu-records').addEventListener('click', () => {
485
+ showPage('page-d');
486
+ loadRecords('today');
487
+ });
488
+ document.getElementById('menu-customise').addEventListener('click', () => {
489
+ showPage('page-e');
490
+ loadSettings();
491
+ });
492
+ document.getElementById('menu-help').addEventListener('click', () => showPage('page-f'));
493
+
494
+ document.getElementById('start-button').addEventListener('click', () => showPage('page-b'));
495
+
496
+ // Page B controls
497
+ document.getElementById('btn-cam-start').addEventListener('click', async () => {
498
+ try {
499
+ await videoManager.initCamera();
500
+ videoManager.startStreaming();
501
+ timeline.clear();
502
+ await loadCurrentSettings();
503
+ } catch (error) {
504
+ console.error('Failed to start camera:', error);
505
+ alert('Camera access denied. Please allow camera permissions and ensure you are using HTTPS or localhost.');
506
+ }
507
+ });
508
+
509
+ document.getElementById('btn-cam-stop').addEventListener('click', () => {
510
+ videoManager.stopStreaming();
511
+ });
512
+
513
+ document.getElementById('btn-floating').addEventListener('click', () => {
514
+ alert('Floating window feature coming soon!');
515
+ });
516
+
517
+ document.getElementById('btn-models').addEventListener('click', () => {
518
+ alert('Model selection feature coming soon!');
519
+ });
520
+
521
+ // Frame control
522
+ const frameSlider = document.getElementById('frame-slider');
523
+ const frameInput = document.getElementById('frame-input');
524
+
525
+ frameSlider.addEventListener('input', (e) => {
526
+ const rate = parseInt(e.target.value);
527
+ frameInput.value = rate;
528
+ videoManager.setFrameRate(rate);
529
+ });
530
+
531
+ frameInput.addEventListener('input', (e) => {
532
+ const rate = parseInt(e.target.value);
533
+ frameSlider.value = rate;
534
+ videoManager.setFrameRate(rate);
535
+ });
536
+
537
+ // ================ ACHIEVEMENT PAGE ================
538
+
539
+ async function loadAchievements() {
540
+ try {
541
+ const response = await fetch('/api/stats/summary');
542
+ const stats = await response.json();
543
+
544
+ document.getElementById('total-sessions').textContent = stats.total_sessions;
545
+ document.getElementById('total-hours').textContent =
546
+ (stats.total_focus_time / 3600).toFixed(1) + 'h';
547
+ document.getElementById('avg-focus').textContent =
548
+ (stats.avg_focus_score * 100).toFixed(1) + '%';
549
+ document.getElementById('current-streak').textContent = stats.streak_days;
550
+
551
+ loadBadges(stats);
552
+ } catch (error) {
553
+ console.error('Failed to load achievements:', error);
554
+ }
555
+ }
556
+
557
+ function loadBadges(stats) {
558
+ const badges = [
559
+ { name: 'First Session', condition: stats.total_sessions >= 1, icon: '' },
560
+ { name: '10 Sessions', condition: stats.total_sessions >= 10, icon: '' },
561
+ { name: '50 Sessions', condition: stats.total_sessions >= 50, icon: '' },
562
+ { name: '10 Hour Focus', condition: stats.total_focus_time >= 36000, icon: '' },
563
+ { name: '7 Day Streak', condition: stats.streak_days >= 7, icon: '' },
564
+ { name: '90% Avg Focus', condition: stats.avg_focus_score >= 0.9, icon: '' }
565
+ ];
566
+
567
+ const container = document.getElementById('badges-container');
568
+ container.innerHTML = '';
569
+
570
+ badges.forEach(badge => {
571
+ const badgeEl = document.createElement('div');
572
+ badgeEl.className = 'badge ' + (badge.condition ? 'earned' : 'locked');
573
+ badgeEl.innerHTML = `
574
+ <div class="badge-icon">${badge.icon}</div>
575
+ <div class="badge-name">${badge.name}</div>
576
+ `;
577
+ container.appendChild(badgeEl);
578
+ });
579
+ }
580
+
581
+ // ================ RECORDS PAGE ================
582
+
583
+ async function loadRecords(filter = 'today') {
584
+ try {
585
+ const response = await fetch(`/api/sessions?filter=${filter}`);
586
+ const sessions = await response.json();
587
+
588
+ renderSessionsTable(sessions);
589
+ renderChart(sessions);
590
+ } catch (error) {
591
+ console.error('Failed to load records:', error);
592
+ }
593
+ }
594
+
595
+ function renderSessionsTable(sessions) {
596
+ const tbody = document.getElementById('sessions-tbody');
597
+ tbody.innerHTML = '';
598
+
599
+ sessions.forEach(session => {
600
+ const row = document.createElement('tr');
601
+ const date = new Date(session.start_time).toLocaleString();
602
+ const duration = formatDuration(session.duration_seconds);
603
+ const score = (session.focus_score * 100).toFixed(1) + '%';
604
+
605
+ row.innerHTML = `
606
+ <td>${date}</td>
607
+ <td>${duration}</td>
608
+ <td>${score}</td>
609
+ <td><button class="btn-view" onclick="viewSessionDetails(${session.id})">View</button></td>
610
+ `;
611
+ tbody.appendChild(row);
612
+ });
613
+
614
+ if (sessions.length === 0) {
615
+ const row = document.createElement('tr');
616
+ row.innerHTML = '<td colspan="4" style="text-align: center;">No sessions found</td>';
617
+ tbody.appendChild(row);
618
+ }
619
+ }
620
+
621
+ function renderChart(sessions) {
622
+ const canvas = document.getElementById('focus-chart');
623
+ const ctx = canvas.getContext('2d');
624
+
625
+ canvas.width = 800;
626
+ canvas.height = 300;
627
+
628
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
629
+
630
+ if (sessions.length === 0) {
631
+ ctx.fillStyle = '#888';
632
+ ctx.font = '20px Nunito';
633
+ ctx.fillText('No data available', canvas.width / 2 - 80, canvas.height / 2);
634
+ return;
635
+ }
636
+
637
+ const barWidth = Math.min((canvas.width - 40) / sessions.length - 10, 80);
638
+ const maxScore = 1.0;
639
+
640
+ sessions.forEach((session, index) => {
641
+ const x = index * (barWidth + 10) + 20;
642
+ const barHeight = (session.focus_score / maxScore) * (canvas.height - 60);
643
+ const y = canvas.height - barHeight - 30;
644
+
645
+ ctx.fillStyle = session.focus_score > 0.7 ? '#28a745' :
646
+ session.focus_score > 0.4 ? '#ffc107' : '#dc3545';
647
+ ctx.fillRect(x, y, barWidth, barHeight);
648
+
649
+ ctx.fillStyle = '#333';
650
+ ctx.font = '12px Nunito';
651
+ const scoreText = (session.focus_score * 100).toFixed(0) + '%';
652
+ ctx.fillText(scoreText, x + barWidth / 2 - 15, y - 5);
653
+ });
654
+ }
655
+
656
+ function viewSessionDetails(sessionId) {
657
+ alert(`Session details for ID ${sessionId} - Feature coming soon!`);
658
+ }
659
+
660
+ // Filter buttons
661
+ document.getElementById('filter-today').addEventListener('click', () => {
662
+ setActiveFilter('filter-today');
663
+ loadRecords('today');
664
+ });
665
+
666
+ document.getElementById('filter-week').addEventListener('click', () => {
667
+ setActiveFilter('filter-week');
668
+ loadRecords('week');
669
+ });
670
+
671
+ document.getElementById('filter-month').addEventListener('click', () => {
672
+ setActiveFilter('filter-month');
673
+ loadRecords('month');
674
+ });
675
+
676
+ document.getElementById('filter-all').addEventListener('click', () => {
677
+ setActiveFilter('filter-all');
678
+ loadRecords('all');
679
+ });
680
+
681
+ function setActiveFilter(activeId) {
682
+ document.querySelectorAll('.filter-btn').forEach(btn => {
683
+ btn.classList.remove('active');
684
+ });
685
+ document.getElementById(activeId).classList.add('active');
686
+ }
687
+
688
+ // ================ SETTINGS PAGE ================
689
+
690
+ async function loadSettings() {
691
+ try {
692
+ const response = await fetch('/api/settings');
693
+ if (!response.ok) {
694
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
695
+ }
696
+
697
+ const settings = await response.json();
698
+ console.log('Loaded settings:', settings);
699
+
700
+ // Apply settings with fallback to defaults
701
+ document.getElementById('sensitivity-slider').value = settings.sensitivity || 6;
702
+ document.getElementById('sensitivity-value').textContent = settings.sensitivity || 6;
703
+ document.getElementById('default-framerate').value = settings.frame_rate || 30;
704
+ document.getElementById('framerate-value').textContent = settings.frame_rate || 30;
705
+ document.getElementById('enable-notifications').checked = settings.notification_enabled !== false;
706
+ document.getElementById('notification-threshold').value = settings.notification_threshold || 30;
707
+ } catch (error) {
708
+ console.error('Failed to load settings:', error);
709
+ alert('Failed to load settings: ' + error.message);
710
+ }
711
+ }
712
+
713
+ async function saveSettings() {
714
+ const settings = {
715
+ sensitivity: parseInt(document.getElementById('sensitivity-slider').value),
716
+ frame_rate: parseInt(document.getElementById('default-framerate').value),
717
+ notification_enabled: document.getElementById('enable-notifications').checked,
718
+ notification_threshold: parseInt(document.getElementById('notification-threshold').value)
719
+ };
720
+
721
+ console.log('Saving settings:', settings);
722
+
723
+ try {
724
+ const response = await fetch('/api/settings', {
725
+ method: 'PUT',
726
+ headers: { 'Content-Type': 'application/json' },
727
+ body: JSON.stringify(settings)
728
+ });
729
+
730
+ if (response.ok) {
731
+ const result = await response.json();
732
+ console.log('Settings saved:', result);
733
+ alert('Settings saved successfully!');
734
+ await loadCurrentSettings();
735
+ } else {
736
+ const error = await response.text();
737
+ console.error('Save failed with status:', response.status, error);
738
+ alert(`Failed to save settings: ${response.status} ${response.statusText}`);
739
+ }
740
+ } catch (error) {
741
+ console.error('Failed to save settings:', error);
742
+ alert('Failed to save settings: ' + error.message);
743
+ }
744
+ }
745
+
746
+ // Settings UI handlers
747
+ document.getElementById('sensitivity-slider').addEventListener('input', (e) => {
748
+ document.getElementById('sensitivity-value').textContent = e.target.value;
749
+ });
750
+
751
+ document.getElementById('default-framerate').addEventListener('input', (e) => {
752
+ document.getElementById('framerate-value').textContent = e.target.value;
753
+ });
754
+
755
+ document.getElementById('save-settings').addEventListener('click', saveSettings);
756
+
757
+ document.getElementById('export-data').addEventListener('click', async () => {
758
+ try {
759
+ const response = await fetch('/api/sessions?filter=all');
760
+ const sessions = await response.json();
761
+
762
+ const dataStr = JSON.stringify(sessions, null, 2);
763
+ const dataBlob = new Blob([dataStr], { type: 'application/json' });
764
+ const url = URL.createObjectURL(dataBlob);
765
+
766
+ const link = document.createElement('a');
767
+ link.href = url;
768
+ link.download = `focus-guard-data-${new Date().toISOString().split('T')[0]}.json`;
769
+ link.click();
770
+
771
+ URL.revokeObjectURL(url);
772
+ } catch (error) {
773
+ console.error('Failed to export data:', error);
774
+ alert('Failed to export data');
775
+ }
776
+ });
777
+
778
+ document.getElementById('clear-history').addEventListener('click', async () => {
779
+ if (confirm('Are you sure you want to clear all history? This cannot be undone.')) {
780
+ alert('Clear history feature requires backend implementation');
781
+ }
782
+ });
783
+
784
+ // ================ INITIALIZATION ================
785
+
786
+ // Request notification permission on load
787
+ if ('Notification' in window && Notification.permission === 'default') {
788
+ Notification.requestPermission();
789
+ }
790
+
791
+ // Load settings on startup
792
+ loadCurrentSettings();
793
+
794
+ console.log(' Focus Guard initialized');
GAP_Large_project-fea-ui/static/styles.css ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* GLOBAL STYLES */
2
+ body {
3
+ margin: 0;
4
+ font-family: 'Nunito', sans-serif; /* Rounded font */
5
+ background-color: #f9f9f9;
6
+ height: 100vh;
7
+ overflow-x: hidden;
8
+ overflow-y: auto;
9
+ }
10
+
11
+ .hidden {
12
+ display: none !important;
13
+ }
14
+
15
+ /* TOP MENU */
16
+ #top-menu {
17
+ height: 60px;
18
+ background-color: white;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center; /* Center buttons horizontally */
22
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
23
+ position: fixed;
24
+ top: 0;
25
+ width: 100%;
26
+ z-index: 1000;
27
+ }
28
+
29
+ .menu-btn {
30
+ background: none;
31
+ border: none;
32
+ font-family: 'Nunito', sans-serif;
33
+ font-size: 16px;
34
+ color: #333;
35
+ padding: 10px 20px;
36
+ cursor: pointer;
37
+ transition: background-color 0.2s;
38
+ }
39
+
40
+ .menu-btn:hover {
41
+ background-color: #f0f0f0;
42
+ border-radius: 4px;
43
+ }
44
+
45
+ .separator {
46
+ width: 1px;
47
+ height: 20px;
48
+ background-color: #555; /* Dark gray separator */
49
+ margin: 0 5px;
50
+ }
51
+
52
+ /* PAGE CONTAINER */
53
+ .page {
54
+ min-height: calc(100vh - 60px);
55
+ width: 100%;
56
+ padding-top: 60px; /* Space for fixed menu */
57
+ padding-bottom: 40px; /* Space at bottom for scrolling */
58
+ box-sizing: border-box;
59
+ display: flex;
60
+ flex-direction: column;
61
+ align-items: center;
62
+ overflow-y: auto; /* Enable vertical scrolling */
63
+ }
64
+
65
+ /* PAGE A SPECIFIC */
66
+ #page-a {
67
+ justify-content: center; /* Center vertically */
68
+ margin-top: -40px; /* Slight offset to look optical centered */
69
+ }
70
+
71
+ #page-a h1 {
72
+ font-size: 80px;
73
+ margin: 0 0 10px 0;
74
+ color: #000;
75
+ }
76
+
77
+ #page-a p {
78
+ color: #666;
79
+ font-size: 20px;
80
+ margin-bottom: 40px;
81
+ }
82
+
83
+ .btn-main {
84
+ background-color: #007BFF; /* Blue */
85
+ color: white;
86
+ border: none;
87
+ padding: 15px 50px;
88
+ font-size: 20px;
89
+ font-family: 'Nunito', sans-serif;
90
+ border-radius: 30px; /* Fully rounded corners */
91
+ cursor: pointer;
92
+ transition: transform 0.2s ease;
93
+ }
94
+
95
+ .btn-main:hover {
96
+ transform: scale(1.1); /* Zoom effect */
97
+ }
98
+
99
+ /* PAGE B SPECIFIC */
100
+ #page-b {
101
+ justify-content: space-evenly; /* Distribute vertical space */
102
+ padding-bottom: 20px;
103
+ }
104
+
105
+ /* 1. Display Area */
106
+ #display-area {
107
+ width: 60%;
108
+ height: 50%; /* Takes up half the page height */
109
+ border: 2px solid #ddd;
110
+ border-radius: 12px;
111
+ background-color: #fff;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ color: #555;
116
+ font-size: 24px;
117
+ position: relative;
118
+ }
119
+
120
+ /* 2. Timeline Area */
121
+ #timeline-area {
122
+ width: 60%;
123
+ height: 80px;
124
+ position: relative;
125
+ display: flex;
126
+ flex-direction: column;
127
+ justify-content: flex-end;
128
+ }
129
+
130
+ .timeline-label {
131
+ position: absolute;
132
+ top: 0;
133
+ left: 0;
134
+ color: #888;
135
+ font-size: 14px;
136
+ }
137
+
138
+ #timeline-line {
139
+ width: 100%;
140
+ height: 2px;
141
+ background-color: #87CEEB; /* Light blue */
142
+ }
143
+
144
+ /* 3. Control Panel */
145
+ #control-panel {
146
+ display: flex;
147
+ gap: 20px;
148
+ width: 60%;
149
+ justify-content: space-between;
150
+ }
151
+
152
+ .action-btn {
153
+ flex: 1; /* Evenly distributed width */
154
+ padding: 12px 0;
155
+ border: none;
156
+ border-radius: 12px;
157
+ font-size: 16px;
158
+ font-family: 'Nunito', sans-serif;
159
+ font-weight: 700;
160
+ cursor: pointer;
161
+ color: white;
162
+ transition: opacity 0.2s;
163
+ }
164
+
165
+ .action-btn:hover {
166
+ opacity: 0.9;
167
+ }
168
+
169
+ .action-btn.green { background-color: #28a745; }
170
+ .action-btn.yellow { background-color: #ffc107; color: #333; }
171
+ .action-btn.blue { background-color: #17a2b8; }
172
+ .action-btn.red { background-color: #dc3545; }
173
+
174
+ /* 4. Frame Control */
175
+ #frame-control {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 15px;
179
+ color: #333;
180
+ font-weight: bold;
181
+ }
182
+
183
+ #frame-slider {
184
+ width: 200px;
185
+ cursor: pointer;
186
+ }
187
+
188
+ #frame-input {
189
+ width: 50px;
190
+ padding: 5px;
191
+ border: 1px solid #ccc;
192
+ border-radius: 5px;
193
+ text-align: center;
194
+ font-family: 'Nunito', sans-serif;
195
+ }
196
+
197
+ /* ================ ACHIEVEMENT PAGE ================ */
198
+
199
+ .stats-grid {
200
+ display: grid;
201
+ grid-template-columns: repeat(4, 1fr);
202
+ gap: 20px;
203
+ width: 80%;
204
+ margin: 40px auto;
205
+ }
206
+
207
+ .stat-card {
208
+ background: white;
209
+ padding: 30px;
210
+ border-radius: 12px;
211
+ text-align: center;
212
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
213
+ }
214
+
215
+ .stat-number {
216
+ font-size: 48px;
217
+ font-weight: bold;
218
+ color: #007BFF;
219
+ margin-bottom: 10px;
220
+ }
221
+
222
+ .stat-label {
223
+ font-size: 16px;
224
+ color: #666;
225
+ }
226
+
227
+ .achievements-section {
228
+ width: 80%;
229
+ margin: 0 auto;
230
+ }
231
+
232
+ .achievements-section h2 {
233
+ color: #333;
234
+ margin-bottom: 20px;
235
+ }
236
+
237
+ .badges-grid {
238
+ display: grid;
239
+ grid-template-columns: repeat(3, 1fr);
240
+ gap: 20px;
241
+ }
242
+
243
+ .badge {
244
+ background: white;
245
+ padding: 30px 20px;
246
+ border-radius: 12px;
247
+ text-align: center;
248
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
249
+ transition: transform 0.2s;
250
+ }
251
+
252
+ .badge:hover {
253
+ transform: translateY(-5px);
254
+ }
255
+
256
+ .badge.locked {
257
+ opacity: 0.4;
258
+ filter: grayscale(100%);
259
+ }
260
+
261
+ .badge-icon {
262
+ font-size: 64px;
263
+ margin-bottom: 15px;
264
+ }
265
+
266
+ .badge-name {
267
+ font-size: 16px;
268
+ font-weight: bold;
269
+ color: #333;
270
+ }
271
+
272
+ /* ================ RECORDS PAGE ================ */
273
+
274
+ .records-controls {
275
+ display: flex;
276
+ gap: 10px;
277
+ margin: 20px auto;
278
+ width: 80%;
279
+ justify-content: center;
280
+ }
281
+
282
+ .filter-btn {
283
+ padding: 10px 20px;
284
+ border: 2px solid #007BFF;
285
+ background: white;
286
+ color: #007BFF;
287
+ border-radius: 8px;
288
+ cursor: pointer;
289
+ font-family: 'Nunito', sans-serif;
290
+ font-weight: 600;
291
+ transition: all 0.2s;
292
+ }
293
+
294
+ .filter-btn:hover {
295
+ background: #e7f3ff;
296
+ }
297
+
298
+ .filter-btn.active {
299
+ background: #007BFF;
300
+ color: white;
301
+ }
302
+
303
+ .chart-container {
304
+ width: 80%;
305
+ background: white;
306
+ padding: 30px;
307
+ border-radius: 12px;
308
+ margin: 20px auto;
309
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
310
+ }
311
+
312
+ #focus-chart {
313
+ display: block;
314
+ margin: 0 auto;
315
+ }
316
+
317
+ .sessions-list {
318
+ width: 80%;
319
+ margin: 20px auto;
320
+ }
321
+
322
+ .sessions-list h2 {
323
+ color: #333;
324
+ margin-bottom: 15px;
325
+ }
326
+
327
+ #sessions-table {
328
+ width: 100%;
329
+ background: white;
330
+ border-collapse: collapse;
331
+ border-radius: 12px;
332
+ overflow: hidden;
333
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
334
+ }
335
+
336
+ #sessions-table th {
337
+ background: #007BFF;
338
+ color: white;
339
+ padding: 15px;
340
+ text-align: left;
341
+ font-weight: 600;
342
+ }
343
+
344
+ #sessions-table td {
345
+ padding: 12px 15px;
346
+ border-bottom: 1px solid #eee;
347
+ }
348
+
349
+ #sessions-table tr:last-child td {
350
+ border-bottom: none;
351
+ }
352
+
353
+ #sessions-table tbody tr:hover {
354
+ background: #f8f9fa;
355
+ }
356
+
357
+ .btn-view {
358
+ padding: 6px 12px;
359
+ background: #007BFF;
360
+ color: white;
361
+ border: none;
362
+ border-radius: 5px;
363
+ cursor: pointer;
364
+ font-family: 'Nunito', sans-serif;
365
+ transition: background 0.2s;
366
+ }
367
+
368
+ .btn-view:hover {
369
+ background: #0056b3;
370
+ }
371
+
372
+ /* ================ SETTINGS PAGE ================ */
373
+
374
+ .settings-container {
375
+ width: 60%;
376
+ max-width: 800px;
377
+ margin: 20px auto;
378
+ }
379
+
380
+ .setting-group {
381
+ background: white;
382
+ padding: 30px;
383
+ border-radius: 12px;
384
+ margin-bottom: 20px;
385
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
386
+ }
387
+
388
+ .setting-group h2 {
389
+ margin-top: 0;
390
+ color: #333;
391
+ font-size: 20px;
392
+ margin-bottom: 20px;
393
+ border-bottom: 2px solid #007BFF;
394
+ padding-bottom: 10px;
395
+ }
396
+
397
+ .setting-item {
398
+ margin-bottom: 25px;
399
+ }
400
+
401
+ .setting-item:last-child {
402
+ margin-bottom: 0;
403
+ }
404
+
405
+ .setting-item label {
406
+ display: block;
407
+ margin-bottom: 8px;
408
+ color: #333;
409
+ font-weight: 600;
410
+ }
411
+
412
+ .slider-group {
413
+ display: flex;
414
+ align-items: center;
415
+ gap: 15px;
416
+ }
417
+
418
+ .slider-group input[type="range"] {
419
+ flex: 1;
420
+ }
421
+
422
+ .slider-group span {
423
+ min-width: 40px;
424
+ text-align: center;
425
+ font-weight: bold;
426
+ color: #007BFF;
427
+ font-size: 18px;
428
+ }
429
+
430
+ .setting-description {
431
+ font-size: 14px;
432
+ color: #666;
433
+ margin-top: 5px;
434
+ font-style: italic;
435
+ }
436
+
437
+ input[type="checkbox"] {
438
+ margin-right: 10px;
439
+ cursor: pointer;
440
+ }
441
+
442
+ input[type="number"] {
443
+ width: 100px;
444
+ padding: 8px;
445
+ border: 1px solid #ccc;
446
+ border-radius: 5px;
447
+ font-family: 'Nunito', sans-serif;
448
+ }
449
+
450
+ .setting-group .action-btn {
451
+ margin-right: 10px;
452
+ margin-top: 10px;
453
+ }
454
+
455
+ #save-settings {
456
+ display: block;
457
+ margin: 20px auto;
458
+ }
459
+
460
+ /* ================ HELP PAGE ================ */
461
+
462
+ .help-container {
463
+ width: 70%;
464
+ max-width: 900px;
465
+ margin: 20px auto;
466
+ }
467
+
468
+ .help-section {
469
+ background: white;
470
+ padding: 30px;
471
+ border-radius: 12px;
472
+ margin-bottom: 20px;
473
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
474
+ }
475
+
476
+ .help-section h2 {
477
+ color: #007BFF;
478
+ margin-top: 0;
479
+ margin-bottom: 15px;
480
+ }
481
+
482
+ .help-section ol,
483
+ .help-section ul {
484
+ line-height: 1.8;
485
+ color: #333;
486
+ }
487
+
488
+ .help-section p {
489
+ line-height: 1.6;
490
+ color: #333;
491
+ }
492
+
493
+ details {
494
+ margin: 15px 0;
495
+ cursor: pointer;
496
+ padding: 10px;
497
+ background: #f8f9fa;
498
+ border-radius: 5px;
499
+ }
500
+
501
+ summary {
502
+ font-weight: bold;
503
+ padding: 5px;
504
+ color: #007BFF;
505
+ }
506
+
507
+ details[open] summary {
508
+ margin-bottom: 10px;
509
+ border-bottom: 1px solid #ddd;
510
+ padding-bottom: 10px;
511
+ }
512
+
513
+ details p {
514
+ margin: 10px 0 0 0;
515
+ }
516
+
517
+ /* ================ SESSION SUMMARY MODAL ================ */
518
+
519
+ .modal-overlay {
520
+ position: fixed;
521
+ top: 0;
522
+ left: 0;
523
+ width: 100%;
524
+ height: 100%;
525
+ background: rgba(0, 0, 0, 0.7);
526
+ display: flex;
527
+ align-items: center;
528
+ justify-content: center;
529
+ z-index: 2000;
530
+ }
531
+
532
+ .modal-content {
533
+ background: white;
534
+ padding: 40px;
535
+ border-radius: 16px;
536
+ box-shadow: 0 10px 40px rgba(0,0,0,0.3);
537
+ max-width: 500px;
538
+ width: 90%;
539
+ }
540
+
541
+ .modal-content h2 {
542
+ margin-top: 0;
543
+ color: #333;
544
+ text-align: center;
545
+ margin-bottom: 30px;
546
+ }
547
+
548
+ .summary-stats {
549
+ margin-bottom: 30px;
550
+ }
551
+
552
+ .summary-item {
553
+ display: flex;
554
+ justify-content: space-between;
555
+ padding: 15px 0;
556
+ border-bottom: 1px solid #eee;
557
+ }
558
+
559
+ .summary-item:last-child {
560
+ border-bottom: none;
561
+ }
562
+
563
+ .summary-label {
564
+ font-weight: 600;
565
+ color: #666;
566
+ }
567
+
568
+ .summary-value {
569
+ font-weight: bold;
570
+ color: #007BFF;
571
+ font-size: 18px;
572
+ }
573
+
574
+ .modal-content .btn-main {
575
+ display: block;
576
+ margin: 0 auto;
577
+ padding: 12px 40px;
578
+ }
579
+
580
+ /* ================ TIMELINE BLOCKS ================ */
581
+
582
+ .timeline-block {
583
+ transition: opacity 0.2s;
584
+ border-radius: 2px;
585
+ }
586
+
587
+ .timeline-block:hover {
588
+ opacity: 0.7;
589
+ }
590
+
591
+ /* ================ RESPONSIVE DESIGN ================ */
592
+
593
+ @media (max-width: 1200px) {
594
+ .stats-grid {
595
+ grid-template-columns: repeat(2, 1fr);
596
+ }
597
+
598
+ .badges-grid {
599
+ grid-template-columns: repeat(2, 1fr);
600
+ }
601
+ }
602
+
603
+ @media (max-width: 768px) {
604
+ .stats-grid,
605
+ .badges-grid {
606
+ grid-template-columns: 1fr;
607
+ width: 90%;
608
+ }
609
+
610
+ .settings-container,
611
+ .help-container,
612
+ .chart-container,
613
+ .sessions-list,
614
+ .records-controls {
615
+ width: 90%;
616
+ }
617
+
618
+ #control-panel {
619
+ width: 90%;
620
+ flex-wrap: wrap;
621
+ }
622
+
623
+ #display-area {
624
+ width: 90%;
625
+ }
626
+
627
+ #timeline-area {
628
+ width: 90%;
629
+ }
630
+
631
+ #frame-control {
632
+ width: 90%;
633
+ flex-direction: column;
634
+ }
635
+ }
GAP_Large_project-fea-ui/yolov8n.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:31e20dde3def09e2cf938c7be6fe23d9150bbbe503982af13345706515f2ef95
3
+ size 6534387