/** * @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? = 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 single‑item 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 } }