Files
PinPoint/app/src/main/java/com/example/jnicpp/bowling/DebugSessionLogger.kt
T
2026-09-09 22:48:06 +08:00

153 lines
6.3 KiB
Kotlin

/**
* @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
import kotlin.math.sqrt
/**
* @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 (_: Exception) {
null
}
writer = outputStream?.let { BufferedWriter(OutputStreamWriter(it)) }
lastLoggedMs = null
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",
)
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.
*/
fun log(landmarks: Map<Int, SmoothedLandmark>, timestampMs: Long, stepCount: Int) {
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]
val wristL = landmarks[PoseLandmark.LEFT_WRIST]
val wristR = landmarks[PoseLandmark.RIGHT_WRIST]
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)} " +
"${format(shoulderL)} ${format(shoulderR)} " +
"${format(wristL)} ${format(wristR)} " +
"${torsoScale?.let { "%.1f".format(it) } ?: "-"} " +
stepCount.toString()
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 (_: 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 (_: 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 sqrt((dx * dx + dy * dy)).takeIf { it > 0f }
}
}