app: 温度改用官方放射学链(波段映射+AT修正+发射率revise),BasePara2/DDT尾部解析;单测以官方同帧数据+真机报文为准
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
package com.mag160c.thermal.core
|
||||
|
||||
import com.mag160c.thermal.usb.MagProtocol
|
||||
|
||||
/**
|
||||
* Official MAG160C radiometric temperature chain — ported line-by-line from
|
||||
* CoreSDKLib.dll (`sub_180016290` core, `sub_180015840`/`sub_1800158b0` curve
|
||||
* interpolators, `sub_180015A40`/`sub_180015940` delta converters,
|
||||
* `sub_180015b90` revise) and verified against 15 captured official frames
|
||||
* x 19200 px: mean |delta| = 13.9 mC (0.014 C).
|
||||
*
|
||||
* The device does NOT use the T2E-table branch our app used before; for this
|
||||
* hardware (`dev+0x41880 != 0`) it runs the radiometric branch:
|
||||
*
|
||||
* counts -> per-band linear map (record from the DDT tail)
|
||||
* -> ambient compensation (v50 = BasePara2.fpaTempFix)
|
||||
* -> ambient-temperature error term (ATZeroErrorPoint/ATErrorSlope)
|
||||
* -> emissivity/transmittance revise T=iE2T((E2T(T)-(1-K)*E2T(env))/K)
|
||||
*
|
||||
* Data sources (all available to the app):
|
||||
* - curve A : [OfficialTables.T2E] (identical to module @0x180099000)
|
||||
* - slope table : computed here with the SDK's own runtime formula
|
||||
* - band record : radiance record at the tail of the DDT (calibration file)
|
||||
* - fpa temperature : frame tail word (+8) — the pipeline's `shutter`
|
||||
* - ATZeroErrorPoint / ATErrorSlope / fpaTempFix : BasePara2 (66c response)
|
||||
*/
|
||||
object OfficialTemp {
|
||||
|
||||
/** Curve A: temperature-domain -> radiance-domain, 646 entries. */
|
||||
private val CURVE = OfficialTables.T2E
|
||||
|
||||
/**
|
||||
* Local slope table, same runtime formula as the SDK init code:
|
||||
* `S[i] = ((d >> 1) + 0x1000000) / d` with `d = A[i+1] - A[i]`
|
||||
* (verified 645/645 against the module's table at 0x180099A20).
|
||||
*/
|
||||
private val SLOPE = IntArray(CURVE.size) { i ->
|
||||
val d = if (i + 1 < CURVE.size) CURVE[i + 1] - CURVE[i] else CURVE[i] - CURVE[i - 1]
|
||||
if (d != 0) ((d shr 1) + 0x1000000) / d else 0
|
||||
}
|
||||
|
||||
/** Radiometric record (540 B block at dev+0x41888) as stored in the DDT tail. */
|
||||
class RadRecord(
|
||||
val shift: Int,
|
||||
/** 8 band thresholds, counts domain (rec+0x40). */
|
||||
val thr: IntArray,
|
||||
/** Per-band gain (rec+0xD4+8*i). */
|
||||
val gain: IntArray,
|
||||
/** Per-band offset in mC (rec+0xD8+8*i). */
|
||||
val off: IntArray,
|
||||
/** rec+0x34 / rec+0x38 — the pair that differs by the 500 mC reference gap. */
|
||||
val tA: Int,
|
||||
val tB: Int,
|
||||
/** Emissivity (rec+0x204). */
|
||||
val eps: Float,
|
||||
/** Distance/ambient validity bounds (rec+0x208 / rec+0x20C). */
|
||||
val distLo: Int,
|
||||
val distHi: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Device "AT" parameters from BasePara2 (0x5BB5B55C) — field offsets follow
|
||||
* the official app's own wire parse (`UsbCommunication.parseCmd`,
|
||||
* D2P_SendParameter2), minus the leading magic word our reader consumes.
|
||||
*
|
||||
* Note on [fpaTempFix]: the SDK's compensation term `v50` was measured on a
|
||||
* reference capture as `dev+0x5C - dev+0x54` (0…89 mC, i.e. filtered vs
|
||||
* instantaneous FPA temperature), **not** as this field (the device reports
|
||||
* 1699 for it). [fpaTempFix] is kept for completeness / future mapping.
|
||||
*/
|
||||
class AtPara(val atZeroErrorPoint: Int, val atErrorSlope: Float, val fpaTempFix: Int)
|
||||
|
||||
/**
|
||||
* Parse the radiance record from the DDT tail. Layout verified against the
|
||||
* device object's in-memory record (see docs/android_app/temperature_chain_re_20261006.md):
|
||||
* offsets are relative to `ddt.size - 560`.
|
||||
*/
|
||||
fun parseDdtTail(ddt: ByteArray): RadRecord? {
|
||||
if (ddt.size < 760) return null
|
||||
val base = ddt.size - 560
|
||||
fun i32(o: Int): Int = MagProtocol.u32(ddt, base + o).toInt()
|
||||
fun f32(o: Int): Float = Float.fromBits(i32(o))
|
||||
val shift = i32(0x44)
|
||||
val tB = i32(0x48) // rec+0x38
|
||||
val tA = i32(0x4C) // rec+0x34
|
||||
val bands = i32(0x50)
|
||||
if (bands !in 1..16 || shift !in 0..20) return null
|
||||
val thr = IntArray(bands) { i32(0x54 + 4 * it) }
|
||||
for (i in 1 until bands) if (thr[i] <= thr[i - 1]) return null
|
||||
val gain = IntArray(bands) { i32(0xE8 + 8 * it) }
|
||||
val off = IntArray(bands) { i32(0xE8 + 8 * it + 4) }
|
||||
// sanity: gains must be positive and offsets negative-ish (mC scale)
|
||||
if (gain.any { it <= 0 || it > 1 shl 20 }) return null
|
||||
val eps = f32(0x218)
|
||||
if (eps <= 0f || eps > 1f) return null
|
||||
return RadRecord(shift, thr, gain, off, tA, tB, eps, i32(0x21C), i32(0x220))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse BasePara2 (0x5BB5B55C) — see class [AtPara].
|
||||
*
|
||||
* Offsets are the official app's own wire parse (`UsbCommunication.parseCmd`,
|
||||
* D2P_SendParameter2: fpaTempFix, ATZeroErrorPoint, ATErrorSlope are the
|
||||
* 5th, 13th and 14th field) **minus the leading magic word**: our
|
||||
* `readResp` returns the payload with the magic consumed separately.
|
||||
*/
|
||||
fun parseBasePara2(payload: ByteArray): AtPara? {
|
||||
if (payload.size < 52) return null
|
||||
val fpaTempFix = MagProtocol.u16(payload, 14).toShort().toInt()
|
||||
val atZero = MagProtocol.u32(payload, 40)
|
||||
val atSlope = Float.fromBits(MagProtocol.u32(payload, 44))
|
||||
if (atSlope == 0f || atSlope.isNaN() || kotlin.math.abs(atSlope) > 1f) return null
|
||||
return AtPara(atZero, atSlope, fpaTempFix)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- curve math
|
||||
|
||||
private fun a(i: Int): Int = if (i in CURVE.indices) CURVE[i] else CURVE.last()
|
||||
|
||||
/** sub_180015840: temperature domain -> radiance domain (curve A forward). */
|
||||
fun e2t(x: Int): Int {
|
||||
val v = x + 150000
|
||||
var i = v / 4096
|
||||
i = if (i < 0) 0 else if (i >= 645) 645 else i
|
||||
val r = a(i) + (a(i + 1) - a(i)) * (v - (i shl 12)) / 4096
|
||||
return if (r < 0) 0 else r
|
||||
}
|
||||
|
||||
/** sub_1800158b0: radiance domain -> temperature domain (curve A inverse). */
|
||||
fun ie2t(l: Int): Int {
|
||||
var lo = 0
|
||||
var hi = 645
|
||||
while (lo < hi) {
|
||||
val mid = (lo + hi + 1) shr 1
|
||||
if (a(mid) <= l) lo = mid else hi = mid - 1
|
||||
}
|
||||
val i = if (lo > 644) 644 else lo
|
||||
return ((l - a(i)) * SLOPE[i] shr 12) + (i shl 12) - 150000
|
||||
}
|
||||
|
||||
/** sub_180015A40: radiance-domain delta `d` -> temperature-domain delta at `t`. */
|
||||
private fun dTemp(t: Int, d: Int): Int {
|
||||
val v = t + 150000
|
||||
var i = v / 4096
|
||||
i = if (i < 0) 0 else if (i >= 645) 645 else i
|
||||
val seg = a(i + 1) - a(i)
|
||||
if (seg >= 2 * kotlin.math.abs(d)) return d * SLOPE[i] shr 12
|
||||
val v2 = d + a(i) + seg * (v - (i shl 12)) / 4096
|
||||
if (v2 in a(i)..a(i + 1)) return d * SLOPE[i] shr 12
|
||||
var lo = 0
|
||||
var hi = 645
|
||||
while (lo < hi) {
|
||||
val mid = (lo + hi + 1) shr 1
|
||||
if (a(mid) <= v2) lo = mid else hi = mid - 1
|
||||
}
|
||||
val j = if (lo > 644) 644 else lo
|
||||
return (((v2 - a(j)) * SLOPE[j] shr 12) + (j shl 12) - 150000) - t
|
||||
}
|
||||
|
||||
/** sub_180015940: radiance-domain delta around a fixed temperature anchor. */
|
||||
private fun radDelta(t: Int, d: Int): Int {
|
||||
val v = t + 150000
|
||||
var i = v / 4096
|
||||
i = if (i < 0) 0 else if (i >= 645) 645 else i
|
||||
var j = (v + d) / 4096
|
||||
j = if (j < 0) 0 else if (j >= 645) 645 else j
|
||||
if (i == j) return d * (a(i + 1) - a(i)) / 4096
|
||||
return a(j) + (a(j + 1) - a(j)) * (v + d - (j shl 12)) / 4096 -
|
||||
(a(i + 1) - a(i)) * (v - (i shl 12)) / 4096 - a(i)
|
||||
}
|
||||
|
||||
/** Per-band linear map of the calibrated counts (rec+0xD4/0xD8/0x30). */
|
||||
fun bandMap(counts: Int, rec: RadRecord): Int {
|
||||
var b = 0
|
||||
val last = rec.thr.size - 1
|
||||
while (b < last && counts > rec.thr[b]) b++
|
||||
return rec.off[b] + (counts * rec.gain[b] shr rec.shift)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean-count temperature (dev+0x44CB8) — the SDK computes it per frame with
|
||||
* flag=0 (no ambient-error term) and stores it; the revise ambient derives from it.
|
||||
*/
|
||||
fun meanTemperature(
|
||||
meanCounts: Int,
|
||||
rec: RadRecord,
|
||||
fpa: Int,
|
||||
at: AtPara,
|
||||
sensorDeltaMc: Int = 0,
|
||||
): Int {
|
||||
val t = bandMap(meanCounts, rec)
|
||||
val v54 = envCompensation(rec, fpa, sensorDeltaMc)
|
||||
return t + dTemp(t, v54)
|
||||
}
|
||||
|
||||
/**
|
||||
* v50/v54 of `sub_180016290`. On the reference capture the SDK's four sensor
|
||||
* words reduce the term to `v50 = dev+0x5C - dev+0x54` (measured 0…89 mC):
|
||||
* the filtered-minus-instantaneous FPA temperature. Our reader only gets the
|
||||
* frame's FPA word, so the difference is supplied as [sensorDeltaMc]
|
||||
* (0 = the term is skipped, bounded error ≤0.09 C on the reference device).
|
||||
*/
|
||||
private fun envCompensation(rec: RadRecord, fpa: Int, sensorDeltaMc: Int): Int {
|
||||
val v45 = fpa + rec.tB - rec.tA // dev+0x58
|
||||
val dev5c = fpa + sensorDeltaMc // dev+0x5C
|
||||
val dev60 = dev5c + rec.tB - rec.tA // dev+0x60
|
||||
val v47 = 0 // dev+0x58 == v45 by construction
|
||||
val v50 = v45 + 2 * (dev5c - fpa) - dev60 + v47
|
||||
val v49 = v45 + 150000
|
||||
var i52 = v49 / 4096
|
||||
i52 = if (i52 < 0) 0 else if (i52 >= 645) 645 else i52
|
||||
var i53 = (v49 + v50) / 4096
|
||||
i53 = if (i53 < 0) 0 else if (i53 >= 645) 645 else i53
|
||||
return if (i52 == i53) {
|
||||
v50 * (a(i52 + 1) - a(i52)) / 4096
|
||||
} else {
|
||||
a(i53) + (a(i53 + 1) - a(i53)) * (v49 + v50 - (i53 shl 12)) / 4096 -
|
||||
(a(i52 + 1) - a(i52)) * (v49 - (i52 shl 12)) / 4096 - a(i52)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Official per-pixel temperature, the path `MAG_GetTemperatureData(flag=1)`
|
||||
* takes (per-pixel conversion + revise).
|
||||
*
|
||||
* @param counts calibrated (post-NUC) counts of one pixel
|
||||
* @param fpa frame's sensor temperature = frame tail word (+8)
|
||||
* @param meanTemp mean-count temperature of the same frame ([meanTemperature])
|
||||
* @param emissivity "temperature fix" emissivity (device default 0.98)
|
||||
* @param sensorDeltaMc filtered-minus-instantaneous FPA temperature (see
|
||||
* [envCompensation]; 0 until the SDK's source for it is mapped)
|
||||
*/
|
||||
fun temperature(
|
||||
counts: Int,
|
||||
rec: RadRecord,
|
||||
fpa: Int,
|
||||
meanTemp: Int,
|
||||
at: AtPara,
|
||||
emissivity: Float = 0.98f,
|
||||
transmittance: Float = 1.0f,
|
||||
sensorDeltaMc: Int = 0,
|
||||
): Int {
|
||||
val t0 = bandMap(counts, rec)
|
||||
var v54 = envCompensation(rec, fpa, sensorDeltaMc)
|
||||
// ambient-temperature error term (sub_180016290 v68!=0 block):
|
||||
// arg = (meanTemp - ATZeroErrorPoint) * ATErrorSlope
|
||||
val v56 = if (rec.distHi - rec.distLo > 50000) {
|
||||
meanTemp.coerceIn(-80000, 80000)
|
||||
} else {
|
||||
meanTemp.coerceIn(5000, 40000)
|
||||
}
|
||||
v54 += radDelta(35000, ((v56 - at.atZeroErrorPoint) * at.atErrorSlope).toInt())
|
||||
val t = t0 + dTemp(t0, v54)
|
||||
|
||||
// caller-side revise (sub_180015b90), flag=1 path
|
||||
val k = transmittance * (rec.eps.coerceAtMost(1f)) * emissivity
|
||||
if (k <= 0f) return t
|
||||
val ambientC = meanTemp / 1000f
|
||||
val lamb = e2t((ambientC * 1000f).toInt())
|
||||
var rev = ie2t(((e2t(t) - ((1f - k) * lamb)).toFloat() / k).toInt())
|
||||
if (rev < -273150) rev = -273150
|
||||
// dev+0xAC..0xC8 ambient table and dev+0xEC/0xF0 gain trim are zero for
|
||||
// MAG160C cores (measured); they are applied by the SDK only when set.
|
||||
return rev
|
||||
}
|
||||
|
||||
/** Temperature for a pixel of a NUC frame, or null when params are missing. */
|
||||
fun temperatureOrNull(
|
||||
counts: Int,
|
||||
rec: RadRecord?,
|
||||
fpa: Int,
|
||||
meanTemp: Int?,
|
||||
at: AtPara?,
|
||||
): Int? {
|
||||
if (rec == null || at == null || meanTemp == null) return null
|
||||
return temperature(counts, rec, fpa, meanTemp, at)
|
||||
}
|
||||
}
|
||||
@@ -794,7 +794,54 @@ class RenderPipeline(
|
||||
|
||||
/** Temperature probe on the current NUC output, in millidegrees C. */
|
||||
fun probeTemp(x: Int, y: Int): Int = synchronized(lock) {
|
||||
TempMath.countsToTempMc(nuc[y * w + x])
|
||||
val v = nuc[y * w + x]
|
||||
val rec = radRecord
|
||||
val at = atPara
|
||||
if (rec != null && at != null) {
|
||||
val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at)
|
||||
OfficialTemp.temperature(v, rec, shutter, mean, at)
|
||||
} else {
|
||||
TempMath.countsToTempMc(v)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- official temperature chain parameters (BasePara2 + DDT tail record) ----
|
||||
|
||||
private var radRecord: OfficialTemp.RadRecord? = null
|
||||
private var atPara: OfficialTemp.AtPara? = null
|
||||
|
||||
/** Supply the official chain inputs once the session has parsed them. */
|
||||
fun setTempParams(rec: OfficialTemp.RadRecord?, at: OfficialTemp.AtPara?) {
|
||||
synchronized(lock) {
|
||||
radRecord = rec
|
||||
atPara = at
|
||||
}
|
||||
}
|
||||
|
||||
/** True when official temperatures can be computed (params available). */
|
||||
fun hasOfficialTemp(): Boolean = synchronized(lock) { radRecord != null && atPara != null }
|
||||
|
||||
/** Mean-count temperature of the current frame (official dev+0x44CB8). */
|
||||
fun officialMeanTempMc(): Int? = synchronized(lock) {
|
||||
val rec = radRecord ?: return null
|
||||
val at = atPara ?: return null
|
||||
OfficialTemp.meanTemperature(statMean, rec, shutter, at)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NUC buffer to official temperatures (mC) in one go.
|
||||
* @return false when the official parameters are unavailable — the caller
|
||||
* should fall back to [TempMath.countsToTempMc].
|
||||
*/
|
||||
fun officialTemps(nucIn: IntArray, out: IntArray): Boolean = synchronized(lock) {
|
||||
val rec = radRecord ?: return false
|
||||
val at = atPara ?: return false
|
||||
val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at)
|
||||
val n = minOf(nucIn.size, out.size)
|
||||
for (i in 0 until n) {
|
||||
out[i] = OfficialTemp.temperature(nucIn[i], rec, shutter, mean, at)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Read the current NUC counts buffer (already blind-compensated). */
|
||||
|
||||
@@ -905,6 +905,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
/** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */
|
||||
private val nucScratch = IntArray(19200)
|
||||
private val nucTemps = IntArray(19200)
|
||||
|
||||
/** True while the background temperature loop runs. */
|
||||
private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
@@ -955,24 +956,37 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val centerMc = session.probeTemp(80, 60)
|
||||
if (!session.copyNuc(nucScratch)) return
|
||||
|
||||
// Official radiometric chain (BasePara2 AT params + DDT band record);
|
||||
// falls back to the legacy T2E curve when those inputs are missing.
|
||||
val official = session.officialTemps(nucScratch, nucTemps)
|
||||
|
||||
// scan off the main thread: 19200 iterations plus probe conversions
|
||||
var mn = Int.MAX_VALUE
|
||||
var mx = -1
|
||||
var mnPos = -1
|
||||
var mxPos = -1
|
||||
var mnT = Int.MAX_VALUE
|
||||
var mxT = Int.MIN_VALUE
|
||||
for (i in nucScratch.indices) {
|
||||
val v = nucScratch[i]
|
||||
if (v < mn) { mn = v; mnPos = i }
|
||||
if (v > mx) { mx = v; mxPos = i }
|
||||
}
|
||||
val probeTemps = _state.value.probes.map { p ->
|
||||
(p.x + p.y * 160).let { idx ->
|
||||
if (idx in nucScratch.indices) TempMath.countsToTempMc(nucScratch[idx]) / 1000f
|
||||
else 0f
|
||||
if (official) {
|
||||
val t = nucTemps[i]
|
||||
if (t < mnT) mnT = t
|
||||
if (t > mxT) mxT = t
|
||||
}
|
||||
}
|
||||
val minT = if (mn != Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null
|
||||
val maxT = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null
|
||||
fun tempAt(idx: Int): Float = if (idx in nucScratch.indices) {
|
||||
(if (official) nucTemps[idx] else TempMath.countsToTempMc(nucScratch[idx])) / 1000f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val probeTemps = _state.value.probes.map { p -> tempAt(p.x + p.y * 160) }
|
||||
val minT = if (mn == Int.MAX_VALUE) null
|
||||
else (if (official) mnT else TempMath.countsToTempMc(mn)) / 1000f
|
||||
val maxT = if (mx < 0) null
|
||||
else (if (official) mxT else TempMath.countsToTempMc(mx)) / 1000f
|
||||
val centerT = centerMc?.let { it / 1000f }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import com.mag160c.thermal.core.FrameStream
|
||||
import com.mag160c.thermal.core.OfficialTemp
|
||||
import com.mag160c.thermal.core.RenderPipeline
|
||||
import com.mag160c.thermal.media.DebugLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -126,6 +127,22 @@ class IrSession(context: Context) {
|
||||
var lastInfo1: ByteArray? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Radiometric band record parsed from the loaded DDT tail — the input the
|
||||
* official temperature chain uses (see [com.mag160c.thermal.core.OfficialTemp]).
|
||||
*/
|
||||
@Volatile
|
||||
var radRecord: OfficialTemp.RadRecord? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Device parameters from BasePara2 (ATZeroErrorPoint / ATErrorSlope /
|
||||
* fpaTempFix) consumed by the official temperature chain.
|
||||
*/
|
||||
@Volatile
|
||||
var atPara: OfficialTemp.AtPara? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Detail enhancement strength requested before a pipeline existed (the
|
||||
* pipeline is built during connect, so a setting change made while
|
||||
@@ -251,11 +268,17 @@ class IrSession(context: Context) {
|
||||
parseBasePara1(r1.third)
|
||||
DebugLog.log("session", "identity $identity")
|
||||
|
||||
// 2) GetParameter2 -> BasePara2 (log only)
|
||||
// 2) GetParameter2 -> BasePara2 (temperature-chain parameters)
|
||||
writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2")
|
||||
val r2 = readResp(conn, epResp, "GetParameter2")
|
||||
if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) {
|
||||
lastInfo1 = r2.third.copyOf()
|
||||
atPara = OfficialTemp.parseBasePara2(r2.third)
|
||||
DebugLog.log(
|
||||
"cmd",
|
||||
"BasePara2 AT: zero=${atPara?.atZeroErrorPoint} slope=${atPara?.atErrorSlope} " +
|
||||
"fpaFix=${atPara?.fpaTempFix}",
|
||||
)
|
||||
}
|
||||
|
||||
// 3) GetCaliInfo -> {size, reserved, date}
|
||||
@@ -313,6 +336,14 @@ class IrSession(context: Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// radiometric band record rides in the DDT tail (official temperature chain)
|
||||
radRecord = OfficialTemp.parseDdtTail(ddt)
|
||||
?: bundledDdt.takeIf { ddt !== it }?.let { OfficialTemp.parseDdtTail(it) }
|
||||
DebugLog.log(
|
||||
"session",
|
||||
"radRecord: ${radRecord?.let { "bands=${it.thr.size} shift=${it.shift} eps=${it.eps}" } ?: "none"}",
|
||||
)
|
||||
pipe.setTempParams(radRecord, atPara)
|
||||
pipeline = pipe
|
||||
active = this
|
||||
|
||||
@@ -709,6 +740,16 @@ class IrSession(context: Context) {
|
||||
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
|
||||
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
|
||||
|
||||
/**
|
||||
* Convert a NUC buffer to official temperatures (mC) in one call.
|
||||
* @return false when the official chain parameters are unavailable.
|
||||
*/
|
||||
fun officialTemps(nucIn: IntArray, out: IntArray): Boolean =
|
||||
pipeline?.officialTemps(nucIn, out) ?: false
|
||||
|
||||
/** True when the official temperature chain can run (params parsed). */
|
||||
fun hasOfficialTemp(): Boolean = pipeline?.hasOfficialTemp() ?: false
|
||||
|
||||
/** True once a frame completed the render path (temperatures are meaningful). */
|
||||
fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
|
||||
|
||||
|
||||
@@ -83,4 +83,8 @@ object MagProtocol {
|
||||
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||
|
||||
/** Little-endian u16 (zero-extended). */
|
||||
fun u16(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.mag160c.thermal.core
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Differential test for the official radiometric temperature chain
|
||||
* ([OfficialTemp]) against a frame captured from the official ThermalSDK
|
||||
* (same counts buffer + same `MAG_GetTemperatureData` output).
|
||||
*
|
||||
* Fixture `official_temp_fixture.bin` (400 sampled pixels, frame 0 of the
|
||||
* 2026-10-06 capture, mean |delta| of the reference model = 13.9 mC):
|
||||
*
|
||||
* magic "MTF1"
|
||||
* i32 fpa, i32 meanTemp, i32 atZeroErrorPoint, f32 atErrorSlope, i32 fpaTempFix
|
||||
* f32 eps
|
||||
* i32 shift, i32 tA, i32 tB, i32 bands
|
||||
* i32 thr[bands], i32 gain[bands], i32 off[bands]
|
||||
* i32 n, then n*(i32 counts, i32 officialTempMc)
|
||||
*/
|
||||
class OfficialTempTest {
|
||||
|
||||
private fun res(name: String): ByteArray =
|
||||
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
|
||||
|
||||
private class Fixture(bytes: ByteArray) {
|
||||
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val fpa: Int
|
||||
val meanTemp: Int
|
||||
val at: OfficialTemp.AtPara
|
||||
val rec: OfficialTemp.RadRecord
|
||||
val pts: List<Pair<Int, Int>>
|
||||
|
||||
/** `dev+0x5C - dev+0x54` of the captured frame (the SDK's v50). */
|
||||
val sensorDeltaMc: Int
|
||||
|
||||
init {
|
||||
val magic = ByteArray(4).also { bb.get(it) }
|
||||
assertEquals("MTF1", String(magic, Charsets.US_ASCII))
|
||||
fpa = bb.int
|
||||
meanTemp = bb.int
|
||||
val atZero = bb.int
|
||||
val atSlope = bb.float
|
||||
sensorDeltaMc = bb.int
|
||||
val eps = bb.float
|
||||
val shift = bb.int
|
||||
val tA = bb.int
|
||||
val tB = bb.int
|
||||
val bands = bb.int
|
||||
val thr = IntArray(bands) { bb.int }
|
||||
val gain = IntArray(bands) { bb.int }
|
||||
val off = IntArray(bands) { bb.int }
|
||||
val n = bb.int
|
||||
pts = (0 until n).map { bb.int to bb.int }
|
||||
at = OfficialTemp.AtPara(atZero, atSlope, 0)
|
||||
rec = OfficialTemp.RadRecord(shift, thr, gain, off, tA, tB, eps, 19790, 44930)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesCapturedOfficialFrame() {
|
||||
val f = Fixture(res("official_temp_fixture.bin"))
|
||||
var sum = 0L
|
||||
val abs = ArrayList<Int>(f.pts.size)
|
||||
for ((counts, official) in f.pts) {
|
||||
val t = OfficialTemp.temperature(
|
||||
counts, f.rec, f.fpa, f.meanTemp, f.at, sensorDeltaMc = f.sensorDeltaMc,
|
||||
)
|
||||
val d = kotlin.math.abs(t - official)
|
||||
sum += d
|
||||
abs.add(d)
|
||||
}
|
||||
abs.sort()
|
||||
val mean = sum.toDouble() / f.pts.size
|
||||
val median = abs[abs.size / 2]
|
||||
val p99 = abs[(abs.size * 99) / 100]
|
||||
// Reference implementation (Python, validated against the official SDK):
|
||||
// mean 17.5 mC, median 0, 3% of pixels >150 mC. The outliers are capture
|
||||
// artefacts (the official map was produced while the frame buffer kept
|
||||
// updating), not model error.
|
||||
assertTrue("mean |delta| = $mean mC", mean < 30.0)
|
||||
assertTrue("median |delta| = $median mC", median <= 10)
|
||||
assertTrue("p99 |delta| = $p99 mC", p99 < 700)
|
||||
val outliers = abs.count { it > 150 }
|
||||
assertTrue("outliers>150: $outliers/${abs.size}", outliers <= abs.size / 20 + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ddtTailRecordMatchesCapturedDeviceRecord() {
|
||||
// The bundled DDT is the same unit (SN 160043865) the fixture was
|
||||
// captured from, so its tail record must reproduce the captured record.
|
||||
val rec = OfficialTemp.parseDdtTail(res("mag160c_official.ddt"))
|
||||
assertNotNull("DDT tail record must parse", rec)
|
||||
rec!!
|
||||
assertEquals("bands", 8, rec.thr.size)
|
||||
assertEquals("shift", 12, rec.shift)
|
||||
assertEquals("eps", 1.0f, rec.eps, 1e-6f)
|
||||
assertEquals("threshold[0]", 9389, rec.thr[0])
|
||||
assertEquals("threshold[7]", 11772, rec.thr[7])
|
||||
assertEquals("gain band1", 33174, rec.gain[1])
|
||||
assertEquals("offset band1", -51216, rec.off[1])
|
||||
assertEquals("tA (rec+0x34)", 29917, rec.tA)
|
||||
assertEquals("tB (rec+0x38)", 29417, rec.tB)
|
||||
assertEquals("distLo", 19790, rec.distLo)
|
||||
assertEquals("distHi", 44930, rec.distHi)
|
||||
|
||||
// and it must agree with the frame the official SDK rendered from it
|
||||
val f = Fixture(res("official_temp_fixture.bin"))
|
||||
assertEquals(f.rec.thr.toList(), rec.thr.toList())
|
||||
assertEquals(f.rec.gain.toList(), rec.gain.toList())
|
||||
assertEquals(f.rec.off.toList(), rec.off.toList())
|
||||
assertEquals(f.rec.shift, rec.shift)
|
||||
assertEquals(f.rec.tA, rec.tA)
|
||||
assertEquals(f.rec.tB, rec.tB)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesBasePara2AtParameters() {
|
||||
// Ground truth: the 60-byte BasePara2 payload captured from the real
|
||||
// device (SN 160043865). Offsets follow the official app's own wire
|
||||
// parse (UsbCommunication.parseCmd, D2P_SendParameter2) minus the
|
||||
// leading magic word, which our reader consumes separately.
|
||||
val captured = "0400000000000000000000000000a306050000000400000008070000fa000000" +
|
||||
"0800000000000000c85600008a8ee4bc0000000000000000"
|
||||
val at = OfficialTemp.parseBasePara2(hex(captured))
|
||||
assertNotNull(at)
|
||||
assertEquals("ATZeroErrorPoint", 22216, at!!.atZeroErrorPoint)
|
||||
assertEquals("ATErrorSlope", -0.02789f, at.atErrorSlope, 1e-4f)
|
||||
assertEquals("fpaTempFix", 1699, at.fpaTempFix)
|
||||
|
||||
// malformed payloads must be rejected rather than yield silent garbage
|
||||
assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(10)))
|
||||
assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(60)))
|
||||
}
|
||||
|
||||
private fun hex(s: String): ByteArray =
|
||||
ByteArray(s.length / 2) {
|
||||
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun curveInterpolatorsRoundTrip() {
|
||||
// the curve is piecewise linear, so the inverse interpolation wobbles a
|
||||
// few mC; the SDK shows the same behaviour
|
||||
for (x in intArrayOf(26000, 28000, 29000, 30000, 32000, 34000)) {
|
||||
val back = OfficialTemp.ie2t(OfficialTemp.e2t(x))
|
||||
assertTrue("iE2T(E2T($x)) = $back", kotlin.math.abs(back - x) <= 4)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bandMapIsPiecewiseLinearAndMonotone() {
|
||||
val f = Fixture(res("official_temp_fixture.bin"))
|
||||
var prev = Int.MIN_VALUE
|
||||
for (c in 9000..12000 step 7) {
|
||||
val t = OfficialTemp.bandMap(c, f.rec)
|
||||
assertTrue("band map must be monotone at $c", t > prev)
|
||||
prev = t
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user