android: MDT temperature decode + analyzer probe UI
This commit is contained in:
@@ -33,6 +33,21 @@ object TempMath {
|
|||||||
return temp.toInt()
|
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
|
* T2E piecewise-linear evaluation with Q13 band selection
|
||||||
* (vendor ReviseTemperature/CorrectTemperature core, 274-entry curve).
|
* (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
|
/** Parse an MDT file produced by [compose] (or any file whose last 152
|
||||||
* bytes carry a valid tail). Returns the sections or null. */
|
* 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
|
if (bytes.size < 152) return null
|
||||||
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
|
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
|
||||||
if (u32(tail, 0) != SECTION_TAIL) return null
|
if (u32(tail, 0) != SECTION_TAIL) return null
|
||||||
@@ -103,7 +103,7 @@ object Mdt {
|
|||||||
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
|
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
|
||||||
val bodySize = u32(bytes, ddtOffset + 4)
|
val bodySize = u32(bytes, ddtOffset + 4)
|
||||||
val bodyStart = ddtOffset + 0x88
|
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>()
|
val blocks = HashMap<Int, ByteArray>()
|
||||||
var p = bodyStart
|
var p = bodyStart
|
||||||
val end = bodyStart + bodySize
|
val end = bodyStart + bodySize
|
||||||
@@ -114,20 +114,39 @@ object Mdt {
|
|||||||
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
|
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
|
||||||
p += 8 + len
|
p += 8 + len
|
||||||
}
|
}
|
||||||
return Parsed(
|
return MdtFile(
|
||||||
jpg = bytes.copyOfRange(0, ddtOffset),
|
jpg = extractJpg(bytes, ddtOffset),
|
||||||
info0 = blocks[BLOCK_INFO0],
|
info0 = blocks[BLOCK_INFO0],
|
||||||
info1 = blocks[BLOCK_INFO1],
|
info1 = blocks[BLOCK_INFO1],
|
||||||
frame = blocks[BLOCK_FRAME],
|
framePixels = blocks[BLOCK_FRAME],
|
||||||
text = blocks[BLOCK_TXT],
|
// 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 jpg: ByteArray,
|
||||||
val info0: ByteArray?,
|
val info0: ByteArray?,
|
||||||
val info1: ByteArray?,
|
val info1: ByteArray?,
|
||||||
val frame: ByteArray?,
|
/** Raw 38400 B measurement frame (19200 x u16 LE), null when absent. */
|
||||||
val text: ByteArray?,
|
val framePixels: ByteArray?,
|
||||||
|
val text: String?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ class AnalyzeViewModel(
|
|||||||
private val containerBytes: ByteArray,
|
private val containerBytes: ByteArray,
|
||||||
private val fileUri: android.net.Uri,
|
private val fileUri: android.net.Uri,
|
||||||
) : AndroidViewModel(app) {
|
) : AndroidViewModel(app) {
|
||||||
val parsed: Mdt.Parsed? = Mdt.parse(containerBytes)
|
val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes)
|
||||||
|
|
||||||
/** Raw measurement frame (19200 uint16) if present. */
|
/** Raw measurement frame (19200 uint16) if present. */
|
||||||
val rawFrame: IntArray? by lazy {
|
val rawFrame: IntArray? by lazy {
|
||||||
parsed?.frame?.let { raw ->
|
parsed?.framePixels?.let { raw ->
|
||||||
val out = IntArray(19200)
|
val out = IntArray(19200)
|
||||||
for (i in out.indices) {
|
for (i in out.indices) {
|
||||||
out[i] = (raw[i * 2].toInt() and 0xFF) or ((raw[i * 2 + 1].toInt() and 0xFF) shl 8)
|
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)
|
private val _render = MutableStateFlow<Bitmap?>(null)
|
||||||
val render: StateFlow<Bitmap?> = _render
|
val render: StateFlow<Bitmap?> = _render
|
||||||
|
|
||||||
@@ -65,10 +98,7 @@ class AnalyzeViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decodeNote(): String? {
|
fun decodeNote(): String? = parsed?.text
|
||||||
val t = parsed?.text ?: return null
|
|
||||||
return String(t, Charsets.UTF_8)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Probe temperature approximation at a raw pixel (millidegrees C). */
|
/** Probe temperature approximation at a raw pixel (millidegrees C). */
|
||||||
fun probeTemp(x: Int, y: Int): Int? {
|
fun probeTemp(x: Int, y: Int): Int? {
|
||||||
@@ -90,7 +120,7 @@ class AnalyzeViewModel(
|
|||||||
jpg = jpg,
|
jpg = jpg,
|
||||||
info0 = parsed?.info0,
|
info0 = parsed?.info0,
|
||||||
info1 = parsed?.info1,
|
info1 = parsed?.info1,
|
||||||
framePixels = parsed?.frame,
|
framePixels = parsed?.framePixels,
|
||||||
text = note.toByteArray(Charsets.UTF_8),
|
text = note.toByteArray(Charsets.UTF_8),
|
||||||
)
|
)
|
||||||
val ok = runCatching {
|
val ok = runCatching {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
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.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.mag160c.thermal.core.Palettes
|
import com.mag160c.thermal.core.Palettes
|
||||||
import com.mag160c.thermal.ui.gallery.GalleryViewModel
|
import com.mag160c.thermal.ui.gallery.GalleryViewModel
|
||||||
|
|
||||||
@@ -83,6 +89,9 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
|
|||||||
pan = Offset.Zero
|
pan = Offset.Zero
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { pos -> vm.probe = tapProbe(vm.probe, pos, zoom, pan, size) }
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val img = render?.asImageBitmap()
|
val img = render?.asImageBitmap()
|
||||||
@@ -97,6 +106,11 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
|
|||||||
dstSize = IntSize(w.toInt(), h.toInt()),
|
dstSize = IntSize(w.toInt(), h.toInt()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val tempMap = vm.tempMap
|
||||||
|
if (tempMap != null) {
|
||||||
|
drawTemperatureOsd(tempMap)
|
||||||
|
vm.probe?.let { drawProbeMarker(it, tempMap, zoom, pan) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
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 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
|
@Composable
|
||||||
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
|
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
var text by remember { mutableStateOf(initial) }
|
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.
@@ -22,7 +22,7 @@
|
|||||||
| 8 | 点底部大圆快门拍照 | `[vm] hb: state=saved ...`(下一次 5 s 心跳即可看到 status=saved);UI 上状态文案短暂显示 | 相册/文件管理器 DCIM/MAG160C 出现 `MAG160C_yyyyMMdd_HHmmss.jpg`(实际为 MDT 容器,扩展名 .jpg) |
|
| 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,无崩溃 |
|
| 9 | 点录像,录 10 s,再点停止 | `[vm] hb: state=recording ...` → 停止后 `[vm] hb: state=rec_done ...` | 状态回到 rec_done,无崩溃 |
|
||||||
| 10 | 切到"相册"页 | (无 DebugLog;纯 UI) | 列表出现步骤 8 的照片缩略图(MDT 尾部校验通过才显示) |
|
| 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 生成,可打开 |
|
| 12 | 分析页生成 PDF 报告 | (无 DebugLog;纯 UI) | 报告文件在 DCIM/MAG160C 或 Download/MAG160C 生成,可打开 |
|
||||||
|
|
||||||
## 失败时的快速定位(沿用第 11 轮起的诊断路径)
|
## 失败时的快速定位(沿用第 11 轮起的诊断路径)
|
||||||
|
|||||||
@@ -387,9 +387,15 @@
|
|||||||
|
|
||||||
## 执行计划进度(2026-09-10,docs/android_app/execution_plan.md)
|
## 执行计划进度(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;
|
cali 缓存与内置 DDT MD5 一致性日志;新增 real_device_checklist.md;
|
||||||
gradlew test 全绿,APK 已更新。commit: "android: lifetime query + cali consistency check + real-device checklist"
|
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 调色板精确提取(脚本)
|
||||||
|
|
||||||
|
|
||||||
## 里程碑日志
|
## 里程碑日志
|
||||||
|
|||||||
Reference in New Issue
Block a user