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°",
|
||||
|
||||
Reference in New Issue
Block a user