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
feat: draw text labels in drawobjects
author
nate
date
5 months ago
(Jan 6, 2026, 11:55 PM +0200)
commit
4a7b98b0
4a7b98b0e0a4ee939c1f58d575978461a76d4f30
parent
9fafb791
9fafb791e8bb85caedba7cd59773eeee7eb61a95
+206
-22
8 changed files
Expand all
Collapse all
Unified
Split
posedetection
src
androidMain
kotlin
com
performancecoachlab
posedetection
camera
LabelTextAndroid.kt
Utils.android.kt
commonMain
kotlin
com
performancecoachlab
posedetection
camera
CameraView.kt
Utils.kt
iosMain
kotlin
com
performancecoachlab
posedetection
camera
CameraEngine.kt
CameraView.ios.kt
FrameProcessor.kt
LabelTextIos.kt
+61
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/camera/LabelTextAndroid.kt
Reviewed
···
1
1
+
package com.performancecoachlab.posedetection.camera
2
2
+
3
3
+
import androidx.compose.ui.graphics.drawscope.DrawScope
4
4
+
import androidx.compose.ui.graphics.nativeCanvas
5
5
+
import androidx.compose.ui.text.TextMeasurer
6
6
+
7
7
+
actual fun DrawScope.drawLabelTextPlatform(
8
8
+
drawableObject: DrawableObject,
9
9
+
textMeasurer: TextMeasurer?
10
10
+
) {
11
11
+
val label = drawableObject.obj.labels.firstOrNull()?.text ?: "Object"
12
12
+
if (label.isEmpty()) return
13
13
+
14
14
+
val box = drawableObject.obj.boundingBox
15
15
+
val boxLeft = box.left
16
16
+
val boxTop = box.top
17
17
+
val boxWidth = box.width
18
18
+
val boxHeight = box.height
19
19
+
20
20
+
if (boxWidth <= 0f || boxHeight <= 0f) return
21
21
+
22
22
+
// Padding inside the box so text doesn't touch the edges
23
23
+
val paddingX = boxWidth * 0.08f
24
24
+
val paddingY = boxHeight * 0.12f
25
25
+
26
26
+
val availableWidth = (boxWidth - 2 * paddingX).coerceAtLeast(1f)
27
27
+
val availableHeight = (boxHeight - 2 * paddingY).coerceAtLeast(1f)
28
28
+
29
29
+
// Start with a reasonable text size relative to box height
30
30
+
val baseTextSizePx = (boxHeight * 0.5f).coerceAtLeast(8f)
31
31
+
32
32
+
val paint = android.graphics.Paint().apply {
33
33
+
isAntiAlias = true
34
34
+
color = android.graphics.Color.WHITE
35
35
+
textAlign = android.graphics.Paint.Align.LEFT
36
36
+
textSize = baseTextSizePx
37
37
+
}
38
38
+
39
39
+
val textBounds = android.graphics.Rect()
40
40
+
paint.getTextBounds(label, 0, label.length, textBounds)
41
41
+
var textWidth = textBounds.width().toFloat().coerceAtLeast(1f)
42
42
+
var textHeight = textBounds.height().toFloat().coerceAtLeast(1f)
43
43
+
44
44
+
// Compute scale factors to fit text within available area
45
45
+
val scaleX = availableWidth / textWidth
46
46
+
val scaleY = availableHeight / textHeight
47
47
+
val scale = minOf(scaleX, scaleY, 1f) // never scale up, only down
48
48
+
49
49
+
if (scale < 1f) {
50
50
+
paint.textSize = baseTextSizePx * scale
51
51
+
paint.getTextBounds(label, 0, label.length, textBounds)
52
52
+
textWidth = textBounds.width().toFloat()
53
53
+
textHeight = textBounds.height().toFloat()
54
54
+
}
55
55
+
56
56
+
// Center text within the box, respecting padding
57
57
+
val x = boxLeft + paddingX + (availableWidth - textWidth) / 2f
58
58
+
val y = boxTop + paddingY + (availableHeight + textHeight) / 2f
59
59
+
60
60
+
drawContext.canvas.nativeCanvas.drawText(label, x, y, paint)
61
61
+
}
+3
-2
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/camera/Utils.android.kt
Reviewed
···
13
13
import com.performancecoachlab.posedetection.recording.FrameSize
14
14
import com.performancecoachlab.posedetection.skeleton.Skeleton
15
15
import org.tensorflow.lite.support.image.TensorImage
16
16
+
import kotlin.math.absoluteValue
16
17
17
18
actual enum class PlatformType {
18
19
ANDROID, IOS;
···
195
196
)
196
197
},
197
198
frameSize = FrameSize(
198
198
-
width = width,
199
199
-
height = height
199
199
+
width = width.absoluteValue,
200
200
+
height = height.absoluteValue
200
201
)
201
202
)
202
203
} ?: emptyList()
+1
-1
posedetection/src/commonMain/kotlin/com/performancecoachlab/posedetection/camera/CameraView.kt
Reviewed
···
46
46
)
47
47
48
48
enum class DrawableShape {
49
49
-
OVAL,RECTANGLE
49
49
+
OVAL,RECTANGLE,LABEL
50
50
}
51
51
52
52
data class CameraViewData(
+44
-2
posedetection/src/commonMain/kotlin/com/performancecoachlab/posedetection/camera/Utils.kt
Reviewed
···
1
1
package com.performancecoachlab.posedetection.camera
2
2
3
3
+
import androidx.compose.ui.geometry.Rect
3
4
import androidx.compose.ui.geometry.Size
4
5
import androidx.compose.ui.graphics.BlendMode
5
6
import androidx.compose.ui.graphics.Brush
···
10
11
import androidx.compose.ui.graphics.PaintingStyle.Companion.Fill
11
12
import androidx.compose.ui.graphics.PaintingStyle.Companion.Stroke
12
13
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
14
14
+
import androidx.compose.ui.graphics.drawscope.DrawScope
13
15
import androidx.compose.ui.graphics.drawscope.Stroke
14
16
import androidx.compose.ui.graphics.rotate
17
17
+
import androidx.compose.ui.text.TextMeasurer
15
18
import androidx.compose.ui.unit.Density
16
19
import androidx.compose.ui.unit.LayoutDirection
20
20
+
import co.touchlab.kermit.Logger
17
21
import com.performancecoachlab.posedetection.recording.AnalysisResult
18
22
import com.performancecoachlab.posedetection.skeleton.Skeleton
19
23
···
102
106
}
103
107
104
108
fun ImageBitmap.drawResults(
105
105
-
skeleton: Skeleton?, drawableObjects: List<DrawableObject>
109
109
+
skeleton: Skeleton?, drawableObjects: List<DrawableObject>,
110
110
+
textMeasurer: TextMeasurer? = null
106
111
): ImageBitmap {
107
107
-
also {
112
112
+
also { it ->
108
113
val bitmap = ImageBitmap(it.width, it.height)
109
114
val canvas = Canvas(bitmap)
110
115
val drawScope = CanvasDrawScope()
111
116
val size = Size(it.width.toFloat(), it.height.toFloat())
117
117
+
Logger.d{"Size ${size.width} x ${size.height}"}
112
118
113
119
// Calculate scaling factor based on skeleton size
114
120
val skeletonSize = skeleton?.joints()?.let { joints ->
···
155
161
),
156
162
style = drawableObject.style
157
163
)
164
164
+
165
165
+
DrawableShape.LABEL -> {
166
166
+
// Draw the label background
167
167
+
drawRect(
168
168
+
color = drawableObject.colour,
169
169
+
topLeft = androidx.compose.ui.geometry.Offset(
170
170
+
drawableObject.obj.boundingBox.left,
171
171
+
drawableObject.obj.boundingBox.top
172
172
+
),
173
173
+
size = Size(
174
174
+
drawableObject.obj.boundingBox.width,
175
175
+
drawableObject.obj.boundingBox.height
176
176
+
),
177
177
+
style = drawableObject.style
178
178
+
)
179
179
+
// Defer actual text rendering to platform-specific implementation
180
180
+
drawLabelTextPlatform(
181
181
+
drawableObject = drawableObject,
182
182
+
textMeasurer = textMeasurer
183
183
+
)
184
184
+
}
158
185
}
159
186
}
160
187
skeleton?.apply {
···
296
323
return bitmap
297
324
}
298
325
}
326
326
+
327
327
+
fun Rect.normalize(): Rect {
328
328
+
return Rect(
329
329
+
left = minOf(this.left, this.right),
330
330
+
top = minOf(this.top, this.bottom),
331
331
+
right = maxOf(this.left, this.right),
332
332
+
bottom = maxOf(this.top, this.bottom)
333
333
+
)
334
334
+
}
335
335
+
336
336
+
// Platform-specific label text drawing
337
337
+
expect fun DrawScope.drawLabelTextPlatform(
338
338
+
drawableObject: DrawableObject,
339
339
+
textMeasurer: TextMeasurer?
340
340
+
)
299
341
300
342
expect enum class PlatformType {
301
343
ANDROID, IOS;
+12
-3
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/camera/CameraEngine.kt
Reviewed
···
100
100
import platform.UIKit.UIInterfaceOrientationPortrait
101
101
import platform.UIKit.UIInterfaceOrientationPortraitUpsideDown
102
102
import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol
103
103
+
import kotlin.math.absoluteValue
103
104
104
105
fun interfaceOrientationToVideoOrientation(): AVCaptureVideoOrientation {
105
106
val orientation = UIApplication.sharedApplication.keyWindow?.windowScene?.interfaceOrientation
···
117
118
class CameraEngine : UIViewController(null, null) {
118
119
val cameraController = CameraController()
119
120
private var onVideoSaved: ((String) -> Unit)? = null
121
121
+
122
122
+
private var textMeasurer: androidx.compose.ui.text.TextMeasurer? = null
120
123
121
124
private val memoryManager = MemoryManager
122
125
···
256
259
cameraController.onVideoSaved = callback
257
260
}
258
261
262
262
+
fun setTextMeasurer(textMeasurer: androidx.compose.ui.text.TextMeasurer) {
263
263
+
cameraController.textMeasurer = textMeasurer
264
264
+
}
265
265
+
259
266
fun setDrawOptions(
260
267
drawSkeleton: Boolean, drawObjects: ((List<AnalysisObject>) -> List<DrawableObject>)?
261
268
) {
···
291
298
var onVideoSaved: ((String) -> Unit)? = null
292
299
var drawSkeleton: Boolean = true
293
300
var drawObjects: ((List<AnalysisObject>) -> List<DrawableObject>)? = null
301
301
+
var textMeasurer: androidx.compose.ui.text.TextMeasurer? = null
294
302
295
303
// Serial queue to serialize session configuration and start/stop calls
296
304
private val sessionQueue = dispatch_queue_create("com.performancecoachlab.captureSessionQueue", null)
···
604
612
preview,
605
613
width = 480f,
606
614
height = 360f
607
607
-
).let { FrameSize(it.width.toInt(), it.height.toInt()) }
615
615
+
).let { FrameSize(it.width.toInt().absoluteValue, it.height.toInt().absoluteValue) }
608
616
})
609
617
}
610
618
previewObjects.also { objects ->
···
619
627
bo.first, bo.second
620
628
).drawResults(
621
629
if (drawSkeleton) previewSkeleton else null,
622
622
-
drawObjects?.invoke(previewObjects) ?: emptyList()
630
630
+
drawObjects?.invoke(previewObjects) ?: emptyList(),
631
631
+
textMeasurer = textMeasurer
623
632
).also { drawn ->
624
633
cameraViewController?.setRequestDataProvider {
625
634
CameraViewData(
···
697
706
698
707
val topLeft = mapPoint(box.topLeft)
699
708
val bottomRight = mapPoint(box.bottomRight)
700
700
-
return Rect(topLeft = topLeft, bottomRight = bottomRight)
709
709
+
return Rect(topLeft = topLeft, bottomRight = bottomRight).normalize()
701
710
}
702
711
703
712
override fun captureOutput(
+3
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/camera/CameraView.ios.kt
Reviewed
···
14
14
import androidx.compose.ui.Modifier
15
15
import androidx.compose.ui.geometry.Rect
16
16
import androidx.compose.ui.layout.ContentScale
17
17
+
import androidx.compose.ui.text.rememberTextMeasurer
17
18
import com.performancecoachlab.posedetection.custom.CustomObjectRespository
18
19
import com.performancecoachlab.posedetection.custom.ObjectModel
19
20
import com.performancecoachlab.posedetection.recording.AnalysisObject
···
45
46
val frameBitmap by frameListener.frameFlow.collectAsState()
46
47
var lastRecordingState by remember { mutableStateOf(false) }
47
48
var idMap by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
49
49
+
val textMeasurer = rememberTextMeasurer()
48
50
LaunchedEffect(detectMode) {
49
51
cameraEngine.value?.setDetectMode(detectMode)
50
52
}
···
66
68
drawSkeleton = drawSkeleton,
67
69
drawObjects = drawObjects,
68
70
)
71
71
+
setTextMeasurer(textMeasurer)
69
72
70
73
if (recordingId != null) {
71
74
(if(lastRecordingState) splitRecording()
+17
-14
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/camera/FrameProcessor.kt
Reviewed
···
54
54
import platform.Vision.VNRecognizedPoint
55
55
import platform.Vision.VNRequest
56
56
import kotlin.experimental.ExperimentalNativeApi
57
57
+
import kotlin.math.absoluteValue
58
58
+
import kotlin.math.max
59
59
+
import kotlin.math.min
57
60
import kotlin.native.identityHashCode
58
61
59
62
fun bodyPoseHandler(request: VNRequest): List<MutableMap<VNHumanBodyPoseObservationJointName, VNRecognizedPoint>>? {
···
221
224
val right = (origin.x + size.width) * w
222
225
val bottom = (1.0 - (origin.y + size.height)) * h
223
226
Rect(
224
224
-
left = left.toFloat(),
225
225
-
top = top.toFloat(),
226
226
-
right = right.toFloat(),
227
227
-
bottom = bottom.toFloat()
227
227
+
left = min(left,right).toFloat(),
228
228
+
top = min(top,bottom).toFloat(),
229
229
+
right = max(left,right).toFloat(),
230
230
+
bottom = max(top,bottom).toFloat()
228
231
)
229
232
}
230
233
val labels = observation.labels.mapNotNull {
···
238
241
AnalysisObject(
239
242
trackingId = observation.identityHashCode(),
240
243
labels = labels,
241
241
-
boundingBox = boundingBox,
244
244
+
boundingBox = boundingBox.normalize(),
242
245
frameSize = FrameSize(
243
243
-
width = width.toInt(),
244
244
-
height = height.toInt()
246
246
+
width = width.toInt().absoluteValue,
247
247
+
height = height.toInt().absoluteValue
245
248
)
246
249
)
247
250
}
···
400
403
val right = (origin.x + size.width) * w
401
404
val bottom = (1.0 - (origin.y + size.height)) * h
402
405
Rect(
403
403
-
left = left.toFloat(),
404
404
-
top = top.toFloat(),
405
405
-
right = right.toFloat(),
406
406
-
bottom = bottom.toFloat()
406
406
+
left = min(left,right).toFloat(),
407
407
+
top = min(top,bottom).toFloat(),
408
408
+
right = max(left,right).toFloat(),
409
409
+
bottom = max(top,bottom).toFloat()
407
410
)
408
411
}
409
412
val labels = observation.labels.mapNotNull {
···
417
420
AnalysisObject(
418
421
trackingId = observation.identityHashCode(),
419
422
labels = labels,
420
420
-
boundingBox = boundingBox,
423
423
+
boundingBox = boundingBox.normalize(),
421
424
frameSize = FrameSize(
422
422
-
width = width.toInt(),
423
423
-
height = height.toInt()
425
425
+
width = width.toInt().absoluteValue,
426
426
+
height = height.toInt().absoluteValue
424
427
)
425
428
)
426
429
}
+65
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/camera/LabelTextIos.kt
Reviewed
···
1
1
+
package com.performancecoachlab.posedetection.camera
2
2
+
3
3
+
import androidx.compose.ui.geometry.Offset
4
4
+
import androidx.compose.ui.geometry.Size
5
5
+
import androidx.compose.ui.graphics.Color
6
6
+
import androidx.compose.ui.graphics.drawscope.DrawScope
7
7
+
import androidx.compose.ui.graphics.drawscope.clipRect
8
8
+
import androidx.compose.ui.graphics.drawscope.withTransform
9
9
+
import androidx.compose.ui.text.TextLayoutResult
10
10
+
import androidx.compose.ui.text.TextMeasurer
11
11
+
import androidx.compose.ui.text.TextStyle
12
12
+
import androidx.compose.ui.text.drawText
13
13
+
14
14
+
actual fun DrawScope.drawLabelTextPlatform(
15
15
+
drawableObject: DrawableObject,
16
16
+
textMeasurer: TextMeasurer?
17
17
+
) {
18
18
+
val label = drawableObject.obj.labels.firstOrNull()?.text.let { if (it.isNullOrBlank()) "Object" else it }
19
19
+
if (label.isEmpty() || textMeasurer == null) return
20
20
+
21
21
+
val box = drawableObject.obj.boundingBox
22
22
+
val boxLeft = box.left
23
23
+
val boxTop = box.top
24
24
+
val boxWidth = box.width
25
25
+
val boxHeight = box.height
26
26
+
27
27
+
if (boxWidth <= 0f || boxHeight <= 0f) return
28
28
+
29
29
+
// Measure once at a base size and re-use that exact layout result for drawing.
30
30
+
// On iOS, measuring and drawing can diverge if we re-measure or rely on fontSize conversions.
31
31
+
val baseStyle = TextStyle(
32
32
+
color = Color.White,
33
33
+
// Use a stable base size; the final size will come from scaling the canvas.
34
34
+
fontSize = 100f.toSp()
35
35
+
)
36
36
+
37
37
+
val layout: TextLayoutResult = textMeasurer.measure(
38
38
+
text = label,
39
39
+
style = baseStyle,
40
40
+
maxLines = 1
41
41
+
)
42
42
+
43
43
+
val baseWidth = layout.size.width.toFloat()
44
44
+
val baseHeight = layout.size.height.toFloat()
45
45
+
if (baseWidth <= 0f || baseHeight <= 0f) return
46
46
+
47
47
+
val scale = minOf(boxWidth / baseWidth, boxHeight / baseHeight)
48
48
+
val scaledWidth = baseWidth * scale
49
49
+
val scaledHeight = baseHeight * scale
50
50
+
51
51
+
val centerX = boxLeft + boxWidth / 2f
52
52
+
val centerY = boxTop + boxHeight / 2f
53
53
+
val x = centerX - scaledWidth / 2f
54
54
+
val y = centerY - scaledHeight / 2f
55
55
+
56
56
+
withTransform({
57
57
+
// Keep (x,y) fixed; scale relative to that same top-left.
58
58
+
scale(scaleX = scale, scaleY = scale, pivot = Offset(x, y))
59
59
+
}) {
60
60
+
drawText(
61
61
+
textLayoutResult = layout,
62
62
+
topLeft = Offset(x, y)
63
63
+
)
64
64
+
}
65
65
+
}