2026-08-14 18:16:55 +08:00
|
|
|
/**
|
|
|
|
|
* @file DebugSessionLogger.kt
|
|
|
|
|
* @brief Per-frame diagnostic trace written to a text file for the duration of a recording.
|
|
|
|
|
*/
|
|
|
|
|
package com.example.jnicpp.bowling
|
|
|
|
|
|
|
|
|
|
import android.content.ContentValues
|
|
|
|
|
import android.content.Context
|
|
|
|
|
import android.os.Build
|
|
|
|
|
import android.os.Environment
|
|
|
|
|
import android.provider.MediaStore
|
|
|
|
|
import com.google.mlkit.vision.pose.PoseLandmark
|
|
|
|
|
import java.io.BufferedWriter
|
|
|
|
|
import java.io.File
|
|
|
|
|
import java.io.FileOutputStream
|
|
|
|
|
import java.io.OutputStreamWriter
|
|
|
|
|
import java.text.SimpleDateFormat
|
|
|
|
|
import java.util.Locale
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Writes one line per analyzed frame -- landmark confidence,
|
|
|
|
|
* position, and derived torso scale -- to a plain-text file in the
|
|
|
|
|
* public Downloads/bowling folder, for the duration of a single
|
|
|
|
|
* recording.
|
|
|
|
|
*
|
|
|
|
|
* Exists purely as a step-counting troubleshooting aid: it's a much richer,
|
|
|
|
|
* un-throttled trace than the 1/sec Logcat line in
|
|
|
|
|
* [BowlingCameraActivity.onPoseResult], and lands somewhere retrievable
|
|
|
|
|
* without needing adb or the Logcat panel -- the file shows up like any
|
|
|
|
|
* other downloaded file, so it can be opened, shared, or copied off the
|
|
|
|
|
* device by whatever means is convenient.
|
|
|
|
|
*
|
|
|
|
|
* One instance is meant to be reused across the Activity's lifetime; call
|
|
|
|
|
* [start] when a recording begins and [stop] when it ends. Calling [log]
|
|
|
|
|
* while not started is a harmless no-op.
|
|
|
|
|
*/
|
|
|
|
|
class DebugSessionLogger(private val appContext: Context) {
|
|
|
|
|
|
|
|
|
|
private var writer: BufferedWriter? = null
|
|
|
|
|
private var lastLoggedMs: Long? = null
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Opens a new timestamped file in Downloads/bowling and writes a header line.
|
|
|
|
|
*
|
|
|
|
|
* Uses `MediaStore.Downloads` on API 29+ (scoped storage, no extra
|
|
|
|
|
* permission needed) and a direct file write into the public Downloads
|
|
|
|
|
* directory below that, mirroring [CameraXController.startRecording]'s
|
|
|
|
|
* own API-level branching for saving the video file.
|
|
|
|
|
*/
|
|
|
|
|
fun start() {
|
|
|
|
|
stop()
|
|
|
|
|
val fileName = "bowling_debug_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(java.util.Date())}.txt"
|
|
|
|
|
val outputStream = try {
|
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
|
|
|
val contentValues = ContentValues().apply {
|
|
|
|
|
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
|
|
|
|
put(MediaStore.Downloads.MIME_TYPE, "text/plain")
|
|
|
|
|
put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/bowling")
|
|
|
|
|
}
|
|
|
|
|
val uri = appContext.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
|
|
|
|
uri?.let { appContext.contentResolver.openOutputStream(it) }
|
|
|
|
|
} else {
|
|
|
|
|
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "bowling")
|
|
|
|
|
dir.mkdirs()
|
|
|
|
|
FileOutputStream(File(dir, fileName))
|
|
|
|
|
}
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
writer = outputStream?.let { BufferedWriter(OutputStreamWriter(it)) }
|
|
|
|
|
lastLoggedMs = null
|
|
|
|
|
writer?.let {
|
2026-09-07 19:38:43 +08:00
|
|
|
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 handRaiseProgress"
|
|
|
|
|
)
|
2026-08-14 18:16:55 +08:00
|
|
|
it.newLine()
|
|
|
|
|
it.flush()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Appends one frame's diagnostic data as a line, if a session is currently open.
|
|
|
|
|
* @param landmarks EMA-smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant.
|
|
|
|
|
* @param timestampMs Wall-clock time this frame was analyzed, in milliseconds.
|
|
|
|
|
* @param stepCount Current cumulative step count at the time of this frame.
|
2026-09-07 19:38:43 +08:00
|
|
|
* @param handRaiseProgress Current hold-to-reset gesture progress (0-1) at the time of this frame.
|
2026-08-14 18:16:55 +08:00
|
|
|
*/
|
2026-09-07 19:38:43 +08:00
|
|
|
fun log(landmarks: Map<Int, SmoothedLandmark>, timestampMs: Long, stepCount: Int, handRaiseProgress: Float) {
|
2026-08-14 18:16:55 +08:00
|
|
|
val out = writer ?: return
|
|
|
|
|
|
|
|
|
|
val ankleL = landmarks[PoseLandmark.LEFT_ANKLE]
|
|
|
|
|
val ankleR = landmarks[PoseLandmark.RIGHT_ANKLE]
|
|
|
|
|
val hipL = landmarks[PoseLandmark.LEFT_HIP]
|
|
|
|
|
val hipR = landmarks[PoseLandmark.RIGHT_HIP]
|
|
|
|
|
val shoulderL = landmarks[PoseLandmark.LEFT_SHOULDER]
|
|
|
|
|
val shoulderR = landmarks[PoseLandmark.RIGHT_SHOULDER]
|
2026-09-07 19:38:43 +08:00
|
|
|
val wristL = landmarks[PoseLandmark.LEFT_WRIST]
|
|
|
|
|
val wristR = landmarks[PoseLandmark.RIGHT_WRIST]
|
2026-08-14 18:16:55 +08:00
|
|
|
|
|
|
|
|
val torsoScale = torsoScale(shoulderL, shoulderR, hipL, hipR)
|
|
|
|
|
val dtMs = lastLoggedMs?.let { timestampMs - it }
|
|
|
|
|
lastLoggedMs = timestampMs
|
|
|
|
|
|
|
|
|
|
val line = "$timestampMs " +
|
|
|
|
|
"${dtMs ?: "-"} " +
|
|
|
|
|
"${format(ankleL)} ${format(ankleR)} " +
|
|
|
|
|
"${format(hipL)} ${format(hipR)} " +
|
2026-09-07 19:38:43 +08:00
|
|
|
"${format(shoulderL)} ${format(shoulderR)} " +
|
|
|
|
|
"${format(wristL)} ${format(wristR)} " +
|
2026-08-14 18:16:55 +08:00
|
|
|
"${torsoScale?.let { "%.1f".format(it) } ?: "-"} " +
|
2026-09-07 19:38:43 +08:00
|
|
|
"$stepCount " +
|
|
|
|
|
"%.2f".format(handRaiseProgress)
|
2026-08-14 18:16:55 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
out.write(line)
|
|
|
|
|
out.newLine()
|
|
|
|
|
// Flush every line, not just on stop() -- if the app is force-
|
|
|
|
|
// 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) {
|
|
|
|
|
// A failed debug write shouldn't disrupt the actual recording.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @brief Closes the current file, if one is open. Safe to call even if nothing is open. */
|
|
|
|
|
fun stop() {
|
|
|
|
|
try {
|
|
|
|
|
writer?.close()
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
// Nothing useful to do about a failed close on a debug file.
|
|
|
|
|
}
|
|
|
|
|
writer = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun format(landmark: SmoothedLandmark?): String =
|
|
|
|
|
if (landmark == null) "-" else "(%.1f,%.2f)".format(landmark.y, landmark.inFrameLikelihood)
|
|
|
|
|
|
|
|
|
|
/** @brief Shoulder-to-hip pixel distance, matching [LiveStepDetector]'s own torso-scale definition. */
|
|
|
|
|
private fun torsoScale(
|
|
|
|
|
shoulderL: SmoothedLandmark?,
|
|
|
|
|
shoulderR: SmoothedLandmark?,
|
|
|
|
|
hipL: 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 }
|
|
|
|
|
}
|
|
|
|
|
}
|