Files
MAG160C/docs/android_app/rendering_diff_phone_20261006.md
T

769 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 手机端渲染差异定位(Android SDK ↔ Windows/Linux SDK 交叉验证)2026-10-06
> 背景:相机改插 root 手机(22041211AC,Android 12 + Magisk)后,按用户要求
> "官方 App 拍照 vs 我们的 App 拍照" 做对比,出现差异就**同时**逆 Android SDK 与
> Linux/Win SDK 交叉验证。本轮结论:**亮度/对比度差异的根因是"窗口半宽 = 基础窗口 ×
> 对比度修正",官方手机 App 带对比度偏移,我们的移植只在 c=0 时一致。**
## 1. 对比做法与结果
| 项 | 官方 App(cn.com.magnity.magnitycx) | 我们的 App |
|---|---|---|
| 照片存放 | `/sdcard/magnity/Cx/media/MAG-160CORE<时间>.jpg`(+ `others/` 里是 DDT) | `/sdcard/DCIM/MAG160C/MAG160C_<时间>.jpg` |
| 照片内容 | **纯 JPEG(无任何数据块,EOI 即文件尾)** | 完整 MDT(INFO0/INFO1/FRAME/NUC/RENDER/EXTREMES) |
| 亮度(同场景) | luma p25=40 p50=49 mean=84 | p25=10 p50=19 mean=53 |
| 调色板索引分布(按 16 档) | 峰值在 2–4 档(10%/40%/12%) | 峰值在 0–1 档(15%/39%) |
→ 同一静止场景,**我们的图整体偏暗约 30 luma**:热点位置一致、调色板一致,属于
"灰度映射/窗口"差异(不是色板或数据差异)。
## 2. 交叉验证:窗口是怎么算的
**Android SDK(libcxsdk 反编译,权威)**:
```
CFunctions::GrayEnlarge() // 灰度/窗口总入口
uVar2 = CalContrastOffsetedAutoEnlargeRange() // 窗口"范围"(不是半宽)
winLo = min(mean - (uVar2>>1), statMin) // 注意:向外扩到数据范围
winHi = max(mean + (uVar2>>1), statMax)
HistDeRedundance() // 直方图去冗余(我们的 lutRebuild)
CFunctions::CalContrastOffsetedAutoEnlargeRange():
base = (dev24 * 1000) >> shift // dev24=8, shift=3 → 1000
c = clamp(contrast, -100, 100) // ★ 对比度设置
range = base - base*c / (150 + (c<0 ? -100 : 0)) // c>0:range 变小(窗口更窄→更亮)
if (brightness 项 dev+0x6c/0x38 有效) range += 亮度修正项
range = max(range, 0x80)
```
**Windows SDK**:`MAG_SetAutoEnlargePara` / `MAG_SetFixedEnlargePara` /
`MAG_SetSubsectionEnlargePara`(同一组参数的用户 API)。
**Linux SDK**:`MAG_SetAutoEnlargePara`(libmagcore @0x11be50)同名同参 —— 两条 SDK 线吻合。
**官方手机 App 侧**:`ExtPara`(设备上报)含 `intContrastOffset` / `intBrightOffset` /
`intAutoEnlargeRange` / `intDetailRatio` / `intCurrentEnvTemperature`,App 用
`DeviceController.getExtParameter()` 读取后回写(`setExtParameter`)。
## 3. 我们的移植差在哪
`RenderPipeline.statsWindow()` 的窗口结构与官方**完全一致**(`winLo=min(mean−half,min)`、
`winHi=max(mean+half,max)`),但:
- `half = base>>1` **只在对比度 c=0 时**等于官方(官方 `range=base` → `range>>1`);
- 官方手机 App 带 `intContrastOffset`(正对比度 → 窗口更窄 → 更亮)→ 这就解释了我们偏暗。
**证据链**:PC 端(对比度 0)我们的渲染与官方 PC SDK 逐像素差 1.3 级 ✔;
手机端(官方 App 带对比度)差值 ~30 级 ✔ —— 同一实现、不同对比度输入,两个场景都对得上。
## 4. 顺带确认的两件事
1. **环境温度输入**:官方 App 调 `setEmissivity(emissivity, ExtPara.intCurrentEnvTemperature)`
—— 即 PC SDK 的 `dev+0xA4`(我们温度链里的 `ATZeroErrorPoint`)就是**环境温度**
(设备默认 22216 = 22.2 ℃);App 的发射率设置也走这里(100 = 1.00)。
→ 我们 App 的发射率设置应当**接进 `OfficialTemp.temperature(emissivity=…)`**(当前固定 0.98)。
2. **BasePara2 真机报文**(60 B,magic 已由我们的读取层吃掉):
`fpaTempFix` @+14(short)=1699、`ATZeroErrorPoint` @+40(int)=22216、`ATErrorSlope` @+44(float)=−0.02789
—— 已写入单测作为地面真值。**注意**:PC 上实测的补偿量 `v50`(=dev+0x5C−dev+0x54,0…89 mC)
**不等于** `fpaTempFix`(1699),故 App 侧 `sensorDeltaMc` 暂取 0(误差 ≤0.09 ℃),
待定位 dev+0x5C 的来源(疑似滤波后的 FPA 温度)后再接入。
## 5. 下一步(按此顺序)
1. 移植 `CalContrastOffsetedAutoEnlargeRange`(含亮度项到 `statsWindow`),并在设置页加
"对比度/亮度"(默认值取官方 App 的默认),重拍对比 → 目标是 luma 直方图与官方吻合。
2. 补 `getExtParameter` 命令(Android SDK 侧 JNI:`DeviceController.getExtParameter`),
把设备的 `intContrastOffset/intBrightOffset/intCurrentEnvTemperature` 接进来。
3. 温度读数接 App 的发射率设置(`OfficialTemp` 已支持参数)。
4. 以上在 App 验证无误后,按用户要求同步进 `csdk`(C 参考实现)与 csdk 测试。
## 6. 追加实测(同日):实时画面亮度差已复现,差异区间收敛到"窗口/LUT"
同一静止场景、前后相隔约一分钟分别截取两个 App 的**实时画面**(官方横屏 3200×1440、
我们竖屏 1440×3200),按行/列亮度自动裁掉黑边后统计:
| | p25 | p50 | p75 | mean |
|---|---|---|---|---|
| 官方 App 实时 | 12–20 | 26–33 | 63–124 | 53–72 |
| 我们的实时 | 2–4 | 12–25 | 27 | 20–25 |
→ 差异是**真实且系统性的**(不是拍照时刻/场景差异),并已把差异区间收敛到显示阶段的
"窗口 + 灰度 LUT"两步。
**三方交叉验证(Android ↔ Windows ↔ Linux)**:
| 阶段 | Android(libcxsdk) | Windows(CoreSDKLib) | Linux(libmagcore) |
|---|---|---|---|
| 设置入口 | `setAutoEnlarge(enlargeRange)`(官方 App 传 BasePara2 的 `enlargeRange`=**8**) | `MAG_SetAutoEnlargePara(chan, range, contrast, brightness)` | 同名同参(+0x24/+0x30/+0x34) |
| 窗口范围 | `CalContrastOffsetedAutoEnlargeRange()`:`range = base − base*c/(150+(c<0?−100:0))`,`base=(dev24*1000)>>shift`;`winLo=min(mean−range/2, min)`、`winHi=max(mean+range/2, max)` | 同结构(我们的 `statsWindow` 即此移植) | 同结构 |
| 灰度 LUT | `HistDeRedundance()`:1024 桶直方图(`0xFFC00000/span`、`>>20 & 0xffc` = 我们的 `lutRebuild` 公式)+平滑+CDF | 我们的移植来源 | 同结构 |
**结论**:窗口公式在对比度 c=0 时与我们**逐字节一致**(且官方 App 只传了 range=8,
未设对比度),直方图/LUT 的骨架也一致 —— 所以剩下的差异只可能出在
① LUT 的**平滑/CDF/pivot 细节**(Android 版 `HistDeRedundance` 的后续段尚未逐行核对),
或 ② 官方 App 通过 `setEX`/`ExtPara` 设置的**亮度偏移**(`intBrightOffset`)等参数。
这两处是下一轮的直接目标(继续用同一套"三 SDK 交叉 + 真机截图定量"的方法)。
## 7. 渲染差异的真正落点:**调色板(铁虹表)不同**
### 7.1 先排除的两项(都有反编译证据)
- **窗口**:Android `GrayEnlarge` + `CalContrastOffsetedAutoEnlargeRange` 与我们
`statsWindow` 在对比度 c=0 时**逐字节一致**;官方 App 只调用
`setAutoEnlarge(BasePara2.enlargeRange=8)`,没有对比度/亮度参数。
- **灰度 LUT/CDF**:Android `HistDeRedundance` 的直方图与双向 CDF 段与我们 `lutRebuild`
**逐行一致**(`0xFFC00000/span`、`>>20 & 0xffc`、`total>>12`、`iVar3=0x10000/step`、
前后向各一张表、`+0x100/+0x200` 步进)——这段不是差异来源。
### 7.2 官方默认调色板 = 索引 2(和我们一样),但**表不同**
- 官方 App:`ImageViewer.java:383` / `DeviceController.java:91` →
`setColorPalette(SharedPreferencesManager.getInt("paletteIndex", 2))` → 默认 **2(铁虹)**。
- 判定方法:**"亮度 → 颜色"曲线在同一调色板下唯一**(与灰度分布/亮度水平无关),
用它做检验(把像素代入调色板表,取同亮度最近项的色差):
- 对照(我们自己的像素 → 我们的表):中位色差 **0.3**,83.6% 在 ±10 内;
- 官方像素 → 我们的表:中位色差 **20.3**,只有 36% 在 ±10 内;最接近的调色板也不是 2 号;
- 分亮度段看:亮度 32–96 时官方是**橙色**(R−B≈+90,R≈97–102/G≈44–77/B≈6),
我们同一亮度是**紫/蓝**(R−B≈−33…+20)。
- 原因(已定位到二进制):**我们索引 2 用的是 PC SDK 的表**
(`OfficialTables.PALETTE256_ARGB`,来自 CoreSDKLib dev+0xb18),而官方**手机** App 用的是
**Android SDK 运行时生成**的表 —— `CFunctions::SetColorPalette` case 2(libcxsdk @0x26c70)。
上一轮把 case 0/1/3…10 的生成式移植成了 `VendorPalettes`,**case 2(铁虹)当时是拿 PC 表
做"锚点校验"、没有移植生成式** → 两表在中间段不同,颜色/亮度观感因此不同。
- "对比度更高"和"条纹更少"都由此解释:不同色带的亮度斜率不同(观感对比度不同),
且官方色调更亮(暗端被抬起)→ 噪声与 FPN 在暗端不再显眼。
### 7.3 条纹本身的实测(数据层无差异)
| 指标 | 官方 | 我们 |
|---|---|---|
| NUC 帧 column-profile HF(PC 抓帧对照) | 7.81(0.63% of spread) | 7.20(0.63%) |
| 渲染图平坦块列抖动(同刻截图,16×16 块) | 0.81–1.44 | 0.34–0.94 |
| 渲染图 HF 能量 mean\|dx\|/mean\|dy\| | 2.80 / 3.67 | 2.71 / 3.47 |
→ 数据与降噪层面我们不差于官方;"条纹更明显"是**色调/亮度**的感知效应
(官方实时画面 HF 更低 1.27 vs 2.92,也可能有 SDK 时域平滑参与)。
### 7.4 下一步
1. 移植 `SetColorPalette` **case 2** 的生成式(4 段线性,常量 0x75/0xbc/0x25/0xff/0x27/0x3b/0x1c3… 已在反编译中),
替换 App 索引 2 的表;然后**同一分钟**内截两 App 实时图做"亮度→颜色"曲线回归(目标:中位色差降到控制组水平)。
2. 顺带核对其余 11 个索引是否也应改用 Android 生成式(本轮 `VendorPalettes` 已覆盖 10 个)。
3. 之后再按用户要求把验证过的改动同步进 `csdk`。
## 8. 定案并修复:**灰度 CDF 的 75% pivot**(`cdfPivot75`)
### 8.1 先纠正 §7 的结论
按"照片"(不经过屏幕显示路径)重做调色板拟合:**官方照片与我们的照片都完美拟合 2 号铁虹**
(中位色差 0.67 / 1.00)→ **调色板不是差异来源**;§7 里"官方颜色对不上"是当时裁剪/对话框
污染造成的误判(已用照片复核推翻)。`VendorPalettes` 头注释也记录上一轮已验证
`SetColorPalette` case 2 与我们的铁虹表 **256/256 逐项一致**。
### 8.2 决定性测量(同刻截图 + 调色板反解灰度)
关闭两边所有对话框后**前后约一分钟**各截一张实时画面,图像区用**调色板反解出灰度值**
(消除显示路径/色域影响;99%+ 像素能唯一反解):
| | p5 | p25 | **p50** | p75 | p95 | mean |
|---|---|---|---|---|---|---|
| 官方 App | 0 | 48 | **61** | 129 | 202 | 82.2 |
| 我们(`cdfPivot75=true`,原状) | 11 | 23 | **33** | 82 | 189 | 60.2 |
| 我们(`cdfPivot75=false`) | 15–17 | 31–34 | **50–54** | 132–135 | 200–202 | **79.8–81.2** |
→ 我们的灰度整体偏低约 1 档半("偏暗、对比度低"),把 pivot 关掉后**与官方分布重合**。
### 8.3 根因与修复
- SDK 里这段 CDF 有一个由**参数**控制的支路(Android 反编译 `HistDeRedundance`:
`if (*(int *)(this + 0x60) != 0) { ...把 lres 抬到 75% 分位... }`);
- 我们的移植(来自 PC SDK 版语义)把它固定为 **开**(`RenderPipeline.cdfPivot75 = true`,
也是 C 参考实现的默认);
- 但**官方手机 App 实际是不启用它**的 → 灰度被压低、观感偏暗、低对比,噪声/FPN 在暗端更显眼
(这同时解释了用户说的"对比度更高""条纹更少")。
**修复**:`IrSession` 构造实时管线时显式传 `cdfPivot75 = false`(类默认与 C 参考保持
`true`,`matchesCReferencePixelExact` 等黄金测试不受影响)。已装机验证(上图对比),
同刻灰度分布与官方一致。
### 8.4 待办
1. 同一步骤同步进 `csdk`(C 参考实现把该参数作为配置项/默认关闭,并更新其测试与文档)。
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 参考帧窗口的实测与教训(已参数化,默认仍 4 帧)
把参考窗口从 4 帧延长到 8 帧(`RenderPipeline.baselineFrames`)后实测:
| 配置 | 灰度空间 HF | 灰度帧间噪声 |
|---|---|---|
| 官方 App | 0.34 / 0.26 | 4.12 / 1.80 |
| 我们 baseline=4(原状) | 0.76 / 0.80 | 4.02 / 3.95 |
| 我们 baseline=8 | 0.77 / 0.84 | **9.91 / 8.83(变差 ✗)** |
→ **不能盲目延长窗口**:设备和我们的 FFC 时序里,快门窗口比"6…13 帧"短,
多收的帧实际是**开快门**帧,把参考污染了(输出不稳定)。参考帧收集必须
**感知快门状态**(例如按帧统计判断快门是否关闭),而不是按固定帧号取。
### 9.5 下一步(精确)
1. 参考帧收集改成**快门感知**:在 FFC(0) 之后逐帧判断"是否仍为闭合快门帧"
(可用帧统计/与前一帧的差异判断),把**所有闭合帧**都累积进参考(对齐官方
`PushBaselineRaw` 的语义),FFC(1) 之后重置;
2. 用同一套灰度指标回归:目标空间 HF ≈0.34、帧间噪声 ≤4;
3. 之后同步 csdk(pivot + 温度链 + 参考累积)。
## 10. 定案(真机 libcxsdk 反汇编 + 官方照片定量):显示 LUT 才是差异,§9 的机制被推翻
数据源换成**手机上的官方库本体**:`adb` 从官方 App 的 APK 目录取
`lib/arm/libcxsdk.so`(arm32,本 App 只用 arm),IDB 里按 vtable 指针
(`0x5a7xx` 段)解析出真实函数体,逐条指令核对(Hex-Rays 会吞掉隐式除数,
必须看反汇编)。以下结论**全部以该反汇编为准**。
### 10.1 先纠正前两轮的错误结论
| 前结论 | 反汇编事实 |
|---|---|
| §8「官方不启用 CDF 75% pivot,所以我们要关掉」 | **错**。pivot 支路由 `theFunc._96_4_` 控制,`Controller::Controller()` 把它设为 **1**,全库无第二处写入 → 官方**是开着**的;而且它的写法不是 75% 分位,而是 `max(center_index, (meanBelow + meanAbove) >> 1)`。当时"关掉更接近"是因为**用的 LUT 本身是 PC 版**(结构与 Android 版不同),两点误差互相抵消。 |
| §9.3「官方参考帧是长窗口 IIR 指数平均,比我们干净」 | **错**。`CAccumulator::PushFrame` 的 `mode≥4` 分支是**恰好 mode 帧的块平均**(`sum>>log2(mode)`,计满清零),mode=2 才是 `(buf+new)>>1`。`Controller::PushFrame` 的快门状态机对 **A+1..A+E 共 E=baseLineAcc=4 帧**各推一次 → 官方参考 = 4 帧块平均 = **和我们完全一样**。 |
| §9.2「官方画面空间噪声比我们低 2.3×」 | **测量有分辨率偏差**:官方 App 显示的是 SDK 原生的 160×120 输出(照片 640×480 = 4×),我们的实时画面是 320×240(SDK 2× 后再 ×3 = 960×720 照片)。逐屏像素 HF 随放大倍数变化,两个 App 的放大倍数不同 → 该数字不可比。 |
### 10.2 官方每帧链路的完整配置(都已定位到 App 自己的调用)
`Controller::StartProcess(cb, fpaW, fpaH, sn, devType, shutterCloseSpeed,
shutterOpenSpeed, ffcTriggerFrame, ffcTriggerTemperature, palette,
baseLineAcc, caliPath)` —— 对应 App 侧
`UsbCommunication.startTransfer()` 的实参;真机 BasePara2 实测:
`shutterCloseSpeed=5, shutterOpenSpeed=4, ffcTriggerFrame=1800,
ffcTriggerTemperature=250, enlargeRange=8, baseLineAcc=4, fpaTempFix=1699`。
| 阶段 | 官方取值 | 我们 |
|---|---|---|
| FFC 相位 A/E/B | 5 / 4(=baseLineAcc)/ 4 帧 | 5 / 4 / 4 ✔(`ffcStep`) |
| 参考帧 | 4 帧块平均(`CAccumulator` mode=4) | 4 帧等权平均 ✔ 等价 |
| 曲线插值温度 | `帧尾 FPA + fpaTempFix(1699)`(App 加,SDK 的 `SelectCaliCurve`/`GenerateVolatileLens` 用它) | **帧尾 FPA(少了 1699)✗ 本轮修复** |
| 窗口 | `winLo=min(mean−range/2, statMin)`、`winHi=max(mean+range/2, statMax)`,`range=(enlargeRange*1000)>>3=1000`(对比度 0;`this+0x6c` 全库未写 → 亮度项恒不生效) | 一致 ✔(DDT 头 +20 读到的正是 8) |
| 灰度 LUT | Android 版 `HistDeRedundance`(见 10.3) | **PC 版结构 ✗ 本轮修复** |
| 时域平滑 `CSmoother` | 关(`this+0x380c = IntrinsicPara[0x20]`,构造默认 1,需 >1 才生效) | 未实现 ✔ |
| 显示侧累积 `CAccumulator(+0x37a4)` | 关(`this+0x3808 = 1` → `Raw2Temperature` 直通) | 默认关 ✔ |
| `setEX` 电子变倍 | 关(`imageEX` 默认 0 → `SetEX(0)` → `0x3814=0`) | 无此功能 ✔ |
| 7×7 细节增强 | **关**:`MapTemperature` 确实把门槛 `0x3864` 置 1(所以 `PushFrame` 每帧都会调 `FilterDetailEnhancement_Simple`),但强度来自 `CFunctions+0x28 << 3`,而该字段**全库从未被写入**(CFunctions 构造只 memclr,Controller 构造设了 0x1C/0x2C/0x30 等但没设 0x28)→ 传 0 → 函数首行 `if (param_1 != 0)` 直接返回 = **空操作** | 默认 0 ✔ 一致(我们的用户开关是超集,官方 Android 侧根本没有入口) |
| 翻转 | App 侧 `dwFlip` 只是存进全局 `0x6b90c` 结构,本库无人消费(翻在 App/显示层做) | 我们在显示/保存层做 ✔ |
### 10.3 真正的差异:灰度 LUT(Android 版 vs PC 版)
拿我们**自己照片里存的 NUC 计数块**与**官方照片**做像素级对齐(官方照片 = 官方实时渲染,两者同场景同机位):
- 垂直翻转后相关性 **r = 0.99** → **计数本身几乎一致**,差异**只在"计数→调色板索引"的映射**;
- 官方映射 ≈ `0.84 × 我们的LUT + 21`(被"压缩并抬升"):官方平坦背景落在索引 **45–55 的一段平台**上,我们的旧 LUT 把它压到 **0–20**(暗端被压死)→ 这就是"官方对比度更高 / 更细腻 / 条纹更少"观感的来源;
- 逐条核对反汇编后确认 Android 版 `HistDeRedundance` 与 PC 版(我们原来移植的)**结构不同**:
固定基值 `16*(448+447*brightness/200)`(PC 版是"自动居中 + 时域平滑"的二分搜索)、
步进上限 `max(1,(0x1800+4j)·k>>13)`(`k=min(0x200,16·span/range)`)、
反向 CDF 从 `hist[pivot]` 起(PC 版从 `pivot+1`)、平滑后 `hist[1023]=hist[1022]`、
pivot 用"两段均值的平均"。
**已落地**:`RenderPipeline.androidLutOf()` = 逐指令移植(含 golden 向量单测
`AndroidLutTest`),实时会话 `androidLut = true`;PC 版路径保留,C 参考黄金测试不受影响。
### 10.4 在线拟合与遗留项
用官方照片做目标(同一 NUC 帧、逐像素比对,均方误差以索引计):
| 配置 | 平均误差 | 低端 p5/p25 |
|---|---|---|
| 我们旧 LUT(PC 版 + pivot 开,= 09-12 照片) | ≈35 | 8 / 17 |
| 我们旧 LUT(PC 版 + pivot 关,= round-30 实时) | ≈14 | 15 / 29 |
| **Android 版移植(k 按公式 = 21)** | **12.5** | 24 / 36 |
| Android 版移植,k=16(拟合最优) | **5.9** | 45 / 54 |
| 官方照片实测 | — | 45 / 52 |
→ 移植已在正确轨道上;**遗留一处常数**:反汇编算出 `k = 16·span/range = 21`,
数据偏好 ≈16。两个可能来源,都需要真机同刻 A/B 才能定:
① 计数统计仍有细微差别(最可能就是 §10.2 的 `+fpaTempFix`,它改变插值曲线→改变计数分布);
② 该 build 的 `v45` 常数与反汇编读出的 16 不同(已在反汇编里逐位核对过 `LSLS R6,R0,#4` + `uidiv`,倾向排除)。
**在真机上先验证 `+fpaTempFix` 是否让计数分布与官方一致**,再看 k。
### 10.5 本轮已改代码
1. `RenderPipeline.androidLutOf()`:Android `HistDeRedundance` 逐指令移植(新增,含测试);
2. `IrSession` 实时管线改用 `androidLut = true`,删除 round-30 的 `cdfPivot75 = false` 临时开关;
3. `RenderPipeline.fpaTempFix`(构造参数)+ `ffcStep`/`frameRemote` 调 `rebuildTables(shutter + fpaTempFix)`,
`IrSession` 从 `BasePara2` 传入(官方 `UsbCommunication` 同款语义)。
### 10.6 待办
1. 真机 A/B(手机当前被 PIN 锁屏,需机主解锁):同一静止场景,官方实时 vs 我们实时,用"调色板反解灰度 + 逐像素对齐"复核 10.4 的 k/计数假设;
2. ~~7×7 细节增强按官方默认打开~~ —— **已排除**:强度源 `CFunctions+0x28` 全库未写 = 0,
官方那一步是空操作(这也解释了为什么离线拟合"打开反而略差");我们的实现保持用户可选、
默认关。顺带:我们的 `DetailEnhance` 与反汇编有一处细节差(`LocalMap7x7_Simple` 的
min/max 还要多采每行的第 1 列,共 5 列;我们只采了求和用的 4 列),会让门槛略偏严,
属可选功能的精度问题,不影响默认路径;
3. 同步 csdk(Android 版 LUT + fpaTempFix + 温度链)。
## 11. 真机验证(2026-10-06 下午,手机解锁后)
### 11.1 先修掉一个"实时全黑"的 bug(并补上会拦住它的测试)
Android LUT 落地时把 `IntArray` 的 LUT 用 `System.arraycopy` 拷进 `ByteArray` 的 `lut`
——**每帧抛 `ArrayStoreException`**,实时画面整块全黑(SurfaceView 仍在刷,logcat 无异常)。
黄金向量测试只覆盖纯函数 `androidLutOf()`,没覆盖实例路径,所以没拦住。已改为逐元素拷贝,
并新增 `AndroidLutPipelineTest`(4 个端到端用例:`androidLut=true` 能渲染出非全黑/单色画面、
两种 LUT 结果必须不同、`fpaTempFix` 必须改变插值表、手动 FFC 之后仍能渲染)。
单测 113 → **117 全绿**。
### 11.2 同场景 A/B:映射已经对齐
手机(22041211AC,官方 App 与我们的 App 前后 2 分钟各拍一张,同一静止场景),
用**调色板索引空间**在同一 160×120 网格上比对(避开两个 App 不同的放大倍数):
| 指标 | 官方 | 我们 |
|---|---|---|
| 索引分位 p5/p25/p50/p75/p95 | 82 / 89 / **96** / 117 / 133 | 85 / 92 / **98** / 114 / 134 |
| 逐计数中位索引(9490→10330 共 21 档)差值 | — | **中位 2.0(最大 4.5)** |
| 全图平均 \|Δ索引\| | — | **4.47 / 255**,中位 3.6,85.1% ≤8,99.2% ≤16 |
对比上一轮的旧 LUT:低端被压死(我们 p5/p25 = 8/17,官方 45/52,等效系统偏差 ≈35 级)
→ **"官方对比度更高、更细腻"的映射差异已消除**。
### 11.3 复核那个遗留常数 k:反汇编公式是对的
用我们照片里存的 **NUC 计数**逐像素对官方照片做模型比对(新的一对照片,
计数窗 [9142,10383],span=1241,公式 k=19):
| 步进常数 | 平均 \|Δ索引\| | 中位 | ≤8 占比 |
|---|---|---|---|
| **k = 16·span/range(反汇编公式,=19)** | **4.64** | **3.9** | **82.9%** |
| k = 16(上一轮"拟合最优") | 5.76 | 5.1 | 73.6% |
| k = 12 | 9.16 | 8.1 | 49.2% |
| k = 24 | 4.92 | 4.1 | 80.8% |
→ **按反汇编公式实现即为最优**;上一轮"数据偏好 ≈16"是当时那对照片(曝光/窗宽都不同)的产物,
**结论作废,代码不动**(§10.4 的遗留项关闭)。
### 11.4 遗留并已定位到域:我们的"计数"噪声偏大
同一个 LUT 作用在**我们的 NUC 计数**上得到的 HF(传感器网格)明显高于官方成品:
| 数据 | HF \|dx\| / \|dy\| |
|---|---|
| 官方照片(其渲染输出) | 2.46 / 2.22 |
| 官方实时截图(1.2 s ×3) | 1.61 / 1.51,帧间 1.83/1.77 |
| 我们的实时截图 | 2.35 / 2.39,帧间 2.34/2.40 |
| 我们的 NUC → 同一个 LUT(模型) | 4.65 / 4.25 |
即:**映射一致、噪声不一致**,且证据指向"我们的计数本身比官方毛"(同 LUT 下模型 HF 高 1.9×)。
排除项:FFC 之后立刻截图(新鲜参考)几乎不改善(帧间 2.44→2.34)→ 不是参考老化;
我们的实时帧率实测 **15.0 fps**、单帧绘制 6-9 ms(与第 22 轮记录一致,无回归)。
下一步方向:把**实时 FFC 参考采集**与已验证逐字节一致的离线/PC 路径对齐(同一个参考下
PC 端 NUC 是逐字节相同的,所以嫌疑在"实时状态机采到的参考帧")。
### 11.5 运维笔记(真机调试踩坑)
1. **正在推流时 force-stop App 会把相机卡死**(命令端点 halt、`GetCaliInfo` 超时、此后无流数据)。
恢复:`su -c 'echo 0 > /sys/bus/usb/devices/2-1/authorized; sleep 2; echo 1 > ...'` 重新枚举;
切换 App 时**先按 HOME 让 App 正常停流**。
2. MIUI 的 USB 授权在**每次重装/重新枚举**后都要重新授权;要勾"一律打开 MAG160C"
必须点**复选框本体**(x≈186),点标签中部(x≈718)不会勾上;授权后 App 需要重启进程才生效。
3. 相机被卡住时 App 的统计会显示 `reads=0 frames=0 timeouts=N`,UI 以 ~3.3 fps 空转刷新
(不是渲染帧率)。
## 12. 追查"计数噪声偏大"(2026-10-06 傍晚,真机分阶段实测)
用户:"把'计数'噪声偏大修一下"。做法:在管线里临时插桩(DebugLog 打开、逐级打印
raw/参考/差值/NUC 的 mean/std/HF),真机跑一遍,把噪声预算算清楚,再看 A/B 差在哪里。
### 12.1 真机实测的噪声预算(设备 160043865,同一静止场景)
```
[diag] ffcRefFrame mean=15084 std=3625 hfdx=1172 ×4 帧(快门帧)
[diag] refFrameSpread sigma=30.2 / 27.5 / 27.2 ← 逐帧像素噪声(快门帧间差)
[diag] stage.raw mean=11442 std=3466 hfdx=1168 ← 场景帧原始响应
[diag] stage.ref mean=15083 std=3624 hfdx=1172 ← 4 帧快门平均
[diag] stage.diff mean=-1821 std=326 hfdx=29.1 ← (raw-ref)>>1
[diag] stage.nuc mean=9731 std=245 hfdx=24.2 ← 波段映射输出
```
- **快门帧是真快门**:参考帧均值 15084 与场景帧 11442 相差 3600 counts,且 4 帧之间
mean/std 一致到 0.1% → 快门在参考窗口内确实闭合、且是同一位置。
- **逐像素时域噪声 σ_n ≈ 21–24 counts**(由 refFrameSpread 的 √(1+1/k) 反解)。
- **我们的差值噪声 = 0.559·σ_n = 12.3 counts**(= 0.5·√(1+1/4),4 帧平均参考的理论值),
由实测 hfdx=29.1/1.128 推得的白噪声部分 ≈ 25.8 里含场景结构(NUC 的 24.2 同量级)→
**与理论预算一致,没有额外噪声源** ✓。
- **FPN 抵消到 2.4%**(1168 → 29.1)✓。
结论:**我们的参考采集与 NUC 链路的噪声与时域表现完全符合物理预算**,"计数噪声偏大"
在数据链路上**不成立**(上轮凭跨 App 指标推断的"我们的计数更毛"没有证据支持)。
### 12.2 那 1.2–1.5× 的跨 App 差是什么
同场景 A/B 复测(官方 16:10 / 我们 16:08,16:08 那张同时给了 raw+NUC 块):
| 指标 | 官方 | 我们 | 比 |
|---|---|---|---|
| 索引 HF \|dx\|(照片) | 1.98 | 2.84 | 1.43 |
| 索引 HF \|dx\|(实时截图) | 1.61 | 2.35 | 1.46 |
| 实时帧间 \|Δ\|(重采样到 160×120) | 1.83/1.77 | 2.34/2.40 | 1.31 |
| HF 随亮度分档比(40→200 级) | — | — | 1.18–1.35(近似**加性**,不是乘性) |
已知的**测量混淆项**(不足以解释全部,但都在同一方向):
1. 我方形如 960×720(=6× 传感器,JPEG q≈95 → 0.29 B/px),官方 640×480(=4× → 0.42 B/px)
→ 我们每像素压得更狠,JPEG 振铃/块效应**加性**抬 HF(与"近似加性"的观察一致);
2. 官方 App 的 `ImageViewer` 用 `new Paint(1)`(只有抗锯齿、**没有** FILTER_BITMAP)并且先画进
640×480 离屏位图 → 它的放大链是**最近邻**,我们的是 160→320→屏幕两级**双线性**;
我的"重采样回 160×120"这一步对两种链路的**近 Nyquist 衰减不同**,会系统性地把
双线性那一侧测得更"平滑"。
3. 我们 18-1 的**实时**帧间指标此前受 UI 元素(OSD 文本/色条)污染,本轮已用遮罩排除到 99%。
### 12.3 本轮据此**真正修掉**的两件事
1. **MDT 照片的 raw/NUC 配对**(数据完整性 bug):照片里的 FRAME 块原本写
`session.lastRawFrame`(每帧都更新,包括 FFC 窗口内的**快门帧**),而 NUC 块是
**场景帧**的渲染结果 → 拍在 FFC 之后的那张照片,两块不是同一次曝光(离线再渲染/
分析界面正是消费这一对)。现在由管线交付 `copyLastRenderRaw()`:**渲染该帧时**
把 raw 载荷存下来,照片两块必然同源。新增回归测试
`MdtRawNucPairTest`(对**低通后**的两块做相关性,>0.8;raw 的方差 99% 是 FPN,
逐像素相关性即使配对正确也接近 0,这个坑在测试里写明了)。
2. **LUT 的 32 位语义对齐厂商**:`cdf*denom` 的乘法与 `>>14` 改为 32 位无符号
(Kotlin 侧 `u32(...)` + 逻辑右移);对真实数据结果不变(golden 向量仍全绿),
但退化场景(几乎全空的直方图)下与反汇编一致。
### 12.4 留给下一轮的(不盲改)
- 若要*严格*分清"我们比官方毛 1.3×"是渲染还是测量:需要**受控采集**——同一帧数据
过两条链(官方 App 只有 JPEG 输出、没有 raw 服务),或用一次 PNG/无损导出对比。
- 官方实时画面更平滑的**可复现线索**:其显示链是"离屏 640×480 + 最近邻放大"。
我们若要更接近,可以(a)把保存的照片改成 4×(640×480)或提高 JPEG 质量,
(b)实时预览改单级放大。两者都是**表现层**改动,不影响已对齐的映射,风险低。
## 13. 用户复现:"低温区条纹更多"——定位到**大负差值**处的噪声放大(未修完,留了下一步)
用户:"我换了个场景,背景的低温区域在我们的 app 里面明显条纹更多"。手机仍在,做同刻 A/B
(同一场景:我们 16:35 拍、官方 16:37 拍;另有 12 s 间隔的漂移标定)。
### 13.1 先标定漂移(12 s 内、同一 App 两张)
官方 App 相隔 12 s 两张:分位 [9,17,44,73,229] → [10,18,45,74,229],逐像素 |Δ| 均值 1.20(中位 1.0)。
→ 12 s 的漂移约 1 级,**不足以解释跨 App 的分位差**,所以下面的对比是有效的。
### 13.2 冷区(最冷 25% 像素)量化
| 指标(照片,160×120 网格) | 官方 | 我们 | 比 |
|---|---|---|---|
| 逐计数中位索引(冷区 9830→10000 counts) | — | — | **最大 1.7 级** ✔ 映射一致 |
| 列条纹幅度(去 9 宽趋势后的列剖面 std) | 2.12 | 2.28 | 1.08 ✔ 基本一致 |
| 细纹理 HF \|dx\| | 1.71 | 2.17 | **1.27 ✗** |
| 实时截图冷区 HF(无 JPEG 干扰) | 1.06 | 1.01 | 0.95 ✔ |
**结论**:冷区的**映射**和**列条纹(FPN)**都与官方一致,差的是**细纹理/像素噪声**(1.27×)。
JPEG 只能解释一部分:把我们那张(960×720 q95,202 KB)按 640×480/q98 重压后冷区 HF 从
2.17 只降到 1.96(官方 1.71)→ 仍差 1.15×。所以**我们确实在冷区多出约 1.2–1.3× 的计数噪声**。
### 13.3 哪来的:整帧预算吻合,冷区超预算 1.65×
- 整帧(中等场景)实测:σ_n≈21–24 counts → 差值噪声 0.559σ_n=12.3 ✔ 与实测吻合(§12.1);
- **冷区**(大负 d;d=(raw-ref)>>1)实测计数 HF = **20.4 counts**,而按同一预算应为 12.3
→ **1.65×** ✗ —— 与"冷区更毛"的用户观察方向一致,且只在大负 d 处出现。
已逐条排除:阈值/增益的 16 位解析(与厂商一致,且厂商侧也是**有符号**比较)、band 选择
(映射一致说明段选择一致)、快门参考(4 帧快门平均 ✔)、JPEG(只占约 1/3)、
显示链(实时冷区 HF 反而更低)。
### 13.4 下一步(精确定位,需一次带插桩的实机)
在大负 d 的像素上分层打印:`d` 的分布、命中的 band、该 band 的 `gain/off`(插值后)与
`σ_n`,对 DDT 里 band0 的原始 gain/off 核一遍。最可疑的两条(都可控):
1. **`fpaTempFix` 是否也该作用在图像链**:我们按官方 App 的代码给插值温度加了 1699
(§10.5),如果该设备上图像链实际用的是帧尾原值,则 band0 的 gain 会被插值偏,
表现为"映射总体还对、但低端噪声被放大"——正好是现象;
验证:临时把 `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 全绿**。
## 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 的新策略)。
## 16. 「官方背景条纹更不明显」——逆向排除全部候选后,定位为**我们独有的逐行固定偏移**(2026-10-06 深夜,交接轮)
用户发了同场景双截图(官方 vs 我们,均为竖屏、12 px/传感器像素、同一调色板铁虹)并要求
"看逆向出来的代码是不是还漏了什么"。本轮做了两件事:把官方每帧链路逐条再对一遍(IDA 批量
反编译),并对两台 App 的显示结果做**调色板弧长反演**的定量对比。**结论:每帧算法没有遗漏;
差异是一个官方没有、我们有的"逐传感器行固定偏移"(残余行 FPN,显示上就是水平条纹)。**
### 16.1 逆向再核对(libcxsdk = 普通版 MAG-Cx 的 SDK,全部读反编译原文)
- **内参来源定案**:`Controller::StartProcess(cb, w,h,sn,devType, close,open, ffcFrame,ffcTemp, palette, baseLineAcc, cali)`
只写 Controller+0x28(w)/+0x2c(h)/+0x18(palette)/+0x40(baseLineAcc),其余字段保持
`Controller::Controller()` 的默认:+0x44=**1**、+0x48=**1**、+0x4c=5。
映射到 CFunctions:0x3804=baseLineAcc=**4**、0x3808=**1**(温度累积器**旁路**)、
0x380c=**1**(**CSmoother 关**)、0x3810=5。→ 官方手机链路**没有**平滑器/累积器,
之前 §8/§9/§10 的结论不用改。
- **每帧链路** = `Calibration`(DDT 有效时走 `CalibrationMain_RTV`:
`d=(raw−base)>>1` → 按段阈值选 `(gain,off)` → `(d*gain>>12)+off`,clamp 0..0xffff)
+ `BlindCompensation`(盲元按 3..8 邻域均值替换,DDT 盲元表) + Mirror(关) +
`TempRangeStat` + `GrayEnlarge`(内含 `HistDeRedundance`)。E2X/E4X 仅当 EX≠0
(App imageEX=0),`FilterDetailEnhancement(_Simple)` 仅当 CFunctions+0x28≠0
(**从未被写**=0)。→ 与我们的实现一一对应,**没有缺的阶段**。
- **标定表只在 FFC 窗口重建**:`DoSomething(param_1==0)`→`SelectCaliCurve`+
`GenerateVolatileLens`;param_1≥1 时在窗口各帧**分块** `InterpolateShort/UShort`
(块大小按 `(13+总字节+尾-2)/(13-1)+7&~7`,完成后置 0x2d24=1)。不在每帧跑 →
我们"FFC 时 rebuildTables"一致。
- **FFC 时序逐帧一致**:触发→cb(0) 关快门 →帧 1..5→帧 6..9 逐帧 `PushBaselineRaw`
(首帧 Restart,4 帧块平均)→**第 9 帧回调 cb(1) 开快门**→帧 10..13 DoSomething→帧 14 起正常。
我们的状态机(idx 1..5 / 6..9 / idx==9 onFfc(1) / 10..13)完全同相。
- **设备配置命令**:`setFrameRate`(P2D_SetFrameRate=0x6BB6B679,载荷 interFrame/interLine
两个 u32;fpsMode 0→9/12,否则 105/105)只在 **devType 1/2** 时发;我们 devType=**3**
→ 官方也不发。`P2D_SetParameter1/2` 官方 USB 路径只发 Get。→ 原始流配置两边相同。
- **工具链记录**:普通版 APK=`app/【普通版】MAG-Cx.apk`(libcxsdk.so 372KB armeabi,
md5 4d8d98bb…,与 analysis/sdk_re/android_app/bin 相同;JNI 层是 libcxx2java.so 67KB)。
`analysis/ida/libcoresdk_arm64.so` 是**专业版 MAG-Mx** 的库,别拿它推普通版
(它的 0x439d80/88 内参在 .bss,文件里读不到)。IDA Pro 9.4 批量反编译命令:
`idat.exe -A -S"<script.idc>" -L"<log>" <so>`(脚本按 `get_next_func` 遍历;
对 `__imp__` 导入 thunk 会异常中断,注意跳过;Ghidra headless 兜底)。
- `test_frames.bin` 夹具是合成/重复帧(60 帧 mean/std 完全相同),**不能**用于噪声分析。
### 16.2 定量对比(调色板弧长反演,脚本在 %TEMP%\mag160c_re\analyzer.py,临时件)
方法:ironbow 256 色表(VendorPalettes)→ RGB 弧长参数化(消除调色板斜率影响)→
12 px/传感器像素块中值采样到 160×120 → 3×3 高通。数据:用户双截图(竖屏)+
真机连拍 3 帧(官方横屏 crop、我们竖屏,均 PNG 截屏无 JPEG 伪影)。
| 指标(弧长单位 ×1e-2) | 官方 | 我们 | 比值 |
|---|---|---|---|
| 整图低频 σ(对比度/音调曲线) | 20.2 | 21.2 | 1.05(一致) |
| scene(>8px) σ(真实场景对比度) | — | — | 0.95~0.97(一致) |
| 高频 σ(单帧 1~8px 频带) | 1.16~1.21 | 2.10~2.51 | **~2×** |
| 时间分量(连拍差分/√2) | 0.52 | 0.75 | 1.44× |
| **沿显示水平线均值的 std(=逐传感器行固定偏移)** | **0.106~0.15(max 0.57)** | **1.20~1.84(max 12.1)** | **8~17×** |
分块统计(背景块):scene 比 0.95、1-8px 频带比 1.58、时间比 2.0 —— 我们多出来的
能量集中在 1~8px 频带且**逐帧可复现(固定)**。
### 16.3 结论与交接(下一步)
- **缺陷**:我们的渲染里有一个官方没有的**逐传感器行固定偏移**(残余行 FPN)。
显示旋转 90° 后,传感器行=屏幕水平线 → 就是用户看到的"背景条纹"。
它是固定的(连拍均值里都在),所以不是每帧算法、也不是显示插值(两边显示链等价:
都是 160×120 数据 → 12 px/px 双线性上屏)。
- **候选(按嫌疑排序)**:
1. FFC 参考帧被污染/时序错位:官方与我们都"关 5 帧→采 4 帧→第 9 帧开",但我们的
帧计数从自己的处理循环来;USB 抖动/丢帧会让采集窗错相(§9.4 曾测得
"延到 8 帧反而变差"——设备实际快门相与我们假设的错位的旁证)。
参考≠纯快门帧 → 减出的残差里留下行结构。
2. DDT 段表 (gain,off) 的布局/插值在当前 FPA 温度下与我们不一致
(我们 `rebuildTables(shutterAtFfc + fpaTempFix)`;官方 `SelectCaliCurve` 的温度
来源、端点选择、Interpolate 的语义需要逐字段比对)。
3. 盲元处理差异(我们已实现 `nucAndBlind`,但要和官方 `BlindCompensation` 的
邻域定义逐条对——它用 10 int/条 的记录、邻居偏移含 ±1 与 ±行距)。
- **验证路径(下一个模型从这里接)**:
1. App 加"原始帧录制"开关(IrSession 读线程帧入口处写 files/,含一次完整 FFC 的
60+ 帧与帧头);真机录一段。
2. 离线重放:raw → 参考 → NUC → 表插值 → LUT,**每一级都算"沿行均值 std"**,
找出引入行偏移的那一级;再用录到的数据做 A/B(改窗口/改表温度/改盲元)复现官方数值。
3. 真机 A/B 验收指标就用 16.2 的表:目标"逐行固定偏移 std ≤ 0.2"。
## 17. 第三十八轮:原始帧录制 + 逐级重放全链路定案 + 显示链修复(2026-10-07 凌晨)
按 §16.3 清单执行。工具:`IrSession.rawFrameSink` + `RawFrameRecorder`(调试开关,
`files/rawcap.enable` 标志文件触发,录 raw+NUC dump 到 `files/rawcap/run_*.bin`,
带 FFC 触发,300 帧自动停);离线重放脚本在 `%TEMP%\mag160c_re\`(临时件)。
### 17.1 逐级重放(整数语义复刻,与真机 NUC dump 互锁)
录制 300 帧(含 3 次完整 FFC 窗口)。Python 逐级复刻(i32/算术移位/向零截断与
Kotlin 一致),与 App 自己 `copyNuc` 的 dump 对拍:corr=0.9996,仅盲元像素大差异
——**复刻可信**。各级行交替(alt = 行均值相邻二阶差,固定分量):
| 级 | 行交替 std(counts) |
|---|---|
| raw(场景帧固定行剖面) | 596.9 |
| ref(4 帧快门平均) | 596.7(与 raw 图样相关 0.999) |
| d2 = (raw−ref)>>1 | 5.20 |
| NUC(查表后,App dump) | 4.25 |
**结论 1:参考减除消掉 98.4% 的行 FPN,表再消一半,残差 4.25 counts 就是
显示条纹的直接来源**(换算到显示域 ≈ §16.2 的我们 1.2–1.84)。
### 17.2 排除清单(都做了 A/B)
- **参考窗相位**(±2 帧移动):4.01–4.33,不敏感。
- **参考平均长度**(1/4/6/8 帧):4.14–4.51,不敏感(时间噪声早已被平均压低)。
- **表插值温度**(33637…47664 扫描):4.06–4.57,不敏感。
- **残差时间结构**:FFC 后 1–10.3 s 固定分量 5.14–5.34,完全平坦 → 不是参考老化。
- **跨 FFC 窗口**:d2 残差图样相关 +0.986 → 不是每次 FFC 的随机量。
- **残差与 raw/ref 图样相关** ≈ 0.02/0.03 → 白状固定残差,**即"快门态与开放态的
行 offset FPN 图样差"(≈1.7%)**,设备固有。
- **GenerateVolatileLens**(0x18314):11 段温度响应折线,只进温度链(`this+3163`
默认 1 时直通),与图像链无关 → "官方把参考融合进表"的假设**不成立**。
- **官方 SDK 全函数核对**(本轮新读):`CalibrationMain_RTV`(段选择/`(d*gain>>12)+off`/
布局与步进逐字段一致)、`PushBaselineRaw`(双累积器 +14212/+14244)、
`CAccumulator::Reset/PushFrame/Restart`(mode=baseLineAcc=4 恰好 4 帧块平均)、
`Raw2Temperature`/`CFunctions::PushFrame`(0x3808=1 旁路、每帧渲染、FFC 条件同相)、
`TempRangeStat`(纯 min/max/mean)、`BlindCompensation`(typ 3..8 邻域 switch)、
`InterpolateShort/UShort`(Q12 端点插值同式)、`SelectCaliCurve`、`DoSomething`、
`Controller::PushFrame/StartProcess`(A=5/E=4/C=4 同相;PushBaselineRaw 收 JNI 原样
帧,Java `ThreadImgProcess` 不做预处理)。**官方每帧链路与我们逐字段一致,没有
任何"额外处理"能解释残差差**。
### 17.3 显示域 A/B(同场景同铁虹,§16.2 口径:12px 中值网格 + 弧长 + hf3 行/列剖面)
| 测量 | ROW-dir | COL-dir |
|---|---|---|
| 官方截图(横屏) | 0.12 | 0.31 |
| 我们修复前截图 | 1.41 | 0.46 |
| 我们修复后截图 | 1.32 | 0.59 |
| 我们修复后截图(**mask 掉 UI 覆盖层后**) | 0.76 | 0.67 |
**重要测量教训**:我们的竖屏 UI 把中心温度/MAX 标注/色标条画在热像区内,它们对
行剖面的贡献与真实 FPN 同量级——**对比测量必须 mask**(官方 UI 覆盖为 0)。
§16.2 的"8–17×"里含此混淆 + 几何错位(37 轮连拍的官方横屏 crop 与我们竖屏
行/列方向不一致);本轮修正后**真实 NUC 域差距约 1.6×**(同一份我们的数据走
官方显示链复刻 = 0.19,走我们链 = 0.32,官方截图实测 0.12)。
### 17.4 定案与修复
1. **官方显示链的两级结构**(jadx `ImageViewer` + 本轮重核):SDK 输出 160×120
bitmap → `PaintFlagsDrawFilter + FILTER_BITMAP` 画入 **640×480 离屏**(4× 双线性)
→ SurfaceView 再放大到屏。**中间 4× 双线性对逐行 FPN 图样有 ~0.6× 的低通**——
同一份我们的 NUC 数据:走官方两级链复刻 = 0.12,走我们单级 12× 复刻 = 0.32。
**"官方背景条纹更不明显"的主导机制是显示链,不是 NUC 域**。
2. **修复 A(nativeOut)**:`RenderPipeline` 去掉 PC 版遗留的内部 2× 插值级
(官方安卓没有),输出原生 160×120 调色板图;`LiveRenderer`/录像/拍照全链
适配。复刻证明它对行交替数值无影响,但消除了自造的"全幅/半幅"行调制
(§35 的竖列交错在行方向的同类项),显示等价性更完整。
3. **修复 B(640×480 中间级)**:`LiveRenderer` 按官方 viewer 补上 640×480 离屏
位图(FILTER_BITMAP 4×)再上屏,**显示链与官方逐级等价**。
4. **遗留(诚实记录)**:UI mask 后我们 0.76 vs 官方 0.12 仍有差距,其中
a) NUC 域残差 2.87 vs 官方等效 ~1.3–1.8 counts(**1.6×**,同显示链对比)——
与"快门态/开放态行 offset FPN 图样差"相关,设备固有,官方同算法也应承担,
差异来源未定位(疑似两 App 会话时设备状态不同);
b) 真机 GPU 显示链与 PIL 复刻间仍有 ~2× 未解释差(复刻 0.34 vs 真机 0.76),
需下一轮用"真机截图 vs 同帧 NUC dump 的逐像素对照"收敛。
§16.3 的验收目标"逐行 std ≤ 0.2"未达成;下一轮从 b) 项入手(它是纯测量
差异还是真机显示链仍有未建模环节)。
### 17.5 工具与现场
- `RawFrameRecorder`/`rawFrameSink`/flag 轮询已合入(调试工具,默认不活动)。
- `DebugLog.enabled` 本轮临时 true,提交前已改回 false。
- 现场状态:rotation 锁竖屏(原值 1,**待用户恢复**);我们 App 前台出流;
MIUI 的 App 自动启动会在授权后把焦点拉回我们 App,切官方测前先 force-stop
我们,用 `TAB×2+ENTER` 过 USB 弹窗(横屏时 input tap 坐标不可靠)。