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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user