Cleared warnings

This commit is contained in:
Gabriel Low
2026-09-09 22:48:06 +08:00
parent 981071ef74
commit 7cf217488a
22 changed files with 126 additions and 140 deletions
@@ -38,7 +38,7 @@ class VideoStepReplayTest {
val detector = PoseDetection.getClient(
AccuratePoseDetectorOptions.Builder()
.setDetectorMode(AccuratePoseDetectorOptions.STREAM_MODE)
.build()
.build(),
)
val landmarkSmoother = PoseLandmarkSmoother()
val ankleHipSmoother = AnkleHipMovingAverageFilter()
@@ -26,13 +26,13 @@ import com.google.mlkit.vision.pose.PoseLandmark
* @param windowSize Number of most-recent samples averaged per landmark.
*/
class AnkleHipMovingAverageFilter(
private val windowSize: Int = 5
private val windowSize: Int = 5,
) {
private val trackedTypes = setOf(
PoseLandmark.LEFT_ANKLE,
PoseLandmark.RIGHT_ANKLE,
PoseLandmark.LEFT_HIP,
PoseLandmark.RIGHT_HIP
PoseLandmark.RIGHT_HIP,
)
private val windows = mutableMapOf<Int, ArrayDeque<SmoothedLandmark>>()
@@ -106,11 +106,10 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
cameraXController = CameraXController(applicationContext, cameraExecutor)
debugSessionLogger = DebugSessionLogger(applicationContext)
stepCounterUi = StepCounterUiController(
context = this,
cardStepCounter = binding.cardStepCounter,
textStepCountBig = binding.textStepCountBig
textStepCountBig = binding.textStepCountBig,
)
feedbackUI = FeedbackUI(this, binding.root)
feedbackUI = FeedbackUI(binding.root)
binding.poseOverlay.attachFeedback(feedbackUI)
binding.btnGrantPermissions.setOnClickListener {
@@ -145,7 +144,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
previewView = binding.cameraPreview,
callback = this,
lensFacing = lensFacing,
feedbackUi = feedbackUI
feedbackUi = feedbackUI,
)
}
@@ -248,16 +247,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
binding.textFinalPosition.setTextColor(
ContextCompat.getColor(
this@BowlingCameraActivity,
if (reachedFinal) R.color.final_position_highlight else R.color.white
)
if (reachedFinal) R.color.final_position_highlight else R.color.white,
),
)
}
}
}
launch {
viewModel.poseStageFeedback.collect { feedback ->
binding.textPoseStageFeedback?.text = feedback
binding.textPoseStageFeedback?.visibility = if (feedback != null) View.VISIBLE else View.GONE
binding.textPoseStageFeedback.text = feedback
binding.textPoseStageFeedback.visibility = if (feedback != null) View.VISIBLE else View.GONE
}
}
// Deliberately its own collector, independent of stepEvents
@@ -305,7 +304,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
when (state) {
is CameraViewModel.RecordingState.Idle -> {
binding.layoutRecordingIndicator.visibility = View.GONE
stepCounterUi.setVisible(false)
stepCounterUi.setVisible(visible = false)
binding.btnRecord.isEnabled = true
binding.btnRecord.setText(R.string.record)
// Pose mode can only be changed between recordings, not
@@ -329,7 +328,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
binding.btnRecord.setText(R.string.stop_recording)
binding.switchPose.isEnabled = false
binding.layoutRecordingIndicator.visibility = View.VISIBLE
stepCounterUi.setVisible(true)
stepCounterUi.setVisible(visible = true)
binding.btnEditor.visibility = View.GONE
val minutes = state.elapsedSeconds / 60
val seconds = state.elapsedSeconds % 60
@@ -355,28 +354,28 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
*/
private fun renderPosePhase(poseEnabled: Boolean, phase: BowlingPhase?) {
if (!poseEnabled) {
binding.textPoseFeedback?.visibility = View.GONE
binding.textPoseFeedback.visibility = View.GONE
return
}
binding.textPoseFeedback?.visibility = View.VISIBLE
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.
when (phase) {
BowlingPhase.STARTING_STANCE -> {
binding.textPoseFeedback?.text = getString(R.string.pose_phase_starting_stance)
binding.textPoseFeedback?.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_ready))
binding.textPoseFeedback.text = getString(R.string.pose_phase_starting_stance)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_ready))
}
BowlingPhase.APPROACH -> {
binding.textPoseFeedback?.text = getString(R.string.pose_phase_approach)
binding.textPoseFeedback?.setBackgroundColor(ContextCompat.getColor(this, R.color.Approach_ready))
binding.textPoseFeedback.text = getString(R.string.pose_phase_approach)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Approach_ready))
}
BowlingPhase.PUSHAWAY -> {
binding.textPoseFeedback?.text = getString(R.string.pose_phase_pushaway)
binding.textPoseFeedback?.setBackgroundColor(ContextCompat.getColor(this, R.color.Pushaway_ready))
binding.textPoseFeedback.text = getString(R.string.pose_phase_pushaway)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Pushaway_ready))
}
else -> {
binding.textPoseFeedback?.text = getString(R.string.pose_phase_waiting)
binding.textPoseFeedback?.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_waiting))
binding.textPoseFeedback.text = getString(R.string.pose_phase_waiting)
binding.textPoseFeedback.setBackgroundColor(ContextCompat.getColor(this, R.color.Starting_stance_waiting))
}
}
}
@@ -397,7 +396,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
angleText(metrics.leftKneeAngleDegrees),
angleText(metrics.rightKneeAngleDegrees),
angleText(metrics.leftElbowAngleDegrees),
angleText(metrics.rightElbowAngleDegrees)
angleText(metrics.rightElbowAngleDegrees),
)
}
@@ -422,7 +421,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
private fun showPermissionRationale(showAsDenied: Boolean) {
binding.layoutPermissionRationale.visibility = View.VISIBLE
binding.textPermissionMessage.setText(
if (showAsDenied) R.string.permission_denied_message else R.string.permission_rationale_message
if (showAsDenied) R.string.permission_denied_message else R.string.permission_rationale_message,
)
}
@@ -455,7 +454,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
Toast.makeText(
this,
"${outputUri.lastPathSegment ?: outputUri.toString()} (debug trace saved to Downloads/bowling)",
Toast.LENGTH_LONG
Toast.LENGTH_LONG,
).show()
}
@@ -497,10 +496,10 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
debugSessionLogger.log(
result.landmarks,
frameTimestampMs,
viewModel.stepEvents.value.size
viewModel.stepEvents.value.size,
)
if (frameTimestampMs - lastLandmarkLogMs >= 1000) {
if ((frameTimestampMs - lastLandmarkLogMs) >= 1000) {
lastLandmarkLogMs = frameTimestampMs
val leftAnkle = result.landmarks[PoseLandmark.LEFT_ANKLE]
val rightAnkle = result.landmarks[PoseLandmark.RIGHT_ANKLE]
@@ -513,7 +512,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
"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}"
"shoulder L=${leftShoulder?.inFrameLikelihood} R=${rightShoulder?.inFrameLikelihood}",
)
}
}
@@ -53,6 +53,7 @@ object CameraPermissions {
* @param context Context used to query permission state.
* @return The subset of [REQUIRED] that is not yet granted; empty if all are granted.
*/
@Suppress("unused")
fun missing(context: Context): List<String> =
REQUIRED.filter { permission ->
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
/**
* @brief Holds camera/recording UI state so it survives configuration
@@ -51,7 +52,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
// recordingState is Idle -- the UI disables the toggle otherwise (see
// BowlingCameraActivity#renderRecordingState) since the recording
// pipeline picks its pose mode once at start.
private val _poseEnabled = MutableStateFlow(false)
private val _poseEnabled = MutableStateFlow(value = false)
/** @brief Whether pose detection/overlay is currently enabled. */
val poseEnabled: StateFlow<Boolean> = _poseEnabled.asStateFlow()
@@ -62,6 +63,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
// confidence bar -- see PoseAngleCalculator).
private val _poseAngles = MutableStateFlow<PoseAngles?>(null)
/** @brief Joint angles computed for the most recent analyzed frame, or null if none available. */
@Suppress("unused")
val poseAngles: StateFlow<PoseAngles?> = _poseAngles.asStateFlow()
// Pose-frame buffering and live step counting for the current/most
@@ -97,8 +99,9 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
/** @brief Live form feedback for the current step, or null if there's nothing to say yet. */
val poseStageFeedback: StateFlow<String?> = _poseStageFeedback.asStateFlow()
private val _permissionsGranted = MutableStateFlow(false)
private val _permissionsGranted = MutableStateFlow(value = false)
/** @brief Whether all required camera/microphone/storage permissions are currently granted. */
@Suppress("unused")
val permissionsGranted: StateFlow<Boolean> = _permissionsGranted.asStateFlow()
// One-shot user-facing error messages (camera unavailable, detector
@@ -161,11 +164,11 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
landmarks = landmarks,
angles = angles,
timestampMs = System.currentTimeMillis(),
isStartingPosition = isStartingStance
isStartingPosition = isStartingStance,
)
_poseStageFeedback.value = PoseStageAdvisor.feedback(
stepNumber = stepEvents.value.size.takeIf { it > 0 },
angles = angles
angles = angles,
)
}
}
@@ -196,7 +199,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
var seconds = 0L
while (isActive) {
_recordingState.value = RecordingState.Recording(seconds)
delay(1000)
delay(1.seconds)
seconds++
}
}
@@ -235,7 +238,6 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
/** @brief Cancels the elapsed-time timer when this ViewModel is destroyed. */
override fun onCleared() {
super.onCleared()
timerJob?.cancel()
}
}
@@ -40,6 +40,7 @@ import java.text.SimpleDateFormat
import java.util.Locale
import com.google.mlkit.vision.pose.PoseLandmark // for testing
import java.util.concurrent.Executor
/**
* @brief Owns all CameraX use-case binding and recording control.
@@ -55,7 +56,7 @@ import com.google.mlkit.vision.pose.PoseLandmark // for testing
*/
class CameraXController(
private val appContext: Context,
private val cameraExecutor: java.util.concurrent.Executor
private val cameraExecutor: Executor,
) {
/** @brief Callbacks through which [CameraXController] reports camera, recording, and pose-detection events. */
@@ -145,15 +146,16 @@ class CameraXController(
previewView: PreviewView,
callback: Callback,
lensFacing: Int = CameraSelector.LENS_FACING_BACK,
feedbackUi: FeedbackUI
feedbackUi: FeedbackUI,
) {
this.callback = callback
this.currentLensFacing = lensFacing
this.feedbackUI = feedbackUi
val providerFuture = ProcessCameraProvider.getInstance(appContext)
providerFuture.addListener({
try {
providerFuture.addListener(
{
try {
val provider = providerFuture.get()
cameraProvider = provider
@@ -186,7 +188,7 @@ class CameraXController(
callback.onPoseResult(result)
}
},
onError = { e -> callback.onPoseDetectorError(e.message ?: "Pose detector error") }
onError = { e -> callback.onPoseDetectorError(e.message ?: "Pose detector error") },
)
poseAnalyzer = analyzer
@@ -250,7 +252,7 @@ class CameraXController(
// result yet), this leaves the canvas fully transparent, so the
// recorded frame passes through untouched.
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
if (poseDetectionEnabled && poseFrame != null) {
if ((poseDetectionEnabled && poseFrame != null)) {
val frameSize = frame.size
// Mirrors the same raw-buffer-dimensions-plus-rotation-degrees
// convention CameraX uses for ImageAnalysis/ImageProxy (see
@@ -284,8 +286,8 @@ class CameraXController(
} else {
emptyMap()
}
feedbackUI?.drawCircles(canvas, singleLandmark, transform, true)
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step", true)
feedbackUI?.drawCircles(canvas, singleLandmark, transform, forRecord = true)
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step", forRecord = true)
}
true
}
@@ -16,6 +16,7 @@ import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.sqrt
/**
* @brief Writes one line per analyzed frame -- landmark confidence,
@@ -64,7 +65,7 @@ class DebugSessionLogger(private val appContext: Context) {
dir.mkdirs()
FileOutputStream(File(dir, fileName))
}
} catch (e: Exception) {
} catch (_: Exception) {
null
}
writer = outputStream?.let { BufferedWriter(OutputStreamWriter(it)) }
@@ -72,7 +73,7 @@ class DebugSessionLogger(private val appContext: Context) {
writer?.let {
it.write(
"timestampMs dtMs ankleL(y,lik) ankleR(y,lik) hipL(y,lik) hipR(y,lik) shoulderL(y,lik) shoulderR(y,lik) " +
"wristL(y,lik) wristR(y,lik) torsoScalePx stepCount"
"wristL(y,lik) wristR(y,lik) torsoScalePx stepCount",
)
it.newLine()
it.flush()
@@ -108,7 +109,7 @@ class DebugSessionLogger(private val appContext: Context) {
"${format(shoulderL)} ${format(shoulderR)} " +
"${format(wristL)} ${format(wristR)} " +
"${torsoScale?.let { "%.1f".format(it) } ?: "-"} " +
"$stepCount"
stepCount.toString()
try {
out.write(line)
@@ -117,7 +118,7 @@ class DebugSessionLogger(private val appContext: Context) {
// stopped mid-recording the file should still have everything
// logged up to that point rather than losing a buffered tail.
out.flush()
} catch (e: Exception) {
} catch (_: Exception) {
// A failed debug write shouldn't disrupt the actual recording.
}
}
@@ -126,7 +127,7 @@ class DebugSessionLogger(private val appContext: Context) {
fun stop() {
try {
writer?.close()
} catch (e: Exception) {
} catch (_: Exception) {
// Nothing useful to do about a failed close on a debug file.
}
writer = null
@@ -140,12 +141,12 @@ class DebugSessionLogger(private val appContext: Context) {
shoulderL: SmoothedLandmark?,
shoulderR: SmoothedLandmark?,
hipL: SmoothedLandmark?,
hipR: SmoothedLandmark?
hipR: SmoothedLandmark?,
): Float? {
val shoulder = shoulderL ?: shoulderR ?: return null
val hip = hipL ?: hipR ?: return null
val dx = shoulder.x - hip.x
val dy = shoulder.y - hip.y
return kotlin.math.sqrt(dx * dx + dy * dy).takeIf { it > 0f }
return sqrt((dx * dx + dy * dy)).takeIf { it > 0f }
}
}
@@ -22,7 +22,7 @@ import android.content.Context
data class DetectorSettings(
val minSpacingMs: Long,
val minProminenceRatio: Float,
val maxFrameJumpRatio: Float
val maxFrameJumpRatio: Float,
) {
companion object {
/** @brief Same values as [LiveStepDetector]'s own constructor defaults. */
@@ -1,26 +1,20 @@
package com.example.jnicpp.bowling
import android.text.Layout
import android.text.TextPaint
import android.text.StaticLayout
import android.graphics.Canvas
import android.graphics.RectF
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.content.Context
import android.graphics.Paint
import android.view.View
import android.widget.TextView
import com.example.jnicpp.R
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.view.View
import androidx.core.graphics.withTranslation
import kotlin.math.min
import androidx.constraintlayout.widget.ConstraintLayout
class FeedbackUI(private val context: Context, private val rootView: View) {
private var landmarks: Map<Int, SmoothedLandmark>? = null
private var CIRCLE_RADIUS = 128f
class FeedbackUI(rootView: View) {
private var circleRadius = 128f
private val uiTextSize = 32f
private val uiStrokeWidth = 12f
private val bannerPaddingY = 24
@@ -94,8 +88,8 @@ class FeedbackUI(private val context: Context, private val rootView: View) {
val textWidth = staticLayout.width.toFloat()
val textHeight = staticLayout.height.toFloat()
val bannerWidth = textWidth + paddingX * 2
val bannerHeight = textHeight + paddingY * 2
val bannerWidth = textWidth + (paddingX * 2)
val bannerHeight = textHeight + (paddingY * 2)
// Center horizontally
val left = (canvas.width - bannerWidth) / 2f
@@ -111,10 +105,9 @@ class FeedbackUI(private val context: Context, private val rootView: View) {
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
// Draw text layout inside background
canvas.save()
canvas.translate(left + paddingX, top + paddingY)
staticLayout.draw(canvas)
canvas.restore()
canvas.withTranslation(left + paddingX, top + paddingY) {
staticLayout.draw(this)
}
}
/**
@@ -130,7 +123,6 @@ class FeedbackUI(private val context: Context, private val rootView: View) {
* @param forRecord If true, scales circle radius and stroke width for
* recording output.
*/
// --- Shape overlay (circle) ---
fun drawCircles(canvas: Canvas, landmarks: Map<Int, SmoothedLandmark>, transform: Matrix, forRecord: Boolean = false) {
for (landmark in landmarks.values) {
val point = floatArrayOf(landmark.x, landmark.y)
@@ -151,14 +143,14 @@ class FeedbackUI(private val context: Context, private val rootView: View) {
* @param canvas The canvas to draw the circle on.
* @param x The xcoordinate of the circles center.
* @param y The ycoordinate of the circles center.
* @param radius The circle radius. If set to 0, defaults to [CIRCLE_RADIUS].
* @param radius The circle radius. If set to 0, defaults to [circleRadius].
* @param forRecord If true, applies recording scale factor to radius and
* stroke width; otherwise uses live UI scale.
*/
fun drawCircle(canvas: Canvas, x: Float, y: Float, radius: Float = 0f, forRecord: Boolean = false) {
val scale = if (forRecord) camScale else 1f
var rad = (if (radius == 0f) CIRCLE_RADIUS else radius) * scale
val rad = (if (radius == 0f) circleRadius else radius) * scale
circlePaint.strokeWidth = uiStrokeWidth * scale
glowPaint.strokeWidth = uiStrokeWidth * scale
canvas.drawCircle(x, y, rad, circlePaint)
@@ -189,10 +181,9 @@ class FeedbackUI(private val context: Context, private val rootView: View) {
* @return A float scale factor to apply when drawing to the recording canvas.
*/
private fun computeCamScale(recordCanvas: Canvas): Float {
if (liveUiWidth == 0 || liveUiHeight == 0) return 1f
if ((liveUiWidth == 0 || liveUiHeight == 0)) return 1f
val scaleX = recordCanvas.width.toFloat() / liveUiWidth.toFloat()
val scaleY = recordCanvas.height.toFloat() / liveUiHeight.toFloat()
return min(scaleX, scaleY)
}
}
@@ -40,17 +40,8 @@ import kotlin.math.sqrt
* threshold relative to it self-corrects frame to frame instead of
* drifting.
*
* Also tracks a deliberate "raise a hand and hold it up" reset gesture --
* see [HandRaiseTracker] -- rather than resetting automatically whenever
* the bowler holds still. An earlier automatic version misread a stalled
* camera pipeline as a held stance and wiped out real counts mid-recording
* (see git history), and even once that was fixed, silently resetting
* whenever the bowler happens to pause is surprising -- there's no way to
* tell, watching the screen, whether the count is about to vanish. A
* held gesture is deliberate and has an obvious visual cue (see
* [Result.handRaiseProgress]) to build toward, so one recording can still
* capture several practice approaches back to back, each counting from its
* own first step, without an unannounced reset ever surprising the bowler.
* Also tracks holding the starting position stance (>2 seconds) to reset
* the step counter between practice attempts.
*
* Torso scale needs both a shoulder and a hip landmark to compute, and
* during a fast approach either can drop below the confidence bar on any
@@ -79,7 +70,7 @@ class LiveStepDetector(
private val stillnessWindowMs: Long = 600L,
private val stillnessRatio: Float = 0.05f,
private val startingStanceHoldMs: Long = 2000L,
private val enableStillnessReset: Boolean = true
private val enableStillnessReset: Boolean = true,
) {
private val stillness = StillnessTracker(stillnessWindowMs, stillnessRatio)
private var wasStillLastFrame = true
@@ -95,7 +86,7 @@ class LiveStepDetector(
data class Result(
val stepCount: Int,
val newSteps: List<StepEvent>,
val wasReset: Boolean
val wasReset: Boolean,
)
private val leftFoot = FootPeakTracker(minSpacingMs, minProminenceRatio)
@@ -124,7 +115,8 @@ class LiveStepDetector(
val newSteps = mutableListOf<StepEvent>()
val hipMid = hipMidpoint(frame)
val isStalledFrame = (frame.leftAnkleRaw != null || frame.rightAnkleRaw != null || hipMid != null) &&
val hasLandmarks = (frame.leftAnkleRaw != null || frame.rightAnkleRaw != null || hipMid != null)
val isStalledFrame = hasLandmarks &&
frame.leftAnkleRaw == lastLeftAnkleRaw &&
frame.rightAnkleRaw == lastRightAnkleRaw &&
hipMid == lastHipMid
@@ -187,7 +179,7 @@ class LiveStepDetector(
return Result(
stepCount = stepCount,
newSteps = if (wasReset) emptyList() else newSteps,
wasReset = wasReset
wasReset = wasReset,
)
}
@@ -238,7 +230,7 @@ private enum class TrackingMode { SEEKING_PEAK, SEEKING_VALLEY }
private class FootPeakTracker(
private val minSpacingMs: Long,
private val minProminenceRatio: Float
private val minProminenceRatio: Float,
) {
private var mode = TrackingMode.SEEKING_PEAK
private var extreme: Pair<Long, Float>? = null
@@ -290,7 +282,7 @@ private class FootPeakTracker(
private class StillnessTracker(
private val windowMs: Long,
private val maxDriftRatio: Float
private val maxDriftRatio: Float,
) {
private var windowStartMs: Long? = null
private var startX: Float? = null
@@ -65,13 +65,13 @@ class ParameterEditorActivity : AppCompatActivity() {
val minProminenceRatio = binding.fieldMinProminenceRatio.editText?.text?.toString()?.toFloatOrNull()
val maxFrameJumpRatio = binding.fieldMaxFrameJumpRatio.editText?.text?.toString()?.toFloatOrNull()
if (minSpacingMs == null || minProminenceRatio == null || maxFrameJumpRatio == null) {
if ((minSpacingMs == null || minProminenceRatio == null || maxFrameJumpRatio == null)) {
return null
}
return DetectorSettings(
minSpacingMs = minSpacingMs,
minProminenceRatio = minProminenceRatio,
maxFrameJumpRatio = maxFrameJumpRatio
maxFrameJumpRatio = maxFrameJumpRatio,
)
}
}
@@ -29,7 +29,7 @@ data class PoseAngles(
val leftShoulder: Float? = null,
val rightShoulder: Float? = null,
val leftKnee: Float? = null,
val rightKnee: Float? = null
val rightKnee: Float? = null,
)
/**
@@ -96,9 +96,9 @@ object PoseAngleCalculator {
val first = landmarks[firstType] ?: return null
val mid = landmarks[midType] ?: return null
val last = landmarks[lastType] ?: return null
if (first.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD ||
if ((first.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD ||
mid.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD ||
last.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD
last.inFrameLikelihood < PoseSkeletonRenderer.MIN_LIKELIHOOD)
) {
return null
}
@@ -72,7 +72,7 @@ data class PoseFrame(
val rightElbow: LandmarkPoint? = null,
val leftWrist: LandmarkPoint? = null,
val rightWrist: LandmarkPoint? = null,
val angles: PoseAngles = PoseAngles(null, null, null, null)
val angles: PoseAngles = PoseAngles()
)
/**
@@ -41,7 +41,7 @@ data class SmoothedLandmark(val x: Float, val y: Float, val inFrameLikelihood: F
* while still keeping up with a fast bowling arm swing.
*/
class PoseLandmarkSmoother(
private val smoothingFactor: Float = 0.4f
private val smoothingFactor: Float = 0.4f,
) {
private val previous = mutableMapOf<Int, SmoothedLandmark>()
@@ -59,10 +59,10 @@ class PoseLandmarkSmoother(
SmoothedLandmark(landmark.position.x, landmark.position.y, landmark.inFrameLikelihood)
} else {
SmoothedLandmark(
x = prev.x + smoothingFactor * (landmark.position.x - prev.x),
y = prev.y + smoothingFactor * (landmark.position.y - prev.y),
x = prev.x + (smoothingFactor * (landmark.position.x - prev.x)),
y = prev.y + (smoothingFactor * (landmark.position.y - prev.y)),
inFrameLikelihood = prev.inFrameLikelihood +
smoothingFactor * (landmark.inFrameLikelihood - prev.inFrameLikelihood)
(smoothingFactor * (landmark.inFrameLikelihood - prev.inFrameLikelihood)),
)
}
previous[landmark.landmarkType] = next
@@ -28,7 +28,7 @@ import com.google.mlkit.vision.pose.PoseLandmark // for testing
*/
class PoseOverlayView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
attrs: AttributeSet? = null,
) : View(context, attrs) {
private val jointPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@@ -113,7 +113,7 @@ class PoseOverlayView @JvmOverloads constructor(
targetHeight = height,
// Front camera preview is mirrored; flip the x axis about the
// view's center so the overlay matches what's on screen.
mirror = isFrontCamera
mirror = isFrontCamera,
)
}
@@ -20,7 +20,9 @@ enum class BowlingPhase {
STARTING_STANCE,
APPROACH,
PUSHAWAY,
@Suppress("unused")
SLIDE_RELEASE,
@Suppress("unused")
FOLLOW_THROUGH
}
@@ -84,7 +86,7 @@ class PosePhaseDetector(
private val elbowAngleMinDegrees: Float = 70f,
private val elbowAngleMaxDegrees: Float = 125f,
private val requiredConsecutiveFrames: Int = 8,
private val requiredInvalidFramesToExit: Int = 5
private val requiredInvalidFramesToExit: Int = 5,
) {
// Shared parameters for all phases (consecutive frames, etc) could be
// split out, but for now they're reused from the constructor.
@@ -116,7 +118,7 @@ class PosePhaseDetector(
val leftKneeAngleDegrees: Float?,
val rightKneeAngleDegrees: Float?,
val leftElbowAngleDegrees: Float?,
val rightElbowAngleDegrees: Float?
val rightElbowAngleDegrees: Float?,
)
/**
@@ -149,7 +151,7 @@ class PosePhaseDetector(
// If the posture matches starting stance, target starting stance even if currently in another phase
val isStartingValid = isStartingStanceValid(metrics)
val targetPhase = if (isStartingValid && currentPhase != BowlingPhase.STARTING_STANCE) {
val targetPhase = if ((isStartingValid && currentPhase != BowlingPhase.STARTING_STANCE)) {
BowlingPhase.STARTING_STANCE
} else {
when (currentPhase) {
@@ -240,9 +242,7 @@ class PosePhaseDetector(
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
return elbowAngles.isNotEmpty() && elbowAngles.all { it in elbowAngleMinDegrees..elbowAngleMaxDegrees }
}
/**
@@ -67,7 +67,7 @@ object PoseSkeletonRenderer {
PoseLandmark.RIGHT_HIP to PoseLandmark.RIGHT_KNEE,
PoseLandmark.RIGHT_KNEE to PoseLandmark.RIGHT_ANKLE,
PoseLandmark.RIGHT_ANKLE to PoseLandmark.RIGHT_HEEL,
PoseLandmark.RIGHT_HEEL to PoseLandmark.RIGHT_FOOT_INDEX
PoseLandmark.RIGHT_HEEL to PoseLandmark.RIGHT_FOOT_INDEX,
)
/**
@@ -100,12 +100,12 @@ object PoseSkeletonRenderer {
sourceRotationDegrees: Int,
targetWidth: Int,
targetHeight: Int,
mirror: Boolean
mirror: Boolean,
): Matrix {
val transform = Matrix()
val imageWidth: Int
val imageHeight: Int
if (sourceRotationDegrees == 90 || sourceRotationDegrees == 270) {
if ((sourceRotationDegrees == 90 || sourceRotationDegrees == 270)) {
imageWidth = sourceHeight
imageHeight = sourceWidth
} else {
@@ -61,7 +61,7 @@ object PoseStageAdvisor {
val frontKnee = smallerOf(angles.leftKnee, angles.rightKnee)
return when {
stepNumber == null || stepNumber <= 1 -> "Starting position - stay relaxed"
(stepNumber == null || stepNumber <= 1) -> "Starting position - stay relaxed"
stepNumber == 2 -> swingShoulder?.let {
if (it <= PUSH_AWAY_MAX_SHOULDER_DEG) "Good push-away" else "Push the ball out first"
@@ -4,21 +4,18 @@
*/
package com.example.jnicpp.bowling
import android.content.Context
import android.view.View
import android.widget.TextView
/**
* @brief Owns rendering for [BowlingCameraActivity]'s step-counter card.
*
* @param context Used only for string resource lookups.
* @param cardStepCounter The step-counter card container view.
* @param textStepCountBig The large step-count number TextView.
*/
class StepCounterUiController(
private val context: Context,
private val cardStepCounter: View,
private val textStepCountBig: TextView
private val textStepCountBig: TextView,
) {
// Last step count rendered, so pulse() in renderStepCount only plays
// when a new step actually pushed the count up.
@@ -62,14 +62,14 @@ class StepCountingSession {
landmarks: Map<Int, SmoothedLandmark>,
angles: PoseAngles,
timestampMs: Long,
isStartingPosition: Boolean = false
isStartingPosition: Boolean = false,
) {
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
val frame = buildPoseFrame(
timestampMs = timestampMs,
landmarks = landmarks,
smoothedAnkleHip = smoothedAnkleHip,
angles = angles
angles = angles,
)
poseFrameBuffer.add(frame)
@@ -78,7 +78,7 @@ class StepCountingSession {
_stepEvents.value = emptyList()
}
if (result.newSteps.isNotEmpty()) {
_stepEvents.value = _stepEvents.value + result.newSteps
_stepEvents.value += result.newSteps
}
}
@@ -23,7 +23,7 @@ enum class Foot { LEFT, RIGHT }
data class StepEvent(
val timestampMs: Long,
val foot: Foot,
val stepIndex: Int
val stepIndex: Int,
)
/**
@@ -67,7 +67,7 @@ object StepDetector {
fun detect(
frames: List<PoseFrame>,
minSpacingMs: Long = 300L,
minProminenceRatio: Float = 0.12f
minProminenceRatio: Float = 0.12f,
): List<StepEvent> {
val leftSteps = findFootPeaks(
frames.mapNotNull { frame -> frame.leftAnkle?.let { frame.timestampMs to it.y } },
@@ -81,10 +81,12 @@ object StepDetector {
)
return (leftSteps.map { it to Foot.LEFT } + rightSteps.map { it to Foot.RIGHT })
.asSequence()
.sortedBy { (timestampMs, _) -> timestampMs }
.mapIndexed { index, (timestampMs, foot) ->
StepEvent(timestampMs = timestampMs, foot = foot, stepIndex = index + 1)
}
.toList()
}
/**
@@ -110,7 +112,7 @@ object StepDetector {
// Strict local maxima: higher than both immediate neighbors. A
// genuinely flat-topped peak still has passing samples on its
// shoulders, so missing the exact plateau center isn't a concern.
val candidates = (1 until series.size - 1).mapNotNull { i ->
val candidates = (1 until (series.size - 1)).mapNotNull { i ->
val (t, y) = series[i]
if (y > series[i - 1].second && y > series[i + 1].second) Candidate(i, t, y) else null
}
@@ -15,7 +15,7 @@ class LiveStepDetectorTest {
ankleR: Float,
hipX: Float,
hipY: Float,
leftWristY: Float? = null
leftWristY: Float? = null,
) = PoseFrame(
timestampMs = t,
leftAnkle = null,
@@ -99,10 +99,10 @@ class LiveStepDetectorTest {
// Hold in starting position for 2200 ms with slight landmark micro-jitter
while (t <= 2400L) {
val yJitter = 805f + if ((t / 100L) % 2L == 0L) 0.2f else -0.2f
val yJitter = 805f + (if (((t / 100L) % 2L == 0L)) 0.2f else -0.2f)
result = detector.update(
frame(t, ankleL = 1000f, ankleR = 700f, hipX = 405f, hipY = yJitter),
isStartingPosition = true
isStartingPosition = true,
)
if (result.wasReset) sawReset = true
t += 100L
@@ -134,7 +134,7 @@ class LiveStepDetectorTest {
180L to 601f, 210L to 599f, 240L to 600f, 270L to 601f, 300L to 599f, 330L to 600f,
360L to 580f, 390L to 560f, 420L to 540f, 450L to 520f, 480L to 500f
)
var result = LiveStepDetector.Result(0, emptyList(), false)
var result = LiveStepDetector.Result(0, emptyList(), wasReset = false)
for ((t, y) in firstCycle) {
result = detector.update(frame(t, ankleL = y, ankleR = 700f, hipX = 400f, hipY = 800f + t * 0.05f))
}
@@ -156,8 +156,8 @@ class LiveStepDetectorTest {
fun jitterBelowThresholdNeverConfirms() {
val detector = LiveStepDetector()
var result = LiveStepDetector.Result(0, emptyList(), false)
var y = 600f
var result = LiveStepDetector.Result(0, emptyList(), wasReset = false)
var y: Float
var t = 0L
val deltas = floatArrayOf(3f, -5f, 2f, -1f, 6f, -4f, 1f, -2f, 4f, -3f)
for (i in 0 until 60) {
@@ -174,16 +174,15 @@ class LiveStepDetectorTest {
val detector = LiveStepDetector()
val hipY = 455f
var result = LiveStepDetector.Result(0, emptyList(), false)
var t = 0L
result = detector.update(frame(t, ankleL = 400f, ankleR = 700f, hipX = 400f, hipY = hipY))
detector.update(frame(t, ankleL = 400f, ankleR = 700f, hipX = 400f, hipY = hipY))
t += 30L
result = detector.update(frame(t, ankleL = 402f, ankleR = 700f, hipX = 400f, hipY = hipY))
detector.update(frame(t, ankleL = 402f, ankleR = 700f, hipX = 400f, hipY = hipY))
t += 30L
result = detector.update(frame(t, ankleL = 482f, ankleR = 700f, hipX = 400f, hipY = hipY))
detector.update(frame(t, ankleL = 482f, ankleR = 700f, hipX = 400f, hipY = hipY))
t += 30L
result = detector.update(frame(t, ankleL = 403f, ankleR = 700f, hipX = 400f, hipY = hipY))
val result = detector.update(frame(t, ankleL = 403f, ankleR = 700f, hipX = 400f, hipY = hipY))
t += 30L
assertEquals("an implausible single-frame jump should never read as a step", 0, result.stepCount)