This repository has no description
0

Configure Feed

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

chore: bump version

+52 -546
+1 -1
README.md
··· 11 11 Import the Compose library 12 12 13 13 ```kotlin 14 - implementation("com.performancecoachlab.posedetection:posedetection-compose:4.9.2") 14 + implementation("com.performancecoachlab.posedetection:posedetection-compose:4.10.0") 15 15 ``` 16 16 17 17 Add camera use to your android manifest
+49 -517
sample/composeApp/src/commonMain/kotlin/com/nate/posedetection/App.kt
··· 2 2 3 3 import androidx.compose.animation.AnimatedContent 4 4 import androidx.compose.foundation.Image 5 - import androidx.compose.foundation.pager.HorizontalPager 6 - import androidx.compose.foundation.pager.rememberPagerState 7 5 import androidx.compose.foundation.layout.Arrangement 8 6 import androidx.compose.foundation.layout.Box 9 7 import androidx.compose.foundation.layout.Column ··· 11 9 import androidx.compose.foundation.layout.Spacer 12 10 import androidx.compose.foundation.layout.WindowInsets 13 11 import androidx.compose.foundation.layout.fillMaxSize 14 - import androidx.compose.ui.layout.onSizeChanged 15 12 import androidx.compose.foundation.layout.fillMaxWidth 16 13 import androidx.compose.foundation.layout.imePadding 17 14 import androidx.compose.foundation.layout.padding ··· 42 39 import androidx.compose.ui.Alignment 43 40 import androidx.compose.ui.Modifier 44 41 import androidx.compose.ui.geometry.Rect 45 - import androidx.compose.ui.geometry.Size 46 - import androidx.compose.ui.graphics.Canvas 47 42 import androidx.compose.ui.graphics.Color 48 43 import androidx.compose.ui.graphics.ImageBitmap 49 - import androidx.compose.ui.graphics.drawscope.CanvasDrawScope 50 44 import androidx.compose.ui.graphics.drawscope.Stroke 51 - import androidx.compose.ui.unit.Density 52 - import androidx.compose.ui.unit.LayoutDirection 53 45 import androidx.compose.ui.layout.ContentScale 54 46 import androidx.compose.ui.text.style.TextAlign 55 47 import androidx.compose.ui.unit.dp ··· 72 64 import com.performancecoachlab.posedetection.encoding.VideoBuilder 73 65 import com.performancecoachlab.posedetection.encoding.createVideoBuilder 74 66 import com.performancecoachlab.posedetection.permissions.PermissionProvider 75 - import com.performancecoachlab.posedetection.recording.AnalysisObject 76 67 import com.performancecoachlab.posedetection.recording.FrameAnalyser 77 68 import com.performancecoachlab.posedetection.recording.InputFrame 78 - import com.performancecoachlab.posedetection.recording.AnalysisResult 79 - import com.performancecoachlab.posedetection.recording.FrameSize 80 69 import com.performancecoachlab.posedetection.recording.extractFrame 81 - import com.performancecoachlab.posedetection.recording.extractFrames 82 70 import com.performancecoachlab.posedetection.recording.listVideoFrameTimestamps 83 71 import com.performancecoachlab.posedetection.skeleton.Pose 84 - import com.performancecoachlab.posedetection.skeleton.Skeleton 85 72 import com.performancecoachlab.posedetection.skeleton.SkeletonRepository 86 73 import io.github.vinceglb.filekit.FileKit 87 74 import io.github.vinceglb.filekit.PlatformFile ··· 92 79 import io.github.vinceglb.filekit.extension 93 80 import io.github.vinceglb.filekit.filesDir 94 81 import io.github.vinceglb.filekit.path 95 - import kotlinx.coroutines.CompletableDeferred 96 82 import kotlinx.coroutines.Job 97 - import kotlinx.coroutines.delay 98 83 import kotlinx.coroutines.launch 99 84 import kotlin.math.roundToLong 100 85 import kotlin.time.Clock ··· 170 155 } 171 156 DropdownMenu( 172 157 expanded = modelMenuExpanded, 173 - onDismissRequest = { modelMenuExpanded = false } 174 - ) { 158 + onDismissRequest = { modelMenuExpanded = false }) { 175 159 if (availableModels.isEmpty()) { 176 160 DropdownMenuItem(text = { Text("No models found") }, onClick = {}) 177 161 } else { 178 162 availableModels.forEach { model -> 179 - DropdownMenuItem( 180 - text = { 181 - Row( 182 - verticalAlignment = Alignment.CenterVertically, 183 - horizontalArrangement = Arrangement.spacedBy(8.dp) 184 - ) { 185 - RadioButton( 186 - selected = selectedModel == model, 187 - onClick = null 188 - ) 189 - Text(model.name) 190 - } 191 - }, 192 - onClick = { 193 - selectedModel = model 194 - modelMenuExpanded = false 163 + DropdownMenuItem(text = { 164 + Row( 165 + verticalAlignment = Alignment.CenterVertically, 166 + horizontalArrangement = Arrangement.spacedBy(8.dp) 167 + ) { 168 + RadioButton( 169 + selected = selectedModel == model, onClick = null 170 + ) 171 + Text(model.name) 195 172 } 196 - ) 173 + }, onClick = { 174 + selectedModel = model 175 + modelMenuExpanded = false 176 + }) 197 177 } 198 178 } 199 179 } ··· 395 375 } 396 376 } 397 377 398 - // DebugTestScreen removed. 399 - // CameraSample starts below. 400 - private object DebugTestScreenRemoved 401 - 402 - @Suppress("unused") 403 - @Composable 404 - private fun debugTestScreenRemoved() { 405 - val availableModels = discoverModels() 406 - var selectedModel by remember(availableModels) { mutableStateOf(availableModels.firstOrNull()) } 407 - val modelPath = selectedModel?.path ?: ModelPath() 408 - val generalModel = rememberObjectModel(modelPath) 409 - 410 - val skeletonRepository = remember { SkeletonRepository() } 411 - val customObjectRepository = remember { CustomObjectRespository() } 412 - val skeleton by skeletonRepository.skeletonFlow.collectAsState() 413 - val customObjects by customObjectRepository.customObjectFlow.collectAsState() 414 - var permissionGranted by remember { mutableStateOf(false) } 415 - var detectMode by remember { mutableStateOf(DetectMode.BOTH) } 416 - 417 - // Test phases: WAITING_PERMISSION -> PREVIEW -> RECORDING -> COMPARING -> DONE 418 - var phase by remember { mutableStateOf("WAITING_PERMISSION") } 419 - var recordingId: String? by remember { mutableStateOf(null) } 420 - var originalVideoPath by remember { mutableStateOf("") } 421 - var processedVideoPath: String? by remember { mutableStateOf(null) } 422 - // Comparison test data 423 - data class ComparisonResult( 424 - val image: ImageBitmap, 425 - val label: String, 426 - val frameTs: Long, // video PTS of this frame 427 - val frameElapsedMs: Long, // elapsed from video start 428 - val expectedElapsedMs: Long, // where the skeleton was expected to match 429 - val liveSkeleton: Skeleton?, 430 - val videoPath: String, 431 - val allTimestamps: List<Long>, 432 - val angleMatch: Float, 433 - val posMatch: Float 434 - ) 435 - var comparisonImages by remember { mutableStateOf<List<ComparisonResult>>(emptyList()) } 436 - var findBestResult by remember { mutableStateOf<Pair<Int, List<Pair<ImageBitmap, String>>>?>(null) } 437 - var statusText by remember { mutableStateOf("Waiting for camera permission...") } 438 - 439 - 440 - // Timing 441 - var recordTimeMs by remember { mutableStateOf(0L) } 442 - var extractTimeMs by remember { mutableStateOf(0L) } 443 - var frameCount by remember { mutableStateOf(0) } 444 - 445 - // Controller to get feed dimensions from CameraView. 446 - val controller = remember { CameraViewControllerImpl() } 447 - var feedWidth by remember { mutableStateOf(0f) } 448 - var feedHeight by remember { mutableStateOf(0f) } 449 - // Actual CameraView display size in pixels (for cropOffsetY calculation) 450 - var cameraViewPxW by remember { mutableStateOf(0f) } 451 - var cameraViewPxH by remember { mutableStateOf(0f) } 452 - 453 - // Saved analysis results during recording, keyed by timestamp ms. 454 - val analysisResults = remember { mutableMapOf<Long, AnalysisResult>() } 455 - // Wall-clock time when recording starts. 456 - var videoStartWallClock by remember { mutableStateOf(0L) } 457 - var recordingActive by remember { mutableStateOf(false) } 458 - 459 - // Deferred to wait for video save callback 460 - val videoSavedDeferred = remember { mutableStateOf<CompletableDeferred<String>?>(null) } 461 - 462 - val coroutineScope = rememberCoroutineScope() 463 - 464 - PermissionProvider().apply { 465 - if (!hasCameraPermission()) RequestCameraPermission(onGranted = { 466 - permissionGranted = true 467 - }, onDenied = { permissionGranted = false }) else permissionGranted = true 468 - } 469 - 470 - // Save skeleton + objects together. Objects don't have their own timestamp, 471 - // so we cache the latest objects and attach them when a skeleton arrives. 472 - var latestObjects by remember { mutableStateOf<List<AnalysisObject>>(emptyList()) } 473 - LaunchedEffect(Unit) { 474 - customObjectRepository.customObjectFlow.collect { objs -> 475 - if (objs != null) latestObjects = objs 476 - } 477 - } 478 - LaunchedEffect(Unit) { 479 - skeletonRepository.skeletonFlow.collect { skel -> 480 - // Only save skeletons after recording has started (videoStartWallClock set by onRecordToggled) 481 - if (recordingActive && videoStartWallClock > 0 && skel != null) { 482 - analysisResults[skel.timestamp] = AnalysisResult(skel, latestObjects) 483 - } 484 - } 485 - } 486 - 487 - // Go to preview once permission is granted 488 - LaunchedEffect(permissionGranted) { 489 - if (permissionGranted && phase == "WAITING_PERMISSION") { 490 - phase = "PREVIEW" 491 - statusText = "Tap Record to start" 492 - } 493 - } 494 - 495 - // Recording trigger — launched when phase becomes RECORDING 496 - LaunchedEffect(phase) { 497 - if (phase == "RECORDING") { 498 - statusText = "Recording 3 seconds..." 499 - analysisResults.clear() 500 - val deferred = CompletableDeferred<String>() 501 - videoSavedDeferred.value = deferred 502 - val recordStart = Clock.System.now().toEpochMilliseconds() 503 - videoStartWallClock = 0L 504 - recordingActive = false 505 - val beforeSetId = System.currentTimeMillis() 506 - println("RECORD-TIMING: before setRecordingId wallMs=$beforeSetId") 507 - recordingId = "${Clock.System.now().epochSeconds}" 508 - val afterSetId = System.currentTimeMillis() 509 - println("RECORD-TIMING: after setRecordingId wallMs=$afterSetId delta=${afterSetId - beforeSetId}ms") 510 - 511 - delay(1500) 512 - 513 - recordingId = null 514 - statusText = "Waiting for video to save..." 515 - val savedPath = deferred.await() 516 - recordTimeMs = Clock.System.now().toEpochMilliseconds() - recordStart 517 - 518 - val goodSkeletons = analysisResults.values.count { it.skeleton != null && it.skeleton!!.joints().size >= 10 } 519 - println("DEBUG: skeleton data: $goodSkeletons good skeletons") 520 - 521 - originalVideoPath = savedPath 522 - phase = "COMPARING" 523 - statusText = "Analysing timestamp relationship..." 524 - 525 - val capturedAnalysisResults = analysisResults.toMap() 526 - val capturedVideoStart = videoStartWallClock 527 - 528 - val extractJob = coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { 529 - val timestamps = listVideoFrameTimestamps(savedPath) 530 - val savedKeys = capturedAnalysisResults.keys.sorted() 531 - val firstTs = timestamps.firstOrNull() ?: return@launch 532 - println("DEBUG: ${timestamps.size} video frames, ${savedKeys.size} analysis results") 533 - println("DEBUG: videoStartWallClock=$capturedVideoStart") 534 - println("DEBUG: video PTS range: ${timestamps.first()}..${timestamps.last()} span=${timestamps.last() - timestamps.first()}ms") 535 - println("DEBUG: skeleton ts range: ${savedKeys.firstOrNull()}..${savedKeys.lastOrNull()} span=${if (savedKeys.size > 1) savedKeys.last() - savedKeys.first() else 0}ms") 536 - 537 - // Diagnostic: compare video PTS against skeleton timestamps 538 - // Video PTS is relative (starts from ~0). Skeleton timestamps are wall-clock. 539 - // If we subtract videoStartWallClock from skeleton timestamps, they should 540 - // become comparable to video PTS. The difference reveals the lag. 541 - val relativeKeys = savedKeys.map { it - capturedVideoStart } 542 - println("DIAG: relative skeleton timestamps (first 10): ${relativeKeys.take(10)}") 543 - println("DIAG: video PTS (first 10): ${timestamps.take(10)}") 544 - 545 - // For each skeleton, find the closest video PTS and report the difference 546 - val offsets = relativeKeys.mapNotNull { skelRelTs -> 547 - val closestPts = timestamps.minByOrNull { kotlin.math.abs(it - skelRelTs) } ?: return@mapNotNull null 548 - skelRelTs - closestPts 549 - } 550 - if (offsets.isNotEmpty()) { 551 - println("DIAG: skeleton-to-video offsets (first 10): ${offsets.take(10)}") 552 - println("DIAG: avg offset=${offsets.average().toLong()}ms min=${offsets.min()}ms max=${offsets.max()}ms") 553 - } 554 - 555 - // Pick a frame near 1.5s that has BOTH skeleton (10+ joints) AND objects 556 - val targetTime = capturedVideoStart + 750L 557 - val bestKey = savedKeys 558 - .filter { key -> 559 - val r = capturedAnalysisResults[key] 560 - r?.skeleton != null && r.skeleton!!.joints().size >= 10 && r.objects.isNotEmpty() 561 - } 562 - .minByOrNull { kotlin.math.abs(it - targetTime) } 563 - ?: savedKeys // fallback: just skeleton if no frame has both 564 - .filter { capturedAnalysisResults[it]?.skeleton?.joints()?.size ?: 0 >= 10 } 565 - .minByOrNull { kotlin.math.abs(it - targetTime) } 566 - val liveResult = bestKey?.let { capturedAnalysisResults[it] } 567 - val liveSkel = liveResult?.skeleton 568 - val liveObjects = liveResult?.objects ?: emptyList() 569 - if (liveSkel == null || liveSkel.joints().size < 10) { 570 - println("DEBUG: no good skeleton found") 571 - phase = "DONE" 572 - statusText = "No skeleton with objects found" 573 - return@launch 574 - } 575 - println("DEBUG: selected frame has ${liveSkel.joints().size} joints, ${liveObjects.size} objects") 576 - val skelVideoTs = bestKey - capturedVideoStart 577 - // Log all skeleton timestamps near the target for debugging 578 - val nearKeys = savedKeys.filter { kotlin.math.abs(it - targetTime) < 500 } 579 - println("DEBUG: skeleton at sensorTs=$bestKey videoTs=${skelVideoTs}ms joints=${liveSkel.joints().size}") 580 - println("DEBUG: videoStartWallClock=$capturedVideoStart targetTime=$targetTime") 581 - println("DEBUG: nearby skeleton keys (within 500ms of target): ${nearKeys.map { it - capturedVideoStart }}") 582 - println("DEBUG: first 5 skeleton keys (relative): ${savedKeys.take(5).map { it - capturedVideoStart }}") 583 - println("DEBUG: video PTS first=${timestamps.first()} last=${timestamps.last()} count=${timestamps.size}") 584 - // Show the gap between video start and first skeleton 585 - println("DEBUG: gap from recording start to first skeleton: ${savedKeys.first() - capturedVideoStart}ms") 586 - 587 - // Extract ALL frames and draw the same skeleton on each 588 - val results = mutableListOf<ComparisonResult>() 589 - var expectedPageIdx = 0 590 - 591 - extractFrames(savedPath, timestamps).collect { inputFrame -> 592 - val frameBitmap = inputFrame.toImageBitmap() 593 - val frameElapsed = inputFrame.timestamp - firstTs 594 - 595 - val output = ImageBitmap(frameBitmap.width, frameBitmap.height) 596 - val canvas = Canvas(output) 597 - val drawScope = CanvasDrawScope() 598 - drawScope.draw(Density(1f), LayoutDirection.Ltr, canvas, Size(frameBitmap.width.toFloat(), frameBitmap.height.toFloat())) { 599 - drawImage(frameBitmap) 600 - val fw = frameBitmap.width.toFloat(); val fh = frameBitmap.height.toFloat() 601 - val sA = liveSkel.width / liveSkel.height; val fA = fw / fh 602 - val coX: Float; val coY: Float; val sx: Float; val sy: Float 603 - if (kotlin.math.abs(sA - fA) > 0.01f) { 604 - if (sA > fA) { sy = fh / liveSkel.height; val vW = liveSkel.height * fA; coX = (liveSkel.width - vW) / 2f; sx = fw / vW; coY = 0f } 605 - else { sx = fw / liveSkel.width; val vH = liveSkel.width / fA; coY = (liveSkel.height - vH) / 2f; sy = fh / vH; coX = 0f } 606 - } else { sx = fw / liveSkel.width; sy = fh / liveSkel.height; coX = 0f; coY = 0f } 607 - fun pt(c: Skeleton.SkeletonCoordinate?) = c?.let { androidx.compose.ui.geometry.Offset((it.x - coX) * sx, (it.y - coY) * sy) } 608 - val conns = listOf(liveSkel.leftShoulder to liveSkel.leftElbow, liveSkel.leftElbow to liveSkel.leftWrist, liveSkel.rightShoulder to liveSkel.rightElbow, liveSkel.rightElbow to liveSkel.rightWrist, liveSkel.leftShoulder to liveSkel.rightShoulder, liveSkel.leftShoulder to liveSkel.leftHip, liveSkel.rightShoulder to liveSkel.rightHip, liveSkel.leftHip to liveSkel.rightHip, liveSkel.leftHip to liveSkel.leftKnee, liveSkel.leftKnee to liveSkel.leftAnkle, liveSkel.rightHip to liveSkel.rightKnee, liveSkel.rightKnee to liveSkel.rightAnkle) 609 - for ((f, t) in conns) { val p1 = pt(f); val p2 = pt(t); if (p1 != null && p2 != null) drawLine(Color.Blue, p1, p2, strokeWidth = 4f) } 610 - for (j in listOfNotNull(liveSkel.leftShoulder, liveSkel.rightShoulder, liveSkel.leftElbow, liveSkel.rightElbow, liveSkel.leftWrist, liveSkel.rightWrist, liveSkel.leftHip, liveSkel.rightHip, liveSkel.leftKnee, liveSkel.rightKnee, liveSkel.leftAnkle, liveSkel.rightAnkle)) { pt(j)?.let { drawCircle(Color.Blue, 6f, it) } } 611 - // Draw objects (green rectangles) with same aspect ratio correction 612 - for (obj in liveObjects) { 613 - val ofw = obj.frameSize.width.toFloat() 614 - val ofh = obj.frameSize.height.toFloat() 615 - val oA = ofw / ofh 616 - val ocX: Float; val ocY: Float; val osx: Float; val osy: Float 617 - if (kotlin.math.abs(oA - fA) > 0.01f) { 618 - if (oA > fA) { osy = fh / ofh; val vW = ofh * fA; ocX = (ofw - vW) / 2f; osx = fw / vW; ocY = 0f } 619 - else { osx = fw / ofw; val vH = ofw / fA; ocY = (ofh - vH) / 2f; osy = fh / vH; ocX = 0f } 620 - } else { osx = fw / ofw; osy = fh / ofh; ocX = 0f; ocY = 0f } 621 - val left = (obj.boundingBox.left - ocX) * osx 622 - val top = (obj.boundingBox.top - ocY) * osy 623 - val right = (obj.boundingBox.right - ocX) * osx 624 - val bottom = (obj.boundingBox.bottom - ocY) * osy 625 - drawRect(Color.Green, topLeft = androidx.compose.ui.geometry.Offset(left, top), size = Size(right - left, bottom - top), style = Stroke(3f)) 626 - } 627 - } 628 - 629 - if (frameElapsed <= skelVideoTs) expectedPageIdx = results.size 630 - val offset = frameElapsed - skelVideoTs 631 - results.add(ComparisonResult( 632 - image = output, 633 - label = "${frameElapsed}ms (${if (offset >= 0) "+" else ""}${offset}ms)", 634 - frameTs = inputFrame.timestamp, frameElapsedMs = frameElapsed, 635 - expectedElapsedMs = skelVideoTs, liveSkeleton = liveSkel, 636 - videoPath = savedPath, allTimestamps = timestamps, 637 - angleMatch = -1f, posMatch = -1f 638 - )) 639 - statusText = "Frame ${results.size}/${timestamps.size}..." 640 - } 641 - 642 - println("DEBUG: ${results.size} frames, expected page=$expectedPageIdx (${skelVideoTs}ms)") 643 - comparisonImages = results 644 - findBestResult = expectedPageIdx to emptyList() 645 - phase = "DONE" 646 - statusText = "Skeleton from ${skelVideoTs}ms — swipe to find match" 647 - } // end of launch(Dispatchers.Default) 648 - } 649 - } 650 - 651 - Column(modifier = Modifier.fillMaxSize()) { 652 - // Status bar 653 - Text( 654 - text = statusText, 655 - modifier = Modifier.fillMaxWidth().padding(8.dp), 656 - textAlign = TextAlign.Center, 657 - style = androidx.compose.material3.MaterialTheme.typography.titleSmall, 658 - ) 659 - 660 - // Timing info 661 - if (phase == "DONE") { 662 - val recordSec = (recordTimeMs / 100.0).roundToLong() / 10.0 663 - val extractSec = (extractTimeMs / 100.0).roundToLong() / 10.0 664 - val fps = if (extractTimeMs > 0) (frameCount * 1000.0 / extractTimeMs).roundToLong() else 0 665 - Text( 666 - text = "Record: ${recordSec}s | Extract+encode: ${extractSec}s ($frameCount frames, ~${fps} fps)", 667 - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), 668 - textAlign = TextAlign.Center, 669 - fontSize = 12.sp, 670 - ) 671 - } 672 - 673 - when (phase) { 674 - "WAITING_PERMISSION" -> { 675 - Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { 676 - CircularProgressIndicator() 677 - } 678 - } 679 - "PREVIEW", "RECORDING" -> { 680 - if (permissionGranted) { 681 - var menuExpanded by remember { mutableStateOf(false) } 682 - DetectOrientation { orientation -> 683 - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { 684 - key(orientation, detectMode) { 685 - CameraView( 686 - skeletonRepository = skeletonRepository, 687 - customObjectRepository = customObjectRepository, 688 - detectMode = detectMode, 689 - drawSkeleton = true, 690 - objectModel = generalModel, 691 - modifier = Modifier.fillMaxSize() 692 - .onSizeChanged { size -> 693 - cameraViewPxW = size.width.toFloat() 694 - cameraViewPxH = size.height.toFloat() 695 - }, 696 - frontCamera = false, 697 - recordingId = recordingId, 698 - controller = controller, 699 - onRecordToggled = { recording -> 700 - recordingActive = recording 701 - if (recording) { 702 - val callbackWallMs = System.currentTimeMillis() 703 - videoStartWallClock = callbackWallMs 704 - println("RECORD-TIMING: onRecordToggled(true) wallMs=$callbackWallMs") 705 - controller.requestData { data -> 706 - feedWidth = data.width 707 - feedHeight = data.height 708 - } 709 - } 710 - }, 711 - onVideoSaved = { id, url -> 712 - videoSavedDeferred.value?.complete(url) 713 - }, 714 - ) 715 - } 716 - // Menu overlay (same as CameraSample) 717 - if (phase == "PREVIEW") { 718 - Box(modifier = Modifier.padding(12.dp).align(Alignment.TopEnd)) { 719 - IconButton(onClick = { menuExpanded = true }) { 720 - Text("⋮", color = Color.White, fontSize = 22.sp) 721 - } 722 - DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { 723 - // Detection mode 724 - DropdownMenuItem(text = { 725 - Column { 726 - Text("Detection Mode", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) 727 - listOf("Pose" to DetectMode.POSE, "Objects" to DetectMode.OBJECT, "Both" to DetectMode.BOTH, "None" to DetectMode.NONE).forEach { (label, mode) -> 728 - Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { 729 - Text(label); Spacer(Modifier.width(12.dp)) 730 - RadioButton(selected = detectMode == mode, onClick = { detectMode = mode }) 731 - } 732 - } 733 - } 734 - }, onClick = {}) 735 - HorizontalDivider() 736 - // Model picker 737 - DropdownMenuItem(text = { 738 - Column { 739 - Text("Model", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) 740 - availableModels.forEach { model -> 741 - Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { 742 - Text(model.name, fontSize = 13.sp); Spacer(Modifier.width(12.dp)) 743 - RadioButton(selected = selectedModel == model, onClick = { selectedModel = model }) 744 - } 745 - } 746 - } 747 - }, onClick = {}) 748 - HorizontalDivider() 749 - // Record button 750 - DropdownMenuItem( 751 - text = { Text("Record 1.5 seconds") }, 752 - onClick = { menuExpanded = false; phase = "RECORDING" } 753 - ) 754 - } 755 - } 756 - } 757 - } 758 - } 759 - } 760 - } 761 - "EXTRACTING" -> { 762 - Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { 763 - CircularProgressIndicator() 764 - } 765 - } 766 - "COMPARING" -> { 767 - Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { 768 - CircularProgressIndicator() 769 - } 770 - } 771 - "DONE" -> { 772 - if (comparisonImages.isNotEmpty()) { 773 - val initialPage = findBestResult?.first ?: 0 774 - val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { comparisonImages.size }) 775 - Column( 776 - modifier = Modifier.weight(1f).fillMaxWidth().padding(8.dp), 777 - verticalArrangement = Arrangement.spacedBy(4.dp) 778 - ) { 779 - val comp = comparisonImages.getOrNull(pagerState.currentPage) 780 - val lagText = "" 781 - Text( 782 - "${lagText}${pagerState.currentPage + 1}/${comparisonImages.size} — ${comp?.label ?: ""}", 783 - fontSize = 12.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() 784 - ) 785 - HorizontalPager( 786 - state = pagerState, 787 - modifier = Modifier.weight(1f).fillMaxWidth() 788 - ) { page -> 789 - val c = comparisonImages[page] 790 - Image( 791 - bitmap = c.image, 792 - contentDescription = c.label, 793 - contentScale = ContentScale.Fit, 794 - modifier = Modifier.fillMaxSize() 795 - ) 796 - } 797 - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { 798 - androidx.compose.material3.Button( 799 - onClick = { 800 - val c = comparisonImages.getOrNull(pagerState.currentPage) 801 - if (c != null) { 802 - val lagMs = c.frameElapsedMs - c.expectedElapsedMs 803 - println("BEST-MATCH: lagMs=$lagMs frameElapsed=${c.frameElapsedMs}ms expected=${c.expectedElapsedMs}ms mode=$detectMode model=${selectedModel?.name} page=${pagerState.currentPage}/${comparisonImages.size}") 804 - } 805 - }, 806 - modifier = Modifier.weight(1f) 807 - ) { Text("Best Match") } 808 - androidx.compose.material3.Button( 809 - onClick = { 810 - comparisonImages = emptyList() 811 - findBestResult = null 812 - phase = "PREVIEW" 813 - statusText = "Tap Record to start" 814 - }, 815 - modifier = Modifier.weight(1f) 816 - ) { Text("New Recording") } 817 - } 818 - } 819 - } else { 820 - Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { 821 - Text("No comparison images produced") 822 - } 823 - } 824 - } 825 - } 826 - } 827 - } 828 - 829 378 @OptIn(ExperimentalTime::class) 830 379 @Composable 831 380 fun CameraSample() { ··· 917 466 } 918 467 } 919 468 Box( 920 - modifier = Modifier 921 - .imePadding() 922 - .padding(12.dp) 923 - .align(Alignment.TopEnd) 469 + modifier = Modifier.imePadding().padding(12.dp).align(Alignment.TopEnd) 924 470 ) { 925 471 IconButton(onClick = { menuExpanded = true }) { 926 472 Text("⋮", color = Color.White, fontSize = 22.sp) 927 473 } 928 474 929 475 DropdownMenu( 930 - expanded = menuExpanded, 931 - onDismissRequest = { menuExpanded = false } 932 - ) { 476 + expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { 933 477 // Zoom toggle 934 - DropdownMenuItem( 935 - text = { 936 - Row( 937 - horizontalArrangement = Arrangement.SpaceBetween, 938 - verticalAlignment = Alignment.CenterVertically, 939 - modifier = Modifier.fillMaxWidth() 940 - ) { 941 - Text(text = if (ultrawide) "0.5x zoom" else "1.0x zoom") 942 - Spacer(Modifier.width(12.dp)) 943 - Switch(checked = ultrawide, onCheckedChange = null) 944 - } 945 - }, 946 - onClick = { ultrawide = !ultrawide } 947 - ) 478 + DropdownMenuItem(text = { 479 + Row( 480 + horizontalArrangement = Arrangement.SpaceBetween, 481 + verticalAlignment = Alignment.CenterVertically, 482 + modifier = Modifier.fillMaxWidth() 483 + ) { 484 + Text(text = if (ultrawide) "0.5x zoom" else "1.0x zoom") 485 + Spacer(Modifier.width(12.dp)) 486 + Switch(checked = ultrawide, onCheckedChange = null) 487 + } 488 + }, onClick = { ultrawide = !ultrawide }) 948 489 949 490 // Preview fill/crop toggle 950 - DropdownMenuItem( 951 - text = { 952 - Row( 953 - horizontalArrangement = Arrangement.SpaceBetween, 954 - verticalAlignment = Alignment.CenterVertically, 955 - modifier = Modifier.fillMaxWidth() 956 - ) { 957 - Text(text = if (previewFillMode == PreviewFillMode.FIT) "Fill Preview" else "Fit Preview") 958 - Spacer(Modifier.width(12.dp)) 959 - Switch( 960 - checked = previewFillMode == PreviewFillMode.FIT, 961 - onCheckedChange = null 962 - ) 963 - } 964 - }, 965 - onClick = { 966 - previewFillMode = 967 - if (previewFillMode == PreviewFillMode.FIT) PreviewFillMode.FILL else PreviewFillMode.FIT 491 + DropdownMenuItem(text = { 492 + Row( 493 + horizontalArrangement = Arrangement.SpaceBetween, 494 + verticalAlignment = Alignment.CenterVertically, 495 + modifier = Modifier.fillMaxWidth() 496 + ) { 497 + Text(text = if (previewFillMode == PreviewFillMode.FIT) "Fill Preview" else "Fit Preview") 498 + Spacer(Modifier.width(12.dp)) 499 + Switch( 500 + checked = previewFillMode == PreviewFillMode.FIT, 501 + onCheckedChange = null 502 + ) 968 503 } 969 - ) 504 + }, onClick = { 505 + previewFillMode = 506 + if (previewFillMode == PreviewFillMode.FIT) PreviewFillMode.FILL else PreviewFillMode.FIT 507 + }) 970 508 971 509 HorizontalDivider() 972 510 ··· 985 523 Text(text = "Pose") 986 524 Spacer(Modifier.width(12.dp)) 987 525 RadioButton( 988 - selected = detectMode == DetectMode.POSE, 989 - onClick = { 526 + selected = detectMode == DetectMode.POSE, onClick = { 990 527 detectMode = DetectMode.POSE 991 528 }) 992 529 } ··· 998 535 Text(text = "Objects") 999 536 Spacer(Modifier.width(12.dp)) 1000 537 RadioButton( 1001 - selected = detectMode == DetectMode.OBJECT, 1002 - onClick = { 538 + selected = detectMode == DetectMode.OBJECT, onClick = { 1003 539 detectMode = DetectMode.OBJECT 1004 540 }) 1005 541 } ··· 1011 547 Text(text = "Both") 1012 548 Spacer(Modifier.width(12.dp)) 1013 549 RadioButton( 1014 - selected = detectMode == DetectMode.BOTH, 1015 - onClick = { 550 + selected = detectMode == DetectMode.BOTH, onClick = { 1016 551 detectMode = DetectMode.BOTH 1017 552 }) 1018 553 } ··· 1024 559 Text(text = "None") 1025 560 Spacer(Modifier.width(12.dp)) 1026 561 RadioButton( 1027 - selected = detectMode == DetectMode.NONE, 1028 - onClick = { 562 + selected = detectMode == DetectMode.NONE, onClick = { 1029 563 detectMode = DetectMode.NONE 1030 564 }) 1031 565 } ··· 1055 589 Spacer(Modifier.width(12.dp)) 1056 590 RadioButton( 1057 591 selected = selectedModel == model, 1058 - onClick = { selectedModel = model } 1059 - ) 592 + onClick = { selectedModel = model }) 1060 593 } 1061 594 } 1062 595 } ··· 1076 609 } 1077 610 // Keep menu open or close? Close feels better. 1078 611 menuExpanded = false 1079 - } 1080 - ) 612 + }) 1081 613 } 1082 614 } 1083 615 }
+2 -10
sample/iosApp/iosApp.xcodeproj/project.pbxproj
··· 39 39 children = ( 40 40 A93A953929CC810C00F8E227 /* iosApp */, 41 41 A93A953829CC810C00F8E227 /* Products */, 42 - C4127409AE3703430489E7BC /* Frameworks */, 43 42 ); 44 43 sourceTree = "<group>"; 45 44 }; ··· 68 67 A93A954129CC810D00F8E227 /* Preview Assets.xcassets */, 69 68 ); 70 69 path = "Preview Content"; 71 - sourceTree = "<group>"; 72 - }; 73 - C4127409AE3703430489E7BC /* Frameworks */ = { 74 - isa = PBXGroup; 75 - children = ( 76 - ); 77 - name = Frameworks; 78 70 sourceTree = "<group>"; 79 71 }; 80 72 /* End PBXGroup section */ ··· 302 294 CODE_SIGN_STYLE = Automatic; 303 295 CURRENT_PROJECT_VERSION = 1; 304 296 DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 305 - DEVELOPMENT_TEAM = 6H9FHG23L3; 297 + DEVELOPMENT_TEAM = FAGG2XS28P; 306 298 ENABLE_PREVIEWS = YES; 307 299 GENERATE_INFOPLIST_FILE = YES; 308 300 INFOPLIST_FILE = iosApp/Info.plist; ··· 328 320 CODE_SIGN_STYLE = Automatic; 329 321 CURRENT_PROJECT_VERSION = 1; 330 322 DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 331 - DEVELOPMENT_TEAM = 6H9FHG23L3; 323 + DEVELOPMENT_TEAM = FAGG2XS28P; 332 324 ENABLE_PREVIEWS = YES; 333 325 GENERATE_INFOPLIST_FILE = YES; 334 326 INFOPLIST_FILE = iosApp/Info.plist;
sample/iosApp/iosApp/models/yolo11n_dataset_dataset.mlpackage/Data/com.apple.CoreML/weights/weight.bin

This is a binary file and will not be displayed.

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 - }