android: revert image to locked orientation (user decision); keep official-style manual rotate/flip settings

This commit is contained in:
ZXCLI
2026-09-11 03:50:56 +08:00
parent 464cfcb5e8
commit f7bca894ba
13 changed files with 227 additions and 218 deletions
@@ -7,35 +7,40 @@ package com.mag160c.thermal.ui.live
* be unit tested on the JVM — a mismatch between the drawn image and the marker
* mapping is exactly the class of bug this file exists to prevent.
*
* ## Why the image rotates at all
* ## The image IS locked (user decision, 2026-09-11)
*
* The activity is portrait-locked, so the composition (top bar / image area /
* bottom bar) never moves — that stays as the user demanded. But the IMAGE
* CONTENT must stay aligned with the world, otherwise turning the phone makes
* the scene turn with it.
* The composition (top bar / image area / bottom bar) and the IMAGE CONTENT are
* both glued to the phone's portrait frame: rotating the phone never changes the
* image's rotation. Because the thermal sensor is physically attached to the
* phone, it rotates with it, so a locked image keeps the scene aligned with the
* world automatically — this is also what the official app ends up showing
* (its window auto-rotates, so its panel-level image rotation is the constant 90
* that we draw directly).
*
* The official app rotates the image according to the display rotation:
* Display.rotation 0/1/2/3 -> image 90/0/270/180 (MainActivity
* windowOrientationListener -> DeviceController.setPreviewOrientation, applied
* as matrix.postRotate in ImageViewer.drawImage). Its window also auto-rotates,
* so the net on-screen rotation is constant. With a LOCKED window the same
* appearance requires
* An earlier revision made the image counter-rotate with the accelerometer grip
* (`rot = 90 - grip`). That was wrong and was reverted: it double-compensated,
* since the locked image already accounts for the sensor turning with the phone.
*
* rot = 90 - gripDeg (mod 360)
* Adapting to a differently mounted sensor is done with the three manual
* corrections the official app also offers (settings screen):
* [userRotateDeg] "旋转USB画面" 0/90/180/270 added to the locked 90
* flipH / flipV "水平翻转" / "竖直翻转", applied to the SENSOR frame
* before the rotation (same order as the official app, which
* passes the flip to the native renderer and rotates the
* result on the display matrix)
*
* where gripDeg is the clockwise physical rotation of the phone (DeviceOrientation
* reports 0/90/180/270). Sanity check against the official mapping:
* official image rotation 90+displayRotation_delta equals 90 + gripDeg; the
* window contributes -gripDeg, so the visible result matches.
*
* [userRotateDeg] and the two flips are the official-style manual corrections
* ("旋转USB画面" / "水平翻转" / "竖直翻转") for setups where the sensor is mounted
* differently.
* NOTE (verified by ImageTransformOrientationTest): rotating 90 with flipV is
* mathematically identical to rotating 270 with flipH. So a vertical flip and a
* 270 rotation differ only by a horizontal mirror — worth knowing when choosing
* between them on a device.
*/
object ImageTransform {
const val SENSOR_W = 160
const val SENSOR_H = 120
/** Rotation the image is always drawn with: 90 deg CW, i.e. 3:4 portrait. */
const val LOCKED_ROT_DEG = 90
/**
* @param rotDeg clockwise rotation applied to the image content, in buffer space
* @param flipH mirror the source horizontally (before rotation)
@@ -47,8 +52,19 @@ object ImageTransform {
val flipV: Boolean,
)
fun params(gripDeg: Int, userRotateDeg: Int = 0, flipH: Boolean = false, flipV: Boolean = false): Params =
Params((((90 - gripDeg + userRotateDeg) % 360) + 360) % 360, flipH, flipV)
/**
* The locked rotation plus the user's manual correction. Deliberately takes
* NO grip angle: the image must not rotate with the phone.
*/
fun params(
userRotateDeg: Int = 0,
flipH: Boolean = false,
flipV: Boolean = false,
): Params = Params(
(((LOCKED_ROT_DEG + userRotateDeg) % 360) + 360) % 360,
flipH,
flipV,
)
/** True when the drawn image is taller than wide on screen (rot 90/270). */
fun swapped(rotDeg: Int): Boolean = rotDeg % 180 != 0
@@ -98,11 +98,11 @@ class LiveRenderer(
val availH = bottom - top
if (availH <= 0) return
// Grip-compensated orientation (see ImageTransform): the composition —
// bars, insets, image area — stays glued to the portrait frame as
// demanded, but the IMAGE CONTENT counter-rotates so the scene stays
// aligned with the world when the phone is turned.
val params = vm.imageParams(orientationDeg)
// Grip compensation applies to the IMAGE CONTENT only (the composition
// stays glued to the portrait frame, and the image content is locked to
// it too — see ImageTransform). The user's manual corrections are the
// only rotation adjustments; the grip angle is used for OSD text only.
val params = vm.imageParams()
val fit = ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
viewport.set(fit.left, fit.top, fit.right, fit.bottom)
@@ -132,7 +132,7 @@ class LiveRenderer(
drawOsd(canvas, vm.state.value)
}
/** Grip angle in 0/90/180/270; drives the OSD pre-rotation. */
/** Grip angle in 0/90/180/270; used for OSD TEXT readability only. */
private val orientationDeg: Int
get() = com.mag160c.thermal.ui.DeviceOrientation.deg.value
@@ -143,7 +143,7 @@ class LiveRenderer(
* [ImageTransform] exact and testable.
*/
private fun setFramePixels(frame: IntArray) {
val p = vm.imageParams(orientationDeg)
val p = vm.imageParams()
if (!p.flipH && !p.flipV) {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
return
@@ -132,14 +132,13 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(phi) {
.pointerInput(Unit) {
detectTapGestures { offset ->
// the same grip angle the renderer used, so the tap maps
// to the pixel actually under the finger
// the image is locked to the portrait frame, so the tap
// mapping does not depend on the grip angle
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
phi,
)
}
},
@@ -305,8 +305,8 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
*/
/**
* Manual orientation corrections, mirroring the official app's settings
* ("旋转USB画面" / "水平翻转" / "竖直翻转"). Applied on top of the automatic
* grip compensation, for sensor mounts that need a fixed offset.
* ("旋转USB画面" / "水平翻转" / "竖直翻转"). The image itself is LOCKED to the
* portrait frame; these are the only adjustments.
*/
@Volatile
var userRotateDeg: Int = 0
@@ -318,52 +318,39 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
var flipV: Boolean = false
/**
* Orientation actually used for drawing and for the sensor<->screen mapping.
* [gripDeg] is the accelerometer grip angle (0/90/180/270).
* One definition for both the renderer and the tap/probe mapping — they must
* Orientation used for drawing and for the sensor<->screen mapping. One
* definition for both the renderer and the tap/probe mapping — they must
* never disagree, which is how markers ended up on the wrong pixel.
* Takes no grip angle: the image does not rotate with the phone.
*/
fun imageParams(gripDeg: Int): ImageTransform.Params =
ImageTransform.params(gripDeg, userRotateDeg, flipH, flipV)
fun imageParams(): ImageTransform.Params =
ImageTransform.params(userRotateDeg, flipH, flipV)
/** Fit rect of the drawn image for the current view/insets/orientation. */
private fun currentFit(gripDeg: Int): ImageTransform.Fit {
private fun currentFit(): ImageTransform.Fit {
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
return ImageTransform.fit(
0f, uiTopPx.toFloat(), viewW, availH,
imageParams(gripDeg).rotDeg,
)
return ImageTransform.fit(0f, uiTopPx.toFloat(), viewW, availH, imageParams().rotDeg)
}
/**
* Tap on the live image: add a probe point, or delete an existing one when
* tapping near it. Coordinates go through the SAME transform the renderer
* used, so a tap always lands on the pixel under the finger whatever the
* grip angle and flip settings are.
* used, so a tap always lands on the pixel under the finger.
*/
fun tapImage(
screenX: Float,
screenY: Float,
viewW: Float,
viewH: Float,
gripDeg: Int = 0,
) {
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
val s = _state.value
if (!s.streaming) return
val availH = (viewH - uiBottomPx - uiTopPx).coerceAtLeast(1f)
val fit = ImageTransform.fit(
0f, uiTopPx.toFloat(), viewW, availH,
imageParams(gripDeg).rotDeg,
)
val fit = ImageTransform.fit(0f, uiTopPx.toFloat(), viewW, availH, imageParams().rotDeg)
val crop = ImageTransform.cropForZoom(s.zoom)
val sensor = ImageTransform.screenToSensor(
screenX, screenY, imageParams(gripDeg), fit, crop,
screenX, screenY, imageParams(), fit, crop,
) ?: return
// near an existing probe (compare in screen space)? delete it instead
val thr = fit.width * 0.06f
val existing = s.probes.firstOrNull { p ->
val scr = probeToScreen(p.x, p.y, gripDeg)
val scr = probeToScreen(p.x, p.y)
val dx = screenX - scr[0]
val dy = screenY - scr[1]
dx * dx + dy * dy < thr * thr
@@ -379,18 +366,13 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/**
* Screen coords of a sensor point, using the renderer's current transform.
* Defaults to the LIVE grip angle so renderer and callers cannot disagree —
* a mismatched grip here is exactly how markers land on the wrong pixel.
* No grip angle: the image is locked, so the mapping is stable.
*/
fun probeToScreen(
sx: Int,
sy: Int,
gripDeg: Int = com.mag160c.thermal.ui.DeviceOrientation.deg.value,
): FloatArray {
val fit = currentFit(gripDeg)
fun probeToScreen(sx: Int, sy: Int): FloatArray {
val fit = currentFit()
val crop = ImageTransform.cropForZoom(state.value.zoom)
return ImageTransform.sensorToScreen(
sx.toFloat(), sy.toFloat(), imageParams(gripDeg), fit, crop,
sx.toFloat(), sy.toFloat(), imageParams(), fit, crop,
)
}
@@ -83,10 +83,9 @@ class RemoteRendererHost(
val availH = bottom - top
if (availH <= 0) return
// Same orientation pipeline as the live view: the remote screen must
// present the host's image identically, including the grip compensation
// and the manual rotate/flip corrections.
val params = vm.imageParams(DeviceOrientation.deg.value)
// Same orientation pipeline as the live view: the image is LOCKED to the
// portrait frame and only the user's manual corrections apply.
val params = vm.imageParams()
setFramePixels(frame, params)
val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
viewport.set(fit.left, fit.top, fit.right, fit.bottom)
@@ -224,7 +223,7 @@ class RemoteRendererHost(
private fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = vm.uiViewW.toFloat().coerceAtLeast(1f)
val availH = (vm.uiViewH - vm.uiTopPx - vm.uiBottomPx).toFloat().coerceAtLeast(1f)
val params = vm.imageParams(DeviceOrientation.deg.value)
val params = vm.imageParams()
val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, vm.uiTopPx.toFloat(), viewW, availH, params.rotDeg)
val crop = com.mag160c.thermal.ui.live.ImageTransform.cropForZoom(vm.state.value.zoom)
return com.mag160c.thermal.ui.live.ImageTransform.sensorToScreen(
@@ -77,9 +77,12 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
@Volatile
var flipV: Boolean = false
/** See [com.mag160c.thermal.ui.live.ImageTransform.params]. */
fun imageParams(gripDeg: Int): com.mag160c.thermal.ui.live.ImageTransform.Params =
com.mag160c.thermal.ui.live.ImageTransform.params(gripDeg, userRotateDeg, flipH, flipV)
/**
* Image orientation: LOCKED base rotation plus the user's manual
* corrections, exactly as on the live screen (no grip dependence).
*/
fun imageParams(): com.mag160c.thermal.ui.live.ImageTransform.Params =
com.mag160c.thermal.ui.live.ImageTransform.params(userRotateDeg, flipH, flipV)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var session: RemoteSession? = null
@@ -133,6 +133,12 @@ fun SettingsScreen(
title = { Text("旋转USB画面") },
text = {
Column {
Text(
"在固定的 90° 基础上再旋转。用于传感器安装方向特殊的机器。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
listOf(0, 90, 180, 270).forEach { deg ->
Text(
"$deg°",