android: 修正横屏拍摄方向——文件旋转跟随握持角度,不再只跟显示旋转

用户:"切到横屏拍照片和视频方向又不对了"。

根因:上一轮把文件旋转定成显示旋转(锁定 90° + 手动旋转),而这与手机物理姿态无关。
相机模组固定在手机上,手机转 90° 场景就在传感器画面里转 90°,文件必须跟着转,
否则横屏拍出来就是"竖幅文件里装着横躺的场景"。

修法(普通相机做法):文件旋转 = 显示旋转 + 握持角度(PhotoSaver.captureRotation,
握持角取自 DeviceOrientation.deg)。竖直/倒置 → 竖幅 720x960;两个横屏姿态 → 横幅
960x720,且两者相差 180°。录像在开始录制时采样一次(编码尺寸固定)。预览仍保持锁定
(用户此前明确要求),所以横屏文件不是预览截图,而是预览再转一个握持角。
真机复现时设备恰在 270° 姿态(gx=9.88, gy=-1.02 即 deg=270):
修复前 720x960 竖幅;修复后 960x720 横幅,把修复前文件转 -90° 与新文件逐像素比对
平均亮度差 5.06、转 +90° 为 40.94 —— 方向正确且只差这一转。录像 tkhd 960x720
97帧 0 丢弃;分析页读横幅照片 rot=0:MIN/MAX 各一个且与烧录标记重合。

新增 savedFileRotationFollowsTheGrip / landscapeGripsProduceLandscapeFiles。
105 项测试全绿。文档补记教训:文件方向是世界坐标问题、预览方向是屏幕坐标问题,
两者可以不同;这类"方向正确"必须用同一场景在不同姿态下可复现拍摄来验证。
This commit is contained in:
ZXCLI
2026-09-12 15:02:03 +08:00
parent 4c940fe773
commit f465831d8e
5 changed files with 147 additions and 18 deletions
@@ -171,6 +171,29 @@ object PhotoSaver {
}
}
/**
* Rotation baked into a saved photo or video, so the FILE always shows the scene
* the way the user saw it.
*
* `displayRot` is what the live view draws the image with (the locked 90 plus the
* user's manual correction); `gripDeg` is how far the device is physically turned
* ([com.mag160c.thermal.ui.DeviceOrientation.deg]: 0 upright, 90 turned clockwise,
* ...). Adding the grip is what a normal camera app does, and it is required
* because the sensor is bolted to the phone: turn the phone 90 deg and the scene
* rotates 90 deg inside the sensor frame, so the saved frame has to turn with it.
*
* Without this, a photo taken in a landscape grip came out as a portrait file
* holding a sideways scene — reported on device ("切到横屏拍照片和视频方向又不对了").
* Measured against a portrait-grip photo of the same room, the file was exactly
* 90 deg out.
*
* Consequences worth knowing: upright and upside-down grips give a portrait file
* (720x960), the two landscape grips give a landscape file (960x720), and the two
* landscape grips differ by 180 deg from each other — the same as any camera.
*/
fun captureRotation(displayRot: Int, gripDeg: Int): Int =
normalizeDeg(displayRot + gripDeg)
/**
* Rotate [src] by [rot] degrees CLOCKWISE, returning a bitmap of the rotated
* size. The pivot+translate pair is what keeps the result inside the new
@@ -645,13 +645,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/**
* Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore.
*
* Orientation (user decision 2026-09-12, revised): the saved photo is written in
* the orientation the user is LOOKING AT — the display rotation plus the manual
* rotate correction are baked into the JPEG, so holding the phone upright gives
* an upright (3:4) photo instead of the sensor's 4:3 landscape frame. The manual
* flips are applied first, exactly as the live renderer does. [Mdt.RenderParams]
* records both so a reader can map the sensor-space measurement data onto the
* rotated pixels.
* Orientation: the saved photo shows the scene the way the user saw it —
* the live view's rotation (locked 90 plus the manual correction) PLUS the
* physical grip, so shooting upright gives an upright portrait file and shooting
* in a landscape grip gives an upright landscape file, exactly like a normal
* camera app. See [com.mag160c.thermal.media.PhotoSaver.captureRotation]. The
* manual flips are applied first, as the live renderer does.
* [Mdt.RenderParams] records the result so a reader can map the sensor-space
* measurement data onto the rotated pixels.
*
* The NUC counts are stored on the SENSOR grid (160x120), unrotated: the data
* stays in one fixed space rather than being resampled, and the rotation lives
@@ -662,9 +663,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val s = session
val st = _state.value
val mirror = com.mag160c.thermal.media.PhotoSaver.Mirror(flipH, flipV)
// Same rotation the live view draws the image with, so the file matches the
// screen the user was looking at.
val rotDeg = imageParams().rotDeg
val rotDeg = captureRotationDeg()
// NUC counts: the calibrated 160x120 data the live readouts use
val nuc160 = IntArray(19200)
@@ -780,6 +779,20 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
fun imageParams(): ImageTransform.Params =
ImageTransform.params(userRotateDeg, flipH, flipV)
/**
* Rotation for a saved file: the live view's rotation plus the physical grip, so
* the file shows the scene the way the user saw it (upright in a portrait grip,
* upright and landscape in a landscape grip). The PREVIEW deliberately stays
* locked — the user asked for that — so a file taken in a landscape grip is not
* a copy of the preview; it is the preview turned by the grip angle, which is
* what makes it come out the right way up.
*/
private fun captureRotationDeg(): Int =
com.mag160c.thermal.media.PhotoSaver.captureRotation(
imageParams().rotDeg,
com.mag160c.thermal.ui.DeviceOrientation.deg.value,
)
/** Fit rect of the drawn image for the current view/insets/orientation. */
private fun currentFit(): ImageTransform.Fit {
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
@@ -841,18 +854,22 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val rec = recorder
if (rec == null) {
// Same orientation and scale policy as a photo, so the recording matches
// both the live screen and the stills: the capture rotation is baked in
// and the frame is encoded at RECORD_SCALE x the sensor size. Encoding at
// the native 320x240 made the burned-in readouts blurry once the video was
// viewed full screen ("视频里面的温度标点糊糊的").
val p = imageParams()
val portrait = p.rotDeg == 90 || p.rotDeg == 270
// the stills: the capture rotation (live rotation + physical grip) is
// baked in and the frame is encoded at RECORD_SCALE x the sensor size.
// Encoding at the native 320x240 made the burned-in readouts blurry once
// the video was viewed full screen ("视频里面的温度标点糊糊的").
//
// The grip is sampled once, at start: changing hands mid-clip would need
// mid-stream rotation, and the encoder's dimensions are fixed. Every
// camera behaves this way.
val rot = captureRotationDeg()
val portrait = rot == 90 || rot == 270
val r = com.mag160c.thermal.media.Mp4Recorder(
width = if (portrait) 240 * RECORD_SCALE else 320 * RECORD_SCALE,
height = if (portrait) 320 * RECORD_SCALE else 240 * RECORD_SCALE,
).apply {
mirror = com.mag160c.thermal.media.PhotoSaver.Mirror(flipH, flipV)
rotDeg = p.rotDeg
rotDeg = rot
}
if (r.start()) {
recorder = r
@@ -141,6 +141,54 @@ class PhotoNucMappingTest {
assertEquals(270, PhotoSaver.normalizeDeg(-90))
}
/**
* A saved file's rotation = the live view's rotation + the physical grip, so the
* scene comes out the right way up whatever way the phone is held.
*
* This is the rule that was missing: the display rotation alone gave a portrait
* file with a sideways scene whenever the user turned the phone ("切到横屏拍照片
* 和视频方向又不对了"), measured on device as exactly 90 deg out against a photo
* of the same room taken upright.
*/
@Test
fun savedFileRotationFollowsTheGrip() {
val display = 90 // LOCKED_ROT_DEG with no manual correction
assertEquals("upright: portrait file, as before", 90, PhotoSaver.captureRotation(display, 0))
assertEquals("turned clockwise", 180, PhotoSaver.captureRotation(display, 90))
assertEquals("upside down", 270, PhotoSaver.captureRotation(display, 180))
assertEquals("turned counter-clockwise", 0, PhotoSaver.captureRotation(display, 270))
// the manual "rotate USB image" correction adds on top of the grip
assertEquals(270, PhotoSaver.captureRotation(180, 90))
assertEquals(90, PhotoSaver.captureRotation(0, 90))
}
/**
* Only the two upright-ish grips may give a portrait file; both landscape grips
* must give a landscape file, and they differ by 180 deg from each other (the
* scene is upside down between them).
*/
@Test
fun landscapeGripsProduceLandscapeFiles() {
val display = 90
for (grip in intArrayOf(0, 90, 180, 270)) {
val rot = PhotoSaver.captureRotation(display, grip)
val portrait = rot == 90 || rot == 270
val expectedPortrait = grip == 0 || grip == 180
assertEquals(
"grip=$grip rot=$rot must be ${if (expectedPortrait) "portrait" else "landscape"}",
expectedPortrait,
portrait,
)
}
val cw = PhotoSaver.captureRotation(display, 90)
val ccw = PhotoSaver.captureRotation(display, 270)
assertEquals(
"the two landscape grips differ by 180 deg",
180,
PhotoSaver.normalizeDeg(cw - ccw),
)
}
/**
* A version-1 render block (written before the rotation was baked in) reports no
* rotation, so old photos keep being interpreted as sensor-oriented instead of