Step counter test1
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* @file BowlingCameraActivity.kt
|
||||
* @brief Camera screen Activity: owns the ViewModel, wires user actions to the camera controller, and reflects state onto views.
|
||||
*/
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -14,15 +19,17 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.example.jnicpp.R
|
||||
import com.example.jnicpp.databinding.ActivityBowlingCameraBinding
|
||||
import com.google.mlkit.vision.pose.PoseLandmark
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Screen that records video and runs real-time pose detection on the same
|
||||
* camera stream. This is a standalone entry point for now (see the plan
|
||||
* this was built from) -- it isn't wired into a menu/game-state system yet.
|
||||
* @brief Screen that records video and runs real-time pose detection on the same camera stream.
|
||||
*
|
||||
* This is a standalone entry point for now (see the plan this was built
|
||||
* from) -- it isn't wired into a menu/game-state system yet.
|
||||
*
|
||||
* This class intentionally does *not* contain any CameraX/ML Kit binding
|
||||
* logic itself -- that lives in [CameraXController] (use-case binding) and
|
||||
@@ -33,6 +40,10 @@ import java.util.concurrent.Executors
|
||||
*/
|
||||
class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BowlingCameraActivity"
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityBowlingCameraBinding
|
||||
private val viewModel: CameraViewModel by viewModels()
|
||||
|
||||
@@ -40,6 +51,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
private lateinit var cameraXController: CameraXController
|
||||
private var lensFacing = CameraSelector.LENS_FACING_BACK
|
||||
|
||||
// Throttled diagnostic for step-count troubleshooting: confirms whether
|
||||
// ankles are actually clearing PoseSkeletonRenderer.MIN_LIKELIHOOD, since
|
||||
// LiveStepDetector silently sees nothing for a foot until they do.
|
||||
private var lastLandmarkLogMs = 0L
|
||||
|
||||
// Richer, un-throttled per-frame trace of the same troubleshooting data,
|
||||
// written to a file instead of Logcat -- see DebugSessionLogger's class
|
||||
// doc. Open only while a recording is in progress.
|
||||
private lateinit var debugSessionLogger: DebugSessionLogger
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ ->
|
||||
val granted = CameraPermissions.allGranted(this)
|
||||
@@ -52,6 +73,12 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Standard Activity entry point: inflates the layout, wires up
|
||||
* click listeners and ViewModel observers, and starts the
|
||||
* camera if permissions already allow it.
|
||||
* @param savedInstanceState Previous saved state, if this is a re-creation; unused.
|
||||
*/
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityBowlingCameraBinding.inflate(layoutInflater)
|
||||
@@ -59,6 +86,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
|
||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
cameraXController = CameraXController(applicationContext, cameraExecutor)
|
||||
debugSessionLogger = DebugSessionLogger(applicationContext)
|
||||
|
||||
binding.btnGrantPermissions.setOnClickListener {
|
||||
permissionLauncher.launch(CameraPermissions.REQUIRED)
|
||||
@@ -80,6 +108,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Binds the CameraX use cases to this Activity's lifecycle using the current [lensFacing]. */
|
||||
private fun startCamera() {
|
||||
cameraXController.bindToLifecycle(
|
||||
lifecycleOwner = this,
|
||||
@@ -89,6 +118,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
)
|
||||
}
|
||||
|
||||
/** @brief Handles the record button: starts or stops recording depending on current state. */
|
||||
private fun onRecordClicked() {
|
||||
if (cameraXController.isRecording) {
|
||||
cameraXController.stopRecording()
|
||||
@@ -102,6 +132,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Handles the switch-camera button: toggles [lensFacing] and rebinds, unless a recording is in progress. */
|
||||
private fun onSwitchCameraClicked() {
|
||||
if (cameraXController.isRecording) {
|
||||
Toast.makeText(this, R.string.switch_camera_while_recording, Toast.LENGTH_SHORT).show()
|
||||
@@ -120,6 +151,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Collects every [CameraViewModel] StateFlow/SharedFlow and reflects each onto the views. */
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
@@ -144,18 +176,33 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
Toast.makeText(this@BowlingCameraActivity, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.stepEvents.collect { events ->
|
||||
binding.textStepCount.text = getString(R.string.step_count_format, events.size)
|
||||
if (events.isNotEmpty()) {
|
||||
Log.d(TAG, "Step ${events.size}: ${events.last()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reflects [state] onto the record button, pose toggle, recording
|
||||
* indicator/timer, and screen-orientation lock.
|
||||
*
|
||||
* The screen now rotates freely (see res/layout-land/), which recreates
|
||||
* this Activity on rotation - that would orphan an in-progress
|
||||
* recording, since the [CameraXController] instance holding the active
|
||||
* recording gets torn down with it. Locking to whichever orientation
|
||||
* we're already in while Starting/Recording (and releasing the lock
|
||||
* once Idle again) keeps recordings from being interrupted by a
|
||||
* rotation mid-shot.
|
||||
*
|
||||
* @param state The recording state to render.
|
||||
*/
|
||||
private fun renderRecordingState(state: CameraViewModel.RecordingState) {
|
||||
// The screen now rotates freely (see res/layout-land/), which
|
||||
// recreates this Activity on rotation - that would orphan an
|
||||
// in-progress recording, since the CameraXController instance
|
||||
// holding the active Recording gets torn down with it. Locking to
|
||||
// whichever orientation we're already in while Starting/Recording
|
||||
// (and releasing the lock once Idle again) keeps recordings from
|
||||
// being interrupted by a rotation mid-shot.
|
||||
requestedOrientation = if (state is CameraViewModel.RecordingState.Idle) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
} else {
|
||||
@@ -188,10 +235,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Hides the permission-rationale screen, revealing the camera UI underneath. */
|
||||
private fun showCameraUi() {
|
||||
binding.layoutPermissionRationale.visibility = View.GONE
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Shows the permission-rationale screen with either the initial
|
||||
* ask or the post-denial message.
|
||||
* @param showAsDenied true to show the "permission denied" message, false for the initial rationale.
|
||||
*/
|
||||
private fun showPermissionRationale(showAsDenied: Boolean) {
|
||||
binding.layoutPermissionRationale.visibility = View.VISIBLE
|
||||
binding.textPermissionMessage.setText(
|
||||
@@ -201,44 +254,97 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
|
||||
// ----- CameraXController.Callback -----
|
||||
|
||||
/** @brief Hides the permission-rationale screen once the camera is bound and ready. */
|
||||
override fun onCameraReady() {
|
||||
showCameraUi()
|
||||
}
|
||||
|
||||
/** @brief Surfaces a camera-binding failure to the ViewModel. @param message Human-readable failure reason. */
|
||||
override fun onCameraError(message: String) {
|
||||
viewModel.postError(getString(R.string.error_camera_unavailable, message))
|
||||
}
|
||||
|
||||
/** @brief Forwards the "recording actually started" event to the ViewModel and opens the debug trace file. */
|
||||
override fun onRecordingStarted() {
|
||||
viewModel.onRecordingStarted()
|
||||
debugSessionLogger.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Forwards the "recording finished" event to the ViewModel,
|
||||
* closes the debug trace file, and shows the saved video's name.
|
||||
* @param outputUri Location of the saved video.
|
||||
*/
|
||||
override fun onRecordingFinalized(outputUri: Uri) {
|
||||
viewModel.onRecordingStopped()
|
||||
Toast.makeText(this, outputUri.lastPathSegment ?: outputUri.toString(), Toast.LENGTH_LONG).show()
|
||||
debugSessionLogger.stop()
|
||||
Toast.makeText(
|
||||
this,
|
||||
"${outputUri.lastPathSegment ?: outputUri.toString()} (debug trace saved to Downloads/bowling)",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
/** @brief Surfaces a recording failure to the ViewModel and closes the debug trace file. @param message Human-readable failure reason. */
|
||||
override fun onRecordingError(message: String) {
|
||||
viewModel.postError(getString(R.string.error_recording_failed, message))
|
||||
debugSessionLogger.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Surfaces a pose-detector failure without disrupting an in-progress recording.
|
||||
*
|
||||
* Detector hiccups on a single frame shouldn't interrupt an
|
||||
* in-progress recording -- just surface it, don't reset state.
|
||||
*
|
||||
* @param message Human-readable failure reason.
|
||||
*/
|
||||
override fun onPoseDetectorError(message: String) {
|
||||
// Detector hiccups on a single frame shouldn't interrupt an
|
||||
// in-progress recording -- just surface it, don't reset state.
|
||||
Toast.makeText(this, getString(R.string.error_pose_detector, message), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Receives each analyzed frame's pose result: updates the live
|
||||
* overlay, forwards it to the ViewModel for buffering/step
|
||||
* detection, and periodically logs landmark confidence for
|
||||
* troubleshooting.
|
||||
*
|
||||
* Safe to touch the view directly here: ML Kit's Task listeners (see
|
||||
* [PoseAnalyzer]) deliver on the main thread by default even though
|
||||
* inference itself runs on the background camera executor.
|
||||
*
|
||||
* @param result The current frame's landmarks, angles, and image metadata.
|
||||
*/
|
||||
override fun onPoseResult(result: PoseAnalyzer.PoseFrameResult) {
|
||||
// Safe to touch the view directly here: ML Kit's Task listeners
|
||||
// (see PoseAnalyzer) deliver on the main thread by default even
|
||||
// though inference itself runs on the background camera executor.
|
||||
binding.poseOverlay.update(result)
|
||||
viewModel.onPoseAnglesUpdated(result.angles)
|
||||
viewModel.onPoseFrameUpdated(result.landmarks, result.angles)
|
||||
|
||||
val frameTimestampMs = System.currentTimeMillis()
|
||||
debugSessionLogger.log(result.landmarks, frameTimestampMs, viewModel.stepEvents.value.size)
|
||||
|
||||
if (frameTimestampMs - lastLandmarkLogMs >= 1000) {
|
||||
lastLandmarkLogMs = frameTimestampMs
|
||||
val leftAnkle = result.landmarks[PoseLandmark.LEFT_ANKLE]
|
||||
val rightAnkle = result.landmarks[PoseLandmark.RIGHT_ANKLE]
|
||||
val leftHip = result.landmarks[PoseLandmark.LEFT_HIP]
|
||||
val rightHip = result.landmarks[PoseLandmark.RIGHT_HIP]
|
||||
val leftShoulder = result.landmarks[PoseLandmark.LEFT_SHOULDER]
|
||||
val rightShoulder = result.landmarks[PoseLandmark.RIGHT_SHOULDER]
|
||||
Log.d(
|
||||
TAG,
|
||||
"Likelihood (need >= ${PoseSkeletonRenderer.MIN_LIKELIHOOD}) -- " +
|
||||
"ankle L=${leftAnkle?.inFrameLikelihood} R=${rightAnkle?.inFrameLikelihood}, " +
|
||||
"hip L=${leftHip?.inFrameLikelihood} R=${rightHip?.inFrameLikelihood}, " +
|
||||
"shoulder L=${leftShoulder?.inFrameLikelihood} R=${rightShoulder?.inFrameLikelihood}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Releases the camera controller, shuts down the analysis executor, and closes any open debug trace file. */
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
cameraXController.release()
|
||||
cameraExecutor.shutdown()
|
||||
debugSessionLogger.stop()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user