posedetection
src
androidMain
kotlin
com
com.performancecoachlab
···
60
60
import androidx.camera.core.CameraInfo
61
61
import androidx.camera.core.CameraSelector
62
62
import co.touchlab.kermit.Logger
63
63
+
import java.util.concurrent.atomic.AtomicBoolean
63
64
64
65
// Data class to hold recording state for each recording ID
65
66
data class RecordingSlot(
···
235
236
236
237
}
237
238
239
239
+
val gate = remember { FrameGate() }
240
240
+
238
241
LaunchedEffect(lifecycleOwner, frontCamera) {
239
242
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
240
243
cameraProvider.unbindAll()
···
248
251
val preview = Preview.Builder().build().also {
249
252
it.surfaceProvider = previewView.surfaceProvider
250
253
}
251
251
-
val imageAnalysis = ImageAnalysis.Builder().build().also { analysis ->
252
252
-
analysis.setAnalyzer(executor) { imageProxy ->
253
253
-
val timestamp = System.currentTimeMillis()
254
254
-
val area = focus
255
255
-
val poseClient = if (currentDetectMode.doPose()) poseDetector else null
256
256
-
val objectClient = if (currentDetectMode.doObject()) objectDetector?.getDetector() else null
257
257
-
imageProxy.process(
258
258
-
objectClient, poseClient, timestamp, area
259
259
-
){ analysisResult, _bitmap ->
260
260
-
customObjectRepository.updateCustomObject(analysisResult.objects)
261
261
-
analysisResult.skeleton?.let { skel ->
262
262
-
skeletonRepository.updateSkeleton(skel)
254
254
+
val imageAnalysis = ImageAnalysis.Builder()
255
255
+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
256
256
+
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
257
257
+
.build()
258
258
+
.also { analysis ->
259
259
+
analysis.setAnalyzer(executor) { imageProxy ->
260
260
+
// Drop frames while we're still working on the previous one.
261
261
+
if (!gate.tryEnter()) {
262
262
+
imageProxy.close()
263
263
+
return@setAnalyzer
263
264
}
264
264
-
bitmap = _bitmap.asImageBitmap().let { inbmp ->
265
265
+
266
266
+
val timestamp = System.currentTimeMillis()
267
267
+
val area = focus
268
268
+
val poseClient = if (currentDetectMode.doPose()) poseDetector else null
269
269
+
val objectClient = if (currentDetectMode.doObject()) objectDetector?.getDetector() else null
270
270
+
imageProxy.process(
271
271
+
objectClient, poseClient, timestamp, area
272
272
+
) { analysisResult, _bitmap ->
273
273
+
try {
274
274
+
customObjectRepository.updateCustomObject(analysisResult.objects)
275
275
+
analysisResult.skeleton?.let { skel ->
276
276
+
skeletonRepository.updateSkeleton(skel)
277
277
+
}
278
278
+
bitmap = _bitmap.asImageBitmap().let { inbmp ->
265
279
controller?.setRequestDataProvider {
266
280
CameraViewData(
267
281
width = inbmp.width.toFloat(),
···
276
290
)
277
291
}
278
292
addFrameToActiveRecordings(inbmp, timestamp)
279
279
-
inbmp.drawResults(if(drawSkeleton) analysisResult.skeleton else null, drawObjects?.invoke(analysisResult.objects)?: emptyList())
293
293
+
inbmp.drawResults(
294
294
+
if (drawSkeleton) analysisResult.skeleton else null,
295
295
+
drawObjects?.invoke(analysisResult.objects) ?: emptyList()
296
296
+
)
280
297
}
281
281
-
imageProxy.close()
298
298
+
} finally {
299
299
+
gate.exit()
300
300
+
}
301
301
+
}
282
302
}
283
303
}
284
284
-
}
285
304
val camera = cameraProvider.bindToLifecycle(
286
305
lifecycleOwner,
287
306
cameraSelector,
···
366
385
height = height.toFloat(),
367
386
)
368
387
}
388
388
+
389
389
+
private class FrameGate {
390
390
+
private val busy = AtomicBoolean(false)
391
391
+
fun tryEnter(): Boolean = busy.compareAndSet(false, true)
392
392
+
fun exit() { busy.set(false) }
393
393
+
}
···
68
68
}
69
69
}
70
70
71
71
+
/**
72
72
+
* Separate pool for analysis/masking bitmaps (must match analysisBitmap size).
73
73
+
* We keep a single instance because analysis runs on a single-thread executor.
74
74
+
*/
75
75
+
private object AnalysisBitmapPool {
76
76
+
private var cached: Bitmap? = null
77
77
+
private var cachedW: Int = 0
78
78
+
private var cachedH: Int = 0
79
79
+
private var cachedConfig: Bitmap.Config = Bitmap.Config.ARGB_8888
80
80
+
81
81
+
fun obtain(width: Int, height: Int, config: Bitmap.Config = Bitmap.Config.ARGB_8888): Bitmap {
82
82
+
val bmp = cached
83
83
+
return if (
84
84
+
bmp != null &&
85
85
+
!bmp.isRecycled &&
86
86
+
cachedW == width &&
87
87
+
cachedH == height &&
88
88
+
cachedConfig == config
89
89
+
) {
90
90
+
// Clear the bitmap; canvas draw will overwrite but masking may not cover entire area.
91
91
+
bmp.eraseColor(android.graphics.Color.TRANSPARENT)
92
92
+
bmp
93
93
+
} else {
94
94
+
createBitmap(width, height, config).also { newBmp ->
95
95
+
cached = newBmp
96
96
+
cachedW = width
97
97
+
cachedH = height
98
98
+
cachedConfig = config
99
99
+
}
100
100
+
}
101
101
+
}
102
102
+
}
103
103
+
71
104
private fun Bitmap.rotateToNew(degrees: Int): Bitmap {
72
105
if (degrees % 360 == 0) return this
73
106
val m = Matrix().apply { postRotate(degrees.toFloat()) }
···
88
121
focusArea: Rect?,
89
122
onComplete: (AnalysisResult, Bitmap) -> Unit
90
123
) {
91
91
-
if (objectDetector == null && poseDetector == null) return
124
124
+
// If both are null, still close to avoid stalling the camera pipeline.
125
125
+
if (objectDetector == null && poseDetector == null) {
126
126
+
close()
127
127
+
return
128
128
+
}
92
129
93
130
val rotationDegrees = imageInfo.rotationDegrees
94
131
95
95
-
// 1) Create ONE rotated bitmap in "analysis space" (used by MLKit + drawing + coordinate mapping).
132
132
+
// Convert to bitmap ASAP so we can close the ImageProxy immediately.
96
133
val analysisBitmap: Bitmap = toBitmap().rotateToNew(rotationDegrees)
134
134
+
close()
97
135
98
136
// 2) MLKit image must match analysisBitmap coordinate space. Rotation is now 0.
99
137
val mlKitImage: InputImage? = poseDetector?.let {
100
100
-
val masked = analysisBitmap.applyFocusAreaMask(focusArea, rotationDegrees)
138
138
+
val masked = analysisBitmap.applyFocusAreaMaskPooled(focusArea, rotationDegrees)
101
139
InputImage.fromBitmap(masked, 0)
102
140
}
103
141
···
126
164
}
127
165
}
128
166
129
129
-
// If no objectDetector, we still want pose results; if no poseDetector, we still want objects.
130
167
process(
131
168
tensorImage = processedTensorImage,
132
169
mlKitImage = mlKitImage,
···
298
335
299
336
300
337
fun Bitmap.applyFocusAreaMask(focusArea: Rect?, angle: Int = 0): Bitmap {
338
338
+
// Keep old API for call sites that truly want a new bitmap.
301
339
return focusArea?.let { rect ->
302
340
val result = this.copy(this.config ?: Bitmap.Config.ARGB_8888, true)
303
341
val canvas = Canvas(result)
···
312
350
right = rect.bottom,
313
351
bottom = 1f - rect.left
314
352
)
353
353
+
315
354
180 -> Rect(
316
355
left = 1f - rect.right,
317
356
top = 1f - rect.bottom,
318
357
right = 1f - rect.left,
319
358
bottom = 1f - rect.top
320
359
)
360
360
+
321
361
270 -> Rect(
322
362
left = 1f - rect.bottom,
323
363
top = rect.left,
324
364
right = 1f - rect.top,
325
365
bottom = rect.right
326
366
)
367
367
+
327
368
else -> rect // 0 degrees or any other angle
328
369
}
329
370
···
336
377
337
378
// Black out bottom area
338
379
if (focusRect.bottom < height) {
339
339
-
canvas.drawRect(0f, focusRect.bottom.toFloat(), width.toFloat(),height.toFloat(), paint)
380
380
+
canvas.drawRect(0f, focusRect.bottom.toFloat(), width.toFloat(), height.toFloat(), paint)
340
381
}
341
382
342
383
// Black out left area
···
351
392
352
393
result
353
394
} ?: this
395
395
+
}
396
396
+
397
397
+
/**
398
398
+
* More efficient variant of applyFocusAreaMask: draws into a pooled bitmap, avoiding Bitmap.copy().
399
399
+
* Returns a bitmap in the same size/coords as the receiver.
400
400
+
*/
401
401
+
fun Bitmap.applyFocusAreaMaskPooled(focusArea: Rect?, angle: Int = 0): Bitmap {
402
402
+
if (focusArea == null) return this
403
403
+
404
404
+
val out = AnalysisBitmapPool.obtain(width, height, this.config ?: Bitmap.Config.ARGB_8888)
405
405
+
val canvas = Canvas(out)
406
406
+
// Draw the source into the pooled bitmap.
407
407
+
canvas.drawBitmap(this, 0f, 0f, null)
408
408
+
409
409
+
val paint = Paint().apply { color = android.graphics.Color.BLACK }
410
410
+
411
411
+
val transformedRect = when (angle % 360) {
412
412
+
90 -> Rect(
413
413
+
left = focusArea.top,
414
414
+
top = 1f - focusArea.right,
415
415
+
right = focusArea.bottom,
416
416
+
bottom = 1f - focusArea.left
417
417
+
)
418
418
+
419
419
+
180 -> Rect(
420
420
+
left = 1f - focusArea.right,
421
421
+
top = 1f - focusArea.bottom,
422
422
+
right = 1f - focusArea.left,
423
423
+
bottom = 1f - focusArea.top
424
424
+
)
425
425
+
426
426
+
270 -> Rect(
427
427
+
left = 1f - focusArea.bottom,
428
428
+
top = focusArea.left,
429
429
+
right = 1f - focusArea.top,
430
430
+
bottom = focusArea.right
431
431
+
)
432
432
+
433
433
+
else -> focusArea
434
434
+
}
435
435
+
436
436
+
val focusRect = transformedRect.toGraphicsRect(width, height)
437
437
+
438
438
+
if (focusRect.top > 0) canvas.drawRect(0f, 0f, width.toFloat(), focusRect.top.toFloat(), paint)
439
439
+
if (focusRect.bottom < height) canvas.drawRect(0f, focusRect.bottom.toFloat(), width.toFloat(), height.toFloat(), paint)
440
440
+
if (focusRect.left > 0) canvas.drawRect(0f, focusRect.top.toFloat(), focusRect.left.toFloat(), focusRect.bottom.toFloat(), paint)
441
441
+
if (focusRect.right < width) canvas.drawRect(focusRect.right.toFloat(), focusRect.top.toFloat(), width.toFloat(), focusRect.bottom.toFloat(), paint)
442
442
+
443
443
+
return out
354
444
}
355
445
356
446
data class BoundingBox(