android: MDT temperature decode + analyzer probe UI
This commit is contained in:
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user