Cleaned up merge UI, added reset step button

This commit is contained in:
Gabriel Low
2026-09-10 12:09:46 +08:00
parent ac4366d04d
commit 17fad430c1
9 changed files with 170 additions and 91 deletions
@@ -133,6 +133,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
startActivity(Intent(this, ParameterEditorActivity::class.java))
}
}
binding.btnResetCounter.setOnClickListener { viewModel.resetStepCounter() }
observeViewModel()
@@ -243,7 +244,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// detection is actually seeing each footfall while
// testing/tuning it, rather than only finding out at
// step 5 that earlier steps were silently missed.
if (events.isEmpty()) {
val isRecording = viewModel.recordingState.value is CameraViewModel.RecordingState.Recording
if (events.isEmpty() || !isRecording) {
binding.textFinalPosition.visibility = View.GONE
} else {
val reachedFinal = events.size >= FINAL_STEP_COUNT
@@ -264,8 +266,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
}
launch {
viewModel.poseStageFeedback.collect { feedback ->
val isRecording = viewModel.recordingState.value is CameraViewModel.RecordingState.Recording
binding.textPoseStageFeedback.text = feedback
binding.textPoseStageFeedback.visibility = if (feedback != null) View.VISIBLE else View.GONE
binding.textPoseStageFeedback.visibility = if ((feedback != null && isRecording)) View.VISIBLE else View.GONE
}
}
// Deliberately its own collector, independent of stepEvents
@@ -314,6 +317,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
is CameraViewModel.RecordingState.Idle -> {
binding.layoutRecordingIndicator.visibility = View.GONE
stepCounterUi.setVisible(visible = false)
binding.textFinalPosition.visibility = View.GONE
binding.textPoseStageFeedback.visibility = View.GONE
binding.btnRecord.isEnabled = true
binding.btnRecord.setText(R.string.record)
// Pose mode can only be changed between recordings, not
@@ -149,17 +149,14 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
fun onPoseFrameUpdated(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles) {
_poseAngles.value = angles
// Update pose phase detector with this frame's landmarks and angles
// Update pose phase detector with this frame's landmarks and angles (independent of step counter)
val phaseResult = posePhaseDetector.update(landmarks, angles)
val stepCount = stepEvents.value.size
val stepPhase = PosePhaseDetector.phaseForStep(stepCount)
_posePhase.value = phaseResult.phase ?: stepPhase
_posePhase.value = phaseResult.phase
_poseMetrics.value = phaseResult.metrics
// Check if the current pose matches the Starting Stance
// Check if the current pose matches the Starting Stance (pure posture query)
val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE)
|| posePhaseDetector.isStartingStanceValid(phaseResult.metrics)
|| (PoseStageAdvisor.feedback(stepCount.takeIf { it > 0 }, angles)?.contains("Starting position") == true)
if (_recordingState.value is RecordingState.Recording) {
stepCountingSession.onFrame(
@@ -168,13 +165,20 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
timestampMs = System.currentTimeMillis(),
isStartingPosition = isStartingStance,
)
val currentStepCount = stepEvents.value.size
_poseStageFeedback.value = PoseStageAdvisor.feedback(
stepNumber = stepCount.takeIf { it > 0 },
stepNumber = currentStepCount.takeIf { it > 0 },
angles = angles,
)
}
}
/** @brief Manually resets step counting state for the current recording session. */
fun resetStepCounter() {
stepCountingSession.resetStepCounter()
_poseStageFeedback.value = null
}
/**
* @brief Marks a recording as being requested and resets all
* per-session buffering/detection state.
@@ -152,6 +152,20 @@ class CameraXController(
style = Paint.Style.FILL
}
}
private val overlayCardBorderPaint by lazy {
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(appContext, R.color.step_counter_accent)
style = Paint.Style.STROKE
strokeWidth = 3f
}
}
private val overlayAccentTextPaint by lazy {
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(appContext, R.color.step_counter_accent)
textSize = 14f
isFakeBoldText = true
}
}
private var feedbackUI: FeedbackUI? = null
@@ -316,7 +330,7 @@ class CameraXController(
feedbackUI?.drawCircles(canvas, singleLandmark, transform, forRecord = true)
val state = recordingOverlayStateProvider?.invoke()
val scale = (minOf(targetWidth, targetHeight) / 720f).coerceAtLeast(1f)
val scale = feedbackUI?.computeCamScale(canvas) ?: (minOf(targetWidth, targetHeight) / 1080f)
// 1. Step Counter Card (Top Center)
val stepCount = state?.stepCount ?: 0
@@ -327,7 +341,7 @@ class CameraXController(
drawPhaseBadgeOverlay(canvas, phase, scale)
}
// 3. Body Angle Metrics Readout (Top Left)
// 3. Body Angle Metrics Readout (Bottom Left)
state?.metrics?.let { metrics ->
drawMetricsOverlay(canvas, metrics, scale)
}
@@ -345,22 +359,49 @@ class CameraXController(
}
private fun drawStepCounterOverlay(canvas: Canvas, stepCount: Int, scale: Float) {
val countText = "$stepCount STEPS"
val textPaint = overlayTextPaint.apply { textSize = 26f * scale }
val textWidth = textPaint.measureText(countText)
val paddingX = 18f * scale
val paddingY = 8f * scale
val cardWidth = textWidth + (paddingX * 2f)
val cardHeight = (26f * scale) + (paddingY * 2f)
val numberText = stepCount.toString()
val labelText = "STEPS"
val numPaint = overlayTextPaint.apply { textSize = 32f * scale }
val labelPaint = overlayAccentTextPaint.apply {
textSize = 11f * scale
strokeWidth = 0f
style = Paint.Style.FILL
}
val numWidth = numPaint.measureText(numberText)
val labelWidth = labelPaint.measureText(labelText)
val contentWidth = maxOf(numWidth, labelWidth)
val paddingX = 20f * scale
val paddingY = 6f * scale
val cardWidth = contentWidth + (paddingX * 2f)
val cardHeight = (32f * scale) + (11f * scale) + (paddingY * 2f)
val left = (canvas.width - cardWidth) / 2f
val top = 32f * scale
val top = 104f * scale
val right = left + cardWidth
val bottom = top + cardHeight
val rect = RectF(left, top, right, bottom)
canvas.drawRoundRect(rect, 14f * scale, 14f * scale, overlayScrimPaint)
canvas.drawText(countText, left + paddingX, top + paddingY + (22f * scale), textPaint)
val radius = 16f * scale
// Draw card background
canvas.drawRoundRect(rect, radius, radius, overlayScrimPaint)
// Draw card border
overlayCardBorderPaint.strokeWidth = 2f * scale
canvas.drawRoundRect(rect, radius, radius, overlayCardBorderPaint)
// Draw big number text (centered)
val numX = left + ((cardWidth - numWidth) / 2f)
val numY = top + paddingY + (28f * scale)
canvas.drawText(numberText, numX, numY, numPaint)
// Draw "STEPS" label text (centered below number)
val labelX = left + ((cardWidth - labelWidth) / 2f)
val labelY = numY + (14f * scale)
canvas.drawText(labelText, labelX, labelY, labelPaint)
}
private fun drawPhaseBadgeOverlay(canvas: Canvas, phase: BowlingPhase, scale: Float) {
@@ -380,16 +421,16 @@ class CameraXController(
BowlingPhase.POWER_STEP -> R.color.Power_step_ready
BowlingPhase.SLIDE_AND_RELEASE -> R.color.Slide_and_release_ready
}
val textPaint = overlayTextPaint.apply { textSize = 20f * scale }
val textPaint = overlayTextPaint.apply { textSize = 14f * scale }
val textWidth = textPaint.measureText(label)
val paddingX = 14f * scale
val paddingY = 6f * scale
val paddingX = 10f * scale
val paddingY = 4f * scale
val badgeWidth = textWidth + (paddingX * 2f)
val badgeHeight = (20f * scale) + (paddingY * 2f)
val badgeHeight = (14f * scale) + (paddingY * 2f)
val right = canvas.width - (24f * scale)
val right = canvas.width - (16f * scale)
val left = right - badgeWidth
val top = 32f * scale
val top = 112f * scale
val bottom = top + badgeHeight
val badgePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@@ -398,8 +439,8 @@ class CameraXController(
}
val rect = RectF(left, top, right, bottom)
canvas.drawRoundRect(rect, 12f * scale, 12f * scale, badgePaint)
canvas.drawText(label, left + paddingX, top + paddingY + (18f * scale), textPaint)
canvas.drawRoundRect(rect, 10f * scale, 10f * scale, badgePaint)
canvas.drawText(label, left + paddingX, top + paddingY + (12f * scale), textPaint)
}
private fun drawMetricsOverlay(canvas: Canvas, metrics: PosePhaseDetector.Metrics, scale: Float) {
@@ -407,24 +448,24 @@ class CameraXController(
val line1 = "Torso: ${angleText(metrics.torsoTiltDegrees)} · Knee L: ${angleText(metrics.leftKneeAngleDegrees)} R: ${angleText(metrics.rightKneeAngleDegrees)}"
val line2 = "Elbow L: ${angleText(metrics.leftElbowAngleDegrees)} R: ${angleText(metrics.rightElbowAngleDegrees)}"
val textPaint = overlayTextPaint.apply { textSize = 16f * scale }
val textPaint = overlayTextPaint.apply { textSize = 12f * scale }
val w1 = textPaint.measureText(line1)
val w2 = textPaint.measureText(line2)
val maxWidth = maxOf(w1, w2)
val paddingX = 12f * scale
val paddingY = 6f * scale
val paddingX = 8f * scale
val paddingY = 4f * scale
val cardWidth = maxWidth + (paddingX * 2f)
val cardHeight = (18f * 2f * scale) + (paddingY * 2f) + (4f * scale)
val cardHeight = (12f * 2f * scale) + (paddingY * 2f) + (4f * scale)
val left = 24f * scale
val top = 32f * scale
val left = 16f * scale
val bottom = canvas.height - (80f * scale)
val top = bottom - cardHeight
val right = left + cardWidth
val bottom = top + cardHeight
val rect = RectF(left, top, right, bottom)
canvas.drawRoundRect(rect, 10f * scale, 10f * scale, overlayScrimPaint)
canvas.drawText(line1, left + paddingX, top + paddingY + (15f * scale), textPaint)
canvas.drawText(line2, left + paddingX, top + paddingY + (15f * 2f * scale) + (4f * scale), textPaint)
canvas.drawRoundRect(rect, 8f * scale, 8f * scale, overlayScrimPaint)
canvas.drawText(line1, left + paddingX, top + paddingY + (11f * scale), textPaint)
canvas.drawText(line2, left + paddingX, top + paddingY + (11f * 2f * scale) + (4f * scale), textPaint)
}
/**
@@ -180,7 +180,7 @@ class FeedbackUI(rootView: View) {
* @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 {
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()
@@ -139,9 +139,6 @@ class PoseOverlayView @JvmOverloads constructor(
}
// 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")
}
/**
@@ -82,6 +82,14 @@ class StepCountingSession {
}
}
/**
* @brief Manually resets live step detector state and clears detected step events.
*/
fun resetStepCounter() {
liveStepDetector.reset()
_stepEvents.value = emptyList()
}
/**
* @brief Clears all buffering/detection state and rebuilds the step
* detector from [settings]; call when a new recording starts.