From 595309735e00835bdbb7ed4638c14577b68e12d4 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Tue, 6 Oct 2026 17:57:17 +0800 Subject: [PATCH] =?UTF-8?q?app+csdk+docs:=20=E4=BF=AE=E6=AD=A3=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E6=9C=80=E8=BF=91=E9=82=BB2=C3=97=E7=9A=84?= =?UTF-8?q?=E9=80=80=E5=8C=96=EF=BC=88=E9=A2=97=E7=B2=92/=E8=BE=B9?= =?UTF-8?q?=E6=B2=BF=E5=8F=B0=E9=98=B6=EF=BC=89=E2=80=94=E2=80=94=E7=85=A7?= =?UTF-8?q?=E7=89=87=E6=94=B9=E4=B8=BA=E4=BB=8E160=C3=97120=E5=8D=95?= =?UTF-8?q?=E7=BA=A76=C3=97=E6=94=BE=E5=A4=A7=EF=BC=88=E5=AE=98=E6=96=B9Im?= =?UTF-8?q?ageViewer=E5=90=8C=E6=AC=BE=EF=BC=89=EF=BC=8C=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E4=B8=8Ecsdk=E5=9B=9E=E9=80=80=E4=B8=BA?= =?UTF-8?q?=E6=8F=92=E5=80=BC2=C3=97=EF=BC=9B=E5=AE=9E=E6=B5=8B=E5=86=B7?= =?UTF-8?q?=E5=8C=BA|dx|=200.76=C3=97=E5=AE=98=E6=96=B9=EF=BC=88=E6=9B=B4?= =?UTF-8?q?=E5=B9=B3=E6=BB=91=EF=BC=89=EF=BC=8C119=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mag160c/thermal/media/PhotoSaver.kt | 21 +++++++++--- .../mag160c/thermal/ui/live/LiveViewModel.kt | 20 ++++++++++- .../com/mag160c/thermal/usb/IrSession.kt | 11 ++++--- .../thermal/media/PhotoNucMappingTest.kt | 9 +++-- csdk/include/mag160c/mag160c_render.h | 13 ++++---- csdk/tools/mag160c_demo3.c | 7 +++- csdk/tools/mag160c_linux_demo.c | 7 +++- .../rendering_diff_phone_20261006.md | 33 +++++++++++++++++++ docs/android_app/session_state.md | 22 +++++++++++++ 9 files changed, 124 insertions(+), 19 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 5547e47..84ecd8a 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 @@ -50,12 +50,25 @@ import com.mag160c.thermal.core.AnnotSpec object PhotoSaver { private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH) - /** Sensor frame size used by the pipeline. */ - const val SENSOR_W = 320 - const val SENSOR_H = 240 + /** + * Native sensor frame size (160x120), the SAME grid the probes/extremes and the + * MDT's NUC block use. + * + * It used to be 320x240 - the pipeline's internally 2x-upscaled buffer - and the + * photo was then scaled 3x on top of that: a two-stage upscale whose intermediate + * stage either interpolates (a full/half amplitude column pattern at the sensor's + * Nyquist) or replicates (halving the effective resolution: 2-sensor-pixel + * blocks, visible as a coarse staircase along hot/cold edges). The official app + * upscales ONCE from the native 160x120 (`ImageViewer` draws the native bitmap + * into a 640x480 buffer with a filtering paint), so the saved photo is now a + * single 6x bilinear step from the sensor grid: 1-sensor-pixel granules, no + * interlace pattern. [RENDER_SCALE] keeps the output at the same 960x720. + */ + const val SENSOR_W = 160 + const val SENSOR_H = 120 /** Saved photo is this many times the sensor frame (crisp text). */ - const val RENDER_SCALE = 3 + const val RENDER_SCALE = 6 fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg" 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 6936cee..f91fc2b 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 @@ -658,6 +658,21 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { * stays in one fixed space rather than being resampled, and the rotation lives * only in the presentation layer. */ + /** + * Exact 160x120 sensor-grid copy of the 320x240 display buffer. The pipeline's + * 2x upscale keeps the source value at every even index (`o[2x] = src[x]`), so + * sampling (2x, 2y) recovers the native pixels without any resampling. + */ + private fun sensorFrame(display: IntArray): IntArray { + val out = IntArray(160 * 120) + var i = 0 + for (y in 0 until 120) { + val row = y * 2 * 320 + for (x in 0 until 160) out[i++] = display[row + x * 2] + } + return out + } + fun capturePhoto(context: android.content.Context, density: Float = 2f) { val frame = latestFrame ?: return val s = session @@ -690,8 +705,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { ) } + // PhotoSaver works in the NATIVE sensor grid (160x120) and upscales once, so + // hand it the exact sensor pixels: the pipeline's 320x240 buffer is a 2x + // upscale whose even samples are the untouched source values. val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered( - frame = frame, + frame = sensorFrame(frame), mirror = mirror, rotDeg = rotDeg, probes = st.probes.mapNotNull { p -> diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt index cdbf8f5..d3e6dff 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -324,10 +324,13 @@ class IrSession(context: Context) { // anchor, no auto-centre search). See RenderPipeline.lutRebuildAndroid // and docs/android_app/rendering_diff_phone_20261006.md §10. androidLut = true, - // the official Android display path has no interpolating 2x stage - // (nearest-neighbour draw of the native 160x120 bitmap); see the doc - // comment on RenderPipeline.upscaleNearest - upscaleNearest = true, + // Keep the interpolating 2x for the live buffer: the nearest variant + // halves the effective resolution (2-sensor-pixel granules, a visible + // staircase along hot/cold edges - measured, and reported by the user), + // while the interpolated one keeps 1-sensor-pixel granules. The photo no + // longer depends on this buffer: it is a single 6x step from the sensor + // grid (PhotoSaver.SENSOR_W/H = 160x120). + upscaleNearest = false, // baselineFrames stays at the verified 4: extending the window to 8 made // the output *unstable* (frame-to-frame gray noise 3.95 -> 8.8), i.e. the // device re-opens the shutter earlier than our schedule assumes, so the 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 8500eb7..52d34b6 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 @@ -24,8 +24,13 @@ class PhotoNucMappingTest { fun renderScaleIsAppliedToTheSavedPhotoSize() { // keeps the sharpness fix pinned: text drawn at 1x was visibly soft assertTrue("photo is rendered larger than the sensor frame", PhotoSaver.RENDER_SCALE >= 2) - assertEquals(320, PhotoSaver.SENSOR_W) - assertEquals(240, PhotoSaver.SENSOR_H) + // The frame handed to the writer is the NATIVE sensor grid, not the + // pipeline's 2x display buffer: the photo is then a single upscale (no + // two-stage interlace, no halved effective resolution). + assertEquals(160, PhotoSaver.SENSOR_W) + assertEquals(120, PhotoSaver.SENSOR_H) + assertEquals("output stays 960x720", 960, PhotoSaver.SENSOR_W * PhotoSaver.RENDER_SCALE) + assertEquals(720, PhotoSaver.SENSOR_H * PhotoSaver.RENDER_SCALE) // the saved photo therefore stays 4:3 landscape — the sensor's own // orientation, with no display rotation baked in assertTrue( diff --git a/csdk/include/mag160c/mag160c_render.h b/csdk/include/mag160c/mag160c_render.h index b3dd878..6d57d4f 100644 --- a/csdk/include/mag160c/mag160c_render.h +++ b/csdk/include/mag160c/mag160c_render.h @@ -61,12 +61,13 @@ typedef struct mag160c_render_cfg_t { /* Brightness offset for the Android LUT base; the official app passes 0. */ int lut_brightness; /* 1 = replicate pixels in the 160x120 -> 320x240 stage instead of interpolating. - The official ANDROID path has no such stage at all (its SDK hands the app a - 160x120 bitmap which ImageViewer draws with a non-filtering paint = nearest), - while the PC SDK's bilinear version fabricates an interleaved full/half - amplitude column pattern at the sensor Nyquist - visible as extra fine stripes - in the dark/cold regions. Measured on device: fine-scale cold-region stripe - ratio vs the official 1.16-1.29x (bilinear) -> 1.00/0.93 (nearest). */ + Measured on device, this is the WRONG trade: replication halves the effective + resolution (2-sensor-pixel granules, a coarse staircase along hot/cold edges, + reported by the user), while the interpolation only adds a faint full/half + amplitude column pattern at the sensor Nyquist. A consumer that can draw + gray160 directly (like the Android app's photo path: one 6x bilinear step from + the sensor grid) needs no intermediate stage at all and gets both right. + Kept as a switch for experiments; leave 0. */ int upscale_nearest; mag160c_render_ffc_cb_t ffc_cb; void *ffc_user; diff --git a/csdk/tools/mag160c_demo3.c b/csdk/tools/mag160c_demo3.c index 7890069..85a0289 100644 --- a/csdk/tools/mag160c_demo3.c +++ b/csdk/tools/mag160c_demo3.c @@ -618,7 +618,12 @@ int WINAPI WinMain(HINSTANCE inst, HINSTANCE prev, LPSTR cmd, int show) { cfg.cdf_pivot_75 = 1; /* kept for the PC path */ cfg.android_lut = 1; cfg.fpa_temp_fix = 1699; /* BasePara2.fpaTempFix of device 160043865 */ - cfg.upscale_nearest = 1; /* official Android path has no interpolating 2x stage */ + /* Keep the interpolating 2x: the nearest variant halves the effective + resolution (2-sensor-pixel granules / a coarse staircase on hot-cold edges), + which was visible on device. A display path that consumes gray160 directly + needs no intermediate stage at all - that is what the Android app now does + for its photos (single 6x step from the sensor grid). */ + cfg.upscale_nearest = 0; cfg.ffc_cb = ffc_cb; cfg.post_nuc_cb = post_nuc_cb; cfg.post_gray_cb = post_gray_cb; diff --git a/csdk/tools/mag160c_linux_demo.c b/csdk/tools/mag160c_linux_demo.c index f72f10c..463f170 100644 --- a/csdk/tools/mag160c_linux_demo.c +++ b/csdk/tools/mag160c_linux_demo.c @@ -134,7 +134,12 @@ int main(int argc, char **argv) { cfg.cdf_pivot_75 = 1; /* kept for the PC path */ cfg.android_lut = 1; cfg.fpa_temp_fix = 1699; /* BasePara2.fpaTempFix of device 160043865 */ - cfg.upscale_nearest = 1; /* official Android path has no interpolating 2x stage */ + /* Keep the interpolating 2x: the nearest variant halves the effective + resolution (2-sensor-pixel granules / a coarse staircase on hot-cold edges), + which was visible on device. A display path that consumes gray160 directly + needs no intermediate stage at all - that is what the Android app now does + for its photos (single 6x step from the sensor grid). */ + cfg.upscale_nearest = 0; cfg.ffc_cb = ffc_cb; mag160c_render_t *render = NULL; if (mag160c_render_init(&render, &cfg) != MAG160C_OK) { diff --git a/docs/android_app/rendering_diff_phone_20261006.md b/docs/android_app/rendering_diff_phone_20261006.md index 7579b2f..fc2f653 100644 --- a/docs/android_app/rendering_diff_phone_20261006.md +++ b/docs/android_app/rendering_diff_phone_20261006.md @@ -566,3 +566,36 @@ JPEG 只能解释一部分:把我们那张(960×720 q95,202 KB)按 640× - `RenderPipeline.upscaleNearest`(构造参数,实时会话 = true;默认 false 保持 C 参考字节不变); - csdk:`mag160c_render.h/.c` 增加 `upscale_nearest`(两个 demo 默认 1),同一段注释说明依据; - 单测新增 `UpscaleParityTest`(两种模式必须不同 + 最近邻必须逐行复制),共 **119 全绿**。 + +## 15. 用户复核:"我们颗粒更大,尤其高低温分界边沿"——是我上一轮 14 节的最近邻改错了 + +用户:"我们的app图片看起来像素颗粒更大啊,特别是高低温分界的边沿,是不是啥滤波没开"。 + +### 15.1 眼睛 + 数据都指向同一个原因 + +17:51 同尺寸对照(左=我们 17:06 版,右=官方):**我们的边沿是 2 个传感器像素一级的台阶**, +官方是**单像素**的平滑过渡——即 14 节里"最近邻 2×"把有效分辨率砍半了(相邻两个传感器像素被复制成同一个值)。 +定量复核:冷区 `|dx|` 比值 1.27(我们更毛)。 + +反汇编/源码再核对:官方 `ImageViewer` 除 `new Paint(1)` 外还有 `PaintFlagsDrawFilter pfd_` +(会**加上** FILTER_BITMAP)→ 它的显示/保存链其实是**从 160×120 单级滤波放大** +(`cachedbmp_` 640×480),既没有交错也没有块状复制。 + +### 15.2 修法(照片改为单级放大;实时缓冲回退到插值) + +- **照片**:`PhotoSaver.SENSOR_W/H` 改为 **160×120**(原生传感器网格,与探针/NUC 同一空间), + `RENDER_SCALE = 6`(输出仍是 960×720);调用方交入的帧改为**精确取样** 320×240 缓冲的偶数像素 + (`o[2x]=src[x]`,所以偶数采样就是原始像素,无重采样误差)。→ **单级 6× 双线性**, + 传感器像素级颗粒、无交错、无块状。 +- **实时缓冲**:`upscaleNearest` 回退为 **false**(插值 2×):最近邻会把颗粒变成 2 传感器像素、 + 边沿出现粗台阶,是可见的退化。csdk 的两个 demo 同步回退为 0,选项保留并写明测得的教训。 + +### 15.3 实测(同场景:我们 17:54 / 官方 17:56,翻转对齐 r=0.994) + +| 指标 | 官方 | 我们(新) | +|---|---|---| +| 索引分位 p5/25/50/75/95 | 21 / 48 / 76 / 90 / 224 | 18 / 47 / 78 / 92 / 224 | +| 冷区 `\|dx\|` | 4.32 | **3.29(比值 0.76)** | + +→ 修好后我们**比官方更平滑**(官方因最近邻 4× 反而有 4 像素大块,见 x8 放大对照图)。 +单测 119 全绿(`PhotoNucMappingTest` 更新为 160×120→960×720 的新策略)。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index b1bcead..c3f66fe 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,28 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 +## 用户反馈修复 第三十六轮(2026-10-06 夜,"我们颗粒更大"→ 照片改单级放大,实时回退插值) + +用户:"我们的app图片看起来像素颗粒更大啊,特别是高低温分界的边沿,是不是啥滤波没开。" + +### 定位 + +14 节的"最近邻 2×"把**相邻两个传感器像素复制成同一个值** → 有效分辨率砍半 → +边沿变成 2 像素台阶(用户看到的"颗粒更大")。再核对官方源码:`ImageViewer` 还有 +`PaintFlagsDrawFilter pfd_`(**加** FILTER_BITMAP)→ 官方是从 160×120 **单级滤波放大**。 + +### 修法 + +- 照片:`PhotoSaver.SENSOR_W/H = 160×120`、`RENDER_SCALE = 6`(输出仍 960×720), + 调用方交入偶数采样的精确 160×120 帧 → **单级 6× 双线性**(无交错、无块状); +- 实时:`upscaleNearest = false`(回退插值 2×,颗粒回到 1 传感器像素); +- csdk:两个 demo 的 `upscale_nearest` 回退 0,选项保留并写明教训。 + +### 实测(同场景 17:54 vs 17:56,对齐 r=0.994) + +分位 18/47/78/92/224 vs 官方 21/48/76/90/224;冷区 `|dx|` **3.29 vs 4.32(0.76)**—— +修好后我们比官方更平滑。单测 119 全绿(`PhotoNucMappingTest` 已按新策略更新)。 + ## 用户反馈修复 第三十五轮(2026-10-06 夜,"低温区条纹更多":定位为我们自造的 2× 交错并修复) 用户:"你依据反汇编出来的东西进行修复"。