Files
PinPoint/app/src/main/java/com/example/jnicpp/bowling/PoseAnalyzer.kt
T

123 lines
5.1 KiB
Kotlin
Raw Normal View History

2026-08-11 19:46:54 +08:00
package com.example.jnicpp.bowling
import androidx.annotation.OptIn
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.PoseDetection
import com.google.mlkit.vision.pose.PoseDetector
import com.google.mlkit.vision.pose.accurate.AccuratePoseDetectorOptions
/**
* Bridges CameraX's [ImageAnalysis] frame stream into ML Kit's streaming
* pose detector.
*
* CameraX invokes [analyze] on whatever executor was passed to
* `ImageAnalysis.setAnalyzer(executor, this)` -- as long as that's a
* background executor (see [CameraXController]), the actual inference work
* never runs on the UI thread, so it can't block the UI or the video
* recording pipeline. Note that [onResult]/[onError] themselves fire back on
* the *main* thread: ML Kit's `Task#addOnSuccessListener`/`addOnFailureListener`
* without an explicit `Executor` deliver on the main application thread by
* default, regardless of which thread called `.process()`. That's
* intentional here -- it means callers (see [BowlingCameraActivity]) can
* update views directly from [onResult] with no extra thread hop.
* `STREAM_MODE` on the detector itself also makes ML Kit assume frames
* arrive close together and reuse state between them, which is what makes
* it track a moving body smoothly instead of re-detecting from scratch.
*/
class PoseAnalyzer(
private val isFrontCamera: () -> Boolean,
private val onResult: (PoseFrameResult) -> Unit,
private val onError: (Exception) -> Unit
) : ImageAnalysis.Analyzer {
/**
* Everything [PoseOverlayView] needs to both draw a pose and correctly
2026-08-12 12:46:33 +08:00
* 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)
2026-08-13 19:54:48 +08:00
* 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.
2026-08-11 19:46:54 +08:00
*/
data class PoseFrameResult(
2026-08-13 19:54:48 +08:00
val landmarks: Map<Int, SmoothedLandmark>,
2026-08-11 19:46:54 +08:00
val imageWidth: Int,
val imageHeight: Int,
val rotationDegrees: Int,
2026-08-12 12:46:33 +08:00
val isFrontCamera: Boolean,
val angles: PoseAngles
2026-08-11 19:46:54 +08:00
)
private val detector: PoseDetector = PoseDetection.getClient(
AccuratePoseDetectorOptions.Builder()
.setDetectorMode(AccuratePoseDetectorOptions.STREAM_MODE)
.build()
)
2026-08-13 19:54:48 +08:00
// 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()
2026-08-11 19:46:54 +08:00
// 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
// behind the frame producer.
@Volatile
private var isProcessing = false
@OptIn(ExperimentalGetImage::class)
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage == null || isProcessing) {
imageProxy.close()
return
}
isProcessing = true
// Capture these before handing off to the async detector call --
// imageProxy itself is closed as soon as the detector is done with
// the underlying buffer, so nothing here should read from it after
// that point.
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val width = imageProxy.width
val height = imageProxy.height
val frontCamera = isFrontCamera()
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
detector.process(inputImage)
.addOnSuccessListener { pose ->
2026-08-13 19:54:48 +08:00
val landmarks = landmarkSmoother.smooth(pose)
2026-08-11 19:46:54 +08:00
onResult(
PoseFrameResult(
2026-08-13 19:54:48 +08:00
landmarks = landmarks,
2026-08-11 19:46:54 +08:00
imageWidth = width,
imageHeight = height,
rotationDegrees = rotationDegrees,
2026-08-12 12:46:33 +08:00
isFrontCamera = frontCamera,
// Cheap (four atan2 pairs at most), safe to compute
// on every frame right alongside the detection result.
2026-08-13 19:54:48 +08:00
angles = PoseAngleCalculator.compute(landmarks)
2026-08-11 19:46:54 +08:00
)
)
}
.addOnFailureListener { e -> onError(e) }
.addOnCompleteListener {
isProcessing = false
// Must always close, or CameraX stalls the analysis
// pipeline waiting for this frame's buffer to be released.
imageProxy.close()
}
}
fun close() {
detector.close()
}
}