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