android: revert image to locked orientation (user decision); keep official-style manual rotate/flip settings
This commit is contained in:
@@ -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°",
|
||||
|
||||
+70
-68
@@ -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() {
|
||||
|
||||
Binary file not shown.
@@ -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 × 翻转组合做往返测试)。渲染与取温映射**必须**用同一套参数,
|
||||
|
||||
@@ -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,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 |
|
||||
|
||||
@@ -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()` 不再接受握持角参数以防回退。
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user