android: MDT temperature decode + analyzer probe UI

This commit is contained in:
ZXCLI
2026-09-10 23:43:19 +08:00
parent 06c1f30b13
commit 656d419f81
9 changed files with 389 additions and 19 deletions
@@ -33,6 +33,21 @@ object TempMath {
return temp.toInt()
}
/**
* Decode a raw measurement frame (19200 x u16 LE = 38400 B, as stored in
* the MDT BLOCK_FRAME) into a millidegree-C map, one entry per pixel.
*/
fun tempMapFromPixels(pixels: ByteArray, w: Int = 160, h: Int = 120): IntArray {
val n = minOf(w * h, pixels.size / 2)
val out = IntArray(n)
for (i in 0 until n) {
val lo = pixels[i * 2].toInt() and 0xFF
val hi = pixels[i * 2 + 1].toInt() and 0xFF
out[i] = countsToTempMc(lo or (hi shl 8))
}
return out
}
/**
* T2E piecewise-linear evaluation with Q13 band selection
* (vendor ReviseTemperature/CorrectTemperature core, 274-entry curve).
@@ -94,7 +94,7 @@ object Mdt {
/** Parse an MDT file produced by [compose] (or any file whose last 152
* bytes carry a valid tail). Returns the sections or null. */
fun parse(bytes: ByteArray): Parsed? {
fun parse(bytes: ByteArray): MdtFile? {
if (bytes.size < 152) return null
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
if (u32(tail, 0) != SECTION_TAIL) return null
@@ -103,7 +103,7 @@ object Mdt {
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
val bodySize = u32(bytes, ddtOffset + 4)
val bodyStart = ddtOffset + 0x88
if (bodyStart + bodySize > bytes.size - 152) return null
if (bodySize < 0 || bodyStart + bodySize > bytes.size - 152) return null
val blocks = HashMap<Int, ByteArray>()
var p = bodyStart
val end = bodyStart + bodySize
@@ -114,20 +114,39 @@ object Mdt {
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
p += 8 + len
}
return Parsed(
jpg = bytes.copyOfRange(0, ddtOffset),
return MdtFile(
jpg = extractJpg(bytes, ddtOffset),
info0 = blocks[BLOCK_INFO0],
info1 = blocks[BLOCK_INFO1],
frame = blocks[BLOCK_FRAME],
text = blocks[BLOCK_TXT],
framePixels = blocks[BLOCK_FRAME],
// block payloads are padded to 4; strip the NUL padding
text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') },
)
}
class Parsed(
/**
* The JPEG section is padded to 4 bytes before the DDT header, so cut it
* back to the last EOI marker (FF D9) — the padding bytes are zeros and
* cannot contain one.
*/
private fun extractJpg(bytes: ByteArray, ddtOffset: Int): ByteArray {
var i = ddtOffset - 2
while (i >= 0) {
if ((bytes[i].toInt() and 0xFF) == 0xFF && (bytes[i + 1].toInt() and 0xFF) == 0xD9) {
return bytes.copyOfRange(0, i + 2)
}
i--
}
return bytes.copyOfRange(0, ddtOffset)
}
/** Decoded MDT container (mirror of [compose]). */
class MdtFile(
val jpg: ByteArray,
val info0: ByteArray?,
val info1: ByteArray?,
val frame: ByteArray?,
val text: ByteArray?,
/** Raw 38400 B measurement frame (19200 x u16 LE), null when absent. */
val framePixels: ByteArray?,
val text: String?,
)
}
@@ -20,11 +20,11 @@ class AnalyzeViewModel(
private val containerBytes: ByteArray,
private val fileUri: android.net.Uri,
) : AndroidViewModel(app) {
val parsed: Mdt.Parsed? = Mdt.parse(containerBytes)
val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes)
/** Raw measurement frame (19200 uint16) if present. */
val rawFrame: IntArray? by lazy {
parsed?.frame?.let { raw ->
parsed?.framePixels?.let { raw ->
val out = IntArray(19200)
for (i in out.indices) {
out[i] = (raw[i * 2].toInt() and 0xFF) or ((raw[i * 2 + 1].toInt() and 0xFF) shl 8)
@@ -33,6 +33,39 @@ class AnalyzeViewModel(
}
}
/** Millidegree-C map of the measurement frame (19200 entries), if present. */
private val _tempMap = androidx.compose.runtime.mutableStateOf<IntArray?>(null)
val tempMap: IntArray? get() = _tempMap.value
/**
* Selected probe in sensor coordinates (x 0..159, y 0..119).
* Backed by snapshot state so the viewer's draw phase invalidates on change.
*/
private val _probe = androidx.compose.runtime.mutableStateOf<Pair<Int, Int>?>(null)
var probe: Pair<Int, Int>?
get() = _probe.value
set(value) {
_probe.value = value
}
init {
val frame = parsed?.framePixels
if (frame != null) {
viewModelScope.launch(Dispatchers.Default) {
val map = com.mag160c.thermal.core.TempMath.tempMapFromPixels(frame)
withContext(Dispatchers.Main) { _tempMap.value = map }
}
}
}
/** Temperature (millidegree C) at a sensor pixel from the decoded map. */
fun probeTempMc(x: Int, y: Int): Int? {
val map = _tempMap.value ?: return null
if (x < 0 || y < 0 || x >= 160 || y >= 120) return null
val idx = y * 160 + x
return if (idx < map.size) map[idx] else null
}
private val _render = MutableStateFlow<Bitmap?>(null)
val render: StateFlow<Bitmap?> = _render
@@ -65,10 +98,7 @@ class AnalyzeViewModel(
}
}
fun decodeNote(): String? {
val t = parsed?.text ?: return null
return String(t, Charsets.UTF_8)
}
fun decodeNote(): String? = parsed?.text
/** Probe temperature approximation at a raw pixel (millidegrees C). */
fun probeTemp(x: Int, y: Int): Int? {
@@ -90,7 +120,7 @@ class AnalyzeViewModel(
jpg = jpg,
info0 = parsed?.info0,
info1 = parsed?.info1,
framePixels = parsed?.frame,
framePixels = parsed?.framePixels,
text = note.toByteArray(Charsets.UTF_8),
)
val ok = runCatching {
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -33,10 +34,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.gallery.GalleryViewModel
@@ -83,6 +89,9 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
pan = Offset.Zero
}
}
}
.pointerInput(Unit) {
detectTapGestures { pos -> vm.probe = tapProbe(vm.probe, pos, zoom, pan, size) }
},
) {
val img = render?.asImageBitmap()
@@ -97,6 +106,11 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
dstSize = IntSize(w.toInt(), h.toInt()),
)
}
val tempMap = vm.tempMap
if (tempMap != null) {
drawTemperatureOsd(tempMap)
vm.probe?.let { drawProbeMarker(it, tempMap, zoom, pan) }
}
}
Row(
modifier = Modifier
@@ -166,6 +180,123 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y)
private const val TEMP_W = 160
private const val TEMP_H = 120
/**
* Image rect actually occupied by the bitmap for the current zoom/pan.
* NOTE: unlike the live screen (which always draws the frame rotated 90 deg
* CW), this viewer draws it in native orientation, so the tap mapping below is
* the direct one — copying the live 90-deg inverse map here would return the
* temperature of a wrongly transposed pixel.
*/
private fun imageRect(size: androidx.compose.ui.geometry.Size, zoom: Float, pan: Offset) =
androidx.compose.ui.geometry.Rect(
left = (size.width - size.width * zoom) / 2 + pan.x,
top = (size.height - size.height * zoom) / 2 + pan.y,
right = (size.width - size.width * zoom) / 2 + pan.x + size.width * zoom,
bottom = (size.height - size.height * zoom) / 2 + pan.y + size.height * zoom,
)
/** Tap -> sensor pixel; tapping the same spot again clears the probe. */
private fun PointerInputScope.tapProbe(
current: Pair<Int, Int>?,
pos: Offset,
zoom: Float,
pan: Offset,
size: IntSize,
): Pair<Int, Int>? {
val rect = imageRect(
androidx.compose.ui.geometry.Size(size.width.toFloat(), size.height.toFloat()),
zoom, pan,
)
if (!rect.contains(pos)) return current
val x = ((pos.x - rect.left) / rect.width * TEMP_W).toInt().coerceIn(0, TEMP_W - 1)
val y = ((pos.y - rect.top) / rect.height * TEMP_H).toInt().coerceIn(0, TEMP_H - 1)
val hit = current?.let {
val dx = (it.first - x) * rect.width / TEMP_W
val dy = (it.second - y) * rect.height / TEMP_H
dx * dx + dy * dy <= (6.dp.toPx() * 6.dp.toPx())
} ?: false
return if (hit) null else x to y
}
/** Temperature bar glued to the top of the displayed image (fit-rect based). */
private fun DrawScope.drawTemperatureOsd(map: IntArray) {
if (map.size < TEMP_W * TEMP_H) return
val rect = imageRect(size, 1f, Offset.Zero)
var mn = Int.MAX_VALUE
var mx = Int.MIN_VALUE
for (v in map) {
if (v < mn) mn = v
if (v > mx) mx = v
}
val center = map[(TEMP_H / 2) * TEMP_W + TEMP_W / 2]
val text = "中心 %.1f℃ 最低 %.1f℃ 最高 %.1f℃".format(
Locale.US, center / 1000f, mn / 1000f, mx / 1000f,
)
val barH = 28.dp.toPx()
val radius = 4.dp.toPx()
drawRoundRect(
color = Color(0x99000000),
topLeft = Offset(rect.left, rect.top),
size = androidx.compose.ui.geometry.Size(rect.width, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius, radius),
)
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = 12.sp.toPx()
isAntiAlias = true
}
val baseline = rect.top + barH / 2f - (paint.ascent() + paint.descent()) / 2f
drawIntoCanvas { canvas ->
canvas.nativeCanvas.drawText(text, rect.left + 12.dp.toPx(), baseline, paint)
}
}
/** White dot + ring marker at the probed pixel, with a temperature label. */
private fun DrawScope.drawProbeMarker(
probe: Pair<Int, Int>,
map: IntArray,
zoom: Float,
pan: Offset,
) {
val idx = probe.second * TEMP_W + probe.first
if (idx < 0 || idx >= map.size) return
val rect = imageRect(size, zoom, pan)
val cx = rect.left + (probe.first + 0.5f) / TEMP_W * rect.width
val cy = rect.top + (probe.second + 0.5f) / TEMP_H * rect.height
val dotR = 4.dp.toPx()
val ringR = 9.dp.toPx()
drawCircle(Color.White, dotR, Offset(cx, cy))
drawCircle(Color.White, ringR, Offset(cx, cy), style = androidx.compose.ui.graphics.drawscope.Stroke(2.dp.toPx()))
val label = "%.1f℃".format(Locale.US, map[idx] / 1000f)
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.BLACK
textSize = 12.sp.toPx()
isAntiAlias = true
}
val padH = 6.dp.toPx()
val padV = 4.dp.toPx()
val tw = paint.measureText(label)
val boxW = tw + padH * 2
val boxH = paint.textSize + padV * 2
var boxLeft = cx + 8.dp.toPx()
if (boxLeft + boxW > rect.right) boxLeft = cx - 8.dp.toPx() - boxW
var boxTop = cy - 8.dp.toPx() - boxH
if (boxTop < rect.top) boxTop = cy + 8.dp.toPx()
val radius = 4.dp.toPx()
drawRoundRect(
color = Color(0xF0FFFFFF),
topLeft = Offset(boxLeft, boxTop),
size = androidx.compose.ui.geometry.Size(boxW, boxH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius, radius),
)
val baseline = boxTop + boxH / 2f - (paint.ascent() + paint.descent()) / 2f
drawIntoCanvas { canvas -> canvas.nativeCanvas.drawText(label, boxLeft + padH, baseline, paint) }
}
@Composable
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
var text by remember { mutableStateOf(initial) }
@@ -0,0 +1,61 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Phase B: MDT raw-frame -> millidegree temperature map. */
class TempMathTest {
private fun frameLe(vararg counts: Int): ByteArray {
val out = ByteArray(counts.size * 2)
counts.forEachIndexed { i, c ->
out[i * 2] = (c and 0xFF).toByte()
out[i * 2 + 1] = ((c shr 8) and 0xFF).toByte()
}
return out
}
@Test
fun mapHas19200EntriesForAFullFrame() {
val frame = ByteArray(38400) { ((it * 7 + 33) and 0xFF).toByte() }
val map = TempMath.tempMapFromPixels(frame)
assertEquals(19200, map.size)
}
@Test
fun mapIsMonotonicInCounts() {
// increasing counts must give increasing temperature (spot samples)
val counts = intArrayOf(6000, 6500, 7000, 7500, 8000)
val map = TempMath.tempMapFromPixels(frameLe(*counts), w = counts.size, h = 1)
for (i in 1 until map.size) {
assertTrue(
"monotonic: ${counts[i - 1]}->${counts[i]} gave ${map[i - 1]}->${map[i]}",
map[i] > map[i - 1],
)
}
}
@Test
fun mapMatchesScalarConversionPerPixel() {
val counts = intArrayOf(5000, 7000, 9000, 11000)
val map = TempMath.tempMapFromPixels(frameLe(*counts), w = counts.size, h = 1)
counts.forEachIndexed { i, c ->
assertEquals("pixel $i", TempMath.countsToTempMc(c), map[i])
}
}
@Test
fun ambientRegionIsPlausibleCelsius() {
// 7000 counts is the ambient-ish region used by the live pipeline test
val map = TempMath.tempMapFromPixels(frameLe(7000), w = 1, h = 1)
assertTrue("plausible mC: ${map[0]}", map[0] in 0..60_000)
}
@Test
fun shortInputIsHandledWithoutThrowing() {
// defensively stop at the available bytes instead of over-reading
val map = TempMath.tempMapFromPixels(frameLe(7000, 7001, 7002))
assertEquals(3, map.size)
assertEquals(TempMath.countsToTempMc(7000), map[0])
}
}
@@ -0,0 +1,108 @@
package com.mag160c.thermal.media
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* MDT container round trip: compose -> parse must reproduce every section,
* and a corrupted tail must be rejected (offline analysis, phase B).
*/
class MdtTest {
/** Minimal fake JPEG: SOI + payload + EOI, length deliberately not
* aligned to 4 so the padding cut-back is exercised. */
private fun fakeJpg(): ByteArray = byteArrayOf(
0xFF.toByte(), 0xD8.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55,
0xFF.toByte(), 0xD9.toByte(),
)
private fun pixels(seed: Int = 1234): ByteArray {
val out = ByteArray(38400)
var s = seed
for (i in out.indices) {
s = s * 1103515245 + 12345
out[i] = ((s shr 16) and 0xFF).toByte()
}
return out
}
@Test
fun composeParseRoundTripPreservesAllSections() {
val jpg = fakeJpg()
val info0 = ByteArray(0x38) { (it * 3).toByte() }
val info1 = ByteArray(0x38) { (it * 5 + 1).toByte() }
val frame = pixels()
val note = "现场巡检 A 区 3 号柜"
val mdt = Mdt.compose(
jpg = jpg,
info0 = info0,
info1 = info1,
framePixels = frame,
text = note.toByteArray(Charsets.UTF_8),
)
val parsed = Mdt.parse(mdt)
assertNotNull("parse must succeed", parsed)
parsed!!
assertArrayEquals("jpg (padding trimmed)", jpg, parsed.jpg)
assertArrayEquals("info0", info0, parsed.info0)
assertArrayEquals("info1", info1, parsed.info1)
assertArrayEquals("framePixels", frame, parsed.framePixels)
assertEquals("text", note, parsed.text)
}
@Test
fun composeWithoutOptionalBlocksStillParses() {
val jpg = fakeJpg()
val mdt = Mdt.compose(jpg = jpg, info0 = null, info1 = null, framePixels = null)
val parsed = Mdt.parse(mdt)
assertNotNull(parsed)
parsed!!
assertArrayEquals(jpg, parsed.jpg)
assertNull(parsed.info0)
assertNull(parsed.info1)
assertNull(parsed.framePixels)
assertNull(parsed.text)
}
@Test
fun corruptedTailReturnsNull() {
val mdt = Mdt.compose(fakeJpg(), ByteArray(0x38) { 1 }, null, pixels())
// the tail carries the section magic + ddt offset; the remaining 144
// reserved bytes are opaque to us (no checksum in the format), so the
// detectable single-byte corruptions are the two meaningful fields
val tail = mdt.size - 152
for (flipAt in intArrayOf(tail, tail + 4, tail + 6)) {
val bad = mdt.copyOf()
bad[flipAt] = (bad[flipAt].toInt() xor 0x5A).toByte()
assertNull("flip at $flipAt must be rejected", Mdt.parse(bad))
}
}
@Test
fun corruptedDdtHeaderReturnsNull() {
val mdt = Mdt.compose(fakeJpg(), null, null, pixels())
val ddtOffset = Mdt.u32(mdt, mdt.size - 152 + 4)
val bad = mdt.copyOf()
bad[ddtOffset] = (bad[ddtOffset].toInt() xor 0xFF).toByte()
assertNull("bad ddt section magic must be rejected", Mdt.parse(bad))
}
@Test
fun truncatedFileReturnsNull() {
val mdt = Mdt.compose(fakeJpg(), null, null, pixels())
assertNull(Mdt.parse(mdt.copyOfRange(0, 100)))
}
@Test
fun framePixelsAre19200LittleEndianShort() {
val mdt = Mdt.compose(fakeJpg(), null, null, pixels())
val parsed = Mdt.parse(mdt)!!
val frame = parsed.framePixels!!
assertEquals("38400 bytes = 19200 u16 LE", 19200, frame.size / 2)
assertTrue("some non-zero payload", frame.any { it.toInt() != 0 })
}
}
Binary file not shown.
+1 -1
View File
@@ -22,7 +22,7 @@
| 8 | 点底部大圆快门拍照 | `[vm] hb: state=saved ...`(下一次 5 s 心跳即可看到 status=saved);UI 上状态文案短暂显示 | 相册/文件管理器 DCIM/MAG160C 出现 `MAG160C_yyyyMMdd_HHmmss.jpg`(实际为 MDT 容器,扩展名 .jpg) |
| 9 | 点录像,录 10 s,再点停止 | `[vm] hb: state=recording ...` → 停止后 `[vm] hb: state=rec_done ...` | 状态回到 rec_done,无崩溃 |
| 10 | 切到"相册"页 | (无 DebugLog;纯 UI) | 列表出现步骤 8 的照片缩略图(MDT 尾部校验通过才显示) |
| 11 | 点该照片进入分析页 | (无 DebugLog;纯 UI) | 图片可缩放/平移;调色板重渲染可用;备注可编辑保存 |
| 11 | 点该照片进入分析页 | (无 DebugLog;纯 UI) | 图片可缩放/平移;调色板重渲染可用;**图像顶部出现温度条**`中心 x.x℃ 最低 x.x℃ 最高 x.x℃`);点图任意位置出现白色圆点+温度标签,再点同一点可清除;备注可编辑保存 |
| 12 | 分析页生成 PDF 报告 | (无 DebugLog;纯 UI | 报告文件在 DCIM/MAG160C 或 Download/MAG160C 生成,可打开 |
## 失败时的快速定位(沿用第 11 轮起的诊断路径)
+7 -1
View File
@@ -387,9 +387,15 @@
## 执行计划进度(2026-09-10docs/android_app/execution_plan.md
- [x] Phase A2026-09-10):GetLifeTime(675→0x5BB5B55B61) 查询 + deviceLifetimeMs
- [x] Phase A2026-09-10):GetLifeTime(675→0x5BB5B561) 查询 + deviceLifetimeMs
cali 缓存与内置 DDT MD5 一致性日志;新增 real_device_checklist.md
gradlew test 全绿,APK 已更新。commit: "android: lifetime query + cali consistency check + real-device checklist"
- [x] Phase B2026-09-10):Mdt.parse 返回 MdtFilejpg 按 FFD9 裁尾、text 去 NUL
填充、framePixels/info0/info1 齐全);TempMath.tempMapFromPixels 毫度图;
分析页温度条(中心/最低/最高)+ 点击测温探针(白点+环+温度标签,再点清除);
新增 MdtTest/TempMathTest17 个单测全绿)。注:分析页图像为原生横向显示,
故探针映射用直接映射(不是实时页的 90° 逆映射)。APK 已更新。
- [ ] Phase C:厂商 12 调色板精确提取(脚本)
## 里程碑日志