app+docs: 按真机 libcxsdk 反汇编定案——灰度 LUT 改用 Android 版 HistDeRedundance(固定基值/pivot=两段均值/步进上限),曲线插值温度补 fpaTempFix;推翻上轮'参考帧累积'机制(CAccumulator 是 4 帧块平均,与我们等价)

This commit is contained in:
ZXCLI
2026-10-06 05:51:31 +08:00
parent 3510bdc774
commit a1dfb647ea
5 changed files with 468 additions and 13 deletions
@@ -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
}
}
@@ -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
@@ -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])
}
}
}