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() {
|
||||
|
||||
Reference in New Issue
Block a user