Browse Source

Avoid double sensor rotation in direct Android camera preview

master
evgeny 6 days ago
parent
commit
2ee3103c8c
  1. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPreview.kt
  2. 62
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt
  3. 18
      tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamPreviewTest.kt

6
tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPreview.kt

@ -13,8 +13,10 @@ data class StreamPreviewCamera(val sensor: Int, val front: Boolean) {
data class StreamPreviewFormat(val width: Int, val height: Int, val camera: StreamPreviewCamera, val realtimeTimestamp: Boolean = false) { data class StreamPreviewFormat(val width: Int, val height: Int, val camera: StreamPreviewCamera, val realtimeTimestamp: Boolean = false) {
fun aspect(displayDegrees: Int): Float = if (camera.rotation(displayDegrees) % 180 == 0) width.toFloat() / height else height.toFloat() / width fun aspect(displayDegrees: Int): Float = if (camera.rotation(displayDegrees) % 180 == 0) width.toFloat() / height else height.toFloat() / width
/** Углы TextureView после поворота; размеры view уже учитывают поворот сенсора. */ /** TextureView уже учитывает SENSOR_ORIENTATION; остаётся компенсация поворота экрана. */
fun corners(displayDegrees: Int): FloatArray = when (camera.rotation(displayDegrees)) { fun textureRotation(displayDegrees: Int): Int = (360 - displayDegrees) % 360
fun corners(displayDegrees: Int): FloatArray = when (textureRotation(displayDegrees)) {
90 -> floatArrayOf(1f, 0f, 1f, 1f, 0f, 1f, 0f, 0f) 90 -> floatArrayOf(1f, 0f, 1f, 1f, 0f, 1f, 0f, 0f)
180 -> floatArrayOf(1f, 1f, 0f, 1f, 0f, 0f, 1f, 0f) 180 -> floatArrayOf(1f, 1f, 0f, 1f, 0f, 0f, 1f, 0f)
270 -> floatArrayOf(0f, 1f, 0f, 0f, 1f, 0f, 1f, 1f) 270 -> floatArrayOf(0f, 1f, 0f, 0f, 1f, 0f, 1f, 1f)

62
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt

@ -6,6 +6,9 @@ import android.content.pm.PackageManager
import android.content.Context import android.content.Context
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.hardware.display.DisplayManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.TextureView
@ -69,6 +72,7 @@ import kotlinx.coroutines.withContext
fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSource>, selected: StreamSource?, fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSource>, selected: StreamSource?,
sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) { sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val displayDegrees = StreamDisplayDegrees()
val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L } val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L }
val state by StreamController.state.collectAsState() val state by StreamController.state.collectAsState()
val local = state?.takeIf { it.group == group } val local = state?.takeIf { it.group == group }
@ -117,7 +121,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
if (selected != null) { if (selected != null) {
Text(selected.displayName, style = MaterialTheme.typography.titleSmall) Text(selected.displayName, style = MaterialTheme.typography.titleSmall)
if (selected.self && local?.ongoing == true) { if (selected.self && local?.ongoing == true) {
CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, displayDegrees,
StreamController::change) StreamController::change)
CaptureModeStatus(local.config, local.pendingConfig, local.notice) CaptureModeStatus(local.config, local.pendingConfig, local.notice)
StreamTxPanel(local.id) StreamTxPanel(local.id)
@ -125,7 +129,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended)) if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended))
else key(group, selected.id, selected.epoch) { else key(group, selected.id, selected.epoch) {
LiveStreamView(group, selected.id, selected.epoch, if (selected.self) local?.preview else null) LiveStreamView(group, selected.id, selected.epoch, if (selected.self) local?.preview else null, displayDegrees)
} }
} }
TextButton(onClick = { onSelect(null); onBack() }) { Text(stringResource(R.string.stream_close_view)) } TextButton(onClick = { onSelect(null); onBack() }) { Text(stringResource(R.string.stream_close_view)) }
@ -133,7 +137,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) } enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
} else { } else {
if (local?.ongoing == true) { if (local?.ongoing == true) {
CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, displayDegrees,
StreamController::change) StreamController::change)
CaptureModeStatus(local.config, local.pendingConfig, local.notice) CaptureModeStatus(local.config, local.pendingConfig, local.notice)
StreamTxPanel(local.id) StreamTxPanel(local.id)
@ -144,7 +148,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
})) }))
Button(onClick = StreamController::stop, enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) } Button(onClick = StreamController::stop, enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
} else { } else {
CaptureOptions(cameras, config, !loadingModes) { mode = it.mode; orientation = it.orientation; videoBitrate = it.videoBitrate } CaptureOptions(cameras, config, !loadingModes, displayDegrees) { mode = it.mode; orientation = it.orientation; videoBitrate = it.videoBitrate }
if (!loadingModes && cameras.isEmpty()) Text(stringResource(R.string.stream_no_modes), color = MaterialTheme.colorScheme.error) if (!loadingModes && cameras.isEmpty()) Text(stringResource(R.string.stream_no_modes), color = MaterialTheme.colorScheme.error)
Row { Checkbox(checked = audio, onCheckedChange = { audio = it }); Text(stringResource(R.string.microphone), Modifier.padding(top = 12.dp)) } Row { Checkbox(checked = audio, onCheckedChange = { audio = it }); Text(stringResource(R.string.microphone), Modifier.padding(top = 12.dp)) }
Button(onClick = { Button(onClick = {
@ -188,7 +192,7 @@ private fun StreamTxPanel(id: Long) {
} }
@Composable @Composable
private fun CaptureOptions(cameras: List<StreamCamera>, config: StreamCaptureConfig?, enabled: Boolean, private fun CaptureOptions(cameras: List<StreamCamera>, config: StreamCaptureConfig?, enabled: Boolean, display: Int,
onChange: (StreamCaptureConfig) -> Unit) { onChange: (StreamCaptureConfig) -> Unit) {
val mode = config?.mode val mode = config?.mode
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -239,9 +243,6 @@ private fun CaptureOptions(cameras: List<StreamCamera>, config: StreamCaptureCon
} }
} }
} }
val context = LocalContext.current
val configuration = LocalConfiguration.current
val display = remember(configuration) { ((context as? Activity)?.windowManager?.defaultDisplay?.rotation ?: 0) * 90 }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
Box { Box {
@ -295,7 +296,7 @@ private fun StreamRotationMark(top: Int) {
} }
@Composable @Composable
private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: StreamPreview?) { private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: StreamPreview?, displayDegrees: Int) {
val owner = LocalLifecycleOwner.current val owner = LocalLifecycleOwner.current
var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
DisposableEffect(owner, localPreview) { DisposableEffect(owner, localPreview) {
@ -307,17 +308,35 @@ private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview:
owner.lifecycle.addObserver(observer) owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer); localPreview?.setVisible(false) } onDispose { owner.lifecycle.removeObserver(observer); localPreview?.setVisible(false) }
} }
val context = LocalContext.current
val configuration = LocalConfiguration.current
val displayDegrees = if (localPreview != null) remember(configuration) {
((context as? Activity)?.windowManager?.defaultDisplay?.rotation ?: Surface.ROTATION_0) * 90
} else 0
if (visible) { if (visible) {
if (localPreview != null) CameraPreviewSurface(localPreview, displayDegrees) if (localPreview != null) CameraPreviewSurface(localPreview, displayDegrees)
else LiveStreamSurface(group, source, epoch) else LiveStreamSurface(group, source, epoch)
} }
} }
/** DisplayListener замечает также 180°, когда Configuration и размеры окна не меняются. */
@Composable
private fun StreamDisplayDegrees(): Int {
val context = LocalContext.current
val configuration = LocalConfiguration.current
val display = (context as? Activity)?.windowManager?.defaultDisplay
var degrees by remember(display) { mutableStateOf((display?.rotation ?: 0) * 90) }
DisposableEffect(context, display, configuration) {
degrees = (display?.rotation ?: 0) * 90
val manager = context.getSystemService(DisplayManager::class.java)
val listener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(id: Int) {}
override fun onDisplayRemoved(id: Int) {}
override fun onDisplayChanged(id: Int) {
if (id == display?.displayId) degrees = display.rotation * 90
}
}
manager.registerDisplayListener(listener, Handler(Looper.getMainLooper()))
onDispose { manager.unregisterDisplayListener(listener) }
}
return degrees
}
@Composable @Composable
private fun LiveStreamSurface(group: Long, source: Long, epoch: Long) { private fun LiveStreamSurface(group: Long, source: Long, epoch: Long) {
val engine = remember(group, source, epoch) { StreamPlayerEngine(group, source, epoch) } val engine = remember(group, source, epoch) { StreamPlayerEngine(group, source, epoch) }
@ -356,7 +375,7 @@ private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) {
val state by preview.state.collectAsState() val state by preview.state.collectAsState()
val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Text(stringResource(if (state.failed) R.string.stream_failed else if (state.active) R.string.stream_live else R.string.stream_connecting)) Text(stringResource(if (state.failed) R.string.stream_preview_failed else if (state.active) R.string.stream_live else R.string.stream_connecting))
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) { Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) {
AndroidView(factory = { CameraPreviewView(it, preview) }, modifier = Modifier.matchParentSize(), AndroidView(factory = { CameraPreviewView(it, preview) }, modifier = Modifier.matchParentSize(),
@ -375,13 +394,14 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre
private var frames = 0L private var frames = 0L
private var reportFrames = 0L private var reportFrames = 0L
private var reportNs = 0L private var reportNs = 0L
private var observedFormat: StreamPreviewFormat? = null
init { init {
keepScreenOn = true keepScreenOn = true
surfaceTextureListener = object : SurfaceTextureListener { surfaceTextureListener = object : SurfaceTextureListener {
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
if (closed) return if (closed) return
target = preview.attach(texture); frames = 0; reportFrames = 0; reportNs = System.nanoTime() target = preview.attach(texture); frames = 0; reportFrames = 0; reportNs = System.nanoTime(); observedFormat = null
transform() transform()
} }
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { transform() } override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { transform() }
@ -393,6 +413,12 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) { override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {
val timestamp = texture.timestamp val timestamp = texture.timestamp
if (timestamp == 0L) return // Очистка TextureView до первого кадра камеры. if (timestamp == 0L) return // Очистка TextureView до первого кадра камеры.
if (format != observedFormat) {
val native = FloatArray(16); texture.getTransformMatrix(native)
LogManager.addLog("INFO", "video", "direct preview native transform sensor=${format?.camera?.sensor} " +
"front=${format?.camera?.front} native=${listOf(native[0], native[1], native[4], native[5])}")
observedFormat = format
}
frames++ frames++
val now = System.nanoTime() val now = System.nanoTime()
if (frames == 1L || now - reportNs >= 1_000_000_000) { if (frames == 1L || now - reportNs >= 1_000_000_000) {
@ -401,7 +427,7 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre
LogManager.addLog(if (frames == 1L) "INFO" else "DEBUG", "video", "direct preview frame " + LogManager.addLog(if (frames == 1L) "INFO" else "DEBUG", "video", "direct preview frame " +
"target=${target?.let(System::identityHashCode)} frames=$frames fps=${"%.1f".format(fps)} " + "target=${target?.let(System::identityHashCode)} frames=$frames fps=${"%.1f".format(fps)} " +
"ageUs=$age timestampNs=$timestamp realtime=${format?.realtimeTimestamp} " + "ageUs=$age timestampNs=$timestamp realtime=${format?.realtimeTimestamp} " +
"rotation=${format?.camera?.rotation(displayDegrees)}") "sensor=${format?.camera?.sensor} display=$displayDegrees textureRotation=${format?.textureRotation(displayDegrees)}")
reportNs = now; reportFrames = frames reportNs = now; reportFrames = frames
} }
} }
@ -425,7 +451,7 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre
} }
setTransform(matrix) setTransform(matrix)
LogManager.addLog("DEBUG", "video", "direct preview transform view=${width}x$height buffer=${next.width}x${next.height} " + LogManager.addLog("DEBUG", "video", "direct preview transform view=${width}x$height buffer=${next.width}x${next.height} " +
"rotation=${next.camera.rotation(displayDegrees)} front=${next.camera.front}") "sensor=${next.camera.sensor} display=$displayDegrees textureRotation=${next.textureRotation(displayDegrees)} front=${next.camera.front}")
} }
fun close() { closed = true; keepScreenOn = false; target?.let(preview::detach) } fun close() { closed = true; keepScreenOn = false; target?.let(preview::detach) }

18
tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamPreviewTest.kt

@ -11,10 +11,20 @@ class StreamPreviewTest {
assertEquals(16f / 9f, rear.aspect(90), 0f) assertEquals(16f / 9f, rear.aspect(90), 0f)
assertEquals(9f / 16f, front.aspect(0), 0f) assertEquals(9f / 16f, front.aspect(0), 0f)
assertEquals(16f / 9f, front.aspect(90), 0f) assertEquals(16f / 9f, front.aspect(90), 0f)
assertArrayEquals(floatArrayOf(1f, 0f, 1f, 1f, 0f, 1f, 0f, 0f), rear.corners(0), 0f) assertArrayEquals(floatArrayOf(0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f), rear.corners(0), 0f)
assertArrayEquals(floatArrayOf(0f, 1f, 0f, 0f, 1f, 0f, 1f, 1f), front.corners(0), 0f) assertArrayEquals(rear.corners(0), front.corners(0), 0f)
assertArrayEquals(floatArrayOf(0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f), rear.corners(90), 0f) assertArrayEquals(floatArrayOf(0f, 1f, 0f, 0f, 1f, 0f, 1f, 1f), rear.corners(90), 0f)
assertArrayEquals(floatArrayOf(0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f), front.corners(90), 0f) assertArrayEquals(rear.corners(90), front.corners(90), 0f)
}
@Test fun textureViewNeverAppliesSensorRotationTwice() {
for (front in listOf(false, true)) for (sensor in listOf(0, 90, 180, 270)) {
val format = StreamPreviewFormat(1280, 720, StreamPreviewCamera(sensor, front))
assertEquals(0, format.textureRotation(0))
assertEquals(270, format.textureRotation(90))
assertEquals(180, format.textureRotation(180))
assertEquals(90, format.textureRotation(270))
}
} }
@Test fun allPreviewRotationsPreserveRectangleCorners() { @Test fun allPreviewRotationsPreserveRectangleCorners() {

Loading…
Cancel
Save