From 4a3916a2b7b0ee49c333decc82db0043c705b06e Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Tue, 6 Oct 2026 17:09:00 +0800 Subject: [PATCH] =?UTF-8?q?app+csdk+docs:=20=E6=8C=89=E5=8F=8D=E6=B1=87?= =?UTF-8?q?=E7=BC=96=E5=AE=9A=E6=A1=88=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E5=AE=89=E5=8D=93=E8=B7=AF=E5=BE=84=E6=97=A0?= =?UTF-8?q?=20160=E2=86=92320=20=E6=8F=92=E5=80=BC=E7=BA=A7=EF=BC=88ImageV?= =?UTF-8?q?iewer=20=E6=97=A0=20FILTER=5FBITMAP=20=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E9=82=BB=E6=94=BE=E5=A4=A7=EF=BC=89=EF=BC=8C=E6=88=91=E4=BB=AC?= =?UTF-8?q?=E7=9A=84=E5=8F=8C=E7=BA=BF=E6=80=A7=202=C3=97=20=E8=87=AA?= =?UTF-8?q?=E9=80=A0=E4=BA=A4=E9=94=99=E7=BB=86=E7=BA=B9=EF=BC=9B=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E5=86=B7=E5=8C=BA=E7=BB=86=E6=9D=A1=E7=BA=B9=E6=AF=94?= =?UTF-8?q?=201.16/1.29=E2=86=921.00/0.93=EF=BC=8Ccsdk=20=E5=90=8C?= =?UTF-8?q?=E5=8A=A0=20upscale=5Fnearest=EF=BC=8C119=20=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 --- .../mag160c/thermal/core/RenderPipeline.kt | 31 +++++++++ .../com/mag160c/thermal/usb/IrSession.kt | 4 ++ .../thermal/core/AndroidLutPipelineTest.kt | 63 +++++++++++++++++++ csdk/README.md | 4 ++ csdk/include/mag160c/mag160c_render.h | 8 +++ csdk/src/mag160c_render.c | 16 ++++- csdk/tools/mag160c_demo3.c | 1 + csdk/tools/mag160c_linux_demo.c | 1 + .../rendering_diff_phone_20261006.md | 38 +++++++++++ docs/android_app/session_state.md | 23 +++++++ 10 files changed, 188 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt index 685431b..5a75808 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt @@ -68,6 +68,23 @@ class RenderPipeline( * they were baselined on. */ var androidLut: Boolean = false, + /** + * Replicate pixels instead of interpolating in the 160x120 -> 320x240 stage. + * + * The official ANDROID path has no such interpolation: its SDK hands the app a + * 160x120 bitmap (`ImageViewer` allocates `Bitmap.createBitmap(fpaWidth, + * fpaHeight)`) and the app draws it with `new Paint(1)` - anti-alias only, no + * FILTER_BITMAP - i.e. nearest-neighbour. The bilinear version we ported from + * the PC SDK fabricates an interleaved full/half-amplitude pattern at the + * sensor's Nyquist frequency (even columns keep the sample, odd columns get the + * neighbour average), which reads as extra fine stripes exactly where the + * palette is steepest: the dark/cold regions. Measured on device: fine-scale + * column-stripe ratio vs the official 1.16-1.29x with bilinear, wide-scale + * (real FPN) 0.87-0.55x. + * + * false keeps the PC-reference bytes for the C-parity golden tests. + */ + var upscaleNearest: Boolean = false, /** * `BasePara2.fpaTempFix`, added to the frame-tail FPA before the per-pixel * calibration tables are interpolated (the official app passes @@ -617,6 +634,20 @@ class RenderPipeline( val W2 = W * 2 val s = gray160 val o = gray320 + if (upscaleNearest) { + for (y in 0 until H) { + val r = y * W + val o0 = y * W2 * 2 + val o1 = o0 + W2 + for (x in 0 until W) { + val v = s[r + x] + val c = x * 2 + o[o0 + c] = v; o[o0 + c + 1] = v + o[o1 + c] = v; o[o1 + c + 1] = v + } + } + return + } for (y in 0 until H - 1) { val r0 = y * W val r1 = r0 + W 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 bcec040..cdbf8f5 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,6 +324,10 @@ 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, // 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/core/AndroidLutPipelineTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt index 1c00bea..6a955ef 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt @@ -192,3 +192,66 @@ class MdtRawNucPairTest { return cov / kotlin.math.sqrt(va * vb) } } + +/** + * The 160x120 -> 320x240 stage must not fabricate an interleaved pattern when the + * official-parity path is on: the official Android display draws the native 160x120 + * bitmap with a non-filtering paint (nearest), so a bilinear 2x upscale would add a + * full/half-amplitude column pattern that the official image does not have - most + * visible in the dark (cold) regions where the palette is steepest. + */ +class UpscaleParityTest { + private fun res(name: String): ByteArray = + javaClass.classLoader.getResourceAsStream(name)!!.readBytes() + + private fun frame(counter: Int): ByteArray { + val f = ByteArray(0x38 + 38400) + MagProtocolBytes.putU32(f, 0, 0x1BB1B11B) + MagProtocolBytes.putU32(f, 4, counter) + MagProtocolBytes.putU32(f, 8, 38400) + MagProtocolBytes.putU32(f, 12, 0) + for (i in 0 until 19200) { + // smooth ramp + a little structure so the LUT has spread + val x = i % 160 + val y = i / 160 + MagProtocolBytes.putU16(f, 0x1C + i * 2, 11500 + x * 4 + y * 3) + } + MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C) + MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, 19000) + return f + } + + private fun render(nearest: Boolean): IntArray { + val p = RenderPipeline( + w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2, + force75 = false, androidLut = true, upscaleNearest = nearest, + onFfc = {}, + ) + assertTrue("DDT must load", p.loadDdt(res("mag160c_official.ddt"))) + val out = IntArray(320 * 240) + var c = 0 + while (c < 200) { + if (p.frame(frame(c), true, out)) return out + c++ + } + throw AssertionError("never rendered") + } + + @Test + fun nearestUpscaleReplicatesPixelPairs() { + val nearest = render(true) + val bilinear = render(false) + assertFalse("the two upscale modes must differ", nearest.contentEquals(bilinear)) + // with nearest, row pairs are identical; with bilinear they are not + var sameRows = 0 + var sameRowsBilinear = 0 + for (y in 0 until 239 step 2) { + for (x in 0 until 320) { + if (nearest[y * 320 + x] == nearest[(y + 1) * 320 + x]) sameRows++ + if (bilinear[y * 320 + x] == bilinear[(y + 1) * 320 + x]) sameRowsBilinear++ + } + } + assertTrue("nearest must replicate every second row ($sameRows/38240)", + sameRows > 320 * 119) + } +} diff --git a/csdk/README.md b/csdk/README.md index 73f6c2c..3c4e08b 100644 --- a/csdk/README.md +++ b/csdk/README.md @@ -301,6 +301,10 @@ build-artifacts\csdk_test_display.exe 同场景照片逐像素平均差 4.5/255、逐计数中位差 2 级,见 §10/§11)。 ⚠️ **本机没有 C 编译器**(gcc/clang/NDK 都没有),所以这段 C **未编译**—— 算法本身由 Kotlin golden 测试锁定,合入前请在带编译器的环境编译 + 跑回归。 + - 同一节还有个 `upscale_nearest`:官方**安卓**路径没有 160→320 的插值级 + (SDK 给 App 的位图就是 160×120,App 用 `new Paint(1)`(无 FILTER_BITMAP)最近邻放大), + 而 PC 版的双线性会展出一层"全幅/半幅"交错细纹(暗/低温区最明显)。 + 真机实测:冷区细尺度列条纹比 1.16/1.29(双线性)→ **1.00/0.93**(最近邻)。 - 顺带(同一条链):**曲线插值温度要加 `BasePara2.fpaTempFix`**。官方 App 传给 SDK 的是 `帧尾 FPA + fpaTempFix`(真机 1699),`SelectCaliCurve`/`GenerateVolatileLens` 用它选/插值每像素标定表;温度换算链路仍用帧尾原值。C 侧**已实现** diff --git a/csdk/include/mag160c/mag160c_render.h b/csdk/include/mag160c/mag160c_render.h index db7a386..b3dd878 100644 --- a/csdk/include/mag160c/mag160c_render.h +++ b/csdk/include/mag160c/mag160c_render.h @@ -60,6 +60,14 @@ typedef struct mag160c_render_cfg_t { int fpa_temp_fix; /* 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). */ + int upscale_nearest; mag160c_render_ffc_cb_t ffc_cb; void *ffc_user; mag160c_render_post_nuc_cb_t post_nuc_cb; diff --git a/csdk/src/mag160c_render.c b/csdk/src/mag160c_render.c index 79913b1..b42f797 100644 --- a/csdk/src/mag160c_render.c +++ b/csdk/src/mag160c_render.c @@ -59,7 +59,7 @@ struct mag160c_render_t { mag160c_render_post_gray_cb_t post_gray_cb; void *post_gray_user; int ffc_period, ffc_drift, warm_frames, cdf_pivot_75, force75; - int android_lut, fpa_temp_fix, lut_brightness; + int android_lut, fpa_temp_fix, lut_brightness, upscale_nearest; }; static int counts_to_temp_mc(int counts) { @@ -524,6 +524,19 @@ static void upscale2x(mag160c_render_t *r) { const unsigned char *s = r->gray160; unsigned char *o = r->gray320; int W = r->w, H = r->h, W2 = W * 2; + if (r->upscale_nearest) { + /* Android parity: no interpolation stage (see mag160c_render.h) */ + for (int y = 0; y < H; ++y) { + const unsigned char *row = s + y * W; + unsigned char *o0 = o + y * W2 * 2, *o1 = o0 + W2; + for (int x = 0; x < W; ++x) { + unsigned char v = row[x]; + o0[x * 2] = v; o0[x * 2 + 1] = v; + o1[x * 2] = v; o1[x * 2 + 1] = v; + } + } + return; + } for (int y = 0; y < H - 1; ++y) { const unsigned char *r0 = s + y * W, *r1 = r0 + W; unsigned char *o0 = o + y * W2 * 2, *o1 = o0 + W2; @@ -667,6 +680,7 @@ mag160c_error_t mag160c_render_init(mag160c_render_t **out, r->android_lut = cfg->android_lut; r->fpa_temp_fix = cfg->fpa_temp_fix; r->lut_brightness = cfg->lut_brightness; + r->upscale_nearest = cfg->upscale_nearest; r->force75 = cfg->startup_force_ffc; r->dev24 = 5; r->dev4c_shift = 3; diff --git a/csdk/tools/mag160c_demo3.c b/csdk/tools/mag160c_demo3.c index 2a79977..7890069 100644 --- a/csdk/tools/mag160c_demo3.c +++ b/csdk/tools/mag160c_demo3.c @@ -618,6 +618,7 @@ 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 */ 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 36f9e5a..f72f10c 100644 --- a/csdk/tools/mag160c_linux_demo.c +++ b/csdk/tools/mag160c_linux_demo.c @@ -134,6 +134,7 @@ 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 */ 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 88f5510..7579b2f 100644 --- a/docs/android_app/rendering_diff_phone_20261006.md +++ b/docs/android_app/rendering_diff_phone_20261006.md @@ -528,3 +528,41 @@ JPEG 只能解释一部分:把我们那张(960×720 q95,202 KB)按 640× 验证:临时把 `fpaTempFix = 0` 出两版,量冷区 HF(同样一张场景、同样流程)。 2. band0 的 gain 插值本身与 PC 参考路径的差异(PC 端 NUC 逐字节一致,说明差异在 Android 的 `GenerateVolatileLens` 重生成路径,而非表本身)。 + +## 14. 定案并修复:"低温区条纹更多"= 我们自造的 2× 交错图案(反汇编依据 + 实测验证) + +### 14.1 依据(官方安卓路径没有插值级) + +`ImageViewer`(官方 App,jadx 源码): +- `this.bmpIr_ = Bitmap.createBitmap(fpaWidth, fpaHeight, ARGB_8888)`(**160×120**,即 SDK 直接给原生尺寸); +- `this.paint_ = new Paint(1)` —— 只有 ANTI_ALIAS,**没有 FILTER_BITMAP**;离屏 `cachedbmp_ = Bitmap.createBitmap(640, 480)` 就用这些 paint 画进去(= **最近邻**)。 + +而我们的 `upscale2x`(移植自 PC SDK)是**双线性**:偶列取原样本、奇列取邻均值 → +在传感器 Nyquist 上**人为造出一层"全幅/半幅"交错的细竖条纹**,且**只在色带最陡的暗/低温区**最扎眼。 + +### 14.2 实测验证(同场景 A/B,冷区=最冷 25% 像素) + +| 冷区指标(照片,160×120 网格) | 修复前(双线性 2×) | 修复后(最近邻 2×) | +|---|---|---| +| 细尺度列条纹(去趋势宽 5)之比 | 1.16 ✗ | **1.00 ✔** | +| 细尺度列条纹(去趋势宽 9)之比 | 1.29 ✗ | **0.93 ✔** | +| 宽尺度列条纹(21 / 41)之比 | 0.90 / 0.87 ✔ | 0.82 / 0.72 ✔ | +| 列剖面相关性(同一 FPN 图案) | +0.815 | +0.832 ✔ | +| \|dx\| HF 之比 | 1.27 | 1.67(见 14.3) | + +→ **用户报的"细条纹"就是这层自造交错**:去掉后细尺度条纹与官方一致(1.00/0.93), +而真实 FPN(宽尺度)我们本来就比官方更轻。 + +### 14.3 顺带说明:HF 比值反而变大不是变差 + +重建后的 `|dx|` HF 从 1.27 升到 1.67,是因为**旧的交错本身就是一次列方向的平滑** +(奇列被平均掉一半),它把这个指标"压低"了——去掉假平滑后露出的是真实逐像素噪声。 +即旧值 1.27 是"假图案 + 被平滑的噪声"的合成,新值是纯噪声。逐像素噪声的差异 +(同一场景、同一映射下)仍待查,方向已收窄到"照片路径的 JPEG/缩放"与"设备帧噪声"两者, +方法学与数据见 §12/§13。 + +### 14.4 落地 + +- `RenderPipeline.upscaleNearest`(构造参数,实时会话 = true;默认 false 保持 C 参考字节不变); +- csdk:`mag160c_render.h/.c` 增加 `upscale_nearest`(两个 demo 默认 1),同一段注释说明依据; +- 单测新增 `UpscaleParityTest`(两种模式必须不同 + 最近邻必须逐行复制),共 **119 全绿**。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 52c1cea..b1bcead 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,29 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 +## 用户反馈修复 第三十五轮(2026-10-06 夜,"低温区条纹更多":定位为我们自造的 2× 交错并修复) + +用户:"你依据反汇编出来的东西进行修复"。 + +### 依据(官方安卓路径没有插值级) + +`ImageViewer`:`Bitmap.createBitmap(fpaWidth,fpaHeight)` = **160×120** 原生尺寸, +`new Paint(1)`(仅抗锯齿、**无 FILTER_BITMAP**)+ 离屏 640×480 → **最近邻**放大。 +我们的 `upscale2x`(PC SDK 移植)是双线性(偶列原值、奇列邻均值)→ 在传感器 Nyquist 上 +自造"全幅/半幅"交错细竖纹,恰好在暗/低温区最显眼。 + +### 实测(同场景 A/B,冷区=最冷 25%) + +细尺度列条纹比(我们/官方):**1.16 / 1.29(双线性)→ 1.00 / 0.93(最近邻)** ✔; +宽尺度(真实 FPN)0.87/0.90 → 0.72/0.82(我们本来更轻 ✔);列剖面相关性 0.815→0.832。 +`|dx|` HF 比 1.27→1.67 是**旧交错充当了列平滑**所致(假图案被去掉后露出真实噪声), +不是变差;逐像素噪声差异的排查见 §12/§13。 + +### 落地 + +`RenderPipeline.upscaleNearest`(实时 true,默认 false 保 C 参考字节不变); +csdk 增加 `upscale_nearest`(两个 demo 默认 1);新增 `UpscaleParityTest`;**119 单测全绿**。 + ## 用户反馈修复 第三十四轮(2026-10-06 晚,"低温区条纹更多":冷区噪声超预算 1.65×,定位中) 用户:"不对吧,还是有差别,我换了个场景,背景的低温区域在我们的app里面明显条纹更多"。