Udasri Hasindu commited on
Commit
ca58c12
·
unverified ·
2 Parent(s): a4f90fa75e2d01

Merge pull request #1 from Xplorers-org/docker

Browse files
Files changed (7) hide show
  1. .github/workflows/deploy-hf-space.yml +33 -0
  2. .gitignore +3 -0
  3. Dockerfile +34 -0
  4. README.md +69 -1
  5. app.py +444 -407
  6. scripts/cleanup_runs.py +102 -0
  7. scripts/start.sh +21 -0
.github/workflows/deploy-hf-space.yml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face Space
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+
18
+ - name: Configure Git
19
+ run: |
20
+ git config user.name "github-actions[bot]"
21
+ git config user.email "github-actions[bot]@users.noreply.github.com"
22
+
23
+ - name: Push to Hugging Face Space
24
+ env:
25
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
26
+ run: |
27
+ if [ -z "${HF_TOKEN}" ]; then
28
+ echo "HF_TOKEN secret is missing"
29
+ exit 1
30
+ fi
31
+
32
+ git remote add hf "https://oauth2:${HF_TOKEN}@huggingface.co/spaces/xplorers/GAIT_API"
33
+ git push --force hf HEAD:main
.gitignore CHANGED
@@ -173,6 +173,9 @@ cython_debug/
173
  # PyPI configuration file
174
  .pypirc
175
 
 
 
 
176
  # Cursor
177
  # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
178
  # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
 
173
  # PyPI configuration file
174
  .pypirc
175
 
176
+ # Runtime outputs
177
+ runs/
178
+
179
  # Cursor
180
  # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
181
  # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ # Install system dependencies
7
+ RUN apt-get update && apt-get install -y \
8
+ libglib2.0-0 \
9
+ libsm6 \
10
+ libxext6 \
11
+ libxrender1 \
12
+ libgomp1 \
13
+ libgl1 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Set working directory
17
+ WORKDIR /app
18
+
19
+ # Copy requirements and install Python dependencies
20
+ COPY requirements.txt .
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # Copy application files
24
+ COPY app.py /app/app.py
25
+ COPY scripts /app/scripts
26
+
27
+ # Create output directory
28
+ RUN mkdir -p /app/runs/outputs && chmod +x /app/scripts/start.sh
29
+
30
+ # Expose port
31
+ EXPOSE 7860
32
+
33
+ # Run the application
34
+ CMD ["/app/scripts/start.sh"]
README.md CHANGED
@@ -20,7 +20,15 @@ Open Swagger UI:
20
  - `POST /analyze`
21
  - form-data:
22
  - `video`: video file (front-view gait)
23
- - `patient_gender`: `male` or `female`
 
 
 
 
 
 
 
 
24
 
25
  Response includes:
26
 
@@ -29,3 +37,63 @@ Response includes:
29
  - full feature values
30
  - URL to annotated output video
31
  - URL to biomarker plot image
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  - `POST /analyze`
21
  - form-data:
22
  - `video`: video file (front-view gait)
23
+ - `gender`: `male` or `female`
24
+
25
+ - `POST /analyze_files`
26
+ - form-data:
27
+ - `video`: video file
28
+ - `gender`: `male` or `female`
29
+
30
+ - `GET /download/{filename}`
31
+ - `GET /health`
32
 
33
  Response includes:
34
 
 
37
  - full feature values
38
  - URL to annotated output video
39
  - URL to biomarker plot image
40
+
41
+ ## Automatic cleanup for runs/
42
+
43
+ Generated files from `/analyze_files` are stored under `runs/outputs`.
44
+ To prevent storage growth, use the cleanup script every 30 minutes.
45
+
46
+ Script path:
47
+
48
+ - `scripts/cleanup_runs.py`
49
+
50
+ Behavior:
51
+
52
+ - Deletes files older than 30 minutes (default)
53
+ - Removes empty subdirectories
54
+
55
+ ### Local host cron example
56
+
57
+ ```bash
58
+ */30 * * * * /usr/bin/python3 /path/to/GAIT_API/scripts/cleanup_runs.py --path /path/to/GAIT_API/runs --max-age-minutes 30 >> /var/log/gait_cleanup.log 2>&1
59
+ ```
60
+
61
+ ### Docker container cron example (host cron running docker exec)
62
+
63
+ ```bash
64
+ */30 * * * * docker exec gait-api python /app/scripts/cleanup_runs.py --path /app/runs --max-age-minutes 30 >> /var/log/gait_cleanup.log 2>&1
65
+ ```
66
+
67
+ Replace `gait-api` with your running container name.
68
+
69
+ ## Hugging Face Spaces (Docker) deployment
70
+
71
+ This repository is ready for Docker Spaces deployment. The container now:
72
+
73
+ - listens on `PORT` (default `7860`) required by Spaces
74
+ - starts a background cleanup loop for `/app/runs`
75
+ - launches the API via `scripts/start.sh`
76
+
77
+ Environment variables (optional):
78
+
79
+ - `PORT` (default: `7860`)
80
+ - `CLEANUP_INTERVAL_SECONDS` (default: `1800`)
81
+ - `RUNS_MAX_AGE_MINUTES` (default: `30`)
82
+
83
+ ## GitHub Actions auto-deploy to your HF Space
84
+
85
+ Workflow file:
86
+
87
+ - `.github/workflows/deploy-hf-space.yml`
88
+
89
+ Target Space:
90
+
91
+ - `xplorers/GAIT_API`
92
+
93
+ ### Required GitHub secret
94
+
95
+ Add this repository secret in GitHub:
96
+
97
+ - `HF_TOKEN` = a Hugging Face User Access Token with write permission to `xplorers/GAIT_API`
98
+
99
+ On every push to `main`, GitHub Actions force-pushes this repo to your Space.
app.py CHANGED
@@ -1,58 +1,54 @@
1
- import uuid
2
- from typing import Annotated
3
- from pathlib import Path
4
- from typing import Any, Dict, List, Literal, Tuple
5
-
6
  import cv2
7
  import mediapipe as mp
 
8
  import matplotlib
 
9
  import matplotlib.pyplot as plt
10
- import numpy as np
11
- from fastapi import FastAPI, File, Form, HTTPException, UploadFile
12
- from fastapi.responses import FileResponse
13
- from fastapi.staticfiles import StaticFiles
14
- from pydantic import BaseModel
15
- from scipy.signal import detrend, find_peaks, savgol_filter
 
 
16
 
17
- # Use a non-interactive backend for server-side rendering.
18
- matplotlib.use("Agg")
 
 
 
19
 
20
- # Initialize MediaPipe Pose Model (High Complexity for Clinical Accuracy)
 
 
 
 
 
 
 
 
 
21
  mp_pose = mp.solutions.pose
22
  pose = mp_pose.Pose(
23
  static_image_mode=False,
24
  model_complexity=2,
25
  min_detection_confidence=0.5,
26
- min_tracking_confidence=0.5,
27
  )
28
-
29
  mp_drawing = mp.solutions.drawing_utils
30
 
31
- BASE_DIR = Path(__file__).resolve().parent
32
- RUNS_DIR = BASE_DIR / "runs"
33
- INPUTS_DIR = RUNS_DIR / "inputs"
34
- OUTPUTS_DIR = RUNS_DIR / "outputs"
35
- PLOTS_DIR = RUNS_DIR / "plots"
36
- for folder in (RUNS_DIR, INPUTS_DIR, OUTPUTS_DIR, PLOTS_DIR):
37
- folder.mkdir(parents=True, exist_ok=True)
38
-
39
- app = FastAPI(
40
- title="HEMAS NeuroTrack Gait Analysis API",
41
- description="FastAPI version of the complete gait notebook with identical scoring and interpretation logic.",
42
- version="1.0.0",
43
- )
44
- app.mount("/runs", StaticFiles(directory=str(RUNS_DIR)), name="runs")
45
-
46
 
47
- class AnalyzeResponse(BaseModel):
48
- run_id: str
49
- fps: float
50
- features: Dict[str, float]
51
- clinical_report: str
52
- gait_score: float
53
- gait_interpretation: str
54
- annotated_video_url: str
55
- plot_image_url: str
56
 
57
 
58
  def smooth_signal(data, window_length=9, polyorder=3):
@@ -68,18 +64,14 @@ def extract_validate_and_visualize(input_video_path, output_video_path):
68
 
69
  width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
70
  height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
71
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
72
  out = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height))
73
 
74
  signals = {
75
- "l_ankle_y": [],
76
- "r_ankle_y": [],
77
- "l_arm_swing": [],
78
- "r_arm_swing": [],
79
- "mid_hip_x": [],
80
- "mid_hip_y": [],
81
- "l_foot_x": [],
82
- "r_foot_x": [],
83
  }
84
 
85
  while cap.isOpened():
@@ -97,54 +89,46 @@ def extract_validate_and_visualize(input_video_path, output_video_path):
97
  if results.pose_landmarks:
98
  lm = results.pose_landmarks.landmark
99
 
100
- # -----------------------------
101
- # BODY CENTER (IMPORTANT)
102
- # -----------------------------
103
  mid_hip_x = (lm[23].x + lm[24].x) / 2
104
  mid_hip_y = (lm[23].y + lm[24].y) / 2
105
 
106
- signals["mid_hip_x"].append(mid_hip_x)
107
- signals["mid_hip_y"].append(mid_hip_y)
108
 
109
- # -----------------------------
110
- # LOWER BODY (RELATIVE SIGNAL)
111
- # -----------------------------
112
- signals["l_ankle_y"].append(lm[27].y)
113
- signals["r_ankle_y"].append(lm[28].y)
114
 
115
- # FIX: normalize foot X relative to body center
116
- signals["l_foot_x"].append(lm[31].x - mid_hip_x)
117
- signals["r_foot_x"].append(lm[32].x - mid_hip_x)
118
 
119
- # -----------------------------
120
- # ARM SWING (IMPROVED)
121
- # -----------------------------
122
  l_torso_len = np.linalg.norm([
123
  lm[11].x - lm[23].x,
124
- lm[11].y - lm[23].y,
125
  ])
126
  r_torso_len = np.linalg.norm([
127
  lm[12].x - lm[24].x,
128
- lm[12].y - lm[24].y,
129
  ])
130
 
131
- # FIX: relative to shoulder (remove body sway)
132
  l_ws = np.linalg.norm([
133
  lm[15].x - lm[11].x,
134
- lm[15].y - lm[11].y,
135
  ])
136
  r_ws = np.linalg.norm([
137
  lm[16].x - lm[12].x,
138
- lm[16].y - lm[12].y,
139
  ])
140
 
141
- # FINAL: normalized + stabilized
142
- signals["l_arm_swing"].append(l_ws / (l_torso_len + 1e-6))
143
- signals["r_arm_swing"].append(r_ws / (r_torso_len + 1e-6))
144
 
145
- # -----------------------------
146
  # DRAW SKELETON
147
- # -----------------------------
148
  mp_drawing.draw_landmarks(
149
  image_bgr,
150
  results.pose_landmarks,
@@ -154,7 +138,7 @@ def extract_validate_and_visualize(input_video_path, output_video_path):
154
  ),
155
  connection_drawing_spec=mp_drawing.DrawingSpec(
156
  color=(255, 255, 255), thickness=2
157
- ),
158
  )
159
 
160
  out.write(image_bgr)
@@ -162,78 +146,61 @@ def extract_validate_and_visualize(input_video_path, output_video_path):
162
  cap.release()
163
  out.release()
164
 
165
- # -----------------------------
166
  # VALIDATION
167
- # -----------------------------
168
- if len(signals["mid_hip_x"]) == 0:
169
  raise ValueError("❌ No person detected in the video.")
170
 
171
  # IMPROVED SIDE VIEW DETECTION
172
- x_var = np.var(signals["mid_hip_x"])
173
- y_var = np.var(signals["mid_hip_y"])
174
 
175
- if x_var > y_var: # more horizontal movement -> side view
176
  raise ValueError("❌ SIDE-VIEW DETECTED: Upload FRONT-VIEW video")
177
 
178
- # -----------------------------
179
  # SMOOTH SIGNALS
180
- # -----------------------------
181
  for key in signals:
182
  signals[key] = smooth_signal(np.array(signals[key]))
183
 
184
- print("✅ Signal extraction complete (normalized + stabilized)")
185
-
186
  return signals, fps
187
 
188
 
189
- def robust_amplitude(signal, threshold=0.08):
190
- """
191
- Computes real movement amplitude and removes MediaPipe noise.
192
- """
193
  if len(signal) == 0:
194
  return 0
195
-
196
- amp = np.max(signal) - np.min(signal)
197
-
198
- # Noise filtering
199
  return amp if amp > threshold else 0
200
 
201
 
202
  def compute_gait_features(signals, fps):
203
  features = {}
204
 
205
- # -----------------------------
206
- # 1. FOOT X SIGNAL
207
- # -----------------------------
208
- l_signal = detrend(signals["l_foot_x"])
209
- r_signal = detrend(signals["r_foot_x"])
210
 
211
  def smooth(x):
212
- return np.convolve(x, np.ones(7) / 7, mode="same") # balanced smoothing
213
 
214
  l_signal = smooth(l_signal)
215
  r_signal = smooth(r_signal)
216
 
217
- # -----------------------------
218
- # 2. PEAK DETECTION
219
- # -----------------------------
220
  min_distance = int(fps * 0.3)
221
 
222
  l_peaks, _ = find_peaks(
223
  l_signal,
224
  distance=min_distance,
225
- prominence=np.std(l_signal) * 0.25,
226
  )
227
 
228
  r_peaks, _ = find_peaks(
229
  r_signal,
230
  distance=min_distance,
231
- prominence=np.std(r_signal) * 0.25,
232
  )
233
 
234
- # -----------------------------
235
- # 3. CLEAN PEAKS
236
- # -----------------------------
237
  def clean_peaks(peaks, fps, min_gap=0.4):
238
  if len(peaks) == 0:
239
  return peaks
@@ -247,15 +214,11 @@ def compute_gait_features(signals, fps):
247
  l_peaks = clean_peaks(l_peaks, fps)
248
  r_peaks = clean_peaks(r_peaks, fps)
249
 
250
- # -----------------------------
251
- # 4. STRIDE TIMES
252
- # -----------------------------
253
  l_stride = np.diff(l_peaks) / fps if len(l_peaks) > 1 else np.array([])
254
  r_stride = np.diff(r_peaks) / fps if len(r_peaks) > 1 else np.array([])
255
 
256
- # -----------------------------
257
- # 5. ROBUST FILTER (RELAXED)
258
- # -----------------------------
259
  def filter_stride(strides):
260
  if len(strides) < 2:
261
  return strides
@@ -263,9 +226,8 @@ def compute_gait_features(signals, fps):
263
  median = np.median(strides)
264
 
265
  filtered = strides[
266
- (strides > 0.4)
267
- & (strides < 1.3) # slightly relaxed
268
- & (np.abs(strides - median) < 0.15) # not too strict
269
  ]
270
 
271
  return filtered
@@ -273,70 +235,45 @@ def compute_gait_features(signals, fps):
273
  l_stride = filter_stride(l_stride)
274
  r_stride = filter_stride(r_stride)
275
 
276
- # -----------------------------
277
- # 6. STRIDE VARIABILITY (FIXED PROPERLY)
278
- # -----------------------------
279
  stride_variability = None
280
 
281
- # Case 1: Both sides available
282
  if len(l_stride) >= 2 and len(r_stride) >= 2:
283
  cv_left = np.std(l_stride) / np.median(l_stride)
284
  cv_right = np.std(r_stride) / np.median(r_stride)
285
-
286
  stride_variability = ((cv_left + cv_right) / 2) * 100
287
-
288
- # Case 2: Only one side available
289
  elif len(l_stride) >= 2:
290
  stride_variability = (np.std(l_stride) / np.median(l_stride)) * 100
291
-
292
  elif len(r_stride) >= 2:
293
  stride_variability = (np.std(r_stride) / np.median(r_stride)) * 100
294
-
295
- # Case 3: Not enough data
296
  else:
297
- stride_variability = 0.5 # fallback (NOT zero)
298
 
299
- # Clamp to realistic clinical range
300
  stride_variability = max(0.5, min(stride_variability, 8.5))
 
301
 
302
- features["stride_variability"] = stride_variability
303
-
304
- # -----------------------------
305
- # 7. CADENCE
306
- # -----------------------------
307
  total_steps = len(l_peaks) + len(r_peaks)
308
  duration_minutes = len(l_signal) / fps / 60
309
-
310
  cadence = total_steps / duration_minutes if duration_minutes > 0 else 0
311
- features["cadence"] = cadence
312
 
313
- # -----------------------------
314
- # 8. SYMMETRY
315
- # -----------------------------
316
  if len(l_stride) > 0 and len(r_stride) > 0:
317
  l_mean = np.mean(l_stride)
318
  r_mean = np.mean(r_stride)
319
-
320
  symmetry = abs(l_mean - r_mean) / ((l_mean + r_mean) / 2)
321
  else:
322
  symmetry = 0
323
 
324
- features["symmetry_ratio"] = symmetry
325
-
326
- # -----------------------------
327
- # 9. ARM SWING (UNCHANGED)
328
- # -----------------------------
329
- def robust_amplitude_local(signal, threshold=0.01):
330
- if len(signal) == 0:
331
- return 0
332
- amp = np.percentile(signal, 95) - np.percentile(signal, 5)
333
- return amp if amp > threshold else 0
334
 
335
- l_arm = smooth(signals["l_arm_swing"])
336
- r_arm = smooth(signals["r_arm_swing"])
 
337
 
338
- l_amp = robust_amplitude_local(l_arm)
339
- r_amp = robust_amplitude_local(r_arm)
340
 
341
  scale_factor = 20.0
342
  l_amp *= scale_factor
@@ -344,208 +281,114 @@ def compute_gait_features(signals, fps):
344
 
345
  avg_arm = (l_amp + r_amp) / 2
346
 
347
- features["l_arm_amp"] = l_amp
348
- features["r_arm_amp"] = r_amp
349
- features["avg_arm_swing"] = avg_arm
350
 
351
- # -----------------------------
352
- # 10. ARM ASYMMETRY
353
- # -----------------------------
354
  if l_amp > 0 and r_amp > 0:
355
  asym = abs(l_amp - r_amp) / max(l_amp, r_amp) * 100
356
  else:
357
  asym = 100
358
 
359
- features["arm_asymmetry_index"] = asym
360
 
361
- # -----------------------------
362
  # Save signals for plots
363
- # -----------------------------
364
- signals["l_signal"] = l_signal
365
- signals["r_signal"] = r_signal
366
 
367
  return features, l_peaks, r_peaks
368
 
369
 
370
  def interpret_clinical_features(features, gender):
371
- lines: List[str] = []
372
- lines.append("\n" + "=" * 50)
373
- lines.append(f" HEMAS NEUROTRACK: CLINICAL INTERPRETATION ({gender.upper()})")
374
- lines.append("=" * 50)
375
-
376
- cv = features["stride_variability"]
377
- lines.append(f"\n▶ STRIDE TIME VARIABILITY: {cv:.2f}%")
378
- if gender.lower() == "male":
 
 
 
379
  if cv <= 2.5:
380
- lines.append(" ↳ Status: NORMAL (Healthy rhythm)")
381
  elif cv <= 4.0:
382
- lines.append(" ↳ Status: MILD DEVIATION (Slight irregularity)")
383
  elif cv <= 6.0:
384
- lines.append(" ↳ Status: MODERATE IMPAIRMENT (Noticeable rhythm fluctuation)")
385
  else:
386
- lines.append(" ↳ Status: HIGH IMPAIRMENT (Severe gait instability detected)")
387
- elif gender.lower() == "female":
388
  if cv <= 3.0:
389
- lines.append(" ↳ Status: NORMAL (Healthy rhythm)")
390
  elif cv <= 4.5:
391
- lines.append(" ↳ Status: MILD DEVIATION (Slight irregularity)")
392
  elif cv <= 6.5:
393
- lines.append(" ↳ Status: MODERATE IMPAIRMENT (Noticeable rhythm fluctuation)")
394
  else:
395
- lines.append(" ↳ Status: HIGH IMPAIRMENT (Severe gait instability detected)")
396
 
397
- cad = features["cadence"]
398
- lines.append(f"\n▶ CADENCE: {cad:.1f} steps/min")
399
- if gender.lower() == "male":
 
400
  if cad >= 100:
401
- lines.append(" ↳ Status: NORMAL (Healthy pace)")
402
  elif cad >= 90:
403
- lines.append(" ↳ Status: MILD REDUCTION (Slightly slower pace)")
404
  elif cad >= 80:
405
- lines.append(" ↳ Status: MODERATE REDUCTION (Bradykinesia indicator)")
406
  else:
407
- lines.append(" ↳ Status: HIGH REDUCTION (Severe shuffling or freezing tendency)")
408
- elif gender.lower() == "female":
409
  if cad >= 105:
410
- lines.append(" ↳ Status: NORMAL (Healthy pace)")
411
  elif cad >= 95:
412
- lines.append(" ↳ Status: MILD REDUCTION (Slightly slower pace)")
413
  elif cad >= 85:
414
- lines.append(" ↳ Status: MODERATE REDUCTION (Bradykinesia indicator)")
415
  else:
416
- lines.append(" ↳ Status: HIGH REDUCTION (Severe shuffling or freezing tendency)")
417
 
418
- lines.append("\n▶ GAIT SYMMETRY:")
419
- sym = features["symmetry_ratio"]
 
420
  if sym >= 0.95:
421
- lines.append(" ↳ Status: HIGHLY SYMMETRIC (Healthy left/right balance)")
422
  elif sym >= 0.85:
423
- lines.append(" ↳ Status: MILD ASYMMETRY (Slight favoring of one leg)")
424
  else:
425
- lines.append(" ↳ Status: SIGNIFICANT ASYMMETRY (Typical of unilateral Parkinsonian symptoms)")
426
 
427
- lines.append("\n▶ OVERALL ARM SWING:")
428
- swing = features["avg_arm_swing"]
429
- lines.append(f" [Raw AI Swing Variance Score: {swing:.2f}]")
 
430
 
431
  if swing > 5.0:
432
- lines.append(" ↳ Status: HEALTHY RANGE OF MOTION (Fluid arm swing)")
433
  elif swing > 2.5:
434
- lines.append(" ↳ Status: REDUCED AMPLITUDE (Stiffened arm movement)")
435
  else:
436
- lines.append(" ↳ Status: SEVERELY RESTRICTED (En-bloc / Rigid posture detected)")
437
 
438
- lines.append("\n▶ ARM SWING ASYMMETRY:")
439
- arm_asym = features["arm_asymmetry_index"]
440
- lines.append(f" [Raw AI Asymmetry Index: {arm_asym:.1f}%]")
 
441
 
442
  if arm_asym <= 25.0:
443
- lines.append(" ↳ Status: BALANCED (Both arms swing/rest equally)")
444
  elif arm_asym <= 45.0:
445
- lines.append(" ↳ Status: MILD ASYMMETRY (One arm shows slight rigidity)")
446
  else:
447
- lines.append(" ↳ Status: UNILATERAL RIGIDITY (One arm is significantly stiffer than the other)")
448
-
449
- lines.append("\n" + "=" * 50)
450
-
451
- return "\n".join(lines)
452
-
453
-
454
- def plot_clinical_biomarkers(signals, features, l_peaks, r_peaks, fps, plot_output_path):
455
- fig, axs = plt.subplots(3, 2, figsize=(16, 14))
456
- fig.suptitle(
457
- "NeuroTrack AI: Kinematic Gait Analysis",
458
- fontsize=20,
459
- fontweight="bold",
460
- color="#1f77b4",
461
- )
462
-
463
- time_axis = np.arange(len(signals["l_ankle_y"])) / fps
464
-
465
- # -----------------------------
466
- # 1. Ankle Vertical Displacement
467
- # -----------------------------
468
- axs[0, 0].plot(time_axis, signals["l_ankle_y"], label="Left Ankle", color="blue", alpha=0.7)
469
- axs[0, 0].plot(time_axis, signals["r_ankle_y"], label="Right Ankle", color="orange", alpha=0.7)
470
- axs[0, 0].set_title("Ankle Vertical Displacement")
471
- axs[0, 0].invert_yaxis()
472
- axs[0, 0].legend()
473
-
474
- # -----------------------------
475
- # 2. Peak Detection (FIXED)
476
- # -----------------------------
477
- if "l_signal" in signals and "r_signal" in signals:
478
- axs[0, 1].plot(time_axis, signals["l_signal"], color="gray", alpha=0.6)
479
-
480
- if len(l_peaks) > 0:
481
- axs[0, 1].plot(
482
- time_axis[l_peaks],
483
- signals["l_signal"][l_peaks],
484
- "X",
485
- color="red",
486
- markersize=8,
487
- label="Left Steps",
488
- )
489
-
490
- if len(r_peaks) > 0:
491
- axs[0, 1].plot(
492
- time_axis[r_peaks],
493
- signals["r_signal"][r_peaks],
494
- "X",
495
- color="green",
496
- markersize=8,
497
- label="Right Steps",
498
- )
499
-
500
- axs[0, 1].set_title("Step Detection (Foot X Signal)")
501
- axs[0, 1].legend()
502
- else:
503
- axs[0, 1].set_title("Step Detection (No Data)")
504
-
505
- # -----------------------------
506
- # 3. Stride Times
507
- # -----------------------------
508
- l_stride_times = np.diff(l_peaks) / fps if len(l_peaks) > 1 else []
509
- r_stride_times = np.diff(r_peaks) / fps if len(r_peaks) > 1 else []
510
-
511
- if len(l_stride_times) > 0:
512
- axs[1, 0].plot(l_stride_times, marker="o", linestyle="-", color="blue", label="Left")
513
-
514
- if len(r_stride_times) > 0:
515
- axs[1, 0].plot(r_stride_times, marker="o", linestyle="-", color="orange", label="Right")
516
 
517
- axs[1, 0].set_title(f"Stride Variability (CV: {features['stride_variability']:.2f}%)")
518
- axs[1, 0].legend()
519
-
520
- # -----------------------------
521
- # 4. Arm Swing
522
- # -----------------------------
523
- axs[1, 1].plot(time_axis, signals["l_arm_swing"], label="Left Arm", color="purple", alpha=0.7)
524
- axs[1, 1].plot(time_axis, signals["r_arm_swing"], label="Right Arm", color="brown", alpha=0.7)
525
- axs[1, 1].set_title("Normalized Arm Swing")
526
- axs[1, 1].legend()
527
-
528
- # -----------------------------
529
- # 5. Arm Amplitude
530
- # -----------------------------
531
- axs[2, 0].bar(
532
- ["Left Arm", "Right Arm"],
533
- [features["l_arm_amp"], features["r_arm_amp"]],
534
- color=["purple", "brown"],
535
- )
536
- axs[2, 0].set_title(f"Arm Asymmetry Index: {features['arm_asymmetry_index']:.1f}%")
537
- axs[2, 0].set_ylabel("Amplitude")
538
-
539
- # -----------------------------
540
- # 6. Postural Sway
541
- # -----------------------------
542
- axs[2, 1].plot(time_axis, signals["mid_hip_x"], color="teal")
543
- axs[2, 1].set_title("Postural Sway (Hip X Movement)")
544
-
545
- # -----------------------------
546
- plt.tight_layout(rect=[0, 0.03, 1, 0.95])
547
- fig.savefig(plot_output_path, dpi=150)
548
- plt.close(fig)
549
 
550
 
551
  def score_stride_variability(v):
@@ -612,26 +455,24 @@ def score_arm_asymmetry(a):
612
 
613
 
614
  def compute_gait_stability_score(features):
615
- sv = features["stride_variability"]
616
- sym = features["symmetry_ratio"]
617
- cad = features["cadence"]
618
- arm = features["avg_arm_swing"]
619
- asym = features["arm_asymmetry_index"]
620
 
621
- # Individual scores
622
  sv_score = score_stride_variability(sv)
623
  sym_score = score_symmetry(sym)
624
  cad_score = score_cadence(cad)
625
  arm_score = score_arm_swing(arm)
626
  asym_score = score_arm_asymmetry(asym)
627
 
628
- # Weighted sum
629
  final_score = (
630
- 0.30 * sv_score
631
- + 0.20 * sym_score
632
- + 0.15 * cad_score
633
- + 0.20 * arm_score
634
- + 0.15 * asym_score
635
  )
636
 
637
  return round(final_score, 2)
@@ -648,109 +489,305 @@ def interpret_gait_score(score):
648
  return "🔴 Severe gait instability"
649
 
650
 
651
- def _safe_float_dict(d: Dict[str, Any]) -> Dict[str, float]:
652
- safe: Dict[str, float] = {}
653
- for key, value in d.items():
654
- safe[key] = float(value)
655
- return safe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
 
 
 
 
 
 
 
 
 
 
 
 
 
657
 
658
- def run_pipeline(input_video: str, output_video: str, gender: str, plot_output: str) -> Tuple[Dict[str, float], str, float, str, float]:
659
- print("1. Overlaying skeleton and extracting kinematics...")
660
- signals, fps = extract_validate_and_visualize(input_video, output_video)
661
 
662
- print("2. Computing clinical biomarkers...")
663
- features, l_peaks, r_peaks = compute_gait_features(signals, fps)
664
 
665
- clinical_report = interpret_clinical_features(features, gender)
666
- score = compute_gait_stability_score(features)
667
- interpretation = interpret_gait_score(score)
668
 
669
- # Store inside features (BEST PRACTICE)
670
- features["gait_score"] = score
671
- features["gait_interpretation"] = interpretation
 
 
672
 
673
- print("\n🧠 GAIT STABILITY SCORE:", score)
674
- print("📊 INTERPRETATION:", interpretation)
 
 
 
 
 
 
675
 
676
- print("\nGenerating Clinical Visualization Dashboard...")
677
- plot_clinical_biomarkers(signals, features, l_peaks, r_peaks, fps, plot_output)
 
678
 
679
- return _safe_float_dict(features), clinical_report, score, interpretation, float(fps)
 
 
680
 
681
 
682
  @app.get("/")
683
- def root():
 
684
  return {
685
  "message": "HEMAS NeuroTrack Gait Analysis API",
686
- "swagger_ui": "/docs",
687
- "analyze_endpoint": "/analyze",
 
 
 
 
688
  }
689
 
690
 
691
- @app.post(
692
- "/analyze",
693
- response_model=AnalyzeResponse,
694
- responses={
695
- 400: {"description": "Invalid video input (no person/side view detected)."},
696
- 500: {"description": "Unexpected processing error."},
697
- },
698
- )
699
- async def analyze_video(
700
- video: Annotated[UploadFile, File(...)],
701
- patient_gender: Annotated[Literal["male", "female"], Form("male")],
702
  ):
703
- run_id = str(uuid.uuid4())
704
- suffix = Path(video.filename or "input.mp4").suffix or ".mp4"
705
-
706
- input_path = INPUTS_DIR / f"{run_id}{suffix}"
707
- output_video_path = OUTPUTS_DIR / f"annotated_{run_id}.mp4"
708
- plot_path = PLOTS_DIR / f"plot_{run_id}.png"
709
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  try:
 
711
  content = await video.read()
712
- input_path.write_bytes(content)
713
-
714
- features, clinical_report, score, interpretation, fps = run_pipeline(
715
- input_video=str(input_path),
716
- output_video=str(output_video_path),
717
- gender=patient_gender,
718
- plot_output=str(plot_path),
719
- )
720
-
721
- return AnalyzeResponse(
722
- run_id=run_id,
723
- fps=fps,
724
- features=features,
725
- clinical_report=clinical_report,
726
- gait_score=score,
727
- gait_interpretation=interpretation,
728
- annotated_video_url=f"/runs/outputs/{output_video_path.name}",
729
- plot_image_url=f"/runs/plots/{plot_path.name}",
730
- )
731
- except ValueError as exc:
732
- raise HTTPException(status_code=400, detail=str(exc)) from exc
733
- except Exception as exc:
734
- raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {exc}") from exc
735
-
736
-
737
- @app.get(
738
- "/download/annotated/{filename}",
739
- responses={404: {"description": "Annotated video not found."}},
740
- )
741
- def download_annotated_video(filename: str):
742
- file_path = OUTPUTS_DIR / filename
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  if not file_path.exists():
744
- raise HTTPException(status_code=404, detail="Annotated video not found")
745
- return FileResponse(path=file_path, media_type="video/mp4", filename=filename)
 
 
 
 
 
746
 
747
 
748
- @app.get(
749
- "/download/plot/{filename}",
750
- responses={404: {"description": "Plot image not found."}},
751
- )
752
- def download_plot(filename: str):
753
- file_path = PLOTS_DIR / filename
754
- if not file_path.exists():
755
- raise HTTPException(status_code=404, detail="Plot image not found")
756
- return FileResponse(path=file_path, media_type="image/png", filename=filename)
 
1
+ from fastapi import FastAPI, File, UploadFile, Form, HTTPException
2
+ from fastapi.responses import FileResponse, JSONResponse
3
+ from fastapi.middleware.cors import CORSMiddleware
 
 
4
  import cv2
5
  import mediapipe as mp
6
+ import numpy as np
7
  import matplotlib
8
+ matplotlib.use('Agg') # Use non-interactive backend
9
  import matplotlib.pyplot as plt
10
+ from scipy.signal import find_peaks, savgol_filter, detrend
11
+ import os
12
+ import tempfile
13
+ import shutil
14
+ from pathlib import Path
15
+ from typing import Dict, List, Tuple
16
+ import base64
17
+ import io
18
 
19
+ app = FastAPI(
20
+ title="Gait Analysis API",
21
+ description="Clinical gait analysis using MediaPipe Pose estimation",
22
+ version="1.0.0"
23
+ )
24
 
25
+ # Add CORS middleware
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_credentials=True,
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ # Initialize MediaPipe Pose Model
35
  mp_pose = mp.solutions.pose
36
  pose = mp_pose.Pose(
37
  static_image_mode=False,
38
  model_complexity=2,
39
  min_detection_confidence=0.5,
40
+ min_tracking_confidence=0.5
41
  )
 
42
  mp_drawing = mp.solutions.drawing_utils
43
 
44
+ # Create output directory(1)
45
+ # OUTPUT_DIR = Path("/tmp/gait_outputs")
46
+ # OUTPUT_DIR.mkdir(exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
+ # Create output directory(2) (cross-platform, inside repository)
49
+ BASE_DIR = Path(__file__).resolve().parent
50
+ OUTPUT_DIR = BASE_DIR / "runs" / "outputs"
51
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
52
 
53
 
54
  def smooth_signal(data, window_length=9, polyorder=3):
 
64
 
65
  width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
66
  height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
67
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
68
  out = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height))
69
 
70
  signals = {
71
+ 'l_ankle_y': [], 'r_ankle_y': [],
72
+ 'l_arm_swing': [], 'r_arm_swing': [],
73
+ 'mid_hip_x': [], 'mid_hip_y': [],
74
+ 'l_foot_x': [], 'r_foot_x': []
 
 
 
 
75
  }
76
 
77
  while cap.isOpened():
 
89
  if results.pose_landmarks:
90
  lm = results.pose_landmarks.landmark
91
 
92
+ # BODY CENTER
 
 
93
  mid_hip_x = (lm[23].x + lm[24].x) / 2
94
  mid_hip_y = (lm[23].y + lm[24].y) / 2
95
 
96
+ signals['mid_hip_x'].append(mid_hip_x)
97
+ signals['mid_hip_y'].append(mid_hip_y)
98
 
99
+ # LOWER BODY
100
+ signals['l_ankle_y'].append(lm[27].y)
101
+ signals['r_ankle_y'].append(lm[28].y)
 
 
102
 
103
+ # Normalize foot X relative to body center
104
+ signals['l_foot_x'].append(lm[31].x - mid_hip_x)
105
+ signals['r_foot_x'].append(lm[32].x - mid_hip_x)
106
 
107
+ # ARM SWING
 
 
108
  l_torso_len = np.linalg.norm([
109
  lm[11].x - lm[23].x,
110
+ lm[11].y - lm[23].y
111
  ])
112
  r_torso_len = np.linalg.norm([
113
  lm[12].x - lm[24].x,
114
+ lm[12].y - lm[24].y
115
  ])
116
 
117
+ # Relative to shoulder
118
  l_ws = np.linalg.norm([
119
  lm[15].x - lm[11].x,
120
+ lm[15].y - lm[11].y
121
  ])
122
  r_ws = np.linalg.norm([
123
  lm[16].x - lm[12].x,
124
+ lm[16].y - lm[12].y
125
  ])
126
 
127
+ # Normalized + stabilized
128
+ signals['l_arm_swing'].append(l_ws / (l_torso_len + 1e-6))
129
+ signals['r_arm_swing'].append(r_ws / (r_torso_len + 1e-6))
130
 
 
131
  # DRAW SKELETON
 
132
  mp_drawing.draw_landmarks(
133
  image_bgr,
134
  results.pose_landmarks,
 
138
  ),
139
  connection_drawing_spec=mp_drawing.DrawingSpec(
140
  color=(255, 255, 255), thickness=2
141
+ )
142
  )
143
 
144
  out.write(image_bgr)
 
146
  cap.release()
147
  out.release()
148
 
 
149
  # VALIDATION
150
+ if len(signals['mid_hip_x']) == 0:
 
151
  raise ValueError("❌ No person detected in the video.")
152
 
153
  # IMPROVED SIDE VIEW DETECTION
154
+ x_var = np.var(signals['mid_hip_x'])
155
+ y_var = np.var(signals['mid_hip_y'])
156
 
157
+ if x_var > y_var:
158
  raise ValueError("❌ SIDE-VIEW DETECTED: Upload FRONT-VIEW video")
159
 
 
160
  # SMOOTH SIGNALS
 
161
  for key in signals:
162
  signals[key] = smooth_signal(np.array(signals[key]))
163
 
 
 
164
  return signals, fps
165
 
166
 
167
+ def robust_amplitude(signal, threshold=0.01):
168
+ """Computes real movement amplitude and removes MediaPipe noise."""
 
 
169
  if len(signal) == 0:
170
  return 0
171
+ amp = np.percentile(signal, 95) - np.percentile(signal, 5)
 
 
 
172
  return amp if amp > threshold else 0
173
 
174
 
175
  def compute_gait_features(signals, fps):
176
  features = {}
177
 
178
+ # FOOT X SIGNAL
179
+ l_signal = detrend(signals['l_foot_x'])
180
+ r_signal = detrend(signals['r_foot_x'])
 
 
181
 
182
  def smooth(x):
183
+ return np.convolve(x, np.ones(7)/7, mode='same')
184
 
185
  l_signal = smooth(l_signal)
186
  r_signal = smooth(r_signal)
187
 
188
+ # PEAK DETECTION
 
 
189
  min_distance = int(fps * 0.3)
190
 
191
  l_peaks, _ = find_peaks(
192
  l_signal,
193
  distance=min_distance,
194
+ prominence=np.std(l_signal) * 0.25
195
  )
196
 
197
  r_peaks, _ = find_peaks(
198
  r_signal,
199
  distance=min_distance,
200
+ prominence=np.std(r_signal) * 0.25
201
  )
202
 
203
+ # CLEAN PEAKS
 
 
204
  def clean_peaks(peaks, fps, min_gap=0.4):
205
  if len(peaks) == 0:
206
  return peaks
 
214
  l_peaks = clean_peaks(l_peaks, fps)
215
  r_peaks = clean_peaks(r_peaks, fps)
216
 
217
+ # STRIDE TIMES
 
 
218
  l_stride = np.diff(l_peaks) / fps if len(l_peaks) > 1 else np.array([])
219
  r_stride = np.diff(r_peaks) / fps if len(r_peaks) > 1 else np.array([])
220
 
221
+ # ROBUST FILTER
 
 
222
  def filter_stride(strides):
223
  if len(strides) < 2:
224
  return strides
 
226
  median = np.median(strides)
227
 
228
  filtered = strides[
229
+ (strides > 0.4) & (strides < 1.3) &
230
+ (np.abs(strides - median) < 0.15)
 
231
  ]
232
 
233
  return filtered
 
235
  l_stride = filter_stride(l_stride)
236
  r_stride = filter_stride(r_stride)
237
 
238
+ # STRIDE VARIABILITY
 
 
239
  stride_variability = None
240
 
 
241
  if len(l_stride) >= 2 and len(r_stride) >= 2:
242
  cv_left = np.std(l_stride) / np.median(l_stride)
243
  cv_right = np.std(r_stride) / np.median(r_stride)
 
244
  stride_variability = ((cv_left + cv_right) / 2) * 100
 
 
245
  elif len(l_stride) >= 2:
246
  stride_variability = (np.std(l_stride) / np.median(l_stride)) * 100
 
247
  elif len(r_stride) >= 2:
248
  stride_variability = (np.std(r_stride) / np.median(r_stride)) * 100
 
 
249
  else:
250
+ stride_variability = 0.5
251
 
 
252
  stride_variability = max(0.5, min(stride_variability, 8.5))
253
+ features['stride_variability'] = stride_variability
254
 
255
+ # CADENCE
 
 
 
 
256
  total_steps = len(l_peaks) + len(r_peaks)
257
  duration_minutes = len(l_signal) / fps / 60
 
258
  cadence = total_steps / duration_minutes if duration_minutes > 0 else 0
259
+ features['cadence'] = cadence
260
 
261
+ # SYMMETRY
 
 
262
  if len(l_stride) > 0 and len(r_stride) > 0:
263
  l_mean = np.mean(l_stride)
264
  r_mean = np.mean(r_stride)
 
265
  symmetry = abs(l_mean - r_mean) / ((l_mean + r_mean) / 2)
266
  else:
267
  symmetry = 0
268
 
269
+ features['symmetry_ratio'] = symmetry
 
 
 
 
 
 
 
 
 
270
 
271
+ # ARM SWING
272
+ l_arm = smooth(signals['l_arm_swing'])
273
+ r_arm = smooth(signals['r_arm_swing'])
274
 
275
+ l_amp = robust_amplitude(l_arm)
276
+ r_amp = robust_amplitude(r_arm)
277
 
278
  scale_factor = 20.0
279
  l_amp *= scale_factor
 
281
 
282
  avg_arm = (l_amp + r_amp) / 2
283
 
284
+ features['l_arm_amp'] = l_amp
285
+ features['r_arm_amp'] = r_amp
286
+ features['avg_arm_swing'] = avg_arm
287
 
288
+ # ARM ASYMMETRY
 
 
289
  if l_amp > 0 and r_amp > 0:
290
  asym = abs(l_amp - r_amp) / max(l_amp, r_amp) * 100
291
  else:
292
  asym = 100
293
 
294
+ features['arm_asymmetry_index'] = asym
295
 
 
296
  # Save signals for plots
297
+ signals['l_signal'] = l_signal
298
+ signals['r_signal'] = r_signal
 
299
 
300
  return features, l_peaks, r_peaks
301
 
302
 
303
  def interpret_clinical_features(features, gender):
304
+ """Generate clinical interpretation text"""
305
+ interpretation = []
306
+
307
+ interpretation.append("=" * 50)
308
+ interpretation.append(f" HEMAS NEUROTRACK: CLINICAL INTERPRETATION ({gender.upper()})")
309
+ interpretation.append("=" * 50)
310
+
311
+ # Stride Variability
312
+ cv = features['stride_variability']
313
+ interpretation.append(f"\n▶ STRIDE TIME VARIABILITY: {cv:.2f}%")
314
+ if gender.lower() == 'male':
315
  if cv <= 2.5:
316
+ interpretation.append(" ↳ Status: NORMAL (Healthy rhythm)")
317
  elif cv <= 4.0:
318
+ interpretation.append(" ↳ Status: MILD DEVIATION (Slight irregularity)")
319
  elif cv <= 6.0:
320
+ interpretation.append(" ↳ Status: MODERATE IMPAIRMENT (Noticeable rhythm fluctuation)")
321
  else:
322
+ interpretation.append(" ↳ Status: HIGH IMPAIRMENT (Severe gait instability detected)")
323
+ elif gender.lower() == 'female':
324
  if cv <= 3.0:
325
+ interpretation.append(" ↳ Status: NORMAL (Healthy rhythm)")
326
  elif cv <= 4.5:
327
+ interpretation.append(" ↳ Status: MILD DEVIATION (Slight irregularity)")
328
  elif cv <= 6.5:
329
+ interpretation.append(" ↳ Status: MODERATE IMPAIRMENT (Noticeable rhythm fluctuation)")
330
  else:
331
+ interpretation.append(" ↳ Status: HIGH IMPAIRMENT (Severe gait instability detected)")
332
 
333
+ # Cadence
334
+ cad = features['cadence']
335
+ interpretation.append(f"\n▶ CADENCE: {cad:.1f} steps/min")
336
+ if gender.lower() == 'male':
337
  if cad >= 100:
338
+ interpretation.append(" ↳ Status: NORMAL (Healthy pace)")
339
  elif cad >= 90:
340
+ interpretation.append(" ↳ Status: MILD REDUCTION (Slightly slower pace)")
341
  elif cad >= 80:
342
+ interpretation.append(" ↳ Status: MODERATE REDUCTION (Bradykinesia indicator)")
343
  else:
344
+ interpretation.append(" ↳ Status: HIGH REDUCTION (Severe shuffling or freezing tendency)")
345
+ elif gender.lower() == 'female':
346
  if cad >= 105:
347
+ interpretation.append(" ↳ Status: NORMAL (Healthy pace)")
348
  elif cad >= 95:
349
+ interpretation.append(" ↳ Status: MILD REDUCTION (Slightly slower pace)")
350
  elif cad >= 85:
351
+ interpretation.append(" ↳ Status: MODERATE REDUCTION (Bradykinesia indicator)")
352
  else:
353
+ interpretation.append(" ↳ Status: HIGH REDUCTION (Severe shuffling or freezing tendency)")
354
 
355
+ # Symmetry
356
+ interpretation.append("\n▶ GAIT SYMMETRY:")
357
+ sym = features['symmetry_ratio']
358
  if sym >= 0.95:
359
+ interpretation.append(" ↳ Status: HIGHLY SYMMETRIC (Healthy left/right balance)")
360
  elif sym >= 0.85:
361
+ interpretation.append(" ↳ Status: MILD ASYMMETRY (Slight favoring of one leg)")
362
  else:
363
+ interpretation.append(" ↳ Status: SIGNIFICANT ASYMMETRY (Typical of unilateral Parkinsonian symptoms)")
364
 
365
+ # Arm Swing
366
+ interpretation.append("\n▶ OVERALL ARM SWING:")
367
+ swing = features['avg_arm_swing']
368
+ interpretation.append(f" [Raw AI Swing Variance Score: {swing:.2f}]")
369
 
370
  if swing > 5.0:
371
+ interpretation.append(" ↳ Status: HEALTHY RANGE OF MOTION (Fluid arm swing)")
372
  elif swing > 2.5:
373
+ interpretation.append(" ↳ Status: REDUCED AMPLITUDE (Stiffened arm movement)")
374
  else:
375
+ interpretation.append(" ↳ Status: SEVERELY RESTRICTED (En-bloc / Rigid posture detected)")
376
 
377
+ # Arm Asymmetry
378
+ interpretation.append("\n▶ ARM SWING ASYMMETRY:")
379
+ arm_asym = features['arm_asymmetry_index']
380
+ interpretation.append(f" [Raw AI Asymmetry Index: {arm_asym:.1f}%]")
381
 
382
  if arm_asym <= 25.0:
383
+ interpretation.append(" ↳ Status: BALANCED (Both arms swing/rest equally)")
384
  elif arm_asym <= 45.0:
385
+ interpretation.append(" ↳ Status: MILD ASYMMETRY (One arm shows slight rigidity)")
386
  else:
387
+ interpretation.append(" ↳ Status: UNILATERAL RIGIDITY (One arm is significantly stiffer than the other)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
+ interpretation.append("\n" + "=" * 50)
390
+
391
+ return "\n".join(interpretation)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
 
394
  def score_stride_variability(v):
 
455
 
456
 
457
  def compute_gait_stability_score(features):
458
+ sv = features['stride_variability']
459
+ sym = features['symmetry_ratio']
460
+ cad = features['cadence']
461
+ arm = features['avg_arm_swing']
462
+ asym = features['arm_asymmetry_index']
463
 
 
464
  sv_score = score_stride_variability(sv)
465
  sym_score = score_symmetry(sym)
466
  cad_score = score_cadence(cad)
467
  arm_score = score_arm_swing(arm)
468
  asym_score = score_arm_asymmetry(asym)
469
 
 
470
  final_score = (
471
+ 0.30 * sv_score +
472
+ 0.20 * sym_score +
473
+ 0.15 * cad_score +
474
+ 0.20 * arm_score +
475
+ 0.15 * asym_score
476
  )
477
 
478
  return round(final_score, 2)
 
489
  return "🔴 Severe gait instability"
490
 
491
 
492
+ def plot_clinical_biomarkers(signals, features, l_peaks, r_peaks, fps, output_path):
493
+ """Generate clinical visualization dashboard and save to file"""
494
+ fig, axs = plt.subplots(3, 2, figsize=(16, 14))
495
+ fig.suptitle('NeuroTrack AI: Kinematic Gait Analysis', fontsize=20, fontweight='bold', color='#1f77b4')
496
+
497
+ time_axis = np.arange(len(signals['l_ankle_y'])) / fps
498
+
499
+ # 1. Ankle Vertical Displacement
500
+ axs[0, 0].plot(time_axis, signals['l_ankle_y'], label='Left Ankle', color='blue', alpha=0.7)
501
+ axs[0, 0].plot(time_axis, signals['r_ankle_y'], label='Right Ankle', color='orange', alpha=0.7)
502
+ axs[0, 0].set_title('Ankle Vertical Displacement')
503
+ axs[0, 0].invert_yaxis()
504
+ axs[0, 0].legend()
505
+
506
+ # 2. Peak Detection
507
+ if 'l_signal' in signals and 'r_signal' in signals:
508
+ axs[0, 1].plot(time_axis, signals['l_signal'], color='gray', alpha=0.6)
509
+
510
+ if len(l_peaks) > 0:
511
+ axs[0, 1].plot(time_axis[l_peaks], signals['l_signal'][l_peaks], "X",
512
+ color='red', markersize=8, label='Left Steps')
513
 
514
+ if len(r_peaks) > 0:
515
+ axs[0, 1].plot(time_axis[r_peaks], signals['r_signal'][r_peaks], "X",
516
+ color='green', markersize=8, label='Right Steps')
517
+
518
+ axs[0, 1].set_title('Step Detection (Foot X Signal)')
519
+ axs[0, 1].legend()
520
+ else:
521
+ axs[0, 1].set_title("Step Detection (No Data)")
522
+
523
+ # 3. Stride Times
524
+ l_stride_times = np.diff(l_peaks) / fps if len(l_peaks) > 1 else []
525
+ r_stride_times = np.diff(r_peaks) / fps if len(r_peaks) > 1 else []
526
 
527
+ if len(l_stride_times) > 0:
528
+ axs[1, 0].plot(l_stride_times, marker='o', linestyle='-', color='blue', label='Left')
 
529
 
530
+ if len(r_stride_times) > 0:
531
+ axs[1, 0].plot(r_stride_times, marker='o', linestyle='-', color='orange', label='Right')
532
 
533
+ axs[1, 0].set_title(f"Stride Variability (CV: {features['stride_variability']:.2f}%)")
534
+ axs[1, 0].legend()
 
535
 
536
+ # 4. Arm Swing
537
+ axs[1, 1].plot(time_axis, signals['l_arm_swing'], label='Left Arm', color='purple', alpha=0.7)
538
+ axs[1, 1].plot(time_axis, signals['r_arm_swing'], label='Right Arm', color='brown', alpha=0.7)
539
+ axs[1, 1].set_title('Normalized Arm Swing')
540
+ axs[1, 1].legend()
541
 
542
+ # 5. Arm Amplitude
543
+ axs[2, 0].bar(
544
+ ['Left Arm', 'Right Arm'],
545
+ [features['l_arm_amp'], features['r_arm_amp']],
546
+ color=['purple', 'brown']
547
+ )
548
+ axs[2, 0].set_title(f"Arm Asymmetry Index: {features['arm_asymmetry_index']:.1f}%")
549
+ axs[2, 0].set_ylabel('Amplitude')
550
 
551
+ # 6. Postural Sway
552
+ axs[2, 1].plot(time_axis, signals['mid_hip_x'], color='teal')
553
+ axs[2, 1].set_title('Postural Sway (Hip X Movement)')
554
 
555
+ plt.tight_layout(rect=[0, 0.03, 1, 0.95])
556
+ plt.savefig(output_path, dpi=100, bbox_inches='tight')
557
+ plt.close()
558
 
559
 
560
  @app.get("/")
561
+ async def root():
562
+ """Root endpoint with API information"""
563
  return {
564
  "message": "HEMAS NeuroTrack Gait Analysis API",
565
+ "version": "1.0.0",
566
+ "endpoints": {
567
+ "analyze_gait": "/analyze",
568
+ "docs": "/docs",
569
+ "redoc": "/redoc"
570
+ }
571
  }
572
 
573
 
574
+ @app.post("/analyze")
575
+ async def analyze_gait(
576
+ video: UploadFile = File(..., description="Video file for gait analysis"),
577
+ gender: str = Form(..., description="Patient gender (male/female)")
 
 
 
 
 
 
 
578
  ):
579
+ """
580
+ Analyze gait from video file
581
+
582
+ - **video**: Video file (mp4, mov, avi, etc.)
583
+ - **gender**: Patient gender (male or female) for clinical interpretation
584
+
585
+ Returns:
586
+ - Annotated video with skeleton overlay
587
+ - Clinical biomarkers visualization
588
+ - Detailed clinical interpretation
589
+ - Gait stability score
590
+ """
591
+
592
+ if gender.lower() not in ['male', 'female']:
593
+ raise HTTPException(status_code=400, detail="Gender must be 'male' or 'female'")
594
+
595
+ # Create temporary file paths without keeping open handles (important on Windows)
596
+ input_fd, temp_input_path = tempfile.mkstemp(suffix='.mp4')
597
+ output_fd, temp_output_video_path = tempfile.mkstemp(suffix='.mp4')
598
+ plot_fd, temp_plot_path = tempfile.mkstemp(suffix='.png')
599
+ os.close(input_fd)
600
+ os.close(output_fd)
601
+ os.close(plot_fd)
602
+
603
  try:
604
+ # Save uploaded video
605
  content = await video.read()
606
+ with open(temp_input_path, 'wb') as f:
607
+ f.write(content)
608
+
609
+ # Process video
610
+ print("1. Overlaying skeleton and extracting kinematics...")
611
+ signals, fps = extract_validate_and_visualize(temp_input_path, temp_output_video_path)
612
+
613
+ print("2. Computing clinical biomarkers...")
614
+ features, l_peaks, r_peaks = compute_gait_features(signals, fps)
615
+
616
+ # Generate clinical interpretation
617
+ clinical_interpretation = interpret_clinical_features(features, gender)
618
+
619
+ # Compute gait stability score
620
+ score = compute_gait_stability_score(features)
621
+ interpretation = interpret_gait_score(score)
622
+
623
+ features['gait_score'] = score
624
+ features['gait_interpretation'] = interpretation
625
+
626
+ # Generate plots
627
+ print("3. Generating Clinical Visualization Dashboard...")
628
+ plot_clinical_biomarkers(signals, features, l_peaks, r_peaks, fps, temp_plot_path)
629
+
630
+ # Convert files to base64
631
+ with open(temp_output_video_path, 'rb') as f:
632
+ annotated_video_b64 = base64.b64encode(f.read()).decode('utf-8')
633
+
634
+ with open(temp_plot_path, 'rb') as f:
635
+ plot_b64 = base64.b64encode(f.read()).decode('utf-8')
636
+
637
+ # Prepare response
638
+ response = {
639
+ "status": "success",
640
+ "clinical_interpretation": clinical_interpretation,
641
+ "gait_stability_score": score,
642
+ "gait_interpretation": interpretation,
643
+ "features": {
644
+ "stride_variability": float(features['stride_variability']),
645
+ "cadence": float(features['cadence']),
646
+ "symmetry_ratio": float(features['symmetry_ratio']),
647
+ "avg_arm_swing": float(features['avg_arm_swing']),
648
+ "l_arm_amp": float(features['l_arm_amp']),
649
+ "r_arm_amp": float(features['r_arm_amp']),
650
+ "arm_asymmetry_index": float(features['arm_asymmetry_index'])
651
+ },
652
+ "files": {
653
+ "annotated_video": f"data:video/mp4;base64,{annotated_video_b64}",
654
+ "clinical_dashboard": f"data:image/png;base64,{plot_b64}"
655
+ },
656
+ "metadata": {
657
+ "fps": float(fps),
658
+ "total_frames": len(signals['l_ankle_y']),
659
+ "duration_seconds": len(signals['l_ankle_y']) / fps,
660
+ "left_steps_detected": int(len(l_peaks)),
661
+ "right_steps_detected": int(len(r_peaks))
662
+ }
663
+ }
664
+
665
+ return JSONResponse(content=response)
666
+
667
+ except ValueError as e:
668
+ raise HTTPException(status_code=400, detail=str(e))
669
+ except Exception as e:
670
+ raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
671
+ finally:
672
+ # Cleanup
673
+ for temp_file in [temp_input_path, temp_output_video_path, temp_plot_path]:
674
+ if os.path.exists(temp_file):
675
+ try:
676
+ os.unlink(temp_file)
677
+ except PermissionError:
678
+ pass
679
+
680
+
681
+ @app.post("/analyze_files")
682
+ async def analyze_gait_files(
683
+ video: UploadFile = File(..., description="Video file for gait analysis"),
684
+ gender: str = Form(..., description="Patient gender (male/female)")
685
+ ):
686
+ """
687
+ Analyze gait from video file and return downloadable files
688
+
689
+ - **video**: Video file (mp4, mov, avi, etc.)
690
+ - **gender**: Patient gender (male or female) for clinical interpretation
691
+
692
+ Returns:
693
+ - JSON with URLs to download annotated video and clinical dashboard
694
+ """
695
+
696
+ if gender.lower() not in ['male', 'female']:
697
+ raise HTTPException(status_code=400, detail="Gender must be 'male' or 'female'")
698
+
699
+ # Create unique filenames
700
+ import uuid
701
+ session_id = str(uuid.uuid4())
702
+
703
+ input_path = OUTPUT_DIR / f"{session_id}_input.mp4"
704
+ output_video_path = OUTPUT_DIR / f"{session_id}_annotated.mp4"
705
+ plot_path = OUTPUT_DIR / f"{session_id}_dashboard.png"
706
+
707
+ try:
708
+ # Save uploaded video
709
+ content = await video.read()
710
+ with open(input_path, 'wb') as f:
711
+ f.write(content)
712
+
713
+ # Process video
714
+ signals, fps = extract_validate_and_visualize(str(input_path), str(output_video_path))
715
+ features, l_peaks, r_peaks = compute_gait_features(signals, fps)
716
+
717
+ # Generate clinical interpretation
718
+ clinical_interpretation = interpret_clinical_features(features, gender)
719
+
720
+ # Compute gait stability score
721
+ score = compute_gait_stability_score(features)
722
+ interpretation = interpret_gait_score(score)
723
+
724
+ features['gait_score'] = score
725
+ features['gait_interpretation'] = interpretation
726
+
727
+ # Generate plots
728
+ plot_clinical_biomarkers(signals, features, l_peaks, r_peaks, fps, str(plot_path))
729
+
730
+ # Prepare response
731
+ response = {
732
+ "status": "success",
733
+ "session_id": session_id,
734
+ "clinical_interpretation": clinical_interpretation,
735
+ "gait_stability_score": score,
736
+ "gait_interpretation": interpretation,
737
+ "features": {
738
+ "stride_variability": float(features['stride_variability']),
739
+ "cadence": float(features['cadence']),
740
+ "symmetry_ratio": float(features['symmetry_ratio']),
741
+ "avg_arm_swing": float(features['avg_arm_swing']),
742
+ "l_arm_amp": float(features['l_arm_amp']),
743
+ "r_arm_amp": float(features['r_arm_amp']),
744
+ "arm_asymmetry_index": float(features['arm_asymmetry_index'])
745
+ },
746
+ "download_urls": {
747
+ "annotated_video": f"/download/{session_id}_annotated.mp4",
748
+ "clinical_dashboard": f"/download/{session_id}_dashboard.png"
749
+ },
750
+ "metadata": {
751
+ "fps": float(fps),
752
+ "total_frames": len(signals['l_ankle_y']),
753
+ "duration_seconds": len(signals['l_ankle_y']) / fps,
754
+ "left_steps_detected": int(len(l_peaks)),
755
+ "right_steps_detected": int(len(r_peaks))
756
+ }
757
+ }
758
+
759
+ # Clean up input file
760
+ os.unlink(input_path)
761
+
762
+ return JSONResponse(content=response)
763
+
764
+ except ValueError as e:
765
+ raise HTTPException(status_code=400, detail=str(e))
766
+ except Exception as e:
767
+ raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
768
+
769
+
770
+ @app.get("/download/{filename}")
771
+ async def download_file(filename: str):
772
+ """Download generated files"""
773
+ file_path = OUTPUT_DIR / filename
774
+
775
  if not file_path.exists():
776
+ raise HTTPException(status_code=404, detail="File not found")
777
+
778
+ return FileResponse(
779
+ path=file_path,
780
+ filename=filename,
781
+ media_type='application/octet-stream'
782
+ )
783
 
784
 
785
+ @app.get("/health")
786
+ async def health_check():
787
+ """Health check endpoint"""
788
+ return {"status": "healthy", "service": "HEMAS NeuroTrack API"}
789
+
790
+
791
+ if __name__ == "__main__":
792
+ import uvicorn
793
+ uvicorn.run(app, host="0.0.0.0", port=8000)
scripts/cleanup_runs.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Cleanup utility for GAIT_API generated files.
3
+
4
+ This script removes files under the runs directory that are older than a
5
+ configurable age (default: 30 minutes). It is designed to be triggered by cron.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(description="Cleanup old files from runs directory")
18
+ parser.add_argument(
19
+ "--path",
20
+ default="/app/runs",
21
+ help="Runs directory path (default: /app/runs)",
22
+ )
23
+ parser.add_argument(
24
+ "--max-age-minutes",
25
+ type=float,
26
+ default=30,
27
+ help="Delete files older than this many minutes (default: 30)",
28
+ )
29
+ parser.add_argument(
30
+ "--dry-run",
31
+ action="store_true",
32
+ help="Only print what would be deleted",
33
+ )
34
+ return parser.parse_args()
35
+
36
+
37
+ def cleanup_runs(root: Path, max_age_minutes: float, dry_run: bool = False) -> tuple[int, int]:
38
+ if not root.exists():
39
+ return 0, 0
40
+
41
+ now = time.time()
42
+ cutoff = now - (max_age_minutes * 60)
43
+
44
+ deleted_files = 0
45
+ deleted_dirs = 0
46
+
47
+ # Delete eligible files first
48
+ for file_path in root.rglob("*"):
49
+ if not file_path.is_file():
50
+ continue
51
+
52
+ try:
53
+ mtime = file_path.stat().st_mtime
54
+ if mtime <= cutoff:
55
+ if dry_run:
56
+ print(f"[DRY-RUN] file: {file_path}")
57
+ else:
58
+ file_path.unlink(missing_ok=True)
59
+ deleted_files += 1
60
+ except FileNotFoundError:
61
+ # Might have been removed by another process
62
+ continue
63
+ except PermissionError:
64
+ print(f"[WARN] Permission denied: {file_path}")
65
+
66
+ # Remove empty directories bottom-up (excluding root)
67
+ for dir_path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
68
+ try:
69
+ if any(dir_path.iterdir()):
70
+ continue
71
+ if dry_run:
72
+ print(f"[DRY-RUN] dir: {dir_path}")
73
+ else:
74
+ dir_path.rmdir()
75
+ deleted_dirs += 1
76
+ except (FileNotFoundError, PermissionError, OSError):
77
+ # OSError when directory isn't empty anymore
78
+ continue
79
+
80
+ return deleted_files, deleted_dirs
81
+
82
+
83
+ def main() -> int:
84
+ args = parse_args()
85
+ root = Path(args.path)
86
+
87
+ deleted_files, deleted_dirs = cleanup_runs(
88
+ root=root,
89
+ max_age_minutes=args.max_age_minutes,
90
+ dry_run=args.dry_run,
91
+ )
92
+
93
+ print(
94
+ f"Cleanup complete for {root} | "
95
+ f"deleted_files={deleted_files} | deleted_dirs={deleted_dirs} | "
96
+ f"max_age_minutes={args.max_age_minutes}"
97
+ )
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
scripts/start.sh ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ PORT="${PORT:-7860}"
5
+ CLEANUP_INTERVAL_SECONDS="${CLEANUP_INTERVAL_SECONDS:-1800}"
6
+ RUNS_MAX_AGE_MINUTES="${RUNS_MAX_AGE_MINUTES:-30}"
7
+
8
+ mkdir -p /app/runs/outputs
9
+
10
+ cleanup_loop() {
11
+ while true; do
12
+ python /app/scripts/cleanup_runs.py \
13
+ --path /app/runs \
14
+ --max-age-minutes "${RUNS_MAX_AGE_MINUTES}" || true
15
+ sleep "${CLEANUP_INTERVAL_SECONDS}"
16
+ done
17
+ }
18
+
19
+ cleanup_loop &
20
+
21
+ exec uvicorn app:app --host 0.0.0.0 --port "${PORT}"