Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0709f9305 | ||
|
|
2290fbbafb | ||
|
|
bf03e852ca | ||
|
|
6392bd89ee | ||
|
|
a1dfb647ea | ||
|
|
3510bdc774 | ||
|
|
1d6c5e3e55 | ||
|
|
d66d253f9f | ||
|
|
7e1db78676 | ||
|
|
3b191e6bca | ||
|
|
95ae4d46dd | ||
|
|
4f3bb50e4a | ||
|
|
604ab0cd7e | ||
|
|
e096c96430 | ||
|
|
497e5100ef | ||
|
|
705ad27cac | ||
|
|
8560b83816 | ||
|
|
e80d62050b | ||
|
|
4198f9e163 | ||
|
|
3d4bb65247 | ||
|
|
ce27a1bb82 | ||
|
|
948c9d5d3d | ||
|
|
4a60011ed9 |
+5
-2
@@ -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
@@ -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
@@ -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 测试后再提交。
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)实时预览改单级放大。两者都是**表现层**改动,不影响已对齐的映射,风险低。
|
||||||
@@ -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 端口。
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user