From 17fad430c1d368369f8fb585778e92b28461d7ff Mon Sep 17 00:00:00 2001 From: Gabriel Low <73009315+gabriellow1111@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:09:46 +0800 Subject: [PATCH] Cleaned up merge UI, added reset step button --- .../jnicpp/bowling/BowlingCameraActivity.kt | 9 +- .../example/jnicpp/bowling/CameraViewModel.kt | 18 ++-- .../jnicpp/bowling/CameraXController.kt | 101 ++++++++++++------ .../com/example/jnicpp/bowling/FeedbackUI.kt | 2 +- .../example/jnicpp/bowling/PoseOverlayView.kt | 3 - .../jnicpp/bowling/StepCountingSession.kt | 8 ++ .../layout-land/activity_bowling_camera.xml | 55 ++++++---- .../res/layout/activity_bowling_camera.xml | 64 ++++++----- app/src/main/res/values/strings.xml | 1 + 9 files changed, 170 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt b/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt index b039e9b..5580c61 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt @@ -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 diff --git a/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt b/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt index cb4c334..6bde4a5 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt @@ -149,17 +149,14 @@ class CameraViewModel(application: Application) : AndroidViewModel(application) fun onPoseFrameUpdated(landmarks: Map, 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. diff --git a/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt b/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt index 9050564..6efbedf 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt @@ -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) } /** diff --git a/app/src/main/java/com/example/jnicpp/bowling/FeedbackUI.kt b/app/src/main/java/com/example/jnicpp/bowling/FeedbackUI.kt index a54c33c..0ba355a 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/FeedbackUI.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/FeedbackUI.kt @@ -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() diff --git a/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt b/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt index 2717cf4..1d63cd5 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt @@ -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") } /** diff --git a/app/src/main/java/com/example/jnicpp/bowling/StepCountingSession.kt b/app/src/main/java/com/example/jnicpp/bowling/StepCountingSession.kt index 8814c0f..8da12ab 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/StepCountingSession.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/StepCountingSession.kt @@ -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. diff --git a/app/src/main/res/layout-land/activity_bowling_camera.xml b/app/src/main/res/layout-land/activity_bowling_camera.xml index dd45cac..4da2b23 100644 --- a/app/src/main/res/layout-land/activity_bowling_camera.xml +++ b/app/src/main/res/layout-land/activity_bowling_camera.xml @@ -75,31 +75,12 @@ android:fontFamily="monospace" /> - - - + android:layout_marginTop="96dp"> + + + + +