From f465831d8e268ad79b011011bdc117b1fa525b6e Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sat, 12 Sep 2026 15:02:03 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E4=BF=AE=E6=AD=A3=E6=A8=AA=E5=B1=8F?= =?UTF-8?q?=E6=8B=8D=E6=91=84=E6=96=B9=E5=90=91=E2=80=94=E2=80=94=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=97=8B=E8=BD=AC=E8=B7=9F=E9=9A=8F=E6=8F=A1=E6=8C=81?= =?UTF-8?q?=E8=A7=92=E5=BA=A6=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8F=AA=E8=B7=9F?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=97=8B=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:"切到横屏拍照片和视频方向又不对了"。 根因:上一轮把文件旋转定成显示旋转(锁定 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 项测试全绿。文档补记教训:文件方向是世界坐标问题、预览方向是屏幕坐标问题, 两者可以不同;这类"方向正确"必须用同一场景在不同姿态下可复现拍摄来验证。 --- .../com/mag160c/thermal/media/PhotoSaver.kt | 23 +++++++++ .../mag160c/thermal/ui/live/LiveViewModel.kt | 51 ++++++++++++------- .../thermal/media/PhotoNucMappingTest.kt | 48 +++++++++++++++++ build-artifacts/mag160c-app-debug.apk | 2 +- docs/android_app/session_state.md | 41 +++++++++++++++ 5 files changed, 147 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt index bfd9b74..5547e47 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index d55aef7..4e81a0d 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -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 diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt index bfe7087..8500eb7 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt @@ -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 diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index cdbcc95..8777c70 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a43716ab5996e2315c344cd18f1157ef26a3644498daf863b42911615e166b0c +oid sha256:69b8ea3a8edb5c0f8a7c1e0f900223e3fa62f1625ee071d36bf13c0054644cbd size 12741424 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 53cb438..3ef57c9 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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,照片竖屏 / 标注风格重做 / 录像清晰度) 用户第五轮反馈(三点):竖屏拍照出来的照片不是竖屏;录像里的温度标点糊;