This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: detection in all orientations and split-recording support

Fix detection failing in reverse landscape and upside-down orientations:
- Add OrientationEventListener to update ImageAnalysis/VideoCapture
targetRotation when the device rotates, keeping rotationDegrees correct
- Add RotatedBitmapPool to prevent pool collision in rotateIntoPooled()
where 180° rotation reused the same bitmap as the source (erasing it)

Fix split-recording (changing recordingId to start a new segment):
- Await Finalize event before starting next recording so CameraX Recorder
is fully idle
- Capture id/path per-recording in the Finalize closure instead of reading
shared mutable state that gets overwritten

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+91 -13
+61 -12
posedetection/src/androidMain/kotlin/com.performancecoachlab/posedetection/camera/CameraView.android.kt
··· 2 2 3 3 import android.graphics.Bitmap 4 4 import android.hardware.camera2.CameraCharacteristics 5 + import android.view.OrientationEventListener 6 + import android.view.Surface 5 7 import androidx.annotation.OptIn 6 8 import androidx.camera.camera2.interop.Camera2CameraInfo 7 9 import androidx.camera.camera2.interop.ExperimentalCamera2Interop ··· 24 26 import androidx.compose.foundation.layout.Box 25 27 import androidx.compose.foundation.layout.fillMaxSize 26 28 import androidx.compose.runtime.Composable 29 + import androidx.compose.runtime.DisposableEffect 27 30 import androidx.compose.runtime.LaunchedEffect 28 31 import androidx.compose.runtime.getValue 32 + import androidx.compose.runtime.mutableIntStateOf 29 33 import androidx.compose.runtime.mutableStateOf 30 34 import androidx.compose.runtime.remember 31 35 import androidx.compose.runtime.setValue ··· 56 60 import com.performancecoachlab.posedetection.recording.AnalysisObject 57 61 import com.performancecoachlab.posedetection.skeleton.Skeleton 58 62 import com.performancecoachlab.posedetection.skeleton.SkeletonRepository 63 + import kotlinx.coroutines.CompletableDeferred 59 64 import java.io.File 60 65 import java.util.concurrent.Executors 61 66 import java.util.concurrent.atomic.AtomicBoolean ··· 157 162 objectDetector = objectModel 158 163 } 159 164 165 + // Track device orientation so we can update CameraX use-case target rotations. 166 + val currentRotation = remember { mutableIntStateOf(Surface.ROTATION_0) } 167 + var imageAnalysisRef by remember { mutableStateOf<ImageAnalysis?>(null) } 168 + var videoCaptureRef by remember { mutableStateOf<VideoCapture<Recorder>?>(null) } 169 + 170 + DisposableEffect(context) { 171 + val listener = object : OrientationEventListener(context) { 172 + override fun onOrientationChanged(orientation: Int) { 173 + if (orientation == ORIENTATION_UNKNOWN) return 174 + val rotation = when { 175 + orientation >= 315 || orientation < 45 -> Surface.ROTATION_0 176 + orientation in 45 until 135 -> Surface.ROTATION_270 177 + // Skip 180° — most Android phones don't rotate the display 178 + // to upside-down portrait, so the target rotation should stay 179 + // at the previous value to match what the preview shows. 180 + orientation in 135 until 225 -> return 181 + orientation in 225 until 315 -> Surface.ROTATION_90 182 + else -> return 183 + } 184 + if (currentRotation.intValue != rotation) { 185 + currentRotation.intValue = rotation 186 + imageAnalysisRef?.targetRotation = rotation 187 + videoCaptureRef?.targetRotation = rotation 188 + } 189 + } 190 + } 191 + listener.enable() 192 + onDispose { listener.disable() } 193 + } 194 + 160 195 // CameraX VideoCapture recording state 161 196 var recorderRef by remember { mutableStateOf<Recorder?>(null) } 162 197 var currentCxRecording by remember { mutableStateOf<Recording?>(null) } 163 - var currentRecordingId by remember { mutableStateOf<String?>(null) } 164 - var currentRecordingPath by remember { mutableStateOf<String?>(null) } 198 + // Completes when the active recording's Finalize event fires. 199 + var finalizeLatch by remember { mutableStateOf<CompletableDeferred<Unit>?>(null) } 165 200 166 201 // React to recordingId changes — start/stop CameraX VideoCapture recording 167 202 LaunchedEffect(recordingId, recorderRef) { 168 203 val recorder = recorderRef ?: return@LaunchedEffect 169 204 170 - // Stop any active recording when recordingId changes. 205 + // Stop any active recording and wait for it to fully finalize 206 + // before starting the next one. CameraX's Recorder only supports 207 + // one active recording at a time. 171 208 currentCxRecording?.let { activeRec -> 172 209 activeRec.stop() 210 + finalizeLatch?.await() 211 + finalizeLatch = null 173 212 currentCxRecording = null 174 213 } 175 214 ··· 182 221 val outputFile = File(outputPath) 183 222 val fileOutputOptions = FileOutputOptions.Builder(outputFile).build() 184 223 185 - currentRecordingId = id 186 - currentRecordingPath = outputPath 224 + // Capture id and path for this specific recording so the Finalize 225 + // callback uses the correct values even if recordingId changes 226 + // before finalization completes (e.g. split-recording). 227 + val capturedId = id 228 + val capturedPath = outputPath 229 + 230 + val latch = CompletableDeferred<Unit>() 231 + finalizeLatch = latch 187 232 188 233 val pendingRecording = recorder.prepareRecording(context, fileOutputOptions) 189 234 ··· 196 241 } 197 242 is VideoRecordEvent.Finalize -> { 198 243 onRecordToggled(false) 199 - currentRecordingId?.let { savedId -> 200 - currentRecordingPath?.let { savedPath -> 201 - onVideoSaved(savedId, savedPath) 202 - } 203 - } 244 + onVideoSaved(capturedId, capturedPath) 204 245 currentCxRecording = null 205 - currentRecordingId = null 206 - currentRecordingPath = null 246 + latch.complete(Unit) 207 247 } 208 248 } 209 249 } ··· 217 257 currentCxRecording?.stop() 218 258 currentCxRecording = null 219 259 260 + // Clear stale refs before unbinding. 261 + imageAnalysisRef = null 262 + videoCaptureRef = null 263 + 220 264 val cameraProvider = ProcessCameraProvider.getInstance(context).get() 221 265 cameraProvider.unbindAll() 222 266 ··· 230 274 } 231 275 } 232 276 277 + val rotation = currentRotation.intValue 233 278 val preview = Preview.Builder().build().also { 234 279 it.surfaceProvider = previewView.surfaceProvider 235 280 } ··· 238 283 .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) 239 284 .build() 240 285 .also { analysis -> 286 + analysis.targetRotation = rotation 241 287 analysis.setAnalyzer(executor) { imageProxy -> 242 288 // Drop frames while we're still working on the previous one. 243 289 if (!gate.tryEnter()) { ··· 311 357 } 312 358 } 313 359 } 360 + imageAnalysisRef = imageAnalysis 314 361 val recorder = Recorder.Builder() 315 362 .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) 316 363 .build() 317 364 val videoCapture = VideoCapture.withOutput(recorder) 365 + videoCapture.targetRotation = rotation 366 + videoCaptureRef = videoCapture 318 367 319 368 val camera = try { 320 369 cameraProvider.bindToLifecycle(
+30 -1
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/camera/Utils.android.kt
··· 126 126 return Bitmap.createBitmap(this, 0, 0, width, height, m, true) 127 127 } 128 128 129 + /** Separate pool for the rotated bitmap so it never collides with the base AnalysisBitmapPool. */ 130 + private object RotatedBitmapPool { 131 + private var cached: Bitmap? = null 132 + private var cachedW: Int = 0 133 + private var cachedH: Int = 0 134 + private var cachedConfig: Bitmap.Config = Bitmap.Config.ARGB_8888 135 + 136 + fun obtain(width: Int, height: Int, config: Bitmap.Config = Bitmap.Config.ARGB_8888): Bitmap { 137 + val bmp = cached 138 + return if ( 139 + bmp != null && 140 + !bmp.isRecycled && 141 + cachedW == width && 142 + cachedH == height && 143 + cachedConfig == config 144 + ) { 145 + bmp.eraseColor(android.graphics.Color.TRANSPARENT) 146 + bmp 147 + } else { 148 + createBitmap(width, height, config).also { newBmp -> 149 + cached = newBmp 150 + cachedW = width 151 + cachedH = height 152 + cachedConfig = config 153 + } 154 + } 155 + } 156 + } 157 + 129 158 private fun Bitmap.rotateIntoPooled(degrees: Int): Bitmap { 130 159 if (degrees % 360 == 0) return this 131 160 val outW = if (degrees % 180 == 0) width else height 132 161 val outH = if (degrees % 180 == 0) height else width 133 162 134 - val out = AnalysisBitmapPool.obtain(outW, outH, this.config ?: Bitmap.Config.ARGB_8888) 163 + val out = RotatedBitmapPool.obtain(outW, outH, this.config ?: Bitmap.Config.ARGB_8888) 135 164 val c = Canvas(out) 136 165 val m = Matrix().apply { 137 166 postRotate(degrees.toFloat())