Merge branch 'master' into jingwen

This commit is contained in:
2026-09-08 10:50:35 +08:00
7 changed files with 315 additions and 2 deletions
@@ -63,6 +63,19 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// doc. Open only while a recording is in progress. // doc. Open only while a recording is in progress.
private lateinit var debugSessionLogger: DebugSessionLogger private lateinit var debugSessionLogger: DebugSessionLogger
// class for FeedbackUI
private lateinit var feedbackUI: FeedbackUI
private val stepLabels = listOf(
R.string.pose_phase_waiting,
R.string.pose_phase_starting_stance,
R.string.first_step,
R.string.second_step,
R.string.third_step,
R.string.fourth_step,
R.string.end_position
)
private var currentStepIndex = 0
private val permissionLauncher = private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ ->
val granted = CameraPermissions.allGranted(this) val granted = CameraPermissions.allGranted(this)
@@ -89,7 +102,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
cameraExecutor = Executors.newSingleThreadExecutor() cameraExecutor = Executors.newSingleThreadExecutor()
cameraXController = CameraXController(applicationContext, cameraExecutor) cameraXController = CameraXController(applicationContext, cameraExecutor)
debugSessionLogger = DebugSessionLogger(applicationContext) debugSessionLogger = DebugSessionLogger(applicationContext)
feedbackUI = FeedbackUI(this, binding.root)
binding.poseOverlay.attachFeedback(feedbackUI)
binding.btnGrantPermissions.setOnClickListener { binding.btnGrantPermissions.setOnClickListener {
permissionLauncher.launch(CameraPermissions.REQUIRED) permissionLauncher.launch(CameraPermissions.REQUIRED)
} }
@@ -97,6 +112,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
binding.switchPose.setOnCheckedChangeListener { _, isChecked -> viewModel.onPoseToggled(isChecked) } binding.switchPose.setOnCheckedChangeListener { _, isChecked -> viewModel.onPoseToggled(isChecked) }
binding.btnSwitchCamera.setOnClickListener { onSwitchCameraClicked() } binding.btnSwitchCamera.setOnClickListener { onSwitchCameraClicked() }
binding.btnBack.setOnClickListener { finish() } binding.btnBack.setOnClickListener { finish() }
binding.btnShowStep.setOnClickListener { onStepIncrease() }
observeViewModel() observeViewModel()
@@ -116,7 +132,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
lifecycleOwner = this, lifecycleOwner = this,
previewView = binding.cameraPreview, previewView = binding.cameraPreview,
callback = this, callback = this,
lensFacing = lensFacing lensFacing = lensFacing,
feedbackUi = feedbackUI
) )
} }
@@ -236,12 +253,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// Pose mode can only be changed between recordings, not // Pose mode can only be changed between recordings, not
// mid-flight -- see setPoseDetectionEnabled()'s doc comment. // mid-flight -- see setPoseDetectionEnabled()'s doc comment.
binding.switchPose.isEnabled = true binding.switchPose.isEnabled = true
// Feedback UI - buttons only shown when recording
binding.btnShowStep.isEnabled = false
} }
is CameraViewModel.RecordingState.Starting -> { is CameraViewModel.RecordingState.Starting -> {
// Can't stop a recording that hasn't started yet, and pose // Can't stop a recording that hasn't started yet, and pose
// mode for it is already locked in. // mode for it is already locked in.
binding.btnRecord.isEnabled = false binding.btnRecord.isEnabled = false
binding.switchPose.isEnabled = false binding.switchPose.isEnabled = false
binding.btnShowStep.isEnabled = true
binding.btnShowStep.setText(R.string.pose_phase_waiting)
} }
is CameraViewModel.RecordingState.Recording -> { is CameraViewModel.RecordingState.Recording -> {
binding.btnRecord.isEnabled = true binding.btnRecord.isEnabled = true
@@ -251,6 +272,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
val minutes = state.elapsedSeconds / 60 val minutes = state.elapsedSeconds / 60
val seconds = state.elapsedSeconds % 60 val seconds = state.elapsedSeconds % 60
binding.textTimer.text = String.format(Locale.US, "%02d:%02d", minutes, seconds) binding.textTimer.text = String.format(Locale.US, "%02d:%02d", minutes, seconds)
binding.btnShowStep.isEnabled = true
} }
} }
} }
@@ -438,4 +460,18 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
cameraExecutor.shutdown() cameraExecutor.shutdown()
debugSessionLogger.stop() debugSessionLogger.stop()
} }
/**
* @brief Advances the step index and updates the step button label.
*
* This method increments the current step index, cycling back to zero
* once the end of the [stepLabels] list is reached. It then updates the
* `btnShowStep` text to reflect the new step, ensuring the UI button
* always displays the correct label for the current position in the
* sequence.
*/
fun onStepIncrease() {
currentStepIndex = (currentStepIndex + 1) % stepLabels.size
binding.btnShowStep.setText(stepLabels[currentStepIndex])
}
} }
@@ -39,6 +39,8 @@ import com.example.jnicpp.R
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import com.google.mlkit.vision.pose.PoseLandmark // for testing
/** /**
* @brief Owns all CameraX use-case binding and recording control. * @brief Owns all CameraX use-case binding and recording control.
* *
@@ -116,6 +118,8 @@ class CameraXController(
} }
} }
private var feedbackUI: FeedbackUI? = null
/** @brief Whether a video recording is currently in progress. */ /** @brief Whether a video recording is currently in progress. */
val isRecording: Boolean val isRecording: Boolean
get() = activeRecording != null get() = activeRecording != null
@@ -140,10 +144,12 @@ class CameraXController(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
previewView: PreviewView, previewView: PreviewView,
callback: Callback, callback: Callback,
lensFacing: Int = CameraSelector.LENS_FACING_BACK lensFacing: Int = CameraSelector.LENS_FACING_BACK,
feedbackUi: FeedbackUI
) { ) {
this.callback = callback this.callback = callback
this.currentLensFacing = lensFacing this.currentLensFacing = lensFacing
this.feedbackUI = feedbackUi
val providerFuture = ProcessCameraProvider.getInstance(appContext) val providerFuture = ProcessCameraProvider.getInstance(appContext)
providerFuture.addListener({ providerFuture.addListener({
@@ -267,6 +273,19 @@ class CameraXController(
mirror = frame.isMirroring mirror = frame.isMirroring
) )
PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint) PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint)
// currentLandmarks to change to landmarks that require highlighting
// test code to contain only left wrist in currentLandmarks to not clutter the screen
val leftWrist = poseFrame.landmarks[PoseLandmark.LEFT_WRIST]
// Build a singleitem map if it exists
val singleLandmark = if (leftWrist != null) {
mapOf(PoseLandmark.LEFT_WRIST to leftWrist)
} else {
emptyMap()
}
feedbackUI?.drawCircles(canvas, singleLandmark, transform, true)
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step", true)
} }
true true
} }
@@ -0,0 +1,198 @@
package com.example.jnicpp.bowling
import android.text.Layout
import android.text.TextPaint
import android.text.StaticLayout
import android.graphics.Canvas
import android.graphics.RectF
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.content.Context
import android.graphics.Paint
import android.view.View
import android.widget.TextView
import com.example.jnicpp.R
import android.graphics.BlurMaskFilter
import android.graphics.Matrix
import kotlin.math.min
import androidx.constraintlayout.widget.ConstraintLayout
class FeedbackUI(private val context: Context, private val rootView: View) {
private var landmarks: Map<Int, SmoothedLandmark>? = null
private var CIRCLE_RADIUS = 128f
private val uiTextSize = 32f
private val uiStrokeWidth = 12f
private val bannerPaddingY = 24
private val bannerPaddingX = 24
private val camScale = 0.5f
private val bannerTopMargin = 512f
private var liveUiWidth: Int = 0
private var liveUiHeight: Int = 0
private val glowPaint = Paint().apply {
color = Color.RED
style = Paint.Style.STROKE
isAntiAlias = true
strokeWidth = uiStrokeWidth // thicker stroke so glow is visible
maskFilter = BlurMaskFilter(25f, BlurMaskFilter.Blur.OUTER)
}
private val circlePaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.STROKE
isAntiAlias = true
strokeWidth = uiStrokeWidth
}
private val textPaint = TextPaint().apply {
color = Color.WHITE
textSize = uiTextSize
isAntiAlias = true
}
private val bgPaint = Paint().apply {
color = Color.argb(64, 255, 255, 255)
isAntiAlias = true
}
init {
rootView.viewTreeObserver.addOnGlobalLayoutListener {
setLiveUiSize(rootView)
}
}
/**
* @brief Displays a banner with the given message on the provided canvas.
*
* The banner is centered horizontally and offset vertically. It uses
* `StaticLayout` to support multi-line text and scales its size depending
* on whether the canvas is for live UI or recording output.
*
* @param canvas The canvas to draw the banner on.
* @param message The text message to display inside the banner.
* @param forRecord If true, scales the banner relative to recording canvas
* dimensions; otherwise uses live UI scale.
*/
fun showBanner(canvas: Canvas, message: String, forRecord: Boolean = false) {
val scale = if (forRecord) computeCamScale(canvas) else 1f
val paddingX = bannerPaddingX * scale
val paddingY = bannerPaddingY * scale
val maxWidth = (canvas.width * 0.8f).toInt()
// Build StaticLayout for multi-line text
textPaint.textSize = uiTextSize * scale
val staticLayout = StaticLayout.Builder
.obtain(message, 0, message.length, textPaint, maxWidth)
.setAlignment(Layout.Alignment.ALIGN_CENTER) // center text horizontally
.setLineSpacing(0f, 1f)
.setIncludePad(false)
.build()
// Use StaticLayout dimensions
val textWidth = staticLayout.width.toFloat()
val textHeight = staticLayout.height.toFloat()
val bannerWidth = textWidth + paddingX * 2
val bannerHeight = textHeight + paddingY * 2
// Center horizontally
val left = (canvas.width - bannerWidth) / 2f
val top = bannerTopMargin * scale
val right = left + bannerWidth
val bottom = top + bannerHeight
// Draw background
val rect = RectF(left, top, right, bottom)
// Scale corner radius
val cornerRadius = 24f * scale
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
// Draw text layout inside background
canvas.save()
canvas.translate(left + paddingX, top + paddingY)
staticLayout.draw(canvas)
canvas.restore()
}
/**
* @brief Draws glowing circles around all provided landmarks.
*
* Each landmarks coordinates are transformed by the given matrix before
* drawing. Circles are rendered with both a white stroke and a red glow
* effect for visibility.
*
* @param canvas The canvas to draw circles on.
* @param landmarks A map of landmark indices to smoothed landmark positions.
* @param transform A matrix applied to landmark coordinates before drawing.
* @param forRecord If true, scales circle radius and stroke width for
* recording output.
*/
// --- Shape overlay (circle) ---
fun drawCircles(canvas: Canvas, landmarks: Map<Int, SmoothedLandmark>, transform: Matrix, forRecord: Boolean = false) {
for (landmark in landmarks.values) {
val point = floatArrayOf(landmark.x, landmark.y)
transform.mapPoints(point)
drawCircle(canvas, point[0], point[1], 0f, forRecord)
}
}
/**
* @brief Draws a single circle with both a solid stroke and a glowing outline.
*
* This method renders a circle at the specified coordinates using two
* layered paints: a white stroke (`circlePaint`) and a red glow (`glowPaint`).
* The radius and stroke widths are scaled depending on whether the canvas
* is for live UI or recording output, ensuring consistent visual feedback
* across different resolutions.
*
* @param canvas The canvas to draw the circle on.
* @param x The xcoordinate of the circles center.
* @param y The ycoordinate of the circles center.
* @param radius The circle radius. If set to 0, defaults to [CIRCLE_RADIUS].
* @param forRecord If true, applies recording scale factor to radius and
* stroke width; otherwise uses live UI scale.
*/
fun drawCircle(canvas: Canvas, x: Float, y: Float, radius: Float = 0f, forRecord: Boolean = false) {
val scale = if (forRecord) camScale else 1f
var rad = (if (radius == 0f) CIRCLE_RADIUS else radius) * scale
circlePaint.strokeWidth = uiStrokeWidth * scale
glowPaint.strokeWidth = uiStrokeWidth * scale
canvas.drawCircle(x, y, rad, circlePaint)
canvas.drawCircle(x, y, rad, glowPaint)
}
/**
* @brief Updates stored live UI dimensions based on the root view.
*
* This method caches the width and height of the root view so that
* recording canvas scaling can be computed consistently later.
*
* @param rootView The root view whose dimensions are measured.
*/
fun setLiveUiSize(rootView: View) {
liveUiWidth = rootView.width
liveUiHeight = rootView.height
}
/**
* @brief Computes the scaling factor between live UI and recording canvas.
*
* The scale is determined by comparing the recording canvas dimensions
* against the cached live UI dimensions, using the smaller ratio to
* preserve aspect consistency.
*
* @param recordCanvas The canvas used for recording output.
* @return A float scale factor to apply when drawing to the recording canvas.
*/
private fun computeCamScale(recordCanvas: Canvas): Float {
if (liveUiWidth == 0 || liveUiHeight == 0) return 1f
val scaleX = recordCanvas.width.toFloat() / liveUiWidth.toFloat()
val scaleY = recordCanvas.height.toFloat() / liveUiHeight.toFloat()
return min(scaleX, scaleY)
}
}
@@ -12,6 +12,7 @@ import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.jnicpp.R 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 * @brief Draws the 33 ML Kit pose landmarks and connecting skeleton lines
@@ -63,6 +64,8 @@ class PoseOverlayView @JvmOverloads constructor(
private var transform = Matrix() private var transform = Matrix()
private var feedbackUI: FeedbackUI? = null
/** /**
* @brief Updates the view with the latest analyzer result and triggers a redraw. * @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. * @param frame The latest analyzer result to draw, or null to clear the overlay.
@@ -123,5 +126,35 @@ class PoseOverlayView @JvmOverloads constructor(
val currentLandmarks = landmarks ?: return val currentLandmarks = landmarks ?: return
PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint) PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint)
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) } 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
} }
} }
@@ -167,6 +167,17 @@
app:layout_constraintBottom_toTopOf="@id/btn_record" app:layout_constraintBottom_toTopOf="@id/btn_record"
app:layout_constraintEnd_toEndOf="@id/btn_record" /> app:layout_constraintEnd_toEndOf="@id/btn_record" />
<!-- to only show when recording-->
<Button
android:id="@+id/btn_show_step"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pose_phase_waiting"
android:layout_marginBottom="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Shown instead of the camera UI when CAMERA/RECORD_AUDIO aren't granted --> <!-- Shown instead of the camera UI when CAMERA/RECORD_AUDIO aren't granted -->
<LinearLayout <LinearLayout
android:id="@+id/layout_permission_rationale" android:id="@+id/layout_permission_rationale"
@@ -126,6 +126,17 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_marginEnd="16dp" /> android:layout_marginEnd="16dp" />
<!-- to only show when recording-->
<Button
android:id="@+id/btn_show_step"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pose_phase_waiting"
android:layout_marginBottom="128dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Live pose overlay + baked-in-recording toggle. Only togglable while <!-- Live pose overlay + baked-in-recording toggle. Only togglable while
not recording (see BowlingCameraActivity#renderRecordingState) since not recording (see BowlingCameraActivity#renderRecordingState) since
+5
View File
@@ -23,4 +23,9 @@
<string name="pose_phase_pushaway">Pushaway</string> <string name="pose_phase_pushaway">Pushaway</string>
<string name="pose_phase_waiting">Get into pose</string> <string name="pose_phase_waiting">Get into pose</string>
<string name="pose_metrics_format">Torso %1$s · Knee L%2$s R%3$s · Elbow L%4$s R%5$s</string> <string name="pose_metrics_format">Torso %1$s · Knee L%2$s R%3$s · Elbow L%4$s R%5$s</string>
<string name="first_step">1st Step</string>
<string name="second_step">2nd Step</string>
<string name="third_step">3rd Step</string>
<string name="fourth_step">4th Step</string>
<string name="end_position">Ending Position</string>
</resources> </resources>