starting pose detection
This commit is contained in:
Generated
+7
@@ -4,6 +4,13 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-09-05T10:57:53.251732600Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<template>
|
||||
<DeviceId pluginId="FirebaseDirectAccess" type="TEMPLATE" identifier="model_id=pa1qksx/36" />
|
||||
</template>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
|
||||
Generated
+2
-1
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -17,9 +17,11 @@ import androidx.camera.core.CameraSelector
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.jnicpp.R
|
||||
import com.example.jnicpp.databinding.ActivityBowlingCameraBinding
|
||||
import com.google.mlkit.vision.pose.PoseLandmark
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ExecutorService
|
||||
@@ -184,6 +186,24 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deliberately its own collector, independent of stepEvents
|
||||
// above -- delivery-phase feedback and step counting are
|
||||
// separate concerns (see PosePhaseDetector's class doc).
|
||||
// Combined with poseEnabled (rather than posePhase alone) so
|
||||
// the label can tell "pose off" (hidden) apart from "pose on
|
||||
// but not yet in the target posture" (amber prompt) -- both
|
||||
// cases otherwise report a null phase.
|
||||
launch {
|
||||
combine(viewModel.poseEnabled, viewModel.posePhase) { enabled, phase -> enabled to phase }
|
||||
.collect { (enabled, phase) -> renderPosePhase(enabled, phase) }
|
||||
}
|
||||
// Raw angle readout backing the label above -- its own
|
||||
// collector since it's driven by a separate StateFlow
|
||||
// (poseMetrics is null on its own whenever pose detection is
|
||||
// off, so no need to combine with poseEnabled here).
|
||||
launch {
|
||||
viewModel.poseMetrics.collect { metrics -> renderPoseMetrics(metrics) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +255,66 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Shows or hides the delivery-phase feedback label, and colors/labels
|
||||
* it for whether [phase] currently validates.
|
||||
*
|
||||
* Visible for the entire time [poseEnabled] is on -- not just at the
|
||||
* moment a phase is confirmed -- so the bowler gets a continuous
|
||||
* "not yet"/"confirmed" signal to line themselves up against, rather
|
||||
* than a label that silently disappears whenever they drift out of
|
||||
* position. Independent of [renderRecordingState]/the step counter --
|
||||
* see [PosePhaseDetector]'s class doc for why phase feedback and step
|
||||
* counting are kept as separate concerns.
|
||||
*
|
||||
* @param poseEnabled Whether pose detection is currently on at all.
|
||||
* @param phase The bowler's current delivery phase, or null if none currently validates.
|
||||
*/
|
||||
private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?) {
|
||||
if (!poseEnabled) {
|
||||
binding.textPoseFeedback.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
binding.textPoseFeedback.visibility = View.VISIBLE
|
||||
// Every other BowlingPhase falls back to the "waiting" message too --
|
||||
// see PosePhaseDetector's class doc, only STARTING_STANCE is detected today.
|
||||
if (phase == BowlingPhase.STARTING_STANCE) {
|
||||
binding.textPoseFeedback.text = getString(R.string.pose_phase_starting_stance)
|
||||
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.pose_feedback_ready))
|
||||
} else {
|
||||
binding.textPoseFeedback.text = getString(R.string.pose_phase_waiting)
|
||||
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.pose_feedback_waiting))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Shows or hides the raw torso/knee/elbow angle readout backing [renderPosePhase]'s label.
|
||||
* @param metrics This frame's angle readings, or null to hide the readout (pose detection off).
|
||||
*/
|
||||
private fun renderPoseMetrics(metrics: PosePhaseDetector.Metrics?) {
|
||||
if (metrics == null) {
|
||||
binding.textPoseMetrics.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
binding.textPoseMetrics.visibility = View.VISIBLE
|
||||
binding.textPoseMetrics.text = getString(
|
||||
R.string.pose_metrics_format,
|
||||
angleText(metrics.torsoTiltDegrees),
|
||||
angleText(metrics.leftKneeAngleDegrees),
|
||||
angleText(metrics.rightKneeAngleDegrees),
|
||||
angleText(metrics.leftElbowAngleDegrees),
|
||||
angleText(metrics.rightElbowAngleDegrees)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Formats one angle reading for display.
|
||||
* @param degrees The angle in degrees, or null if that landmark wasn't confidently detected this frame.
|
||||
* @return e.g. "12°", or "--" if [degrees] is null.
|
||||
*/
|
||||
private fun angleText(degrees: Float?): String =
|
||||
if (degrees == null) "--" else "${degrees.toInt()}°"
|
||||
|
||||
/** @brief Hides the permission-rationale screen, revealing the camera UI underneath. */
|
||||
private fun showCameraUi() {
|
||||
binding.layoutPermissionRationale.visibility = View.GONE
|
||||
|
||||
@@ -82,6 +82,19 @@ class CameraViewModel : ViewModel() {
|
||||
// several practice approaches back to back.
|
||||
private val liveStepDetector = LiveStepDetector()
|
||||
|
||||
// Live delivery-phase classification (starting stance, approach, etc)
|
||||
private val posePhaseDetector = PosePhaseDetector()
|
||||
private val _posePhase = MutableStateFlow<BowlingPhase?>(null)
|
||||
//The bowler's current delivery phase, or null if no phase currently validates.
|
||||
val posePhase: StateFlow<BowlingPhase?> = _posePhase.asStateFlow()
|
||||
|
||||
// Raw torso/knee/elbow angle readings behind posePhase above, for
|
||||
// showing the bowler the actual numbers rather than just a pass/fail
|
||||
// signal -- see PosePhaseDetector.Metrics.
|
||||
private val _poseMetrics = MutableStateFlow<PosePhaseDetector.Metrics?>(null)
|
||||
//This frame's torso/knee/elbow angle readings, or null if pose detection is off.
|
||||
val poseMetrics: StateFlow<PosePhaseDetector.Metrics?> = _poseMetrics.asStateFlow()
|
||||
|
||||
// Steps detected so far in the current attempt (since the last reset,
|
||||
// whether that reset was a new recording starting or the bowler
|
||||
// returning to a stationary stance mid-recording -- see
|
||||
@@ -119,7 +132,12 @@ class CameraViewModel : ViewModel() {
|
||||
*/
|
||||
fun onPoseToggled(enabled: Boolean) {
|
||||
_poseEnabled.value = enabled
|
||||
if (!enabled) _poseAngles.value = null
|
||||
if (!enabled) {
|
||||
_poseAngles.value = null
|
||||
posePhaseDetector.reset()
|
||||
_posePhase.value = null
|
||||
_poseMetrics.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +153,9 @@ class CameraViewModel : ViewModel() {
|
||||
*/
|
||||
fun onPoseFrameUpdated(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles) {
|
||||
_poseAngles.value = angles
|
||||
val phaseResult = posePhaseDetector.update(landmarks, angles) //for pose detector
|
||||
_posePhase.value = phaseResult.phase
|
||||
_poseMetrics.value = phaseResult.metrics
|
||||
if (_recordingState.value is RecordingState.Recording) {
|
||||
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
|
||||
val frame = buildPoseFrame(
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file PosePhaseDetector.kt
|
||||
* @brief Incremental detector for which phase of the bowling delivery the bowler is currently in.
|
||||
*/
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import com.google.mlkit.vision.pose.PoseLandmark
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
|
||||
/**
|
||||
* @brief The delivery phases this app distinguishes, in the order a bowler moves through them.
|
||||
*
|
||||
* Only [BowlingPhase.STARTING_STANCE] has detection logic today (see
|
||||
* [PosePhaseDetector]); the rest are declared up front so callers (state,
|
||||
* UI) can already model "which of the 5 phases" without a later enum
|
||||
* change, and get filled in one at a time.
|
||||
*/
|
||||
enum class BowlingPhase {
|
||||
STARTING_STANCE,
|
||||
APPROACH,
|
||||
PUSHAWAY,
|
||||
SLIDE_RELEASE,
|
||||
FOLLOW_THROUGH
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Incrementally classifies the bowler's current posture into a
|
||||
* [BowlingPhase], entirely independent of [LiveStepDetector]/[StepDetector].
|
||||
*
|
||||
* Deliberately a separate detector rather than folded into the step
|
||||
* counter: step counting only cares about ankle-y peaks, while phase
|
||||
* detection classifies overall posture (torso lean, knee bend, elbow bend)
|
||||
* against a per-phase reference range. The two run side by side off the
|
||||
* same per-frame data (see [CameraViewModel.onPoseFrameUpdated]) but track
|
||||
* completely independent state, and neither calls into the other.
|
||||
*
|
||||
* [update] is fed one frame's landmarks/angles at a time, in recording (or
|
||||
* live-preview) order. A posture only "counts" once it holds for
|
||||
* [requiredConsecutiveFrames] frames in a row -- this filters out a
|
||||
* momentary, correct-looking pose caught mid-transition (e.g. a fleeting
|
||||
* instant during the approach where the knee angle briefly passes through
|
||||
* the starting-stance range) -- and keeps reporting that phase for as long
|
||||
* as the posture keeps validating, reverting to null the instant it doesn't.
|
||||
*
|
||||
* @param torsoTiltMinDegrees Minimum forward torso lean from vertical
|
||||
* (shoulder-midpoint-to-hip-midpoint vector vs. vertical) still
|
||||
* considered a starting-stance lean, in degrees.
|
||||
* @param torsoTiltMaxDegrees Maximum forward torso lean from vertical still
|
||||
* considered a starting-stance lean, in degrees.
|
||||
* @param kneeAngleMinDegrees Minimum hip-knee-ankle angle still considered
|
||||
* a near-straight standing leg, in degrees.
|
||||
* @param kneeAngleMaxDegrees Maximum hip-knee-ankle angle still considered
|
||||
* a near-straight standing leg, in degrees.
|
||||
* @param elbowAngleMinDegrees Minimum shoulder-elbow-wrist angle still
|
||||
* considered "holding the ball in front", in degrees.
|
||||
* @param elbowAngleMaxDegrees Maximum shoulder-elbow-wrist angle still
|
||||
* considered "holding the ball in front", in degrees.
|
||||
* @param requiredConsecutiveFrames How many consecutive frames the posture
|
||||
* must validate before [update] starts reporting [BowlingPhase.STARTING_STANCE].
|
||||
*/
|
||||
class PosePhaseDetector(
|
||||
private val torsoTiltMinDegrees: Float = 10f,
|
||||
private val torsoTiltMaxDegrees: Float = 15f,
|
||||
private val kneeAngleMinDegrees: Float = 160f,
|
||||
private val kneeAngleMaxDegrees: Float = 175f,
|
||||
private val elbowAngleMinDegrees: Float = 70f,
|
||||
private val elbowAngleMaxDegrees: Float = 110f,
|
||||
private val requiredConsecutiveFrames: Int = 8
|
||||
) {
|
||||
private var consecutiveValidFrames = 0
|
||||
private var currentPhase: BowlingPhase? = null
|
||||
|
||||
/**
|
||||
* @brief The raw angle readings [update] computed for one frame, for
|
||||
* callers that want to show the bowler the actual numbers (e.g.
|
||||
* "Torso 12°") rather than just a pass/fail phase.
|
||||
*
|
||||
* Each field is null exactly when the landmarks it depends on weren't
|
||||
* confidently detected that frame (see [reliable]) -- same meaning as a
|
||||
* null field elsewhere in this codebase (e.g. [PoseAngles]).
|
||||
*
|
||||
* @param torsoTiltDegrees See [torsoTiltDegrees].
|
||||
* @param leftKneeAngleDegrees Left hip-knee-ankle angle, or null.
|
||||
* @param rightKneeAngleDegrees Right hip-knee-ankle angle, or null.
|
||||
* @param leftElbowAngleDegrees Left shoulder-elbow-wrist angle (from [PoseAngles]), or null.
|
||||
* @param rightElbowAngleDegrees Right shoulder-elbow-wrist angle (from [PoseAngles]), or null.
|
||||
*/
|
||||
data class Metrics(
|
||||
val torsoTiltDegrees: Float?,
|
||||
val leftKneeAngleDegrees: Float?,
|
||||
val rightKneeAngleDegrees: Float?,
|
||||
val leftElbowAngleDegrees: Float?,
|
||||
val rightElbowAngleDegrees: Float?
|
||||
)
|
||||
|
||||
/**
|
||||
* @brief One [update] call's outcome: the classified phase plus the raw angles it was based on.
|
||||
* @param phase See [update]'s return doc.
|
||||
* @param metrics This frame's raw angle readings, for display regardless of whether [phase] validated.
|
||||
*/
|
||||
data class Result(val phase: BowlingPhase?, val metrics: Metrics)
|
||||
|
||||
/**
|
||||
* @brief Feeds one frame's landmarks/angles into the detector.
|
||||
*
|
||||
* @param landmarks Smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant.
|
||||
* @param angles Joint angles computed for this same frame (see
|
||||
* [PoseAngleCalculator]) -- elbow angles are reused from here
|
||||
* rather than recomputed, so this detector doesn't duplicate that math.
|
||||
* @return This frame's [Metrics] alongside [BowlingPhase.STARTING_STANCE]
|
||||
* once the posture has validated for [requiredConsecutiveFrames]
|
||||
* frames in a row and keeps validating on this frame too,
|
||||
* otherwise alongside a null phase.
|
||||
*/
|
||||
fun update(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles): Result {
|
||||
val metrics = Metrics(
|
||||
torsoTiltDegrees = torsoTiltDegrees(landmarks),
|
||||
leftKneeAngleDegrees = kneeAngle(landmarks, PoseLandmark.LEFT_HIP, PoseLandmark.LEFT_KNEE, PoseLandmark.LEFT_ANKLE),
|
||||
rightKneeAngleDegrees = kneeAngle(landmarks, PoseLandmark.RIGHT_HIP, PoseLandmark.RIGHT_KNEE, PoseLandmark.RIGHT_ANKLE),
|
||||
leftElbowAngleDegrees = angles.leftElbow,
|
||||
rightElbowAngleDegrees = angles.rightElbow
|
||||
)
|
||||
val isValid = isStartingStanceValid(metrics)
|
||||
consecutiveValidFrames = if (isValid) consecutiveValidFrames + 1 else 0
|
||||
currentPhase = if (consecutiveValidFrames >= requiredConsecutiveFrames) BowlingPhase.STARTING_STANCE else null
|
||||
return Result(currentPhase, metrics)
|
||||
}
|
||||
|
||||
/** @brief Clears all detection state. Call at the start of a new session/attempt. */
|
||||
fun reset() {
|
||||
consecutiveValidFrames = 0
|
||||
currentPhase = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Checks whether this single frame's [Metrics] match the starting stance.
|
||||
*
|
||||
* Every check below requires the angle it needs to actually be
|
||||
* available -- a null reading (landmark not confidently detected) fails
|
||||
* the check rather than being silently skipped, so a frame with too
|
||||
* little of the body visible can't falsely confirm a stance it didn't
|
||||
* actually see.
|
||||
*
|
||||
* @param metrics This frame's raw angle readings.
|
||||
* @return true if torso tilt, both visible knee angles, and both visible elbow angles all fall within range.
|
||||
*/
|
||||
private fun isStartingStanceValid(metrics: Metrics): Boolean {
|
||||
val torsoTilt = metrics.torsoTiltDegrees ?: return false
|
||||
if (torsoTilt !in torsoTiltMinDegrees..torsoTiltMaxDegrees) return false
|
||||
|
||||
val kneeAngles = listOfNotNull(metrics.leftKneeAngleDegrees, metrics.rightKneeAngleDegrees)
|
||||
if (kneeAngles.isEmpty() || kneeAngles.any { it !in kneeAngleMinDegrees..kneeAngleMaxDegrees }) return false
|
||||
|
||||
val elbowAngles = listOfNotNull(metrics.leftElbowAngleDegrees, metrics.rightElbowAngleDegrees)
|
||||
if (elbowAngles.isEmpty() || elbowAngles.any { it !in elbowAngleMinDegrees..elbowAngleMaxDegrees }) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Forward/backward torso lean from vertical, from the
|
||||
* shoulder-midpoint-to-hip-midpoint vector.
|
||||
*
|
||||
* @param landmarks Smoothed landmarks for this frame.
|
||||
* @return The tilt in degrees (always >= 0, direction-agnostic), or
|
||||
* null if neither shoulder or neither hip is reliably detected.
|
||||
*/
|
||||
private fun torsoTiltDegrees(landmarks: Map<Int, SmoothedLandmark>): Float? {
|
||||
val shoulderMid = midpoint(landmarks, PoseLandmark.LEFT_SHOULDER, PoseLandmark.RIGHT_SHOULDER) ?: return null
|
||||
val hipMid = midpoint(landmarks, PoseLandmark.LEFT_HIP, PoseLandmark.RIGHT_HIP) ?: return null
|
||||
val dx = shoulderMid.first - hipMid.first
|
||||
val dy = shoulderMid.second - hipMid.second
|
||||
// atan2 against the vertical axis; abs() folds left/right lean
|
||||
// direction into an unsigned magnitude, same convention as
|
||||
// PoseAngleCalculator.calculateAngle.
|
||||
return Math.toDegrees(atan2(abs(dx.toDouble()), abs(dy.toDouble()))).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Hip-knee-ankle angle for one leg, gated by confidence.
|
||||
* @param landmarks Smoothed landmarks for this frame.
|
||||
* @param hipType Landmark type constant for that leg's hip.
|
||||
* @param kneeType Landmark type constant for that leg's knee.
|
||||
* @param ankleType Landmark type constant for that leg's ankle.
|
||||
* @return The angle in degrees, or null if any of the three landmarks isn't reliably detected.
|
||||
*/
|
||||
private fun kneeAngle(landmarks: Map<Int, SmoothedLandmark>, hipType: Int, kneeType: Int, ankleType: Int): Float? {
|
||||
val hip = reliable(landmarks, hipType) ?: return null
|
||||
val knee = reliable(landmarks, kneeType) ?: return null
|
||||
val ankle = reliable(landmarks, ankleType) ?: return null
|
||||
return PoseAngleCalculator.calculateAngle(hip, knee, ankle)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Midpoint of two landmarks, gated by confidence, falling back to
|
||||
* whichever single one is reliable if only one is.
|
||||
* @param landmarks Smoothed landmarks for this frame.
|
||||
* @param firstType Landmark type constant for the first side.
|
||||
* @param secondType Landmark type constant for the second side.
|
||||
* @return The midpoint as (x, y), or null if neither landmark is reliable.
|
||||
*/
|
||||
private fun midpoint(landmarks: Map<Int, SmoothedLandmark>, firstType: Int, secondType: Int): Pair<Float, Float>? {
|
||||
val first = reliable(landmarks, firstType)
|
||||
val second = reliable(landmarks, secondType)
|
||||
return when {
|
||||
first != null && second != null -> (first.x + second.x) / 2f to (first.y + second.y) / 2f
|
||||
first != null -> first.x to first.y
|
||||
second != null -> second.x to second.y
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Looks up one landmark, gated by [PoseSkeletonRenderer.MIN_LIKELIHOOD].
|
||||
* @param landmarks Smoothed landmarks for this frame.
|
||||
* @param type Landmark type constant to look up.
|
||||
* @return The landmark, or null if missing or below the confidence bar.
|
||||
*/
|
||||
private fun reliable(landmarks: Map<Int, SmoothedLandmark>, type: Int): SmoothedLandmark? {
|
||||
val landmark = landmarks[type] ?: return null
|
||||
return landmark.takeIf { it.inFrameLikelihood >= PoseSkeletonRenderer.MIN_LIKELIHOOD }
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,48 @@
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Delivery-phase feedback (e.g. "Starting stance"), separate from the
|
||||
step counter above - see CameraViewModel#posePhase. Shown whenever
|
||||
pose detection is on, live preview or recording, unlike the step
|
||||
counter which is recording-only. -->
|
||||
<TextView
|
||||
android:id="@+id/text_pose_feedback"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/overlay_scrim"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
tools:text="Get into starting stance"
|
||||
app:layout_constraintTop_toBottomOf="@id/btn_back"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<!-- Raw torso/knee/elbow angle readout backing text_pose_feedback above
|
||||
- see CameraViewModel#poseMetrics. Stacked directly below it. -->
|
||||
<TextView
|
||||
android:id="@+id/text_pose_metrics"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/overlay_scrim"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
tools:text="Torso 12° · Knee L168° R170° · Elbow L85° R90°"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_pose_feedback"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<!--
|
||||
Mirrored onto the start edge at the same vertical center as btn_record
|
||||
on the end edge. Live pose overlay + baked-in-recording toggle; only
|
||||
|
||||
@@ -85,6 +85,48 @@
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Delivery-phase feedback (e.g. "Starting stance"), separate from the
|
||||
step counter above - see CameraViewModel#posePhase. Shown whenever
|
||||
pose detection is on, live preview or recording, unlike the step
|
||||
counter which is recording-only. -->
|
||||
<TextView
|
||||
android:id="@+id/text_pose_feedback"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/overlay_scrim"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
tools:text="Get into starting stance"
|
||||
app:layout_constraintTop_toBottomOf="@id/btn_back"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<!-- Raw torso/knee/elbow angle readout backing text_pose_feedback above
|
||||
- see CameraViewModel#poseMetrics. Stacked directly below it. -->
|
||||
<TextView
|
||||
android:id="@+id/text_pose_metrics"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/overlay_scrim"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
tools:text="Torso 12° · Knee L168° R170° · Elbow L85° R90°"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_pose_feedback"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<!-- Live pose overlay + baked-in-recording toggle. Only togglable while
|
||||
not recording (see BowlingCameraActivity#renderRecordingState) since
|
||||
the recording pipeline picks its pose mode once at start. -->
|
||||
|
||||
@@ -13,4 +13,6 @@
|
||||
<color name="skeleton_joint">#FF00E5FF</color>
|
||||
<color name="skeleton_bone">#FF76FF03</color>
|
||||
<color name="overlay_scrim">#99000000</color>
|
||||
<color name="pose_feedback_ready">#CC00C853</color> //green
|
||||
<color name="pose_feedback_waiting">#CCFFA000</color> //orange
|
||||
</resources>
|
||||
@@ -18,4 +18,7 @@
|
||||
<string name="error_camera_unavailable">Camera unavailable: %1$s</string>
|
||||
<string name="error_recording_failed">Recording failed: %1$s</string>
|
||||
<string name="error_pose_detector">Pose detector error: %1$s</string>
|
||||
<string name="pose_phase_starting_stance">✓ Starting pose</string>
|
||||
<string name="pose_phase_waiting">Get into starting 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>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user