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:
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -489,6 +489,47 @@
|
||||
"未连接"分支显示,正常出图时用户看不到任何反馈)。
|
||||
- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。
|
||||
|
||||
## 用户反馈修复 第二十四轮(2026-09-12,横屏拍摄方向:文件跟随握持角度)
|
||||
|
||||
用户反馈:"切到横屏拍照片和视频方向又不对了"。
|
||||
|
||||
**根因**:上一轮把照片旋转定为**显示旋转**(锁定 90° + 手动旋转),但这个值与手机的
|
||||
物理姿态无关。相机模组是**固定在手机上的**,手机转 90°,场景就在传感器画面里转 90°,
|
||||
所以文件必须跟着转——否则横屏拍出来就是"竖幅文件里装着横躺的场景"。
|
||||
|
||||
**修法**(普通相机的做法):
|
||||
|
||||
```
|
||||
文件旋转 = 显示旋转 + 握持角度 (PhotoSaver.captureRotation)
|
||||
```
|
||||
|
||||
握持角度取自 `DeviceOrientation.deg`(0 竖直/90 顺时针/180 倒置/270 逆时针)。
|
||||
推论:竖直与倒置 → 竖幅文件(720×960);两个横屏姿态 → 横幅文件(960×720),
|
||||
且两者相差 180°(场景在两种横屏姿态下本来就上下颠倒)。录像在**开始录制时**采样一次
|
||||
(编码尺寸固定,中途换手不旋转——所有相机都如此)。
|
||||
|
||||
注意这**不影响预览**:预览仍是锁定的(用户此前明确要求"热成像画面还是要锁定")。
|
||||
所以横屏下拍出来的文件不是预览的截图,而是预览**再转一个握持角**——正是这一步让
|
||||
场景在文件里是正的。
|
||||
|
||||
**真机验证(关键证据)**:测试时设备恰好停在 270° 横屏姿态(`dumpsys sensorservice`
|
||||
读数 gx=9.88, gy=-1.02 → deg=270),于是直接复现了用户的问题:
|
||||
|
||||
- 修复前:720×960 **竖幅**,内容相对竖直姿态拍的照片转了 90°
|
||||
- 修复后:960×720 **横幅**;把修复前的文件转 **-90°** 与新文件逐像素比对,
|
||||
平均亮度差 **5.06**,而转 +90° 的差是 **40.94** → 方向正确、只差这一转
|
||||
- 录像:`tkhd 960 x 720`、avc1、97 帧 0 丢弃
|
||||
- 分析页读该横幅照片(rot=0):MIN/MAX 各一个、与烧录标记重合,
|
||||
面板 41.4 / 21.7 / 27.0 与照片一致
|
||||
|
||||
新增测试 `savedFileRotationFollowsTheGrip`(四种握持角度 + 手动旋转叠加)与
|
||||
`landscapeGripsProduceLandscapeFiles`(形状规则 + 两个横屏姿态相差 180°)。
|
||||
105 项测试全绿。
|
||||
|
||||
**教训**:文件方向是"世界坐标"问题,预览方向是"屏幕坐标"问题——两者可以不同,
|
||||
混为一谈就会在某个握持姿态下出错。固件级/相机类功能的"方向正确"必须用
|
||||
**同一场景在不同姿态下的可复现拍摄**来验证,不能只靠推导。
|
||||
|
||||
## 用户反馈修复 第二十三轮(2026-09-12,照片竖屏 / 标注风格重做 / 录像清晰度)
|
||||
|
||||
用户第五轮反馈(三点):竖屏拍照出来的照片不是竖屏;录像里的温度标点糊;
|
||||
|
||||
Reference in New Issue
Block a user