This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: auto detect models and allow user to switch between them in sample app

+207 -36
+7 -4
posedetection/src/commonMain/kotlin/com/performancecoachlab/posedetection/custom/CustomObjectModel.kt
··· 29 29 internal expect fun platformRememberObjectModel(modelPath: ModelPath): ObjectModel 30 30 31 31 object ObjectModelProvider { 32 + // Cache keyed by ModelPath so switching models works correctly on Android. 32 33 @Volatile 33 - private var cached: ObjectModel? = null 34 + private var cache: Map<ModelPath, ObjectModel> = emptyMap() 34 35 35 36 // Kotlin/Native requires a SynchronizedObject as the lock 36 37 @OptIn(InternalCoroutinesApi::class) ··· 39 40 @OptIn(InternalCoroutinesApi::class) 40 41 @Composable 41 42 fun get(modelPath: ModelPath): ObjectModel { 42 - val fast = cached 43 + val fast = cache[modelPath] 43 44 if (fast != null) return fast 44 45 45 46 return synchronized(lock) { 46 - cached ?: initialiseObjectModel(modelPath).also { cached = it } 47 + cache[modelPath] ?: initialiseObjectModel(modelPath).also { model -> 48 + cache = cache + (modelPath to model) 49 + } 47 50 } 48 51 } 49 52 50 53 @OptIn(InternalCoroutinesApi::class) 51 54 fun clear() { 52 - synchronized(lock) { cached = null } 55 + synchronized(lock) { cache = emptyMap() } 53 56 } 54 57 }
+23
sample/composeApp/src/androidMain/kotlin/com/nate/posedetection/DiscoverModels.android.kt
··· 1 + package com.nate.posedetection 2 + 3 + import androidx.compose.runtime.Composable 4 + import androidx.compose.ui.platform.LocalContext 5 + import com.performancecoachlab.posedetection.custom.ModelPath 6 + 7 + @Composable 8 + actual fun discoverModels(): List<NamedModel> { 9 + val context = LocalContext.current 10 + val assets = context.assets.list("") ?: emptyArray() 11 + return assets 12 + .filter { it.endsWith(".tflite") } 13 + .sorted() 14 + .map { filename -> 15 + val displayName = filename.removeSuffix(".tflite") 16 + .replace('_', ' ') 17 + NamedModel( 18 + name = displayName, 19 + path = ModelPath(androidModelPath = filename) 20 + ) 21 + } 22 + } 23 +
+96 -17
sample/composeApp/src/commonMain/kotlin/com/nate/posedetection/App.kt
··· 85 85 import kotlin.time.Clock 86 86 import kotlin.time.ExperimentalTime 87 87 88 - val androidPath = "yolov10n_float16.tflite" 89 - val iosPath = "YOLOv3FP16" 90 88 91 89 @Composable 92 90 internal fun App() = AppTheme { ··· 121 119 122 120 @Composable 123 121 fun RecordedSample() { 122 + val availableModels = discoverModels() 123 + var selectedModel by remember(availableModels) { mutableStateOf(availableModels.firstOrNull()) } 124 124 var videoFile: PlatformFile? = null 125 125 var path by remember { mutableStateOf("") } 126 126 var extension by remember { mutableStateOf("") } ··· 129 129 var totalTime by remember { mutableStateOf<Long?>(null) } 130 130 131 131 Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { 132 - Button(onClick = { 133 - scope.launch { 134 - videoFile = FileKit.openFilePicker(type = FileKitType.Video) 135 - loading = true 136 - videoFile?.also { vid -> 137 - path = vid.absolutePath() 138 - extension = vid.extension 132 + Row( 133 + verticalAlignment = Alignment.CenterVertically, 134 + horizontalArrangement = Arrangement.spacedBy(8.dp), 135 + ) { 136 + Button(onClick = { 137 + scope.launch { 138 + videoFile = FileKit.openFilePicker(type = FileKitType.Video) 139 + loading = true 140 + videoFile?.also { vid -> 141 + path = vid.absolutePath() 142 + extension = vid.extension 143 + } 144 + loading = false 145 + } 146 + }) { 147 + Text("Select Video") 148 + } 149 + 150 + // Model picker 151 + var modelMenuExpanded by remember { mutableStateOf(false) } 152 + Box { 153 + Button(onClick = { modelMenuExpanded = true }) { 154 + Text(selectedModel?.name ?: "No model", fontSize = 12.sp) 155 + } 156 + DropdownMenu( 157 + expanded = modelMenuExpanded, 158 + onDismissRequest = { modelMenuExpanded = false } 159 + ) { 160 + if (availableModels.isEmpty()) { 161 + DropdownMenuItem(text = { Text("No models found") }, onClick = {}) 162 + } else { 163 + availableModels.forEach { model -> 164 + DropdownMenuItem( 165 + text = { 166 + Row( 167 + verticalAlignment = Alignment.CenterVertically, 168 + horizontalArrangement = Arrangement.spacedBy(8.dp) 169 + ) { 170 + RadioButton( 171 + selected = selectedModel == model, 172 + onClick = null 173 + ) 174 + Text(model.name) 175 + } 176 + }, 177 + onClick = { 178 + selectedModel = model 179 + modelMenuExpanded = false 180 + } 181 + ) 182 + } 183 + } 139 184 } 140 - loading = false 141 185 } 142 - }) { 143 - Text("Select Video") 144 186 } 145 187 AnimatedContent(path) { 146 188 if (it.isNotBlank()) { ··· 150 192 }) 151 193 totalTime?.also { duration -> 152 194 FrameAnalysis( 153 - url = path, modifier = Modifier.weight(1f), duration = duration 195 + url = path, 196 + modifier = Modifier.weight(1f), 197 + duration = duration, 198 + selectedModel = selectedModel, 154 199 ) 155 200 } 156 201 ··· 163 208 164 209 @Composable 165 210 fun FrameAnalysis( 166 - url: String, modifier: Modifier = Modifier, duration: Long 211 + url: String, modifier: Modifier = Modifier, duration: Long, selectedModel: NamedModel? 167 212 ) { 168 213 val coroutineScope = rememberCoroutineScope() 169 214 var currentJob by remember { mutableStateOf<Job?>(null) } 170 215 var image by remember { mutableStateOf<InputFrame?>(null) } 171 216 val timeRange = Pair(0L, duration) 172 217 var frame by remember { mutableStateOf(timeRange.first) } 173 - val generalModel = rememberObjectModel(ModelPath(androidPath, iosPath)) 174 - val frameAnalyser by remember { mutableStateOf(FrameAnalyser(generalModel)) } 218 + val modelPath = selectedModel?.path ?: ModelPath() 219 + val generalModel = rememberObjectModel(modelPath) 220 + val frameAnalyser by remember(modelPath) { mutableStateOf(FrameAnalyser(generalModel)) } 175 221 var bitmap by remember { mutableStateOf<ImageBitmap?>(null) } 176 222 val videoBuilder = remember { mutableStateOf<VideoBuilder?>(null) } 177 223 var isRecording by remember { mutableStateOf(false) } ··· 317 363 @OptIn(ExperimentalTime::class) 318 364 @Composable 319 365 fun CameraSample() { 366 + val availableModels = discoverModels() 320 367 val skeletonRepository = remember { SkeletonRepository() } 321 368 val customObjectRespository = remember { CustomObjectRespository() } 322 369 val skeleton by skeletonRepository.skeletonFlow.collectAsState() ··· 324 371 var permissionGranted by remember { mutableStateOf(false) } 325 372 var recordingId: String? by remember { mutableStateOf(null) } 326 373 var path by remember { mutableStateOf("") } 327 - val generalModel = rememberObjectModel(ModelPath(androidPath, iosPath)) 374 + var selectedModel by remember(availableModels) { mutableStateOf(availableModels.firstOrNull()) } 375 + val generalModel = rememberObjectModel(selectedModel?.path ?: ModelPath()) 328 376 var frontCamera by remember { mutableStateOf(false) } 329 377 var ultrawide by remember { mutableStateOf(false) } 330 378 var previewFillMode by remember { mutableStateOf(PreviewFillMode.FIT) } ··· 514 562 onClick = { 515 563 detectMode = DetectMode.NONE 516 564 }) 565 + } 566 + } 567 + }, onClick = {}) 568 + 569 + HorizontalDivider() 570 + 571 + // Model picker — populated dynamically from bundled resources 572 + DropdownMenuItem(text = { 573 + Column { 574 + Text( 575 + "Model", 576 + modifier = Modifier.fillMaxWidth(), 577 + textAlign = TextAlign.Center 578 + ) 579 + if (availableModels.isEmpty()) { 580 + Text("No models found", fontSize = 12.sp) 581 + } else { 582 + availableModels.forEach { model -> 583 + Row( 584 + horizontalArrangement = Arrangement.SpaceBetween, 585 + verticalAlignment = Alignment.CenterVertically, 586 + modifier = Modifier.fillMaxWidth() 587 + ) { 588 + Text(text = model.name, fontSize = 13.sp) 589 + Spacer(Modifier.width(12.dp)) 590 + RadioButton( 591 + selected = selectedModel == model, 592 + onClick = { selectedModel = model } 593 + ) 594 + } 595 + } 517 596 } 518 597 } 519 598 }, onClick = {})
+18
sample/composeApp/src/commonMain/kotlin/com/nate/posedetection/DiscoverModels.kt
··· 1 + package com.nate.posedetection 2 + 3 + import androidx.compose.runtime.Composable 4 + import com.performancecoachlab.posedetection.custom.ModelPath 5 + 6 + /** 7 + * A discovered model with a human-readable display name and its platform paths. 8 + */ 9 + data class NamedModel(val name: String, val path: ModelPath) 10 + 11 + /** 12 + * Returns all models bundled with the app by scanning platform resources at runtime. 13 + * Android scans the assets folder for .tflite files. 14 + * iOS scans the main bundle for compiled .mlmodelc resources. 15 + */ 16 + @Composable 17 + expect fun discoverModels(): List<NamedModel> 18 +
+30
sample/composeApp/src/iosMain/kotlin/com/nate/posedetection/DiscoverModels.ios.kt
··· 1 + package com.nate.posedetection 2 + 3 + import androidx.compose.runtime.Composable 4 + import com.performancecoachlab.posedetection.custom.ModelPath 5 + import platform.Foundation.NSBundle 6 + import platform.Foundation.NSURL 7 + 8 + // Scans the main bundle for compiled Core ML models (.mlmodelc) at runtime. 9 + @Composable 10 + actual fun discoverModels(): List<NamedModel> { 11 + val bundle = NSBundle.mainBundle 12 + // pathsForResourcesOfType returns full paths to every .mlmodelc bundle in the app 13 + @Suppress("UNCHECKED_CAST") 14 + val paths = bundle.pathsForResourcesOfType("mlmodelc", inDirectory = null) as List<String> 15 + return paths 16 + .map { path -> 17 + // e.g. ".../balls_full.mlmodelc" -> "balls_full" 18 + val resourceName = NSURL.fileURLWithPath(path) 19 + .lastPathComponent 20 + ?.removeSuffix(".mlmodelc") 21 + ?: path 22 + val displayName = resourceName.replace("_", " ") 23 + NamedModel( 24 + name = displayName, 25 + path = ModelPath(iosModelPath = resourceName) 26 + ) 27 + } 28 + .sortedBy { it.name } 29 + } 30 +
+15 -15
sample/iosApp/FastViTT8F16.mlpackage/Manifest.json
··· 1 1 { 2 - "fileFormatVersion": "1.0.0", 3 - "itemInfoEntries": { 4 - "76187EC5-87E5-4263-B6E2-1CF5E747A0EE": { 5 - "author": "com.apple.CoreML", 6 - "description": "CoreML Model Weights", 7 - "name": "weights", 8 - "path": "com.apple.CoreML/weights" 9 - }, 10 - "D3756FF7-6CCB-4582-AB58-B91896E60AE4": { 11 - "author": "com.apple.CoreML", 12 - "description": "CoreML Model Specification", 13 - "name": "model.mlmodel", 14 - "path": "com.apple.CoreML/model.mlmodel" 15 - } 2 + "fileFormatVersion": "1.0.0", 3 + "itemInfoEntries": { 4 + "76187EC5-87E5-4263-B6E2-1CF5E747A0EE": { 5 + "author": "com.apple.CoreML", 6 + "description": "CoreML Model Weights", 7 + "name": "weights", 8 + "path": "com.apple.CoreML/weights" 16 9 }, 17 - "rootModelIdentifier": "D3756FF7-6CCB-4582-AB58-B91896E60AE4" 10 + "D3756FF7-6CCB-4582-AB58-B91896E60AE4": { 11 + "author": "com.apple.CoreML", 12 + "description": "CoreML Model Specification", 13 + "name": "model.mlmodel", 14 + "path": "com.apple.CoreML/model.mlmodel" 15 + } 16 + }, 17 + "rootModelIdentifier": "D3756FF7-6CCB-4582-AB58-B91896E60AE4" 18 18 }
sample/iosApp/iosApp/models/yolo11s_9.mlpackage/Data/com.apple.CoreML/model.mlmodel

This is a binary file and will not be displayed.

sample/iosApp/iosApp/models/yolo11s_9.mlpackage/Data/com.apple.CoreML/weights/weight.bin

This is a binary file and will not be displayed.

+18
sample/iosApp/iosApp/models/yolo11s_9.mlpackage/Manifest.json
··· 1 + { 2 + "fileFormatVersion": "1.0.0", 3 + "itemInfoEntries": { 4 + "3441f189-3f0c-4f67-8c0d-2a0eb42f6158": { 5 + "author": "com.apple.CoreML", 6 + "description": "CoreML Model Specification", 7 + "name": "model.mlmodel", 8 + "path": "com.apple.CoreML/model.mlmodel" 9 + }, 10 + "f68a8742-864f-4d14-879c-e9e89f44967c": { 11 + "author": "com.apple.CoreML", 12 + "description": "CoreML Model Weights", 13 + "name": "weights", 14 + "path": "com.apple.CoreML/weights" 15 + } 16 + }, 17 + "rootModelIdentifier": "3441f189-3f0c-4f67-8c0d-2a0eb42f6158" 18 + }