-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalysis.py
More file actions
748 lines (647 loc) · 32.3 KB
/
Copy pathanalysis.py
File metadata and controls
748 lines (647 loc) · 32.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
"""Movement analysis — pure math over landmark coordinates.
Keeps a short rolling buffer of recent centroids + timestamps so it can compute
speed and smooth jitter. Two coordinate channels flow in from
:mod:`pose_estimator`, both indexed by MediaPipe's native numbering (matches the
framework diagram — no remapping):
- **pixel** ``(x, y, visibility)`` landmarks → centroid + on-screen speed (px/s).
- **world** ``(x, y, z, visibility)`` metric landmarks → joint angles. Using the
3D world coords makes the knee angle *view-invariant*, so a squat done facing
the camera is measured correctly rather than collapsing under 2D foreshortening.
"""
from __future__ import annotations
import math
from collections import deque
from typing import Deque, List, Optional, Tuple
import numpy as np
Landmark = Tuple[float, float, float]
# 3D metric landmark: (x, y, z, visibility). Defined locally rather than imported
# from pose_estimator so this module never pulls in MediaPipe.
WorldLandmark = Tuple[float, float, float, float]
# MediaPipe Pose landmark indices (native numbering).
NOSE = 0
L_SHOULDER, R_SHOULDER = 11, 12
L_ELBOW, R_ELBOW = 13, 14
L_WRIST, R_WRIST = 15, 16
L_HIP, R_HIP = 23, 24
L_KNEE, R_KNEE = 25, 26
L_ANKLE, R_ANKLE = 27, 28
# Landmarks below this visibility are treated as unreliable and skipped.
VIS_THRESHOLD = 0.5
# Squat uses an ABSOLUTE knee-angle threshold: a rep is a bend past a fixed angle,
# not a drop below a rolling baseline. CAVEAT: a single RGB camera under-estimates
# out-of-plane bend, so a deep *front-facing* squat reads ~130 deg in MediaPipe's
# monocular 3D (never below ~105) — this rule therefore fires reliably only for
# side-on views where the 3D knee angle is true. The DOWN/UP gap is the hysteresis
# that stops jitter at the edge from inflating the count.
SQUAT_KNEE_DOWN = 105.0 # both knee angles below this (deg) => squatting (down)
SQUAT_KNEE_UP = 115.0 # both back above this => neutral (rep complete)
_BASELINE_WINDOW = 90 # frames (~3 s @ 30 fps) used to track the push-up baseline
_SMOOTH_ALPHA = 0.3 # EMA factor that tames per-frame angle jitter (<1 deg)
# A rep counts only when BOTH sides (left + right knee / elbow) drop together and
# stay roughly symmetric — this rejects one-legged dips, lunges, and single-arm
# motion. The tolerance absorbs natural asymmetry and monocular angle noise.
SYMMETRY_TOL = 25.0 # max |left - right| (deg) still counted as a symmetric rep
# An exercise stays "active" (for the activity label) for this many frames after
# its last detected motion, so the label doesn't flicker to a locomotion state
# during the brief standing pause between reps of a set.
_ACTIVE_HOLD = 45 # frames (~1.5 s @ 30 fps)
# --- Activity recognition (heuristic, in the same self-calibrating style as the
# squat counter above). All thresholds are RELATIVE: angles work off a rolling
# baseline, the jumping-jack signals off their own rolling range, and locomotion
# speed is normalised by torso length — so nothing depends on absolute degrees,
# body size, or camera distance. ---
# Jumping jacks: two torso-normalised cyclic signals (arm raise, leg spread).
# Each oscillates between two extremes, so it self-calibrates from its rolling
# min/max rather than a single baseline. Open/close fractions leave a dead-zone
# in the middle for hysteresis; the min ranges reject fidgeting / arm-only motion.
_JJ_WINDOW = 90 # frames (~3 s) for the rolling min/max calibration
_JJ_SMOOTH_ALPHA = 0.3
JJ_OPEN_FRAC = 0.65 # signal >= lo + 0.65*range => "open" (arms up / legs apart)
JJ_CLOSE_FRAC = 0.35 # signal <= lo + 0.35*range => "closed"
JJ_MIN_ARM_RANGE = 0.8 # torso-lengths the arm swing must span to count as jacks
JJ_MIN_LEG_RANGE = 0.25 # torso-lengths the (per-side) ankle offset must span to count
# (the offset is a half-spread, so a real jack spans ~0.45;
# fidgets stay ~0.02, leaving a wide reject margin)
# Push-ups: gate on a roughly horizontal torso, then count elbow flexion against a
# rolling *relative* baseline (the drop below the recently-extended arm) — robust
# to monocular angle compression, unlike the squat's absolute knee threshold.
PUSHUP_TORSO_HORIZ = 55.0 # torso-vs-vertical angle (deg) above which body is horizontal
PUSHUP_ELBOW_DROP = 30.0 # elbow this far below the extended baseline => "down"
PUSHUP_ELBOW_BACK = 15.0 # back to within this of the baseline => "up" (rep)
# Locomotion: scale-invariant centroid speed in torso-lengths/second, with
# asymmetric enter/exit thresholds (hysteresis) and a short debounce so the
# Neutral/Walking/Running label doesn't oscillate at a band edge.
WALK_ENTER = 0.5 # torso-lengths/s above which Neutral -> Walking
RUN_ENTER = 2.0 # ... above which -> Running
WALK_EXIT = 0.35 # drop below to fall back to Neutral
RUN_EXIT = 1.6 # drop below to fall back to Walking
_LOCO_SMOOTH_ALPHA = 0.3
_LOCO_DEBOUNCE = 5 # consecutive frames a new band must hold before it commits
# Activity arbitration: a new winner must persist this many frames before the
# displayed label switches (kills flicker from momentary candidate changes).
ACTIVITY_DEBOUNCE = 6
# An exercise must reach this many completed reps before it can be displayed as the
# activity — so a one-off stray rep from non-exercise motion (e.g. a penalty-kick
# crouch) stays hidden. While an exercise is active but still below this count, the
# label reads "Neutral" rather than a locomotion state, so the in-place
# bounce of warm-up reps isn't mislabelled as Walking/Running (see ActivityClassifier).
MIN_REPS_TO_LABEL = 2
def _angle(a: WorldLandmark, b: WorldLandmark, c: WorldLandmark) -> Optional[float]:
"""3D angle in degrees at point ``b`` between vectors b->a and b->c."""
ba = np.array([a[0] - b[0], a[1] - b[1], a[2] - b[2]], dtype=float)
bc = np.array([c[0] - b[0], c[1] - b[1], c[2] - b[2]], dtype=float)
denom = np.linalg.norm(ba) * np.linalg.norm(bc)
if denom == 0:
return None
cos = float(np.dot(ba, bc) / denom)
cos = max(-1.0, min(1.0, cos)) # guard against FP drift outside [-1, 1]
return math.degrees(math.acos(cos))
def knee_angle(world: List[WorldLandmark], side: str = "left") -> Optional[float]:
"""Knee angle (hip->knee->ankle) in degrees from 3D world landmarks, or
``None`` if a joint is unreliable. View-invariant — see module docstring."""
if side == "left":
hip, knee, ankle = world[L_HIP], world[L_KNEE], world[L_ANKLE]
else:
hip, knee, ankle = world[R_HIP], world[R_KNEE], world[R_ANKLE]
if min(hip[3], knee[3], ankle[3]) < VIS_THRESHOLD: # index 3 = visibility
return None
return _angle(hip, knee, ankle)
def centroid(landmarks: List[Landmark]) -> Tuple[float, float]:
"""Body centroid from the two hip landmarks — a stable reference point."""
lh, rh = landmarks[L_HIP], landmarks[R_HIP]
return ((lh[0] + rh[0]) / 2.0, (lh[1] + rh[1]) / 2.0)
def shoulder_mid(landmarks: List[Landmark]) -> Tuple[float, float]:
"""Pixel midpoint of the two shoulders — the upper end of the torso line."""
ls, rs = landmarks[L_SHOULDER], landmarks[R_SHOULDER]
return ((ls[0] + rs[0]) / 2.0, (ls[1] + rs[1]) / 2.0)
def torso_length_px(landmarks: List[Landmark]) -> Optional[float]:
"""Pixel distance shoulder-mid -> hip-mid: the body-size reference that makes
the activity signals scale-invariant (raw pixels depend on camera distance).
``None`` if any of the four shoulder/hip landmarks is unreliable.
"""
if min(
landmarks[L_SHOULDER][2], landmarks[R_SHOULDER][2],
landmarks[L_HIP][2], landmarks[R_HIP][2],
) < VIS_THRESHOLD: # index 2 = visibility for pixel landmarks
return None
sx, sy = shoulder_mid(landmarks)
hx, hy = centroid(landmarks)
return math.hypot(sx - hx, sy - hy)
def elbow_angle(world: List[WorldLandmark], side: str = "left") -> Optional[float]:
"""Elbow angle (shoulder->elbow->wrist) in degrees from 3D world landmarks,
or ``None`` if a joint is unreliable. View-invariant, like ``knee_angle``."""
if side == "left":
sh, el, wr = world[L_SHOULDER], world[L_ELBOW], world[L_WRIST]
else:
sh, el, wr = world[R_SHOULDER], world[R_ELBOW], world[R_WRIST]
if min(sh[3], el[3], wr[3]) < VIS_THRESHOLD: # index 3 = visibility
return None
return _angle(sh, el, wr)
def torso_incline(world: List[WorldLandmark]) -> Optional[float]:
"""Angle (deg) between the torso line and the world vertical axis: ~0 when
standing upright, ~90 when the body is horizontal (a push-up / plank).
Uses 3D world coords so the test is view-invariant. ``None`` if any of the
four shoulder/hip landmarks is unreliable.
"""
if min(
world[L_SHOULDER][3], world[R_SHOULDER][3],
world[L_HIP][3], world[R_HIP][3],
) < VIS_THRESHOLD:
return None
sx = (world[L_SHOULDER][0] + world[R_SHOULDER][0]) / 2.0
sy = (world[L_SHOULDER][1] + world[R_SHOULDER][1]) / 2.0
sz = (world[L_SHOULDER][2] + world[R_SHOULDER][2]) / 2.0
hx = (world[L_HIP][0] + world[R_HIP][0]) / 2.0
hy = (world[L_HIP][1] + world[R_HIP][1]) / 2.0
hz = (world[L_HIP][2] + world[R_HIP][2]) / 2.0
torso = np.array([sx - hx, sy - hy, sz - hz], dtype=float)
norm = np.linalg.norm(torso)
if norm == 0:
return None
# MediaPipe world Y grows downward; the torso axis sign doesn't matter here,
# so take the absolute vertical component => angle in [0, 90].
vertical_cos = abs(float(torso[1]) / norm)
vertical_cos = max(0.0, min(1.0, vertical_cos))
return math.degrees(math.acos(vertical_cos))
class _SymmetricFlexCounter:
"""Rep machine over a LEFT/RIGHT joint-angle pair.
Each side's angle is EMA-smoothed and tracked independently. A rep is one full
down-and-up cycle. With ``require_both`` (the default) it needs **both** sides
together and within ``SYMMETRY_TOL`` of each other (rejects one-legged dips,
lunges, single-arm motion); with ``require_both=False`` it runs on whichever
side(s) are reliably visible — for a side-on push-up the far arm is occluded by
the body at the bottom of every rep, so demanding both elbows would simply
refuse to count (see :class:`PushupCounter`). When both sides *are* present the
symmetry check still applies. Two ways to decide "down" / "up", picked at
construction:
- **absolute** (``down_angle`` / ``up_angle``): down when both smoothed angles
fall below ``down_angle``, up when both rise back above ``up_angle``. A fixed
anatomical threshold — simple, but sensitive to monocular angle compression.
- **relative** (``drop`` / ``back``): down when both sides have dropped at least
``drop`` below their own rolling extended baseline (max over the last
``_BASELINE_WINDOW`` frames), up when both recover to within ``back`` of it.
Self-calibrating, so it stays correct under monocular compression and across
body sizes / camera distances.
In both cases the down/up thresholds don't touch — the gap is the hysteresis
that stops jitter from inflating the count. ``update`` returns ``None`` only
when *no* usable side is present (both missing, or — under ``require_both`` —
either missing): the machine then holds rather than guessing.
"""
def __init__(
self,
drop: Optional[float] = None,
back: Optional[float] = None,
*,
down_angle: Optional[float] = None,
up_angle: Optional[float] = None,
require_both: bool = True,
) -> None:
self._absolute = down_angle is not None
self._drop = drop
self._back = back
self._down_angle = down_angle
self._up_angle = up_angle
self._require_both = require_both
self._state = "up" # "up" = extended, "down" = both bent & symmetric
self.reps = 0
self.rep_done = False # True only on the frame a rep just completed
self.both_up = True # both sides in the "extended" band this frame
self._ema_l: Optional[float] = None
self._ema_r: Optional[float] = None
self._win_l: Deque[float] = deque(maxlen=_BASELINE_WINDOW)
self._win_r: Deque[float] = deque(maxlen=_BASELINE_WINDOW)
def _side(
self, value: float, ema: Optional[float], win: Deque[float]
) -> Tuple[float, bool, bool]:
"""EMA-smooth one side's angle and return ``(ema, is_down, is_up)``.
``is_down``/``is_up`` are this side's band membership, by absolute angle or
by drop from its own rolling baseline (relative mode). The caller stores the
returned EMA back and aggregates the flags across the visible sides.
"""
ema = value if ema is None else _SMOOTH_ALPHA * value + (1 - _SMOOTH_ALPHA) * ema
if self._absolute:
return ema, ema < self._down_angle, ema >= self._up_angle
win.append(ema)
drop = max(win) - ema
return ema, drop >= self._drop, drop <= self._back
def update(self, left: Optional[float], right: Optional[float]) -> Optional[str]:
"""Advance the machine from a left/right angle pair.
Returns the state (``"up"``/``"down"``) or ``None`` when no usable side is
present (paused — state, baseline and count are all held). Under
``require_both`` a single missing side also pauses; otherwise the machine
runs on whatever side(s) are visible.
"""
self.rep_done = False
have_l, have_r = left is not None, right is not None
if self._require_both and not (have_l and have_r):
return None # squat-style rule: needs both sides visible
if not have_l and not have_r:
return None # nothing to judge
downs, ups, emas = [], [], []
if have_l:
self._ema_l, d, u = self._side(left, self._ema_l, self._win_l)
downs.append(d); ups.append(u); emas.append(self._ema_l)
if have_r:
self._ema_r, d, u = self._side(right, self._ema_r, self._win_r)
downs.append(d); ups.append(u); emas.append(self._ema_r)
# Symmetry only constrains frames where both sides are visible; a lone
# visible side (far arm occluded in a side-on push-up) can't be asymmetric.
symmetric = len(emas) < 2 or abs(emas[0] - emas[1]) <= SYMMETRY_TOL
both_down = all(downs) and symmetric
self.both_up = all(ups)
if both_down and self._state == "up":
self._state = "down"
elif self.both_up and self._state == "down":
self._state = "up"
self.reps += 1
self.rep_done = True
return self._state
@property
def state(self) -> str:
return self._state
def reset(self) -> None:
self._state = "up"
self.reps = 0
self.rep_done = False
self.both_up = True
self._ema_l = self._ema_r = None
self._win_l.clear()
self._win_r.clear()
class RepCounter:
"""Counts squat reps from the LEFT and RIGHT knee angles together.
Wraps :class:`_SymmetricFlexCounter` in **absolute** mode (``SQUAT_KNEE_DOWN`` /
``SQUAT_KNEE_UP``): a rep needs both knees to bend below ``SQUAT_KNEE_DOWN``
*at the same time* and within ``SYMMETRY_TOL`` of each other, then straighten
back above ``SQUAT_KNEE_UP``. A one-legged dip or lunge doesn't count and both
knees must be visible. NOTE: the fixed angle means front-facing squats may not
register (monocular compression — see ``SQUAT_KNEE_DOWN``); side-on views read
the true angle and count reliably.
"""
def __init__(self) -> None:
self._machine = _SymmetricFlexCounter(
down_angle=SQUAT_KNEE_DOWN, up_angle=SQUAT_KNEE_UP
)
self._active_hold = 0 # frames left in the post-rep "active" window
def update(self, left: Optional[float], right: Optional[float]) -> str:
"""Advance the state machine and return a posture label."""
state = self._machine.update(left, right)
if state is None:
return "--" # a knee isn't reliably visible -> can't judge a squat
# "active" while bent and for a short hold after each rep (see _ACTIVE_HOLD).
if not self._machine.both_up or self._machine.rep_done:
self._active_hold = _ACTIVE_HOLD
elif self._active_hold > 0:
self._active_hold -= 1
if self._machine.both_up:
return "Neutral"
if state == "down":
return "Squatting"
return "Transition"
@property
def reps(self) -> int:
return self._machine.reps
@property
def active(self) -> bool:
"""True while squatting or briefly after a rep — used to pick the
activity label so it doesn't flip to a locomotion state mid-set."""
return self._active_hold > 0
def reset(self) -> None:
self._machine.reset()
self._active_hold = 0
class _CyclicSignal:
"""An EMA-smoothed scalar with a rolling min/max window for self-calibration.
A cyclic activity signal (arm raise, leg spread) has no single baseline — it
swings between two extremes — so the open/close thresholds are derived from
its own recent ``lo``/``hi`` and adapt to the person's range of motion.
"""
def __init__(self, window: int, alpha: float) -> None:
self._ema: Optional[float] = None
self._window: Deque[float] = deque(maxlen=window)
self._alpha = alpha
def update(self, value: float) -> float:
self._ema = (
value if self._ema is None
else self._alpha * value + (1 - self._alpha) * self._ema
)
self._window.append(self._ema)
return self._ema
@property
def lo(self) -> float:
return min(self._window)
@property
def span(self) -> float:
return max(self._window) - min(self._window)
def reset(self) -> None:
self._ema = None
self._window.clear()
class JumpingJackCounter:
"""Counts jumping jacks from two torso-normalised cyclic signals.
``arm_raise`` (wrists rising above the shoulders) and ``leg_spread`` (ankles
moving apart) must *both* swing together — one full out-and-back is a rep —
which rejects arm-only or leg-only motion. Each channel feeds the **min of
the left and right side** (the lagging limb gates it), so a lopsided pose —
one arm up, one down — never reads as "open": the same both-sides-together
rule the squat/push-up counters use. Each signal self-calibrates from its
rolling range (:class:`_CyclicSignal`); a rep only counts once both ranges
exceed a minimum, so small fidgets don't register. All four wrist/ankle
landmarks must be visible.
"""
def __init__(self) -> None:
self.reps = 0
self._state = "closed" # "closed" = arms down/legs together, "open" = up/apart
self._arm = _CyclicSignal(_JJ_WINDOW, _JJ_SMOOTH_ALPHA)
self._leg = _CyclicSignal(_JJ_WINDOW, _JJ_SMOOTH_ALPHA)
self._active_hold = 0 # frames left in the post-motion "active" window
def update(self, landmarks: List[Landmark]) -> str:
torso = torso_length_px(landmarks)
lw, rw = landmarks[L_WRIST], landmarks[R_WRIST]
la, ra = landmarks[L_ANKLE], landmarks[R_ANKLE]
if torso is None or torso == 0 or min(lw[2], rw[2], la[2], ra[2]) < VIS_THRESHOLD:
if self._active_hold > 0:
self._active_hold -= 1
return self._state # hold state when the needed joints aren't reliable
_, sy = shoulder_mid(landmarks)
hip_cx = centroid(landmarks)[0]
# Image y grows downward, so wrists ABOVE the shoulders give a positive
# raise. min() over the two sides means the lower arm gates "open", so
# one arm up / one down can't register.
arm = self._arm.update(min(sy - lw[1], sy - rw[1]) / torso)
# Each ankle's offset from the hip centre; min() requires both feet to
# move out (one leg stepping aside won't read as a spread).
leg = self._leg.update(min(abs(la[0] - hip_cx), abs(ra[0] - hip_cx)) / torso)
arm_open = self._arm.lo + JJ_OPEN_FRAC * self._arm.span
arm_close = self._arm.lo + JJ_CLOSE_FRAC * self._arm.span
leg_open = self._leg.lo + JJ_OPEN_FRAC * self._leg.span
leg_close = self._leg.lo + JJ_CLOSE_FRAC * self._leg.span
# In-range = the motion currently spans a real jumping-jack range. Refresh
# the active-hold while in range and decay it otherwise, so a brief dip in
# the rolling range between reps doesn't flip the label to locomotion (the
# same hysteresis the squat / push-up counters use — see _ACTIVE_HOLD).
in_range = (
self._arm.span >= JJ_MIN_ARM_RANGE and self._leg.span >= JJ_MIN_LEG_RANGE
)
if in_range:
self._active_hold = _ACTIVE_HOLD
elif self._active_hold > 0:
self._active_hold -= 1
if self._state == "closed":
if arm >= arm_open and leg >= leg_open:
self._state = "open"
elif arm <= arm_close and leg <= leg_close:
self._state = "closed"
if in_range:
self.reps += 1 # one full open-and-back = one rep (range gated)
return self._state
@property
def active(self) -> bool:
return self._active_hold > 0
def reset(self) -> None:
self.reps = 0
self._state = "closed"
self._arm.reset()
self._leg.reset()
self._active_hold = 0
class PushupCounter:
"""Counts push-ups: gate on a horizontal torso, then count BOTH elbows.
The gate (:func:`torso_incline` above ``PUSHUP_TORSO_HORIZ``) keeps this from
firing on standing arm motion. Reps run through the same relative-mode
:class:`_SymmetricFlexCounter` as the squat (``PUSHUP_ELBOW_DROP`` /
``PUSHUP_ELBOW_BACK``), applied to the left/right elbow angles. Unlike the
squat it is built with ``require_both=False``: a side-on push-up — the usual
filming angle — occludes the far arm behind the body at the bottom of every
rep, so demanding both elbows would refuse to count. It therefore runs on
whichever elbow(s) are visible, still enforcing ``SYMMETRY_TOL`` when both are.
The horizontal-torso gate already rules out the one-arm motions the both-sides
rule guards against for the squat. Monocular angle compression is handled
exactly as it is for the knee.
"""
def __init__(self) -> None:
self._machine = _SymmetricFlexCounter(
PUSHUP_ELBOW_DROP, PUSHUP_ELBOW_BACK, require_both=False
)
self._active_hold = 0
def update(self, world: Optional[List[WorldLandmark]]) -> str:
incline = torso_incline(world) if world is not None else None
elbow_l = elbow_angle(world, "left") if world is not None else None
elbow_r = elbow_angle(world, "right") if world is not None else None
gated = incline is not None and incline >= PUSHUP_TORSO_HORIZ
state = self._machine.update(elbow_l, elbow_r) if gated else None
if state is None:
# Not in a push-up posture (gate closed or an elbow hidden): hold.
if self._active_hold > 0:
self._active_hold -= 1
return self._machine.state
self._active_hold = _ACTIVE_HOLD
return state
@property
def reps(self) -> int:
return self._machine.reps
@property
def active(self) -> bool:
return self._active_hold > 0
def reset(self) -> None:
self._machine.reset()
self._active_hold = 0
class LocomotionClassifier:
"""Labels Neutral / Walking / Running from scale-invariant centroid speed.
Input is speed in torso-lengths/second (camera-distance invariant). A 3-state
machine with asymmetric enter/exit thresholds (hysteresis) plus a short
debounce keeps the label steady at a band edge. A person moving in place
(treadmill) has ~0 centroid speed and reads "Neutral" — a known limitation.
"""
def __init__(self) -> None:
self._ema: Optional[float] = None
self._label = "Neutral"
self._pending: Optional[str] = None
self._pending_count = 0
def update(self, speed_bl_s: Optional[float]) -> str:
if speed_bl_s is None:
return self._label # hold the last label when speed is unavailable
self._ema = (
speed_bl_s if self._ema is None
else _LOCO_SMOOTH_ALPHA * speed_bl_s + (1 - _LOCO_SMOOTH_ALPHA) * self._ema
)
target = self._band(self._ema)
if target == self._label:
self._pending, self._pending_count = None, 0
else:
if target == self._pending:
self._pending_count += 1
else:
self._pending, self._pending_count = target, 1
if self._pending_count >= _LOCO_DEBOUNCE:
self._label = target
self._pending, self._pending_count = None, 0
return self._label
def _band(self, s: float) -> str:
"""The band ``s`` maps to, with thresholds that depend on the current
label so leaving a band needs a bigger move than entering it."""
if self._label == "Neutral":
if s > WALK_ENTER:
return "Running" if s > RUN_ENTER else "Walking"
return "Neutral"
if self._label == "Walking":
if s > RUN_ENTER:
return "Running"
return "Neutral" if s < WALK_EXIT else "Walking"
# currently Running
if s < RUN_EXIT:
return "Neutral" if s < WALK_EXIT else "Walking"
return "Running"
def reset(self) -> None:
self._ema = None
self._label = "Neutral"
self._pending, self._pending_count = None, 0
class ActivityClassifier:
"""Arbitrates the per-frame metrics into ONE activity label + its rep count.
Priority runs from the most distinctive gate to the least: push-ups (horizontal
torso) -> jumping jacks (coordinated arm+leg cycle) -> squat -> locomotion
(the residual Neutral/Walking/Running). An exercise only counts as a candidate
once it has **completed at least ``MIN_REPS_TO_LABEL`` reps**, so a one-off stray
rep from non-exercise motion (e.g. a penalty kick) doesn't get labelled. While an
exercise *is* active but still short of that count, the label reads
``"Neutral"`` instead of falling through to locomotion — so the in-place bounce
of warm-up reps isn't mislabelled as Walking/Running. A sticky debounce means a
new winner must persist ``ACTIVITY_DEBOUNCE`` frames before the displayed label
switches. The three exercise counters all run every frame, so switching the label
never loses an underlying count (``activity_reps`` reflects the displayed label).
"""
def __init__(self) -> None:
self._label = "--"
self._pending: Optional[str] = None
self._pending_count = 0
def choose(
self,
squat: RepCounter,
jj: JumpingJackCounter,
pushup: PushupCounter,
loco_label: str,
) -> Tuple[str, int]:
# An exercise must be active AND have logged at least MIN_REPS_TO_LABEL reps
# to claim the label (one stray rep from a non-exercise motion never shows).
if pushup.active and pushup.reps >= MIN_REPS_TO_LABEL:
candidate = "Push-ups"
elif jj.active and jj.reps >= MIN_REPS_TO_LABEL:
candidate = "Jumping Jacks"
elif squat.active and squat.reps >= MIN_REPS_TO_LABEL:
candidate = "Squat"
elif pushup.active or jj.active or squat.active:
# An exercise is in progress but hasn't reached the rep threshold yet:
# show "Neutral" rather than let the in-place bounce read as
# Walking/Running, while still withholding the exercise label.
candidate = "Neutral"
else:
candidate = loco_label
if candidate == self._label:
self._pending, self._pending_count = None, 0
else:
if candidate == self._pending:
self._pending_count += 1
else:
self._pending, self._pending_count = candidate, 1
if self._pending_count >= ACTIVITY_DEBOUNCE:
self._label = candidate
self._pending, self._pending_count = None, 0
return self._label, self._reps_for(self._label, squat, jj, pushup)
@staticmethod
def _reps_for(
label: str, squat: RepCounter, jj: JumpingJackCounter, pushup: PushupCounter
) -> int:
if label == "Squat":
return squat.reps
if label == "Jumping Jacks":
return jj.reps
if label == "Push-ups":
return pushup.reps
return 0 # locomotion states have no rep count
def reset(self) -> None:
self._label = "--"
self._pending, self._pending_count = None, 0
class MovementAnalyzer:
"""Holds a rolling buffer and produces per-frame movement metrics."""
def __init__(self, buffer_size: int = 8):
self._hist: Deque[Tuple[Tuple[float, float], float]] = deque(maxlen=buffer_size)
self._reps = RepCounter() # squat reps + posture
self._jj = JumpingJackCounter()
self._pushup = PushupCounter()
self._loco = LocomotionClassifier()
self._activity = ActivityClassifier()
self._torso_ema: Optional[float] = None # smoothed body-size reference for speed
def update(
self,
landmarks: List[Landmark],
world: Optional[List[WorldLandmark]],
timestamp: float,
) -> dict:
"""Record this frame and return the current metrics dict.
``landmarks`` are pixel-space (centroid/speed); ``world`` are the 3D
metric landmarks used for the view-invariant knee angle (may be ``None``
if the detector did not produce them this frame).
"""
self._hist.append((centroid(landmarks), timestamp))
if world is not None:
angle_l = knee_angle(world, "left")
angle_r = knee_angle(world, "right")
elbow_l = elbow_angle(world, "left")
elbow_r = elbow_angle(world, "right")
posture = self._reps.update(angle_l, angle_r)
else:
angle_l = angle_r = elbow_l = elbow_r = None
posture = self._reps.update(None, None)
# Exercise detectors run every frame so switching the displayed activity
# never loses an underlying count.
self._jj.update(landmarks)
self._pushup.update(world)
# Scale-invariant speed: px/s divided by a smoothed torso length, giving
# torso-lengths/second (independent of camera distance). Per-frame torso
# jitter would otherwise add noise, so the reference is EMA-smoothed.
speed_px = self._speed()
torso = torso_length_px(landmarks)
if torso is not None:
self._torso_ema = (
torso if self._torso_ema is None
else _LOCO_SMOOTH_ALPHA * torso + (1 - _LOCO_SMOOTH_ALPHA) * self._torso_ema
)
speed_bl = (
speed_px / self._torso_ema
if speed_px is not None and self._torso_ema not in (None, 0)
else None
)
loco_label = self._loco.update(speed_bl)
activity, activity_reps = self._activity.choose(
self._reps, self._jj, self._pushup, loco_label
)
return {
"knee_angle_left": angle_l,
"knee_angle_right": angle_r,
"elbow_angle_left": elbow_l,
"elbow_angle_right": elbow_r,
"speed_px_s": speed_px,
"speed_bl_s": speed_bl,
"reps": self._reps.reps,
"posture": posture,
"activity": activity,
"activity_reps": activity_reps,
}
def _speed(self) -> Optional[float]:
"""Smoothed centroid speed in pixels/second across the buffer window.
Reported in px/s; converting to m/s requires camera calibration.
Using the first and last buffered samples averages out
per-frame jitter.
"""
if len(self._hist) < 2:
return None
(p0, t0), (p1, t1) = self._hist[0], self._hist[-1]
dt = t1 - t0
if dt <= 0:
return None
return math.hypot(p1[0] - p0[0], p1[1] - p0[1]) / dt
def reset(self) -> None:
"""Clear the buffer and all detector state (e.g. when the person leaves frame)."""
self._hist.clear()
self._reps.reset()
self._jj.reset()
self._pushup.reset()
self._loco.reset()
self._activity.reset()
self._torso_ema = None