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 "PlatformBridge.h"
// Define logging macros for this file
#ifndef LOG_TAG
#define LOG_TAG "NativeTemplate"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
@@ -235,7 +235,7 @@ class CameraXController(
targetHeight = targetHeight,
mirror = frame.isMirroring
)
PoseSkeletonRenderer.draw(canvas, poseFrame.pose, transform, overlayBonePaint, overlayJointPaint)
PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint)
}
true
}
@@ -5,7 +5,6 @@ import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
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.PoseDetector
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
* angles derived from that same pose (see [PoseAngleCalculator]) so
* 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(
val pose: Pose,
val landmarks: Map<Int, SmoothedLandmark>,
val imageWidth: Int,
val imageHeight: Int,
val rotationDegrees: Int,
@@ -56,6 +58,12 @@ class PoseAnalyzer(
.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
// CameraXController) already ensures we're never handed a backlog, but
// this guards against overlapping calls if the detector ever falls
@@ -85,16 +93,17 @@ class PoseAnalyzer(
detector.process(inputImage)
.addOnSuccessListener { pose ->
val landmarks = landmarkSmoother.smooth(pose)
onResult(
PoseFrameResult(
pose = pose,
landmarks = landmarks,
imageWidth = width,
imageHeight = height,
rotationDegrees = rotationDegrees,
isFrontCamera = frontCamera,
// Cheap (four atan2 pairs at most), safe to compute
// 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
import com.google.mlkit.vision.pose.Pose
import com.google.mlkit.vision.pose.PoseLandmark
import kotlin.math.abs
import kotlin.math.atan2
@@ -21,28 +20,26 @@ data class PoseAngles(
/**
* Plain landmark-angle math, deliberately independent of [PoseSkeletonRenderer]
* (Canvas/View drawing) and [android.graphics] entirely, so [PoseAnalyzer] can
* call it straight from ML Kit's result callback on whatever thread that
* lands on.
* (Canvas/View drawing) and Android entirely, so [PoseAnalyzer] can call it
* straight from ML Kit's result callback on whatever thread that 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 {
/**
* Angle in degrees, at [midPoint], between rays [midPoint]->[firstPoint]
* and [midPoint]->[lastPoint], via 2D atan2 vector math on the landmarks'
* image-space (x, y). Always returns a value in 0..180 -- atan2 gives a
* signed angle in -360..360 depending on winding direction, which this
* folds down to the unsigned interior angle since callers only care about
* how bent the joint is, not which way it's bent.
* and [midPoint]->[lastPoint], via 2D atan2 vector math. Always returns
* a value in 0..180 -- atan2 gives a signed angle in -360..360 depending
* on winding direction, which this folds down to the unsigned interior
* angle since callers only care about how bent the joint is, not which
* way it's bent.
*/
fun calculateAngle(firstPoint: PoseLandmark, midPoint: PoseLandmark, lastPoint: PoseLandmark): Float {
val first = firstPoint.position
val mid = midPoint.position
val last = lastPoint.position
fun calculateAngle(firstPoint: SmoothedLandmark, midPoint: SmoothedLandmark, lastPoint: SmoothedLandmark): Float {
var degrees = Math.toDegrees(
(atan2((last.y - mid.y).toDouble(), (last.x - mid.x).toDouble()) -
atan2((first.y - mid.y).toDouble(), (first.x - mid.x).toDouble()))
(atan2((lastPoint.y - midPoint.y).toDouble(), (lastPoint.x - midPoint.x).toDouble()) -
atan2((firstPoint.y - midPoint.y).toDouble(), (firstPoint.x - midPoint.x).toDouble()))
).toFloat()
degrees = abs(degrees)
@@ -53,16 +50,16 @@ object PoseAngleCalculator {
}
/**
* Computes every tracked angle for [pose] in one pass, leaving a field
* null wherever a required landmark is missing or too unreliable
* Computes every tracked angle from [landmarks] in one pass, leaving a
* field null wherever a required landmark is missing or too unreliable
* ([PoseSkeletonRenderer.MIN_LIKELIHOOD] -- the same bar the skeleton
* 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? {
val first = pose.getPoseLandmark(firstType) ?: return null
val mid = pose.getPoseLandmark(midType) ?: return null
val last = pose.getPoseLandmark(lastType) ?: return null
val first = landmarks[firstType] ?: return null
val mid = landmarks[midType] ?: return null
val last = landmarks[lastType] ?: return null
if (first.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD ||
mid.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 androidx.core.content.ContextCompat
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
@@ -43,7 +42,7 @@ class PoseOverlayView @JvmOverloads constructor(
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 isFrontCamera = false
@@ -58,7 +57,7 @@ class PoseOverlayView @JvmOverloads constructor(
/** Called from the main thread with the latest analyzer result, or null to clear. */
fun update(frame: PoseAnalyzer.PoseFrameResult?) {
pose = frame?.pose
landmarks = frame?.landmarks
angles = frame?.angles
if (frame != null) {
isFrontCamera = frame.isFrontCamera
@@ -71,7 +70,7 @@ class PoseOverlayView @JvmOverloads constructor(
}
fun clear() {
pose = null
landmarks = null
angles = null
invalidate()
}
@@ -96,8 +95,8 @@ class PoseOverlayView @JvmOverloads constructor(
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val currentPose = pose ?: return
PoseSkeletonRenderer.draw(canvas, currentPose, transform, bonePaint, jointPaint)
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentPose, it, transform, anglePaint) }
val currentLandmarks = landmarks ?: return
PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint)
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) }
}
}
@@ -4,7 +4,6 @@ import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PointF
import com.google.mlkit.vision.pose.Pose
import com.google.mlkit.vision.pose.PoseLandmark
/**
@@ -140,20 +139,26 @@ object PoseSkeletonRenderer {
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) {
val start = pose.getPoseLandmark(startType) ?: continue
val end = pose.getPoseLandmark(endType) ?: continue
val start = landmarks[startType] ?: continue
val end = landmarks[endType] ?: continue
if (start.inFrameLikelihood < MIN_LIKELIHOOD || end.inFrameLikelihood < MIN_LIKELIHOOD) continue
val p1 = mapPoint(transform, start.position.x, start.position.y)
val p2 = mapPoint(transform, end.position.x, end.position.y)
val p1 = mapPoint(transform, start.x, start.y)
val p2 = mapPoint(transform, end.x, end.y)
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
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)
}
}
@@ -166,12 +171,12 @@ object PoseSkeletonRenderer {
* are silently skipped, matching how [draw] already skips low-confidence
* 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?) {
if (angle == null) return
val landmark = pose.getPoseLandmark(landmarkType) ?: return
val landmark = landmarks[landmarkType] ?: 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)
}