This repository has no description
0

Configure Feed

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

feat(android): higher pose input res in CROP + conf filter + smoothing

Three compounding pose-quality tweaks:

- CROP downscale target rises from 256 → 384 max side. The crop is
already smaller than the full frame so this lands more pixels on the
person without moving the mask-path downscale.
- Drop landmarks whose MLKit inFrameLikelihood is below 0.5 before
emitting. Removes the "phantom joint" problem where occluded limbs
get guessed with low confidence.
- Temporal smoothing on successive skeletons (α=0.6, 500ms gap reset)
via PoseSmoother. Cuts visible jitter during fast shot motion while
staying responsive to real position changes.

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

+43 -3
+43 -3
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/camera/Utils.android.kt
··· 248 248 // 2) Pose input: build MLKit image (optionally masked) and DOWNscale it. 249 249 // Rotation is now 0 because analysisBitmap is upright. 250 250 val mlKitPoseInput: MlKitPoseInput? = poseDetector?.let { 251 + // In CROP mode we feed MLKit only the focus-area crop, which is already 252 + // smaller than the full frame — so raise the downscale target to give 253 + // the model more effective pixels on the person without ballooning work. 254 + val downscale = if (focusArea != null && poseFocusMode == PoseFocusMode.CROP) 384 else 256 251 255 buildMlKitPoseInput( 252 256 analysisBitmap = analysisBitmap, 253 257 focusArea = focusArea, 254 258 poseFocusMode = poseFocusMode, 255 - downscaleMaxSidePx = 256 259 + downscaleMaxSidePx = downscale 256 260 ) 257 261 } 258 262 ··· 292 296 // Shared executor for running pose detection in parallel with object detection. 293 297 private val poseExecutor = Executors.newSingleThreadExecutor() 294 298 299 + // Drop landmarks whose MLKit inFrameLikelihood is below this threshold — keeps 300 + // obvious phantoms (occluded joints guessed with low conf) out of downstream. 301 + private const val LANDMARK_CONF_THRESHOLD = 0.5f 302 + 303 + // Temporal smoothing across consecutive skeletons reduces jitter during fast 304 + // shot motion. α is the weight on the new frame; (1-α) on the previous. 305 + private const val SMOOTHING_ALPHA = 0.6f 306 + // Reset the smoother after a long gap so a re-acquired pose doesn't inherit a 307 + // stale position from where the shooter used to be. 308 + private const val SMOOTHING_GAP_MS = 500L 309 + 310 + private object PoseSmoother { 311 + private var last: Skeleton? = null 312 + private var lastWallMs: Long = 0L 313 + 314 + @Synchronized 315 + fun smooth(skel: Skeleton?, timestamp: Long): Skeleton? { 316 + if (skel == null) { 317 + if (timestamp - lastWallMs > SMOOTHING_GAP_MS) last = null 318 + return null 319 + } 320 + val prev = last 321 + val gapOk = (timestamp - lastWallMs) <= SMOOTHING_GAP_MS 322 + val out = if (prev != null && gapOk) Skeleton.lerp(prev, skel, SMOOTHING_ALPHA) else skel 323 + last = out 324 + lastWallMs = timestamp 325 + return out 326 + } 327 + } 328 + 295 329 // Core processing used by both live camera (ImageProxy) and offline frames (FrameAnalyser). 296 330 fun process( 297 331 tensorImage: TensorImage?, ··· 313 347 poseExecutor.submit<Skeleton?> { 314 348 runCatching { 315 349 val pose = Tasks.await(poseDetector.process(mlKitImage)) 316 - skeletonFromPoseScaled( 350 + val raw = skeletonFromPoseScaled( 317 351 pose = pose, 318 352 timestamp = timestamp, 319 353 width = width, ··· 323 357 offsetX = mlKitOffsetX, 324 358 offsetY = mlKitOffsetY, 325 359 ) 360 + // Empty skeletons (no landmarks passed conf threshold) smooth as 361 + // null so the smoother doesn't lerp toward a zeroed pose. 362 + val hasAny = raw.joints().isNotEmpty() 363 + PoseSmoother.smooth(if (hasAny) raw else null, timestamp) 326 364 }.onFailure { t -> 327 365 Logger.e(t) { "MLKit poseDetector.process failed" } 328 366 }.getOrNull() ··· 816 854 offsetX: Float, 817 855 offsetY: Float, 818 856 ): Skeleton.SkeletonCoordinate? { 819 - val pos = this?.position ?: return null 857 + val lm = this ?: return null 858 + if (lm.inFrameLikelihood < LANDMARK_CONF_THRESHOLD) return null 859 + val pos = lm.position 820 860 return Skeleton.SkeletonCoordinate( 821 861 x = pos.x * scaleX + offsetX, 822 862 y = pos.y * scaleY + offsetY,