app+docs: 按真机 libcxsdk 反汇编定案——灰度 LUT 改用 Android 版 HistDeRedundance(固定基值/pivot=两段均值/步进上限),曲线插值温度补 fpaTempFix;推翻上轮'参考帧累积'机制(CAccumulator 是 4 帧块平均,与我们等价)
This commit is contained in:
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user