From f7bca894ba56979dcf422c0f8a030ce1fdb0d69b Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Fri, 11 Sep 2026 03:50:56 +0800 Subject: [PATCH] android: revert image to locked orientation (user decision); keep official-style manual rotate/flip settings --- .../mag160c/thermal/ui/live/ImageTransform.kt | 60 +++++--- .../mag160c/thermal/ui/live/LiveRenderer.kt | 14 +- .../com/mag160c/thermal/ui/live/LiveScreen.kt | 7 +- .../mag160c/thermal/ui/live/LiveViewModel.kt | 54 +++---- .../thermal/ui/remote/RemoteRendererHost.kt | 9 +- .../ui/remote/RemoteViewerViewModel.kt | 9 +- .../thermal/ui/settings/SettingsScreen.kt | 6 + .../ui/live/ImageTransformOrientationTest.kt | 138 +++++++++--------- .../thermal/ui/live/ImageTransformTest.kt | 77 +++++----- build-artifacts/mag160c-app-debug.apk | 2 +- docs/android_app/HANDOFF_DEVELOPMENT.md | 38 ++--- docs/android_app/real_device_checklist.md | 5 +- docs/android_app/session_state.md | 26 ++-- 13 files changed, 227 insertions(+), 218 deletions(-) diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt index b1d9d47..5275ebd 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt index 3e4ad68..588bce6 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index 529cb83..b9d85bc 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -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, ) } }, diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index 03b924c..090e14c 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -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, ) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt index 23fa270..20be8b3 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt @@ -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( diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt index fd6d9d6..ca7cdf2 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt index 28e763e..8f5c410 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt @@ -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°", diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt index c61b19f..ae72485 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt @@ -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) } } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt index 8a2a05d..3d55431 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt @@ -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() { diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 3f88049..ad4f841 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7caf9b358fd69edc1c0c27a3c1aa61986ef3a8fda96713655f68ec6330ad814 +oid sha256:73e2c981cd55d216d3dcdb8c955b4ef607117d6da1a671e6ee9e03b7d46cb0d1 size 12663188 diff --git a/docs/android_app/HANDOFF_DEVELOPMENT.md b/docs/android_app/HANDOFF_DEVELOPMENT.md index 4206f77..8595498 100644 --- a/docs/android_app/HANDOFF_DEVELOPMENT.md +++ b/docs/android_app/HANDOFF_DEVELOPMENT.md @@ -67,7 +67,7 @@ android/app/src/main/kotlin/com/mag160c/thermal/ │ │ +底部快门区(相册/拍照/录像)+SurfaceView+PIP浮层 │ ├─ PipCameraView.kt 可见光PIP相机引擎(Camera2最小实现,异常只记日志) │ ├─ ImageTransform.kt 方向/翻转/letterbox/缩放几何 + 传感器↔屏幕互逆映射(纯 Kotlin,单测覆盖) -│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像按握持角补偿旋转(rot=90-φ)、 +│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像锁定竖屏框架(90°,可按设置手工旋转/翻转)、 │ letterbox+变倍、色标条/圆环标记/中心温OSD(标签按旋转包围盒定位) ├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限) ├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/温度条+点击测温/备注回写/PDF报告) @@ -130,28 +130,30 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形 - 旧 analysis/protocol_spec.md 的 66f/670 描述("prepare/version query")不准, 以 magcx_official_flow.md 为准。 -### 4.3 实时画面渲染(2026-09-11 修订:图像内容随握持朝向补偿) +### 4.3 实时画面渲染(2026-09-11 定稿:图像锁定 + 官方同款手工修正) - **Activity 锁定竖屏**(manifest `screenOrientation="portrait"`):屏幕相对手机框架 永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、 中间、物理底边;手机怎么物理旋转,构图都不动。**不要改回 fullSensor。** -- **图像内容补偿旋转(本轮修订,勿回退)**:构图不动,但**画面内容**必须随握持朝向 - 反向补偿,否则转身后场景跟着转、与镜头实际指向脱节。规则: - `rot = 90 - φ`(φ = DeviceOrientation 握持角 0/90/180/270)。 - 依据官方实现:`MainActivity.windowOrientationListener` 把 Display.rotation - (0/1/2/3) 映射成图像 90/0/270/180,再经 `ImageViewer.drawImage` 的 - `matrix.postRotate` 应用;官方**窗口会随传感器旋转**,故可见结果恒为 90。 - 我们锁了窗口,图像就要承担全部差值 → `90 - φ`。 - 显示旋转索引→角度约定取自官方自家相机代码 - (`VisibleCameraHelper.setPreviewOrientation`:case 0→0/1→90/2→180/3→270, - 即索引 N = 顺时针 N×90)。 -- **手工修正项**(官方"旋转USB画面/水平翻转/竖直翻转"同款):设置页三项, - 叠加在自动补偿之上,持久化于 `AppSettings.imageRotateDeg/imageFlipH/imageFlipV`, - 用于传感器安装方向特殊的机器。翻转在像素拷贝阶段完成,保证几何映射可测。 +- **图像内容同样锁定**(用户 2026-09-11 明确)**,不随握持角旋转**:热像传感器 + 物理装在手机上、跟着手机一起转,所以锁定图像时场景与世界的相对方向自动保持 + 正确,**不需要**加速度计参与。这也正是官方 App 的屏上效果(官方窗口随传感器 + 转,其面板级图像旋转因此是恒定的 90°)。 + - ⚠️ **曾经改错又改回**:2026-09-11 中途有一版把图像按握持角反向补偿 + (`rot = 90 - φ`),结果**双重补偿**,正是用户报的"右转画面往反方向转"。 + **不要再引入握持角参与图像旋转。** + - `ImageTransform.params()` **不接受握持角参数**——这是编译期保证。 +- **手工修正项**(官方"旋转USB画面/水平翻转/竖直翻转"同款,设置页): + `imageRotateDeg`(0/90/180/270) 叠加在锁定基准 90° 之上;两个翻转作用在 + **传感器帧**上(与官方一致:翻转交给 native,旋转在显示矩阵上)。 + 持久化于 `AppSettings`。 + - **数学等价关系(单测锁定)**:**"竖直翻转 + 旋转 90°" ≡ "水平翻转 + 旋转 270°"** + (逐像素比对验证)。用户实测"竖直翻转 + 旋转 90°"可把画面转到另一方向, + 正是这条恒等式;两者只差一次水平镜像。 - **图标/文字按握持角补偿**:`graphicsLayer rotationZ=-φ`(Compose)/ - `canvas.rotate(-φ)`(Canvas)。**标签一律按旋转后包围盒定位**( - `ImageTransform.rotatedBoxHalfExtents` + `drawGripText`),不要再拿基线锚点 - 摆位——色标条两端数字曾因此与色条错位。 + `canvas.rotate(-φ)`(Canvas),保证任何握持姿态下可读。**标签一律按旋转后 + 包围盒定位**(`ImageTransform.rotatedBoxHalfExtents` + `drawGripText`), + 不要再拿基线锚点摆位——色标条两端数字曾因此与色条错位。 - **几何单一来源**:`ui/live/ImageTransform.kt`(纯 Kotlin,可单测)同时供 渲染器与点击/探针映射使用(`sensorToScreen` / `screenToSensor` 互为逆映射, 已对 0/90/180/270 × 翻转组合做往返测试)。渲染与取温映射**必须**用同一套参数, diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md index 97affd1..fe96218 100644 --- a/docs/android_app/real_device_checklist.md +++ b/docs/android_app/real_device_checklist.md @@ -56,10 +56,11 @@ | # | 操作 | 预期 | 通过标准 | |---|------|------|----------| | 25 | 竖屏正持,观察画面 | (无日志) | 画面正常;中心温读数与手摸/环境常识一致(**不是 -161℃、不是 108℃ 这类离谱值**) | -| 26 | 手持手机**顺时针转 90°**(横过来),观察画面内容 | (无日志) | **场景方向不变**(画面内容跟着手反向补偿),顶栏/底栏文字仍可读;不是整个场景跟着转 | +| 26 | 手持手机转四个方向(0/90/180/270),观察画面内容 | (无日志) | **画面内容始终不动**(图像锁定在竖屏框架,与第四/五轮约定一致);顶栏/底栏文字仍随持机朝向保持可读 | | 27 | 点"追踪"关闭 | (无日志) | **最高温和最低温两个标记同时消失**;再点开→两个都出现(此前最低温标记关不掉) | | 28 | 点 FFC,紧盯最高/最低温读数 | `[cmd] FFC(0) write=8/8` | 读数**不出现 ~150℃ 的瞬时跳变**(可短暂保持不变,但不得跳到离谱值) | -| 29 | 设置页"旋转USB画面"依次选 0/90/180/270° | (无日志) | 画面按所选角度整体旋转,用于修正传感器安装方向 | +| 29 | 设置页"旋转USB画面"依次选 0/90/180/270° | (无日志) | 画面按所选角度整体旋转(在锁定基准 90° 之上叠加),用于修正传感器安装方向 | +| 29b | 设置页开"竖直翻转",同时"旋转USB画面"设为 90° | (无日志) | 这是用户实测能把画面转到另一方向的组合;**它等价于"水平翻转 + 旋转270°"**(只差一次水平镜像)——若 90°+竖直翻转 看着左右是反的,改用 270°+水平翻转 | | 30 | 设置页"水平翻转"/"竖直翻转"开关 | (无日志) | 画面镜像;与官方 app 的同名设置表现一致 | | 31 | 横屏持机时观察色标条 | (无日志) | 色标条**两端**的最高/最低温数字紧贴色条两端,**不与色条重叠**、不偏移 | | 32 | 分析页打开一张 MDT,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 | diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 5bb9c8e..c13e0be 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -466,15 +466,16 @@ 新增回归测试 `PipelineTemperatureStateTest`(3 项)。 - [x] **追踪开关只关最高温**:最低温标记未受开关控制(`LiveRenderer.drawOsd`)。 现在开=最高+最低都画("高"/"低"),关=都不画。 -- [x] **画面转向反了 / 横屏应翻 180°**:原先"图像恒 90°CW 不动"的约定在真机上 - 表现为转身后场景跟着转。改为**图像内容按握持角补偿**:`rot = 90 - φ`。 - 依据官方实现(`MainActivity.windowOrientationListener` 把 Display.rotation - 映射成图像 90/0/270/180,经 `ImageViewer.drawImage` 的 `matrix.postRotate` - 应用;官方窗口随传感器转,可见结果恒 90);显示旋转索引→角度取官方自家 - 相机代码 `VisibleCameraHelper.setPreviewOrientation`(N→N×90)。 - 构图(条栏/图像区绝对位置)仍钉死竖屏框架不变。 -- [x] **新增官方同款方向设置**:设置页"旋转USB画面"(0/90/180/270)、 - "水平翻转"、"竖直翻转",叠加在自动补偿之上并持久化。 +- [x] **画面转向反了 / 横屏应翻 180°**:先按"图像内容随握持角补偿(`rot = 90 - φ`)" + 改了一版,**用户实测后指正:图像应保持锁定**——传感器装在手机上跟着一起转, + 锁定图像时场景相对世界方向自动正确,加速度计参与反而双重补偿(这正是 + "右转画面往反方向转"的原因)。**已改回锁定**:`ImageTransform.params()` + 不再接受握持角参数(编译期保证),只保留官方同款手工修正。 + 用户还实测发现"**竖直翻转 + 旋转 90°**"能把画面转到另一方向——已用单测 + 证明它等价于"水平翻转 + 旋转 270°"(逐像素比对),两者只差一次水平镜像。 +- [x] **新增官方同款方向设置**:设置页"旋转USB画面"(0/90/180/270,叠加在锁定 + 基准 90° 上)、"水平翻转"、"竖直翻转"(作用在传感器帧上,与官方一致), + 持久化于 `AppSettings`。 - [x] **色标条两端最高/最低温与色条错位**:标签原先按未旋转的基线锚点定位。 改为**按旋转后包围盒**定位(`ImageTransform.rotatedBoxHalfExtents` + `drawGripText`),任意握持角都贴着色条两端。 @@ -494,12 +495,13 @@ 主机实现"后来者写 busy"(accept 循环不再阻塞在 serve 上,第二客户端立即 收到 `{"type":"busy"}`;新增 `secondClientIsRejectedWithBusy`)。 另删除未使用的 `ACCESS_NETWORK_STATE` 权限。 -- [x] 单测 44 → **61 项全绿**;debug + release(R8) 双构建通过;APK 已更新 +- [x] 单测 44 → **62 项全绿**;debug + release(R8) 双构建通过;APK 已更新 (12.66MB)。`screenOrientation=portrait` 复核保持、camera 系列仍 not-required、多余权限已消失。 -⚠️ **规格偏离(已告知用户)**:本轮推翻了第四/五轮"图像恒 90°CW 不动"的约定 -(用户实测该约定导致转向错误)。构图绝对位置不变,仅图像**内容**随握持角补偿。 +⚠️ **规格说明(用户裁定)**:图像**锁定**在竖屏框架(第四/五轮约定维持不变); +新增的仅是官方同款三项手工方向修正。中途曾按握持角补偿图像,用户实测指正后 +已改回,`ImageTransform.params()` 不再接受握持角参数以防回退。