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. */
|
/** Temperature probe on the current NUC output, in millidegrees C. */
|
||||||
fun probeTemp(x: Int, y: Int): Int = synchronized(lock) {
|
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). */
|
/** 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). */
|
/** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */
|
||||||
private val nucScratch = IntArray(19200)
|
private val nucScratch = IntArray(19200)
|
||||||
|
private val nucTemps = IntArray(19200)
|
||||||
|
|
||||||
/** True while the background temperature loop runs. */
|
/** True while the background temperature loop runs. */
|
||||||
private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false)
|
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)
|
val centerMc = session.probeTemp(80, 60)
|
||||||
if (!session.copyNuc(nucScratch)) return
|
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
|
// scan off the main thread: 19200 iterations plus probe conversions
|
||||||
var mn = Int.MAX_VALUE
|
var mn = Int.MAX_VALUE
|
||||||
var mx = -1
|
var mx = -1
|
||||||
var mnPos = -1
|
var mnPos = -1
|
||||||
var mxPos = -1
|
var mxPos = -1
|
||||||
|
var mnT = Int.MAX_VALUE
|
||||||
|
var mxT = Int.MIN_VALUE
|
||||||
for (i in nucScratch.indices) {
|
for (i in nucScratch.indices) {
|
||||||
val v = nucScratch[i]
|
val v = nucScratch[i]
|
||||||
if (v < mn) { mn = v; mnPos = i }
|
if (v < mn) { mn = v; mnPos = i }
|
||||||
if (v > mx) { mx = v; mxPos = i }
|
if (v > mx) { mx = v; mxPos = i }
|
||||||
}
|
if (official) {
|
||||||
val probeTemps = _state.value.probes.map { p ->
|
val t = nucTemps[i]
|
||||||
(p.x + p.y * 160).let { idx ->
|
if (t < mnT) mnT = t
|
||||||
if (idx in nucScratch.indices) TempMath.countsToTempMc(nucScratch[idx]) / 1000f
|
if (t > mxT) mxT = t
|
||||||
else 0f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val minT = if (mn != Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null
|
fun tempAt(idx: Int): Float = if (idx in nucScratch.indices) {
|
||||||
val maxT = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null
|
(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 }
|
val centerT = centerMc?.let { it / 1000f }
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.hardware.usb.UsbDeviceConnection
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
import android.hardware.usb.UsbEndpoint
|
import android.hardware.usb.UsbEndpoint
|
||||||
import com.mag160c.thermal.core.FrameStream
|
import com.mag160c.thermal.core.FrameStream
|
||||||
|
import com.mag160c.thermal.core.OfficialTemp
|
||||||
import com.mag160c.thermal.core.RenderPipeline
|
import com.mag160c.thermal.core.RenderPipeline
|
||||||
import com.mag160c.thermal.media.DebugLog
|
import com.mag160c.thermal.media.DebugLog
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -126,6 +127,22 @@ class IrSession(context: Context) {
|
|||||||
var lastInfo1: ByteArray? = null
|
var lastInfo1: ByteArray? = null
|
||||||
private set
|
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
|
* Detail enhancement strength requested before a pipeline existed (the
|
||||||
* pipeline is built during connect, so a setting change made while
|
* pipeline is built during connect, so a setting change made while
|
||||||
@@ -251,11 +268,17 @@ class IrSession(context: Context) {
|
|||||||
parseBasePara1(r1.third)
|
parseBasePara1(r1.third)
|
||||||
DebugLog.log("session", "identity $identity")
|
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")
|
writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2")
|
||||||
val r2 = readResp(conn, epResp, "GetParameter2")
|
val r2 = readResp(conn, epResp, "GetParameter2")
|
||||||
if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) {
|
if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) {
|
||||||
lastInfo1 = r2.third.copyOf()
|
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}
|
// 3) GetCaliInfo -> {size, reserved, date}
|
||||||
@@ -313,6 +336,14 @@ class IrSession(context: Context) {
|
|||||||
return
|
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
|
pipeline = pipe
|
||||||
active = this
|
active = this
|
||||||
|
|
||||||
@@ -709,6 +740,16 @@ class IrSession(context: Context) {
|
|||||||
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
|
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
|
||||||
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
|
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). */
|
/** True once a frame completed the render path (temperatures are meaningful). */
|
||||||
fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
|
fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
|
||||||
|
|
||||||
|
|||||||
@@ -83,4 +83,8 @@ object MagProtocol {
|
|||||||
((b[off + 1].toInt() and 0xFF) shl 8) or
|
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||||
((b[off + 2].toInt() and 0xFF) shl 16) or
|
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||||
((b[off + 3].toInt() and 0xFF) shl 24)
|
((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