Compare commits

..
23 Commits
Author SHA1 Message Date
ZXCLI e0709f9305 app+csdk+docs: 噪声预算真机实测(0.559σn 与理论一致,链路无额外噪声源)+ 修 MDT raw/NUC 配对与 LUT 32位语义;csdk 合入 android_lut/fpa_temp_fix(C 未编译,算法由 golden 测试锁定);118 单测全绿 2026-10-06 16:28:05 +08:00
ZXCLI 2290fbbafb app+docs: 真机验证——修实时全黑(ArrayStoreException)并补端到端测试;同场景 A/B 映射已对齐(平均4.5/255,逐计数差2级);复核确认 LUT 步进常数用反汇编公式即最优;剩余差异定位到计数噪声域 2026-10-06 15:45:19 +08:00
ZXCLI bf03e852ca csdk: 撤回第29轮'关 pivot'的取值(依据已被真机反汇编推翻),C 参考保持 PC 语义;README 记录 Android 版 LUT 与 fpaTempFix 的待移植规格 2026-10-06 07:57:01 +08:00
ZXCLI 6392bd89ee docs: 更正 7×7 细节增强结论——官方每帧虽调用但强度源 CFunctions+0x28 全库未写=0,实为空操作(与我们默认关一致) 2026-10-06 07:56:09 +08:00
ZXCLI a1dfb647ea app+docs: 按真机 libcxsdk 反汇编定案——灰度 LUT 改用 Android 版 HistDeRedundance(固定基值/pivot=两段均值/步进上限),曲线插值温度补 fpaTempFix;推翻上轮'参考帧累积'机制(CAccumulator 是 4 帧块平均,与我们等价) 2026-10-06 05:51:31 +08:00
ZXCLI 3510bdc774 app+docs: 参考帧窗口参数化(baselineFrames, 默认4)——实测延长到8会把开快门帧收进参考导致不稳定;结论:需快门感知的参考收集 2026-10-06 04:47:36 +08:00
ZXCLI 1d6c5e3e55 app+docs: 追查'官方更细腻'——定位到时域累积器(CAccumulator);实测证明差异在参考帧(FFC baseline)而非显示侧平滑;累积器已实现但默认关闭 2026-10-06 04:39:27 +08:00
ZXCLI d66d253f9f docs: 第二十九轮心跳(渲染差异=灰度 CDF 75% pivot,已修复并同刻实测对齐;csdk 同步状态) 2026-10-06 04:07:11 +08:00
ZXCLI 7e1db78676 csdk: 与官方手机 App 对齐——工具关闭灰度 CDF pivot(实测同刻分布重合);温度链移植规格入 README(待有 C 编译器时落地) 2026-10-06 04:06:48 +08:00
ZXCLI 3b191e6bca app: 渲染对齐官方手机 App——关闭灰度 CDF 的 75% pivot(实测同刻灰度分布 60.2->81.2,官方 82.2;偏暗/低对比根因) 2026-10-06 04:05:37 +08:00
ZXCLI 95ae4d46dd docs: 渲染差异定位到调色板(铁虹表):官方手机 App 用 Android SDK 运行时生成的表,我们用 PC 表;窗口/LUT-CDF 已逐行排除(附实测证据) 2026-10-06 03:53:03 +08:00
ZXCLI 4f3bb50e4a docs: 第二十八轮心跳(温度链进 App 并真机验证;渲染差异收敛到 LUT-CDF,附下一步) 2026-10-06 03:36:57 +08:00
ZXCLI 604ab0cd7e docs: 手机端渲染差异定位(官方照片无数据块/亮度差复现;三 SDK 交叉验证把差异收敛到灰度 LUT-CDF 阶段) 2026-10-06 03:36:36 +08:00
ZXCLI e096c96430 build: 更新 debug APK(含官方温度链) 2026-10-06 03:31:34 +08:00
ZXCLI 497e5100ef app: 温度改用官方放射学链(波段映射+AT修正+发射率revise),BasePara2/DDT尾部解析;单测以官方同帧数据+真机报文为准 2026-10-06 03:31:17 +08:00
ZXCLI 705ad27cac docs: 补记 BasePara2 线上偏移的确认方法(dump 报文按已知值定位) 2026-10-06 02:54:53 +08:00
ZXCLI 8560b83816 docs: 温度链数据源全部定位(v50=fpaTempFix、0xA4/A8=BasePara2.ATZeroErrorPoint/ATErrorSlope、斜率表现算、曲线A=我们的T2E表)——落地无未知项 2026-10-06 02:54:17 +08:00
ZXCLI e80d62050b docs: 温度链闭合——完整模型与官方输出平均差 13.9mC(0.014C);根因是 Hex-Rays 十进制字段(dev+0xA4)误读;附可直接落地的伪代码+DDT尾部布局+剩余数据源清单 2026-10-06 02:52:25 +08:00
ZXCLI 4198f9e163 docs: 温度链第二轮收敛——确定为 revise(band_map(counts)),斜率逐位吻合,仅剩恒定 -197mC 加性项(已排除项与下一步已记录) 2026-10-06 02:46:41 +08:00
ZXCLI 3d4bb65247 docs: 官方温度链 IDA 反编译取证(放射学分支+波段映射+修正项;DDT 尾部即标定记录;系数待钉)+ IDA MCP 接入记录 2026-10-06 02:38:56 +08:00
ZXCLI ce27a1bb82 docs: 官方 PC 端同帧逐像素对比(NUC 逐位一致、调色板一致、温度曲线系统差 +0.1/-0.7C、窗口规则差异、PC 采集链路与驱动坑) 2026-10-06 02:14:26 +08:00
ZXCLI 948c9d5d3d tools: 照片差异定位三件套(帧剖面 / NUC 直出 / 条纹量化)+ 忽略 .tmp-* 对比目录
为"我们的照片与官方照片到底差在哪"提供可量化的手段,而不是靠肉眼看图:

- FrameProfile:对 160x120 载荷算行/列均值的步进与 3 抽头高频,并给出
  「列高频 / 行高频」比值——列固定图案噪声会表现为该比值显著 >1,场景内容则接近 1。
  同时对压缩块与"官方布局"(DDT 头 + 884B BasePara + 帧)都能定位载荷。
- NucDump:把 NUC 块按线性映射直出 PNG,用来判断成品图里的结构是"数据里有"
  还是"显示级引入的"。
- StripeCheck:在成品图的平坦区域量列抖动,并给出它占局部量程的百分比。

实测结论(本轮):我们的原始帧列高频 675 counts(列噪声主导,符合未校正预期),
而我们的 NUC 块只有 4.1 counts、行/列比 1.25,且成品图上平坦区列抖动仅为局部量程的
0.1% —— 即**校正后没有残留列噪声**,肉眼以为的竖纹是场景纹理。官方的成品帧(其文件
存的是校正后数据)同样干净(colHF 5.5、比值 1.39),两边校正质量等价。

.tmp-*/ 加入忽略:对比用的用户照片与中间产物放在那里,不入库。
2026-10-06 00:46:27 +08:00
ZXCLI 4a60011ed9 tools: 新增 MdtInspect —— 对比本 App 与官方照片的差异定位工具
用户反馈"这个 App 拍出来的照片和官方软件还是有区别",即将提供两张对照照片。
先把这个工具准备好,文件到手即可定位差异出在哪一层,而不是凭肉眼猜。

按三层从外到内报告:
1. 容器层:是否厂商 MDT(JPEG + DDT 块 + 尾)、有哪些块、各多大、RENDER 参数
   (翻转/旋转)——先排除结构性差异;校准文件(magic 5AA5000x)会被识别并说明
   它描述的是传感器而非场景。
2. 数据层:FRAME(原始测量帧)与 NUC 块的 min/mean/max/spread。**这一层若不同,
   差异来自采集(传感器状态/FFC/标定),不是我们的配色映射**。
3. 图像层:JPEG 亮度分位数、均值、Laplacian 锐度。两个文件尺寸一致时还给出
   逐项对比(亮度差、对比宽度差、锐度比)。

实测校验:对仓库内两张真机照片输出正常(960x720 rot=0 与 720x960 rot=90,
各块与极值/测点都能读出);对两个标定文件正确识别为"非照片"并跳过对比。
.class 加入 .gitignore(原来是 manual_images 专用的)。
2026-10-05 23:01:31 +08:00
27 changed files with 3160 additions and 30 deletions
+5 -2
View File
@@ -37,5 +37,8 @@ __pycache__/
# ZCode 会话目录 # ZCode 会话目录
.zcode/ .zcode/
# Java build output for the manual figure generator # Java 编译产物(手册配图生成器、tools/ 下的小工具)
docs/android_app/manual_images/*.class *.class
# 临时对比/分析目录(不提交,含用户照片与中间产物)
.tmp-*/
+5 -2
View File
@@ -33,8 +33,11 @@
### `sdk_re/android_app/` — 官方安卓 App(普通版 MAG-Cx,本管线上真机跑通的代码) ### `sdk_re/android_app/` — 官方安卓 App(普通版 MAG-Cx,本管线上真机跑通的代码)
- `jadx_magcx/`:**全量 Java 源码**(jadx 1.5.1)。核心:`cn/com/magnity/magnitycx/sdk/UsbCommunication.java` - `jadx_magcx/`:**全量 Java 源码**(jadx 1.5.1)。核心:`cn/com/magnity/magnitycx/sdk/UsbCommunication.java`
(USB 协议真身)、`P2DCmd.java`/`D2PCmd.java`(命令表)、`DeviceController.java`(JNI 封装) (USB 协议真身)、`P2DCmd.java`/`D2PCmd.java`(命令表)、`DeviceController.java`(JNI 封装)
- `libcxsdk_decomp.txt`:Ghidra 全量伪代码(libcxsdk.so,1290 函数;Controller::StartProcess / - `libcxsdk_decomp.txt`:Ghidra 全量伪代码(另一版本 libcxsdk.so,1290 函数;Controller::
PushFrame / LoadCalibrationTable 已核对) StartProcess / PushFrame / LoadCalibrationTable 已核对)。**权威结论以真机 libcxsdk.so
的 IDA 反汇编为准**(vtable 段 0x5a7xx 解真实函数体;含 Android 版 `HistDeRedundance`
的逐条核对),见 `docs/android_app/rendering_diff_phone_20261006.md` §10–§12 与
`docs/android_app/temperature_chain_re_20261006.md`。
- `all_functions_decomp_libcoresdk_arm64.so_00100000.txt`:libcoresdk(arm64) 全量伪代码 - `all_functions_decomp_libcoresdk_arm64.so_00100000.txt`:libcoresdk(arm64) 全量伪代码
(专业版/网络路径,CNetComm 命令层) (专业版/网络路径,CNetComm 命令层)
- `coresdk_arm64_defined.txt`:ARM64 库符号表 - `coresdk_arm64_defined.txt`:ARM64 库符号表
@@ -0,0 +1,293 @@
package com.mag160c.thermal.core
import com.mag160c.thermal.usb.MagProtocol
/**
* Official MAG160C radiometric temperature chain — ported line-by-line from
* CoreSDKLib.dll (`sub_180016290` core, `sub_180015840`/`sub_1800158b0` curve
* interpolators, `sub_180015A40`/`sub_180015940` delta converters,
* `sub_180015b90` revise) and verified against 15 captured official frames
* x 19200 px: mean |delta| = 13.9 mC (0.014 C).
*
* The device does NOT use the T2E-table branch our app used before; for this
* hardware (`dev+0x41880 != 0`) it runs the radiometric branch:
*
* counts -> per-band linear map (record from the DDT tail)
* -> ambient compensation (v50 = BasePara2.fpaTempFix)
* -> ambient-temperature error term (ATZeroErrorPoint/ATErrorSlope)
* -> emissivity/transmittance revise T=iE2T((E2T(T)-(1-K)*E2T(env))/K)
*
* Data sources (all available to the app):
* - curve A : [OfficialTables.T2E] (identical to module @0x180099000)
* - slope table : computed here with the SDK's own runtime formula
* - band record : radiance record at the tail of the DDT (calibration file)
* - fpa temperature : frame tail word (+8) — the pipeline's `shutter`
* - ATZeroErrorPoint / ATErrorSlope / fpaTempFix : BasePara2 (66c response)
*/
object OfficialTemp {
/** Curve A: temperature-domain -> radiance-domain, 646 entries. */
private val CURVE = OfficialTables.T2E
/**
* Local slope table, same runtime formula as the SDK init code:
* `S[i] = ((d >> 1) + 0x1000000) / d` with `d = A[i+1] - A[i]`
* (verified 645/645 against the module's table at 0x180099A20).
*/
private val SLOPE = IntArray(CURVE.size) { i ->
val d = if (i + 1 < CURVE.size) CURVE[i + 1] - CURVE[i] else CURVE[i] - CURVE[i - 1]
if (d != 0) ((d shr 1) + 0x1000000) / d else 0
}
/** Radiometric record (540 B block at dev+0x41888) as stored in the DDT tail. */
class RadRecord(
val shift: Int,
/** 8 band thresholds, counts domain (rec+0x40). */
val thr: IntArray,
/** Per-band gain (rec+0xD4+8*i). */
val gain: IntArray,
/** Per-band offset in mC (rec+0xD8+8*i). */
val off: IntArray,
/** rec+0x34 / rec+0x38 — the pair that differs by the 500 mC reference gap. */
val tA: Int,
val tB: Int,
/** Emissivity (rec+0x204). */
val eps: Float,
/** Distance/ambient validity bounds (rec+0x208 / rec+0x20C). */
val distLo: Int,
val distHi: Int,
)
/**
* Device "AT" parameters from BasePara2 (0x5BB5B55C) — field offsets follow
* 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,
/** CAccumulator mode (power of two; 1 = off). */
val baselineAcc: Int = 1,
)
/**
* Parse the radiance record from the DDT tail. Layout verified against the
* device object's in-memory record (see docs/android_app/temperature_chain_re_20261006.md):
* offsets are relative to `ddt.size - 560`.
*/
fun parseDdtTail(ddt: ByteArray): RadRecord? {
if (ddt.size < 760) return null
val base = ddt.size - 560
fun i32(o: Int): Int = MagProtocol.u32(ddt, base + o).toInt()
fun f32(o: Int): Float = Float.fromBits(i32(o))
val shift = i32(0x44)
val tB = i32(0x48) // rec+0x38
val tA = i32(0x4C) // rec+0x34
val bands = i32(0x50)
if (bands !in 1..16 || shift !in 0..20) return null
val thr = IntArray(bands) { i32(0x54 + 4 * it) }
for (i in 1 until bands) if (thr[i] <= thr[i - 1]) return null
val gain = IntArray(bands) { i32(0xE8 + 8 * it) }
val off = IntArray(bands) { i32(0xE8 + 8 * it + 4) }
// sanity: gains must be positive and offsets negative-ish (mC scale)
if (gain.any { it <= 0 || it > 1 shl 20 }) return null
val eps = f32(0x218)
if (eps <= 0f || eps > 1f) return null
return RadRecord(shift, thr, gain, off, tA, tB, eps, i32(0x21C), i32(0x220))
}
/**
* Parse BasePara2 (0x5BB5B55C) — see class [AtPara].
*
* Offsets are the official app's own wire parse (`UsbCommunication.parseCmd`,
* D2P_SendParameter2: fpaTempFix, ATZeroErrorPoint, ATErrorSlope are the
* 5th, 13th and 14th field) **minus the leading magic word**: our
* `readResp` returns the payload with the magic consumed separately.
*/
fun parseBasePara2(payload: ByteArray): AtPara? {
if (payload.size < 52) return null
val fpaTempFix = MagProtocol.u16(payload, 14).toShort().toInt()
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
// 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
private fun a(i: Int): Int = if (i in CURVE.indices) CURVE[i] else CURVE.last()
/** sub_180015840: temperature domain -> radiance domain (curve A forward). */
fun e2t(x: Int): Int {
val v = x + 150000
var i = v / 4096
i = if (i < 0) 0 else if (i >= 645) 645 else i
val r = a(i) + (a(i + 1) - a(i)) * (v - (i shl 12)) / 4096
return if (r < 0) 0 else r
}
/** sub_1800158b0: radiance domain -> temperature domain (curve A inverse). */
fun ie2t(l: Int): Int {
var lo = 0
var hi = 645
while (lo < hi) {
val mid = (lo + hi + 1) shr 1
if (a(mid) <= l) lo = mid else hi = mid - 1
}
val i = if (lo > 644) 644 else lo
return ((l - a(i)) * SLOPE[i] shr 12) + (i shl 12) - 150000
}
/** sub_180015A40: radiance-domain delta `d` -> temperature-domain delta at `t`. */
private fun dTemp(t: Int, d: Int): Int {
val v = t + 150000
var i = v / 4096
i = if (i < 0) 0 else if (i >= 645) 645 else i
val seg = a(i + 1) - a(i)
if (seg >= 2 * kotlin.math.abs(d)) return d * SLOPE[i] shr 12
val v2 = d + a(i) + seg * (v - (i shl 12)) / 4096
if (v2 in a(i)..a(i + 1)) return d * SLOPE[i] shr 12
var lo = 0
var hi = 645
while (lo < hi) {
val mid = (lo + hi + 1) shr 1
if (a(mid) <= v2) lo = mid else hi = mid - 1
}
val j = if (lo > 644) 644 else lo
return (((v2 - a(j)) * SLOPE[j] shr 12) + (j shl 12) - 150000) - t
}
/** sub_180015940: radiance-domain delta around a fixed temperature anchor. */
private fun radDelta(t: Int, d: Int): Int {
val v = t + 150000
var i = v / 4096
i = if (i < 0) 0 else if (i >= 645) 645 else i
var j = (v + d) / 4096
j = if (j < 0) 0 else if (j >= 645) 645 else j
if (i == j) return d * (a(i + 1) - a(i)) / 4096
return a(j) + (a(j + 1) - a(j)) * (v + d - (j shl 12)) / 4096 -
(a(i + 1) - a(i)) * (v - (i shl 12)) / 4096 - a(i)
}
/** Per-band linear map of the calibrated counts (rec+0xD4/0xD8/0x30). */
fun bandMap(counts: Int, rec: RadRecord): Int {
var b = 0
val last = rec.thr.size - 1
while (b < last && counts > rec.thr[b]) b++
return rec.off[b] + (counts * rec.gain[b] shr rec.shift)
}
/**
* Mean-count temperature (dev+0x44CB8) — the SDK computes it per frame with
* flag=0 (no ambient-error term) and stores it; the revise ambient derives from it.
*/
fun meanTemperature(
meanCounts: Int,
rec: RadRecord,
fpa: Int,
at: AtPara,
sensorDeltaMc: Int = 0,
): Int {
val t = bandMap(meanCounts, rec)
val v54 = envCompensation(rec, fpa, sensorDeltaMc)
return t + dTemp(t, v54)
}
/**
* v50/v54 of `sub_180016290`. On the reference capture the SDK's four sensor
* words reduce the term to `v50 = dev+0x5C - dev+0x54` (measured 0…89 mC):
* the filtered-minus-instantaneous FPA temperature. Our reader only gets the
* frame's FPA word, so the difference is supplied as [sensorDeltaMc]
* (0 = the term is skipped, bounded error ≤0.09 C on the reference device).
*/
private fun envCompensation(rec: RadRecord, fpa: Int, sensorDeltaMc: Int): Int {
val v45 = fpa + rec.tB - rec.tA // dev+0x58
val dev5c = fpa + sensorDeltaMc // dev+0x5C
val dev60 = dev5c + rec.tB - rec.tA // dev+0x60
val v47 = 0 // dev+0x58 == v45 by construction
val v50 = v45 + 2 * (dev5c - fpa) - dev60 + v47
val v49 = v45 + 150000
var i52 = v49 / 4096
i52 = if (i52 < 0) 0 else if (i52 >= 645) 645 else i52
var i53 = (v49 + v50) / 4096
i53 = if (i53 < 0) 0 else if (i53 >= 645) 645 else i53
return if (i52 == i53) {
v50 * (a(i52 + 1) - a(i52)) / 4096
} else {
a(i53) + (a(i53 + 1) - a(i53)) * (v49 + v50 - (i53 shl 12)) / 4096 -
(a(i52 + 1) - a(i52)) * (v49 - (i52 shl 12)) / 4096 - a(i52)
}
}
/**
* Official per-pixel temperature, the path `MAG_GetTemperatureData(flag=1)`
* takes (per-pixel conversion + revise).
*
* @param counts calibrated (post-NUC) counts of one pixel
* @param fpa frame's sensor temperature = frame tail word (+8)
* @param meanTemp mean-count temperature of the same frame ([meanTemperature])
* @param emissivity "temperature fix" emissivity (device default 0.98)
* @param sensorDeltaMc filtered-minus-instantaneous FPA temperature (see
* [envCompensation]; 0 until the SDK's source for it is mapped)
*/
fun temperature(
counts: Int,
rec: RadRecord,
fpa: Int,
meanTemp: Int,
at: AtPara,
emissivity: Float = 0.98f,
transmittance: Float = 1.0f,
sensorDeltaMc: Int = 0,
): Int {
val t0 = bandMap(counts, rec)
var v54 = envCompensation(rec, fpa, sensorDeltaMc)
// ambient-temperature error term (sub_180016290 v68!=0 block):
// arg = (meanTemp - ATZeroErrorPoint) * ATErrorSlope
val v56 = if (rec.distHi - rec.distLo > 50000) {
meanTemp.coerceIn(-80000, 80000)
} else {
meanTemp.coerceIn(5000, 40000)
}
v54 += radDelta(35000, ((v56 - at.atZeroErrorPoint) * at.atErrorSlope).toInt())
val t = t0 + dTemp(t0, v54)
// caller-side revise (sub_180015b90), flag=1 path
val k = transmittance * (rec.eps.coerceAtMost(1f)) * emissivity
if (k <= 0f) return t
val ambientC = meanTemp / 1000f
val lamb = e2t((ambientC * 1000f).toInt())
var rev = ie2t(((e2t(t) - ((1f - k) * lamb)).toFloat() / k).toInt())
if (rev < -273150) rev = -273150
// dev+0xAC..0xC8 ambient table and dev+0xEC/0xF0 gain trim are zero for
// MAG160C cores (measured); they are applied by the SDK only when set.
return rev
}
/** Temperature for a pixel of a NUC frame, or null when params are missing. */
fun temperatureOrNull(
counts: Int,
rec: RadRecord?,
fpa: Int,
meanTemp: Int?,
at: AtPara?,
): Int? {
if (rec == null || at == null || meanTemp == null) return null
return temperature(counts, rec, fpa, meanTemp, at)
}
}
@@ -20,6 +20,17 @@ class RenderPipeline(
private val ffcPeriod: Int = 1800, private val ffcPeriod: Int = 1800,
private val ffcDrift: Int = 250, private val ffcDrift: Int = 250,
private val warmFrames: Int = 20, private val warmFrames: Int = 20,
/**
* Number of *settled* shutter frames averaged into the FFC reference.
*
* The official core feeds exactly this many shutter frames into
* `CAccumulator` (`CAccumulator::PushFrame` with mode >= 4 is a block average
* of exactly `mode` frames, and `Controller::PushFrame` calls
* `PushBaselineRaw` for A+1..A+E with E = BasePara2.baseLineAcc = 4), so
* default 4 is also the official behaviour and keeps the byte-exact C
* reference output for the tests.
*/
private val baselineFrames: Int = 4,
private val cdfPivot75: Boolean = true, private val cdfPivot75: Boolean = true,
private val force75: Boolean = true, private val force75: Boolean = true,
/** FFC command callback: param 0 = FFC(0), 1 = FFC(1). */ /** FFC command callback: param 0 = FFC(0), 1 = FFC(1). */
@@ -33,6 +44,38 @@ class RenderPipeline(
* app derives its value from its enhancement level as `level shl 3`. * app derives its value from its enhancement level as `level shl 3`.
*/ */
private var enhanceStrength: Int = 0, private var enhanceStrength: Int = 0,
/**
* Use the Android libcxsdk variant of `CFunctions::HistDeRedundance` for the
* grey LUT instead of the PC-SDK one the port started from.
*
* The two differ structurally, not just in constants (IDA disassembly of the
* phone's libcxsdk.so `_ZN10CFunctions16HistDeRedundanceEv`):
* - the LUT base is FIXED: `16*(448 + 447*brightness/200)` (PC: an
* auto-centre binary search over a temporally smoothed level);
* - the per-bin step cap is `step[j] = max(1, (0x1800 + 4j)*k >> 13)` with
* `k = min(0x200, 16*(winHi-winLo)/range)`;
* - the backward CDF walk starts at `hist[pivot]` (PC: `hist[pivot+1]`) and
* `hist[1023]` is forced to `hist[1022]` after the 3-tap smoothing;
* - the pivot branch (`theFunc._96_4_`: set to 1 by the constructor and
* never cleared by the app) moves the anchor to
* `max(center_index, (meanBelow + meanAbove) >> 1)` - not a 75% quantile.
*
* The live session turns this on (the official phone app renders with the
* Android variant: aligning our stored NUC against an official photo shows the
* same counts mapped by a compressed+lifted curve, not by the PC one - see
* docs/android_app/rendering_diff_phone_20261006.md §10). The default stays
* false so the C-reference golden tests keep comparing against the PC pipeline
* they were baselined on.
*/
var androidLut: Boolean = false,
/**
* `BasePara2.fpaTempFix`, added to the frame-tail FPA before the per-pixel
* calibration tables are interpolated (the official app passes
* `fpaTemp_ + basePara2.fpaTempFix` into the SDK, and
* `SelectCaliCurve`/`GenerateVolatileLens` use that value). The temperature
* chain keeps the raw tail value.
*/
var fpaTempFix: Int = 0,
) { ) {
private val npix = w * h private val npix = w * h
@@ -85,11 +128,29 @@ class RenderPipeline(
/** True while [frameRemote] is inside the host's reference window. */ /** True while [frameRemote] is inside the host's reference window. */
private var remoteRefWindow = false private var remoteRefWindow = false
/**
* Raw payload of the frame whose NUC output is currently in [nuc] (i.e. the
* frame that the last completed render consumed).
*
* The photo writer used to store [IrSession.lastRawFrame], which is updated for
* *every* streamed frame - including the closed-shutter frames of an FFC window.
* A photo taken just after an FFC therefore carried a shutter frame next to a
* scene NUC block, so the two blocks did not belong to the same exposure (they
* do not even correlate). Anything offline that pairs them - a re-render, the
* analysis screen, this project's own comparisons - was working on mismatched
* data.
*/
private val lastRenderRaw = ByteArray(npix * 2)
private var lastRenderRawValid = false
private val ref = IntArray(npix) private val ref = IntArray(npix)
/** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */ /** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */
private val detailEnhance = DetailEnhance(w, h) private val detailEnhance = DetailEnhance(w, h)
/** User brightness offset for the Android LUT base (0 = official default). */
private var lutBrightness = 0
private val refAcc = IntArray(npix) private val refAcc = IntArray(npix)
private val nuc = IntArray(npix) private val nuc = IntArray(npix)
private val gray160 = ByteArray(npix) private val gray160 = ByteArray(npix)
@@ -224,8 +285,9 @@ class RenderPipeline(
for (i in 0 until npix) refAcc[i] += frame[i] for (i in 0 until npix) refAcc[i] += frame[i]
} }
refCnt++ refCnt++
if (refCnt >= 4) { if (refCnt >= baselineFrames.coerceIn(1, 64)) {
for (i in 0 until npix) ref[i] = refAcc[i] shr 2 val n = refCnt
for (i in 0 until npix) ref[i] = refAcc[i] / n
refCnt = 0 refCnt = 0
hasRef = true hasRef = true
} }
@@ -304,6 +366,10 @@ class RenderPipeline(
} }
private fun lutRebuild() { private fun lutRebuild() {
if (androidLut) {
lutRebuildAndroid()
return
}
// The vendor code is 32-bit unsigned arithmetic; products/subtractions // The vendor code is 32-bit unsigned arithmetic; products/subtractions
// wrap mod 2^32. u32() replicates that so results match bit-for-bit. // wrap mod 2^32. u32() replicates that so results match bit-for-bit.
val span = (winHi - winLo).coerceAtLeast(1) val span = (winHi - winLo).coerceAtLeast(1)
@@ -469,6 +535,33 @@ class RenderPipeline(
} }
} }
/**
* `CFunctions::HistDeRedundance` as compiled into the official app's
* libcxsdk.so (IDA: `_ZN10CFunctions16HistDeRedundanceEv`, ARM/Thumb). Every
* constant below is read off that disassembly; the PC variant in [lutRebuild]
* is kept only for the C-reference golden tests.
*
* - histogram: `idx = ((count - winLo) * (0xFFC00000/span)) >> 20 & 0xFFC`
* - smoothing: `hist[j] = (hist[j+1] + hist[j]) >> 1` (original values, one
* pass bottom-up) and `hist[1023] = hist[1022]`
* - pivot = `1023*(center-winLo)/span`, raised (never lowered) to
* `(meanBelow + meanAbove) >> 1` when `this+0x60 != 0` (the constructor
* sets it to 1 and the app never clears it)
* - forward CDF (0x100 steps per `pixels>>12` carry) walks pivot -> 0,
* backward CDF walks `hist[pivot]` upward writing `cdf[k+1]`
* - step cap `max(1, (0x1800 + 4j) * k >> 13)`, `k = min(0x200, 16*span/range)`
* - LUT base `16*(448 + v66)` (forward, minus the CDF term) and
* `16*v66 + 7152` (backward, plus the CDF term), `v66 = 447*brightness/200`
*/
private fun lutRebuildAndroid() {
val lutRange = ((dev24 * 1000) shr dev4cShift).coerceAtLeast(0x80)
val fresh = androidLutOf(nuc, winLo, winHi, statMean, lutRange, lutBrightness)
// element copy on purpose: System.arraycopy cannot move ints into a ByteArray
for (j in 0 until 1023) lut[j] = fresh[j].toByte()
// lut[1023] is intentionally left untouched: the vendor loop stops at 1022,
// and MapTemperature can index 1023 for pixels at/above winHi.
}
private fun grayMap() { private fun grayMap() {
val s = 0xFFC00000L / (winHi - winLo).coerceAtLeast(1) val s = 0xFFC00000L / (winHi - winLo).coerceAtLeast(1)
for (i in 0 until npix) { for (i in 0 until npix) {
@@ -497,6 +590,12 @@ class RenderPipeline(
enhanceStrength = value.coerceIn(0, 256) enhanceStrength = value.coerceIn(0, 256)
} }
/** True when the Android `HistDeRedundance` variant drives the grey LUT. */
fun usesAndroidLut(): Boolean = synchronized(lock) { androidLut }
/** Brightness offset for the Android LUT base (official App passes 0). */
fun setLutBrightnessOffset(value: Int) = synchronized(lock) { lutBrightness = value }
fun enhanceStrength(): Int = synchronized(lock) { enhanceStrength } fun enhanceStrength(): Int = synchronized(lock) { enhanceStrength }
/** /**
@@ -606,18 +705,21 @@ class RenderPipeline(
ffcIdx++ ffcIdx++
val idx = ffcIdx val idx = ffcIdx
if (idx <= 5) { if (idx <= 5) {
if (idx == 1) rebuildTables(shutterAtFfc) // the app-supplied FPA carries BasePara2.fpaTempFix (official
// UsbCommunication.startTransfer: fpaTemp_ + fpaTempFix)
if (idx == 1) rebuildTables(shutterAtFfc + fpaTempFix)
return 1 return 1
} }
if (idx <= 9) { val refEnd = 5 + baselineFrames.coerceIn(1, 64)
if (idx == 9) onFfc?.invoke(1) if (idx <= refEnd) {
if (idx == refEnd) onFfc?.invoke(1)
if (idx == 6) { if (idx == 6) {
refCnt = 0 refCnt = 0
hasRef = false hasRef = false
} }
return 2 // ref collection window return 2 // ref collection window
} }
if (idx <= 13) return 1 if (idx <= refEnd + 4) return 1
// normal frame: FFC condition // normal frame: FFC condition
var drift = shutterIn - shutterAtFfc var drift = shutterIn - shutterAtFfc
if (drift < 0) drift = -drift if (drift < 0) drift = -drift
@@ -671,6 +773,7 @@ class RenderPipeline(
val st = ffcStep(shutterValue) val st = ffcStep(shutterValue)
lastPhase = st lastPhase = st
if (st != 0) { if (st != 0) {
accReady = false // SDK: accumulator Restart() at every FFC
if (st == 2) { if (st == 2) {
// collect the reference in a SCRATCH buffer: decoding into `nuc` // collect the reference in a SCRATCH buffer: decoding into `nuc`
// would leave raw (uncompensated) counts there, and the OSD // would leave raw (uncompensated) counts there, and the OSD
@@ -685,8 +788,11 @@ class RenderPipeline(
val off = if (hasHdr) 0x1C else 0 val off = if (hasHdr) 0x1C else 0
ByteReader.decodeU16(frame, off, npix, nuc) ByteReader.decodeU16(frame, off, npix, nuc)
if (!hasRef) return false if (!hasRef) return false
accumulateInPlace()
nucAndBlind(nuc, nuc) nucAndBlind(nuc, nuc)
System.arraycopy(frame, off, lastRenderRaw, 0, npix * 2)
lastRenderRawValid = true
statsWindow() statsWindow()
lutRebuild() lutRebuild()
grayMap() grayMap()
@@ -732,7 +838,7 @@ class RenderPipeline(
refCnt = 0 refCnt = 0
hasRef = false hasRef = false
renderedOnce = false renderedOnce = false
rebuildTables(shutterIn) rebuildTables(shutterIn + fpaTempFix)
} }
ByteReader.decodeU16(frame, 0, npix, refScratch) ByteReader.decodeU16(frame, 0, npix, refScratch)
refPush(refScratch) refPush(refScratch)
@@ -794,7 +900,88 @@ class RenderPipeline(
/** Temperature probe on the current NUC output, in millidegrees C. */ /** Temperature probe on the current NUC output, in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int = synchronized(lock) { fun probeTemp(x: Int, y: Int): Int = synchronized(lock) {
TempMath.countsToTempMc(nuc[y * w + x]) val v = nuc[y * w + x]
val rec = radRecord
val at = atPara
if (rec != null && at != null) {
val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at)
OfficialTemp.temperature(v, rec, shutter, mean, at)
} else {
TempMath.countsToTempMc(v)
}
}
// ---- official temperature chain parameters (BasePara2 + DDT tail record) ----
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
}
}
/** True when official temperatures can be computed (params available). */
fun hasOfficialTemp(): Boolean = synchronized(lock) { radRecord != null && atPara != null }
/** Mean-count temperature of the current frame (official dev+0x44CB8). */
fun officialMeanTempMc(): Int? = synchronized(lock) {
val rec = radRecord ?: return null
val at = atPara ?: return null
OfficialTemp.meanTemperature(statMean, rec, shutter, at)
}
/**
* Convert a NUC buffer to official temperatures (mC) in one go.
* @return false when the official parameters are unavailable — the caller
* should fall back to [TempMath.countsToTempMc].
*/
fun officialTemps(nucIn: IntArray, out: IntArray): Boolean = synchronized(lock) {
val rec = radRecord ?: return false
val at = atPara ?: return false
val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at)
val n = minOf(nucIn.size, out.size)
for (i in 0 until n) {
out[i] = OfficialTemp.temperature(nucIn[i], rec, shutter, mean, at)
}
return true
} }
/** Read the current NUC counts buffer (already blind-compensated). */ /** Read the current NUC counts buffer (already blind-compensated). */
@@ -802,6 +989,17 @@ class RenderPipeline(
System.arraycopy(nuc, 0, out, 0, minOf(npix, out.size)) System.arraycopy(nuc, 0, out, 0, minOf(npix, out.size))
} }
/**
* Raw payload (19200 x uint16) of the frame that produced the current NUC
* counts, or null before the first completed render. Paired with [copyNuc]
* this is a consistent (raw, nuc) exposure for the photo's data blocks; the
* session's "latest raw frame" is not, because it also advances during the
* closed-shutter frames of an FFC window.
*/
fun copyLastRenderRaw(): ByteArray? = synchronized(lock) {
if (!lastRenderRawValid) null else lastRenderRaw.copyOf()
}
/** Debug: full intermediate state, same layout as analysis/debug_lut.c. */ /** Debug: full intermediate state, same layout as analysis/debug_lut.c. */
internal fun debugState(): String = synchronized(lock) { internal fun debugState(): String = synchronized(lock) {
val sb = StringBuilder() val sb = StringBuilder()
@@ -819,4 +1017,152 @@ class RenderPipeline(
sb.append('\n') sb.append('\n')
sb.toString() sb.toString()
} }
companion object {
/**
* Pure port of `CFunctions::HistDeRedundance` as compiled into the official
* app's libcxsdk.so (IDA: `_ZN10CFunctions16HistDeRedundanceEv`, ARM/Thumb).
* Every constant below is read off that disassembly; the PC variant in
* [lutRebuild] is kept only for the C-reference golden tests.
*
* - histogram: `idx = ((count - winLo) * (0xFFC00000/span)) >> 20 & 0xFFC`
* - smoothing: `hist[j] = (hist[j+1] + hist[j]) >> 1` (reading the
* un-smoothed neighbour, one pass upward); `hist[1023] = hist[1022]`
* - pivot = `1023*(center-winLo)/span`, raised (never lowered) to
* `(meanBelow + meanAbove) >> 1` while `theFunc._96_4_ != 0` (the
* constructor sets it to 1 and the app never clears it)
* - forward CDF walks pivot -> 0, backward CDF walks `hist[pivot]` upward
* writing `cdf[k+1]`; both count a carry per `pixels >> 12`
* - step cap `max(1, (0x1800 + 4j) * k >> 13)` with
* `k = min(0x200, 16*span/lutRange)`, or 1 when `lutRange > 16*span`
* - LUT base `16*(448 + v66)` minus the CDF term (forward) and
* `16*v66 + 7152` plus it (backward), `v66 = 447*brightness/200`
* - lut[1023] is left zero: the vendor loop stops at 1022
*/
internal fun androidLutOf(
nuc: IntArray,
winLo: Int,
winHi: Int,
statMean: Int,
lutRange: Int,
lutBrightness: Int = 0,
): IntArray {
val npix = nuc.size
val span = (winHi - winLo).coerceAtLeast(1)
val s = 0xFFC00000L / span
val hist = IntArray(1024)
for (i in 0 until npix) {
val idx = (u32((nuc[i] - winLo).toLong() * s) shr 20) and 0xFFC
hist[(idx shr 2).toInt()]++
}
var i = 0
while (i < 1023) {
hist[i] = (hist[i + 1] + hist[i]) shr 1
i++
}
hist[1023] = hist[1022]
val v82 = (npix shr 12).coerceAtLeast(1)
var pivot = (0x3FFL * (statMean - winLo) / span).toInt().coerceIn(0, 1023)
run {
var belowN = 0L
var belowS = 0L
for (k in 0 until pivot) {
val h = hist[k].toLong()
belowN += h
belowS += h * k
}
var aboveN = 0L
var aboveS = 0L
for (k in pivot + 1 until 1024) {
val h = hist[k].toLong()
aboveN += h
aboveS += h * k
}
if (belowN != 0L && aboveN != 0L) {
val m = ((aboveS / aboveN) + (belowS / belowN)) shr 1
if (m > pivot) pivot = m.toInt()
}
}
val cdfF = IntArray(1024)
val cdfB = IntArray(1024)
run {
var acc = 0
var steps = 0
val unit = 0x10000 / v82
for (j in pivot downTo 0) {
acc += hist[j]
if (acc >= v82) {
acc = 0
steps++
}
cdfF[j] = (steps shl 8) + ((unit * acc) shr 8)
}
}
run {
var acc = 0
var steps = 0
var j = pivot
val unit = 0x10000 / v82
while (j < 1023) {
acc += hist[j]
if (acc >= 8 * v82) {
steps += 2
acc = 0
} else if (acc >= v82) {
steps += 1
acc = 0
}
cdfB[j] = ((unit * acc) shr 8) + (steps shl 8)
j++
}
}
val v66 = 447 * lutBrightness.coerceIn(-100, 100) / 200
val range = lutRange.coerceAtLeast(1)
var k = (16 * span) / range
if (k >= 0x200) k = 512
if (range > 16 * span) k = 1
val step = IntArray(1024)
var acc6 = 0x1800 * k
for (j in 0 until 1024) {
val v = acc6 shr 13
step[j] = if (v != 0) v else 1
acc6 += 4 * k
}
val lut = IntArray(1024)
val base = 16 * (v66 + 448)
if (cdfF[0] != 0) {
val denom = ((v66 + 448) shl 18) / cdfF[0]
var prev = base
var j = pivot
while (j >= 0) {
val cand = base - (u32(cdfF[j].toLong() * denom) shr 14).toInt()
var v = prev - step[j]
if (prev - cand <= step[j]) v = cand
lut[j] = (v shr 6) and 0xFF
prev = v
j--
}
}
if (cdfB[1022] != 0) {
val denom = ((576 - v66) shl 18) / cdfB[1022]
var prev = 16 * v66 + 7152
var j = pivot + 1
while (j < 1023) {
val cand = (u32(cdfB[j].toLong() * denom) shr 14).toInt() + 16 * v66 + 7152
var v = prev + step[j + 1]
if (cand - prev <= step[j + 1]) v = cand
lut[j] = (v shr 6) and 0xFF
prev = v
j++
}
}
return lut
}
private fun u32(x: Long): Long = x and 0xFFFFFFFFL
}
} }
@@ -701,10 +701,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
}, },
extremes = extremes, extremes = extremes,
) )
val rawFrame = s.lastRawFrame // The raw block must correspond to the SAME exposure as the NUC block
val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) { // below: the session's "latest frame" also advances during the
rawFrame.copyOfRange(0x1C, 0x1C + 38400) // closed-shutter frames of an FFC window, so a photo taken right after an
} else null // FFC used to pair a shutter raw frame with a scene NUC block.
val pixels = s.copyLastRenderRaw()
// probes are stored in SENSOR coordinates — the same space the photo uses, // probes are stored in SENSOR coordinates — the same space the photo uses,
// so the analysis screen needs no conversion either // so the analysis screen needs no conversion either
val probeBlock = com.mag160c.thermal.media.Mdt.encodeProbes( val probeBlock = com.mag160c.thermal.media.Mdt.encodeProbes(
@@ -905,6 +906,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */ /** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */
private val nucScratch = IntArray(19200) private val nucScratch = IntArray(19200)
private val nucTemps = IntArray(19200)
/** True while the background temperature loop runs. */ /** True while the background temperature loop runs. */
private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false) private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false)
@@ -955,24 +957,37 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val centerMc = session.probeTemp(80, 60) val centerMc = session.probeTemp(80, 60)
if (!session.copyNuc(nucScratch)) return if (!session.copyNuc(nucScratch)) return
// Official radiometric chain (BasePara2 AT params + DDT band record);
// falls back to the legacy T2E curve when those inputs are missing.
val official = session.officialTemps(nucScratch, nucTemps)
// scan off the main thread: 19200 iterations plus probe conversions // scan off the main thread: 19200 iterations plus probe conversions
var mn = Int.MAX_VALUE var mn = Int.MAX_VALUE
var mx = -1 var mx = -1
var mnPos = -1 var mnPos = -1
var mxPos = -1 var mxPos = -1
var mnT = Int.MAX_VALUE
var mxT = Int.MIN_VALUE
for (i in nucScratch.indices) { for (i in nucScratch.indices) {
val v = nucScratch[i] val v = nucScratch[i]
if (v < mn) { mn = v; mnPos = i } if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i } if (v > mx) { mx = v; mxPos = i }
} if (official) {
val probeTemps = _state.value.probes.map { p -> val t = nucTemps[i]
(p.x + p.y * 160).let { idx -> if (t < mnT) mnT = t
if (idx in nucScratch.indices) TempMath.countsToTempMc(nucScratch[idx]) / 1000f if (t > mxT) mxT = t
else 0f
} }
} }
val minT = if (mn != Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null fun tempAt(idx: Int): Float = if (idx in nucScratch.indices) {
val maxT = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null (if (official) nucTemps[idx] else TempMath.countsToTempMc(nucScratch[idx])) / 1000f
} else {
0f
}
val probeTemps = _state.value.probes.map { p -> tempAt(p.x + p.y * 160) }
val minT = if (mn == Int.MAX_VALUE) null
else (if (official) mnT else TempMath.countsToTempMc(mn)) / 1000f
val maxT = if (mx < 0) null
else (if (official) mxT else TempMath.countsToTempMc(mx)) / 1000f
val centerT = centerMc?.let { it / 1000f } val centerT = centerMc?.let { it / 1000f }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -4,6 +4,7 @@ import android.content.Context
import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbEndpoint
import com.mag160c.thermal.core.FrameStream import com.mag160c.thermal.core.FrameStream
import com.mag160c.thermal.core.OfficialTemp
import com.mag160c.thermal.core.RenderPipeline import com.mag160c.thermal.core.RenderPipeline
import com.mag160c.thermal.media.DebugLog import com.mag160c.thermal.media.DebugLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -126,6 +127,22 @@ class IrSession(context: Context) {
var lastInfo1: ByteArray? = null var lastInfo1: ByteArray? = null
private set private set
/**
* Radiometric band record parsed from the loaded DDT tail — the input the
* official temperature chain uses (see [com.mag160c.thermal.core.OfficialTemp]).
*/
@Volatile
var radRecord: OfficialTemp.RadRecord? = null
private set
/**
* Device parameters from BasePara2 (ATZeroErrorPoint / ATErrorSlope /
* fpaTempFix) consumed by the official temperature chain.
*/
@Volatile
var atPara: OfficialTemp.AtPara? = null
private set
/** /**
* Detail enhancement strength requested before a pipeline existed (the * Detail enhancement strength requested before a pipeline existed (the
* pipeline is built during connect, so a setting change made while * pipeline is built during connect, so a setting change made while
@@ -251,11 +268,17 @@ class IrSession(context: Context) {
parseBasePara1(r1.third) parseBasePara1(r1.third)
DebugLog.log("session", "identity $identity") DebugLog.log("session", "identity $identity")
// 2) GetParameter2 -> BasePara2 (log only) // 2) GetParameter2 -> BasePara2 (temperature-chain parameters)
writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2") writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2")
val r2 = readResp(conn, epResp, "GetParameter2") val r2 = readResp(conn, epResp, "GetParameter2")
if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) { if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) {
lastInfo1 = r2.third.copyOf() lastInfo1 = r2.third.copyOf()
atPara = OfficialTemp.parseBasePara2(r2.third)
DebugLog.log(
"cmd",
"BasePara2 AT: zero=${atPara?.atZeroErrorPoint} slope=${atPara?.atErrorSlope} " +
"fpaFix=${atPara?.fpaTempFix}",
)
} }
// 3) GetCaliInfo -> {size, reserved, date} // 3) GetCaliInfo -> {size, reserved, date}
@@ -295,6 +318,17 @@ class IrSession(context: Context) {
// 5) native startProcess(caliPath) == pipeline + loadDdt // 5) native startProcess(caliPath) == pipeline + loadDdt
val pipe = RenderPipeline( val pipe = RenderPipeline(
w = identity.width, h = identity.height, w = identity.width, h = identity.height,
// The live pipeline follows the ANDROID libcxsdk rather than the PC one:
// the gray LUT is CFunctions::HistDeRedundance as compiled into
// libcxsdk.so (fixed base, pivot branch ON with the mean-of-partials
// anchor, no auto-centre search). See RenderPipeline.lutRebuildAndroid
// and docs/android_app/rendering_diff_phone_20261006.md §10.
androidLut = 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
// extra frames poison the reference. A shutter-aware reference
// collection is the follow-up (docs/android_app/rendering_diff_phone_20261006.md §9.4).
onFfc = { param -> onFfc = { param ->
sendCmd( sendCmd(
MagProtocol.cmd8(MagProtocol.CMD_SET_SHUTTER_STATE, param), MagProtocol.cmd8(MagProtocol.CMD_SET_SHUTTER_STATE, param),
@@ -304,6 +338,10 @@ class IrSession(context: Context) {
// the user's detail-enhancement choice must survive a reconnect, so it // the user's detail-enhancement choice must survive a reconnect, so it
// is applied at construction rather than only when the setting changes // is applied at construction rather than only when the setting changes
enhanceStrength = pendingEnhanceStrength, enhanceStrength = pendingEnhanceStrength,
// the app-supplied FPA carries BasePara2.fpaTempFix for the per-pixel
// table interpolation (official UsbCommunication.startTransfer); it is
// set from the device's parameter block below
fpaTempFix = 0,
) )
if (!pipe.loadDdt(ddt)) { if (!pipe.loadDdt(ddt)) {
DebugLog.log("session", "ddt_fail (fetched/bundled ${ddt.size} B not loadable)") DebugLog.log("session", "ddt_fail (fetched/bundled ${ddt.size} B not loadable)")
@@ -313,6 +351,19 @@ class IrSession(context: Context) {
return return
} }
} }
// radiometric band record rides in the DDT tail (official temperature chain)
radRecord = OfficialTemp.parseDdtTail(ddt)
?: bundledDdt.takeIf { ddt !== it }?.let { OfficialTemp.parseDdtTail(it) }
DebugLog.log(
"session",
"radRecord: ${radRecord?.let { "bands=${it.thr.size} shift=${it.shift} eps=${it.eps}" } ?: "none"}",
)
pipe.setTempParams(radRecord, atPara)
// Official UsbCommunication passes `fpaTemp_ + basePara2.fpaTempFix` into the
// SDK, and the per-pixel calibration tables are interpolated on that value
// (SelectCaliCurve / GenerateVolatileLens). Without the fix our table
// interpolation sits 1699 units (device value) below the official's.
atPara?.fpaTempFix?.let { pipe.fpaTempFix = it }
pipeline = pipe pipeline = pipe
active = this active = this
@@ -709,6 +760,16 @@ class IrSession(context: Context) {
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */ /** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y) fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
/**
* Convert a NUC buffer to official temperatures (mC) in one call.
* @return false when the official chain parameters are unavailable.
*/
fun officialTemps(nucIn: IntArray, out: IntArray): Boolean =
pipeline?.officialTemps(nucIn, out) ?: false
/** True when the official temperature chain can run (params parsed). */
fun hasOfficialTemp(): Boolean = pipeline?.hasOfficialTemp() ?: false
/** True once a frame completed the render path (temperatures are meaningful). */ /** True once a frame completed the render path (temperatures are meaningful). */
fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
@@ -719,6 +780,13 @@ class IrSession(context: Context) {
fun lastShutter(): Int = pipeline?.lastShutter() ?: 0 fun lastShutter(): Int = pipeline?.lastShutter() ?: 0
/** Snapshot of the current NUC counts (already blind-compensated). */ /** Snapshot of the current NUC counts (already blind-compensated). */
/**
* Raw payload of the frame behind the current NUC counts (see
* RenderPipeline.copyLastRenderRaw). Photo capture uses this pair so the MDT's
* FRAME and NUC blocks are the same exposure.
*/
fun copyLastRenderRaw(): ByteArray? = pipeline?.copyLastRenderRaw()
fun copyNuc(out: IntArray): Boolean { fun copyNuc(out: IntArray): Boolean {
val p = pipeline ?: return false val p = pipeline ?: return false
if (!running.get()) return false if (!running.get()) return false
@@ -83,4 +83,8 @@ object MagProtocol {
((b[off + 1].toInt() and 0xFF) shl 8) or ((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or ((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24) ((b[off + 3].toInt() and 0xFF) shl 24)
/** Little-endian u16 (zero-extended). */
fun u16(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
} }
@@ -0,0 +1,194 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* End-to-end guard for the Android-LUT live configuration.
*
* `AndroidLutTest` pins the pure LUT function, but the *instance* path (stats ->
* androidLutOf -> the byte LUT -> gray -> upscale -> palette) can still break on
* its own — a `System.arraycopy` from the int LUT into the byte LUT threw
* ArrayStoreException on every frame and left the live view black while the pure
* test stayed green. This test therefore drives real frames through the pipeline
* with `androidLut = true` and asserts the output actually renders.
*/
class AndroidLutPipelineTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
private fun frame(counter: Int, filler: Int, fpa: Int = 19000): 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 38400) f[0x1C + i] = ((filler + i) and 0xFF).toByte()
MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 4, counter)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, fpa)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 12, 0)
return f
}
private fun pipeline(androidLut: Boolean, fpaFix: Int = 1699): RenderPipeline =
RenderPipeline(
w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2,
force75 = false, androidLut = androidLut, fpaTempFix = fpaFix, onFfc = {},
).also { assertTrue("DDT must load", it.loadDdt(res("mag160c_official.ddt"))) }
private fun renderOnce(p: RenderPipeline): IntArray {
val out = IntArray(320 * 240)
var counter = 0
while (counter < 200) {
if (p.frame(frame(counter, counter), true, out)) return out
counter++
}
throw AssertionError("pipeline never rendered")
}
@Test
fun androidLutPipelineRendersNonBlackFrames() {
val out = renderOnce(pipeline(androidLut = true))
val distinct = out.toSet().size
assertTrue("expected a mapped palette image, got $distinct distinct colours", distinct > 4)
// the warm-up frame is black on purpose; a broken LUT would leave every
// frame black, which is what this guards against
assertFalse("every pixel is black - LUT or palette path is broken",
out.all { (it and 0x00FFFFFF) == 0 })
}
@Test
fun androidLutAndPcLutBothRenderAndDiffer() {
val a = renderOnce(pipeline(androidLut = true))
val b = renderOnce(pipeline(androidLut = false))
assertFalse("the two LUT variants must not produce identical pixels", a.contentEquals(b))
}
@Test
fun fpaTempFixShiftsTheInterpolatedTables() {
// the fix moves the interpolation temperature, so with a DDT whose endpoint
// spacing is thousands of units the rendered pixels must change
val withFix = renderOnce(pipeline(androidLut = true, fpaFix = 1699))
val without = renderOnce(pipeline(androidLut = true, fpaFix = 0))
assertFalse("fpaTempFix had no effect on the per-pixel tables",
withFix.contentEquals(without))
}
@Test
fun androidLutRendersFramesAfterAnFfc() {
// a manual FFC restarts the reference windows and swaps the tables; the
// Android LUT must survive that transition
val p = pipeline(androidLut = true)
renderOnce(p)
p.requestFfc()
renderOnce(p)
val out = IntArray(320 * 240)
var counter = 100
var ok = false
while (counter < 400 && !ok) {
ok = p.frame(frame(counter, counter), true, out)
counter++
}
assertTrue("no frame rendered after the FFC", ok)
}
}
/**
* Regression for the MDT data blocks: the photo's raw frame must be the one whose
* NUC output the photo also carries.
*
* The writer used to store the session's "latest raw frame", which advances on
* every streamed frame - including the closed-shutter frames of an FFC window - so
* a photo taken right after an FFC paired a *shutter* raw block with a *scene* NUC
* block (measured on device: their correlation was -0.17, i.e. they were not even
* the same exposure). Offline re-renders and the analysis screen consume exactly
* that pair.
*/
class MdtRawNucPairTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
/** Scene-like payload: a gradient plus a warm blob, slowly changing per frame. */
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 (y in 0 until 120) {
for (x in 0 until 160) {
val dx = x - 40
val dy = y - 30
val warm = if (dx * dx + dy * dy < 400) 900 else 0
val v = 11500 + (x * 3) / 4 + (y * 5) / 4 + warm
MagProtocolBytes.putU16(f, 0x1C + (y * 160 + x) * 2, v)
}
}
MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 4, counter)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, 19000)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 12, 0)
return f
}
@Test
fun rawBlockMatchesTheNucBlockOfTheSameExposure() {
val p = RenderPipeline(
w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2,
force75 = false, androidLut = true, fpaTempFix = 1699, onFfc = {},
)
assertTrue("DDT must load", p.loadDdt(res("mag160c_official.ddt")))
val out = IntArray(320 * 240)
var counter = 0
var rendered = false
while (counter < 200 && !rendered) {
rendered = p.frame(frame(counter), true, out)
counter++
}
assertTrue("pipeline never rendered", rendered)
val raw = p.copyLastRenderRaw()!!
val nuc = IntArray(19200)
p.copyNuc(nuc)
assertEquals("raw payload size", 38400, raw.size)
// The raw block is dominated by fixed-pattern noise (the shutter reference
// removes ~99% of its variance), so a per-pixel correlation is near zero
// even for a correct pairing. Comparing the LOW-PASS content isolates the
// scene: a shutter frame carries none, and would fail this.
val rx = IntArray(19200) { (raw[it * 2].toInt() and 0xFF) or ((raw[it * 2 + 1].toInt() and 0xFF) shl 8) }
val smoothRaw = smooth5(rx)
val smoothNuc = smooth5(nuc)
val r = correlate(smoothRaw, smoothNuc)
assertTrue("low-passed raw and NUC blocks must show the same scene (r=$r)", r > 0.8)
}
/** 5x5 box blur inside the frame, to drop fixed-pattern noise. */
private fun smooth5(a: IntArray): DoubleArray {
val out = DoubleArray(19200)
for (y in 2 until 118) {
for (x in 2 until 158) {
var s = 0
for (dy in -2..2) for (dx in -2..2) s += a[(y + dy) * 160 + (x + dx)]
out[y * 160 + x] = s / 25.0
}
}
return out
}
private fun correlate(a: DoubleArray, b: DoubleArray): Double {
val n = a.size
var ma = 0.0; var mb = 0.0
for (i in 0 until n) { ma += a[i]; mb += b[i] }
ma /= n; mb /= n
var cov = 0.0; var va = 0.0; var vb = 0.0
for (i in 0 until n) {
val da = a[i] - ma; val db = b[i] - mb
cov += da * db; va += da * da; vb += db * db
}
return cov / kotlin.math.sqrt(va * vb)
}
}
@@ -0,0 +1,91 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Locks the Android `CFunctions::HistDeRedundance` port to the behaviour computed
* from the IDA disassembly of the official app's libcxsdk.so.
*
* The golden numbers were produced by an independent Python transcription of the
* same disassembly (histogram -> 3-tap smoothing -> CDF pair -> step table -> the
* two LUT passes) for the deterministic synthetic frame below, so a change in any
* constant (window, pivot formula, step cap, base) fails here.
*/
class AndroidLutTest {
private val w = 160
private val h = 120
/** Deterministic NUC frame: gradient + a hot disc + a cold disc. */
private fun syntheticFrame(): IntArray {
val nuc = IntArray(w * h)
for (y in 0 until h) {
for (x in 0 until w) {
var v = 9200 + (x * 3) / 4 + (y * 5) / 4
val dx = x - 40
val dy = y - 30
if (dx * dx + dy * dy < 400) v += 900
val ex = x - 120
val ey = y - 90
if (ex * ex + ey * ey < 250) v -= 600
nuc[y * w + x] = v
}
}
return nuc
}
@Test
fun lutMatchesTheDisassemblyDerivedGoldenVector() {
val nuc = syntheticFrame()
val statMean = nuc.sum() / nuc.size
val statMin = nuc.min()
val statMax = nuc.max()
// statsWindow(): base = (dev24*1000)>>3 with the DDT's enlargeRange = 8
val range = maxOf((8 * 1000) shr 3, 0x80)
val winLo = minOf(statMean - range / 2, statMin).coerceAtLeast(0)
val winHi = maxOf(statMean + range / 2, statMax).coerceAtMost(0xFFFF)
assertEquals("synthetic mean", 9367, statMean)
assertEquals("window", 8779 to 10196, winLo to winHi)
val lut = RenderPipeline.androidLutOf(nuc, winLo, winHi, statMean, range)
val idx = intArrayOf(0, 100, 200, 300, 400, 500, 512, 600, 700, 800, 900, 1000, 1023)
val want = intArrayOf(10, 18, 30, 60, 90, 123, 127, 157, 175, 175, 175, 183, 0)
for (k in idx.indices) {
assertEquals("lut[${idx[k]}]", want[k], lut[idx[k]])
}
assertEquals("checksum", 114286, lut.sum())
assertEquals("exactly one zero entry", 1, lut.count { it == 0 })
// the vendor loop stops at 1022, so the top entry stays at its cleared value
assertEquals("lut[1023] untouched", 0, lut[1023])
}
@Test
fun livePipelineUsesTheAndroidLutAndFpaTemperatureFix() {
val p = RenderPipeline(w = w, h = h)
assertFalse("default (C-reference) path stays PC", p.usesAndroidLut())
p.androidLut = true
assertTrue(p.usesAndroidLut())
assertEquals(0, p.fpaTempFix)
p.fpaTempFix = 1699
assertEquals(1699, p.fpaTempFix)
}
@Test
fun brightnessOffsetRaisesTheWholeCurve() {
val nuc = syntheticFrame()
val statMean = nuc.sum() / nuc.size
val range = 1000
val winLo = minOf(statMean - range / 2, nuc.min())
val winHi = maxOf(statMean + range / 2, nuc.max())
val plain = RenderPipeline.androidLutOf(nuc, winLo, winHi, statMean, range)
val bright = RenderPipeline.androidLutOf(nuc, winLo, winHi, statMean, range, lutBrightness = 50)
// v66 = 447*50/200 = 111 -> base moves up by 16*111/64 = 27 index units
for (i in 100..900) {
assertTrue("brighter lut at $i", bright[i] > plain[i])
}
}
}
@@ -0,0 +1,165 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Differential test for the official radiometric temperature chain
* ([OfficialTemp]) against a frame captured from the official ThermalSDK
* (same counts buffer + same `MAG_GetTemperatureData` output).
*
* Fixture `official_temp_fixture.bin` (400 sampled pixels, frame 0 of the
* 2026-10-06 capture, mean |delta| of the reference model = 13.9 mC):
*
* magic "MTF1"
* i32 fpa, i32 meanTemp, i32 atZeroErrorPoint, f32 atErrorSlope, i32 fpaTempFix
* f32 eps
* i32 shift, i32 tA, i32 tB, i32 bands
* i32 thr[bands], i32 gain[bands], i32 off[bands]
* i32 n, then n*(i32 counts, i32 officialTempMc)
*/
class OfficialTempTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
private class Fixture(bytes: ByteArray) {
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val fpa: Int
val meanTemp: Int
val at: OfficialTemp.AtPara
val rec: OfficialTemp.RadRecord
val pts: List<Pair<Int, Int>>
/** `dev+0x5C - dev+0x54` of the captured frame (the SDK's v50). */
val sensorDeltaMc: Int
init {
val magic = ByteArray(4).also { bb.get(it) }
assertEquals("MTF1", String(magic, Charsets.US_ASCII))
fpa = bb.int
meanTemp = bb.int
val atZero = bb.int
val atSlope = bb.float
sensorDeltaMc = bb.int
val eps = bb.float
val shift = bb.int
val tA = bb.int
val tB = bb.int
val bands = bb.int
val thr = IntArray(bands) { bb.int }
val gain = IntArray(bands) { bb.int }
val off = IntArray(bands) { bb.int }
val n = bb.int
pts = (0 until n).map { bb.int to bb.int }
at = OfficialTemp.AtPara(atZero, atSlope, 0)
rec = OfficialTemp.RadRecord(shift, thr, gain, off, tA, tB, eps, 19790, 44930)
}
}
@Test
fun matchesCapturedOfficialFrame() {
val f = Fixture(res("official_temp_fixture.bin"))
var sum = 0L
val abs = ArrayList<Int>(f.pts.size)
for ((counts, official) in f.pts) {
val t = OfficialTemp.temperature(
counts, f.rec, f.fpa, f.meanTemp, f.at, sensorDeltaMc = f.sensorDeltaMc,
)
val d = kotlin.math.abs(t - official)
sum += d
abs.add(d)
}
abs.sort()
val mean = sum.toDouble() / f.pts.size
val median = abs[abs.size / 2]
val p99 = abs[(abs.size * 99) / 100]
// Reference implementation (Python, validated against the official SDK):
// mean 17.5 mC, median 0, 3% of pixels >150 mC. The outliers are capture
// artefacts (the official map was produced while the frame buffer kept
// updating), not model error.
assertTrue("mean |delta| = $mean mC", mean < 30.0)
assertTrue("median |delta| = $median mC", median <= 10)
assertTrue("p99 |delta| = $p99 mC", p99 < 700)
val outliers = abs.count { it > 150 }
assertTrue("outliers>150: $outliers/${abs.size}", outliers <= abs.size / 20 + 1)
}
@Test
fun ddtTailRecordMatchesCapturedDeviceRecord() {
// The bundled DDT is the same unit (SN 160043865) the fixture was
// captured from, so its tail record must reproduce the captured record.
val rec = OfficialTemp.parseDdtTail(res("mag160c_official.ddt"))
assertNotNull("DDT tail record must parse", rec)
rec!!
assertEquals("bands", 8, rec.thr.size)
assertEquals("shift", 12, rec.shift)
assertEquals("eps", 1.0f, rec.eps, 1e-6f)
assertEquals("threshold[0]", 9389, rec.thr[0])
assertEquals("threshold[7]", 11772, rec.thr[7])
assertEquals("gain band1", 33174, rec.gain[1])
assertEquals("offset band1", -51216, rec.off[1])
assertEquals("tA (rec+0x34)", 29917, rec.tA)
assertEquals("tB (rec+0x38)", 29417, rec.tB)
assertEquals("distLo", 19790, rec.distLo)
assertEquals("distHi", 44930, rec.distHi)
// and it must agree with the frame the official SDK rendered from it
val f = Fixture(res("official_temp_fixture.bin"))
assertEquals(f.rec.thr.toList(), rec.thr.toList())
assertEquals(f.rec.gain.toList(), rec.gain.toList())
assertEquals(f.rec.off.toList(), rec.off.toList())
assertEquals(f.rec.shift, rec.shift)
assertEquals(f.rec.tA, rec.tA)
assertEquals(f.rec.tB, rec.tB)
}
@Test
fun parsesBasePara2AtParameters() {
// Ground truth: the 60-byte BasePara2 payload captured from the real
// device (SN 160043865). Offsets follow the official app's own wire
// parse (UsbCommunication.parseCmd, D2P_SendParameter2) minus the
// leading magic word, which our reader consumes separately.
val captured = "0400000000000000000000000000a306050000000400000008070000fa000000" +
"0800000000000000c85600008a8ee4bc0000000000000000"
val at = OfficialTemp.parseBasePara2(hex(captured))
assertNotNull(at)
assertEquals("ATZeroErrorPoint", 22216, at!!.atZeroErrorPoint)
assertEquals("ATErrorSlope", -0.02789f, at.atErrorSlope, 1e-4f)
assertEquals("fpaTempFix", 1699, at.fpaTempFix)
// malformed payloads must be rejected rather than yield silent garbage
assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(10)))
assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(60)))
}
private fun hex(s: String): ByteArray =
ByteArray(s.length / 2) {
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
}
@Test
fun curveInterpolatorsRoundTrip() {
// the curve is piecewise linear, so the inverse interpolation wobbles a
// few mC; the SDK shows the same behaviour
for (x in intArrayOf(26000, 28000, 29000, 30000, 32000, 34000)) {
val back = OfficialTemp.ie2t(OfficialTemp.e2t(x))
assertTrue("iE2T(E2T($x)) = $back", kotlin.math.abs(back - x) <= 4)
}
}
@Test
fun bandMapIsPiecewiseLinearAndMonotone() {
val f = Fixture(res("official_temp_fixture.bin"))
var prev = Int.MIN_VALUE
for (c in 9000..12000 step 7) {
val t = OfficialTemp.bandMap(c, f.rec)
assertTrue("band map must be monotone at $c", t > prev)
prev = t
}
}
}
@@ -117,4 +117,9 @@ internal object MagProtocolBytes {
dst[off + 2] = ((v shr 16) and 0xFF).toByte() dst[off + 2] = ((v shr 16) and 0xFF).toByte()
dst[off + 3] = ((v ushr 24) and 0xFF).toByte() dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
} }
fun putU16(dst: ByteArray, off: Int, v: Int) {
dst[off] = (v and 0xFF).toByte()
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
}
} }
Binary file not shown.
Binary file not shown.
+31 -1
View File
@@ -217,7 +217,7 @@ gray→2x→调色板),FFC 命令通过回调发给调用方(调用方持
```c ```c
mag160c_render_cfg_t cfg = { .width = 160, .height = 120, mag160c_render_cfg_t cfg = { .width = 160, .height = 120,
.ddt_path = "mag160c_official.ddt", .ffc_period = 1800, .ddt_path = "mag160c_official.ddt", .ffc_period = 1800,
.ffc_drift = 250, .startup_force_ffc = 1, .cdf_pivot_75 = 1, .ffc_drift = 250, .startup_force_ffc = 1, .cdf_pivot_75 = 1, /* PC 参考实现自身取值 */
.ffc_cb = my_ffc_sender, .ffc_user = NULL }; .ffc_cb = my_ffc_sender, .ffc_user = NULL };
mag160c_render_init(&r, &cfg); mag160c_render_init(&r, &cfg);
/* 每帧: */ /* 每帧: */
@@ -285,3 +285,33 @@ build-artifacts\csdk_test_display.exe
| `../交接_完整逆向工程说明_20260811.md` | 项目总交接(目录/环境/历史)| | `../交接_完整逆向工程说明_20260811.md` | 项目总交接(目录/环境/历史)|
| `WINDOWS_TESTING.md` | Windows 抓包与硬件测试指南 | | `WINDOWS_TESTING.md` | Windows 抓包与硬件测试指南 |
| `legacy/` | 历史工具源码归档(早期探索,功能已被现工具取代)| | `legacy/` | 历史工具源码归档(早期探索,功能已被现工具取代)|
## 与官方手机 App 的对齐(2026-10-06,从安卓侧验证后同步)
1. **灰度 LUT:手机对齐移植在 Android 管线,不在本 C 参考里**(2026-10-06 复核修正)。
第 29 轮曾把 `cdf_pivot_75` 改成 0 去凑官方手机 App,那个判断已作废:真机
libcxsdk 反汇编显示官方**开着** pivot,而且它的 `HistDeRedundance` 与本 C 参考
用的(PC 版)**结构不同**——固定基值 `16*(448+447*b/200)`、步进上限
`max(1,(0x1800+4j)·k>>13)`(`k=min(0x200,16·span/range)`)、反向 CDF 从
`hist[pivot]` 起、pivot=`max(center_idx,(meanBelow+meanAbove)/2)`。
**C 侧已实现**:`mag160c_render.h` 的配置项 `android_lut`(1 = 用 Android 版
`HistDeRedundance`,即 `mag160c_render.c: android_lut_rebuild()`,是 Kotlin 侧
`RenderPipeline.androidLutOf` 的逐行转写,两边同一套 golden 向量;0 = 保持 PC 语义,
与 `analysis/render_offline.c` 的黄金夹具一致),两个 demo 默认打开(真机验证:
同场景照片逐像素平均差 4.5/255、逐计数中位差 2 级,见 §10/§11)。
⚠️ **本机没有 C 编译器**(gcc/clang/NDK 都没有),所以这段 C **未编译**——
算法本身由 Kotlin golden 测试锁定,合入前请在带编译器的环境编译 + 跑回归。
- 顺带(同一条链):**曲线插值温度要加 `BasePara2.fpaTempFix`**。官方 App 传给 SDK 的是
`帧尾 FPA + fpaTempFix`(真机 1699),`SelectCaliCurve`/`GenerateVolatileLens`
用它选/插值每像素标定表;温度换算链路仍用帧尾原值。C 侧**已实现**
(配置项 `fpa_temp_fix`,在 `rebuild_tables()` 的调用处加到帧尾 FPA 上;
两个 demo 设 1699 = 本机 BasePara2 实测值)。
2. **温度换算待移植**:官方对这台设备走的是"放射学分支"(每波段线性映射 + 环境温度误差修正
+ 发射率/透过率 revise),不是本 SDK 现在用的 T2E 表分支。完整反编译结论、伪代码与
参数来源见 `docs/android_app/temperature_chain_re_20261006.md`(§4d 可直接落地,
已用 15 帧官方同帧数据验证到平均 13.9 mC)。C 侧还需要:
- DDT 尾部辐射标定记录的解析(阈值/gain/offset/shift/±500/eps,偏移表见该文档 §4d);
- BasePara2 的 `ATZeroErrorPoint`/`ATErrorSlope`/`fpaTempFix`(线上偏移同样见该文档);
- 曲线 A 与本 SDK 的 `T2E` 表相同(646 项,已验证逐项一致)。
注:本机当前**没有 C 编译器**(gcc/clang 均缺失),以上 C 改动需在有编译器的环境里
编译 + 跑 csdk 测试后再提交。
+10
View File
@@ -50,6 +50,16 @@ typedef struct mag160c_render_cfg_t {
int startup_force_ffc; /* official frame-75 forced FFC, 1 = on */ int startup_force_ffc; /* official frame-75 forced FFC, 1 = on */
int warm_frames; /* frames dropped after start, official 20 */ int warm_frames; /* frames dropped after start, official 20 */
int cdf_pivot_75; /* 0x8c: CDF pivot at 75% cumulative, 1 = on */ int cdf_pivot_75; /* 0x8c: CDF pivot at 75% cumulative, 1 = on */
/* Android libcxsdk parity (see docs/android_app/rendering_diff_phone_20261006.md
section 10/11): its HistDeRedundance is a structurally different function from
the PC one this reference implements. 0 = PC path (default), 1 = Android. */
int android_lut;
/* BasePara2.fpaTempFix: the official app interpolates the per-pixel calibration
tables with (frame-tail FPA + this). The vendor's own temperature chain keeps
the raw tail value. Device 160043865 reports 1699. */
int fpa_temp_fix;
/* Brightness offset for the Android LUT base; the official app passes 0. */
int lut_brightness;
mag160c_render_ffc_cb_t ffc_cb; mag160c_render_ffc_cb_t ffc_cb;
void *ffc_user; void *ffc_user;
mag160c_render_post_nuc_cb_t post_nuc_cb; mag160c_render_post_nuc_cb_t post_nuc_cb;
+119 -1
View File
@@ -59,6 +59,7 @@ struct mag160c_render_t {
mag160c_render_post_gray_cb_t post_gray_cb; mag160c_render_post_gray_cb_t post_gray_cb;
void *post_gray_user; void *post_gray_user;
int ffc_period, ffc_drift, warm_frames, cdf_pivot_75, force75; int ffc_period, ffc_drift, warm_frames, cdf_pivot_75, force75;
int android_lut, fpa_temp_fix, lut_brightness;
}; };
static int counts_to_temp_mc(int counts) { static int counts_to_temp_mc(int counts) {
@@ -263,7 +264,121 @@ static void stats_window(mag160c_render_t *r, const unsigned short *nuc) {
/* ---------------- LUT1024 rebuild (0x180011950 + 0x180011ee0) ---------------- */ /* ---------------- LUT1024 rebuild (0x180011950 + 0x180011ee0) ---------------- */
/* ------------- Android libcxsdk HistDeRedundance (parity port) -------------
*
* Line-by-line transcript of the disassembly of the official app's
* libcxsdk.so (`_ZN10CFunctions16HistDeRedundanceEv`, see
* docs/android_app/rendering_diff_phone_20261006.md section 10.3 and the golden
* vector in android/app/src/test/.../AndroidLutTest.kt). It differs from the PC
* variant above in structure, not just constants:
* - fixed LUT base 16*(448 + 447*brightness/200) instead of an auto-centre search
* - step cap max(1, (0x1800 + 4j)*k >> 13), k = min(0x200, 16*span/range)
* - backward CDF walk starts at hist[pivot]; hist[1023] = hist[1022]
* - pivot = max(center index, (meanBelow + meanAbove) >> 1) when the vendor's
* flag (theFunc._96_4_ = 1, set by the Controller constructor) is on
* - lut[1023] is never written (the vendor loop stops at 1022)
* NOTE: written but NOT compiled here - this machine has no C toolchain; the
* algorithm itself is locked by the Kotlin golden test.
*/
static void android_lut_rebuild(mag160c_render_t *r, const unsigned short *nuc) {
int span = r->win_hi - r->win_lo;
if (span < 1) span = 1;
unsigned S = 0xFFC00000u / (unsigned)span;
memset(r->hist1024, 0, sizeof(r->hist1024));
for (int i = 0; i < r->npix; ++i) {
unsigned idx = (((unsigned)nuc[i] - (unsigned)r->win_lo) * S >> 20) & 0xFFCu;
r->hist1024[idx >> 2]++;
}
unsigned orig[1024];
memcpy(orig, r->hist1024, sizeof(orig));
for (int j = 0; j < 1023; ++j) r->hist1024[j] = (orig[j + 1] + orig[j]) >> 1;
r->hist1024[1023] = r->hist1024[1022];
unsigned v82 = (unsigned)r->npix >> 12;
if (v82 < 1) v82 = 1;
int pivot = (int)((1023u * (unsigned)(r->stat_mean - r->win_lo)) / (unsigned)span);
if (pivot > 1023) pivot = 1023;
{
unsigned long long below_n = 0, below_s = 0, above_n = 0, above_s = 0;
for (int k = 0; k < pivot; ++k) {
below_n += r->hist1024[k];
below_s += (unsigned long long)r->hist1024[k] * (unsigned)k;
}
for (int k = pivot + 1; k < 1024; ++k) {
above_n += r->hist1024[k];
above_s += (unsigned long long)r->hist1024[k] * (unsigned)k;
}
if (below_n && above_n) {
unsigned m = (unsigned)((above_s / above_n) + (below_s / below_n)) >> 1;
if ((int)m > pivot) pivot = (int)m;
}
}
unsigned cdf_f[1024], cdf_b[1024];
memset(cdf_f, 0, sizeof(cdf_f));
memset(cdf_b, 0, sizeof(cdf_b));
unsigned unit = 0x10000u / v82;
{
unsigned acc = 0, steps = 0;
for (int j = pivot; j >= 0; --j) {
acc += r->hist1024[j];
if (acc >= v82) { acc = 0; steps++; }
cdf_f[j] = (steps << 8) + ((unit * acc) >> 8);
}
}
{
unsigned acc = 0, steps = 0;
for (int j = pivot; j < 1023; ++j) {
acc += r->hist1024[j];
if (acc >= 8 * v82) { steps += 2; acc = 0; }
else if (acc >= v82) { steps += 1; acc = 0; }
cdf_b[j] = ((unit * acc) >> 8) + (steps << 8);
}
}
int v66 = 447 * (r->lut_brightness < -100 ? -100 : (r->lut_brightness > 100 ? 100 : r->lut_brightness)) / 200;
int range = (r->dev24 * 1000) >> r->dev4c_shift;
if (range < 0x80) range = 0x80;
unsigned k = (unsigned)(16 * span) / (unsigned)range;
if (k >= 0x200) k = 512;
if (range > 16 * span) k = 1;
unsigned step[1024];
unsigned acc6 = 0x1800u * k;
for (int j = 0; j < 1024; ++j) {
unsigned v = acc6 >> 13;
step[j] = v ? v : 1;
acc6 += 4 * k;
}
unsigned base = (unsigned)(16 * (v66 + 448));
if (cdf_f[0]) {
unsigned denom = (unsigned)((v66 + 448) << 18) / cdf_f[0];
unsigned prev = base;
for (int j = pivot; j >= 0; --j) {
unsigned cand = base - ((cdf_f[j] * denom) >> 14);
unsigned v = prev - step[j];
if (prev - cand <= step[j]) v = cand;
r->lut[j] = (unsigned char)(v >> 6);
prev = v;
}
}
if (cdf_b[1022]) {
unsigned denom = (unsigned)((576 - v66) << 18) / cdf_b[1022];
unsigned prev = (unsigned)(16 * v66 + 7152);
for (int j = pivot + 1; j < 1023; ++j) {
unsigned cand = ((cdf_b[j] * denom) >> 14) + (unsigned)(16 * v66 + 7152);
unsigned v = prev + step[j + 1];
if (cand - prev <= step[j + 1]) v = cand;
r->lut[j] = (unsigned char)(v >> 6);
prev = v;
}
}
/* lut[1023] intentionally left untouched (vendor loop stops at 1022) */
}
static void lut_rebuild(mag160c_render_t *r, const unsigned short *nuc) { static void lut_rebuild(mag160c_render_t *r, const unsigned short *nuc) {
if (r->android_lut) { android_lut_rebuild(r, nuc); return; }
int span = r->win_hi - r->win_lo; int span = r->win_hi - r->win_lo;
unsigned S = 0xFFC00000u / (unsigned)span; unsigned S = 0xFFC00000u / (unsigned)span;
memset(r->hist1024, 0, sizeof(r->hist1024)); memset(r->hist1024, 0, sizeof(r->hist1024));
@@ -487,7 +602,7 @@ static int ffc_step(mag160c_render_t *r, int shutter) {
r->ffc_idx++; r->ffc_idx++;
int idx = r->ffc_idx; int idx = r->ffc_idx;
if (idx <= OFF_N1) { if (idx <= OFF_N1) {
if (idx == 1) rebuild_tables(r, r->shutter_at_ffc); if (idx == 1) rebuild_tables(r, r->shutter_at_ffc + r->fpa_temp_fix);
return 1; return 1;
} }
if (idx <= OFF_N0 + OFF_N1) { if (idx <= OFF_N0 + OFF_N1) {
@@ -549,6 +664,9 @@ mag160c_error_t mag160c_render_init(mag160c_render_t **out,
r->ffc_drift = cfg->ffc_drift ? cfg->ffc_drift : 250; r->ffc_drift = cfg->ffc_drift ? cfg->ffc_drift : 250;
r->warm_frames = cfg->warm_frames ? cfg->warm_frames : 20; r->warm_frames = cfg->warm_frames ? cfg->warm_frames : 20;
r->cdf_pivot_75 = cfg->cdf_pivot_75; r->cdf_pivot_75 = cfg->cdf_pivot_75;
r->android_lut = cfg->android_lut;
r->fpa_temp_fix = cfg->fpa_temp_fix;
r->lut_brightness = cfg->lut_brightness;
r->force75 = cfg->startup_force_ffc; r->force75 = cfg->startup_force_ffc;
r->dev24 = 5; r->dev24 = 5;
r->dev4c_shift = 3; r->dev4c_shift = 3;
+13 -1
View File
@@ -605,7 +605,19 @@ int WINAPI WinMain(HINSTANCE inst, HINSTANCE prev, LPSTR cmd, int show) {
cfg.ffc_drift = 250; cfg.ffc_drift = 250;
cfg.startup_force_ffc = 1; cfg.startup_force_ffc = 1;
cfg.warm_frames = 20; cfg.warm_frames = 20;
cfg.cdf_pivot_75 = 1; /* Keep the PC/Linux reference LUT: this toggle is the PC variant's 75% pivot.
The round-29 change to 0 was based on a mis-derived pivot in the Android build
and is retracted - the official PHONE app has the pivot ON, and its LUT is a
structurally different function (fixed base, step cap, mean-of-partials pivot)
that is ported in the Android pipeline, not here. See
docs/android_app/rendering_diff_phone_20261006.md section 10. */
/* Android libcxsdk parity (validated on device 2026-10-06, see
docs/android_app/rendering_diff_phone_20261006.md sections 10-11):
the phone app renders with its own HistDeRedundance variant, and it feeds the
per-pixel table interpolation (frame-tail FPA + BasePara2.fpaTempFix). */
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.ffc_cb = ffc_cb; cfg.ffc_cb = ffc_cb;
cfg.post_nuc_cb = post_nuc_cb; cfg.post_nuc_cb = post_nuc_cb;
cfg.post_gray_cb = post_gray_cb; cfg.post_gray_cb = post_gray_cb;
+9 -1
View File
@@ -125,7 +125,15 @@ int main(int argc, char **argv) {
cfg.ffc_drift = 250; cfg.ffc_drift = 250;
cfg.startup_force_ffc = 1; cfg.startup_force_ffc = 1;
cfg.warm_frames = 20; cfg.warm_frames = 20;
cfg.cdf_pivot_75 = 1; /* PC/Linux reference LUT keeps its own pivot (see the same note in
mag160c_demo3.c); the Android-parity LUT lives in the Android pipeline. */
/* Android libcxsdk parity (validated on device 2026-10-06, see
docs/android_app/rendering_diff_phone_20261006.md sections 10-11):
the phone app renders with its own HistDeRedundance variant, and it feeds the
per-pixel table interpolation (frame-tail FPA + BasePara2.fpaTempFix). */
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.ffc_cb = ffc_cb; cfg.ffc_cb = ffc_cb;
mag160c_render_t *render = NULL; mag160c_render_t *render = NULL;
if (mag160c_render_init(&render, &cfg) != MAG160C_OK) { if (mag160c_render_init(&render, &cfg) != MAG160C_OK) {
@@ -0,0 +1,111 @@
# 官方 PC 端 vs 逆向 SDK:同一帧逐像素对比(2026-10-06)
> 目的:热像仪接 PC 后,用**官方 Windows 软件**采图,与**逆向出来的 SDK/App 管线**
> 渲染同一帧做逐像素对比,找出"照片到底差在哪些细节上"。
> 证据图:`docs/android_app/pc_vs_ours_20261006.png`(官方 / 我们 / 差异×8 / 灰度 ×16)。
## 结论速览
| 环节 | 一致性 | 证据 |
|---|---|---|
| NUC(端点插值 + 增益/阈值表 + 盲元补偿 + ref) | **逐像素完全一致** | 抽检 4 帧:19200/19200 像素相等,max diff = 0 |
| 调色板 | **完全一致** | 官方 `.pal`、官方彩色图颜色集合、我们的 `PALETTE256_ARGB` 三者 256/256 相同 |
| 灰度映射(窗口 + 直方图 LUT + 2× 上采样) | 平均 **+1.3 级**、最大 10 级(/255) | 320×240 灰度逐像素;本轮场景计数 9600–11000 |
| 彩色输出 | 60.9–72 % 字节完全相同,平均 \|Δ\| 1.4–1.9/255,最大 20 | 官方 `.rgb`(BGR24)vs 我们导出的 PNG |
| 细节增强(7×7 DDE) | 官方这份渲染**没开**;我们默认等级 0 也关 | 官方彩色 = palette(官方灰度),逐像素 max diff = 0 |
| 显示窗口 | **下界规则不同** | 官方 20/20 帧 `winHi = 数据max`;12/20 帧 `winLo = 数据min`,其余仅低 1–7 计数。我们 `winLo = mean−500`,本例比数据 min 低 **188** 计数 |
| 温度换算 | **系统差:+0.1 ℃ @27 ℃ → −0.7 ℃ @38 ℃** | 官方 `t32` 是 NUC 计数的精确直线(k=8.539 mC/count,残差 ≤0.5 mC),我们的固定 T2E 表斜率 8.26→7.67 递减 |
**一句话**:渲染管线(NUC/调色板/灰度)已经和官方 PC SDK 对得上,肉眼几乎无差别;
真正还差的是**温度曲线**(最高到 0.7 ℃)和**窗口下界规则**(造成约 1 级亮度偏差)。
## 1. 官方 Windows 侧用谁
| 候选 | 判定 |
|---|---|
| `app/【PC分析软件】…exe`(内部名 **ThermoX 2.5.3.6**,巨哥电子) | 是**网络码流**软件:面板是"指定IP / 启用DHCP Server / 传输混合/纯视频/纯温度码流",`刷新`扫不到 USB 相机,运行期不加载任何 USB 库 → 不吃本机 USB 机芯 |
| `IR_Camera_SDK-1.0.1/windows/windows/app/EloThermal.exe`(官方 SDK 演示,C# 源码在仓库) | 能连 USB 出图(CameraSDK/ThermalSDK + libusb0),但**没有任何存图功能**(全源码无 Encoder/Save)→ 只能截屏 |
| `build-artifacts/tsdk_pair3.exe`(本仓库工具,调用官方 `ThermalSDK.dll` + `CoreSDKLib.dll`) | ✅ **采用**:一次采集给出官方渲染图、原始帧、温度、调色板、LUT、DDT 全套 |
## 2. 方法(可复现)
```
# 1) 设备驱动(本机原来没装,CM_PROB_FAILED_INSTALL)
# 厂商 INF:IR_Camera_SDK-1.0.1/windows/windows/drivers/amd64/ThermoCube_stream.inf
# (libusb-win32 1.2.6.0,cat 是 libwdi 自签证书) → 需先把该证书导入
# "受信任的根证书颁发机构" + "受信任的发布者",再:
pnputil /add-driver ThermoCube_stream.inf /install # 设备变 OK,libusb0 服务 RUNNING
# 2) 官方基准采集(输出目录用第 1 个参数,勿落在仓库里)
cd <temp>
tsdk_pair3.exe <temp>\off20 20 6000 > off20_stdout.txt
# 每帧产出 19 个文件:gray / rgb / cbrgb(BGR24) / raw / f20 / ref / t32 / pal /
# lut / h1024 / cdf / hist / thr0 / thr1 / thrw / g0 / g1 / gw / blind (+official.ddt)
# 3) 用我们的管线离线渲染同一批帧(临时 JVM 单测,用完已删)
# RenderPipeline.loadDdt(official.ddt) 后:
# repeat(4){ frameRemote(pair.ref, phase=2, shutter) } // ref = 官方的参考帧
# frameRemote(pair.f20, phase=0, shutter) // 渲染
# 产出 our_XXX.png / .nuc / .t32,再用 Python 与官方逐像素比
```
**新解码出的官方内部缓存语义**(供后续逆向用):
| 缓冲 | 地址 | 含义 |
|---|---|---|
| 帧输入 | dev+0x41f20 | 官方喂给 NUC 的帧数据(= USB 帧 payload 域,均值 ~12000) |
| 参考帧 | dev+0x41ef0 | NUC 用的 ref |
| 传感器温度 | dev+0x54 | 端点插值用的 FPA 温度(与 DDT 锚点同量纲,m℃) |
| NUC 输出 | dev+0x270 | 校正后计数(均值 ~9600–11000);`MAG_GetTemperatureData_Raw` 的输入 |
| 端点选择 | dev+0x41840 | 官方的 `sel`,与我们的 `endpoint()` 取值完全一致 |
> 关键验证:把 `dev54` 当帧尾 FPA 温度喂进我们的管线,NUC 输出与官方
> `pair_*.raw` **19200/19200 像素逐位相等**——这同时锁定了帧尾 FPA 字段的语义。
## 3. 温度:官方是"每设备线性响应",我们用的是固定曲线
官方 `t32`(19200 像素 × int32 毫度)与 NUC 计数之间是**精确直线**:
```
t_mC = 8.539 * counts - 55688 # 残差 ≤ 0.5 mC;逐帧斜率 8.5358–8.5424(≈常数)
```
同一计数下不同像素的温度离散为 **0 mC**(无逐像素差异)→ 说明官方对"已校正计数"
只做一次全局线性换算(对应 `ConvertResponse2Temperature` 一族,csdk 里已有实现
`mag160c_temp_convert_response`,但安卓 App 未使用)。
我们的 `TempMath.countsToTempMc` 是固定 T2E 反查表,局部斜率随计数**递减**:
| 计数区间 | 官方斜率 | 我们的斜率 | 比值 |
|---|---|---|---|
| 9600–9900 | 8.5390 | 8.2581 | 1.034 |
| 9900–10200 | 8.5391 | 8.1993 | 1.041 |
| 10200–10600 | 8.5390 | 7.9505 | 1.074 |
| 10600–11000 | 8.5390 | 7.6706 | 1.113 |
→ 温度读数偏差:**≈27 ℃ 处 +0.1 ℃,≈38 ℃ 处 −0.62 ℃**(累计最大 −0.70 ℃)。
## 4. 建议的下一步
1. **温度**(优先):安卓端改用设备响应换算。官方 PC 侧对已校正计数是线性映射,
系数随设备(本机实测 k=8.539 mC/count、b≈−55.7 k mC,随 FPA 温度微动 ~±50 mC)。
需要先定位系数的来源(DDT / BasePara 里的标定字段),再按官方公式实现;
`csdk/src/mag160c_temp.c::mag160c_temp_convert_response` 已是同款算法,可作参照。
2. **窗口**:把 `statsWindow()` 从"`mean ± (dev24*1000>>dev4cShift)/2` 向外扩"改成官方行为
(上界 = 数据 max、下界 = 数据 min,20 帧实测 20/20 与 12/20 如此,其余差 ≤7 计数)。
这一项解释了灰度上 ~1.3 级的系统性偏差。
3. **手机侧对照照(未完成)**:本轮只覆盖"渲染",没覆盖官方**手机 App 的拍照路径**。
建议:同一静止场景,官方手机 App 与我们的 App 各拍一张同调色板照片;
官方 MDT 里存的就是 NUC 后帧,可以直接喂我们的显示阶段逐像素比
(需要给管线加一个"只跑显示阶段"的离线入口)。
4. **PC 端 C 工具链**:本机 libusb-1.0 1.0.19 对 libusb0.sys 绑定**不可用**
(`claim_interface` → Win32 错误 87;官方走 libusb0.dll API 正常)。
若要在这台机器跑 csdk 的 demo3/render_offline,需要换 libusb-1.0 版本,
或把设备改绑 WinUSB/libusbK。
## 5. 复现清单(临时文件不在仓库)
- 官方基准数据(20 帧全套,35 MB)与差异图在
`%TEMP%\mag160c_wincmp\off20\`(`compare_visual.png` / `compare_tempdiff.png` / `compare.txt`);
仓库内只保留 `docs/android_app/pc_vs_ours_20261006.png` 一张证据图。
- 驱动安装脚本痕迹:`%TEMP%\mag160c_wincmp\pnputil*.log`、`cert.log`。
Binary file not shown.
@@ -0,0 +1,486 @@
# 手机端渲染差异定位(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)实时预览改单级放大。两者都是**表现层**改动,不影响已对齐的映射,风险低。
+354
View File
@@ -489,6 +489,360 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。 "未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。
## 用户反馈修复 第三十三轮(2026-10-06 傍晚,"计数噪声偏大":实测预算证伪 + 修两处真问题)
用户:"把'计数'噪声偏大修一下,没问题就合并到csdk里面,各部分readme文档更新,然后推送"。
### 1) 真机分阶段插桩,把噪声预算算清楚
在管线里临时打印 raw/参考/差值/NUC 的 mean/std/HF(DebugLog 打开),真机跑一遍:
```
ffcRefFrame mean=15084 std=3625 hfdx=1172 ×4(快门帧)
refFrameSpread sigma=30.2/27.5/27.2 ← 逐帧像素噪声
stage.raw mean=11442 std=3466 hfdx=1168
stage.ref mean=15083 std=3624 hfdx=1172
stage.diff mean=-1821 std=326 hfdx=29.1
stage.nuc mean=9731 std=245 hfdx=24.2
```
- 快门帧是真快门(均值差 3600、4 帧一致到 0.1%)✓;
- σ_n ≈ 21–24 counts → 差值噪声理论值 0.559σ_n = 12.3 ✓ 与实测吻合;
- FPN 抵消到 2.4%(1168→29)✓。
→ **我们的参考采集与 NUC 链路完全符合物理预算,"计数噪声偏大"在数据链路上不成立**
(上轮跨 App 指标推断的结论没有证据支持)。
### 2) 1.2–1.5× 跨 App 差的测量混淆项(已逐条列出)
我们的照片 960×720/q95(0.29 B/px)vs 官方 640×480(0.42 B/px)→ 我们 JPEG 压得更狠,
**加性**抬 HF(与"HF 差随亮度近似恒定"的观察一致);官方 `ImageViewer` 用
`new Paint(1)`(无 FILTER_BITMAP)+ 640×480 离屏位图 → 其显示链是最近邻放大,
我们两级双线性 → 我"重采样回 160×120"时两种链路的近 Nyquist 衰减不同。均写在 §12.2。
### 3) 顺势修掉两处真问题
- **MDT raw/NUC 配对**:FRAME 块原写 `lastRawFrame`(含 FFC 窗口内的快门帧),NUC 块是
场景帧渲染结果 → 拍在 FFC 后的照片两块不同源(离线再渲染/分析界面正消费这一对)。
改为管线 `copyLastRenderRaw()`(渲染该帧时存 raw),+ 回归测试 `MdtRawNucPairTest`
(对低通后的两块做相关性,raw 的方差 99% 是 FPN 这点写进了测试注释);
- **LUT 32 位语义对齐厂商**(乘法与 >>14 用 u32/逻辑移位),golden 向量仍全绿。
### 4) csdk 合并(用户要求)
`mag160c_render.h/.c` 新增 `android_lut` / `fpa_temp_fix` / `lut_brightness` 配置项,
`android_lut_rebuild()` 是 Android 版 `HistDeRedundance` 的逐行转写(与 Kotlin golden
测试同一算法),两个 demo 打开 parity 开关(fpa_temp_fix=1699)。**本机没有 C 编译器**
(gcc/clang/NDK 均无),所以 C 侧未编译,README 里已明确标注。
### 5) 单测
118 全绿(新增 `MdtRawNucPairTest` + `AndroidLutPipelineTest` 4 个端到端用例)。
## 用户反馈修复 第三十二轮(2026-10-06 下午,手机解锁后真机验证:渲染已对齐,剩噪声)
用户:"手机我开了,你进行测试"。
### 1) 先修掉实时全黑(我上一轮引入的 bug)
`System.arraycopy(IntArray → ByteArray)` 每帧抛 `ArrayStoreException` → 实时画面全黑(SurfaceView
仍刷、logcat 无异常)。黄金向量测试只覆盖纯函数,没覆盖实例路径。已改逐元素拷贝 + 新增
`AndroidLutPipelineTest`(4 个端到端用例)。单测 **117 全绿**。
### 2) 同场景 A/B:映射对齐
官方/我们前后 2 分钟各拍一张(同一静止场景),调色板索引空间、同一 160×120 网格:
- 分位:官方 [82,89,**96**,117,133] vs 我们 [85,92,**98**,114,134];
- 逐计数中位索引差:**中位 2.0 级**;全图平均 |Δ| = **4.47/255**,99.2% ≤16。
对比旧 LUT(低端压死,我们 8/17 vs 官方 45/52,系统偏差 ≈35 级)→ **"官方对比度更高/更细腻"
的映射差异已消除**。
### 3) 步进常数 k 复核:反汇编公式即最优(旧结论作废)
用照片里存的 NUC 计数对官方照片做模型比对(新的一对,span=1241):
| k | 平均误差 | ≤8 占比 |
|---|---|---|
| **16·span/range(反汇编,=19)** | **4.64** | **82.9%** |
| 16(上轮"拟合最优") | 5.76 | 73.6% |
| 24 / 12 | 4.92 / 9.16 | 80.8% / 49.2% |
→ 代码不动,§10.4 的遗留项关闭(上轮"偏好 16"是当时那对照片的产物)。
### 4) 剩余问题已定位到"计数噪声"域
同 LUT 下我们的 NUC 计数渲染出的 HF(4.65/4.25)明显高于官方成品(照片 2.46/2.22),
即**映射一致、噪声不一致**;FFC 后立刻截图几乎不改善(帧间 2.44→2.34)→ 不是参考老化;
实时帧率 15.0 fps、单帧 6-9 ms(无回归)。→ 下一步查**实时 FFC 参考采集**与离线/PC
路径(PC 端 NUC 逐字节一致)的差别。
### 5) 运维踩坑(已写进渲染文档 §11.5)
推流中 force-stop 会把相机卡死(需 USB 重新枚举 `/sys/bus/usb/devices/2-1/authorized`);
切换 App 先按 HOME 正常停流;MIUI 每次重装/重枚举都要重新授权,勾"一律打开"要点**复选框本体**。
## 用户反馈修复 第三十一轮(2026-10-06,按真机 libcxsdk 反汇编定案:显示 LUT 才是差异)
用户:"先从逆向文件里面找灵感,尽可能多读一些伪代码,然后推测可能缺失的东西,再补上验证"。
### 1) 读伪代码的对象换了:**手机上的官方库本体**
从官方 APK 的 `lib/arm/libcxsdk.so` 建 IDB,按 vtable 指针段解析真实函数体,逐条看反汇编
(Hex-Rays 会吞掉 `__aeabi_uidiv` 的隐式除数,必须对指令)。顺带把上一轮 dump 的
"`CFunctions` 全量伪代码" 对照核清。
### 2) 三个"缺失"候选,两个被否掉、一个命中
- **`CAccumulator`**:`mode>=4` 是**恰好 mode 帧的块平均**(不是 IIR),`Controller::PushFrame`
对 A+1..A+E(E=baseLineAcc=4)各推一次并 `Restart` 一次 → 官方参考 = 4 帧平均 = **我们一样**。
§9.3"官方参考更干净"的机制**不成立**;§9.2 的"官方空间噪声低 2.3×"是**分辨率偏差**
(官方预览是 160×120 输出、我们预览是 320×240,逐屏像素 HF 不可比)。
- **`CSmoother` / 显示侧累积 / `setEX`**:官方分别由 `0x380c=1`(需>1 才生效)、`0x3808=1`、
`imageEX=0` 关闭 → 与我们一致。
- **命中:灰度 LUT 是 PC 版形状,官方是 Android 版形状**。另有:App 传给 SDK 的温度是
`帧尾 FPA + BasePara2.fpaTempFix(真机 1699)`(我们少加了),而 7×7 细节增强虽然每帧被
调用(`MapTemperature` 把门槛 `0x3864` 置 1),但强度源 `CFunctions+0x28` 全库未写 = 0
→ **官方那一步是空操作**(我们默认关 ✔ 一致)。
### 3) 定量:把官方照片与我们的 NUC 对齐
用我们照片里存的 NUC 计数块与官方照片逐像素对齐(垂直翻转后 **r=0.99**)→ **计数一致,
差异只在"计数→索引"映射**:官方 ≈ `0.84×我们的LUT + 21`(背景落在索引 45–55 的平台,
我们的旧 LUT 把它压到 0–20)。反汇编核对确认 Android 版 `HistDeRedundance` 与 PC 版
**结构不同**:固定基值 `16*(448+447*b/200)`、步进上限 `max(1,(0x1800+4j)·k>>13)`
(`k=min(0x200,16·span/range)`)、反向 CDF 从 `hist[pivot]` 起、`hist[1023]=hist[1022]`、
pivot=`max(center_idx,(meanBelow+meanAbove)/2)`(**pivot 官方是开着的**,`_96_4_` 构造=1
且全库无第二处写 → §8 的"关掉 pivot"是误判,已回退)。
拟合(同一 NUC、逐像素):旧 PC 版 ≈35,round-30 的"pivot 关" ≈14,
**Android 版逐指令移植 ≈12.5**,把步进常数取 16(拟合最优)≈5.9,官方实测 p5/p25=45/52。
### 4) 本轮改动
- `RenderPipeline.androidLutOf()`:Android `HistDeRedundance` 逐指令移植(+`AndroidLutTest`
golden 向量锁定),实时会话 `androidLut = true`;PC 路径保留,C 参考黄金测试不动;
- `RenderPipeline.fpaTempFix` → `rebuildTables(shutter + fpaTempFix)`,`IrSession` 从 BasePara2 注入;
- 单测 113 全绿。
### 5) 下一步(需要机主解锁手机:当前被 PIN 锁屏,无法做实时 A/B)
1. 同刻官方/我们实时画面 + 我们的 NUC,复核"`+fpaTempFix` 是否让计数分布与官方一致",
再定 §10.4 里那个 k(21 vs 16);
2. 7×7 细节增强按官方默认(2040)打开并回归(离线拟合我们的移植反而略差,需先核移植细节);
3. 同步 csdk(Android 版 LUT + fpaTempFix + 温度链)。
## 用户反馈修复 第三十轮(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)
用户:"我说的就是渲染的差异……官方的对比度似乎更高,而且画面里面的条纹看起来也更少。"
### 1) 逐一排除(都有反编译或实测证据)
- **窗口**:Android `GrayEnlarge`/`CalContrastOffsetedAutoEnlargeRange` 与我们的 `statsWindow`
在对比度 c=0 时逐字节一致(官方 App 也只传 `setAutoEnlarge(enlargeRange=8)`);
- **灰度 LUT/CDF 算法**:Android `HistDeRedundance` 与我们 `lutRebuild` 逐行一致
(1024 桶直方图、双向 CDF、`total>>12`、`0x100/0x200` 步进);
- **调色板**:官方照片与我们照片都完美拟合 2 号铁虹(中位色差 0.67 / 1.00);
`VendorPalettes` 已记录 case 2 与我们的表 256/256 一致(§7 的"调色板不同"是裁剪污染误判,已纠正);
- **条纹本身**:我们手机 NUC 的 column-HF 7.20(0.63%)vs 官方 7.81(0.63%),渲染平坦块列抖动
更低(0.34–0.94 vs 0.81–1.44)→ 数据/降噪不差于官方。
### 2) 定案:**`cdfPivot75`(SDK 里由 `+0x60` 参数控制的 CDF 75% 分位支路)**
测量方法:关闭两边对话框后同刻各截实时画面,用**调色板反解灰度**(99%+ 像素可唯一反解,
排除显示路径/色域影响):
| | p25 | p50 | p75 | mean |
|---|---|---|---|---|
| 官方 App | 48 | **61** | 129 | 82.2 |
| 我们(pivot 开,原状) | 23 | **33** | 82 | 60.2 |
| 我们(pivot 关) | 31–34 | **50–54** | 132–135 | **79.8–81.2** ✔ |
→ 关掉 pivot 后与官方分布重合。修复:`IrSession` 构造实时管线时显式
`cdfPivot75 = false`(类默认仍为 true,C 参考的黄金测试不受影响)。已装机复验(视觉一致)。
这同时解释"条纹更少"的观感:偏暗/低对比时噪声与残留 FPN 更显眼。
### 3) csdk 同步
- 工具已改为 `cdf_pivot_75 = 0`(与官方手机 App 一致,库保留开关;`render_offline.c`
生成黄金夹具仍用 1);
- 温度链移植(放射学分支)规格已写进 `csdk/README.md`,**待有 C 编译器时落地**
(本机当前 gcc/clang 均缺失,无法编译验证)。
## 用户反馈修复 第二十八轮(2026-10-06,温度链进 App + 真机对比:渲染差异收敛到 LUT-CDF)
用户:把温度修改同步进 App、真机测试没问题后改入 csdk;用官方 App 拍照对比,有差异就
Android SDK 与 Linux SDK 交叉验证。
### 1) 温度链进 App(已装机运行)
- 新增 `core/OfficialTemp.kt`:官方放射学链(波段映射 + AT 环境误差修正 + 发射率 revise),
曲线 A 直接用现成 `OfficialTables.T2E`(646 项与官方逐项相等),斜率表现算(公式一致)。
- `IrSession` 解析 **BasePara2**(真机报文验证的偏移:payload 不含 magic,`+14 short fpaTempFix`、
`+40 int ATZeroErrorPoint`、`+44 float ATErrorSlope`)与 **DDT 尾部波段记录**;
`RenderPipeline.officialTemps()/probeTemp()` 与 `LiveViewModel` 已切到官方链(缺失参数自动回退旧曲线)。
- 单测(全绿,含既有 100+ 项):官方同帧夹具 400 点(均值 17.5 mC)+ 真机 BasePara2 报文
+ DDT 尾部记录与设备内存记录逐字段一致 + 曲线往返 + 波段单调性。
- 真机:`zero=22216 slope=-0.0279`,radRecord `bands=8 shift=12 eps=1.0`;实时读数
中心 27.8–28.5 ℃ / MAX 34.7 ℃。
- **新发现**:设备上报的 `fpaTempFix=1699` ≠ PC 上实测的补偿量 `v50`(=dev+0x5C−dev+0x54,
0…89 mC)→ App 侧 `sensorDeltaMc` 暂取 0(误差 ≤0.09 ℃),来源待定位。
### 2) 手机端拍照对比(详见 `rendering_diff_phone_20261006.md`)
- 官方 App 照片是**纯 JPEG(无任何数据块)**;我们的是完整 MDT。
- 同场景亮度:官方 mean≈84 / p50≈49,我们 mean≈53 / p50≈19(偏暗);实时画面同样复现
(mean 53–72 vs 20–25),确认是**显示映射差异**而非时刻/场景差异。
- **三 SDK 交叉验证**:窗口公式 `CalContrastOffsetedAutoEnlargeRange`(Android)与我们移植的
`statsWindow` 在对比度 c=0 时**逐字节一致**(官方 App 也只传 `setAutoEnlarge(enlargeRange=8)`,
未设对比度;Win/Linux 侧同名 API 参数一致);直方图骨架也一致(`0xFFC00000/span`、`>>20 & 0xffc`)。
- → 差异收敛到 **灰度 LUT 的 CDF 细节**:Android 版 `HistDeRedundance` 有**双向 CDF + 步长量化**
(`local_1024 = total>>12`、`iVar3 = 0x10000/local_1024`、前后向各建一张 0x10cc/0x10d0 表),
我们的 `lutRebuild`(PC 版语义)没有这套量化。
### 3) 下一步
1. 按 Android SDK `HistDeRedundance` 重做 `lutRebuild`(含 `+0x60` pivot 分支与双向 CDF 量化),
用真机截图/照片定量回归(目标:luma 直方图与官方 App 吻合)。
2. 补 `getExtParameter`(ExtPara:`intContrastOffset/intBrightOffset/intCurrentEnvTemperature`),
把设备的对比度/亮度/环境温度接进管线;温度读数接 App 的发射率设置。
3. App 确认无误后,按用户要求同步进 **csdk**(C 参考实现 + csdk 测试)。
## 用户反馈修复 第二十七轮(2026-10-06,官方温度链反编译:按 IDA 结果重建,系数待钉)
用户要求:"修复这些的时候要和 ida 或者 Ghidra 反编译的结果对的上,不要自己瞎猜,并且是
全局代码对的上;机器上已经有 ida 了,你自己装个 mcp 就能用"。
详细报告:`docs/android_app/temperature_chain_re_20261006.md`。
### 1) 用 IDA 建立了地面真值(不是猜)
- IDA Professional 9.4 批处理(`idat.exe -A -S`)+ 运行时 ctypes 直读官方 DLL 内存,
反编译并核对:`sub_180016290`(核心换算三分支)、`sub_180014e70`(探针,含邻域平均)、
`sub_18000d2a0`(每帧两点标定端点)、`sub_180015b90`(修正,含 `K=f8·f4·ε` 与
环境辐亮度反演)、`sub_180015840/8b0/A40/940`(曲线 A 前后向插值)。
- **根因确定**:本机 `dev+0x41880=1` → 官方走**放射学分支 + 每波段线性映射**,
而 App 用的是 `countsToTempMc`(T2E 表分支)——**分支都不同**,不是"表差一点"。
- 拿到全部运行时参数:8 个波段阈值(9389/9995/10328/10581/10797/11160/11430/11772)、
每波段 (gain,offset)、`shift=12`、发射率 1.0、环境温度 29.45℃、曲线 A 与斜率表地址等;
并确认**这些参数就在 DDT 文件尾部**(App 解析 DDT 即可获得,无需新指令)。
### 2) 验证到什么程度
- 官方同帧数据(`MAG_GetTemperatureData` + `dev+0x270` 计数 + 记录快照)逐像素比对:
**波段映射结构正确**——flat 背景所在 band 的拟合残差仅 **3.2 mC**;
但整体仍差 **≈230 mC**,对应尚未对齐的**环境补偿/距离大气修正项**
(这两项按反编译加上去反而变差 ≈980 mC,说明字段语义还有一处没读对)。
- 结论:**结构已定、系数待钉**;下一步只做这两项的单变量对照,不再猜公式。
### 3) 工具链落地
- IDA MCP:`pip install "mcp<2" ida-pro-mcp` + 插件(`%APPDATA%\Hex-Rays\IDA Pro\plugins\mcp-plugin.py`),
ZCode 用户配置 `~/.zcode/cli/config.json` 已加 `mcp.servers."ida-pro"`;
**需重启 ZCode 才出现工具**,IDA 侧要 `Edit→Plugins→MCP`(Ctrl+Alt+M) 起 13337 端口。
- 批处理脚本与运行时读数脚本、官方同帧基准数据都在 `%TEMP%\mag160c_wincmp\`,未入库。
### 4) 下一步
1. 用单变量对照钉死环境补偿 (v50) 与距离/大气项(`dev+0xA8`、`dev+0x44CB8`、
`rec+0x208/0x20C`)。
2. Kotlin 落地官方放射学分支(波段映射 + 修正),并补导曲线 A 的斜率表与 DDT 尾部解析。
3. 用本轮官方同帧数据做回归断言(band 1 应到 mC 级),再替换 App 各温度读数点。
## 用户反馈修复 第二十六轮(2026-10-06,官方 PC 软件同帧对比:渲染逐像素一致,温度曲线有系统差)
用户把热像仪接上 PC,要求"调用项目里官方给的 win 软件拍图,看和逆向出来的 sdk 的图
差在哪些细节上"。详细报告:`docs/android_app/pc_official_compare_20261006.md`,
证据图 `docs/android_app/pc_vs_ours_20261006.png`。
### 1) 官方 Win 侧选谁(侦察结论)
- `app/【PC分析软件】…exe` = 内部名 **ThermoX 2.5.3.6**(巨哥电子),是**网络码流**软件
(指定IP/DHCP Server/传输混合·纯视频·纯温度码流),扫不到 USB 机芯 → 不适用于本机芯。
- `IR_Camera_SDK-1.0.1/.../EloThermal.exe`(官方 SDK 演示)能出图但**无存图功能**(全源码核实)。
- 采用 `tsdk_pair3.exe`(调用官方 `ThermalSDK.dll`/`CoreSDKLib.dll`)做基准采集:
每帧 19 个文件(官方渲染 rgb/cbrgb、灰度、NUC 输出、输入帧、ref、t32 温度、pal、
LUT、盲元、DDT),官方 DDT 一并拿到。
### 2) 同帧逐像素对比结果(官方 vs 我们的 Kotlin 管线)
- **NUC 域逐位相等**:抽检 4 帧 19200/19200 像素完全一致(max diff = 0)→ 端点插值、
增益/阈值表、盲元补偿、ref 处理全部正确。
- **调色板 256/256 完全一致**;官方彩色图 = palette(官方灰度)(max diff 0)→ 官方这份
渲染**没开** 7×7 细节增强,与我们默认增强等级 0 对齐。
- **灰度平均差 +1.3 级、最大 10 级**;彩色 61–72 % 字节相同(平均 |Δ| 1.4–1.9/255)。
- **窗口下界规则不同**:官方 20/20 帧 `winHi = 数据max`、12/20 帧 `winLo = 数据min`
(其余低 1–7 计数);我们 `winLo = mean−500`,本例比数据 min 低 188 计数
→ 就是那 1.3 级偏差的来源。
- **温度曲线系统差**:官方 t32 是 NUC 计数的精确直线(k = 8.539 mC/count,残差 ≤0.5 mC,
无逐像素差异,即 `ConvertResponse2Temperature` 一类);我们固定 T2E 表斜率 8.26→7.67
递减 → **27 ℃ 处 +0.1 ℃,38 ℃ 处 −0.62 ℃**(最大 −0.70 ℃)。
### 3) 新解码的官方内部语义(供后续复用)
帧输入 = `dev+0x41f20`、ref = `dev+0x41ef0`、**FPA 温度 = `dev+0x54`**(喂进我们管线后
NUC 与官方逐位相等,据此锁定帧尾 FPA 字段)、NUC 输出 = `dev+0x270`(`raw` 缓冲)。
### 4) 环境坑(重要)
- 本机原本没装相机驱动(`CM_PROB_FAILED_INSTALL`)。厂商 INF `ThermoCube_stream.inf`
是 libusb-win32 1.2.6.0 + libwdi 自签 cat → 需先把证书导入"受信任的根/发布者"才能
`pnputil /add-driver /install`;装好后设备 OK、`libusb0` 服务 RUNNING。
- **libusb-1.0 1.0.19 在该绑定下不可用**:`claim_interface` 内部 Win32 错误 87
(`LIBUSB_ERROR_ACCESS`),故 csdk 的 `demo3.exe`/`render_offline` 在本机跑不起来;
官方 SDK(libusb0.dll API)正常。要用 C 工具需换 libusb-1.0 版本或把设备改绑
WinUSB/libusbK。
### 5) 待办(下一轮)
1. 温度改用设备响应换算(线性系数;先定位 DDT/BasePara 中的标定字段)。
2. `statsWindow()` 下界对齐官方行为(≈数据 min)。
3. 手机侧受控对照(官方手机 App 的 MDT 里存的就是 NUC 后帧,可喂我们的显示阶段;
需要给管线加"只跑显示阶段"的离线入口)。
临时文件(官方 20 帧基准数据、差异图、harness)都在 `%TEMP%\mag160c_wincmp\`,仓库内
只保留结论文档 + 一张证据图;本轮临时单测已删除。
## 用户反馈修复 第二十五轮(2026-09-12,关闭 debug 日志 + 带图操作手册) ## 用户反馈修复 第二十五轮(2026-09-12,关闭 debug 日志 + 带图操作手册)
用户三点要求:把 App 的 debug 日志关掉;做一个带图的手册;push 上去。 用户三点要求:把 App 的 debug 日志关掉;做一个带图的手册;push 上去。
@@ -0,0 +1,366 @@
# 官方温度链逆向(IDA 反编译为准绳)2026-10-06
> 目标:把官方 SDK 的"计数 → 温度"换算**按反编译结果**完整复刻到我们 App,
> 不用拟合、不猜。本轮已完成反编译取证、运行时参数读取、官方基准数据采集与
> 波段模型验证;**最后两个修正项(环境/距离项)的字段含义尚未对齐**,下轮继续。
>
> 工具链:IDA Professional 9.4(批处理 `idat.exe -A -S<脚本>` + Hex-Rays)、
> 官方 DLL 运行时内存读取(Python ctypes)、官方 ThermalSDK 同帧采集(tsdk_pair3)。
## 1. 官方温度链(函数级,已反编译)
调用关系(CoreSDKLib.dll,基址 0x180000000):
| 函数 | 作用 |
|---|---|
| `MAG_GetTemperatureData(chan,buf,bytes,flag)` @0x180003fc0 | 主路径:逐像素 `sub_180016290(counts, pixelIdx, 1)`,flag≠0 或 `dev+0x78≠0` 时再套 `sub_180015b90` |
| `MAG_GetTemperatureData_Raw(...)` @0x180004030 | **两点线性标定**:横坐标取当帧 `counts[idxA]`、`counts[idxB]`(`dev+0x44cc0/4`),
纵坐标取 `dev+0x44cb0/4`(由 `sub_18000d2a0` 每帧用 `sub_180016290` 算出),逐像素线性插值 |
| `MAG_GetTemperatureProbe(chan,x,y,mode)` @0x1800045a0 | 探针:先 `sub_180014e70` 做**邻域平均**(mode→1×1/3×3/5×5),再同上 |
| `sub_180016290(dev,counts,pixIdx,flag)` @0x180016290 | **核心换算**,三个分支(见下) |
| `sub_180015b90(dev,T,float)` @0x180015b90 | 修正(发射率/透过率/环境)——装配代码见 §3 |
| `sub_180015840` / `sub_1800158b0` | 曲线 A 的前向(温度域→辐亮度域)/ 逆向插值 |
| `sub_180015A40` / `sub_180015940` | 在曲线 A 上做"增量"换算(把辐亮度增量变回温度增量) |
| `sub_18000d2a0` @0x18000d2a0 | 每帧温度收尾:算 `dev+0x44cb0/4/8/ccc/cd0`(两点标定端点、均值温度),并按 `dev+0x78` 决定是否修正 |
### `sub_180016290` 的三分支(决定我们该走哪条)
```
if (dev+0x41880 == 0):
if (dev+0x47950 != 0): # T2E 表分支
v = counts << (7 - dev+0x47954) # 注意:不是 counts*3-5797!
二分查找 curveA(646 项) → (v-A[i])*S[i]>>12 + (i<<12) - 150000
else: # 线性分支
return dev+0x47948 + (counts << dev+0x4794c)
else: # 放射学分支(本机 = 这条)
eps = *(float*)(rec+0x204)
if eps<=0.01 or eps>=0.99: # 本机 eps=1.0 → 进入
若 rec+0xAC/B0/B4/BC 均有效(>=1/>0) 且 flag!=0: 走"fix"路径(本机这些字段=0 → 跳过)
else: ... 发射率反演 ...
波段映射:band = 阈值(rec+0x40 起 8 个)分段
T = off[band] + ((counts * gain[band]) >> rec+0x30(=12)) # gain/off 在 rec+0xD4 起,步长 8
环境补偿(v44..v54,见 §2)→ v54
if flag: 距离/大气项(dev+0xA8、dev+0x44CB8 等,见 §2)
return T + sub_180015A40(T, v54)
```
**关键结论**:本机 `dev+0x41880 = 1`,即官方走**放射学分支 + 波段映射**,
而我们 App 现在用的是 `TempMath.countsToTempMc`(= T2E 表分支)——**分支都不同**,
这就是温度系统差的根因(而不是"表差一点")。
## 2. 本机运行时读到的参数(同一帧,ctypes 直读官方设备对象)
| 字段 | 值 | 含义 |
|---|---|---|
| `dev+0x41880` | 1 | 走放射学分支 |
| `dev+0x47950 / 0x4794c / 0x47948` | 0 / 3 / −50000 | 表分支关闭;线性分支参数(−50000 与 DDT 头部字段一致) |
| `rec = dev+0x41888`(540B/记录,索引 `dev+0x98`,上限 `dev+0x41884`=1) | | |
| `rec+0x30` | 12 | gain 右移位数 |
| `rec+0x34 / 0x38` | 29917 / 29417 | 差 = −500(与 DDT 第二组锚点 −500 呼应),用于环境补偿 |
| `rec+0x3c` | 8 | 波段数 |
| `rec+0x40…0x5C` | 9389, 9995, 10328, 10581, 10797, 11160, 11430, 11772 | 波段阈值(计数域) |
| `rec+0xD4…0x124` | (33486,−51932) (33174,−51216) (36271,−58774) (32637,−49610) (36280,−59022) (32842,−49958) (29110,−39789) (35059,−56391) | 每波段 (gain, offset),单位 mC |
| `rec+0x204` | 1.0 | 发射率(1.0 → 不走发射率反演) |
| `rec+0x208 / 0x20C` | 19790 / 44930 | 距离/大气项阈值对 |
| `dev+0x54 / 0x58 / 0x5C / 0x60 / 0x64` | 34412 / 33912 / 34382 / 33882 / 1 | 环境补偿输入(传感器/环境温度对) |
| `dev+0xA8`(float) | −0.0279 | 距离/大气系数(非 0 → 该项参与) |
| `dev+0x44CB8` | 29564 | 当帧**均值温度**(`sub_18000d2a0` 写入) |
| `dev+0xCC / 0x78 / 0xD4 / 0xD8` | 2 / 0 / 0.98 / 29.45 ℃ | 修正开关 / 修正使能 / 调用方传入的 ε·τ 因子 / 环境温度 |
| `dev+0xF4 / 0xF8` | 0.99991 / 1.0 | 修正公式里的两个 (0,1] 因子(K = f8·f4·a3) |
| 曲线 A `0x180099000`(646×i32) | 51,70,94,125,162,208,… | **= 我们 OfficialTables.T2E 已经一致** |
| 斜率表 `0x180099A20`(646×i32) | | 逆向插值用的局部斜率(我们表里未导出,需补) |
| `dev+0xAC…0xC8`(8×i32) | 全 0 | 环境温度修正查表(本机为 0) |
**DDT 文件尾部就是这条记录**:`official.ddt`(1856416B) 从 1855856 起可对齐到
rec 的波段阈值(1855940=9389…)、gain/offset(1856088=33486…)、
`0x34/0x38`(1855932/1855928 = 29917/29417)、`rec+0x204` 区(1856372 起 eps=1.0、19790、44930)。
→ App 只要解析 DDT 尾部即可拿到全部温度标定参数(无需额外设备指令)。
## 3. `sub_180015b90`(修正)汇编要点
```
if (dev+0xCC == 0) return T; # 开关
需要 0 < dev+0xF4 <= 1、0 < a3(传入 0.98) <= 1、0 < dev+0xF8 <= 1,否则原样返回
K = dev+0xF8 * dev+0xF4 * a3
Lobj = sub_180015840(T) # 温度域 → 曲线 A 的"辐亮度"域
Lamb = sub_180015840( (int)(dev+0xD8 * 1000) ) # 环境温度 → 辐亮度
Teff = sub_1800158b0( (Lobj - (1-K)*Lamb) / K ) # 反演回温度
Teff = max(Teff, -273150)
idx = clamp((dev+0x5C - 15000)/5000, 0, 6) # 环境温度查表(本机表全 0 → 无修正)
Tout = Teff + tab[idx] + (tab[idx+1]-tab[idx])*(dev+0x5C-5000*(idx+3))/5000
若 dev+0x6C != 0: Tout += (int)(Tout*dev+0xEC) - (int)(dev+0xF0 * 0.4f)
```
(这就是官方"温度修正参数"里的发射率/透过率/环境温度的落点。)
## 4. 验证状态(官方同帧数据)
- 采集:`tsdk_pair3` + ctypes 直读(`dev+0x270` 计数缓冲、`dev+0x41888` 记录、
`MAG_GetTemperatureData(flag=1)` 输出)。
- **配对必须同帧**:`MAG_GetTemperatureData` 是按"当前帧"现算的;先用
`ThermalSDK.Stop()` 冻结缓冲可保证同帧(但 Stop 后温度接口返回失败,故只能
"先取温度、立刻取计数、并校验同计数下温度唯一(spread=0)")。
- 结果:
- **波段映射单独就复现了官方输出的主体**:flat 背景所在的 band 1
拟合残差仅 **3.2 mC**,说明"阈值 + 每波段 gain/offset"结构完全正确;
- 但整体仍有 **≈230 mC 的平均差**,且残差随计数平滑变化——对应 §2 里
还没对齐的**环境补偿 (v50) 与距离/大气项**;
- 我按反编译把这两项加上后误差反而变大(≈980 mC),说明其中某个字段的
语义还没读对(`dev+0xA8`/`dev+0x44CB8`/`rec+0x208/0x20C` 的角色待确认)。
- 结论:**结构已定,系数待钉**。下一步只需在这几个字段上做单变量对照
(逐项开关 + 与官方输出比对),不需要再猜公式。
## 4b. 第二轮收敛(同日稍晚):确认公式为 `revise(band_map(counts))`,只剩一个常数项
按"单变量对照 + 数据反解"推进,结论如下(全部脚本与数据在 `%TEMP%\mag160c_wincmp\`):
- **曲线与修正被证实**:
- `E2T`/`iE2T`(`sub_180015840`/`8b0`)往返误差 ≤1 mC(`iE2T(E2T(29000))=28999`);
- 官方输出在 band 1 上是干净直线(拟合残差 **3.3 mC**,斜率 8.26479);
- 我的模型 `revise(band_map)` 的斜率 **8.26451**(差 0.003 %)→ **revise 就是 1/K 缩放**
(K = f8·f4·ε = 1.0×0.99991×0.98 = 0.979912,1/K = 1.020500)。
- **只剩一个常数**:把官方数据反解回波段域(对 revise 求逆)后,与记录的波段映射
相差 **恒定 −197 mC**(环境取 `dev+0xD8`,即反编译字面值)或 **−95 mC**
(环境取 `dev+0x5C`);与计数无关 → 是**加性项**,不是斜率/表格问题。
- **已排除**(逐项实测,非推断):
- 记录索引/第二条记录:`dev+0x98=0`,`dev+0x41884=1`,第 2 条记录**全 0**(未用);
- 记录的 gain/移位:与数据反解出的斜率**逐位吻合**(band1:记录 8.09905 × 1/K = 8.2645 ≈ 数据 8.2648);
- 环境修正查表 `dev+0xAC…0xC8`:**全 0**;
- `dev+0xEC/0xF0` 增益项:**0/0**(`dev+0x6C=1` 但系数为 0);
- 环境补偿 `v50/v54`:本机 `dev+0x54==0x5C`、`0x58==0x60` → `v50≈30`、`v54≈11`
(≈ +32 mC,方向相反,不是这 −197);
- 距离/大气项(`dev+0xA8=−0.0279`,v56=`dev+0x44CB8` 均值温度):按反编译算出的
增量 ≈ −320(辐射域)≈ −800 mC,**与官方输出不符 → 该项的实际语义仍未读对**
(可能 `dev+0xA8` 需与其他字段组合,或该项仅在对焦/距离参数被设置时才生效)。
- 观察到的可疑未读字段:`dev+0x50 = 200`(int)、`dev+0x14 = 20000`(温度样)、
`dev+0xD0 = 0.65`(float)——需与官方输出做单变量对照确认。
**下一步(精确做法,不猜)**:
1. 在 SDK 的 `SetNewIRFrameDelegate` 回调里读设备字段(最接近温度计算时刻),对同一个
count 记录 (字段值, 官方输出) 序列;看 −197 是否随某个字段变化 → 定位该字段。
2. 若 `dev+0xA8` 距离项确需生效,用 `MAG_SetFixPara`/`MAG_FixTemperature` 改变距离参数
再采一次,观察官方输出的变化量,反推该项的正确组合方式。
3. 钉死后即按 §5 落地 Kotlin,并用本轮同帧数据做逐像素回归(band 1 目标 mC 级)。
## 4c. 第三轮:多帧序列 + 相关性分析(当前状态:差一个约 0.15 ℃ 的静态项)
采集 15 帧同帧配对样本(`offseries.pkl`,脚本 `offseries4.py`:只要求 counts+记录稳定,
其余字段"读到时记录"),逐帧反解"官方隐含波段映射 vs 记录波段映射"的差:
- 差值不是常量而是 **在 −123…−184 mC 之间变化**,并且
**与 `dev+0x5C` 的相关系数 r = 0.999(斜率 ≈ 1:1)**——`dev+0x5C` 正是反编译里
环境补偿 v50 的输入之一(`v50 = dev+0x58 + 2*(dev+0x5C - dev+0x54) - dev+0x60 + v47`,
实测 v50 = 30 / 60 / 89,随帧相位阶跃 30)。
- 逐项复算(按反编译):`v45 = 34120`、`v47 = 0`、`v50 = 30…89`、`v52 = v53 = 44`、
`v54 = 11…33`,对应 `A40` 的温度增量 +30…+90 mC;而"缺的量"是
**≈ +123…+181 mC,方向相反**(即官方比"含环境项的模型"还要高 ~211 mC),
用最小二乘把它写成 `Δ ≈ 210.8 − 0.98·v50`(15 帧内拟合残差 ±3 mC)。
- 该"211"**不是存储常量**:设备对象 0x0–0x1200、记录 540B、DDT 尾部 560B 里都搜不到
211/−211,也没有 0.05 附近的浮点;`dev+0x14 = 20000`(20 ℃)、`dev+0x50 = 200`
是目前仅有的两个可疑字段(后者与 211 接近,需单变量验证)。
- 另一个可能的混杂因素:`dev+0x5C/0x54/0x58/0x60` 每帧都在变(阶跃 30),而我的
读取时刻与 SDK 计算温度的时刻可能差一帧——`v50` 的"追踪"信号有可能来自这个相位差。
**当前结论(第三轮收敛后)**:结构 100 % 确认、斜率逐位吻合;把 15 帧的差值写成闭环形式后
是 **`official = model(含环境项) − 211.3 mC`**(15 帧内该项稳定在 −211.3 ± 1 mC,
与 v50 无关;v50 的"追踪"只是我早期把 Δ 写成"v50 − 211.3"造成的表象)。
也就是说:**只剩一个 −211.3 mC(≈0.21 ℃)的静态项没定位**,且它**不在任何数据文件里**
(设备对象 0x0–0x1200、记录 540B、DDT 尾部 560B 均无 211/−211,也无 0.05 量级浮点);
按反编译算出的距离/大气项是 **−995 mC**(≈4.7 倍),说明该段(`dev+0xA8` 那一支)还有
一段没读全(可能是 `dev+0xA8` 之外的组合,或该项的 `sub_180015940` 首参不是常量 35000)。
工程判断:这已经是"+0.09 ℃(常温)…−0.70 ℃(高温)"→"−0.21 ℃ 常数"的差距,但**未定位前不改代码**
(否则就是把 −211.3 这个拟合常数硬编码进去,违反"不猜"原则)。
## 4d. 第四轮:**模型闭合(平均 13.9 mC)**——根因是 Hex-Rays 的十进制/十六进制陷阱
第三轮卡住的 −211.3 mC 常数,来源已找到:`sub_180016290` 距离/大气段的汇编是
```
movss xmm1, [rsi+0A8h] ; dev+0xA8 (float)
mov ecx, [rsi+44CB8h] ; 当帧均值温度 v56
... clamp v56 到 [5000,40000](或 ±80000)...
sub ecx, [rsi+0A4h] ; ★ 减的是 dev+0xA4(十进制 164 = 0xA4)
mov edx, 88B8h ; 35000
cvtsi2ss xmm0, ecx ; mulss xmm0, xmm1 ; cvttss2si r8d, xmm0
call sub_180015940 ; 温度域小量 → 辐亮度域增量
add ebx, eax ; 累加到 v54
```
Hex-Rays 伪代码里写的是 `*(_DWORD *)(a1 + 164)`(**十进制** 164),我上一轮误当成
十六进制 `0x164`(=0,因此那一项被算成 0)——**正确字段是 `dev+0xA4`(实测 22216 mC)**:
```
arg = (int)((float)(clamp(v56, [5000,40000]) - dev+0xA4) * dev+0xA8)
= (29831 - 22216) * (-0.0279) = -212
dist = sub_180015940(35000, arg) # ≈ -75(辐亮度域)
v54 += dist # 再经 A40 变回温度域 ≈ -212 mC
```
**修正后,完整模型与官方输出(15 帧 × 19200 像素)平均 |Δ| = 13.9 mC(0.014 ℃)**,
全部 15 帧一致(13.4–14.5 mC);残余多为 Python double vs C float32 取整差与跨帧毛刺,
最大 ~950 mC 只在极少数像素(band 边界/跨帧)。
→ 同时也解释了之前"数值对不上"的另一半原因:`dev+0xA4` 我根本没有读到过
(它不在 DDT 里,是设备上报的运行参数)。
### 已完整验证的算法(可直接落地,逐条对应反编译)
```text
A[0..645] = 曲线 A(= 我们 OfficialTables.T2E,已验证前 8 项一致;需再核对全表)
S[i] = (0x1000000 + (A[i+1]-A[i])/2) / (A[i+1]-A[i]) # 官方 0x180099A20 表,我们按需自算
cdiv(a,b) = 向零截断的整数除法(C 语义)
E2T(x) = 曲线A正向:i=clamp((x+150000)/4096,0,645); max(A[i] + (A[i+1]-A[i])*(x+150000-(i<<12))/4096, 0)
iE2T(L) = 曲线A逆向:i=最大使 A[i]<=L; ((L-A[i])*S[i])>>12 + (i<<12) - 150000
A40(T,d) = 在 T 处把"辐亮度域增量 d"换算成"温度域增量"(含跨段与 2|d| 判定)
A940(T,d)= 同上,但斜率恒用 (A[i+1]-A[i])
band_map(c):
b = 最大的 i 使 c > thr[i](thr/thr_cnt 在记录 rec+0x40/rec+0x3C,8 段)
return rec[0xD8+8b] + ((c * rec[0xD4+8b]) >> rec[0x30]) # (offset, gain, shift=12)
温度(c)(MAG_GetTemperatureData flag=1 路径):
v46 = band_map(c)
v45 = dev[0x54] + rec[0x38] - rec[0x34]
v47 = dev[0x64]!=0 ? clamp(v45-dev[0x58], -5000, 1000) : clamp(dev[0x58]-v45, -1000, 1000)
v50 = dev[0x58] + 2*(dev[0x5C]-dev[0x54]) - dev[0x60] + v47
v49 = dev[0x58] + 150000; i52=clamp(v49/4096,0,645); i53=clamp((v49+v50)/4096,0,645)
v54 = (i52==i53) ? v50*(A[i52+1]-A[i52])/4096
: A[i53] + (A[i53+1]-A[i53])*(v49+v50-(i53<<12))/4096
- (A[i52+1]-A[i52])*(v49-(i52<<12))/4096 - A[i52]
v56 = clamp(dev[0x44CB8], 5000..40000) # 当帧均值温度(SDK 每帧算)
v54 += A940(35000, (int)((v56 - dev[0xA4]) * dev[0xA8]))
T = v46 + A40(v46, v54)
# 调用方的 revise(flag=1 时):
K = dev[0xF8] * dev[0xF4] * dev[0xD4] # 实测 1.0 × 0.99991 × 0.98 = 0.979912
Lamb = E2T((int)(dev[0xD8] * 1000.0f)) # dev[0xD8] = 当帧均值温度(℃),实测 = 44CB8/1000
T = max(iE2T((int)((E2T(T) - (1-K)*Lamb) / K)), -273150)
idx = clamp((dev[0x5C]-15000)/5000, 0, 6) # 环境温度查表(本机 dev[0xAC..0xC8] 全 0)
T += tab[idx] + (tab[idx+1]-tab[idx])*(dev[0x5C]-5000*(idx+3))/5000
if dev[0x6C] != 0: T += (int)(T*dev[0xEC]) - (int)(dev[0xF0] * -1000.0f)
```
### DDT 尾部 = 标定记录(已完整对齐,App 可直接解析)
相对 `DDT_size - 560`(本机 `official.ddt` = 1856416,故起点 1855856):
| 尾部偏移 | 值(本机) | 含义 |
|---|---|---|
| +0x34 | 0.0065f | (未用,待确认) |
| +0x38 / +0x3C / +0x40 | 1.0f / 2.0f / 1.0f | (未用,待确认) |
| +0x44 | 12 | gain 右移位数(rec+0x30) |
| +0x48 / +0x4C | 29417 / 29917 | rec+0x38 / rec+0x34(相差 500,环境补偿用) |
| +0x50 | 8 | 波段数(rec+0x3C) |
| +0x54 … +0x70 | 9389,9995,10328,10581,10797,11160,11430,11772 | 8 个波段阈值(rec+0x40…) |
| +0xE8 … +0x124 | (33486,−51932) … (35059,−56391) | 每波段 (gain, offset)(rec+0xD4…) |
| +0x1B0 … +0x1D0 | 11,−2,−16,−25,−31,−36,−45,−52,−61 | (未用,待确认) |
| +0x218 | 1.0f | 发射率(rec+0x204) |
| +0x21C / +0x220 | 19790 / 44930 | rec+0x208 / rec+0x20C(距离项阈值对) |
### 落地到 App 还缺的数据来源(下一步)
| 运行时字段 | 值(本机) | 来源(已定位/待定) |
|---|---|---|
| `dev+0xA4` | 22216 mC | **官方手机 App 的 `ExtPara.intCurrentEnvTemperature`**(`DeviceController.getExtParameter` JNI;我们的 `IrSession` 目前只取 BasePara1/2,需要补这个命令) |
| `dev+0x44CB8` | 当帧均值温度 | 可自算:`band_map(s_mean)` + 环境项(flag=0 路径) |
| `dev+0xD8` | = 44CB8/1000(℃) | 同上 |
| `dev+0x54 / 0x58 / 0x5C / 0x60` | 34620/34120/34650/34150 | 传感器/环境温度对;0x58=0x54−500(=rec 差);0x5C−0x54=30 → 需再用"与帧数据相关性"定位(候选:帧尾 FPA、BasePara2 的 `fpaTempFix`/`ATZeroErrorPoint`) |
| `dev+0xF4 / 0xF8` | 0.99991 / 1.0 | 修正系数;候选 `BasePara2.ATErrorSlope`/`ATZeroErrorPoint` |
| `dev+0x64 / 0x6C / 0xCC` | 1 / 1 / 2 | 开关位,需与 ExtPara/BasePara2 字段对照 |
**下一步**:把上表右列补全(用同一套"同帧相关性"方法:把候选来源的字段与 dev 字段逐帧对齐),
然后按上面的伪代码在 Kotlin 落地(波段映射 + 环境补偿 + 距离项 + revise),
用 15 帧官方同帧数据做逐像素回归(目标 ≤20 mC),最后替换 App 的所有温度读数点。
## 4e. 第五轮:数据来源全部定位(`MAG_SetFixPara` / BasePara2 / 斜率表)
本轮的三个关键发现,把上表右列基本填满:
1. **斜率表不用导**:`FUN_1800512c0`/`FUN_180050fc0` 之外的**初始化代码**里,
`DAT_180099A20[i] = ((Δ >> 1) + 0x1000000) / Δ`,`Δ = A[i+1] - A[i]` ——
与我们在 Kotlin 里现算的公式**完全一致**(645/645 项 ±1 内吻合)。
→ 曲线 A(= `OfficialTables.T2E`,**646 项逐项相等**)已够用。
2. **`dev+0xA4` / `dev+0xA8` = 设备上报的 BasePara2 字段**:
- `dev+0xA4` = 22216 mC = **`BasePara2.ATZeroErrorPoint`**(环境温度误差零点,22.2 ℃)
- `dev+0xA8` = −0.0279 = **`BasePara2.ATErrorSlope`**(环境温度误差斜率)
→ 这一段(我先前叫"距离/大气项")本质是**环境温度误差修正**:
`arg = (当帧均值温度 − ATZeroErrorPoint) × ATErrorSlope`,本机 = −212 mC。
官方手机 App 里对应 `sdk/BasePara2.java` 的同名字段;我们的 `IrSession` 已经发了 66c
拿到 BasePara2,只是**当前仅打印不解析**——补上解析即可。
3. **"温度修正参数"结构 = `dev+0xD0` 起 44 字节**(`MAG_GetFixPara` 直接 memcpy 0x2c):
`MAG_SetFixPara(chan, para, enable)` 写 `dev+0xD0` 并调 `FUN_180014fe0` 重算:
其中 `param_2[9]`(= `dev+0xF4`)由 `(发射率等参数 + 环境温度查表) → 指数/多项式` 算出
(本机 0.99991);`param_2[2]` 是环境温度(整度,clamp −25…35,查表用);
`param_2[4]` 作除数(距离/湿度类);`enable` 写 `dev+0xCC`(本机 2)。
→ `dev+0xF4/F8/CC/6C/D8` 都能由"修正参数 + 每帧均值温度"得到(默认参数见 §2 实测值)。
**仍然待定的一项**:`dev+0x54 / 0x58 / 0x5C / 0x60`(两组传感器温度对)的来源。
已知:`0x58 = 0x54 − 500`、`0x60 = 0x5C − 500`(500 = 记录的 `rec+0x34 − rec+0x38`),
且 `0x54` 在一段采集里保持不变、`0x5C` 缓慢上升(差值 0/30/89 mC)——
很像"上次 FFC 时的 FPA 温度"与"当前 FPA 温度"。实验方法(下轮):
读 SDK 设备对象里的**帧解析缓冲**(`*(void**)(dev+0x2d20)` 指向 28B 头 + payload + 28B 尾),
把 `0x5C/0x54` 与帧头/帧尾各字段逐帧做相关性 → 直接确定它们来自帧的哪个字节。
(帧尾 `+8` = FPA 温度已经由我们的 NUC 逐位吻合实验证明 = `dev+0x54`。)
## 4f. 第六轮:最后一项也定位了 —— `dev+0x5C = dev+0x54 + fpaTempFix`
多次采样发现 `dev+0x5C − dev+0x54 ∈ {0, 30}` 且 `dev+0x54` 恒定(= 帧尾 FPA 温度,
已由 NUC 逐位吻合实验证明),`dev+0x58 = dev+0x54 − 500`、`dev+0x60 = dev+0x5C − 500`。
代入环境补偿公式:
```
v45 = dev54 + rec38 − rec34 = dev54 − 500
v47 = clamp(v45 − dev58, −5000, 1000) = clamp(0) = 0
v50 = dev58 + 2*(dev5C − dev54) − dev60 + v47
= (dev54 − 500) + 2*fix − (dev54 + fix − 500) + 0
= fix
```
→ **`v50 = fpaTempFix`**(`BasePara2.fpaTempFix`,设备上报的 FPA 温度修正,本机 30 mC),
与实测的 v50 = 30/60/89 完全一致(0x5C−0x54 的跳变就是 fpaTempFix 的变化)。
### 结论:温度链的**全部输入**都已定位,App 侧数据源如下
| 输入 | 来源(App 可直接拿) |
|---|---|
| 曲线 A(646 项) | `OfficialTables.T2E`(**与官方模块逐项相等,已验证**) |
| 斜率表 S | 现算 `(0x1000000 + Δ/2)/Δ`(官方也是运行时算的,645/645 吻合) |
| 波段记录(阈值/gain/offset/shift/±500 温度对/eps/19790/44930) | **DDT 尾部**(§4d 偏移表) |
| `dev+0x54` | 帧尾 `+8` 的 FPA 温度(= 管线已有 `shutter`) |
| `dev+0xA4` / `dev+0xA8` | **BasePara2.ATZeroErrorPoint / ATErrorSlope**(66c 已收到,需解析) |
| `v50` | **BasePara2.fpaTempFix** |
| `dev+0x44CB8`、`dev+0xD8` | 当帧均值温度(= `band_map(s_mean)` + 环境项,可自算);`0xD8` = 它/1000 |
| `dev+0xD0..0xFB`(修正参数) | 发射率 0.98、环境温度(默认=场景均值)、距离/湿度默认值;`dev+0xF4` 由 `FUN_180014fe0` 计算(本机 0.99991,**≈1 时对温度影响 <1 mC,可先取 1.0**) |
| `dev+0xCC/0x6C/0x64` | 开关(本机 2 / 1 / 1) |
→ **落地 Kotlin 已无未知项**:按 §4d 伪代码实现 + 解析 DDT 尾部 + 解析 BasePara2
(`ATErrorSlope`/`ATZeroErrorPoint`/`fpaTempFix`),用 15 帧官方同帧数据回归(目标 ≤20 mC)。
**唯一待确认的小事**:`BasePara2` 的**线上字节偏移**(Java 类字段顺序 ≠ 报文顺序)。
libcxsdk 反编译里没有按名写字段的痕迹,因此最快的办法是:设备连手机时 dump 一次
BasePara2 原始报文,用已知值 `22216`(ATZeroErrorPoint)与 `-0.0279`(ATErrorSlope,
float 位模式 `0xBCE4…`)定位偏移;或在 PC 侧从官方 DLL 的设备对象里找这两个值的原始副本。
在此之前,App 可先用"默认修正参数"跑通(本机默认:ATZeroErrorPoint=22216、
ATErrorSlope=−0.0279、fpaTempFix=30、发射率 0.98、f4≈1.0),误差 <1 mC。
## 5. 我们 App 侧要改什么(等模型钉死后再动手)
1. `TempMath` 增加"官方放射学分支"实现:波段映射(参数取自 DDT 尾部)+ §3 修正;
保留 `countsToTempMc` 作为回退(T2E 表分支)。
2. 需要新增导出的表:曲线 A 的**斜率表** `0x180099A20`(我们只导了 T2E);
以及 DDT 尾部的波段记录解析。
3. 温度读数/分析/远程/PDF 全部改用新路径(`RenderPipeline.probeTemp`、
`LiveViewModel` 的最高最低中心温、`AnalyzeViewModel`、`RemoteViewerViewModel`)。
4. 回归:用本轮采集的官方同帧数据做逐像素断言(band 1 应到 mC 级)。
## 6. 复现与工具(都在临时目录,未入库)
- IDA 批处理:`idat.exe -A -S<script.py> -Llog.txt <db.i64>`(脚本见
`%TEMP%\mag160c_wincmp\ida\dump_temp.py|dump_helpers.py|dump_revise.py|dis_revise.py`)。
仓库内 `analysis/ida/CoreSDKLib.dll.i64` 是原始库;批处理请先复制一份再跑(GUI/批处理会互锁)。
- 运行时参数/内存:`%TEMP%\mag160c_wincmp\offdump2.py`、`offpair.py`、`offmodel.py`。
- 官方同帧基准:`%TEMP%\mag160c_wincmp\off20\`、`offmodel.pkl`、`offpair.pkl`、`offverify.pkl`。
- IDA MCP(供交互式使用):`pip install "mcp<2" ida-pro-mcp` 已装,插件已放进
`%APPDATA%\Hex-Rays\IDA Pro\plugins\mcp-plugin.py`,ZCode 用户配置
`~/.zcode/cli/config.json` 已加 `mcp.servers."ida-pro"`;**需重启 ZCode 生效**,
且 IDA 里要先 `Edit → Plugins → MCP`(Ctrl+Alt+M)启动 13337 端口。
+119
View File
@@ -0,0 +1,119 @@
import java.io.File;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
/**
* Column/row profile of a 160x120 measurement payload, to tell scene structure from
* sensor fixed-pattern noise.
*
* The signature of column FPN is a column-to-column JITTER that is large compared
* with the same figure measured along rows: real scene content is smooth in both
* directions, stripes are not. Reported per payload:
*
* colMean step — mean |colMean[i] - colMean[i-1]| (stripes along y)
* rowMean step — mean |rowMean[j] - rowMean[j-1]| (stripes along x)
* colHF — the same after removing the local trend (3-tap), i.e. how much
* of the jitter is high-frequency grain rather than scene slope
*
* Run it on the RAW frame and on the NUC block of the same capture: if RAW is
* striped and NUC is not, the correction works and the stripes seen in a render
* must come from somewhere else; if NUC is still striped, the flat-field is stale.
*
* Usage: java FrameProfile <file.mdt> [rawOffsetHex]
* Without an offset it profiles every FRAME/NUC block it can find; an explicit
* offset is for files that store the payload outside typed blocks (the official
* app's MDT: body + 884-byte BasePara, then the frame).
*/
public class FrameProfile {
static final int B_FRAME = 0x5BB5B55D, B_NUC = 0x5BB5B560;
static final int W = 160, H = 120, NPIX = W * H;
public static void main(String[] args) throws Exception {
File f = new File(args[0]);
byte[] b = Files.readAllBytes(f.toPath());
ByteBuffer bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN);
System.out.println("== " + f.getName() + " (" + b.length + " bytes) ==");
// typed blocks
int t = b.length - 152;
if (b.length >= 152 && bb.getInt(t) == 0x5BB5B57B) {
int ddt = bb.getInt(t + 4);
int bodySize = bb.getInt(ddt + 4);
int p = ddt + 0x88, end = p + bodySize;
while (p + 8 <= end) {
int m = bb.getInt(p), len = bb.getInt(p + 4);
if (len < 0 || p + 8 + len > end) break;
if ((m == B_FRAME || m == B_NUC) && len >= 38400) {
report(m == B_FRAME ? "RAW frame (typed block)" : "NUC block (typed block)",
b, p + 8, f.getName());
}
p += 8 + len;
}
// official layout: 884-byte BasePara right after the DDT header, then the frame
if (args.length < 2) {
int bp = ddt + 0x88;
if (bp + 4 <= b.length) {
int structLen = bb.getInt(bp);
if (structLen > 0 && structLen < 4096 && bp + 4 + structLen + 38400 <= b.length) {
report("RAW frame (after " + structLen + "-byte BasePara)",
b, bp + 4 + structLen, f.getName());
}
}
}
}
if (args.length >= 2) {
report("payload at 0x" + args[1], b, Integer.parseInt(args[1], 16), f.getName());
}
}
static void report(String label, byte[] b, int off, String name) {
if (off + 2 * NPIX > b.length) {
System.out.println(" " + label + ": out of range");
return;
}
int[] v = new int[NPIX];
for (int i = 0; i < NPIX; i++) {
v[i] = (b[off + 2 * i] & 0xFF) | ((b[off + 2 * i + 1] & 0xFF) << 8);
}
double[] col = new double[W], row = new double[H];
long sum = 0;
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
int p = v[y * W + x];
col[x] += p; row[y] += p; sum += p;
}
}
for (int x = 0; x < W; x++) col[x] /= H;
for (int y = 0; y < H; y++) row[y] /= W;
System.out.printf(" %-34s mean=%.0f%n", label, sum / (double) NPIX);
int[] srt = v.clone(); java.util.Arrays.sort(srt);
System.out.printf(" percentiles p1=%.0f p25=%.0f p50=%.0f p75=%.0f p99=%.0f%n",
q(srt,1), q(srt,25), q(srt,50), q(srt,75), q(srt,99));
System.out.printf(" colMean step=%.1f colHF=%.1f (vertical stripes)%n",
step(col), hf(col));
System.out.printf(" rowMean step=%.1f rowHF=%.1f (horizontal stripes)%n",
step(row), hf(row));
double ratio = hf(col) / Math.max(1e-9, hf(row));
System.out.printf(" HF ratio col/row = %.2f %s%n", ratio,
ratio > 2 ? "-> COLUMN FPN dominant" : ratio < 0.5 ? "-> ROW FPN dominant" : "-> balanced (scene-like)");
}
static double q(int[] s, int p) { return s[Math.min(s.length - 1, p * s.length / 100)]; }
static double step(double[] a) {
double s = 0;
for (int i = 1; i < a.length; i++) s += Math.abs(a[i] - a[i - 1]);
return s / (a.length - 1);
}
/** Mean absolute deviation from the 3-tap local trend: the grain, minus scene slope. */
static double hf(double[] a) {
double s = 0;
for (int i = 1; i < a.length - 1; i++) {
s += Math.abs(a[i] - (a[i - 1] + a[i + 1]) / 2);
}
return s / (a.length - 2);
}
}
+262
View File
@@ -0,0 +1,262 @@
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import javax.imageio.ImageIO;
/**
* Inspect MAG160C photo files — ours and the official app's — and print what is
* needed to explain why two photos of the same scene differ.
*
* It reports three layers, from the outside in:
* 1. CONTAINER — is it the vendor MDT (JPEG + DDT blocks + tail)? which blocks,
* how big. A structural difference is the first thing to rule out.
* 2. DATA — the raw measurement frame and the NUC block: counts min/mean/max.
* If the two files disagree HERE, the difference is upstream of
* rendering (sensor state, FFC, calibration), not our colour mapping.
* 3. IMAGE — JPEG luma percentiles, mean and a Laplacian sharpness figure.
* That is what "the photos look different" usually means.
*
* Usage:
* javac -encoding UTF-8 MdtInspect.java
* java MdtInspect ours.jpg official.jpg
* (one file: prints the report; two files: also prints the comparison)
*/
public class MdtInspect {
// ---- MDT container magics (little-endian, as stored) ----
static final int SEC_DDT = 0x5BB5B55B;
static final int SEC_TAIL = 0x5BB5B57B;
static final int B_INFO0 = 0x5BB5B55B;
static final int B_INFO1 = 0x5BB5B55C;
static final int B_FRAME = 0x5BB5B55D;
static final int B_TXT = 0x5BB5B55E;
static final int B_PROBES = 0x5BB5B55F;
static final int B_NUC = 0x5BB5B560;
static final int B_RENDER = 0x5BB5B561;
static final int B_EXTREMES = 0x5BB5B562;
/** Calibration file (not a photo) — worth naming explicitly when handed one. */
static final int CALI_MAGIC_3 = 0x5AA50003;
static final int CALI_MAGIC_4 = 0x5AA50004;
public static void main(String[] args) throws Exception {
if (args.length == 0) {
System.out.println("usage: java MdtInspect <photo.jpg|.ddt> [second file to compare]");
return;
}
Report[] reports = new Report[args.length];
for (int i = 0; i < args.length; i++) {
reports[i] = inspect(new File(args[i]));
System.out.println(reports[i]);
}
if (reports.length == 2) compare(reports[0], reports[1]);
}
// ------------------------------------------------------------------ model
static class Report {
String name;
long bytes;
boolean isCali;
boolean isMdt;
int jpegW, jpegH;
int tailOffset = -1;
java.util.List<String> blocks = new java.util.ArrayList<>();
String probes, text, render, extremes;
int[] rawCounts; // FRAME block, 38400 B as 19200 u16
int[] nucCounts; // NUC block, same layout
double p1, p25, p50, p75, p99, meanLuma, sharpness;
String note = "";
@Override public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("== ").append(name).append(" (").append(bytes).append(" bytes) ==\n");
if (isCali) {
sb.append(" calibration file (magic 5AA5000x) — not a photo\n");
return sb.toString();
}
sb.append(" container: ").append(isMdt ? "vendor MDT" : "plain JPEG (no DDT tail)")
.append(note.isEmpty() ? "" : " [" + note + "]").append('\n');
sb.append(" JPEG : ").append(jpegW).append("x").append(jpegH).append('\n');
for (String b : blocks) sb.append(" block : ").append(b).append('\n');
if (probes != null) sb.append(" probes : ").append(probes).append('\n');
if (extremes != null) sb.append(" extremes : ").append(extremes).append('\n');
if (text != null && !text.isBlank()) sb.append(" note : ").append(text).append('\n');
if (render != null) sb.append(" render : ").append(render).append('\n');
if (rawCounts != null) sb.append(" raw frame: ").append(stats(rawCounts)).append('\n');
if (nucCounts != null) sb.append(" NUC data : ").append(stats(nucCounts)).append('\n');
sb.append(String.format(" luma : p1=%.0f p25=%.0f p50=%.0f p75=%.0f p99=%.0f mean=%.1f sharpness=%.2f%n",
p1, p25, p50, p75, p99, meanLuma, sharpness));
return sb.toString();
}
}
// ---------------------------------------------------------------- parsing
static Report inspect(File f) throws Exception {
Report r = new Report();
r.name = f.getName();
byte[] b = Files.readAllBytes(f.toPath());
r.bytes = b.length;
if (b.length < 8) { r.note = "too small"; return r; }
ByteBuffer bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN);
int magic = bb.getInt(0);
if (magic == CALI_MAGIC_3 || magic == CALI_MAGIC_4) {
r.isCali = true;
return r;
}
// --- DDT tail (last 152 bytes) ---
int ddt = -1;
if (b.length >= 152) {
int t = b.length - 152;
if (bb.getInt(t) == SEC_TAIL) {
int off = bb.getInt(t + 4);
if (off > 0 && off + 0x88 <= b.length && bb.getInt(off) == SEC_DDT) {
ddt = off;
r.isMdt = true;
r.tailOffset = t;
}
}
}
if (ddt < 0) {
r.note = "no MDT tail — official files may store the raw frame elsewhere";
} else {
int bodySize = bb.getInt(ddt + 4);
int p = ddt + 0x88, end = Math.min(b.length - 152, p + bodySize);
while (p + 8 <= end) {
int m = bb.getInt(p), len = bb.getInt(p + 4);
if (len < 0 || p + 8 + len > end) break;
byte[] data = Arrays.copyOfRange(b, p + 8, p + 8 + len);
switch (m) {
case B_INFO0 -> r.blocks.add("INFO0 " + len + " B");
case B_INFO1 -> r.blocks.add("INFO1 " + len + " B");
case B_FRAME -> {
r.blocks.add("FRAME(raw) " + len + " B");
r.rawCounts = toU16(data);
}
case B_TXT -> r.blocks.add("TXT " + len + " B");
case B_PROBES -> {
r.blocks.add("PROBES " + len + " B");
r.probes = new String(data, StandardCharsets.UTF_8).trim();
}
case B_NUC -> {
r.blocks.add("NUC " + len + " B");
r.nucCounts = toU16(data);
}
case B_RENDER -> {
int flags = len >= 8 ? bb.getInt(p + 8 + 4) : 0;
int rot = len >= 12 ? bb.getInt(p + 8 + 8) : 0;
r.blocks.add("RENDER v" + (len >= 12 ? 2 : 1) + " flags=" + flags + " rot=" + rot);
r.render = "flipH=" + ((flags & 1) != 0) + " flipV=" + ((flags & 2) != 0)
+ " rot=" + rot;
}
case B_EXTREMES -> {
r.blocks.add("EXTREMES " + len + " B");
r.extremes = new String(data, StandardCharsets.UTF_8).trim();
}
default -> r.blocks.add(String.format("unknown 0x%08X %d B", m, len));
}
p += 8 + len;
}
}
// --- the JPEG itself + image statistics ---
BufferedImage im = ImageIO.read(f);
if (im == null) { r.note += " (JPEG not decodable)"; return r; }
r.jpegW = im.getWidth();
r.jpegH = im.getHeight();
imageStats(im, r);
return r;
}
static int[] toU16(byte[] d) {
int n = d.length / 2;
int[] out = new int[n];
for (int i = 0; i < n; i++) out[i] = (d[2 * i] & 0xFF) | ((d[2 * i + 1] & 0xFF) << 8);
return out;
}
static String stats(int[] v) {
if (v.length == 0) return "(empty)";
long sum = 0; int mn = Integer.MAX_VALUE, mx = Integer.MIN_VALUE;
for (int x : v) { sum += x; if (x < mn) mn = x; if (x > mx) mx = x; }
return String.format("%d samples min=%d mean=%.1f max=%d spread=%d",
v.length, mn, sum / (double) v.length, mx, mx - mn);
}
static void imageStats(BufferedImage im, Report r) {
int w = im.getWidth(), h = im.getHeight();
int[] lum = new int[w * h];
long sum = 0;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int c = im.getRGB(x, y);
int l = (((c >> 16) & 0xFF) * 299 + ((c >> 8) & 0xFF) * 587 + (c & 0xFF) * 114) / 1000;
lum[y * w + x] = l;
sum += l;
}
}
r.meanLuma = sum / (double) lum.length;
int[] sorted = lum.clone();
Arrays.sort(sorted);
r.p1 = pct(sorted, 1); r.p25 = pct(sorted, 25); r.p50 = pct(sorted, 50);
r.p75 = pct(sorted, 75); r.p99 = pct(sorted, 99);
// Laplacian energy: comparable across images of the SAME size, which is how a
// "ours looks softer/sharper" claim gets settled
double acc = 0; long n = 0;
for (int y = 1; y < h - 1; y++) {
for (int x = 1; x < w - 1; x++) {
int i = y * w + x;
int lap = 4 * lum[i] - lum[i - 1] - lum[i + 1] - lum[i - w] - lum[i + w];
acc += Math.abs(lap); n++;
}
}
r.sharpness = acc / n;
}
static double pct(int[] sorted, int p) {
return sorted[Math.min(sorted.length - 1, Math.max(0, p * sorted.length / 100))];
}
// ------------------------------------------------------------ comparison
static void compare(Report a, Report b) {
// Nothing to compare unless BOTH are photos that actually decoded: a pair of
// calibration files (or an undecodable JPEG) has no luma figures, and printing
// zeroes would look like a real result.
if (a.isCali || b.isCali || a.jpegW == 0 || b.jpegW == 0) {
System.out.println("---- comparison skipped: not two decodable photos ----");
if (a.isCali || b.isCali) {
System.out.println(" calibration files describe the SENSOR, not a scene;");
System.out.println(" compare them byte-wise or with the block report above.");
}
return;
}
System.out.println("---- comparison " + a.name + " vs " + b.name + " ----");
if (a.jpegW != b.jpegW || a.jpegH != b.jpegH) {
System.out.printf(" size differs: %dx%d vs %dx%d — different framing/resolution,%n"
+ " so a pixel-wise diff is meaningless; compare the numbers above instead.%n",
a.jpegW, a.jpegH, b.jpegW, b.jpegH);
}
System.out.printf(" mean luma : %.1f vs %.1f (Δ %+.1f) -> brightness%n",
a.meanLuma, b.meanLuma, b.meanLuma - a.meanLuma);
System.out.printf(" p25/p75 : %.0f/%.0f vs %.0f/%.0f -> contrast width %+.0f%n",
a.p25, a.p75, b.p25, b.p75, (b.p75 - b.p25) - (a.p75 - a.p25));
System.out.printf(" sharpness : %.2f vs %.2f (ratio %.2f) -> detail/softness%n",
a.sharpness, b.sharpness, b.sharpness / Math.max(1e-9, a.sharpness));
if (a.rawCounts != null && b.rawCounts != null) {
System.out.println(" raw frame : " + stats(a.rawCounts) + "\n " + stats(b.rawCounts));
System.out.println(" ^ if these differ, the two captures saw a different sensor state/FFC,");
System.out.println(" so part of the visual gap is scene/camera, not rendering.");
} else {
System.out.println(" raw frame : " + (a.rawCounts == null ? a.name + " has none; " : "")
+ (b.rawCounts == null ? b.name + " has none" : ""));
System.out.println(" ^ without the raw frame of BOTH we cannot separate rendering from capture.");
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import javax.imageio.ImageIO;
/** Render a 160x120 payload (NUC block) with a straight linear map, to see whether
* structure in the final JPEG is in the DATA or added by the display stage. */
public class NucDump {
public static void main(String[] a) throws Exception {
byte[] b = Files.readAllBytes(new File(a[0]).toPath());
ByteBuffer bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN);
int t = b.length - 152, ddt = bb.getInt(t + 4), bodySize = bb.getInt(ddt + 4);
int p = ddt + 0x88, end = p + bodySize, off = -1;
while (p + 8 <= end) {
int m = bb.getInt(p), len = bb.getInt(p + 4);
if (len < 0 || p + 8 + len > end) break;
if (m == 0x5BB5B560 && len >= 38400) { off = p + 8; break; }
p += 8 + len;
}
if (off < 0) { System.out.println("no NUC block"); return; }
int W = 160, H = 120;
int[] v = new int[W * H];
for (int i = 0; i < W * H; i++) v[i] = (b[off + 2 * i] & 0xFF) | ((b[off + 2 * i + 1] & 0xFF) << 8);
int lo = Integer.MAX_VALUE, hi = Integer.MIN_VALUE;
for (int x : v) { if (x < lo) lo = x; if (x > hi) hi = x; }
BufferedImage im = new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++) {
int g = (int) Math.round(255.0 * (v[y * W + x] - lo) / Math.max(1, hi - lo));
im.setRGB(x, y, (g << 16) | (g << 8) | g);
}
ImageIO.write(im, "png", new File(a[1]));
System.out.println("wrote " + a[1] + " range " + lo + ".." + hi);
}
}
+25
View File
@@ -0,0 +1,25 @@
import java.awt.image.BufferedImage;
import java.io.File;
import javax.imageio.ImageIO;
/** Column-jitter (stripe) amplitude inside a flat region of a rendered photo. */
public class StripeCheck {
public static void main(String[] a) throws Exception {
BufferedImage im = ImageIO.read(new File(a[0]));
int x0 = Integer.parseInt(a[1]), y0 = Integer.parseInt(a[2]);
int w = Integer.parseInt(a[3]), h = Integer.parseInt(a[4]);
double[] col = new double[w];
double lo = 255, hi = 0;
for (int y = y0; y < y0 + h; y++)
for (int x = x0; x < x0 + w; x++) {
int c = im.getRGB(x, y);
int l = (((c >> 16) & 0xFF) * 299 + ((c >> 8) & 0xFF) * 587 + (c & 0xFF) * 114) / 1000;
col[x - x0] += l; if (l < lo) lo = l; if (l > hi) hi = l;
}
for (int i = 0; i < w; i++) col[i] /= h;
double hf = 0; int n = 0;
for (int i = 1; i < w - 1; i++) { hf += Math.abs(col[i] - (col[i-1] + col[i+1]) / 2); n++; }
hf /= n;
System.out.printf("%-28s region %dx%d luma %.0f..%.0f (range %.0f) colJitter=%.2f = %.1f%% of local range%n",
new File(a[0]).getName(), w, h, lo, hi, hi - lo, hf, 100 * hf / Math.max(1, hi - lo));
}
}