Added tuning feature and hand palm action to reset the timer

This commit is contained in:
harine
2026-09-07 19:38:43 +08:00
parent 41b567929a
commit e7cb217de8
18 changed files with 1164 additions and 178 deletions
@@ -4,7 +4,8 @@
*/
package com.example.jnicpp.bowling
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -25,8 +26,12 @@ import kotlinx.coroutines.launch
* [BowlingCameraActivity] and [CameraXController] report events into it,
* and the UI observes it back out. That keeps this class trivially
* unit-testable (no Android camera framework involved).
*
* Extends [AndroidViewModel] rather than a plain ViewModel solely to reach
* an Application [android.content.Context] for [DetectorSettings.load] --
* see [onRecordingStarting].
*/
class CameraViewModel : ViewModel() {
class CameraViewModel(application: Application) : AndroidViewModel(application) {
/** @brief The camera screen's overall recording state. */
sealed interface RecordingState {
@@ -59,38 +64,18 @@ class CameraViewModel : ViewModel() {
/** @brief Joint angles computed for the most recent analyzed frame, or null if none available. */
val poseAngles: StateFlow<PoseAngles?> = _poseAngles.asStateFlow()
// Time-ordered pose samples for the current/most recent recording
// session, one appended per analyzed frame while actually recording
// (see onPoseFrameUpdated) -- a live preview with pose overlay on but
// not recording doesn't fill this. Cleared at the start of each new
// recording (see onRecordingStarting). Exposed as a read-only snapshot;
// StepDetector.detect() consumes it once a recording finishes (see
// onRecordingStopped).
private val poseFrameBuffer = mutableListOf<PoseFrame>()
// Pose-frame buffering and live step counting for the current/most
// recent recording session -- see StepCountingSession's class doc for
// why this lives in its own class rather than inline here. Frames only
// flow into it while actually recording (see onPoseFrameUpdated) -- a
// live preview with pose overlay on but not recording doesn't feed it.
private val stepCountingSession = StepCountingSession()
/** @brief Time-ordered pose samples buffered for the current/most recent recording session. */
val poseFrames: List<PoseFrame> get() = poseFrameBuffer
// Extra SMA smoothing for ankle/hip landmarks specifically, on top of
// PoseLandmarkSmoother's per-frame EMA -- see AnkleHipMovingAverageFilter.
// Reset (not replaced) alongside the buffer so a new session's window
// doesn't lerp in from the previous one's last few frames.
private val ankleHipSmoother = AnkleHipMovingAverageFilter()
// Live, incremental step counting for the current recording -- see
// LiveStepDetector. Resets itself mid-recording when the bowler holds
// a stationary "ready" stance again, so one recording can capture
// several practice approaches back to back.
private val liveStepDetector = LiveStepDetector()
// 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
// onPoseFrameUpdated and LiveStepDetector). The UI reads events.size
// as the "Step N" counter. Stays populated after recording stops so
// the last attempt's count remains visible.
private val _stepEvents = MutableStateFlow<List<StepEvent>>(emptyList())
val poseFrames: List<PoseFrame> get() = stepCountingSession.poseFrames
/** @brief Steps detected so far in the current attempt, since the last reset. */
val stepEvents: StateFlow<List<StepEvent>> = _stepEvents.asStateFlow()
val stepEvents: StateFlow<List<StepEvent>> get() = stepCountingSession.stepEvents
/** @brief Progress toward the hold-to-reset gesture, from 0 to 1. */
val handRaiseProgress: StateFlow<Float> get() = stepCountingSession.handRaiseProgress
private val _permissionsGranted = MutableStateFlow(false)
/** @brief Whether all required camera/microphone/storage permissions are currently granted. */
@@ -136,32 +121,22 @@ class CameraViewModel : ViewModel() {
fun onPoseFrameUpdated(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles) {
_poseAngles.value = angles
if (_recordingState.value is RecordingState.Recording) {
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
val frame = buildPoseFrame(
timestampMs = System.currentTimeMillis(),
landmarks = landmarks,
smoothedAnkleHip = smoothedAnkleHip,
angles = angles
)
poseFrameBuffer.add(frame)
val result = liveStepDetector.update(frame)
if (result.wasReset) {
_stepEvents.value = emptyList()
}
if (result.newSteps.isNotEmpty()) {
_stepEvents.value = _stepEvents.value + result.newSteps
}
stepCountingSession.onFrame(landmarks, angles, System.currentTimeMillis())
}
}
/** @brief Marks a recording as being requested and resets all per-session buffering/detection state. */
/**
* @brief Marks a recording as being requested and resets all
* per-session buffering/detection state.
*
* Reloads [DetectorSettings] fresh here (rather than once at
* construction) so a tuning change made in [ParameterEditorActivity]
* takes effect on the very next recording, without needing to
* restart this screen.
*/
fun onRecordingStarting() {
_recordingState.value = RecordingState.Starting
poseFrameBuffer.clear()
ankleHipSmoother.reset()
liveStepDetector.reset()
_stepEvents.value = emptyList()
stepCountingSession.startNewSession(DetectorSettings.load(getApplication()))
}
/** @brief Marks a recording as actively writing and starts the elapsed-time timer. */