kinemotion 0.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kinemotion might be problematic. Click here for more details.

@@ -0,0 +1,639 @@
1
+ """Ground contact detection logic for drop-jump analysis."""
2
+
3
+ from enum import Enum
4
+
5
+ import numpy as np
6
+
7
+ from ..core.smoothing import (
8
+ compute_acceleration_from_derivative,
9
+ compute_velocity_from_derivative,
10
+ )
11
+
12
+
13
+ class ContactState(Enum):
14
+ """States for foot-ground contact."""
15
+
16
+ IN_AIR = "in_air"
17
+ ON_GROUND = "on_ground"
18
+ UNKNOWN = "unknown"
19
+
20
+
21
+ def calculate_adaptive_threshold(
22
+ positions: np.ndarray,
23
+ fps: float,
24
+ baseline_duration: float = 3.0,
25
+ multiplier: float = 1.5,
26
+ smoothing_window: int = 5,
27
+ polyorder: int = 2,
28
+ ) -> float:
29
+ """
30
+ Calculate adaptive velocity threshold based on baseline motion characteristics.
31
+
32
+ Analyzes the first few seconds of video (assumed to be relatively stationary,
33
+ e.g., athlete standing on box) to determine the noise floor, then sets threshold
34
+ as a multiple of this baseline noise.
35
+
36
+ This adapts to:
37
+ - Different camera distances (closer = more pixel movement)
38
+ - Different lighting conditions (affects tracking quality)
39
+ - Different frame rates (higher fps = smoother motion)
40
+ - Video compression artifacts
41
+
42
+ Args:
43
+ positions: Array of vertical positions (0-1 normalized)
44
+ fps: Video frame rate
45
+ baseline_duration: Duration in seconds to analyze for baseline (default: 3.0s)
46
+ multiplier: Factor above baseline noise to set threshold (default: 1.5x)
47
+ smoothing_window: Window size for velocity computation
48
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
49
+
50
+ Returns:
51
+ Adaptive velocity threshold value
52
+
53
+ Example:
54
+ At 30fps with 3s baseline:
55
+ - Analyzes first 90 frames
56
+ - Computes velocity for this "stationary" period
57
+ - 95th percentile velocity = 0.012 (noise level)
58
+ - Threshold = 0.012 × 1.5 = 0.018
59
+ """
60
+ if len(positions) < 2:
61
+ return 0.02 # Fallback to default
62
+
63
+ # Calculate number of frames for baseline analysis
64
+ baseline_frames = int(fps * baseline_duration)
65
+ baseline_frames = min(baseline_frames, len(positions))
66
+
67
+ if baseline_frames < smoothing_window:
68
+ return 0.02 # Not enough data, use default
69
+
70
+ # Extract baseline period (assumed relatively stationary)
71
+ baseline_positions = positions[:baseline_frames]
72
+
73
+ # Compute velocity for baseline period using derivative
74
+ baseline_velocities = compute_velocity_from_derivative(
75
+ baseline_positions, window_length=smoothing_window, polyorder=polyorder
76
+ )
77
+
78
+ # Calculate noise floor as 95th percentile of baseline velocities
79
+ # Using 95th percentile instead of max to be robust against outliers
80
+ noise_floor = float(np.percentile(np.abs(baseline_velocities), 95))
81
+
82
+ # Set threshold as multiplier of noise floor
83
+ # Minimum threshold to avoid being too sensitive
84
+ adaptive_threshold = max(noise_floor * multiplier, 0.005)
85
+
86
+ # Maximum threshold to ensure we still detect contact
87
+ adaptive_threshold = min(adaptive_threshold, 0.05)
88
+
89
+ return adaptive_threshold
90
+
91
+
92
+ def detect_drop_start(
93
+ positions: np.ndarray,
94
+ fps: float,
95
+ min_stationary_duration: float = 1.0,
96
+ position_change_threshold: float = 0.02,
97
+ smoothing_window: int = 5,
98
+ debug: bool = False,
99
+ ) -> int:
100
+ """
101
+ Detect when the drop jump actually starts by finding stable period then detecting drop.
102
+
103
+ Strategy:
104
+ 1. Scan forward to find first STABLE period (low variance over N frames)
105
+ 2. Use that stable period as baseline
106
+ 3. Detect when position starts changing significantly from baseline
107
+
108
+ This handles videos where athlete steps onto box at start (unstable beginning).
109
+
110
+ Args:
111
+ positions: Array of vertical positions (0-1 normalized, y increases downward)
112
+ fps: Video frame rate
113
+ min_stationary_duration: Minimum duration (seconds) of stable period (default: 1.0s)
114
+ position_change_threshold: Position change indicating start of drop
115
+ (default: 0.02 = 2% of frame)
116
+ smoothing_window: Window for computing position variance
117
+ debug: Print debug information (default: False)
118
+
119
+ Returns:
120
+ Frame index where drop starts (or 0 if no clear stable period found)
121
+
122
+ Example:
123
+ - Frames 0-14: Stepping onto box (noisy, unstable)
124
+ - Frames 15-119: Standing on box (stable, low variance)
125
+ - Frame 119: Drop begins (position changes significantly)
126
+ - Returns: 119
127
+ """
128
+ min_stable_frames = int(fps * min_stationary_duration)
129
+ if len(positions) < min_stable_frames + 30: # Need some frames after stable period
130
+ if debug:
131
+ min_frames_needed = min_stable_frames + 30
132
+ print(
133
+ f"[detect_drop_start] Video too short: {len(positions)} < {min_frames_needed}"
134
+ )
135
+ return 0
136
+
137
+ # STEP 1: Find first stable period by scanning forward
138
+ # Look for window with low variance (< 1% of frame height)
139
+ stability_threshold = 0.01 # 1% of frame height
140
+ stable_window = min_stable_frames
141
+
142
+ baseline_start = -1
143
+ baseline_position = 0.0
144
+
145
+ # Scan from start, looking for stable window
146
+ for start_idx in range(0, len(positions) - stable_window, 5): # Step by 5 frames
147
+ window = positions[start_idx : start_idx + stable_window]
148
+ window_std = float(np.std(window))
149
+
150
+ if window_std < stability_threshold:
151
+ # Found stable period!
152
+ baseline_start = start_idx
153
+ baseline_position = float(np.median(window))
154
+
155
+ if debug:
156
+ end_frame = baseline_start + stable_window - 1
157
+ print("[detect_drop_start] Found stable period:")
158
+ print(f" frames {baseline_start}-{end_frame}")
159
+ print(f" baseline_position: {baseline_position:.4f}")
160
+ print(f" baseline_std: {window_std:.4f} < {stability_threshold:.4f}")
161
+ break
162
+
163
+ if baseline_start < 0:
164
+ if debug:
165
+ msg = (
166
+ f"No stable period found (variance always > {stability_threshold:.4f})"
167
+ )
168
+ print(f"[detect_drop_start] {msg}")
169
+ return 0
170
+
171
+ # STEP 2: Find when position changes significantly from baseline
172
+ # Start searching after stable period ends
173
+ search_start = baseline_start + stable_window
174
+ window_size = max(3, smoothing_window)
175
+
176
+ for i in range(search_start, len(positions) - window_size):
177
+ # Average position over small window to reduce noise
178
+ window_positions = positions[i : i + window_size]
179
+ avg_position = float(np.mean(window_positions))
180
+
181
+ # Check if position has increased (dropped) significantly
182
+ position_change = avg_position - baseline_position
183
+
184
+ if position_change > position_change_threshold:
185
+ # Found start of drop - back up slightly to catch beginning
186
+ drop_frame_candidate = i - window_size
187
+ if drop_frame_candidate < baseline_start:
188
+ drop_frame = baseline_start
189
+ else:
190
+ drop_frame = drop_frame_candidate
191
+
192
+ if debug:
193
+ print(f"[detect_drop_start] Drop detected at frame {drop_frame}")
194
+ print(
195
+ f" position_change: {position_change:.4f} > {position_change_threshold:.4f}"
196
+ )
197
+ print(
198
+ f" avg_position: {avg_position:.4f} vs baseline: {baseline_position:.4f}"
199
+ )
200
+
201
+ return drop_frame
202
+
203
+ # No significant position change detected
204
+ if debug:
205
+ print("[detect_drop_start] No drop detected after stable period")
206
+ return 0
207
+
208
+
209
+ def detect_ground_contact(
210
+ foot_positions: np.ndarray,
211
+ velocity_threshold: float = 0.02,
212
+ min_contact_frames: int = 3,
213
+ visibility_threshold: float = 0.5,
214
+ visibilities: np.ndarray | None = None,
215
+ window_length: int = 5,
216
+ polyorder: int = 2,
217
+ ) -> list[ContactState]:
218
+ """
219
+ Detect when feet are in contact with ground based on vertical motion.
220
+
221
+ Uses derivative-based velocity calculation via Savitzky-Golay filter for smooth,
222
+ accurate velocity estimates. This is consistent with the velocity calculation used
223
+ throughout the pipeline for sub-frame interpolation and curvature analysis.
224
+
225
+ Args:
226
+ foot_positions: Array of foot y-positions (normalized, 0-1, where 1 is bottom)
227
+ velocity_threshold: Threshold for vertical velocity to consider stationary
228
+ min_contact_frames: Minimum consecutive frames to confirm contact
229
+ visibility_threshold: Minimum visibility score to trust landmark
230
+ visibilities: Array of visibility scores for each frame
231
+ window_length: Window size for velocity derivative calculation (must be odd)
232
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
233
+
234
+ Returns:
235
+ List of ContactState for each frame
236
+ """
237
+ n_frames = len(foot_positions)
238
+ states = [ContactState.UNKNOWN] * n_frames
239
+
240
+ if n_frames < 2:
241
+ return states
242
+
243
+ # Compute vertical velocity using derivative-based method
244
+ # This provides smoother, more accurate velocity estimates than frame-to-frame differences
245
+ # and is consistent with the velocity calculation used for sub-frame interpolation
246
+ velocities = compute_velocity_from_derivative(
247
+ foot_positions, window_length=window_length, polyorder=polyorder
248
+ )
249
+
250
+ # Detect potential contact frames based on low velocity
251
+ is_stationary = np.abs(velocities) < velocity_threshold
252
+
253
+ # Apply visibility filter
254
+ if visibilities is not None:
255
+ is_visible = visibilities > visibility_threshold
256
+ is_stationary = is_stationary & is_visible
257
+
258
+ # Apply minimum contact duration filter
259
+ contact_frames = []
260
+ current_run = []
261
+
262
+ for i, stationary in enumerate(is_stationary):
263
+ if stationary:
264
+ current_run.append(i)
265
+ else:
266
+ if len(current_run) >= min_contact_frames:
267
+ contact_frames.extend(current_run)
268
+ current_run = []
269
+
270
+ # Don't forget the last run
271
+ if len(current_run) >= min_contact_frames:
272
+ contact_frames.extend(current_run)
273
+
274
+ # Set states
275
+ for i in range(n_frames):
276
+ if visibilities is not None and visibilities[i] < visibility_threshold:
277
+ states[i] = ContactState.UNKNOWN
278
+ elif i in contact_frames:
279
+ states[i] = ContactState.ON_GROUND
280
+ else:
281
+ states[i] = ContactState.IN_AIR
282
+
283
+ return states
284
+
285
+
286
+ def find_contact_phases(
287
+ contact_states: list[ContactState],
288
+ ) -> list[tuple[int, int, ContactState]]:
289
+ """
290
+ Identify continuous phases of contact/flight.
291
+
292
+ Args:
293
+ contact_states: List of ContactState for each frame
294
+
295
+ Returns:
296
+ List of (start_frame, end_frame, state) tuples for each phase
297
+ """
298
+ phases: list[tuple[int, int, ContactState]] = []
299
+ if not contact_states:
300
+ return phases
301
+
302
+ current_state = contact_states[0]
303
+ phase_start = 0
304
+
305
+ for i in range(1, len(contact_states)):
306
+ if contact_states[i] != current_state:
307
+ # Phase transition
308
+ phases.append((phase_start, i - 1, current_state))
309
+ current_state = contact_states[i]
310
+ phase_start = i
311
+
312
+ # Don't forget the last phase
313
+ phases.append((phase_start, len(contact_states) - 1, current_state))
314
+
315
+ return phases
316
+
317
+
318
+ def interpolate_threshold_crossing(
319
+ vel_before: float,
320
+ vel_after: float,
321
+ velocity_threshold: float,
322
+ ) -> float:
323
+ """
324
+ Find fractional offset where velocity crosses threshold between two frames.
325
+
326
+ Uses linear interpolation assuming velocity changes linearly between frames.
327
+
328
+ Args:
329
+ vel_before: Velocity at frame boundary N (absolute value)
330
+ vel_after: Velocity at frame boundary N+1 (absolute value)
331
+ velocity_threshold: Threshold value
332
+
333
+ Returns:
334
+ Fractional offset from frame N (0.0 to 1.0)
335
+ """
336
+ # Handle edge cases
337
+ if abs(vel_after - vel_before) < 1e-9: # Velocity not changing
338
+ return 0.5
339
+
340
+ # Linear interpolation: at what fraction t does velocity equal threshold?
341
+ # vel(t) = vel_before + t * (vel_after - vel_before)
342
+ # Solve for t when vel(t) = threshold:
343
+ # threshold = vel_before + t * (vel_after - vel_before)
344
+ # t = (threshold - vel_before) / (vel_after - vel_before)
345
+
346
+ t = (velocity_threshold - vel_before) / (vel_after - vel_before)
347
+
348
+ # Clamp to [0, 1] range
349
+ return float(max(0.0, min(1.0, t)))
350
+
351
+
352
+ def find_interpolated_phase_transitions(
353
+ foot_positions: np.ndarray,
354
+ contact_states: list[ContactState],
355
+ velocity_threshold: float,
356
+ smoothing_window: int = 5,
357
+ ) -> list[tuple[float, float, ContactState]]:
358
+ """
359
+ Find contact phases with sub-frame interpolation for precise timing.
360
+
361
+ Uses derivative-based velocity from smoothed trajectory for interpolation.
362
+ This provides much smoother velocity estimates than frame-to-frame differences,
363
+ leading to more accurate threshold crossing detection.
364
+
365
+ Args:
366
+ foot_positions: Array of foot y-positions (normalized, 0-1)
367
+ contact_states: List of ContactState for each frame
368
+ velocity_threshold: Threshold used for contact detection
369
+ smoothing_window: Window size for velocity smoothing (must be odd)
370
+
371
+ Returns:
372
+ List of (start_frame, end_frame, state) tuples with fractional frame indices
373
+ """
374
+ # First get integer frame phases
375
+ phases = find_contact_phases(contact_states)
376
+ if not phases or len(foot_positions) < 2:
377
+ return []
378
+
379
+ # Compute velocities from derivative of smoothed trajectory
380
+ # This gives much smoother velocity estimates than simple frame differences
381
+ velocities = compute_velocity_from_derivative(
382
+ foot_positions, window_length=smoothing_window, polyorder=2
383
+ )
384
+
385
+ interpolated_phases: list[tuple[float, float, ContactState]] = []
386
+
387
+ for start_idx, end_idx, state in phases:
388
+ start_frac = float(start_idx)
389
+ end_frac = float(end_idx)
390
+
391
+ # Interpolate start boundary (transition INTO this phase)
392
+ if start_idx > 0 and start_idx < len(velocities):
393
+ vel_before = (
394
+ velocities[start_idx - 1] if start_idx > 0 else velocities[start_idx]
395
+ )
396
+ vel_at = velocities[start_idx]
397
+
398
+ # Check if we're crossing the threshold at this boundary
399
+ if state == ContactState.ON_GROUND:
400
+ # Transition air→ground: velocity dropping below threshold
401
+ if vel_before > velocity_threshold > vel_at:
402
+ # Interpolate between start_idx-1 and start_idx
403
+ offset = interpolate_threshold_crossing(
404
+ vel_before, vel_at, velocity_threshold
405
+ )
406
+ start_frac = (start_idx - 1) + offset
407
+ elif state == ContactState.IN_AIR:
408
+ # Transition ground→air: velocity rising above threshold
409
+ if vel_before < velocity_threshold < vel_at:
410
+ # Interpolate between start_idx-1 and start_idx
411
+ offset = interpolate_threshold_crossing(
412
+ vel_before, vel_at, velocity_threshold
413
+ )
414
+ start_frac = (start_idx - 1) + offset
415
+
416
+ # Interpolate end boundary (transition OUT OF this phase)
417
+ if end_idx < len(foot_positions) - 1 and end_idx + 1 < len(velocities):
418
+ vel_at = velocities[end_idx]
419
+ vel_after = velocities[end_idx + 1]
420
+
421
+ # Check if we're crossing the threshold at this boundary
422
+ if state == ContactState.ON_GROUND:
423
+ # Transition ground→air: velocity rising above threshold
424
+ if vel_at < velocity_threshold < vel_after:
425
+ # Interpolate between end_idx and end_idx+1
426
+ offset = interpolate_threshold_crossing(
427
+ vel_at, vel_after, velocity_threshold
428
+ )
429
+ end_frac = end_idx + offset
430
+ elif state == ContactState.IN_AIR:
431
+ # Transition air→ground: velocity dropping below threshold
432
+ if vel_at > velocity_threshold > vel_after:
433
+ # Interpolate between end_idx and end_idx+1
434
+ offset = interpolate_threshold_crossing(
435
+ vel_at, vel_after, velocity_threshold
436
+ )
437
+ end_frac = end_idx + offset
438
+
439
+ interpolated_phases.append((start_frac, end_frac, state))
440
+
441
+ return interpolated_phases
442
+
443
+
444
+ def refine_transition_with_curvature(
445
+ foot_positions: np.ndarray,
446
+ estimated_frame: float,
447
+ transition_type: str,
448
+ search_window: int = 3,
449
+ smoothing_window: int = 5,
450
+ polyorder: int = 2,
451
+ ) -> float:
452
+ """
453
+ Refine phase transition timing using trajectory curvature analysis.
454
+
455
+ Looks for characteristic acceleration patterns near estimated transition:
456
+ - Landing: Large acceleration spike (rapid deceleration on impact)
457
+ - Takeoff: Acceleration change (transition from static to upward motion)
458
+
459
+ Args:
460
+ foot_positions: Array of foot y-positions (normalized, 0-1)
461
+ estimated_frame: Initial estimate of transition frame (from velocity)
462
+ transition_type: Type of transition ("landing" or "takeoff")
463
+ search_window: Number of frames to search around estimate
464
+ smoothing_window: Window size for acceleration computation
465
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
466
+
467
+ Returns:
468
+ Refined fractional frame index
469
+ """
470
+ if len(foot_positions) < smoothing_window:
471
+ return estimated_frame
472
+
473
+ # Compute acceleration (second derivative)
474
+ acceleration = compute_acceleration_from_derivative(
475
+ foot_positions, window_length=smoothing_window, polyorder=polyorder
476
+ )
477
+
478
+ # Define search range around estimated transition
479
+ est_int = int(estimated_frame)
480
+ search_start = max(0, est_int - search_window)
481
+ search_end = min(len(acceleration), est_int + search_window + 1)
482
+
483
+ if search_end <= search_start:
484
+ return estimated_frame
485
+
486
+ # Extract acceleration in search window
487
+ accel_window = acceleration[search_start:search_end]
488
+
489
+ if len(accel_window) == 0:
490
+ return estimated_frame
491
+
492
+ if transition_type == "landing":
493
+ # Landing: Look for large magnitude acceleration (impact deceleration)
494
+ # Find frame with maximum absolute acceleration
495
+ peak_idx = np.argmax(np.abs(accel_window))
496
+ refined_frame = float(search_start + peak_idx)
497
+
498
+ elif transition_type == "takeoff":
499
+ # Takeoff: Look for acceleration magnitude change
500
+ # Find frame with large acceleration change (derivative of acceleration)
501
+ if len(accel_window) < 2:
502
+ return estimated_frame
503
+
504
+ accel_diff = np.abs(np.diff(accel_window))
505
+ peak_idx = np.argmax(accel_diff)
506
+ refined_frame = float(search_start + peak_idx)
507
+
508
+ else:
509
+ return estimated_frame
510
+
511
+ # Blend with original estimate (don't stray too far)
512
+ # 70% curvature-based, 30% velocity-based
513
+ blend_factor = 0.7
514
+ refined_frame = blend_factor * refined_frame + (1 - blend_factor) * estimated_frame
515
+
516
+ return refined_frame
517
+
518
+
519
+ def find_interpolated_phase_transitions_with_curvature(
520
+ foot_positions: np.ndarray,
521
+ contact_states: list[ContactState],
522
+ velocity_threshold: float,
523
+ smoothing_window: int = 5,
524
+ polyorder: int = 2,
525
+ use_curvature: bool = True,
526
+ ) -> list[tuple[float, float, ContactState]]:
527
+ """
528
+ Find contact phases with sub-frame interpolation and curvature refinement.
529
+
530
+ Combines three methods for maximum accuracy:
531
+ 1. Velocity thresholding (coarse integer frame detection)
532
+ 2. Velocity interpolation (sub-frame precision)
533
+ 3. Curvature analysis (refinement based on acceleration patterns)
534
+
535
+ Args:
536
+ foot_positions: Array of foot y-positions (normalized, 0-1)
537
+ contact_states: List of ContactState for each frame
538
+ velocity_threshold: Threshold used for contact detection
539
+ smoothing_window: Window size for velocity/acceleration smoothing
540
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
541
+ use_curvature: Whether to apply curvature-based refinement
542
+
543
+ Returns:
544
+ List of (start_frame, end_frame, state) tuples with fractional frame indices
545
+ """
546
+ # Get interpolated phases using velocity
547
+ interpolated_phases = find_interpolated_phase_transitions(
548
+ foot_positions, contact_states, velocity_threshold, smoothing_window
549
+ )
550
+
551
+ if not use_curvature or len(interpolated_phases) == 0:
552
+ return interpolated_phases
553
+
554
+ # Refine phase boundaries using curvature analysis
555
+ refined_phases: list[tuple[float, float, ContactState]] = []
556
+
557
+ for start_frac, end_frac, state in interpolated_phases:
558
+ refined_start = start_frac
559
+ refined_end = end_frac
560
+
561
+ if state == ContactState.ON_GROUND:
562
+ # Refine landing (start of ground contact)
563
+ refined_start = refine_transition_with_curvature(
564
+ foot_positions,
565
+ start_frac,
566
+ "landing",
567
+ search_window=3,
568
+ smoothing_window=smoothing_window,
569
+ polyorder=polyorder,
570
+ )
571
+ # Refine takeoff (end of ground contact)
572
+ refined_end = refine_transition_with_curvature(
573
+ foot_positions,
574
+ end_frac,
575
+ "takeoff",
576
+ search_window=3,
577
+ smoothing_window=smoothing_window,
578
+ polyorder=polyorder,
579
+ )
580
+
581
+ elif state == ContactState.IN_AIR:
582
+ # For flight phases, takeoff is at start, landing is at end
583
+ refined_start = refine_transition_with_curvature(
584
+ foot_positions,
585
+ start_frac,
586
+ "takeoff",
587
+ search_window=3,
588
+ smoothing_window=smoothing_window,
589
+ polyorder=polyorder,
590
+ )
591
+ refined_end = refine_transition_with_curvature(
592
+ foot_positions,
593
+ end_frac,
594
+ "landing",
595
+ search_window=3,
596
+ smoothing_window=smoothing_window,
597
+ polyorder=polyorder,
598
+ )
599
+
600
+ refined_phases.append((refined_start, refined_end, state))
601
+
602
+ return refined_phases
603
+
604
+
605
+ def compute_average_foot_position(
606
+ landmarks: dict[str, tuple[float, float, float]],
607
+ ) -> tuple[float, float]:
608
+ """
609
+ Compute average foot position from ankle and foot landmarks.
610
+
611
+ Args:
612
+ landmarks: Dictionary of landmark positions
613
+
614
+ Returns:
615
+ (x, y) average foot position in normalized coordinates
616
+ """
617
+ foot_keys = [
618
+ "left_ankle",
619
+ "right_ankle",
620
+ "left_heel",
621
+ "right_heel",
622
+ "left_foot_index",
623
+ "right_foot_index",
624
+ ]
625
+
626
+ x_positions = []
627
+ y_positions = []
628
+
629
+ for key in foot_keys:
630
+ if key in landmarks:
631
+ x, y, visibility = landmarks[key]
632
+ if visibility > 0.5: # Only use visible landmarks
633
+ x_positions.append(x)
634
+ y_positions.append(y)
635
+
636
+ if not x_positions:
637
+ return (0.5, 0.5) # Default to center if no visible feet
638
+
639
+ return (float(np.mean(x_positions)), float(np.mean(y_positions)))