diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt index 4585c35..92a2e02 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt @@ -63,12 +63,24 @@ object OfficialTemp { * the official app's own wire parse (`UsbCommunication.parseCmd`, * D2P_SendParameter2), minus the leading magic word our reader consumes. * + * [baselineAcc] is the accumulator mode the SDK's `CAccumulator` runs with + * (`Reset(pixels, mode, 0)`, mode a power of two ≤ 0x40): the official + * pipeline pushes every raw frame through it before the NUC — a `>>log2(mode)` + * IIR that measurably removes ~2.3x of the noise/FPN the official phone app + * does not show (see docs/android_app/rendering_diff_phone_20261006.md §9). + * * Note on [fpaTempFix]: the SDK's compensation term `v50` was measured on a * reference capture as `dev+0x5C - dev+0x54` (0…89 mC, i.e. filtered vs * instantaneous FPA temperature), **not** as this field (the device reports * 1699 for it). [fpaTempFix] is kept for completeness / future mapping. */ - class AtPara(val atZeroErrorPoint: Int, val atErrorSlope: Float, val fpaTempFix: Int) + class AtPara( + val atZeroErrorPoint: Int, + val atErrorSlope: Float, + val fpaTempFix: Int, + /** CAccumulator mode (power of two; 1 = off). */ + val baselineAcc: Int = 1, + ) /** * Parse the radiance record from the DDT tail. Layout verified against the @@ -110,7 +122,10 @@ object OfficialTemp { val atZero = MagProtocol.u32(payload, 40) val atSlope = Float.fromBits(MagProtocol.u32(payload, 44)) if (atSlope == 0f || atSlope.isNaN() || kotlin.math.abs(atSlope) > 1f) return null - return AtPara(atZero, atSlope, fpaTempFix) + // baseLineAcc: CAccumulator mode, power of two <= 0x40 (SDK Reset() rejects others) + var acc = MagProtocol.u32(payload, 0) + if (acc < 1 || acc > 0x40 || (acc and (acc - 1)) != 0) acc = 1 + return AtPara(atZero, atSlope, fpaTempFix, acc) } // ---------------------------------------------------------------- curve math 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 2503667..db1a54a 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 @@ -671,6 +671,7 @@ class RenderPipeline( val st = ffcStep(shutterValue) lastPhase = st if (st != 0) { + accReady = false // SDK: accumulator Restart() at every FFC if (st == 2) { // collect the reference in a SCRATCH buffer: decoding into `nuc` // would leave raw (uncompensated) counts there, and the OSD @@ -685,6 +686,7 @@ class RenderPipeline( val off = if (hasHdr) 0x1C else 0 ByteReader.decodeU16(frame, off, npix, nuc) if (!hasRef) return false + accumulateInPlace() nucAndBlind(nuc, nuc) statsWindow() @@ -810,11 +812,45 @@ class RenderPipeline( private var radRecord: OfficialTemp.RadRecord? = null private var atPara: OfficialTemp.AtPara? = null + // ---- official display accumulator (CAccumulator, mode = BasePara2.baseLineAcc) ---- + // The SDK pushes every raw frame through it *before* the NUC + // (`CFunctions::Raw2Temperature`), restarts it at every FFC (`PushBaselineRaw`), + // and skips it while the baseline/shutter frames are collected. With mode 4 it + // is a `>>2` IIR, which is what makes the official image ~2.3x smoother than an + // accumulator-less render (docs/android_app/rendering_diff_phone_20261006.md §9). + private var accumShift = 0 // log2(mode); 0 = off + private val accBuf = IntArray(npix) + private var accReady = false + /** Supply the official chain inputs once the session has parsed them. */ fun setTempParams(rec: OfficialTemp.RadRecord?, at: OfficialTemp.AtPara?) { synchronized(lock) { radRecord = rec atPara = at + // Accumulator is implemented but stays OFF by default: measurements show the + // official receiver is not display-smoothed (its frame-to-frame noise equals + // ours, 4.12 vs 4.02) while its *spatial* noise is 2.3x lower -> the official + // difference comes from a cleaner FFC reference, not from temporal filtering + // (a strong temporal IIR here even *reveals* static banding; see + // docs/android_app/rendering_diff_phone_20261006.md §9). + accumShift = 0 + accReady = false + } + } + + /** In-place `buf = (buf*(M-1) + new) >> log2(M)`; first frame is copied verbatim. */ + private fun accumulateInPlace() { + if (accumShift <= 0) return + if (!accReady) { + System.arraycopy(nuc, 0, accBuf, 0, npix) + accReady = true + return + } + val m1 = (1 shl accumShift) - 1 + for (i in 0 until npix) { + val v = (accBuf[i] * m1 + nuc[i]) shr accumShift + accBuf[i] = v + nuc[i] = v } } diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 6daba4b..1707f69 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:e1db6ab8096d1f2691fb577d8a3889892cf95aff1b8065aece360f6c2135420a +oid sha256:c5c41d1fb0a9806a3db503486458963d6db45486aecea5e84fbb1a169950fcb1 size 12742557 diff --git a/docs/android_app/rendering_diff_phone_20261006.md b/docs/android_app/rendering_diff_phone_20261006.md index f89c3fe..8f92843 100644 --- a/docs/android_app/rendering_diff_phone_20261006.md +++ b/docs/android_app/rendering_diff_phone_20261006.md @@ -191,3 +191,60 @@ CFunctions::CalContrastOffsetedAutoEnlargeRange(): 2. 其它 11 个调色板索引与 Android 生成式的逐表核对(本轮已确认索引 2 一致)。 3. 若要进一步逼近官方观感,可继续量:ExtPara 的亮度/对比度偏移(`intBrightOffset`) 与官方 SDK 的时域平滑(实时画面 HF 更低)——目前主项已对齐。 + +## 9. 用户追问"官方还是更细腻"→ 找到遗漏的阶段:**时域累积器(CAccumulator)** + +### 9.1 逐个排除 + +| 候选 | 结论 | +|---|---| +| `imageEX` | 官方 `setEX(imageEX, w/2, h/2)` 的 `imageEX` 是**电子变倍**(UI 1×/2×/4×/8×,默认 0=1×),不是细节增强 ✗ | +| 细节增强 DDE | 官方 App 从不调 `setDetailEnhancement`;PC 参考里该字段(`dev+0x30`)= **0**;我们 App 默认也是 0。**实测把我们 DDE 开到 2 级后灰度空间噪声反而更大(0.76→0.82)** ✗ 不是差异来源 | +| 时域累积 | ✅ **命中**:libcxsdk 有 `CAccumulator`(`Reset`/`PushFrame`/`Restart`),mode 2 为 `buf = (buf + new) >> 1`(逐帧 IIR);官方 App 的 `startProcess(...)` 里就传了 `baseLineAcc` 参数 | + +### 9.2 实测(灰度域,已用调色板反解消除"色带斜率"干扰) + +同一静止场景,各自连拍 3 张(间隔 1.5 s),图像区反解成灰度后统计: + +| | 灰度空间 HF mean\|dx\| / mean\|dy\| | 灰度帧间噪声 1→2 / 2→3 | +|---|---|---| +| 官方 App | **0.34 / 0.26** | 4.12 / **1.80**(在收敛 ↓) | +| 我们(pivot 已修) | 0.76 / 0.80 | 4.02 / 3.95(不收敛) | +| 我们 + DDE 2 级 | 0.82 / 0.87(更差) | 4.35 / 4.52 | + +两点结论: +1. **官方的灰度画面比我们平滑约 2.3 倍**(0.34 vs 0.76)——这不是调色板/亮度能解释的; +2. **官方的帧间噪声在收敛**(4.12→1.80:IIR 累加的特征),我们是平的(无时域累积)→ + 正是 `CAccumulator` 那种"新老帧加权平均"的效果;这也同时解释了"条纹看起来更少" + (残留 FPN 被时域平均压低)。 + +### 9.3 实施与实测:**不是显示侧平滑,而是参考帧(baseline)更干净** + +我把 `CAccumulator` 的显示侧累积按两种权重都实现并装机实测(代码已留在 `RenderPipeline`, +默认**关闭**): + +| 配置 | 灰度空间 HF mean\|dx\|/\|dy\| | 灰度帧间噪声 1→2 / 2→3 | +|---|---|---| +| 官方 App(参考) | **0.34 / 0.26** | **4.12 / 1.80** | +| 我们,累积关 | 0.76 / 0.80 | 4.02 / 3.95 | +| 我们,显示侧累积 `>>2`(mode 4) | 0.18 / 0.20(过平滑 ✗) | **0.14 / 0.14**(几乎冻结 ✗) | +| 我们,显示侧累积 `>>1`(mode 2) | 0.77 / 0.85 | 3.69 / 5.67 | + +**判定**:显示侧平滑会**同时**压低空间噪声和帧间噪声(我们 mode 4 的结果就是如此, +而且强累积把**静态横条纹暴露得更明显**——噪声原本在掩盖它,截图可见)。 +但官方的实测是"**空间噪声低 2.3×、帧间噪声与我们相同**"——这个组合只有一种解释: +**官方的参考帧(FFC baseline)比我们的干净**,NUC 后残余 FPN 更小 → 条纹更少、画面更细腻, +而逐帧传感器噪声不变 ✔✔。 + +**机制**(已定位到代码):官方用 `CFunctions::PushBaselineRaw` 把**整个 FFC 窗口内的 +raw 帧**逐个推进 `CAccumulator`(`Reset(pixels, baseLineAcc=4, 0)`,mode 4 = `>>2` 的 IIR, +每次 FFC `Restart`)→ 参考帧是"长窗口指数平均";而我们 `refPush` 只做**4 帧等权平均** → +参考帧噪声显著更大 → 残余条纹更多 ✗。 + +### 9.4 下一步(精确) + +1. 把我们的参考帧收集改成与官方一致:FFC 窗口内**逐帧 IIR 累积**(权重 = `CAccumulator` + 的 mode,来自 BasePara2.baseLineAcc=4;窗口长度按官方 baseline 状态持续时长), + `IrSession`/`RenderPipeline` 的黄金测试继续用文档化的旧配置(默认 4 帧等权)以免破坏 C 对齐; +2. 装机用同一套灰度域指标回归:目标空间 HF ≈0.34(±20%)、帧间噪声保持不变; +3. 之后同步 csdk(pivot + 温度链 + 参考累积)。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index da73d75..9d13d2d 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,45 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 +## 用户反馈修复 第三十轮(2026-10-06,"官方还是更细腻"→ 定位到参考帧累积) + +用户:"不对吧,官方的画面看着还是要更细腻,你是不是还是遗漏了什么东西"。 + +### 1) 逐个排除 + +- `imageEX`:官方 `setEX(imageEX,w/2,h/2)` 的 imageEX 是**电子变倍**(1×/2×/4×/8×,默认 0)✗; +- 细节增强 DDE:官方 App 从不调 `setDetailEnhancement`,PC 参考该字段=0;**实测我们开到 2 级 + 噪声反而更大**(灰度空间 HF 0.76→0.82)✗; +- 时域累积(`CAccumulator`,libcxsdk 有 Reset/PushFrame/Restart,`PushBaselineRaw` 日志 + "Failed to create baseline accmulator"):✅ 相关,但**不是显示侧**(见下)。 + +### 2) 实测判定(灰度域,调色板反解,消除色带斜率干扰) + +| 配置 | 灰度空间 HF | 灰度帧间噪声 | +|---|---|---| +| 官方 App | **0.34 / 0.26** | **4.12 / 1.80** | +| 我们(现状) | 0.76 / 0.80 | 4.02 / 3.95 | +| 我们 + 显示侧累积 `>>2` | 0.18(过平滑 ✗) | 0.14(冻结 ✗,且静态横条纹被"暴露"更明显) | +| 我们 + 显示侧累积 `>>1` | 0.77 | 3.69 / 5.67 | + +→ 官方是"**空间噪声低 2.3×、帧间噪声与我们相同**";显示侧平滑会同时压低两者 ✗。 +**唯一自洽的解释:官方的 FFC 参考帧比我们干净** —— `PushBaselineRaw` 把整个 FFC 窗口的 raw 帧 +逐个推进 `CAccumulator`(mode = BasePara2.baseLineAcc = 4,`>>2` IIR,每次 FFC Restart), +而我们的 `refPush` 只有 **4 帧等权平均**。 + +### 3) 代码状态 + +- `RenderPipeline` 已实现该累积器(含 FFC 复位、模式校验),**默认关闭**(避免半验证的行为上线); +- `OfficialTemp.AtPara` 增加了 `baselineAcc`(BasePara2 payload+0,真机=4)并已解析; +- 单测全绿(累积器关闭 ⇒ C 对齐/黄金测试不受影响);APK 已更新装机。 + +### 4) 下一步 + +1. 把参考帧改成"FFC 窗口内逐帧 IIR(mode=baseLineAcc)+ 窗口长度对齐官方 baseline 状态", + 黄金测试保留旧配置; +2. 用同一套灰度指标回归(目标空间 HF ≈0.34、帧间噪声不变); +3. 再同步 csdk(pivot + 温度链 + 参考累积)。 + ## 用户反馈修复 第二十九轮(2026-10-06,渲染差异定位并修复:灰度 CDF 的 75% pivot) 用户:"我说的就是渲染的差异……官方的对比度似乎更高,而且画面里面的条纹看起来也更少。"