diff --git a/analysis/README.md b/analysis/README.md index 962bfa9..f5b8d23 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -33,8 +33,11 @@ ### `sdk_re/android_app/` — 官方安卓 App(普通版 MAG-Cx,本管线上真机跑通的代码) - `jadx_magcx/`:**全量 Java 源码**(jadx 1.5.1)。核心:`cn/com/magnity/magnitycx/sdk/UsbCommunication.java` (USB 协议真身)、`P2DCmd.java`/`D2PCmd.java`(命令表)、`DeviceController.java`(JNI 封装) -- `libcxsdk_decomp.txt`:Ghidra 全量伪代码(libcxsdk.so,1290 函数;Controller::StartProcess / - PushFrame / LoadCalibrationTable 已核对) +- `libcxsdk_decomp.txt`:Ghidra 全量伪代码(另一版本 libcxsdk.so,1290 函数;Controller:: + 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) 全量伪代码 (专业版/网络路径,CNetComm 命令层) - `coresdk_arm64_defined.txt`:ARM64 库符号表 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt index 26c881a..685431b 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt @@ -128,6 +128,21 @@ class RenderPipeline( /** True while [frameRemote] is inside the host's reference window. */ 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) /** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */ @@ -776,6 +791,8 @@ class RenderPipeline( accumulateInPlace() nucAndBlind(nuc, nuc) + System.arraycopy(frame, off, lastRenderRaw, 0, npix * 2) + lastRenderRawValid = true statsWindow() lutRebuild() grayMap() @@ -972,6 +989,17 @@ class RenderPipeline( 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. */ internal fun debugState(): String = synchronized(lock) { val sb = StringBuilder() @@ -1111,7 +1139,7 @@ class RenderPipeline( var prev = base var j = pivot while (j >= 0) { - val cand = base - ((cdfF[j] * denom) shr 14) + 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 @@ -1124,7 +1152,7 @@ class RenderPipeline( var prev = 16 * v66 + 7152 var j = pivot + 1 while (j < 1023) { - val cand = ((cdfB[j] * denom) shr 14) + 16 * v66 + 7152 + 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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index 3be92db..6936cee 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -701,10 +701,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { }, extremes = extremes, ) - val rawFrame = s.lastRawFrame - val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) { - rawFrame.copyOfRange(0x1C, 0x1C + 38400) - } else null + // The raw block must correspond to the SAME exposure as the NUC block + // below: the session's "latest frame" also advances during the + // closed-shutter frames of an FFC window, so a photo taken right after an + // 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, // so the analysis screen needs no conversion either val probeBlock = com.mag160c.thermal.media.Mdt.encodeProbes( diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt index 2605fc8..bcec040 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -780,6 +780,13 @@ class IrSession(context: Context) { fun lastShutter(): Int = pipeline?.lastShutter() ?: 0 /** 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 { val p = pipeline ?: return false if (!running.get()) return false diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt index c74883a..1c00bea 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt @@ -1,5 +1,6 @@ package com.mag160c.thermal.core +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -94,3 +95,100 @@ class AndroidLutPipelineTest { 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) + } +} diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt index 26d0af9..80a486f 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt @@ -117,4 +117,9 @@ internal object MagProtocolBytes { dst[off + 2] = ((v shr 16) 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() + } } diff --git a/csdk/README.md b/csdk/README.md index 7879b39..73f6c2c 100644 --- a/csdk/README.md +++ b/csdk/README.md @@ -294,13 +294,18 @@ build-artifacts\csdk_test_display.exe 用的(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)`。 - 该变体已逐指令移植进 Android 管线(`RenderPipeline.androidLutOf`,含黄金向量单测); - 本 C 参考**保持 PC 语义**(`cdf_pivot_75 = 1`,与 `analysis/render_offline.c` - 黄金夹具一致)。Android 版 LUT 的 C 侧移植待办,规格见 - `docs/android_app/rendering_diff_phone_20261006.md` §10.3。 + **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 侧移植时不要漏。 + 用它选/插值每像素标定表;温度换算链路仍用帧尾原值。C 侧**已实现** + (配置项 `fpa_temp_fix`,在 `rebuild_tables()` 的调用处加到帧尾 FPA 上; + 两个 demo 设 1699 = 本机 BasePara2 实测值)。 2. **温度换算待移植**:官方对这台设备走的是"放射学分支"(每波段线性映射 + 环境温度误差修正 + 发射率/透过率 revise),不是本 SDK 现在用的 T2E 表分支。完整反编译结论、伪代码与 参数来源见 `docs/android_app/temperature_chain_re_20261006.md`(§4d 可直接落地, diff --git a/csdk/include/mag160c/mag160c_render.h b/csdk/include/mag160c/mag160c_render.h index 69e6102..db7a386 100644 --- a/csdk/include/mag160c/mag160c_render.h +++ b/csdk/include/mag160c/mag160c_render.h @@ -50,6 +50,16 @@ typedef struct mag160c_render_cfg_t { int startup_force_ffc; /* official frame-75 forced FFC, 1 = on */ int warm_frames; /* frames dropped after start, official 20 */ 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; void *ffc_user; mag160c_render_post_nuc_cb_t post_nuc_cb; diff --git a/csdk/src/mag160c_render.c b/csdk/src/mag160c_render.c index dfb34a7..79913b1 100644 --- a/csdk/src/mag160c_render.c +++ b/csdk/src/mag160c_render.c @@ -59,6 +59,7 @@ struct mag160c_render_t { mag160c_render_post_gray_cb_t post_gray_cb; void *post_gray_user; int ffc_period, ffc_drift, warm_frames, cdf_pivot_75, force75; + int android_lut, fpa_temp_fix, lut_brightness; }; 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) ---------------- */ + +/* ------------- 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) { + if (r->android_lut) { android_lut_rebuild(r, nuc); return; } int span = r->win_hi - r->win_lo; unsigned S = 0xFFC00000u / (unsigned)span; memset(r->hist1024, 0, sizeof(r->hist1024)); @@ -487,7 +602,7 @@ static int ffc_step(mag160c_render_t *r, int shutter) { r->ffc_idx++; int idx = r->ffc_idx; 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; } 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->warm_frames = cfg->warm_frames ? cfg->warm_frames : 20; 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->dev24 = 5; r->dev4c_shift = 3; diff --git a/csdk/tools/mag160c_demo3.c b/csdk/tools/mag160c_demo3.c index 2c1c3e6..2a79977 100644 --- a/csdk/tools/mag160c_demo3.c +++ b/csdk/tools/mag160c_demo3.c @@ -611,7 +611,13 @@ int WINAPI WinMain(HINSTANCE inst, HINSTANCE prev, LPSTR cmd, int show) { 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. */ - cfg.cdf_pivot_75 = 1; + /* 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.post_nuc_cb = post_nuc_cb; cfg.post_gray_cb = post_gray_cb; diff --git a/csdk/tools/mag160c_linux_demo.c b/csdk/tools/mag160c_linux_demo.c index f797a51..36f9e5a 100644 --- a/csdk/tools/mag160c_linux_demo.c +++ b/csdk/tools/mag160c_linux_demo.c @@ -127,7 +127,13 @@ int main(int argc, char **argv) { cfg.warm_frames = 20; /* 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. */ - cfg.cdf_pivot_75 = 1; + /* 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; mag160c_render_t *render = NULL; if (mag160c_render_init(&render, &cfg) != MAG160C_OK) { diff --git a/docs/android_app/rendering_diff_phone_20261006.md b/docs/android_app/rendering_diff_phone_20261006.md index 95d7e69..3405f29 100644 --- a/docs/android_app/rendering_diff_phone_20261006.md +++ b/docs/android_app/rendering_diff_phone_20261006.md @@ -416,3 +416,71 @@ PC 端 NUC 是逐字节相同的,所以嫌疑在"实时状态机采到的参 必须点**复选框本体**(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)实时预览改单级放大。两者都是**表现层**改动,不影响已对齐的映射,风险低。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 138a1e4..d5a5a54 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,56 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [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 下午,手机解锁后真机验证:渲染已对齐,剩噪声) 用户:"手机我开了,你进行测试"。