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°",
@@ -5,86 +5,88 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The orientation rule must reproduce the official app's on-screen result.
* Orientation of the live image.
*
* Official mapping (MainActivity windowOrientationListener -> DeviceController.
* setPreviewOrientation, applied as matrix.postRotate in ImageViewer.drawImage):
* Display.rotation 0 -> image 90
* Display.rotation 1 -> image 0
* Display.rotation 2 -> image 270
* Display.rotation 3 -> image 180
* and the official WINDOW auto-rotates, so the visible result is
* image_rot - display_rotation*90 (mod 360) = 90 in all four cases.
* USER DECISION (2026-09-11): the image is LOCKED to the phone's portrait frame,
* like the composition. It does NOT rotate with the grip:
*
* The display-rotation index -> degrees convention is taken from the official
* app's own camera code (VisibleCameraHelper.setPreviewOrientation):
* case 0 -> 0, case 1 -> 90, case 2 -> 180, case 3 -> 270
* i.e. index N means an N*90 CLOCKWISE device rotation, the same sign our grip
* angle uses. That makes the locked-window compensation rot = 90 - grip.
* - the thermal sensor is physically attached to the phone, so it turns with
* it; a locked image therefore keeps the scene aligned with the world without
* any accelerometer input (and that is also what the official app shows on
* screen, since its window auto-rotates);
* - an earlier revision counter-rotated the image by the grip angle
* (`rot = 90 - grip`); that double-compensated and produced the reported
* "turn right, picture goes the other way" defect. It was reverted.
*
* Our Activity is portrait-LOCKED, so the window never rotates and the image
* must supply the whole difference: rotating the IMAGE by (90 - grip) gives the
* same constant 90 relative to the user.
* Only the three official-style manual corrections can change the rotation:
* 旋转USB画面 (0/90/180/270), 水平翻转, 竖直翻转.
*/
class ImageTransformOrientationTest {
/** The official app's image rotation for a given display rotation. */
private fun officialImageRot(displayRotation: Int): Int = when (displayRotation) {
0 -> 90
1 -> 0
2 -> 270
else -> 180
@Test
fun imageRotationIsLockedAndGripIndependent() {
// whatever the phone does, the image keeps the fixed 90 deg rotation
assertEquals(90, ImageTransform.LOCKED_ROT_DEG)
// params() takes no grip argument at all — this is the compile-time
// guarantee that the image cannot follow the phone
assertEquals(90, ImageTransform.params().rotDeg)
assertEquals(90, ImageTransform.params(userRotateDeg = 0).rotDeg)
assertEquals(90, ImageTransform.params(flipH = true, flipV = true).rotDeg)
}
@Test
fun officialMappingIsReproducedByTheGripRule() {
// Display.rotation index N corresponds to a CLOCKWISE device rotation of
// N*90 (the standard camera2 convention, where deviceOrientation is
// displayRotation*90). Our grip angle uses the same sign, so grip = N*90.
for (dr in 0..3) {
val grip = dr * 90
val official = officialImageRot(dr)
val ours = ImageTransform.params(grip).rotDeg
assertEquals(
"displayRotation=$dr (grip $grip) must match the official image rotation",
official, ours,
)
fun manualRotationAddsOnTopOfTheLockedBase() {
assertEquals(90, ImageTransform.params(0).rotDeg)
assertEquals(180, ImageTransform.params(90).rotDeg)
assertEquals(270, ImageTransform.params(180).rotDeg)
assertEquals(0, ImageTransform.params(270).rotDeg)
// full turns are no-ops and are normalised away
assertEquals(90, ImageTransform.params(360).rotDeg)
assertEquals(90, ImageTransform.params(-360).rotDeg)
assertEquals(180, ImageTransform.params(450).rotDeg)
}
@Test
fun flipsAreCarriedThroughToTheGeometry() {
val p = ImageTransform.params(userRotateDeg = 180, flipH = true, flipV = false)
assertEquals(270, p.rotDeg)
assertTrue(p.flipH)
assertTrue(!p.flipV)
}
/**
* The equivalence the user discovered on the device: a vertical flip together
* with a 90 deg rotation gives the SAME image as a horizontal flip with a
* 270 deg rotation. Verified here by transforming every pixel corner, so the
* settings screen documentation can state it as a fact.
*/
@Test
fun flipVWith90EqualsFlipHWith270() {
val a = ImageTransform.params(userRotateDeg = 0, flipH = false, flipV = true) // 90 + flipV
val b = ImageTransform.params(userRotateDeg = 180, flipH = true, flipV = false) // 270 + flipH
assertEquals(90, a.rotDeg)
assertEquals(270, b.rotDeg)
val fit = ImageTransform.fit(0f, 0f, 1080f, 1900f, 90)
val crop = ImageTransform.cropForZoom(1)
// compare where every sensor pixel lands; a and b must agree
for (sx in 0 until ImageTransform.SENSOR_W step 7) {
for (sy in 0 until ImageTransform.SENSOR_H step 7) {
val pa = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), a, fit, crop)
val pb = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), b, fit, crop)
assertEquals("x at ($sx,$sy)", pa[0], pb[0], 0.01f)
assertEquals("y at ($sx,$sy)", pa[1], pb[1], 0.01f)
}
}
}
@Test
fun visibleOrientationIsGripIndependent() {
// On the official app the window rotates with the grip, so the visible
// image rotation is constant: image_rot - gripWindowContribution.
// For our locked window the visible rotation IS the image rotation
// measured against the world, and the rule keeps it at 90 for every grip.
for (grip in intArrayOf(0, 90, 180, 270)) {
val rot = ImageTransform.params(grip).rotDeg
val visible = ((rot + grip) % 360 + 360) % 360
assertEquals("grip=$grip keeps the world-aligned result", 90, visible)
}
}
@Test
fun turningThePhoneTurnsTheImageTheOppositeWay() {
// The reported defect: turning the phone right made the picture go the
// other way. The compensation must be OPPOSITE in sign to the grip.
val upright = ImageTransform.params(0).rotDeg // 90
val turnedRight = ImageTransform.params(90).rotDeg // 0
val turnedLeft = ImageTransform.params(270).rotDeg // 180
assertEquals(upright, (turnedRight + 90) % 360)
assertEquals(upright, (turnedLeft + 270) % 360)
assertTrue("rot must decrease as the grip increases", turnedRight < upright)
}
@Test
fun landscapeUsesTheWideFootprint() {
// holds for a landscape grip: the image is drawn 4:3 (not 3:4), which is
// the "must be rotated 180 in landscape" complaint
val landscape = ImageTransform.params(90).rotDeg
assertEquals(0, landscape)
assertEquals(4f / 3f, ImageTransform.screenAspect(landscape), 1e-4f)
val portrait = ImageTransform.params(0).rotDeg
assertEquals(3f / 4f, ImageTransform.screenAspect(portrait), 1e-4f)
fun lockedRotationKeepsThePortraitFootprint() {
// the locked 90 deg rotation draws the 4:3 sensor as a 3:4 image
assertEquals(3f / 4f, ImageTransform.screenAspect(90), 1e-4f)
// the manual 90/270 corrections still swap it, for special mounts
assertEquals(4f / 3f, ImageTransform.screenAspect(180), 1e-4f)
assertEquals(4f / 3f, ImageTransform.screenAspect(0), 1e-4f)
assertEquals(3f / 4f, ImageTransform.screenAspect(270), 1e-4f)
}
}
@@ -19,16 +19,43 @@ class ImageTransformTest {
)
@Test
fun rotationFollowsTheGripCompensationRule() {
// rot = 90 - grip: upright portrait draws the sensor 90 deg CW (sideways
// 3:4 image); turning the phone a quarter turn draws it upright 4:3
assertEquals(90, ImageTransform.params(0).rotDeg)
assertEquals(0, ImageTransform.params(90).rotDeg)
assertEquals(270, ImageTransform.params(180).rotDeg)
assertEquals(180, ImageTransform.params(270).rotDeg)
// the manual correction adds on top and stays normalised
assertEquals(180, ImageTransform.params(0, userRotateDeg = 90).rotDeg)
assertEquals(0, ImageTransform.params(0, userRotateDeg = 270).rotDeg)
fun rotationIsLockedPlusManualCorrection() {
// the image does NOT follow the phone: only the base 90 plus the user's
// manual correction (see ImageTransformOrientationTest for the rationale)
assertEquals(90, ImageTransform.params().rotDeg)
assertEquals(180, ImageTransform.params(userRotateDeg = 90).rotDeg)
assertEquals(0, ImageTransform.params(userRotateDeg = 270).rotDeg)
}
@Test
fun sensorToScreenAndBackAreInversesForEveryOrientation() {
val crop = ImageTransform.cropForZoom(1)
for (rot in intArrayOf(0, 90, 180, 270)) {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val p = ImageTransform.Params(rot, flipH, flipV)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
for (sx in intArrayOf(0, 37, 80, 159)) {
for (sy in intArrayOf(0, 22, 60, 119)) {
val scr = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), p, f, crop)
val back = ImageTransform.screenToSensor(scr[0], scr[1], p, f, crop)
assertNotNull(
"rot=$rot flipH=$flipH flipV=$flipV pixel=($sx,$sy)",
back,
)
assertEquals(
"rot=$rot flipH=$flipH flipV=$flipV sx",
sx, back!!.first,
)
assertEquals(
"rot=$rot flipH=$flipH flipV=$flipV sy",
sy, back.second,
)
}
}
}
}
}
}
@Test
@@ -55,36 +82,6 @@ class ImageTransformTest {
assertEquals(400f, wide.height, 1f)
}
@Test
fun sensorToScreenAndBackAreInversesForEveryGrip() {
val crop = ImageTransform.cropForZoom(1)
for (grip in intArrayOf(0, 90, 180, 270)) {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val p = ImageTransform.params(grip, 0, flipH, flipV)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
for (sx in intArrayOf(0, 37, 80, 159)) {
for (sy in intArrayOf(0, 22, 60, 119)) {
val scr = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), p, f, crop)
val back = ImageTransform.screenToSensor(scr[0], scr[1], p, f, crop)
assertNotNull(
"grip=$grip flipH=$flipH flipV=$flipV pixel=($sx,$sy)",
back,
)
assertEquals(
"grip=$grip flipH=$flipH flipV=$flipV sx",
sx, back!!.first,
)
assertEquals(
"grip=$grip flipH=$flipH flipV=$flipV sy",
sy, back.second,
)
}
}
}
}
}
}
@Test
fun screenPointsOutsideTheImageMapToNull() {