From 656d419f81b10024d9db3009f47e2ef49ebb4f44 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Thu, 10 Sep 2026 23:43:19 +0800 Subject: [PATCH] android: MDT temperature decode + analyzer probe UI --- .../com/mag160c/thermal/core/TempMath.kt | 15 ++ .../kotlin/com/mag160c/thermal/media/Mdt.kt | 37 +++-- .../thermal/ui/analyze/AnalyzeViewModel.kt | 44 +++++- .../thermal/ui/analyze/AnalyzeViewer.kt | 131 ++++++++++++++++++ .../com/mag160c/thermal/core/TempMathTest.kt | 61 ++++++++ .../com/mag160c/thermal/media/MdtTest.kt | 108 +++++++++++++++ build-artifacts/mag160c-app-debug.apk | 2 +- docs/android_app/real_device_checklist.md | 2 +- docs/android_app/session_state.md | 8 +- 9 files changed, 389 insertions(+), 19 deletions(-) create mode 100644 android/app/src/test/kotlin/com/mag160c/thermal/core/TempMathTest.kt create mode 100644 android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt index efc18c6..d92d2d2 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt @@ -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). diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt index 2af087a..c9aba54 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt @@ -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() 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?, ) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt index 98c5d34..b54742d 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt @@ -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(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?>(null) + var probe: Pair? + 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(null) val render: StateFlow = _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 { diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt index 1665ad7..9f06542 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt @@ -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?, + pos: Offset, + zoom: Float, + pan: Offset, + size: IntSize, +): Pair? { + 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, + 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) } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/TempMathTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/TempMathTest.kt new file mode 100644 index 0000000..7f2a81c --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/TempMathTest.kt @@ -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]) + } +} diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt new file mode 100644 index 0000000..b584699 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt @@ -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 }) + } +} diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 60d16c8..8fbe2be 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:417b91871069e50bab56adaaa0c6dcf38a9104587dff9280cf281e4c3f1eb299 +oid sha256:a650f2499a96a2a3e6c2c4d23a54107a17dac35e1bb4e5fec2a511f1e8762b4f size 11873886 diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md index a386bff..43f8cfb 100644 --- a/docs/android_app/real_device_checklist.md +++ b/docs/android_app/real_device_checklist.md @@ -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 轮起的诊断路径) diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 72c331f..efe4031 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -387,9 +387,15 @@ ## 执行计划进度(2026-09-10,docs/android_app/execution_plan.md) -- [x] Phase A(2026-09-10):GetLifeTime(675→0x5BB5B55B61) 查询 + deviceLifetimeMs; +- [x] Phase A(2026-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 B(2026-09-10):Mdt.parse 返回 MdtFile(jpg 按 FFD9 裁尾、text 去 NUL + 填充、framePixels/info0/info1 齐全);TempMath.tempMapFromPixels 毫度图; + 分析页温度条(中心/最低/最高)+ 点击测温探针(白点+环+温度标签,再点清除); + 新增 MdtTest/TempMathTest(17 个单测全绿)。注:分析页图像为原生横向显示, + 故探针映射用直接映射(不是实时页的 90° 逆映射)。APK 已更新。 +- [ ] Phase C:厂商 12 调色板精确提取(脚本) ## 里程碑日志