AFdo commited on
Commit
a4f90fa
·
1 Parent(s): 53024ac

initial commit

Browse files
Files changed (4) hide show
  1. README.md +31 -0
  2. app.py +756 -0
  3. gait2 (5).ipynb +0 -0
  4. requirements.txt +8 -0
README.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HEMAS NeuroTrack FastAPI
2
+
3
+ This project ports the complete notebook logic from `gait2 (5).ipynb` into a FastAPI app with Swagger UI.
4
+
5
+ ## Run
6
+
7
+ ```bash
8
+ python -m venv .venv
9
+ source .venv/bin/activate
10
+ pip install -r requirements.txt
11
+ uvicorn app:app --reload
12
+ ```
13
+
14
+ Open Swagger UI:
15
+
16
+ - http://127.0.0.1:8000/docs
17
+
18
+ ## Endpoint
19
+
20
+ - `POST /analyze`
21
+ - form-data:
22
+ - `video`: video file (front-view gait)
23
+ - `patient_gender`: `male` or `female`
24
+
25
+ Response includes:
26
+
27
+ - clinical interpretation text (same wording/thresholds as notebook)
28
+ - gait score and interpretation
29
+ - full feature values
30
+ - URL to annotated output video
31
+ - URL to biomarker plot image
app.py ADDED
@@ -0,0 +1,756 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
59
+ """Applies Savitzky-Golay filter to remove MediaPipe tracking jitter."""
60
+ if len(data) < window_length:
61
+ return data
62
+ return savgol_filter(data, window_length, polyorder)
63
+
64
+
65
+ def extract_validate_and_visualize(input_video_path, output_video_path):
66
+ cap = cv2.VideoCapture(input_video_path)
67
+ fps = cap.get(cv2.CAP_PROP_FPS)
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():
86
+ ret, frame = cap.read()
87
+ if not ret:
88
+ break
89
+
90
+ image_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
91
+ image_rgb.flags.writeable = False
92
+ results = pose.process(image_rgb)
93
+ image_rgb.flags.writeable = True
94
+
95
+ image_bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)
96
+
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,
151
+ mp_pose.POSE_CONNECTIONS,
152
+ landmark_drawing_spec=mp_drawing.DrawingSpec(
153
+ color=(0, 0, 255), thickness=4, circle_radius=4
154
+ ),
155
+ connection_drawing_spec=mp_drawing.DrawingSpec(
156
+ color=(255, 255, 255), thickness=2
157
+ ),
158
+ )
159
+
160
+ out.write(image_bgr)
161
+
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
240
+
241
+ cleaned = [peaks[0]]
242
+ for p in peaks[1:]:
243
+ if (p - cleaned[-1]) / fps > min_gap:
244
+ cleaned.append(p)
245
+ return np.array(cleaned)
246
+
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
262
+
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
272
+
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
343
+ r_amp *= scale_factor
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):
552
+ if v <= 2:
553
+ return 100
554
+ elif v <= 4:
555
+ return 80
556
+ elif v <= 6:
557
+ return 60
558
+ elif v <= 8.5:
559
+ return 40
560
+ else:
561
+ return 20
562
+
563
+
564
+ def score_symmetry(s):
565
+ if s < 0.05:
566
+ return 100
567
+ elif s < 0.1:
568
+ return 80
569
+ elif s < 0.2:
570
+ return 60
571
+ elif s < 0.3:
572
+ return 40
573
+ else:
574
+ return 20
575
+
576
+
577
+ def score_cadence(c):
578
+ if 100 <= c <= 115:
579
+ return 100
580
+ elif 90 <= c < 100 or 115 < c <= 125:
581
+ return 80
582
+ elif 80 <= c < 90 or 125 < c <= 135:
583
+ return 60
584
+ else:
585
+ return 40
586
+
587
+
588
+ def score_arm_swing(a):
589
+ if a > 1.5:
590
+ return 100
591
+ elif a > 1.0:
592
+ return 80
593
+ elif a > 0.5:
594
+ return 60
595
+ elif a > 0.2:
596
+ return 40
597
+ else:
598
+ return 20
599
+
600
+
601
+ def score_arm_asymmetry(a):
602
+ if a < 10:
603
+ return 100
604
+ elif a < 20:
605
+ return 80
606
+ elif a < 40:
607
+ return 60
608
+ elif a < 60:
609
+ return 40
610
+ else:
611
+ return 20
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)
638
+
639
+
640
+ def interpret_gait_score(score):
641
+ if score >= 85:
642
+ return "🟢 Normal gait (Stable)"
643
+ elif score >= 70:
644
+ return "🟡 Mild impairment"
645
+ elif score >= 55:
646
+ return "🟠 Moderate impairment"
647
+ else:
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)
gait2 (5).ipynb ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ python-multipart>=0.0.9
4
+ mediapipe==0.10.14
5
+ opencv-python>=4.9.0
6
+ numpy>=1.26.0
7
+ matplotlib>=3.8.0
8
+ scipy>=1.12.0