Files
PinPoint/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt
T
2026-09-07 19:06:10 +08:00

161 lines
6.2 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file PoseOverlayView.kt
* @brief Live on-screen View that draws the pose skeleton and angle labels over the camera preview.
*/
package com.example.jnicpp.bowling
import android.content.Context
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import androidx.core.content.ContextCompat
import com.example.jnicpp.R
import com.google.mlkit.vision.pose.PoseLandmark // for testing
/**
* @brief Draws the 33 ML Kit pose landmarks and connecting skeleton lines
* on top of the camera preview.
*
* The landmark topology and the coordinate mapping math (analysis-image
* pixels -> view pixels, replicating `PreviewView`'s `FILL_CENTER` scaling)
* live in [PoseSkeletonRenderer], shared with [CameraXController]'s
* baked-into-the-recorded-video overlay so both paths can't drift apart.
*
* @param context Android context, as required by [View]'s constructor.
* @param attrs XML attribute set, as required by [View]'s constructor; null when constructed in code.
*/
class PoseOverlayView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val jointPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.skeleton_joint)
style = Paint.Style.FILL
}
private val bonePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.skeleton_bone)
style = Paint.Style.STROKE
strokeWidth = PoseSkeletonRenderer.STROKE_WIDTH
}
private val anglePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.white)
style = Paint.Style.FILL
textSize = 36f
isFakeBoldText = true
// Readable regardless of what's behind it (skin, clothing, wall) --
// the skeleton paints get away without this since a line/dot has a
// consistent look, but small text needs the contrast.
setShadowLayer(4f, 0f, 0f, ContextCompat.getColor(context, R.color.black))
}
private var landmarks: Map<Int, SmoothedLandmark>? = null
private var angles: PoseAngles? = null
private var isFrontCamera = false
// Source image size and rotation, as last reported by the analyzer --
// fed straight into PoseSkeletonRenderer.computeTransform on every
// size/frame change.
private var sourceWidth = 0
private var sourceHeight = 0
private var sourceRotationDegrees = 0
private var transform = Matrix()
private var feedbackUI: FeedbackUI? = null
/**
* @brief Updates the view with the latest analyzer result and triggers a redraw.
* @param frame The latest analyzer result to draw, or null to clear the overlay.
* Called from the main thread.
*/
fun update(frame: PoseAnalyzer.PoseFrameResult?) {
landmarks = frame?.landmarks
angles = frame?.angles
if (frame != null) {
isFrontCamera = frame.isFrontCamera
sourceWidth = frame.imageWidth
sourceHeight = frame.imageHeight
sourceRotationDegrees = frame.rotationDegrees
}
recomputeTransform()
invalidate()
}
/** @brief Clears the drawn skeleton/angles and triggers a redraw. */
fun clear() {
landmarks = null
angles = null
invalidate()
}
/**
* @brief Recomputes the source-to-view coordinate transform whenever the view's own size changes.
* @param w New view width, in pixels.
* @param h New view height, in pixels.
* @param oldw Previous view width, in pixels.
* @param oldh Previous view height, in pixels.
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
recomputeTransform()
}
/** @brief Rebuilds [transform] from the current source image size/rotation and this view's current size. */
private fun recomputeTransform() {
transform = PoseSkeletonRenderer.computeTransform(
sourceWidth = sourceWidth,
sourceHeight = sourceHeight,
sourceRotationDegrees = sourceRotationDegrees,
targetWidth = width,
targetHeight = height,
// Front camera preview is mirrored; flip the x axis about the
// view's center so the overlay matches what's on screen.
mirror = isFrontCamera
)
}
/**
* @brief Draws the skeleton and angle labels for the most recent [update] call, if any.
* @param canvas Canvas supplied by the View system to draw onto.
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val currentLandmarks = landmarks ?: return
PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint)
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) }
// currentLandmarks to change to landmarks that require highlighting
// test code to contain only left wrist in currentLandmarks to not clutter the screen
val leftWrist = currentLandmarks[PoseLandmark.LEFT_WRIST]
// Build a singleitem map if it exists
val singleLandmark = if (leftWrist != null) {
mapOf(PoseLandmark.LEFT_WRIST to leftWrist)
} else {
emptyMap()
}
// end test code
feedbackUI?.drawCircles(canvas, singleLandmark, transform)
// Trigger feedback advice for this step
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step efsdfdg d dg df gdgdfg df ")
}
/**
* @brief Attaches a FeedbackUI instance to this component.
*
* This method stores a reference to the provided [FeedbackUI] so that
* banner rendering and landmark overlays can be delegated to it. By
* attaching the UI handler here, the parent component gains access to
* feedback drawing utilities without needing to manage them directly.
*
* @param feedbackUI The [FeedbackUI] instance to associate with this component.
*/
fun attachFeedback(feedbackUI: FeedbackUI) {
this.feedbackUI = feedbackUI
}
}