app+csdk+docs: 噪声预算真机实测(0.559σn 与理论一致,链路无额外噪声源)+ 修 MDT raw/NUC 配对与 LUT 32位语义;csdk 合入 android_lut/fpa_temp_fix(C 未编译,算法由 golden 测试锁定);118 单测全绿

This commit is contained in:
ZXCLI
2026-10-06 16:28:05 +08:00
parent 2290fbbafb
commit e0709f9305
13 changed files with 421 additions and 16 deletions
@@ -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
@@ -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(
@@ -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
@@ -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)
}
}
@@ -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()
}
}