react-native-nitro-pose-exercises 1.0.9 → 1.1.2
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.
|
@@ -19,13 +19,14 @@ Pod::Spec.new do |s|
|
|
|
19
19
|
"cpp/**/*.{hpp,cpp}",
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
+
s.frameworks = ["AVFoundation", "Vision"]
|
|
23
|
+
|
|
22
24
|
s.dependency 'React-jsi'
|
|
23
25
|
s.dependency 'React-callinvoker'
|
|
24
26
|
|
|
25
27
|
load 'nitrogen/generated/ios/NitroPoseExercises+autolinking.rb'
|
|
26
28
|
add_nitrogen_files(s)
|
|
27
29
|
|
|
28
|
-
s.dependency 'MediaPipeTasksVision', '~> 0.10.0'
|
|
29
30
|
s.dependency "VisionCamera"
|
|
30
31
|
|
|
31
32
|
install_modules_dependencies(s)
|
package/README.md
CHANGED
|
@@ -6,21 +6,22 @@
|
|
|
6
6
|
|
|
7
7
|
# react-native-nitro-pose-exercises
|
|
8
8
|
|
|
9
|
-
A **React Native Nitro Module** for real-time, on-device exercise tracking using pose estimation.
|
|
9
|
+
A **React Native Nitro Module** for real-time, on-device exercise tracking using pose estimation. Uses **OS-native pose detection** — Apple Vision on iOS and Google ML Kit on Android — with **VisionCamera v5**.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
* 🏋️ **Rep Counting** — Automatic rep detection with configurable state machines
|
|
12
|
+
* 🧘 **Hold Tracking** — Duration and stability tracking for planks, yoga poses, and isometric holds
|
|
13
|
+
* 📐 **Form Validation** — Real-time form feedback with configurable angle-based rules
|
|
14
|
+
* 💀 **Skeleton Overlay** — Optional Skia-powered skeleton rendering over the camera feed
|
|
15
|
+
* ⚡ **Fully Native** — OS-level pose detection via Nitro Modules, zero JS bridge overhead
|
|
16
|
+
* 📦 **Zero Model Bundling** — No ML model files to download or ship with your app
|
|
16
17
|
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
> [!IMPORTANT]
|
|
20
21
|
>
|
|
21
|
-
>
|
|
22
|
-
>
|
|
23
|
-
>
|
|
22
|
+
> * Requires React Native **0.76+** with Nitro Modules and VisionCamera **v5**.
|
|
23
|
+
> * Must be tested on a **physical device** — camera + ML inference don't work on simulators.
|
|
24
|
+
> * iOS requires **iOS 14+** (Vision body pose API). Android requires **API 23+** (ML Kit).
|
|
24
25
|
|
|
25
26
|
---
|
|
26
27
|
|
|
@@ -44,9 +45,10 @@ cd ios && pod install
|
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
> [!NOTE]
|
|
47
|
-
> This package uses **
|
|
48
|
-
> iOS uses `
|
|
49
|
-
> Android uses `com.google.
|
|
48
|
+
> This package uses **OS-native pose detection** on both platforms.
|
|
49
|
+
> iOS uses Apple's **Vision framework** (`VNDetectHumanBodyPoseRequest`) — built into iOS, no extra dependencies.
|
|
50
|
+
> Android uses **Google ML Kit Pose Detection** (`com.google.mlkit:pose-detection:18.0.0-beta5`) — model auto-managed via Play Services.
|
|
51
|
+
> **No model files to bundle, no extra downloads, no color format conversions.**
|
|
50
52
|
|
|
51
53
|
---
|
|
52
54
|
|
|
@@ -71,30 +73,25 @@ cd ios && pod install
|
|
|
71
73
|
|
|
72
74
|
## 🧠 Overview
|
|
73
75
|
|
|
74
|
-
| Feature
|
|
75
|
-
|
|
|
76
|
-
| **Rep-Based Exercises**
|
|
76
|
+
| Feature | Description |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| **Rep-Based Exercises** | Cyclic state machine (UP → DOWN → UP = 1 rep). Push-ups, squats, curls. |
|
|
77
79
|
| **Hold-Based Exercises** | Single target pose with duration tracking. Planks, wall sits, yoga poses. |
|
|
78
|
-
| **Flow-Based Exercises** | Ordered sequence of poses. Sun salutation, yoga flows.
|
|
79
|
-
| **Form Feedback**
|
|
80
|
-
| **Skeleton Overlay**
|
|
81
|
-
| **Bilateral Tracking**
|
|
80
|
+
| **Flow-Based Exercises** | Ordered sequence of poses. Sun salutation, yoga flows. *(coming soon)* |
|
|
81
|
+
| **Form Feedback** | Angle-based rules with throttled real-time callbacks. |
|
|
82
|
+
| **Skeleton Overlay** | Body skeleton drawn over camera via Skia (19 joints iOS, 33 joints Android). |
|
|
83
|
+
| **Bilateral Tracking** | Left and right side angles tracked independently. |
|
|
82
84
|
|
|
83
85
|
---
|
|
84
86
|
|
|
85
87
|
## 🔧 Setup
|
|
86
88
|
|
|
87
|
-
### Model File
|
|
89
|
+
### No Model File Needed
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
Unlike MediaPipe-based solutions, this library uses OS-native APIs. There is **no model file to download or bundle**.
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
**iOS:** Drag `pose_landmarker_lite.task` into your Xcode project (Copy items if needed, add to app target).
|
|
96
|
-
|
|
97
|
-
**Android:** Place at `android/app/src/main/assets/pose_landmarker_lite.task`
|
|
93
|
+
* **iOS:** Apple Vision is a system framework — it's already on every iPhone running iOS 14+.
|
|
94
|
+
* **Android:** ML Kit manages its own model via Google Play Services — it downloads and updates automatically.
|
|
98
95
|
|
|
99
96
|
### Permissions
|
|
100
97
|
|
|
@@ -125,6 +122,26 @@ module.exports = {
|
|
|
125
122
|
};
|
|
126
123
|
```
|
|
127
124
|
|
|
125
|
+
### Podspec (for library authors)
|
|
126
|
+
|
|
127
|
+
The iOS podspec needs the Vision and AVFoundation system frameworks:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
s.frameworks = ["AVFoundation", "Vision"]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
No CocoaPods dependencies required — Vision is built into iOS.
|
|
134
|
+
|
|
135
|
+
### Android Gradle (for library authors)
|
|
136
|
+
|
|
137
|
+
Add ML Kit Pose Detection to `android/build.gradle`:
|
|
138
|
+
|
|
139
|
+
```groovy
|
|
140
|
+
dependencies {
|
|
141
|
+
implementation 'com.google.mlkit:pose-detection:18.0.0-beta5'
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
128
145
|
---
|
|
129
146
|
|
|
130
147
|
## ⚙️ Usage
|
|
@@ -159,10 +176,10 @@ export default function App() {
|
|
|
159
176
|
if (!hasPermission) requestPermission();
|
|
160
177
|
}, [hasPermission]);
|
|
161
178
|
|
|
162
|
-
// Initialize pose engine
|
|
179
|
+
// Initialize pose engine — modelPath is ignored (OS-native, no model file)
|
|
163
180
|
useEffect(() => {
|
|
164
181
|
async function init() {
|
|
165
|
-
await nitroPoseExercises.initialize('
|
|
182
|
+
await nitroPoseExercises.initialize('');
|
|
166
183
|
nitroPoseExercises.loadExercise(PUSHUP_CONFIG);
|
|
167
184
|
|
|
168
185
|
nitroPoseExercises.onRepComplete = (data: RepData) => {
|
|
@@ -310,7 +327,7 @@ const SKELETON_CONNECTIONS: [number, number][] = [
|
|
|
310
327
|
### Lifecycle
|
|
311
328
|
|
|
312
329
|
```ts
|
|
313
|
-
// Initialize
|
|
330
|
+
// Initialize the pose engine (modelPath is ignored — OS-native, no model file needed)
|
|
314
331
|
initialize(modelPath: string): Promise<void>
|
|
315
332
|
|
|
316
333
|
// Clean up resources
|
|
@@ -346,7 +363,7 @@ processFrame(frame: Frame): void
|
|
|
346
363
|
readonly status: SessionStatus // 'idle' | 'countdown' | 'active' | 'paused' | 'completed'
|
|
347
364
|
readonly currentPhase: ExercisePhase // 'up' | 'down' | 'hold' | 'transition' | 'unknown'
|
|
348
365
|
readonly repCount: number
|
|
349
|
-
readonly landmarks: Landmark[] //
|
|
366
|
+
readonly landmarks: Landmark[] // Body landmarks (mapped to MediaPipe indices)
|
|
350
367
|
```
|
|
351
368
|
|
|
352
369
|
### Callbacks
|
|
@@ -380,9 +397,9 @@ onSessionComplete: ((result: SessionResult) => void) | undefined
|
|
|
380
397
|
|
|
381
398
|
```ts
|
|
382
399
|
{
|
|
383
|
-
ruleName: string
|
|
384
|
-
message: string
|
|
385
|
-
severity: FormSeverity
|
|
400
|
+
ruleName: string // e.g. 'hipSag'
|
|
401
|
+
message: string // e.g. 'Keep your hips up'
|
|
402
|
+
severity: FormSeverity // 'info' | 'warning' | 'error'
|
|
386
403
|
}
|
|
387
404
|
```
|
|
388
405
|
|
|
@@ -403,62 +420,88 @@ onSessionComplete: ((result: SessionResult) => void) | undefined
|
|
|
403
420
|
|
|
404
421
|
## 🏋️ Built-In Exercise Configs
|
|
405
422
|
|
|
406
|
-
###
|
|
423
|
+
### Rep-Based
|
|
407
424
|
|
|
408
|
-
|
|
|
409
|
-
|
|
|
410
|
-
|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
413
|
-
|
|
|
414
|
-
|
|
|
415
|
-
|
|
|
425
|
+
| Config | Exercise | Primary Angle | Form Rules |
|
|
426
|
+
| --- | --- | --- | --- |
|
|
427
|
+
| `PUSHUP_CONFIG` | Push-Up | Elbow (140°–180° up, 30°–110° down) | Hip sag, hip pike |
|
|
428
|
+
| `SQUAT_CONFIG` | Squat | Knee (155°–180° up, 50°–105° down) | Knees caving, leaning forward |
|
|
429
|
+
| `BICEP_CURL_CONFIG` | Bicep Curl | Elbow (150°–180° down, 25°–70° up) | Elbow flare, swinging |
|
|
430
|
+
| `SHOULDER_PRESS_CONFIG` | Shoulder Press | Elbow (155°–180° up, 60°–100° down) | Back arch |
|
|
431
|
+
| `LUNGE_CONFIG` | Lunge | Front knee (155°–180° up, 70°–110° down) | Knee over toe, torso lean |
|
|
432
|
+
| `SITUP_CONFIG` | Sit-Up | Hip (130°–180° down, 40°–90° up) | Neck strain |
|
|
433
|
+
| `TRICEP_DIP_CONFIG` | Tricep Dip | Elbow (150°–180° up, 60°–100° down) | Going too deep |
|
|
434
|
+
|
|
435
|
+
### Hold-Based
|
|
436
|
+
|
|
437
|
+
| Config | Exercise | Hold Angle | Duration | Form Rules |
|
|
438
|
+
| --- | --- | --- | --- | --- |
|
|
439
|
+
| `PLANK_CONFIG` | Plank | Hip 155°–180° | 60s | Hip sag, hip pike |
|
|
440
|
+
| `WALL_SIT_CONFIG` | Wall Sit | Knee 80°–110° | 45s | Too high, leaning forward |
|
|
441
|
+
|
|
442
|
+
### Yoga Poses
|
|
443
|
+
|
|
444
|
+
| Config | Exercise | Hold Angle | Duration | Form Rules |
|
|
445
|
+
| --- | --- | --- | --- | --- |
|
|
446
|
+
| `TREE_POSE_CONFIG` | Tree Pose (Vrksasana) | Standing leg 165°–180° | 30s | Standing leg bent, leaning torso |
|
|
447
|
+
| `WARRIOR_I_CONFIG` | Warrior I (Virabhadrasana I) | Front knee 80°–110° | 30s | Knee too straight, back leg bent, arms not extended, torso leaning |
|
|
448
|
+
| `WARRIOR_II_CONFIG` | Warrior II (Virabhadrasana II) | Front knee 80°–110° | 30s | Knee too straight, back leg bent, arms drooping |
|
|
449
|
+
| `DOWNWARD_DOG_CONFIG` | Downward Dog (Adho Mukha Svanasana) | Hip 55°–100° | 30s | Arms bent, legs bent, hips too low |
|
|
450
|
+
| `CHAIR_POSE_CONFIG` | Chair Pose (Utkatasana) | Knee 90°–130° | 30s | Knees too straight, leaning forward, arms not up |
|
|
451
|
+
| `COBRA_POSE_CONFIG` | Cobra Pose (Bhujangasana) | Hip extension 120°–170° | 30s | Shoulders tensed, legs bending |
|
|
416
452
|
|
|
417
453
|
### Custom Exercise Config
|
|
418
454
|
|
|
419
455
|
```ts
|
|
420
456
|
import type { ExerciseConfig } from 'react-native-nitro-pose-exercises';
|
|
421
457
|
|
|
422
|
-
const
|
|
423
|
-
name: '
|
|
424
|
-
type: 'rep',
|
|
458
|
+
const MY_EXERCISE: ExerciseConfig = {
|
|
459
|
+
name: 'Custom Exercise',
|
|
460
|
+
type: 'rep', // 'rep' | 'hold'
|
|
425
461
|
angles: [
|
|
426
|
-
{ name: '
|
|
427
|
-
{ name: 'rightKnee', landmarkA: 24, landmarkB: 26, landmarkC: 28 },
|
|
462
|
+
{ name: 'myAngle', landmarkA: 11, landmarkB: 13, landmarkC: 15 },
|
|
428
463
|
],
|
|
429
464
|
phases: [
|
|
430
|
-
{ phase: 'up', angleName: '
|
|
431
|
-
{ phase: 'down', angleName: '
|
|
465
|
+
{ phase: 'up', angleName: 'myAngle', minAngle: 150, maxAngle: 180 },
|
|
466
|
+
{ phase: 'down', angleName: 'myAngle', minAngle: 30, maxAngle: 100 },
|
|
432
467
|
],
|
|
433
468
|
repSequence: ['up', 'down', 'up'],
|
|
434
|
-
formRules: [
|
|
435
|
-
{
|
|
436
|
-
name: 'kneesCaving',
|
|
437
|
-
message: 'Push your knees out over your toes',
|
|
438
|
-
severity: 'warning',
|
|
439
|
-
angleName: 'leftKnee',
|
|
440
|
-
minAngle: 50,
|
|
441
|
-
maxAngle: 180,
|
|
442
|
-
},
|
|
443
|
-
],
|
|
469
|
+
formRules: [],
|
|
444
470
|
holdDurationMs: 0,
|
|
445
471
|
};
|
|
446
472
|
```
|
|
447
473
|
|
|
448
474
|
---
|
|
449
475
|
|
|
450
|
-
## 📐
|
|
476
|
+
## 📐 Landmark Index Reference
|
|
477
|
+
|
|
478
|
+
Landmarks are mapped to MediaPipe-compatible indices on both platforms. iOS Vision provides 19 joints (all exercise-critical joints covered), Android ML Kit provides the full 33.
|
|
451
479
|
|
|
452
|
-
| Index | Landmark
|
|
453
|
-
|
|
|
454
|
-
| 0
|
|
455
|
-
| 11
|
|
456
|
-
| 12
|
|
457
|
-
| 13
|
|
458
|
-
| 14
|
|
459
|
-
| 15
|
|
480
|
+
| Index | Landmark | Index | Landmark |
|
|
481
|
+
| --- | --- | --- | --- |
|
|
482
|
+
| 0 | Nose | 16 | Right wrist |
|
|
483
|
+
| 11 | Left shoulder | 23 | Left hip |
|
|
484
|
+
| 12 | Right shoulder | 24 | Right hip |
|
|
485
|
+
| 13 | Left elbow | 25 | Left knee |
|
|
486
|
+
| 14 | Right elbow | 26 | Right knee |
|
|
487
|
+
| 15 | Left wrist | 27 | Left ankle |
|
|
460
488
|
|
|
461
|
-
|
|
489
|
+
**iOS note:** Vision provides 19 joints. Indices not available from Vision (face details 1-10, hands 17-22, feet 29-32) are filled with `visibility: 0` and skipped by the skeleton overlay.
|
|
490
|
+
|
|
491
|
+
**Android note:** ML Kit provides all 33 landmarks matching MediaPipe indices exactly.
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## 🏗️ Architecture — OS-Native vs MediaPipe
|
|
496
|
+
|
|
497
|
+
| | OS-Native (current) | MediaPipe (previous) |
|
|
498
|
+
| --- | --- | --- |
|
|
499
|
+
| **iOS** | Apple Vision framework (built-in) | MediaPipeTasksVision (CocoaPod) |
|
|
500
|
+
| **Android** | Google ML Kit (Play Services) | com.google.mediapipe:tasks-vision |
|
|
501
|
+
| **Model file** | None needed | ~3 MB bundled `.task` file |
|
|
502
|
+
| **Color conversion** | None — takes CVPixelBuffer/ImageProxy directly | BGRA required (iOS), NV21→RGB (Android) |
|
|
503
|
+
| **App size impact** | ~200 KB (Nitro module code only) | ~11-15 MB (SDK + model) |
|
|
504
|
+
| **Updates** | OS/Play Services updates | Manual model file replacement |
|
|
462
505
|
|
|
463
506
|
---
|
|
464
507
|
|
|
@@ -466,33 +509,35 @@ Full 33-point reference: [MediaPipe Pose Landmarks](https://ai.google.dev/edge/m
|
|
|
466
509
|
|
|
467
510
|
For best results, the camera should see the exerciser from a **side profile**:
|
|
468
511
|
|
|
469
|
-
| ✅ Good
|
|
470
|
-
|
|
|
471
|
-
| Side view, full body visible
|
|
472
|
-
| Phone at waist height, 6-8 ft away | Ground-level angle
|
|
473
|
-
| Well-lit environment
|
|
512
|
+
| ✅ Good | ❌ Bad |
|
|
513
|
+
| --- | --- |
|
|
514
|
+
| Side view, full body visible | Front-facing view |
|
|
515
|
+
| Phone at waist height, 6-8 ft away | Ground-level angle |
|
|
516
|
+
| Well-lit environment | Heavy glare or backlight |
|
|
474
517
|
|
|
475
518
|
---
|
|
476
519
|
|
|
477
520
|
## 🧩 Supported Platforms
|
|
478
521
|
|
|
479
|
-
| Platform
|
|
480
|
-
|
|
|
481
|
-
| **iOS**
|
|
482
|
-
| **Android**
|
|
483
|
-
| **iOS Simulator**
|
|
484
|
-
| **Android Emulator** | ❌ Not supported | No real camera feed
|
|
522
|
+
| Platform | Status | Notes |
|
|
523
|
+
| --- | --- | --- |
|
|
524
|
+
| **iOS** | ✅ Supported | Requires physical device, iOS 14+ (Vision body pose) |
|
|
525
|
+
| **Android** | ✅ Supported | API 23+ (ML Kit), Google Play Services required |
|
|
526
|
+
| **iOS Simulator** | ❌ Not supported | No camera access |
|
|
527
|
+
| **Android Emulator** | ❌ Not supported | No real camera feed |
|
|
485
528
|
|
|
486
529
|
---
|
|
487
530
|
|
|
488
531
|
## 📊 App Size Impact
|
|
489
532
|
|
|
490
|
-
| Component
|
|
491
|
-
|
|
|
492
|
-
|
|
|
493
|
-
|
|
|
494
|
-
|
|
|
495
|
-
| **Total new addition**
|
|
533
|
+
| Component | Size |
|
|
534
|
+
| --- | --- |
|
|
535
|
+
| Nitro module code (Swift + Kotlin) | ~200 KB |
|
|
536
|
+
| ML Kit (Android, via Play Services) | ~0 KB (managed externally) |
|
|
537
|
+
| Vision framework (iOS, built-in) | ~0 KB (system framework) |
|
|
538
|
+
| **Total new addition** | **~200 KB** |
|
|
539
|
+
|
|
540
|
+
Compared to the MediaPipe approach (~11-15 MB), the OS-native approach adds virtually zero app size.
|
|
496
541
|
|
|
497
542
|
---
|
|
498
543
|
|
|
@@ -500,9 +545,9 @@ For best results, the camera should see the exerciser from a **side profile**:
|
|
|
500
545
|
|
|
501
546
|
PRs welcome!
|
|
502
547
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
548
|
+
* [Development Workflow](CONTRIBUTING.md#development-workflow)
|
|
549
|
+
* [Sending a PR](CONTRIBUTING.md#sending-a-pull-request)
|
|
550
|
+
* [Code of Conduct](CODE_OF_CONDUCT.md)
|
|
506
551
|
|
|
507
552
|
---
|
|
508
553
|
|
|
@@ -512,4 +557,4 @@ MIT © [**Gautham Vijayan**](https://gauthamvijay.com)
|
|
|
512
557
|
|
|
513
558
|
---
|
|
514
559
|
|
|
515
|
-
Made with ❤️ and [**Nitro Modules**](https://nitro.margelo.com) + [**VisionCamera**](https://visioncamera.margelo.com) + [**
|
|
560
|
+
Made with ❤️ and [**Nitro Modules**](https://nitro.margelo.com) + [**VisionCamera**](https://visioncamera.margelo.com) + [**Apple Vision**](https://developer.apple.com/documentation/vision) + [**ML Kit**](https://developers.google.com/ml-kit/vision/pose-detection)
|
package/android/build.gradle
CHANGED
|
@@ -120,7 +120,7 @@ repositories {
|
|
|
120
120
|
dependencies {
|
|
121
121
|
implementation "com.facebook.react:react-android"
|
|
122
122
|
implementation project(":react-native-nitro-modules")
|
|
123
|
-
implementation 'com.google.
|
|
123
|
+
implementation 'com.google.mlkit:pose-detection:18.0.0-beta5'
|
|
124
124
|
implementation project(":react-native-vision-camera")
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroposeexercises
|
|
2
2
|
|
|
3
3
|
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.Matrix
|
|
5
|
+
import android.media.Image
|
|
4
6
|
import androidx.annotation.Keep
|
|
5
7
|
import com.facebook.proguard.annotations.DoNotStrip
|
|
6
|
-
import com.google.
|
|
7
|
-
import com.google.
|
|
8
|
-
import com.google.
|
|
9
|
-
import com.google.
|
|
10
|
-
import com.google.
|
|
8
|
+
import com.google.mlkit.vision.common.InputImage
|
|
9
|
+
import com.google.mlkit.vision.pose.PoseDetection
|
|
10
|
+
import com.google.mlkit.vision.pose.PoseDetector
|
|
11
|
+
import com.google.mlkit.vision.pose.PoseLandmark
|
|
12
|
+
import com.google.mlkit.vision.pose.defaults.PoseDetectorOptions
|
|
11
13
|
import com.margelo.nitro.NitroModules
|
|
12
14
|
import com.margelo.nitro.core.Promise
|
|
13
15
|
import com.margelo.nitro.camera.HybridFrameSpec
|
|
14
|
-
import com.margelo.nitro.camera.public.NativeFrame
|
|
15
16
|
import kotlin.math.acos
|
|
16
17
|
import kotlin.math.max
|
|
17
18
|
import kotlin.math.min
|
|
@@ -19,12 +20,54 @@ import kotlin.math.sqrt
|
|
|
19
20
|
|
|
20
21
|
@Keep
|
|
21
22
|
@DoNotStrip
|
|
22
|
-
class
|
|
23
|
+
class HybridPoseExercise : HybridNitroPoseExercisesSpec() {
|
|
23
24
|
|
|
24
|
-
// ───
|
|
25
|
-
private var
|
|
25
|
+
// ─── ML Kit ─────────────────────────────────────────────────
|
|
26
|
+
private var poseDetector: PoseDetector? = null
|
|
26
27
|
private var isInitialized = false
|
|
27
28
|
|
|
29
|
+
// ─── Cached Landmarks (ML Kit is async, we cache last result) ──
|
|
30
|
+
private var cachedLandmarks: Array<Landmark> = emptyArray()
|
|
31
|
+
private val landmarkLock = Any()
|
|
32
|
+
|
|
33
|
+
// ─── Landmark Index Mapping ─────────────────────────────────
|
|
34
|
+
// ML Kit PoseLandmark type → MediaPipe index that JS configs expect
|
|
35
|
+
private val mlKitToMediaPipeMap = mapOf(
|
|
36
|
+
PoseLandmark.NOSE to 0,
|
|
37
|
+
PoseLandmark.LEFT_EYE_INNER to 1,
|
|
38
|
+
PoseLandmark.LEFT_EYE to 2,
|
|
39
|
+
PoseLandmark.LEFT_EYE_OUTER to 3,
|
|
40
|
+
PoseLandmark.RIGHT_EYE_INNER to 4,
|
|
41
|
+
PoseLandmark.RIGHT_EYE to 5,
|
|
42
|
+
PoseLandmark.RIGHT_EYE_OUTER to 6,
|
|
43
|
+
PoseLandmark.LEFT_EAR to 7,
|
|
44
|
+
PoseLandmark.RIGHT_EAR to 8,
|
|
45
|
+
PoseLandmark.LEFT_MOUTH to 9,
|
|
46
|
+
PoseLandmark.RIGHT_MOUTH to 10,
|
|
47
|
+
PoseLandmark.LEFT_SHOULDER to 11,
|
|
48
|
+
PoseLandmark.RIGHT_SHOULDER to 12,
|
|
49
|
+
PoseLandmark.LEFT_ELBOW to 13,
|
|
50
|
+
PoseLandmark.RIGHT_ELBOW to 14,
|
|
51
|
+
PoseLandmark.LEFT_WRIST to 15,
|
|
52
|
+
PoseLandmark.RIGHT_WRIST to 16,
|
|
53
|
+
PoseLandmark.LEFT_PINKY to 17,
|
|
54
|
+
PoseLandmark.RIGHT_PINKY to 18,
|
|
55
|
+
PoseLandmark.LEFT_INDEX to 19,
|
|
56
|
+
PoseLandmark.RIGHT_INDEX to 20,
|
|
57
|
+
PoseLandmark.LEFT_THUMB to 21,
|
|
58
|
+
PoseLandmark.RIGHT_THUMB to 22,
|
|
59
|
+
PoseLandmark.LEFT_HIP to 23,
|
|
60
|
+
PoseLandmark.RIGHT_HIP to 24,
|
|
61
|
+
PoseLandmark.LEFT_KNEE to 25,
|
|
62
|
+
PoseLandmark.RIGHT_KNEE to 26,
|
|
63
|
+
PoseLandmark.LEFT_ANKLE to 27,
|
|
64
|
+
PoseLandmark.RIGHT_ANKLE to 28,
|
|
65
|
+
PoseLandmark.LEFT_HEEL to 29,
|
|
66
|
+
PoseLandmark.RIGHT_HEEL to 30,
|
|
67
|
+
PoseLandmark.LEFT_FOOT_INDEX to 31,
|
|
68
|
+
PoseLandmark.RIGHT_FOOT_INDEX to 32,
|
|
69
|
+
)
|
|
70
|
+
|
|
28
71
|
// ─── Exercise Config ────────────────────────────────────────
|
|
29
72
|
private var exerciseConfig: ExerciseConfig? = null
|
|
30
73
|
|
|
@@ -59,7 +102,7 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
59
102
|
// ─── Pose Tracking ──────────────────────────────────────────
|
|
60
103
|
private var poseWasLost = false
|
|
61
104
|
|
|
62
|
-
// ─── Frame
|
|
105
|
+
// ─── Frame Throttle ─────────────────────────────────────────
|
|
63
106
|
private var frameCount: Int = 0
|
|
64
107
|
private val processEveryNFrames: Int = 3
|
|
65
108
|
|
|
@@ -80,31 +123,21 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
80
123
|
// ═══════════════════════════════════════════════════════════
|
|
81
124
|
|
|
82
125
|
override fun initialize(modelPath: String): Promise<Unit> {
|
|
126
|
+
// No model file needed — ML Kit downloads/bundles its own model
|
|
83
127
|
return Promise.async {
|
|
84
|
-
val
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
val baseOptions = BaseOptions.builder()
|
|
88
|
-
.setModelAssetPath(modelPath)
|
|
128
|
+
val options = PoseDetectorOptions.Builder()
|
|
129
|
+
.setDetectorMode(PoseDetectorOptions.STREAM_MODE)
|
|
89
130
|
.build()
|
|
90
131
|
|
|
91
|
-
|
|
92
|
-
.setBaseOptions(baseOptions)
|
|
93
|
-
.setRunningMode(RunningMode.IMAGE)
|
|
94
|
-
.setNumPoses(1)
|
|
95
|
-
.setMinPoseDetectionConfidence(0.5f)
|
|
96
|
-
.setMinPosePresenceConfidence(0.5f)
|
|
97
|
-
.setMinTrackingConfidence(0.5f)
|
|
98
|
-
.build()
|
|
99
|
-
|
|
100
|
-
poseLandmarker = PoseLandmarker.createFromOptions(context, options)
|
|
132
|
+
poseDetector = PoseDetection.getClient(options)
|
|
101
133
|
isInitialized = true
|
|
134
|
+
println("[PoseExercise] Initialized with ML Kit Pose Detection (no model file needed)")
|
|
102
135
|
}
|
|
103
136
|
}
|
|
104
137
|
|
|
105
138
|
override fun release() {
|
|
106
|
-
|
|
107
|
-
|
|
139
|
+
poseDetector?.close()
|
|
140
|
+
poseDetector = null
|
|
108
141
|
isInitialized = false
|
|
109
142
|
_status = SessionStatus.IDLE
|
|
110
143
|
resetSession()
|
|
@@ -154,61 +187,98 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
154
187
|
}
|
|
155
188
|
|
|
156
189
|
// ═══════════════════════════════════════════════════════════
|
|
157
|
-
// Frame Processing
|
|
190
|
+
// Frame Processing (ML Kit — async with cached results)
|
|
158
191
|
// ═══════════════════════════════════════════════════════════
|
|
159
192
|
|
|
160
|
-
|
|
193
|
+
override fun processFrame(frame: HybridFrameSpec) {
|
|
161
194
|
if (_status != SessionStatus.ACTIVE && _status != SessionStatus.COUNTDOWN) return
|
|
162
|
-
if (!isInitialized ||
|
|
195
|
+
if (!isInitialized || poseDetector == null) return
|
|
163
196
|
|
|
197
|
+
// Frame throttle
|
|
164
198
|
frameCount++
|
|
165
199
|
if (frameCount % processEveryNFrames != 0) return
|
|
166
200
|
|
|
167
201
|
try {
|
|
168
|
-
// Get
|
|
202
|
+
// Get raw buffer from VisionCamera frame
|
|
169
203
|
val nativeBuffer = frame.getNativeBuffer()
|
|
170
|
-
val
|
|
171
|
-
val
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// Create
|
|
175
|
-
val
|
|
176
|
-
val
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
204
|
+
val width = frame.getWidth()
|
|
205
|
+
val height = frame.getHeight()
|
|
206
|
+
val bytesPerRow = frame.getBytesPerRow()
|
|
207
|
+
|
|
208
|
+
// Create Bitmap from the native buffer
|
|
209
|
+
val buffer = java.nio.ByteBuffer.allocateDirect(height * bytesPerRow)
|
|
210
|
+
val pointer = nativeBuffer.pointer
|
|
211
|
+
// Copy from native pointer to ByteBuffer
|
|
212
|
+
NativeBufferHelper.copyToByteBuffer(pointer, buffer, height * bytesPerRow)
|
|
213
|
+
|
|
214
|
+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
215
|
+
buffer.rewind()
|
|
216
|
+
bitmap.copyPixelsFromBuffer(buffer)
|
|
217
|
+
|
|
218
|
+
val inputImage = InputImage.fromBitmap(bitmap, 0)
|
|
219
|
+
|
|
220
|
+
// ML Kit is async — fire detection, cache result
|
|
221
|
+
poseDetector!!.process(inputImage)
|
|
222
|
+
.addOnSuccessListener { pose ->
|
|
223
|
+
val poseLandmarks = pose.allPoseLandmarks
|
|
224
|
+
|
|
225
|
+
if (poseLandmarks.isNotEmpty()) {
|
|
226
|
+
if (poseWasLost) {
|
|
227
|
+
poseWasLost = false
|
|
228
|
+
onPoseRegained?.invoke()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
val landmarkArray = Array(34) { Landmark(x = 0.0, y = 0.0, z = 0.0, visibility = 0.0) }
|
|
232
|
+
|
|
233
|
+
val imageWidth = width.toDouble()
|
|
234
|
+
val imageHeight = height.toDouble()
|
|
235
|
+
|
|
236
|
+
for (poseLandmark in poseLandmarks) {
|
|
237
|
+
val mediaPipeIndex = mlKitToMediaPipeMap[poseLandmark.landmarkType] ?: continue
|
|
238
|
+
if (mediaPipeIndex >= 34) continue
|
|
239
|
+
|
|
240
|
+
landmarkArray[mediaPipeIndex] = Landmark(
|
|
241
|
+
x = poseLandmark.position.x.toDouble() / imageWidth,
|
|
242
|
+
y = poseLandmark.position.y.toDouble() / imageHeight,
|
|
243
|
+
z = poseLandmark.position3D.z.toDouble(),
|
|
244
|
+
visibility = poseLandmark.inFrameLikelihood.toDouble()
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
synchronized(landmarkLock) {
|
|
249
|
+
cachedLandmarks = landmarkArray
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
if (!poseWasLost) {
|
|
253
|
+
poseWasLost = true
|
|
254
|
+
onPoseLost?.invoke()
|
|
255
|
+
}
|
|
256
|
+
synchronized(landmarkLock) {
|
|
257
|
+
cachedLandmarks = emptyArray()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
bitmap.recycle()
|
|
185
262
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
x = lm.x().toDouble(),
|
|
190
|
-
y = lm.y().toDouble(),
|
|
191
|
-
z = lm.z().toDouble(),
|
|
192
|
-
visibility = (lm.visibility().orElse(0f)).toDouble()
|
|
193
|
-
)
|
|
194
|
-
}.toTypedArray()
|
|
195
|
-
|
|
196
|
-
if (_status == SessionStatus.ACTIVE) {
|
|
197
|
-
processExerciseLogic()
|
|
263
|
+
.addOnFailureListener { e ->
|
|
264
|
+
println("[PoseExercise] ML Kit error: ${e.message}")
|
|
265
|
+
bitmap.recycle()
|
|
198
266
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
_landmarks = emptyArray()
|
|
267
|
+
|
|
268
|
+
// Use cached landmarks for exercise logic
|
|
269
|
+
val currentLandmarks: Array<Landmark>
|
|
270
|
+
synchronized(landmarkLock) {
|
|
271
|
+
currentLandmarks = cachedLandmarks.copyOf()
|
|
205
272
|
}
|
|
206
273
|
|
|
207
|
-
|
|
208
|
-
|
|
274
|
+
_landmarks = currentLandmarks
|
|
275
|
+
|
|
276
|
+
if (currentLandmarks.isNotEmpty() && _status == SessionStatus.ACTIVE) {
|
|
277
|
+
processExerciseLogic()
|
|
278
|
+
}
|
|
209
279
|
|
|
210
280
|
} catch (e: Exception) {
|
|
211
|
-
|
|
281
|
+
println("[PoseExercise] Frame processing error: ${e.message}")
|
|
212
282
|
}
|
|
213
283
|
}
|
|
214
284
|
|
|
@@ -230,6 +300,9 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
230
300
|
|
|
231
301
|
if (a >= _landmarks.size || b >= _landmarks.size || c >= _landmarks.size) continue
|
|
232
302
|
|
|
303
|
+
// Only calculate if all three landmarks have reasonable confidence
|
|
304
|
+
if (_landmarks[a].visibility < 0.3 || _landmarks[b].visibility < 0.3 || _landmarks[c].visibility < 0.3) continue
|
|
305
|
+
|
|
233
306
|
val angle = calculateAngle(_landmarks[a], _landmarks[b], _landmarks[c])
|
|
234
307
|
currentAngles[angleDef.name] = angle
|
|
235
308
|
angleSnapshots.add(AngleSnapshot(name = angleDef.name, value = angle))
|
|
@@ -365,6 +438,7 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
365
438
|
|
|
366
439
|
for (rule in config.formRules) {
|
|
367
440
|
val angle = currentAngles[rule.angleName] ?: continue
|
|
441
|
+
|
|
368
442
|
val isViolating = angle < rule.minAngle || angle > rule.maxAngle
|
|
369
443
|
|
|
370
444
|
if (isViolating) {
|
|
@@ -407,18 +481,24 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
407
481
|
}
|
|
408
482
|
|
|
409
483
|
if (inPosition) {
|
|
410
|
-
if (holdStartTime == null)
|
|
484
|
+
if (holdStartTime == null) {
|
|
485
|
+
holdStartTime = System.currentTimeMillis()
|
|
486
|
+
}
|
|
411
487
|
|
|
412
488
|
val elapsed = (System.currentTimeMillis() - holdStartTime!!).toDouble()
|
|
413
489
|
val stability = min(100.0, max(0.0, repFormScore))
|
|
414
490
|
|
|
415
|
-
|
|
491
|
+
val progress = HoldProgress(
|
|
416
492
|
elapsedMs = elapsed,
|
|
417
493
|
targetMs = config.holdDurationMs,
|
|
418
494
|
stability = stability
|
|
419
|
-
)
|
|
495
|
+
)
|
|
420
496
|
|
|
421
|
-
|
|
497
|
+
onHoldProgress?.invoke(progress)
|
|
498
|
+
|
|
499
|
+
if (elapsed >= config.holdDurationMs) {
|
|
500
|
+
completeSession()
|
|
501
|
+
}
|
|
422
502
|
} else {
|
|
423
503
|
holdStartTime = null
|
|
424
504
|
}
|
|
@@ -435,14 +515,16 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
435
515
|
val avgRepDuration = if (allRepDurations.isEmpty()) 0.0 else allRepDurations.average()
|
|
436
516
|
val avgFormScore = if (allRepFormScores.isEmpty()) 100.0 else allRepFormScores.average()
|
|
437
517
|
|
|
438
|
-
|
|
518
|
+
val result = SessionResult(
|
|
439
519
|
totalReps = _repCount,
|
|
440
520
|
totalDurationMs = totalDuration,
|
|
441
521
|
averageRepDurationMs = avgRepDuration,
|
|
442
522
|
averageFormScore = avgFormScore,
|
|
443
523
|
formViolations = sessionFormViolations.toTypedArray(),
|
|
444
524
|
angleHistory = repAngleSnapshots
|
|
445
|
-
)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
onSessionComplete?.invoke(result)
|
|
446
528
|
}
|
|
447
529
|
|
|
448
530
|
// ═══════════════════════════════════════════════════════════
|
|
@@ -482,5 +564,9 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
|
|
|
482
564
|
poseWasLost = false
|
|
483
565
|
targetReps = 0.0
|
|
484
566
|
countdownSeconds = 0.0
|
|
567
|
+
frameCount = 0
|
|
568
|
+
synchronized(landmarkLock) {
|
|
569
|
+
cachedLandmarks = emptyArray()
|
|
570
|
+
}
|
|
485
571
|
}
|
|
486
572
|
}
|
|
@@ -1,15 +1,43 @@
|
|
|
1
|
+
// ios/HybridPoseExercise.swift
|
|
2
|
+
|
|
1
3
|
import Foundation
|
|
2
4
|
import NitroModules
|
|
3
|
-
import MediaPipeTasksVision
|
|
4
5
|
import VisionCamera
|
|
5
6
|
import AVFoundation
|
|
7
|
+
import Vision
|
|
6
8
|
|
|
7
|
-
class
|
|
9
|
+
class HybridPoseExercise: HybridNitroPoseExercisesSpec {
|
|
8
10
|
|
|
9
|
-
// ───
|
|
10
|
-
private var poseLandmarker: PoseLandmarker?
|
|
11
|
+
// ─── Vision Framework ───────────────────────────────────────
|
|
11
12
|
private var isInitialized = false
|
|
12
13
|
|
|
14
|
+
// ─── Landmark Index Mapping ─────────────────────────────────
|
|
15
|
+
// Maps Apple Vision joint names to MediaPipe landmark indices
|
|
16
|
+
// that our JS configs expect
|
|
17
|
+
private static let visionToMediaPipeMap: [(VNHumanBodyPoseObservation.JointName, Int)] = [
|
|
18
|
+
(.nose, 0),
|
|
19
|
+
(.leftShoulder, 11),
|
|
20
|
+
(.rightShoulder, 12),
|
|
21
|
+
(.leftElbow, 13),
|
|
22
|
+
(.rightElbow, 14),
|
|
23
|
+
(.leftWrist, 15),
|
|
24
|
+
(.rightWrist, 16),
|
|
25
|
+
(.leftHip, 23),
|
|
26
|
+
(.rightHip, 24),
|
|
27
|
+
(.leftKnee, 25),
|
|
28
|
+
(.rightKnee, 26),
|
|
29
|
+
(.leftAnkle, 27),
|
|
30
|
+
(.rightAnkle, 28),
|
|
31
|
+
// Vision also provides these but they map to non-standard indices
|
|
32
|
+
// We include them for skeleton drawing
|
|
33
|
+
(.neck, 10), // approximate — MediaPipe doesn't have neck
|
|
34
|
+
(.root, 33), // hip center — not in MediaPipe, we skip
|
|
35
|
+
(.leftEar, 7),
|
|
36
|
+
(.rightEar, 8),
|
|
37
|
+
(.leftEye, 2),
|
|
38
|
+
(.rightEye, 5),
|
|
39
|
+
]
|
|
40
|
+
|
|
13
41
|
// ─── Exercise Config ────────────────────────────────────────
|
|
14
42
|
private var exerciseConfig: ExerciseConfig?
|
|
15
43
|
|
|
@@ -34,9 +62,6 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
|
|
|
34
62
|
private var countdownSeconds: Double = 0
|
|
35
63
|
private var countdownTimer: Timer?
|
|
36
64
|
|
|
37
|
-
private var frameCount: Int = 0
|
|
38
|
-
private let processEveryNFrames: Int = 3 // Only process every 3rd frame
|
|
39
|
-
|
|
40
65
|
// ─── Form Tracking ──────────────────────────────────────────
|
|
41
66
|
private var lastFormFeedbackTime: [String: Date] = [:]
|
|
42
67
|
private var sessionFormViolations: [FormFeedback] = []
|
|
@@ -48,6 +73,10 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
|
|
|
48
73
|
// ─── Pose Tracking ──────────────────────────────────────────
|
|
49
74
|
private var poseWasLost = false
|
|
50
75
|
|
|
76
|
+
// ─── Frame Throttle ─────────────────────────────────────────
|
|
77
|
+
private var frameCount: Int = 0
|
|
78
|
+
private let processEveryNFrames: Int = 3
|
|
79
|
+
|
|
51
80
|
// ─── Callbacks ──────────────────────────────────────────────
|
|
52
81
|
var onRepComplete: ((_ data: RepData) -> Void)?
|
|
53
82
|
var onPhaseChange: ((_ phase: ExercisePhase) -> Void)?
|
|
@@ -65,28 +94,15 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
|
|
|
65
94
|
// ═══════════════════════════════════════════════════════════
|
|
66
95
|
|
|
67
96
|
func initialize(modelPath: String) throws -> Promise<Void> {
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
// No model loading needed — Vision framework is built into iOS
|
|
70
98
|
return Promise.async { [weak self] in
|
|
71
99
|
guard let self = self else { return }
|
|
72
|
-
|
|
73
|
-
let options = PoseLandmarkerOptions()
|
|
74
|
-
options.baseOptions.modelAssetPath = modelPath
|
|
75
|
-
options.runningMode = .image
|
|
76
|
-
options.numPoses = 1
|
|
77
|
-
options.minPoseDetectionConfidence = 0.5
|
|
78
|
-
options.minPosePresenceConfidence = 0.5
|
|
79
|
-
options.minTrackingConfidence = 0.5
|
|
80
|
-
|
|
81
|
-
self.poseLandmarker = try PoseLandmarker(options: options)
|
|
82
100
|
self.isInitialized = true
|
|
83
|
-
|
|
84
|
-
print("[PoseExercise] MediaPipe initialized successfully")
|
|
101
|
+
print("[PoseExercise] Initialized with Apple Vision (no model file needed)")
|
|
85
102
|
}
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
func release() throws {
|
|
89
|
-
poseLandmarker = nil
|
|
90
106
|
isInitialized = false
|
|
91
107
|
_status = .idle
|
|
92
108
|
resetSession()
|
|
@@ -106,8 +122,6 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
|
|
|
106
122
|
// ═══════════════════════════════════════════════════════════
|
|
107
123
|
|
|
108
124
|
func startSession(targetReps: Double, countdownSeconds: Double) throws {
|
|
109
|
-
print("[PoseExercise] startSession called - target: \(targetReps), countdown: \(countdownSeconds)")
|
|
110
|
-
|
|
111
125
|
resetSession()
|
|
112
126
|
self.targetReps = targetReps
|
|
113
127
|
self.countdownSeconds = countdownSeconds
|
|
@@ -138,75 +152,90 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
|
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
// ═══════════════════════════════════════════════════════════
|
|
141
|
-
// MARK: - Frame Processing
|
|
155
|
+
// MARK: - Frame Processing (Apple Vision)
|
|
142
156
|
// ═══════════════════════════════════════════════════════════
|
|
143
157
|
|
|
144
|
-
func processFrame(frame: any HybridFrameSpec) throws {
|
|
145
|
-
|
|
158
|
+
func processFrame(frame: any HybridFrameSpec) throws {
|
|
159
|
+
guard _status == .active || _status == .countdown else { return }
|
|
160
|
+
guard isInitialized else { return }
|
|
146
161
|
|
|
147
|
-
|
|
148
|
-
|
|
162
|
+
// Frame throttle
|
|
163
|
+
frameCount += 1
|
|
164
|
+
if frameCount % processEveryNFrames != 0 { return }
|
|
149
165
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
guard isInitialized, let landmarker = poseLandmarker else {
|
|
155
|
-
print("[PoseExercise] Skipping - not initialized: \(isInitialized)")
|
|
156
|
-
return
|
|
157
|
-
}
|
|
166
|
+
// Get CMSampleBuffer from VisionCamera frame
|
|
167
|
+
guard let nativeFrame = frame as? any NativeFrame,
|
|
168
|
+
let sampleBuffer = nativeFrame.sampleBuffer else { return }
|
|
158
169
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return
|
|
162
|
-
}
|
|
163
|
-
guard let sampleBuffer = nativeFrame.sampleBuffer else {
|
|
164
|
-
print("[PoseExercise] sampleBuffer is nil")
|
|
165
|
-
return
|
|
166
|
-
}
|
|
170
|
+
// Get pixel buffer — Vision takes CVPixelBuffer directly, no color conversion needed
|
|
171
|
+
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
|
167
172
|
|
|
168
|
-
|
|
173
|
+
// Create Vision request
|
|
174
|
+
let request = VNDetectHumanBodyPoseRequest()
|
|
169
175
|
|
|
170
|
-
|
|
171
|
-
let
|
|
172
|
-
let result = try landmarker.detect(image: mpImage)
|
|
176
|
+
// Run synchronously on this frame processor thread
|
|
177
|
+
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
|
|
173
178
|
|
|
174
|
-
|
|
179
|
+
do {
|
|
180
|
+
try handler.perform([request])
|
|
181
|
+
|
|
182
|
+
guard let observation = request.results?.first else {
|
|
183
|
+
// No pose detected
|
|
184
|
+
if !poseWasLost {
|
|
185
|
+
poseWasLost = true
|
|
186
|
+
onPoseLost?()
|
|
187
|
+
}
|
|
188
|
+
_landmarks = []
|
|
189
|
+
return
|
|
190
|
+
}
|
|
175
191
|
|
|
176
|
-
|
|
192
|
+
// Pose detected
|
|
177
193
|
if poseWasLost {
|
|
178
194
|
poseWasLost = false
|
|
179
195
|
onPoseRegained?()
|
|
180
196
|
}
|
|
181
197
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
// Map Vision joints to MediaPipe landmark array (34 slots, indices 0-33)
|
|
199
|
+
// Fill all slots with zero-visibility first
|
|
200
|
+
var landmarkArray = [Landmark](repeating: Landmark(x: 0, y: 0, z: 0, visibility: 0), count: 34)
|
|
201
|
+
|
|
202
|
+
for (jointName, mediaPipeIndex) in HybridPoseExercise.visionToMediaPipeMap {
|
|
203
|
+
guard mediaPipeIndex < 34 else { continue }
|
|
204
|
+
|
|
205
|
+
do {
|
|
206
|
+
let point = try observation.recognizedPoint(jointName)
|
|
207
|
+
|
|
208
|
+
// Vision uses bottom-left origin (0,0 = bottom-left)
|
|
209
|
+
// MediaPipe uses top-left origin (0,0 = top-left)
|
|
210
|
+
// Flip Y axis
|
|
211
|
+
let confidence = Double(point.confidence)
|
|
212
|
+
|
|
213
|
+
landmarkArray[mediaPipeIndex] = Landmark(
|
|
214
|
+
x: Double(point.location.x),
|
|
215
|
+
y: 1.0 - Double(point.location.y), // flip Y
|
|
216
|
+
z: 0, // Vision doesn't provide Z depth
|
|
217
|
+
visibility: confidence
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
// Debug logging — uncomment to verify mapping
|
|
221
|
+
// print("[PoseExercise] \(jointName.rawValue.rawValue) → index \(mediaPipeIndex): x=\(String(format: "%.3f", point.location.x)) y=\(String(format: "%.3f", 1.0 - Double(point.location.y))) conf=\(String(format: "%.2f", confidence))")
|
|
222
|
+
|
|
223
|
+
} catch {
|
|
224
|
+
// Joint not detected — leave as zero visibility
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
189
227
|
}
|
|
190
228
|
|
|
191
|
-
|
|
229
|
+
_landmarks = landmarkArray
|
|
192
230
|
|
|
193
231
|
if _status == .active {
|
|
194
232
|
processExerciseLogic()
|
|
195
233
|
}
|
|
196
234
|
|
|
197
|
-
}
|
|
198
|
-
print("[PoseExercise]
|
|
199
|
-
if !poseWasLost {
|
|
200
|
-
poseWasLost = true
|
|
201
|
-
onPoseLost?()
|
|
202
|
-
}
|
|
203
|
-
_landmarks = []
|
|
235
|
+
} catch {
|
|
236
|
+
print("[PoseExercise] Vision error: \(error.localizedDescription)")
|
|
204
237
|
}
|
|
205
|
-
|
|
206
|
-
} catch {
|
|
207
|
-
print("[PoseExercise] MediaPipe error: \(error.localizedDescription)")
|
|
208
238
|
}
|
|
209
|
-
}
|
|
210
239
|
|
|
211
240
|
// ═══════════════════════════════════════════════════════════
|
|
212
241
|
// MARK: - Exercise Logic Engine
|
|
@@ -216,7 +245,6 @@ func processFrame(frame: any HybridFrameSpec) throws {
|
|
|
216
245
|
guard let config = exerciseConfig else { return }
|
|
217
246
|
guard !_landmarks.isEmpty else { return }
|
|
218
247
|
|
|
219
|
-
// 1. Calculate all angles defined in the config
|
|
220
248
|
var currentAngles: [String: Double] = [:]
|
|
221
249
|
var angleSnapshots: [AngleSnapshot] = []
|
|
222
250
|
|
|
@@ -227,6 +255,11 @@ func processFrame(frame: any HybridFrameSpec) throws {
|
|
|
227
255
|
|
|
228
256
|
guard a < _landmarks.count, b < _landmarks.count, c < _landmarks.count else { continue }
|
|
229
257
|
|
|
258
|
+
// Only calculate if all three landmarks have reasonable confidence
|
|
259
|
+
guard _landmarks[a].visibility > 0.3,
|
|
260
|
+
_landmarks[b].visibility > 0.3,
|
|
261
|
+
_landmarks[c].visibility > 0.3 else { continue }
|
|
262
|
+
|
|
230
263
|
let angle = calculateAngle(
|
|
231
264
|
pointA: _landmarks[a],
|
|
232
265
|
vertex: _landmarks[b],
|
|
@@ -239,31 +272,25 @@ func processFrame(frame: any HybridFrameSpec) throws {
|
|
|
239
272
|
|
|
240
273
|
repAngleSnapshots = angleSnapshots
|
|
241
274
|
|
|
242
|
-
//
|
|
275
|
+
// Debug logging — uncomment to see angles
|
|
276
|
+
// for (name, angle) in currentAngles {
|
|
277
|
+
// print("[PoseExercise] Angle \(name): \(String(format: "%.1f", angle))°")
|
|
278
|
+
// }
|
|
279
|
+
|
|
243
280
|
let detectedPhase = determinePhase(from: currentAngles, config: config)
|
|
244
281
|
|
|
245
282
|
if detectedPhase != _currentPhase && detectedPhase != .unknown {
|
|
246
283
|
let previousPhase = _currentPhase
|
|
247
284
|
_currentPhase = detectedPhase
|
|
248
285
|
onPhaseChange?(detectedPhase)
|
|
249
|
-
|
|
250
|
-
// 3. Update phase history for rep counting
|
|
251
286
|
handlePhaseTransition(from: previousPhase, to: detectedPhase, config: config)
|
|
252
287
|
}
|
|
253
288
|
|
|
254
|
-
// 4. Check form rules
|
|
255
289
|
checkFormRules(currentAngles: currentAngles, config: config)
|
|
256
290
|
|
|
257
|
-
// 5. Handle hold-based exercises
|
|
258
291
|
if config.type == .hold {
|
|
259
292
|
handleHoldProgress(currentAngles: currentAngles, config: config)
|
|
260
293
|
}
|
|
261
|
-
|
|
262
|
-
// Temporary debug logging — remove after testing
|
|
263
|
-
for (name, angle) in currentAngles {
|
|
264
|
-
print("[PoseExercise] Angle \(name): \(String(format: "%.1f", angle))°")
|
|
265
|
-
}
|
|
266
|
-
print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_currentPhase)")
|
|
267
294
|
}
|
|
268
295
|
|
|
269
296
|
// ═══════════════════════════════════════════════════════════
|
|
@@ -284,9 +311,7 @@ print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_curren
|
|
|
284
311
|
|
|
285
312
|
let cosAngle = max(-1.0, min(1.0, dot / (magA * magC)))
|
|
286
313
|
let angleRad = acos(cosAngle)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return angleDeg
|
|
314
|
+
return angleRad * (180.0 / .pi)
|
|
290
315
|
}
|
|
291
316
|
|
|
292
317
|
// ═══════════════════════════════════════════════════════════
|
|
@@ -296,7 +321,6 @@ print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_curren
|
|
|
296
321
|
private func determinePhase(from angles: [String: Double], config: ExerciseConfig) -> ExercisePhase {
|
|
297
322
|
for phaseThreshold in config.phases {
|
|
298
323
|
guard let angle = angles[phaseThreshold.angleName] else { continue }
|
|
299
|
-
|
|
300
324
|
if angle >= phaseThreshold.minAngle && angle <= phaseThreshold.maxAngle {
|
|
301
325
|
return phaseThreshold.phase
|
|
302
326
|
}
|
|
@@ -308,7 +332,7 @@ print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_curren
|
|
|
308
332
|
// MARK: - Rep Counting State Machine
|
|
309
333
|
// ═══════════════════════════════════════════════════════════
|
|
310
334
|
|
|
311
|
-
private func handlePhaseTransition(from previousPhase: ExercisePhase, to newPhase: ExercisePhase, config: ExerciseConfig) {
|
|
335
|
+
private func handlePhaseTransition(from previousPhase: ExercisePhase, to newPhase: ExercisePhase, config: ExerciseConfig) {
|
|
312
336
|
guard config.type == .rep else { return }
|
|
313
337
|
|
|
314
338
|
phaseHistory.append(newPhase)
|
|
@@ -523,5 +547,6 @@ private func handlePhaseTransition(from previousPhase: ExercisePhase, to newPhas
|
|
|
523
547
|
countdownSeconds = 0
|
|
524
548
|
countdownTimer?.invalidate()
|
|
525
549
|
countdownTimer = nil
|
|
550
|
+
frameCount = 0
|
|
526
551
|
}
|
|
527
|
-
}
|
|
552
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-pose-exercises",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Real-time on-device exercise tracking for React Native. Rep counting, form validation, and skeleton overlay powered by
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "Real-time on-device exercise tracking for React Native. Rep counting, form validation, and skeleton overlay powered by Apple Vision (iOS) and Google ML Kit (Android) with VisionCamera v5 via Nitro Modules.",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -69,9 +69,9 @@
|
|
|
69
69
|
"@eslint/eslintrc": "^3.3.1",
|
|
70
70
|
"@eslint/js": "^9.35.0",
|
|
71
71
|
"@jest/globals": "^30.0.0",
|
|
72
|
-
"@react-native/babel-preset": "0.85.
|
|
73
|
-
"@react-native/eslint-config": "0.85.
|
|
74
|
-
"@react-native/jest-preset": "0.85.
|
|
72
|
+
"@react-native/babel-preset": "0.85.3",
|
|
73
|
+
"@react-native/eslint-config": "0.85.3",
|
|
74
|
+
"@react-native/jest-preset": "0.85.3",
|
|
75
75
|
"@release-it/conventional-changelog": "^10.0.6",
|
|
76
76
|
"@types/react": "^19.2.0",
|
|
77
77
|
"commitlint": "^20.5.0",
|
|
@@ -82,16 +82,16 @@
|
|
|
82
82
|
"eslint-plugin-prettier": "^5.5.4",
|
|
83
83
|
"jest": "^30.3.0",
|
|
84
84
|
"lefthook": "^2.1.4",
|
|
85
|
-
"nitrogen": "^0.35.
|
|
85
|
+
"nitrogen": "^0.35.9",
|
|
86
86
|
"prettier": "^3.8.1",
|
|
87
87
|
"react": "19.2.3",
|
|
88
|
-
"react-native": "0.85.
|
|
88
|
+
"react-native": "0.85.3",
|
|
89
89
|
"react-native-builder-bob": "^0.41.0",
|
|
90
|
-
"react-native-nitro-modules": "^0.35.
|
|
90
|
+
"react-native-nitro-modules": "^0.35.9",
|
|
91
91
|
"react-native-svg": "^15.15.5",
|
|
92
|
-
"react-native-vision-camera": "^5.0.
|
|
93
|
-
"react-native-vision-camera-worklets": "^5.0.
|
|
94
|
-
"react-native-worklets": "^0.
|
|
92
|
+
"react-native-vision-camera": "^5.0.11",
|
|
93
|
+
"react-native-vision-camera-worklets": "^5.0.11",
|
|
94
|
+
"react-native-worklets": "^0.9.1",
|
|
95
95
|
"release-it": "^19.2.4",
|
|
96
96
|
"turbo": "^2.8.21",
|
|
97
97
|
"typescript": "^6.0.2"
|