2026-08-14 18:16:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @file PoseOverlayView.kt
|
|
|
|
|
|
* @brief Live on-screen View that draws the pose skeleton and angle labels over the camera preview.
|
|
|
|
|
|
*/
|
2026-08-11 19:46:54 +08:00
|
|
|
|
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
|
2026-09-06 22:31:36 +08:00
|
|
|
|
import com.google.mlkit.vision.pose.PoseLandmark // for testing
|
2026-08-11 19:46:54 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-14 18:16:55 +08:00
|
|
|
|
* @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.
|
2026-08-11 19:46:54 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-12 12:46:33 +08:00
|
|
|
|
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))
|
|
|
|
|
|
}
|
2026-08-11 19:46:54 +08:00
|
|
|
|
|
2026-08-13 19:54:48 +08:00
|
|
|
|
private var landmarks: Map<Int, SmoothedLandmark>? = null
|
2026-08-12 12:46:33 +08:00
|
|
|
|
private var angles: PoseAngles? = null
|
2026-08-11 19:46:54 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-09-06 22:31:36 +08:00
|
|
|
|
private var feedbackUI: FeedbackUI? = null
|
|
|
|
|
|
|
2026-08-14 18:16:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @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.
|
|
|
|
|
|
*/
|
2026-08-11 19:46:54 +08:00
|
|
|
|
fun update(frame: PoseAnalyzer.PoseFrameResult?) {
|
2026-08-13 19:54:48 +08:00
|
|
|
|
landmarks = frame?.landmarks
|
2026-08-12 12:46:33 +08:00
|
|
|
|
angles = frame?.angles
|
2026-08-11 19:46:54 +08:00
|
|
|
|
if (frame != null) {
|
|
|
|
|
|
isFrontCamera = frame.isFrontCamera
|
|
|
|
|
|
sourceWidth = frame.imageWidth
|
|
|
|
|
|
sourceHeight = frame.imageHeight
|
|
|
|
|
|
sourceRotationDegrees = frame.rotationDegrees
|
|
|
|
|
|
}
|
|
|
|
|
|
recomputeTransform()
|
|
|
|
|
|
invalidate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 18:16:55 +08:00
|
|
|
|
/** @brief Clears the drawn skeleton/angles and triggers a redraw. */
|
2026-08-11 19:46:54 +08:00
|
|
|
|
fun clear() {
|
2026-08-13 19:54:48 +08:00
|
|
|
|
landmarks = null
|
2026-08-12 12:46:33 +08:00
|
|
|
|
angles = null
|
2026-08-11 19:46:54 +08:00
|
|
|
|
invalidate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 18:16:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @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.
|
|
|
|
|
|
*/
|
2026-08-11 19:46:54 +08:00
|
|
|
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
|
|
|
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
|
|
|
|
recomputeTransform()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 18:16:55 +08:00
|
|
|
|
/** @brief Rebuilds [transform] from the current source image size/rotation and this view's current size. */
|
2026-08-11 19:46:54 +08:00
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 18:16:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @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.
|
|
|
|
|
|
*/
|
2026-08-11 19:46:54 +08:00
|
|
|
|
override fun onDraw(canvas: Canvas) {
|
|
|
|
|
|
super.onDraw(canvas)
|
2026-08-13 19:54:48 +08:00
|
|
|
|
val currentLandmarks = landmarks ?: return
|
|
|
|
|
|
PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint)
|
|
|
|
|
|
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) }
|
2026-09-06 22:31:36 +08:00
|
|
|
|
|
|
|
|
|
|
// 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 ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-07 19:06:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @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.
|
|
|
|
|
|
*/
|
2026-09-06 22:31:36 +08:00
|
|
|
|
fun attachFeedback(feedbackUI: FeedbackUI) {
|
|
|
|
|
|
this.feedbackUI = feedbackUI
|
2026-08-11 19:46:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|