alpha
Login
or
Join now
nateholland.bsky.social
/
PoseDetection
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
fix: pose detection in frame analysis
author
nate
date
4 months ago
(Feb 5, 2026, 9:57 AM +0200)
commit
9ff107e5
9ff107e5cd8362d0fc475d622ca8b44b6c271115
parent
beb1e6fc
beb1e6fc56eec4c21a533288f9dc21a9058597d7
+167
-19
4 changed files
Expand all
Collapse all
Unified
Split
posedetection
src
androidMain
kotlin
com
performancecoachlab
posedetection
camera
Utils.android.kt
recording
InputFrame.android.kt
com.performancecoachlab
posedetection
camera
CameraView.android.kt
sample
composeApp
src
commonMain
kotlin
com
nate
posedetection
App.kt
+40
-11
posedetection/src/androidMain/kotlin/com.performancecoachlab/posedetection/camera/CameraView.android.kt
Reviewed
···
68
68
import androidx.camera.core.CameraSelector
69
69
import co.touchlab.kermit.Logger
70
70
import java.util.concurrent.atomic.AtomicBoolean
71
71
+
import java.util.concurrent.atomic.AtomicLong
71
72
72
73
// Data class to hold recording state for each recording ID
73
74
data class RecordingSlot(
···
134
135
var analysisSize by remember { mutableStateOf<Size?>(null) }
135
136
var latestSkeleton by remember { mutableStateOf<Skeleton?>(null) }
136
137
var latestObjects by remember { mutableStateOf<List<AnalysisObject>>(emptyList()) }
138
138
+
139
139
+
// Throttle detectors to avoid doing heavy work every frame.
140
140
+
// Tune these to balance smoothness vs CPU usage.
141
141
+
val objectIntervalMs = 66L // ~10 FPS
142
142
+
val poseIntervalMs = 66L // ~10 FPS
143
143
+
val lastObjectRunAtMs = remember { AtomicLong(0L) }
144
144
+
val lastPoseRunAtMs = remember { AtomicLong(0L) }
137
145
138
146
val options = PoseDetectorOptions.Builder()
139
147
.setDetectorMode(PoseDetectorOptions.STREAM_MODE)
···
275
283
return@setAnalyzer
276
284
}
277
285
278
278
-
val timestamp = System.currentTimeMillis()
286
286
+
val now = System.currentTimeMillis()
279
287
val area = focus
280
280
-
val poseClient = if (currentDetectMode.doPose()) poseDetector else null
281
281
-
val objectClient = if (currentDetectMode.doObject()) objectDetector?.getDetector() else null
288
288
+
289
289
+
val shouldRunObject = currentDetectMode.doObject() &&
290
290
+
(now - lastObjectRunAtMs.get() >= objectIntervalMs)
291
291
+
val shouldRunPose = currentDetectMode.doPose() &&
292
292
+
(now - lastPoseRunAtMs.get() >= poseIntervalMs)
293
293
+
294
294
+
// If neither detector is scheduled to run, just close quickly and reuse last results.
295
295
+
if (!shouldRunObject && !shouldRunPose) {
296
296
+
// still release the gate; no repositories updated
297
297
+
imageProxy.close()
298
298
+
gate.exit()
299
299
+
return@setAnalyzer
300
300
+
}
301
301
+
302
302
+
if (shouldRunObject) lastObjectRunAtMs.set(now)
303
303
+
if (shouldRunPose) lastPoseRunAtMs.set(now)
304
304
+
305
305
+
val poseClient = if (shouldRunPose) poseDetector else null
306
306
+
val objectClient = if (shouldRunObject) objectDetector?.getDetector() else null
282
307
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
283
308
284
284
-
imageProxy.process(objectClient, poseClient, timestamp, area) { analysisResult, frameBitmap ->
309
309
+
imageProxy.process(objectClient, poseClient, now, area) { analysisResult, frameBitmap ->
285
310
try {
286
286
-
// Keep repositories as before.
287
287
-
customObjectRepository.updateCustomObject(analysisResult.objects)
288
288
-
analysisResult.skeleton?.let { skeletonRepository.updateSkeleton(it) }
311
311
+
// Only update repositories/results for detectors that actually ran.
312
312
+
if (shouldRunObject) {
313
313
+
customObjectRepository.updateCustomObject(analysisResult.objects)
314
314
+
latestObjects = analysisResult.objects
315
315
+
}
316
316
+
if (shouldRunPose) {
317
317
+
analysisResult.skeleton?.let { skeletonRepository.updateSkeleton(it) }
318
318
+
latestSkeleton = if (drawSkeleton) analysisResult.skeleton else null
319
319
+
}
289
320
290
290
-
// Update UI overlay state.
291
291
-
latestSkeleton = if (drawSkeleton) analysisResult.skeleton else null
292
292
-
latestObjects = analysisResult.objects
321
321
+
// Size is based on the frame bitmap we got back.
293
322
analysisSize = androidx.compose.ui.geometry.Size(
294
323
width = frameBitmap.width.toFloat(),
295
324
height = frameBitmap.height.toFloat()
···
312
341
// Only convert to ImageBitmap when we're actually recording.
313
342
if (activeRecordings.isNotEmpty()) {
314
343
val inbmp = frameBitmap.asImageBitmap()
315
315
-
addFrameToActiveRecordings(inbmp, timestamp)
344
344
+
addFrameToActiveRecordings(inbmp, now)
316
345
}
317
346
} finally {
318
347
gate.exit()
+7
-1
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/camera/Utils.android.kt
Reviewed
···
258
258
onComplete = onComplete
259
259
)
260
260
}
261
261
-
private fun process(
261
261
+
262
262
+
// Core processing used by both live camera (ImageProxy) and offline frames (FrameAnalyser).
263
263
+
fun process(
262
264
tensorImage: TensorImage?,
263
265
mlKitImage: InputImage?,
264
266
objectDetector: Interpreter?,
···
343
345
val landmarks = pose.allPoseLandmarks.size
344
346
Logger.d { "MLKit pose landmarks=$landmarks" }
345
347
skeleton(pose, timestamp, width, height)
348
348
+
}.onFailure { t ->
349
349
+
Logger.e(t) { "MLKit poseDetector.process failed" }
346
350
}.getOrNull()
347
351
} else null
348
352
···
531
535
val cls: Int,
532
536
val clsName: String
533
537
)
538
538
+
539
539
+
534
540
535
541
536
542
+118
-5
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/recording/InputFrame.android.kt
Reviewed
···
1
1
package com.performancecoachlab.posedetection.recording
2
2
3
3
import android.graphics.Bitmap
4
4
+
import android.graphics.Canvas
5
5
+
import android.graphics.Paint
4
6
import androidx.compose.ui.geometry.Rect
5
7
import androidx.compose.ui.graphics.ImageBitmap
6
8
import androidx.compose.ui.graphics.asImageBitmap
9
9
+
import com.google.mlkit.vision.common.InputImage
7
10
import com.google.mlkit.vision.pose.PoseDetection
8
11
import com.google.mlkit.vision.pose.defaults.PoseDetectorOptions
12
12
+
import com.performancecoachlab.posedetection.camera.applyFocusAreaMaskPooled
9
13
import com.performancecoachlab.posedetection.camera.drawAnalysisResults
10
14
import com.performancecoachlab.posedetection.camera.drawSkeleton
11
11
-
import com.performancecoachlab.posedetection.camera.process
12
15
import com.performancecoachlab.posedetection.custom.ObjectModel
13
16
import com.performancecoachlab.posedetection.skeleton.Skeleton
14
17
import kotlinx.coroutines.suspendCancellableCoroutine
18
18
+
import org.tensorflow.lite.DataType
19
19
+
import org.tensorflow.lite.support.common.ops.CastOp
20
20
+
import org.tensorflow.lite.support.common.ops.NormalizeOp
21
21
+
import org.tensorflow.lite.support.image.ImageProcessor
22
22
+
import org.tensorflow.lite.support.image.TensorImage
15
23
import kotlin.coroutines.resume
24
24
+
import kotlin.math.max
25
25
+
import androidx.core.graphics.createBitmap
26
26
+
import co.touchlab.kermit.Logger
27
27
+
import com.google.android.gms.tasks.Tasks
28
28
+
import com.performancecoachlab.posedetection.camera.process
29
29
+
import kotlinx.coroutines.Dispatchers
30
30
+
import kotlinx.coroutines.launch
16
31
17
32
actual class InputFrame(val bitmap: Bitmap, actual val timestamp: Long) {
18
33
actual fun toImageBitmap(): ImageBitmap {
···
52
67
PoseDetectorOptions.Builder().setDetectorMode(PoseDetectorOptions.STREAM_MODE).build()
53
68
private val poseDetector = PoseDetection.getClient(options)
54
69
private val objDetector = model?.getDetector()
70
70
+
71
71
+
private val imageProcessor = ImageProcessor.Builder()
72
72
+
.add(NormalizeOp(0f, 255f))
73
73
+
.add(CastOp(DataType.FLOAT32))
74
74
+
.build()
75
75
+
76
76
+
private var tensorBitmap: Bitmap? = null
77
77
+
private var tensorW: Int = 0
78
78
+
private var tensorH: Int = 0
79
79
+
80
80
+
private fun ensureTensorBitmap(width: Int, height: Int): Bitmap {
81
81
+
val existing = tensorBitmap
82
82
+
return if (existing != null && !existing.isRecycled && tensorW == width && tensorH == height) {
83
83
+
existing
84
84
+
} else {
85
85
+
androidx.core.graphics.createBitmap(width, height, Bitmap.Config.ARGB_8888).also {
86
86
+
tensorBitmap = it
87
87
+
tensorW = width
88
88
+
tensorH = height
89
89
+
}
90
90
+
}
91
91
+
}
92
92
+
private fun toPoseBitmap(src: Bitmap): Bitmap {
93
93
+
val argb8888 = if (src.config == Bitmap.Config.ARGB_8888) src else src.copy(Bitmap.Config.ARGB_8888, false)
94
94
+
95
95
+
val minSide = 480
96
96
+
val w = argb8888.width
97
97
+
val h = argb8888.height
98
98
+
val scale = max(minSide.toFloat() / w.toFloat(), minSide.toFloat() / h.toFloat())
99
99
+
if (scale <= 1f) return argb8888
100
100
+
101
101
+
val outW = (w * scale).toInt().coerceAtLeast(1)
102
102
+
val outH = (h * scale).toInt().coerceAtLeast(1)
103
103
+
val resized = createBitmap(outW, outH)
104
104
+
Canvas(resized).drawBitmap(
105
105
+
argb8888,
106
106
+
null,
107
107
+
android.graphics.Rect(0, 0, outW, outH),
108
108
+
Paint(Paint.FILTER_BITMAP_FLAG)
109
109
+
)
110
110
+
return resized
111
111
+
}
112
112
+
55
113
actual suspend fun analyseFrame(inputFrame: InputFrame, focusArea: Rect?): AnalysisResult =
56
114
suspendCancellableCoroutine { continuation ->
57
57
-
inputFrame.bitmap.process(
58
58
-
objDetector, poseDetector, inputFrame.timestamp,focusArea
59
59
-
) { result, bitmap ->
60
60
-
continuation.resume(result)
115
115
+
val interpreter = objDetector
116
116
+
117
117
+
// Pose: keep original resolution (optionally masked) for ML Kit.
118
118
+
val masked = inputFrame.bitmap.applyFocusAreaMaskPooled(focusArea, 0)
119
119
+
val mlKitImage = InputImage.fromBitmap(masked, 0)
120
120
+
121
121
+
// Object: resize to model input size, then normalize.
122
122
+
val tensorImage: TensorImage? = interpreter?.let { tfl ->
123
123
+
val shape = tfl.getInputTensor(0)?.shape()
124
124
+
var w = 0
125
125
+
var h = 0
126
126
+
if (shape != null) {
127
127
+
w = shape[1]
128
128
+
h = shape[2]
129
129
+
if (shape[1] == 3) {
130
130
+
w = shape[2]
131
131
+
h = shape[3]
132
132
+
}
133
133
+
}
134
134
+
if (w <= 0 || h <= 0) null else {
135
135
+
val dst = ensureTensorBitmap(w, h)
136
136
+
// Draw scaled into dst (no new allocations).
137
137
+
android.graphics.Canvas(dst).drawBitmap(
138
138
+
inputFrame.bitmap,
139
139
+
null,
140
140
+
android.graphics.Rect(0, 0, w, h),
141
141
+
android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG)
142
142
+
)
143
143
+
TensorImage(DataType.FLOAT32).also { ti -> ti.load(dst) }.let(imageProcessor::process)
144
144
+
}
145
145
+
}
146
146
+
continuation.context.let { ctx ->
147
147
+
kotlinx.coroutines.CoroutineScope(ctx).launch(Dispatchers.Default) {
148
148
+
try {
149
149
+
process(
150
150
+
tensorImage = tensorImage,
151
151
+
mlKitImage = mlKitImage,
152
152
+
objectDetector = interpreter,
153
153
+
poseDetector = poseDetector,
154
154
+
timestamp = inputFrame.timestamp,
155
155
+
width = inputFrame.bitmap.width,
156
156
+
height = inputFrame.bitmap.height,
157
157
+
bitmap = inputFrame.bitmap,
158
158
+
onComplete = { result, _ ->
159
159
+
if (continuation.isActive) continuation.resume(result)
160
160
+
}
161
161
+
)
162
162
+
} catch (t: Throwable) {
163
163
+
Logger.e(t) { "Offline analyseFrame: process() crashed" }
164
164
+
if (continuation.isActive) {
165
165
+
continuation.resume(
166
166
+
AnalysisResult(
167
167
+
skeleton = null,
168
168
+
objects = emptyList()
169
169
+
)
170
170
+
)
171
171
+
}
172
172
+
}
173
173
+
}
61
174
}
62
175
}
63
176
}
+2
-2
sample/composeApp/src/commonMain/kotlin/com/nate/posedetection/App.kt
Reviewed
···
80
80
import kotlin.time.Clock
81
81
import kotlin.time.ExperimentalTime
82
82
83
83
-
val androidPath = "hoops.tflite"
84
84
-
val iosPath = "YOLOv3FP16"
83
83
+
val androidPath = "basketballs_n1.tflite"
84
84
+
val iosPath = "basketballs_n1"
85
85
@Composable
86
86
internal fun App() = AppTheme {
87
87
var selectedTabIndex by remember { mutableStateOf(0) }