Smoother skeleton

This commit is contained in:
2026-08-13 19:54:48 +08:00
parent e05aca3312
commit 5d07c8c8bd
7 changed files with 110 additions and 47 deletions
+1
View File
@@ -13,6 +13,7 @@
#include "UIRenderer.h" #include "UIRenderer.h"
#include "PlatformBridge.h" #include "PlatformBridge.h"
// Define logging macros for this file // Define logging macros for this file
#ifndef LOG_TAG #ifndef LOG_TAG
#define LOG_TAG "NativeTemplate" #define LOG_TAG "NativeTemplate"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
@@ -235,7 +235,7 @@ class CameraXController(
targetHeight = targetHeight, targetHeight = targetHeight,
mirror = frame.isMirroring mirror = frame.isMirroring
) )
PoseSkeletonRenderer.draw(canvas, poseFrame.pose, transform, overlayBonePaint, overlayJointPaint) PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint)
} }
true true
} }
@@ -5,7 +5,6 @@ import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.pose.Pose
import com.google.mlkit.vision.pose.PoseDetection import com.google.mlkit.vision.pose.PoseDetection
import com.google.mlkit.vision.pose.PoseDetector import com.google.mlkit.vision.pose.PoseDetector
import com.google.mlkit.vision.pose.accurate.AccuratePoseDetectorOptions import com.google.mlkit.vision.pose.accurate.AccuratePoseDetectorOptions
@@ -39,10 +38,13 @@ class PoseAnalyzer(
* map it from analysis-image pixels to view pixels, plus the joint * map it from analysis-image pixels to view pixels, plus the joint
* angles derived from that same pose (see [PoseAngleCalculator]) so * angles derived from that same pose (see [PoseAngleCalculator]) so
* downstream consumers (overlay text, future frame-by-frame logging) * downstream consumers (overlay text, future frame-by-frame logging)
* don't need to recompute them from [pose] themselves. * don't need to recompute them from [landmarks] themselves. [landmarks]
* is already smoothed across frames (see [PoseLandmarkSmoother]) rather
* than raw ML Kit output, so anything drawn straight from it doesn't
* need to smooth it again.
*/ */
data class PoseFrameResult( data class PoseFrameResult(
val pose: Pose, val landmarks: Map<Int, SmoothedLandmark>,
val imageWidth: Int, val imageWidth: Int,
val imageHeight: Int, val imageHeight: Int,
val rotationDegrees: Int, val rotationDegrees: Int,
@@ -56,6 +58,12 @@ class PoseAnalyzer(
.build() .build()
) )
// One smoother per analyzer instance -- a fresh PoseAnalyzer (see
// CameraXController.bindToLifecycle) means a fresh detection stream, so
// its smoothing state should start clean rather than lerping in from
// whatever pose the previous stream last saw.
private val landmarkSmoother = PoseLandmarkSmoother()
// STRATEGY_KEEP_ONLY_LATEST on the ImageAnalysis use case (see // STRATEGY_KEEP_ONLY_LATEST on the ImageAnalysis use case (see
// CameraXController) already ensures we're never handed a backlog, but // CameraXController) already ensures we're never handed a backlog, but
// this guards against overlapping calls if the detector ever falls // this guards against overlapping calls if the detector ever falls
@@ -85,16 +93,17 @@ class PoseAnalyzer(
detector.process(inputImage) detector.process(inputImage)
.addOnSuccessListener { pose -> .addOnSuccessListener { pose ->
val landmarks = landmarkSmoother.smooth(pose)
onResult( onResult(
PoseFrameResult( PoseFrameResult(
pose = pose, landmarks = landmarks,
imageWidth = width, imageWidth = width,
imageHeight = height, imageHeight = height,
rotationDegrees = rotationDegrees, rotationDegrees = rotationDegrees,
isFrontCamera = frontCamera, isFrontCamera = frontCamera,
// Cheap (four atan2 pairs at most), safe to compute // Cheap (four atan2 pairs at most), safe to compute
// on every frame right alongside the detection result. // on every frame right alongside the detection result.
angles = PoseAngleCalculator.compute(pose) angles = PoseAngleCalculator.compute(landmarks)
) )
) )
} }
@@ -1,6 +1,5 @@
package com.example.jnicpp.bowling package com.example.jnicpp.bowling
import com.google.mlkit.vision.pose.Pose
import com.google.mlkit.vision.pose.PoseLandmark import com.google.mlkit.vision.pose.PoseLandmark
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan2 import kotlin.math.atan2
@@ -21,28 +20,26 @@ data class PoseAngles(
/** /**
* Plain landmark-angle math, deliberately independent of [PoseSkeletonRenderer] * Plain landmark-angle math, deliberately independent of [PoseSkeletonRenderer]
* (Canvas/View drawing) and [android.graphics] entirely, so [PoseAnalyzer] can * (Canvas/View drawing) and Android entirely, so [PoseAnalyzer] can call it
* call it straight from ML Kit's result callback on whatever thread that * straight from ML Kit's result callback on whatever thread that lands on.
* lands on. * Operates on [SmoothedLandmark]s (see [PoseLandmarkSmoother]) rather than
* raw ML Kit landmarks, so the reported angles track the same smoothed
* positions the skeleton itself is drawn from.
*/ */
object PoseAngleCalculator { object PoseAngleCalculator {
/** /**
* Angle in degrees, at [midPoint], between rays [midPoint]->[firstPoint] * Angle in degrees, at [midPoint], between rays [midPoint]->[firstPoint]
* and [midPoint]->[lastPoint], via 2D atan2 vector math on the landmarks' * and [midPoint]->[lastPoint], via 2D atan2 vector math. Always returns
* image-space (x, y). Always returns a value in 0..180 -- atan2 gives a * a value in 0..180 -- atan2 gives a signed angle in -360..360 depending
* signed angle in -360..360 depending on winding direction, which this * on winding direction, which this folds down to the unsigned interior
* folds down to the unsigned interior angle since callers only care about * angle since callers only care about how bent the joint is, not which
* how bent the joint is, not which way it's bent. * way it's bent.
*/ */
fun calculateAngle(firstPoint: PoseLandmark, midPoint: PoseLandmark, lastPoint: PoseLandmark): Float { fun calculateAngle(firstPoint: SmoothedLandmark, midPoint: SmoothedLandmark, lastPoint: SmoothedLandmark): Float {
val first = firstPoint.position
val mid = midPoint.position
val last = lastPoint.position
var degrees = Math.toDegrees( var degrees = Math.toDegrees(
(atan2((last.y - mid.y).toDouble(), (last.x - mid.x).toDouble()) - (atan2((lastPoint.y - midPoint.y).toDouble(), (lastPoint.x - midPoint.x).toDouble()) -
atan2((first.y - mid.y).toDouble(), (first.x - mid.x).toDouble())) atan2((firstPoint.y - midPoint.y).toDouble(), (firstPoint.x - midPoint.x).toDouble()))
).toFloat() ).toFloat()
degrees = abs(degrees) degrees = abs(degrees)
@@ -53,16 +50,16 @@ object PoseAngleCalculator {
} }
/** /**
* Computes every tracked angle for [pose] in one pass, leaving a field * Computes every tracked angle from [landmarks] in one pass, leaving a
* null wherever a required landmark is missing or too unreliable * field null wherever a required landmark is missing or too unreliable
* ([PoseSkeletonRenderer.MIN_LIKELIHOOD] -- the same bar the skeleton * ([PoseSkeletonRenderer.MIN_LIKELIHOOD] -- the same bar the skeleton
* drawing itself uses to decide whether a joint is worth showing). * drawing itself uses to decide whether a joint is worth showing).
*/ */
fun compute(pose: Pose): PoseAngles { fun compute(landmarks: Map<Int, SmoothedLandmark>): PoseAngles {
fun angleOrNull(firstType: Int, midType: Int, lastType: Int): Float? { fun angleOrNull(firstType: Int, midType: Int, lastType: Int): Float? {
val first = pose.getPoseLandmark(firstType) ?: return null val first = landmarks[firstType] ?: return null
val mid = pose.getPoseLandmark(midType) ?: return null val mid = landmarks[midType] ?: return null
val last = pose.getPoseLandmark(lastType) ?: return null val last = landmarks[lastType] ?: return null
if (first.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD || if (first.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD ||
mid.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD || mid.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD ||
last.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD last.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD
@@ -0,0 +1,52 @@
package com.example.jnicpp.bowling
import com.google.mlkit.vision.pose.Pose
/**
* A single pose landmark's position and detection confidence, smoothed
* across frames by [PoseLandmarkSmoother]. Deliberately independent of ML
* Kit's own `PoseLandmark`/`PointF3D` so downstream consumers (angle math,
* skeleton drawing) don't need any ML Kit types.
*/
data class SmoothedLandmark(val x: Float, val y: Float, val inFrameLikelihood: Float)
/**
* Low-pass-filters ML Kit's per-frame [Pose] landmarks with an exponential
* moving average, so the drawn skeleton doesn't visibly jitter/flicker from
* frame-to-frame detector noise. This smooths `inFrameLikelihood` too, not
* just position -- without that, a landmark hovering right around
* [PoseSkeletonRenderer.MIN_LIKELIHOOD] makes whole bones repeatedly pop in
* and out, which reads as flicker just as much as position jitter does.
*
* State is per-landmark-type and carries across calls to [smooth], so this
* is meant as one instance per detection stream (i.e. per [PoseAnalyzer]) --
* create a new one whenever the stream restarts rather than reusing one
* across unrelated streams, or the first frame of the new stream will lerp
* in from the old stream's last pose.
*/
class PoseLandmarkSmoother(
// Weight given to each new sample; lower = smoother but more lag behind
// the true position. 0.4 noticeably cuts jitter while still keeping up
// with a fast bowling arm swing.
private val smoothingFactor: Float = 0.4f
) {
private val previous = mutableMapOf<Int, SmoothedLandmark>()
fun smooth(pose: Pose): Map<Int, SmoothedLandmark> {
for (landmark in pose.allPoseLandmarks) {
val prev = previous[landmark.landmarkType]
val next = if (prev == null) {
SmoothedLandmark(landmark.position.x, landmark.position.y, landmark.inFrameLikelihood)
} else {
SmoothedLandmark(
x = prev.x + smoothingFactor * (landmark.position.x - prev.x),
y = prev.y + smoothingFactor * (landmark.position.y - prev.y),
inFrameLikelihood = prev.inFrameLikelihood +
smoothingFactor * (landmark.inFrameLikelihood - prev.inFrameLikelihood)
)
}
previous[landmark.landmarkType] = next
}
return previous.toMap()
}
}
@@ -8,7 +8,6 @@ import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.jnicpp.R import com.example.jnicpp.R
import com.google.mlkit.vision.pose.Pose
/** /**
* Draws the 33 ML Kit pose landmarks and connecting skeleton lines on top of * Draws the 33 ML Kit pose landmarks and connecting skeleton lines on top of
@@ -43,7 +42,7 @@ class PoseOverlayView @JvmOverloads constructor(
setShadowLayer(4f, 0f, 0f, ContextCompat.getColor(context, R.color.black)) setShadowLayer(4f, 0f, 0f, ContextCompat.getColor(context, R.color.black))
} }
private var pose: Pose? = null private var landmarks: Map<Int, SmoothedLandmark>? = null
private var angles: PoseAngles? = null private var angles: PoseAngles? = null
private var isFrontCamera = false private var isFrontCamera = false
@@ -58,7 +57,7 @@ class PoseOverlayView @JvmOverloads constructor(
/** Called from the main thread with the latest analyzer result, or null to clear. */ /** Called from the main thread with the latest analyzer result, or null to clear. */
fun update(frame: PoseAnalyzer.PoseFrameResult?) { fun update(frame: PoseAnalyzer.PoseFrameResult?) {
pose = frame?.pose landmarks = frame?.landmarks
angles = frame?.angles angles = frame?.angles
if (frame != null) { if (frame != null) {
isFrontCamera = frame.isFrontCamera isFrontCamera = frame.isFrontCamera
@@ -71,7 +70,7 @@ class PoseOverlayView @JvmOverloads constructor(
} }
fun clear() { fun clear() {
pose = null landmarks = null
angles = null angles = null
invalidate() invalidate()
} }
@@ -96,8 +95,8 @@ class PoseOverlayView @JvmOverloads constructor(
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
val currentPose = pose ?: return val currentLandmarks = landmarks ?: return
PoseSkeletonRenderer.draw(canvas, currentPose, transform, bonePaint, jointPaint) PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint)
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentPose, it, transform, anglePaint) } angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) }
} }
} }
@@ -4,7 +4,6 @@ import android.graphics.Canvas
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import android.graphics.PointF import android.graphics.PointF
import com.google.mlkit.vision.pose.Pose
import com.google.mlkit.vision.pose.PoseLandmark import com.google.mlkit.vision.pose.PoseLandmark
/** /**
@@ -140,20 +139,26 @@ object PoseSkeletonRenderer {
return PointF(mapped[0], mapped[1]) return PointF(mapped[0], mapped[1])
} }
/** Draws [pose]'s bones and joints onto [canvas], mapping each landmark through [transform]. */ /**
fun draw(canvas: Canvas, pose: Pose, transform: Matrix, bonePaint: Paint, jointPaint: Paint) { * Draws [landmarks]' bones and joints onto [canvas], mapping each through
* [transform]. [landmarks] is keyed by ML Kit [PoseLandmark] type (e.g.
* [PoseLandmark.LEFT_ELBOW]) and comes from [PoseLandmarkSmoother],
* already low-pass-filtered across frames so the skeleton doesn't
* flicker with raw per-frame detector noise.
*/
fun draw(canvas: Canvas, landmarks: Map<Int, SmoothedLandmark>, transform: Matrix, bonePaint: Paint, jointPaint: Paint) {
for ((startType, endType) in BONES) { for ((startType, endType) in BONES) {
val start = pose.getPoseLandmark(startType) ?: continue val start = landmarks[startType] ?: continue
val end = pose.getPoseLandmark(endType) ?: continue val end = landmarks[endType] ?: continue
if (start.inFrameLikelihood < MIN_LIKELIHOOD || end.inFrameLikelihood < MIN_LIKELIHOOD) continue if (start.inFrameLikelihood < MIN_LIKELIHOOD || end.inFrameLikelihood < MIN_LIKELIHOOD) continue
val p1 = mapPoint(transform, start.position.x, start.position.y) val p1 = mapPoint(transform, start.x, start.y)
val p2 = mapPoint(transform, end.position.x, end.position.y) val p2 = mapPoint(transform, end.x, end.y)
canvas.drawLine(p1.x, p1.y, p2.x, p2.y, bonePaint) canvas.drawLine(p1.x, p1.y, p2.x, p2.y, bonePaint)
} }
for (landmark in pose.allPoseLandmarks) { for (landmark in landmarks.values) {
if (landmark.inFrameLikelihood < MIN_LIKELIHOOD) continue if (landmark.inFrameLikelihood < MIN_LIKELIHOOD) continue
val p = mapPoint(transform, landmark.position.x, landmark.position.y) val p = mapPoint(transform, landmark.x, landmark.y)
canvas.drawCircle(p.x, p.y, DOT_RADIUS, jointPaint) canvas.drawCircle(p.x, p.y, DOT_RADIUS, jointPaint)
} }
} }
@@ -166,12 +171,12 @@ object PoseSkeletonRenderer {
* are silently skipped, matching how [draw] already skips low-confidence * are silently skipped, matching how [draw] already skips low-confidence
* joints/bones. * joints/bones.
*/ */
fun drawAngleLabels(canvas: Canvas, pose: Pose, angles: PoseAngles, transform: Matrix, textPaint: Paint) { fun drawAngleLabels(canvas: Canvas, landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles, transform: Matrix, textPaint: Paint) {
fun label(landmarkType: Int, angle: Float?) { fun label(landmarkType: Int, angle: Float?) {
if (angle == null) return if (angle == null) return
val landmark = pose.getPoseLandmark(landmarkType) ?: return val landmark = landmarks[landmarkType] ?: return
if (landmark.inFrameLikelihood < MIN_LIKELIHOOD) return if (landmark.inFrameLikelihood < MIN_LIKELIHOOD) return
val p = mapPoint(transform, landmark.position.x, landmark.position.y) val p = mapPoint(transform, landmark.x, landmark.y)
canvas.drawText("${angle.toInt()}°", p.x + ANGLE_LABEL_OFFSET, p.y - ANGLE_LABEL_OFFSET, textPaint) canvas.drawText("${angle.toInt()}°", p.x + ANGLE_LABEL_OFFSET, p.y - ANGLE_LABEL_OFFSET, textPaint)
} }