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 dc4d56b..86172fc 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 @@ -23,11 +23,12 @@ class RenderPipeline( /** * Number of *settled* shutter frames averaged into the FFC reference. * - * The official core feeds every frame of the shutter window into - * `CAccumulator` (`CFunctions::PushBaselineRaw`), so its reference is much - * cleaner than a short 4-frame average — which is why the official image - * shows less residual FPN (fewer stripes) at the same frame-to-frame noise. - * Default 4 keeps the byte-exact C reference behaviour (tests). + * 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, @@ -43,6 +44,38 @@ class RenderPipeline( * app derives its value from its enhancement level as `level shl 3`. */ 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 @@ -100,6 +133,9 @@ class RenderPipeline( /** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */ 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 nuc = IntArray(npix) private val gray160 = ByteArray(npix) @@ -315,6 +351,10 @@ class RenderPipeline( } private fun lutRebuild() { + if (androidLut) { + lutRebuildAndroid() + return + } // The vendor code is 32-bit unsigned arithmetic; products/subtractions // wrap mod 2^32. u32() replicates that so results match bit-for-bit. val span = (winHi - winLo).coerceAtLeast(1) @@ -480,6 +520,32 @@ 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) + // lut[1023] is intentionally left untouched: the vendor loop stops at 1022, + // and MapTemperature can index 1023 for pixels at/above winHi. + System.arraycopy(fresh, 0, lut, 0, 1023) + } + private fun grayMap() { val s = 0xFFC00000L / (winHi - winLo).coerceAtLeast(1) for (i in 0 until npix) { @@ -508,6 +574,12 @@ class RenderPipeline( 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 } /** @@ -617,7 +689,9 @@ class RenderPipeline( ffcIdx++ val idx = ffcIdx 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 } val refEnd = 5 + baselineFrames.coerceIn(1, 64) @@ -746,7 +820,7 @@ class RenderPipeline( refCnt = 0 hasRef = false renderedOnce = false - rebuildTables(shutterIn) + rebuildTables(shutterIn + fpaTempFix) } ByteReader.decodeU16(frame, 0, npix, refScratch) refPush(refScratch) @@ -914,4 +988,152 @@ class RenderPipeline( sb.append('\n') 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 - ((cdfF[j] * denom) shr 14) + 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 = ((cdfB[j] * denom) shr 14) + 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 + } } 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 c55142d..2605fc8 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 @@ -318,12 +318,12 @@ class IrSession(context: Context) { // 5) native startProcess(caliPath) == pipeline + loadDdt val pipe = RenderPipeline( w = identity.width, h = identity.height, - // The official PHONE app runs the gray CDF without the 75 % pivot: with the - // pivot on, our rendering was measurably darker than the official one - // (gray median 33 vs 61, mean 60 vs 82 in a same-moment comparison); with it - // off the two distributions coincide (median 50 vs 61, mean 80 vs 82). - // See docs/android_app/rendering_diff_phone_20261006.md §8. - cdfPivot75 = false, + // 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 @@ -338,6 +338,10 @@ class IrSession(context: Context) { // the user's detail-enhancement choice must survive a reconnect, so it // is applied at construction rather than only when the setting changes 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)) { DebugLog.log("session", "ddt_fail (fetched/bundled ${ddt.size} B not loadable)") @@ -355,6 +359,11 @@ class IrSession(context: Context) { "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 active = this diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutTest.kt new file mode 100644 index 0000000..e367000 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutTest.kt @@ -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]) + } + } +} diff --git a/docs/android_app/rendering_diff_phone_20261006.md b/docs/android_app/rendering_diff_phone_20261006.md index 20c2b49..2bb6f1b 100644 --- a/docs/android_app/rendering_diff_phone_20261006.md +++ b/docs/android_app/rendering_diff_phone_20261006.md @@ -262,3 +262,87 @@ raw 帧**逐个推进 `CAccumulator`(`Reset(pixels, baseLineAcc=4, 0)`,mode `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(gray, 255<<3=2040)` | 用户可开(默认 0)→ **待办** | +| 翻转 | 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 细节增强按官方默认打开(强度 2040)并回归——本轮先不动,因为离线拟合显示我们的 + `DetailEnhance` 在 2040 下反而略微变差,疑移植细节待核; +3. 同步 csdk(Android 版 LUT + fpaTempFix + 温度链)。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 9d13d2d..724d394 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,55 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 +## 用户反馈修复 第三十一轮(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 版形状**。另有两处官方默认我们没跟: + `MapTemperature` 无条件把 `0x3864=1` → 官方**每帧都跑 7×7 细节增强(强度 255<<3=2040)**; + App 传给 SDK 的温度是 `帧尾 FPA + BasePara2.fpaTempFix(真机 1699)`。 + +### 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,"官方还是更细腻"→ 定位到参考帧累积) 用户:"不对吧,官方的画面看着还是要更细腻,你是不是还是遗漏了什么东西"。