This repository has no description
0

Configure Feed

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

feat: add CropRect parameter to extractFrames for output video cropping

Add optional CropRect to extractFrames() for pixel-level cropping after
decode+rotation. On Android, uses Bitmap.createBitmap for zero-copy crop.
On iOS, delegates to InputFrame.crop().

Sample app debug test uses portrait 9:16 crop with skeleton-tracking pan
and smooth interpolation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+71 -7
+1 -1
posedetection/build.gradle.kts
··· 4 4 5 5 mavenPublishing { 6 6 publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 7 - coordinates("com.performancecoachlab.posedetection", "posedetection-compose", "4.9.4") 7 + coordinates("com.performancecoachlab.posedetection", "posedetection-compose", "4.9.5") 8 8 9 9 pom { 10 10 name.set("Pose Detection")
+9 -2
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.android.kt
··· 260 260 * overhead and the decoder runs continuously. 261 261 */ 262 262 actual fun extractFrames( 263 - videoPath: String, frameTimestamps: List<Long> 263 + videoPath: String, frameTimestamps: List<Long>, cropRect: CropRect? 264 264 ): Flow<InputFrame> = flow { 265 265 val decoder = VideoFrameDecoder(videoPath) 266 266 try { 267 267 for (ts in frameTimestamps) { 268 268 val bitmap = decoder.decodeUpTo(ts) 269 269 if (bitmap != null) { 270 - emit(InputFrame(bitmap = bitmap, timestamp = ts)) 270 + val output = if (cropRect != null) { 271 + val cx = cropRect.x.coerceIn(0, bitmap.width - 1) 272 + val cy = cropRect.y.coerceIn(0, bitmap.height - 1) 273 + val cw = cropRect.width.coerceAtMost(bitmap.width - cx) 274 + val ch = cropRect.height.coerceAtMost(bitmap.height - cy) 275 + Bitmap.createBitmap(bitmap, cx, cy, cw, ch) 276 + } else bitmap 277 + emit(InputFrame(bitmap = output, timestamp = ts)) 271 278 } 272 279 } 273 280 } finally {
+13 -1
posedetection/src/commonMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.kt
··· 2 2 3 3 import kotlinx.coroutines.flow.Flow 4 4 5 + /** 6 + * Pixel-coordinate crop rectangle applied after decode + rotation. 7 + * [x] and [y] are the top-left corner; [width] and [height] are the crop size. 8 + */ 9 + data class CropRect(val x: Int, val y: Int, val width: Int, val height: Int) 10 + 5 11 expect suspend fun extractFrame( 6 12 videoPath: String, frameTimestamp: Long 7 13 ): InputFrame? ··· 10 16 * Batch sequential frame extraction as a Flow. More efficient than repeated 11 17 * [extractFrame] calls for processing many frames from the same video. 12 18 * Frames are emitted in the order of [frameTimestamps] (must be ascending). 19 + * 20 + * If [cropRect] is provided, each frame is cropped to those pixel coordinates 21 + * after decode and rotation. This avoids processing full-resolution pixels 22 + * downstream. 13 23 */ 14 24 expect fun extractFrames( 15 - videoPath: String, frameTimestamps: List<Long> 25 + videoPath: String, 26 + frameTimestamps: List<Long>, 27 + cropRect: CropRect? = null, 16 28 ): Flow<InputFrame> 17 29 18 30 expect suspend fun listVideoFrameTimestamps(
+10 -2
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.ios.kt
··· 60 60 } 61 61 62 62 actual fun extractFrames( 63 - videoPath: String, frameTimestamps: List<Long> 63 + videoPath: String, frameTimestamps: List<Long>, cropRect: CropRect? 64 64 ): Flow<InputFrame> = flow { 65 65 for (ts in frameTimestamps) { 66 - extractFrame(videoPath, ts)?.let { emit(it) } 66 + val frame = extractFrame(videoPath, ts) ?: continue 67 + val output = if (cropRect != null) { 68 + frame.crop(androidx.compose.ui.geometry.Rect( 69 + cropRect.x.toFloat(), cropRect.y.toFloat(), 70 + (cropRect.x + cropRect.width).toFloat(), 71 + (cropRect.y + cropRect.height).toFloat() 72 + )) 73 + } else frame 74 + emit(output) 67 75 } 68 76 }.flowOn(Dispatchers.IO) 69 77
+38 -1
sample/composeApp/src/commonMain/kotlin/com/nate/posedetection/App.kt
··· 516 516 var videoBuilder: VideoBuilder? = null 517 517 var firstFrameTs: Long? = null 518 518 var processedCount = 0 519 + var smoothedCenterX = -1f 519 520 520 521 // Log feed vs preview dimensions for debugging coordinate mapping. 521 522 println("DEBUG: feedSize=${feedWidth}x${feedHeight}") ··· 583 584 AnalysisResult(remapped, remappedObjects) 584 585 } ?: result 585 586 586 - val annotated = inputFrame.drawAnalysisResults(remappedResult) 587 + val fullAnnotated = inputFrame.drawAnalysisResults(remappedResult) 588 + 589 + // Crop to portrait 9:16 with skeleton-tracking pan. 590 + val cropWidth = ((fullAnnotated.height * 9f / 16f).toInt() / 16 * 16) 591 + .coerceAtMost(fullAnnotated.width) 592 + val annotated = if (cropWidth < fullAnnotated.width) { 593 + // Compute skeleton center X in full-frame coordinates (already remapped). 594 + val skel = remappedResult.skeleton 595 + val targetCenterX = if (skel != null) { 596 + val joints = skel.joints() 597 + if (joints.isNotEmpty()) { 598 + (joints.sumOf { it.x.toDouble() } / joints.size).toFloat() 599 + } else fullAnnotated.width / 2f 600 + } else fullAnnotated.width / 2f 601 + 602 + smoothedCenterX = if (smoothedCenterX < 0f) targetCenterX 603 + else smoothedCenterX + (targetCenterX - smoothedCenterX) * 0.15f 604 + 605 + val cropX = (smoothedCenterX - cropWidth / 2f) 606 + .coerceIn(0f, (fullAnnotated.width - cropWidth).toFloat()) 607 + .toInt() 608 + 609 + val cropped = ImageBitmap(cropWidth, fullAnnotated.height) 610 + val cropCanvas = Canvas(cropped) 611 + val drawScope = CanvasDrawScope() 612 + drawScope.draw( 613 + Density(1f), LayoutDirection.Ltr, cropCanvas, 614 + Size(cropWidth.toFloat(), fullAnnotated.height.toFloat()) 615 + ) { 616 + drawImage( 617 + fullAnnotated, 618 + srcOffset = androidx.compose.ui.unit.IntOffset(cropX, 0), 619 + srcSize = androidx.compose.ui.unit.IntSize(cropWidth, fullAnnotated.height) 620 + ) 621 + } 622 + cropped 623 + } else fullAnnotated 587 624 588 625 if (videoBuilder == null) { 589 626 println("DEBUG: creating VideoBuilder ${annotated.width}x${annotated.height}")