android: fix analysis temperatures (store calibrated NUC data), marker sizing, bottom data panel; split album (zoom/delete) from analysis tab

This commit is contained in:
ZXCLI
2026-09-12 00:05:49 +08:00
parent 15a7127287
commit 4cb6e69ea4
15 changed files with 1090 additions and 388 deletions
@@ -13,9 +13,18 @@ import java.io.ByteArrayOutputStream
* DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
* 0x5BB5B55B camera info (0x38B from command 66b)
* 0x5BB5B55C second info block (0x38B from 66c, optional)
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE) — RAW sensor response
* 0x5BB5B55E text note (UTF-8, optional)
* 0x5BB5B55F probe points (UTF-8 lines "x,y,label,tempMc", optional)
* 0x5BB5B560 NUC counts in PHOTO coordinates (19200 x uint16 LE, optional)
*
* WHY THE NUC BLOCK EXISTS (2026-09-11): the raw frame is the sensor response
* BEFORE non-uniformity correction, so converting it directly yields nonsense
* (the analysis panel showed 145 C max / -161 C min for a 30 C scene). The live
* readouts use the pipeline's NUC output (counts), which is what the calibration
* tables are valid for. The photo therefore carries that corrected data, already
* transformed into the saved photo's own pixel order, so an offline temperature
* lookup is a plain index into it and gives the same numbers the live view showed.
*/
object Mdt {
const val SECTION_DDT = 0x5BB5B55B
@@ -27,12 +36,17 @@ object Mdt {
const val BLOCK_TXT = 0x5BB5B55E
/**
* Probe points captured with the photo. Stored as UTF-8 text ("x,y,label,tempMc"
* per line) so the values stay inspectable with any hex editor — the same
* reasoning the vendor layout uses for its TXT section.
* Probe points captured with the photo, in the SAVED PHOTO's pixel
* coordinates (not sensor coordinates — that mismatch put the markers in the
* wrong place when the photo was displayed). Stored as UTF-8 text
* ("x,y,label,tempMc" per line) so the values stay inspectable with any hex
* editor — the same reasoning the vendor layout uses for its TXT section.
*/
const val BLOCK_PROBES = 0x5BB5B55F
/** NUC (calibrated) counts in photo pixel order; see the header note. */
const val BLOCK_NUC = 0x5BB5B560
/** One probe carried in an MDT file. */
data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int)
@@ -84,6 +98,7 @@ object Mdt {
framePixels: ByteArray?,
text: ByteArray? = null,
probes: ByteArray? = null,
nucPixels: ByteArray? = null,
): ByteArray {
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
out.write(jpg, 0, jpg.size)
@@ -107,6 +122,7 @@ object Mdt {
}
text?.let { emit(BLOCK_TXT, it) }
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) }
nucPixels?.let { if (it.size == 38400) emit(BLOCK_NUC, it) }
val bodyBytes = body.toByteArray()
val header = ByteArray(0x88)
@@ -153,6 +169,7 @@ object Mdt {
// block payloads are padded to 4; strip the NUL padding
text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') },
probes = parseProbes(blocks[BLOCK_PROBES]),
nucPixels = blocks[BLOCK_NUC],
)
}
@@ -177,10 +194,23 @@ object Mdt {
val jpg: ByteArray,
val info0: ByteArray?,
val info1: ByteArray?,
/** Raw 38400 B measurement frame (19200 x u16 LE), null when absent. */
/**
* Raw 38400 B sensor response (19200 x u16 LE). NOTE: this is PRE-NUC
* data — do not convert it to temperature directly (see [nucPixels]).
*/
val framePixels: ByteArray?,
val text: String?,
/** Probe points stored with the photo (empty when none). */
/** Probe points in the saved photo's pixel coordinates (empty when none). */
val probes: List<Probe> = emptyList(),
)
/**
* Calibrated NUC counts in the saved photo's pixel order (19200 x u16 LE),
* valid input for TempMath.countsToTempMc. Null for photos taken before
* 2026-09-11 — callers must then refuse to show temperatures rather than
* computing wrong ones.
*/
val nucPixels: ByteArray? = null,
) {
/** True when this photo can be measured offline. */
val hasTemperatureData: Boolean get() = nucPixels != null
}
}
@@ -0,0 +1,21 @@
package com.mag160c.thermal.media
import android.content.Context
import android.net.Uri
/**
* Cheap "can this photo be measured offline?" check for the analysis list.
*
* A measurable photo carries the NUC block (see [Mdt.BLOCK_NUC]). Only the tail
* and the DDT block table are read — never the whole file — so filtering a large
* album stays fast.
*/
object MdtProbe {
fun isMeasurable(context: Context, uri: Uri): Boolean = runCatching {
context.contentResolver.openInputStream(uri)?.use { input ->
val all = input.readBytes()
val parsed = Mdt.parse(all) ?: return@use false
parsed.hasTemperatureData
} ?: false
}.getOrDefault(false)
}
@@ -27,9 +27,126 @@ object PhotoSaver {
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
/** Probe annotation burned into a saved photo. */
/** Probe annotation burned into a saved photo (photo pixel coordinates). */
data class ProbeMark(val x: Int, val y: Int, val label: String, val tempC: Float)
/**
* Marker geometry relative to the IMAGE, not to screen density.
*
* The first version sized markers with screen density (4.5*density dot,
* 9*density ring) while drawing into a 320x240 bitmap, so the rings came out
* ~36 px across on a 320 px-wide photo — enormous (the user's "测温点太大了").
* Sizes are now derived from the image width, keeping the same proportion the
* live screen shows.
*/
private class MarkStyle(imageWidth: Int) {
val scale = imageWidth / 320f
val dotR = 2.6f * scale
val ringR = 5.5f * scale
val ringW = 1.4f * scale
val textSize = 9f * scale
val labelGap = 7f * scale
val shadow = 1.5f * scale
}
/**
* Build the NUC block for a photo: ONE count per photo pixel, so an offline
* lookup is literally `counts[iy * photoWidth + ix]`.
*
* Why 1:1 with the photo and not the 160x120 sensor grid: the photo is
* displayed in its own pixel space, and any attempt to keep a smaller grid
* forces every caller to re-derive the rotation/flip/upscale — the exact class
* of index confusion that produced both wrong temperatures and misplaced
* markers. Costs 4x the bytes (153 KB) and removes the ambiguity entirely.
*/
fun buildPhotoOrderedCounts(
nuc160: IntArray,
orientation: Orientation,
srcW: Int = 320,
srcH: Int = 240,
): IntArray {
require(nuc160.size >= 160 * 120) { "expected 19200 NUC samples, got ${nuc160.size}" }
val rot = ((orientation.rotateDeg % 360) + 360) % 360
val outW = if (rot % 180 == 0) srcW else srcH
val outH = if (rot % 180 == 0) srcH else srcW
val out = IntArray(outW * outH)
for (iy in 0 until outH) {
for (ix in 0 until outW) {
val s = photoToSensor(ix, iy, srcW, srcH, rot, orientation)
out[iy * outW + ix] = nuc160[s[1] * 160 + s[0]]
}
}
return out
}
/**
* Photo pixel -> SENSOR pixel: the exact inverse of the transform
* [encodeRendered] applies to the bitmap (flips, then clockwise rotation) with
* the 2x upscale in between.
*
* Buffer coords come from the documented forward map
* rot 0 (bx,by) -> (bx, by) rot 180 -> (W-bx, H-by)
* rot 90 (bx,by) -> (H-by, bx) rot 270 -> (by, W-bx)
* inverted below; then the flips are undone, then the 2x upscale.
*/
fun photoToSensor(
ix: Int,
iy: Int,
srcW: Int = 320,
srcH: Int = 240,
rot: Int,
orientation: Orientation,
): IntArray {
val W = srcW
val H = srcH
var bx: Int
var by: Int
when (((rot % 360) + 360) % 360) {
90 -> {
bx = iy
by = H - ix
}
180 -> {
bx = W - ix
by = H - iy
}
270 -> {
bx = W - iy
by = ix
}
else -> {
bx = ix
by = iy
}
}
if (orientation.flipH) bx = W - bx
if (orientation.flipV) by = H - by
val sx = (bx / 2).coerceIn(0, 159)
val sy = (by / 2).coerceIn(0, 119)
return intArrayOf(sx, sy)
}
/** Pack an IntArray of counts as u16 little-endian. */
fun countsToBytes(counts: IntArray): ByteArray {
val out = ByteArray(counts.size * 2)
for (i in counts.indices) {
val v = counts[i].coerceIn(0, 0xFFFF)
out[i * 2] = (v and 0xFF).toByte()
out[i * 2 + 1] = ((v shr 8) and 0xFF).toByte()
}
return out
}
/** Unpack u16 little-endian bytes into counts. */
fun bytesToCounts(bytes: ByteArray): IntArray {
val n = bytes.size / 2
val out = IntArray(n)
for (i in 0 until n) {
out[i] = (bytes[i * 2].toInt() and 0xFF) or ((bytes[i * 2 + 1].toInt() and 0xFF) shl 8)
}
return out
}
/**
* Orientation applied to a saved photo, mirroring what the live view showed
* (the image is locked to the portrait frame plus the user's manual
@@ -106,41 +223,61 @@ object PhotoSaver {
if (probes.isNotEmpty()) {
val canvas = Canvas(outBmp)
val s = MarkStyle(outW)
// Probes are given in PHOTO pixel coordinates already (the capture
// converts them together with the NUC data), so no extra transform.
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = 2.5f * density
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
strokeWidth = s.ringW
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
}
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
}
val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 13f * density
textSize = s.textSize
typeface = Typeface.SANS_SERIF
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
}
for (p in probes) {
val pos = sensorToImage(p.x, p.y, w, h, rot, orientation)
val sx = pos[0].toFloat()
val sy = pos[1].toFloat()
canvas.drawCircle(sx, sy, 4.5f * density, dot)
canvas.drawCircle(sx, sy, 9f * density, ring)
val label = (if (p.label.isNotEmpty()) "${p.label} " else "") +
"%.1f℃".format(p.tempC)
val tw = text.measureText(label)
var tx = sx + 13f * density
if (tx + tw > outW - 2f * density) tx = sx - 13f * density - tw
val ty = (sy + text.textSize).coerceAtMost(outH - 4f * density)
canvas.drawText(label, tx, ty, text)
val sx = p.x.toFloat()
val sy = p.y.toFloat()
drawMarker(canvas, sx, sy, p.label, p.tempC, s, ring, dot, text, outW, outH)
}
}
return encodeJpeg(outBmp, quality)
}
/** One marker: filled dot, ring, and a temperature label that stays inside. */
private fun drawMarker(
canvas: Canvas,
sx: Float,
sy: Float,
label: String,
tempC: Float,
s: MarkStyle,
ring: Paint,
dot: Paint,
text: Paint,
outW: Int,
outH: Int,
) {
canvas.drawCircle(sx, sy, s.dotR, dot)
canvas.drawCircle(sx, sy, s.ringR, ring)
val full = (if (label.isNotEmpty()) "$label " else "") + "%.1f℃".format(tempC)
val tw = text.measureText(full)
val half = text.textSize / 2f
var tx = sx + s.ringR + s.labelGap
if (tx + tw > outW - 2f * s.scale) tx = sx - s.ringR - s.labelGap - tw
if (tx < 2f * s.scale) tx = 2f * s.scale
val ty = (sy + half).coerceIn(half + 2f * s.scale, outH - 2f * s.scale)
canvas.drawText(full, tx, ty, text)
}
/**
* Sensor pixel -> position in the FINAL (rotated, flipped) photo.
*
@@ -189,35 +326,29 @@ object PhotoSaver {
?: return jpg
val out = bmp.copy(android.graphics.Bitmap.Config.ARGB_8888, true) ?: return jpg
val canvas = Canvas(out)
val s = MarkStyle(out.width)
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = 2.5f * density
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
strokeWidth = s.ringW
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
}
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
}
val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 13f * density
textSize = s.textSize
typeface = Typeface.SANS_SERIF
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
}
for (p in probes) {
val sx = p.x.toFloat()
val sy = p.y.toFloat()
canvas.drawCircle(sx, sy, 4.5f * density, dot)
canvas.drawCircle(sx, sy, 9f * density, ring)
val label = (if (p.label.isNotEmpty()) "${p.label} " else "") +
"%.1f℃".format(p.tempC)
val tw = text.measureText(label)
var tx = sx + 13f * density
if (tx + tw > out.width - 2f * density) tx = sx - 13f * density - tw
val ty = (sy + text.textSize).coerceAtMost(out.height - 4f * density)
canvas.drawText(label, tx, ty, text)
drawMarker(
canvas, p.x.toFloat(), p.y.toFloat(), p.label, p.tempC, s,
ring, dot, text, out.width, out.height,
)
}
return encodeJpeg(out)
}
@@ -73,6 +73,15 @@ fun AppRoot() {
onDispose { DeviceOrientation.stop() }
}
// Album taps open a plain photo viewer (zoom/delete); analysis-tab taps open
// the measurement viewer. Keeping them separate is what makes the two tabs
// different (the user reported they looked identical).
var viewerItem by remember { mutableStateOf<com.mag160c.thermal.ui.gallery.GalleryViewModel.Item?>(null) }
var analyzeItem by remember { mutableStateOf<com.mag160c.thermal.ui.gallery.GalleryViewModel.Item?>(null) }
// the same view model instance the grids use, so selection/refresh stay in sync
val galleryVm: com.mag160c.thermal.ui.gallery.GalleryViewModel =
androidx.lifecycle.viewmodel.compose.viewModel()
Box(modifier = Modifier.fillMaxSize()) {
when {
remoteHost != null -> RemoteViewerScreen(
@@ -92,11 +101,27 @@ fun AppRoot() {
remotePort = port
},
)
viewerItem != null -> com.mag160c.thermal.ui.gallery.PhotoViewerScreen(
item = viewerItem!!,
vm = galleryVm,
onClose = { viewerItem = null },
)
analyzeItem != null -> com.mag160c.thermal.ui.analyze.AnalyzeViewer(
item = analyzeItem!!,
galleryVm = galleryVm,
onClose = { analyzeItem = null },
density = androidx.compose.ui.platform.LocalDensity.current.density,
)
tab == 0 -> LiveScreen(vm = liveVm, onOpenGallery = { tab = 1 })
tab == 1 || tab == 2 -> Column(
modifier = Modifier.fillMaxSize().padding(bottom = 84.dp),
) {
GalleryScreen()
// album = browse/zoom/delete; analysis = measure a measurable photo
tab == 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen(vm = galleryVm, onOpen = { viewerItem = it })
}
tab == 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
com.mag160c.thermal.ui.analyze.AnalyzeScreen(
vm = galleryVm,
onOpen = { analyzeItem = it },
)
}
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
SettingsScreen(
@@ -0,0 +1,126 @@
package com.mag160c.thermal.ui.analyze
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import com.mag160c.thermal.ui.gallery.GalleryViewModel
/**
* Analysis tab.
*
* Previously this tab rendered the SAME photo grid as the album tab, which is why
* the two looked identical (user report). It now has its own purpose: pick a photo
* to measure, with a short explanation of what the analysis offers, and it only
* lists photos that actually carry temperature data.
*/
@Composable
fun AnalyzeScreen(
vm: GalleryViewModel,
onOpen: (GalleryViewModel.Item) -> Unit,
) {
val items by vm.items.collectAsState()
val context = LocalContext.current
var analyzed by remember { mutableStateOf<List<GalleryViewModel.Item>>(emptyList()) }
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
android.Manifest.permission.READ_MEDIA_IMAGES
else android.Manifest.permission.READ_EXTERNAL_STORAGE
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { vm.refresh() }
LaunchedEffect(Unit) {
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) vm.refresh() else launcher.launch(perm)
}
// only photos with temperature data can be measured
LaunchedEffect(items) {
analyzed = items.filter { com.mag160c.thermal.media.MdtProbe.isMeasurable(context, it.uri) }
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("离线分析 (${analyzed.size})", style = MaterialTheme.typography.titleMedium)
Button(onClick = { vm.refresh() }) { Text("刷新") }
}
Text(
"选择一张照片进入分析:查看测温点、点击图像增删测温点、保存为新照片。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
if (analyzed.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"暂无可分析的照片\n(需要先在实时页拍照)",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.padding(top = 4.dp)) {
items(analyzed.size) { idx ->
val item = analyzed[idx]
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(item.name) { vm.thumbnail(item) { b -> bmp = b } }
Box(
modifier = Modifier
.aspectRatio(3f / 4f)
.padding(1.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onOpen(item) },
) {
val b = bmp
if (b != null) {
Image(
bitmap = b.asImageBitmap(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(3.dp)
.background(Color(0x99000000)),
)
}
}
}
}
}
}
@@ -3,10 +3,10 @@ package com.mag160c.thermal.ui.analyze
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.core.TempMath
import com.mag160c.thermal.media.Mdt
import com.mag160c.thermal.media.PhotoSaver
@@ -18,46 +18,35 @@ import kotlinx.coroutines.withContext
import java.util.Locale
/**
* Offline MDT analysis (redesigned 2026-09-11).
* Offline MDT analysis (2026-09-11).
*
* What is shown is the photo AS SAVED — the rendered, already-rotated JPEG. The
* raw frame is used only to measure temperatures; the image is never re-coloured
* here (changing the palette used to silently produce a different-looking photo,
* which was confusing for a measurement record).
*
* Probes come from the container (each saved photo carries its probe list) and
* can be added/removed by tapping; "保存" writes a NEW photo with the annotations
* baked in and the updated probes stored, leaving the original file untouched.
* Temperatures come from the NUC block stored in the photo — the CALIBRATED
* counts the live screen measures — addressed by the photo's own pixel index.
* The earlier version converted the RAW sensor frame, which is pre-NUC data and
* produced 145 C max / -161 C min on a 30 C scene; photos taken before this
* change have no NUC block, so they report "no temperature data" instead of
* inventing numbers.
*/
class AnalyzeViewModel(
app: Application,
private val containerBytes: ByteArray,
private val fileUri: android.net.Uri,
) : AndroidViewModel(app) {
/** A probe point in the SAVED IMAGE's own pixel space. */
/** A probe point in the SAVED PHOTO's pixel space. */
data class Probe(val x: Int, val y: Int, val label: String, val tempC: Float)
val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes)
/** Raw measurement frame (19200 uint16) used for temperatures. */
val rawFrame: IntArray? by lazy {
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)
}
out
}
/** Calibrated counts in photo pixel order (null for older photos). */
private val nucCounts: IntArray? by lazy {
parsed?.nucPixels?.let { PhotoSaver.bytesToCounts(it) }
}
/** Millidegree-C map of the measurement frame (19200 entries), if present. */
private val tempMap: IntArray? by lazy {
rawFrame?.let { TempMath.tempMapFromPixels(parsed!!.framePixels!!) }
}
val hasTemperatureData: Boolean get() = nucCounts != null && parsed?.hasTemperatureData == true
private val _render = MutableStateFlow<Bitmap?>(null)
/** The image to display: the saved JPEG. */
/** The image to display: the saved JPEG, as-is. */
val render: StateFlow<Bitmap?> = _render
private val _paletteIndex = MutableStateFlow(2)
@@ -66,7 +55,6 @@ class AnalyzeViewModel(
/** Editable probe list (snapshot state so the canvas redraws on change). */
val probes = mutableStateListOf<Probe>()
/** Overall readouts shown in the side panel. */
private val _minTempC = mutableStateOf<Float?>(null)
val minTempC: Float? get() = _minTempC.value
private val _maxTempC = mutableStateOf<Float?>(null)
@@ -74,7 +62,12 @@ class AnalyzeViewModel(
private val _centerTempC = mutableStateOf<Float?>(null)
val centerTempC: Float? get() = _centerTempC.value
/** Image size of the displayed (saved) photo, in pixels. */
/** Min/max position, in photo pixels, for the on-image markers. */
private val _minPos = mutableStateOf<Int>(-1)
val minPos: Int get() = _minPos.value
private val _maxPos = mutableStateOf<Int>(-1)
val maxPos: Int get() = _maxPos.value
private val _imageW = mutableStateOf(320)
val imageW: Int get() = _imageW.value
private val _imageH = mutableStateOf(240)
@@ -88,40 +81,58 @@ class AnalyzeViewModel(
_imageW.value = bmp.width
_imageH.value = bmp.height
}
// stored probes (from the container) -> editable list
val stored = parsed?.probes.orEmpty()
val loaded = stored.mapNotNull { p ->
val t = tempMap?.get(p.y.coerceIn(0, 119) * 160 + p.x.coerceIn(0, 159))
?: p.tempMc
Probe(p.x, p.y, p.label, t / 1000f)
}
// overall stats
val map = tempMap
val counts = nucCounts
var mn = Int.MAX_VALUE
var mx = Int.MIN_VALUE
map?.forEach {
if (it < mn) mn = it
if (it > mx) mx = it
var mnPos = -1
var mxPos = -1
counts?.forEachIndexed { i, v ->
if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i }
}
// stored probes already carry photo coordinates and a measured temp;
// re-measure from the NUC block when available so the panel always
// reflects the stored data rather than a stale label
val loaded = (parsed?.probes.orEmpty()).map { p ->
Probe(p.x, p.y, p.label, measure(p.x, p.y) ?: (p.tempMc / 1000f))
}
withContext(Dispatchers.Main) {
_render.value = bmp
probes.clear()
probes.addAll(loaded)
if (map != null && map.isNotEmpty()) {
if (counts != null) {
_minTempC.value = mn / 1000f
_maxTempC.value = mx / 1000f
_centerTempC.value = map[60 * 160 + 80] / 1000f
_minPos.value = mnPos
_maxPos.value = mxPos
_centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120)
}
}
}
}
/**
* Map a tap in canvas space to the SAVED IMAGE's pixel space and toggle a
* probe there. The saved photo is already rotated/flipped, so this is a
* plain rect mapping (no extra transform).
* Temperature (C) at a pixel of the SAVED photo.
* The stored NUC block is 1:1 with the photo (see PhotoSaver.buildPhotoOrderedCounts),
* so this is a plain index — no rotation/flip/upscale math that could disagree
* with how the markers were placed.
* Returns null when the photo has no NUC data — never a fabricated number.
*/
fun toggleProbeAt(pos: androidx.compose.ui.geometry.Offset, rect: androidx.compose.ui.geometry.Rect) {
fun measure(ix: Int, iy: Int): Float? {
val counts = nucCounts ?: return null
val w = _imageW.value
val h = _imageH.value
if (ix < 0 || iy < 0 || ix >= w || iy >= h) return null
val idx = iy * w + ix
if (idx < 0 || idx >= counts.size) return null
return TempMath.countsToTempMc(counts[idx]) / 1000f
}
/** Tap in canvas space -> photo pixel; toggles a probe there. */
fun toggleProbeAt(
pos: androidx.compose.ui.geometry.Offset,
rect: androidx.compose.ui.geometry.Rect,
) {
if (rect.width <= 0f || rect.height <= 0f) return
if (pos.x < rect.left || pos.x > rect.right || pos.y < rect.top || pos.y > rect.bottom) return
val ix = ((pos.x - rect.left) / rect.width * _imageW.value).toInt()
@@ -129,8 +140,8 @@ class AnalyzeViewModel(
val iy = ((pos.y - rect.top) / rect.height * _imageH.value).toInt()
.coerceIn(0, _imageH.value - 1)
// near an existing probe? remove it
val thr = 12f
// marker footprint scales with the image, so the hit radius must too
val thr = (_imageW.value / 320f * 14f).coerceAtLeast(8f)
val hit = probes.indexOfFirst { p ->
val dx = (p.x - ix).toFloat()
val dy = (p.y - iy).toFloat()
@@ -140,58 +151,13 @@ class AnalyzeViewModel(
probes.removeAt(hit)
return
}
val t = tempAtImagePixel(ix, iy)
probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t ?: 0f))
}
/**
* Temperature for a pixel of the SAVED image. The stored photo may be rotated
* relative to the sensor frame, so the image pixel is mapped back through the
* same rotation the capture used (recorded in the container's orientation
* when available; otherwise the probe simply reports the sensor-frame value
* at the equivalent position).
*/
private fun tempAtImagePixel(ix: Int, iy: Int): Float? {
val map = tempMap ?: return null
val w = _imageW.value
val h = _imageH.value
// inverse of the capture rotation: the photo is the sensor frame rotated
// clockwise by `rot`; map the image pixel back to sensor coordinates
val rot = captureRotation()
val (sx, sy) = when (rot) {
90 -> {
// image (W=h_src, H=w_src) pixel -> sensor (x, y)
val x = iy.toFloat() / h * 160f
val y = (1f - ix.toFloat() / w) * 120f
x to y
}
180 -> {
val x = (1f - ix.toFloat() / w) * 160f
val y = (1f - iy.toFloat() / h) * 120f
x to y
}
270 -> {
val x = (1f - iy.toFloat() / h) * 160f
val y = ix.toFloat() / w * 120f
x to y
}
else -> {
val x = ix.toFloat() / w * 160f
val y = iy.toFloat() / h * 120f
x to y
}
val t = measure(ix, iy)
if (t == null) {
// no calibrated data: still allow the marker, but label it honestly
probes.add(Probe(ix, iy, "Pt${probes.size + 1}", 0f))
} else {
probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t))
}
val cx = sx.toInt().coerceIn(0, 159)
val cy = sy.toInt().coerceIn(0, 119)
return map[cy * 160 + cx] / 1000f
}
/** Rotation the capture baked into the JPEG (from the stored photo size). */
private fun captureRotation(): Int {
val w = _imageW.value
val h = _imageH.value
// sensor frame is 4:3 landscape; a portrait photo means 90/270 was applied
return if (h > w) 90 else 0
}
fun setPaletteIndex(idx: Int) {
@@ -200,7 +166,7 @@ class AnalyzeViewModel(
fun decodeNote(): String? = parsed?.text
/** Save as a NEW photo: annotations baked in, probes stored in the container. */
/** Save as a NEW photo: annotations baked in, probes + NUC stored. */
fun saveAsNew(
context: android.content.Context,
notes: String,
@@ -215,7 +181,6 @@ class AnalyzeViewModel(
viewModelScope.launch(Dispatchers.IO) {
val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92)
val marks = probes.map { PhotoSaver.ProbeMark(it.x, it.y, it.label, it.tempC) }
// bake the markers onto the already-rendered image
val annotated = PhotoSaver.annotateJpeg(jpg, marks, density)
val mdt = Mdt.compose(
jpg = annotated,
@@ -226,6 +191,8 @@ class AnalyzeViewModel(
probes = Mdt.encodeProbes(
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
),
// carry the temperature data forward so the edited photo stays measurable
nucPixels = parsed?.nucPixels,
)
val name = "MAG160C_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
.format(java.util.Date())}_edit.jpg"
@@ -234,13 +201,9 @@ class AnalyzeViewModel(
}
}
/** Save the note into the ORIGINAL container (kept for the note editor). */
/** Save the note into the ORIGINAL container. */
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
val bmp = _render.value
if (bmp == null) {
onDone(false)
return
}
val bmp = _render.value ?: run { onDone(false); return }
viewModelScope.launch(Dispatchers.IO) {
val jpg = PhotoSaver.encodeJpeg(bmp)
val mdt = Mdt.compose(
@@ -252,6 +215,7 @@ class AnalyzeViewModel(
probes = Mdt.encodeProbes(
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
),
nucPixels = parsed?.nucPixels,
)
val ok = runCatching {
val ctx = getApplication<Application>()
@@ -1,29 +1,29 @@
package com.mag160c.thermal.ui.analyze
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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -38,6 +38,7 @@ 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.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
@@ -49,17 +50,15 @@ import com.mag160c.thermal.ui.gallery.GalleryViewModel
import java.util.Locale
/**
* Offline MDT analysis (redesigned 2026-09-11 per the user's request):
* Offline MDT analysis (layout revised 2026-09-11 per user feedback).
*
* - the photo is shown AS SAVED (the rendered, rotated image — no re-render of
* the raw frame with a different palette);
* - tapping the image adds a temperature probe; tapping an existing one removes
* it (the same interaction as the live screen);
* - "保存" writes a NEW photo (rotation/annotations baked in, probes stored in
* the container) and keeps the original untouched;
* - the side panel shows the overall min / max / centre temperatures, and the
* palette selector only affects that panel's number formatting hint — the
* image itself is never re-coloured here.
* Layout: the photo occupies the top area, the measurements are a compact panel
* along the BOTTOM (the side column wasted most of a portrait screen and looked
* unbalanced), with the actions in a slim title row.
*
* The image shown is the saved photo as-is; tapping adds/removes a probe. Marker
* sizes scale with the IMAGE, so they look the same as on the live screen instead
* of covering the photo.
*/
@Composable
fun AnalyzeViewer(
@@ -76,69 +75,61 @@ fun AnalyzeViewer(
AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri)
}
val render by vm.render.collectAsState()
val paletteIdx by vm.paletteIndex.collectAsState()
var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) }
var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
var showNote by remember { mutableStateOf(false) }
var saveResult by remember { mutableStateOf<String?>(null) }
var showPalette by remember { mutableStateOf(false) }
// probes live in the view model so 保存 can serialise them
val probes = vm.probes
DisposableEffect(item.name) {
onDispose { /* nothing to release yet */ }
}
var toast by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { vm.load() }
Column(modifier = Modifier.fillMaxSize().background(Color.Black)) {
// ---- top bar: title + actions ----
Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) {
// ---- slim title row ----
Row(
modifier = Modifier.fillMaxWidth().padding(6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onClose) { Text("返回") }
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
color = Color.White,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f).padding(start = 4.dp),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
maxLines = 1,
)
TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[paletteIdx]) }
TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[vm.paletteIndex.value]) }
TextButton(onClick = { showNote = true }) { Text("备注") }
TextButton(
onClick = {
vm.saveAsNew(context, notes = note, density = density) { ok ->
saveResult = if (ok) "已保存为新照片" else "保存失败"
toast = if (ok) "已保存为新照片" else "保存失败"
if (ok) galleryVm.refresh()
}
},
) { Text("保存") }
}
HorizontalDivider()
HorizontalDivider(color = Color(0x33FFFFFF))
// ---- image ----
val bmp = render
if (bmp == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("无法读取图像(缺少原始测温数据)", color = Color.White)
}
return@Column
}
// ---- image with the side panel ----
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.weight(1f).fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
if (bmp == null) {
Text(
if (vm.hasTemperatureData) "载入中…" else "无温度数据(旧照片)",
color = Color.Gray,
)
} else {
val img = bmp.asImageBitmap()
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
zoom = (zoom * gestureZoom).coerceIn(1f, 6f)
pan += gesturePan
if (zoom <= 1.01f) {
zoom = 1f
@@ -148,18 +139,11 @@ fun AnalyzeViewer(
}
.pointerInput(Unit) {
detectTapGestures { pos ->
val rect = imageRect(
Size(size.width.toFloat(), size.height.toFloat()),
zoom, pan, bmp.width, bmp.height,
)
vm.toggleProbeAt(pos, rect)
vm.toggleProbeAt(pos, imageRect(currentSize(), zoom, pan, bmp))
}
},
) {
val rect = imageRect(
Size(size.width.toFloat(), size.height.toFloat()),
zoom, pan, bmp.width, bmp.height,
)
val rect = imageRect(size, zoom, pan, bmp)
drawImage(
image = img,
dstOffset = androidx.compose.ui.unit.IntOffset(
@@ -167,44 +151,67 @@ fun AnalyzeViewer(
),
dstSize = IntSize(rect.width.toInt(), rect.height.toInt()),
)
drawProbes(probes, rect, bmp.width, bmp.height)
// extremes, only when the photo carries temperature data
if (vm.hasTemperatureData) {
drawExtreme(vm.maxPos, rect, vm.imageW, vm.imageH, "")
drawExtreme(vm.minPos, rect, vm.imageW, vm.imageH, "")
}
drawProbes(vm.probes, rect, vm.imageW, vm.imageH)
}
}
// ---- side panel: the readouts the user asked to keep ----
Column(
modifier = Modifier
.width(132.dp)
.fillMaxHeight()
.background(Color(0xFF101010))
.padding(8.dp),
) {
Text("温度", color = Color.White, style = MaterialTheme.typography.labelLarge)
HorizontalDivider(Modifier.padding(vertical = 6.dp))
TempRow("最高", vm.maxTempC)
TempRow("最低", vm.minTempC)
TempRow("中心", vm.centerTempC)
HorizontalDivider(Modifier.padding(vertical = 6.dp))
Text(
"测温点 ${probes.size}",
color = Color.White,
style = MaterialTheme.typography.labelLarge,
)
Text(
"点击图像添加/删除",
color = Color.Gray,
style = MaterialTheme.typography.labelSmall,
)
probes.forEach { p ->
Text(
"${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 2.dp),
)
}
// ---- bottom measurement panel ----
Surface(
color = Color(0xFF1B1B20),
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Stat("最高", vm.maxTempC)
Stat("最低", vm.minTempC)
Stat("中心", vm.centerTempC)
}
saveResult?.let {
HorizontalDivider(Modifier.padding(vertical = 6.dp))
Text(it, color = Color(0xFF80FF80), style = MaterialTheme.typography.labelSmall)
HorizontalDivider(Modifier.padding(vertical = 6.dp), color = Color(0x33FFFFFF))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"测温点 ${vm.probes.size}",
color = Color.White,
style = MaterialTheme.typography.labelMedium,
)
Text(
if (vm.hasTemperatureData) " 点击图像添加/删除" else " 无温度数据,无法测温",
color = Color.Gray,
style = MaterialTheme.typography.labelSmall,
)
vm.probes.forEach { p ->
Text(
" ${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}",
color = Color(0xFFFFD54F),
style = MaterialTheme.typography.labelMedium,
)
}
}
toast?.let {
Text(
it,
color = Color(0xFF80FF80),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp),
)
LaunchedEffect(it) {
kotlinx.coroutines.delay(2500)
toast = null
}
}
}
}
@@ -215,7 +222,7 @@ fun AnalyzeViewer(
initial = note,
onSave = {
note = it
vm.saveNote(it) { ok -> saveResult = if (ok) "备注已保存" else "备注保存失败" }
vm.saveNote(it) { ok -> toast = if (ok) "备注已保存" else "备注保存失败" }
showNote = false
},
onDismiss = { showNote = false },
@@ -224,101 +231,131 @@ fun AnalyzeViewer(
if (showPalette) {
AlertDialog(
onDismissRequest = { showPalette = false },
title = { Text("调色板(仅影响显示提示,不改图)") },
title = { Text("调色板") },
text = {
Column {
Palettes.NAMES.forEachIndexed { idx, name ->
Text(
name,
color = if (idx == paletteIdx) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
vm.setPaletteIndex(idx)
showPalette = false
}
.padding(10.dp),
)
}
}
Text(
"仅作为显示参考记录;离线分析不改动已保存的照片。",
style = MaterialTheme.typography.bodySmall,
)
},
confirmButton = {
TextButton(onClick = { showPalette = false }) { Text("") }
},
confirmButton = {},
)
}
}
/** One number with its caption, for the bottom panel. */
@Composable
private fun TempRow(label: String, value: Float?) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
private fun Stat(label: String, value: Float?) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label, color = Color.Gray, style = MaterialTheme.typography.labelSmall)
Text(
value?.let { "%.1f℃".format(Locale.US, it) } ?: "--",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.titleMedium,
)
}
}
/**
* Rect the bitmap occupies. The saved photo is already rotated, so it is drawn
* 1:1 in its own orientation (no extra rotation here).
*/
private fun androidx.compose.ui.unit.IntSize.toSize(): Size =
Size(width.toFloat(), height.toFloat())
private fun imageRect(
size: Size,
zoom: Float,
pan: Offset,
bmpW: Int,
bmpH: Int,
bmp: android.graphics.Bitmap,
): androidx.compose.ui.geometry.Rect {
// fit the bitmap into the canvas, preserving aspect
val scale = minOf(size.width / bmpW, size.height / bmpH) * zoom
val w = bmpW * scale
val h = bmpH * scale
val scale = minOf(size.width / bmp.width, size.height / bmp.height) * zoom
val w = bmp.width * scale
val h = bmp.height * scale
val left = (size.width - w) / 2 + pan.x
val top = (size.height - h) / 2 + pan.y
return androidx.compose.ui.geometry.Rect(left, top, left + w, top + h)
}
/** White dot + ring + temperature label for every probe. */
/** Marker geometry in IMAGE pixels, mirroring PhotoSaver.MarkStyle. */
private class MarkerStyle(imageW: Int) {
val scale = imageW / 320f
val dotR = 2.6f * scale
val ringR = 5.5f * scale
val ringW = 1.4f * scale
val textSize = 9f * scale
val gap = 7f * scale
}
private fun DrawScope.markerScaleFactor(rect: androidx.compose.ui.geometry.Rect, imageW: Int): Float =
rect.width / imageW
private fun DrawScope.drawProbes(
probes: List<AnalyzeViewModel.Probe>,
rect: androidx.compose.ui.geometry.Rect,
bmpW: Int,
bmpH: Int,
imageW: Int,
imageH: Int,
) {
if (probes.isEmpty()) return
val k = markerScaleFactor(rect, imageW)
val st = MarkerStyle(imageW)
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = 12.sp.toPx()
textSize = st.textSize * k
isAntiAlias = true
setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK)
setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK)
}
val dot = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
isAntiAlias = true
setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK)
setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK)
}
for (p in probes) {
val dx = p.x.toFloat() / bmpW
val dy = p.y.toFloat() / bmpH
val cx = rect.left + dx * rect.width
val cy = rect.top + dy * rect.height
drawCircle(Color.White, 4.dp.toPx(), Offset(cx, cy))
drawCircle(
Color.White, 9.dp.toPx(), Offset(cx, cy),
style = androidx.compose.ui.graphics.drawscope.Stroke(2.dp.toPx()),
)
val cx = rect.left + (p.x + 0.5f) / imageW * rect.width
val cy = rect.top + (p.y + 0.5f) / imageH * rect.height
drawCircle(Color.White, st.dotR * k, Offset(cx, cy))
drawCircle(Color.White, st.ringR * k, Offset(cx, cy), style = Stroke(st.ringW * k))
val label = "${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}"
val tw = paint.measureText(label)
var tx = cx + 12.dp.toPx()
if (tx + tw > size.width - 2.dp.toPx()) tx = cx - 12.dp.toPx() - tw
drawIntoCanvas { canvas -> canvas.nativeCanvas.drawText(label, tx, cy + 4.dp.toPx(), paint) }
var tx = cx + (st.ringR + st.gap) * k
if (tx + tw > rect.right - 2f * k) tx = cx - (st.ringR + st.gap) * k - tw
val ty = cy + st.textSize * k * 0.4f
drawIntoCanvas { c -> c.nativeCanvas.drawText(label, tx, ty, paint) }
}
}
/** Small ring marking an overall extreme (max/min) position. */
private fun DrawScope.drawExtreme(
pos: Int,
rect: androidx.compose.ui.geometry.Rect,
imageW: Int,
imageH: Int,
label: String,
) {
if (pos < 0) return
val px = pos % imageW
val py = pos / imageW
if (py >= imageH) return
val k = markerScaleFactor(rect, imageW)
val st = MarkerStyle(imageW)
val cx = rect.left + (px + 0.5f) / imageW * rect.width
val cy = rect.top + (py + 0.5f) / imageH * rect.height
drawCircle(Color(0xFFFFD54F), st.ringR * 0.9f * k, Offset(cx, cy), style = Stroke(st.ringW * k))
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.rgb(255, 213, 79)
textSize = st.textSize * k
isAntiAlias = true
setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK)
}
drawIntoCanvas { c ->
c.nativeCanvas.drawText(label, cx + st.ringR * 1.3f * k, cy - st.ringR * 0.6f * k, paint)
}
}
/** Current canvas size, captured for the tap handler. */
private fun androidx.compose.ui.input.pointer.PointerInputScope.currentSize(): Size {
// PointerInputScope exposes `size` as IntSize
return Size(size.width.toFloat(), size.height.toFloat())
}
@Composable
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
var text by remember { mutableStateOf(initial) }
@@ -1,9 +1,6 @@
package com.mag160c.thermal.ui.gallery
import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,9 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,101 +30,77 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Button
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.ui.analyze.AnalyzeViewer
/**
* Photo grid (album tab). Behaves like a normal gallery: tap opens a full-screen
* viewer with pinch-zoom and delete. Temperature tools live in the analysis tab,
* which is why this screen no longer embeds the measurement UI.
*/
@Composable
fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
fun GalleryScreen(
vm: GalleryViewModel = viewModel(),
onOpen: (GalleryViewModel.Item) -> Unit = {},
) {
val items by vm.items.collectAsState()
val context = LocalContext.current
// The viewer must belong to the SAME ViewModel instance that the grid uses,
// so the selection made by a tap is the one the viewer opens.
var showViewer by remember { mutableStateOf(false) }
// runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE)
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
android.Manifest.permission.READ_MEDIA_IMAGES
else android.Manifest.permission.READ_EXTERNAL_STORAGE
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted -> vm.refresh() }
) { vm.refresh() }
LaunchedEffect(Unit) {
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) vm.refresh() else launcher.launch(perm)
}
// System back closes the viewer instead of leaving the tab (or the app).
androidx.activity.compose.BackHandler(enabled = showViewer) {
showViewer = false
vm.select(null)
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
Button(onClick = { vm.refresh() }) { Text("刷新") }
}
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(items.size) { idx ->
val item = items[idx]
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(item.name) {
vm.thumbnail(item) { b -> bmp = b }
}
Box(
modifier = Modifier
.aspectRatio(4f / 3f)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
vm.select(item)
showViewer = true
},
) {
val b = bmp
if (b != null) {
Image(
bitmap = b.asImageBitmap(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(4.dp)
.background(Color(0x88000000)),
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
Button(onClick = { vm.refresh() }) { Text("刷新") }
}
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(items.size) { idx ->
val item = items[idx]
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(item.name) {
vm.thumbnail(item) { b -> bmp = b }
}
Box(
modifier = Modifier
.aspectRatio(3f / 4f)
.padding(1.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onOpen(item) },
) {
val b = bmp
if (b != null) {
Image(
bitmap = b.asImageBitmap(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
// Overlay the viewer ON TOP of the grid: previously it was placed after a
// fillMaxSize() Column, which laid it out BELOW the visible area, so
// tapping a photo appeared to do nothing at all.
if (showViewer) {
val sel = vm.selected.collectAsState().value
if (sel != null) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
AnalyzeViewer(
item = sel,
galleryVm = vm,
onClose = {
showViewer = false
vm.select(null)
},
density = androidx.compose.ui.platform.LocalDensity.current.density,
// delete lives in the viewer (with confirmation), matching how
// normal gallery apps behave; no clutter on the grid itself
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(3.dp)
.background(Color(0x99000000)),
)
}
}
@@ -108,6 +108,33 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* Full-resolution image for the album viewer. Reads the container's embedded
* JPEG (the MDT file is not a plain JPEG, so the system decoder cannot be
* handed the file directly).
*/
fun fullImage(item: Item, onReady: (Bitmap) -> Unit) {
full[item.name]?.let { onReady(it); return }
viewModelScope.launch(Dispatchers.IO) {
val ctx = getApplication<Application>()
val bmp = runCatching {
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
val all = s.readBytes()
val parsed = Mdt.parse(all)
if (parsed != null) {
BitmapFactory.decodeByteArray(parsed.jpg, 0, parsed.jpg.size)
} else null
}
}.getOrNull()
if (bmp != null) {
full[item.name] = bmp
withContext(Dispatchers.Main) { onReady(bmp) }
}
}
}
private val full = ConcurrentHashMap<String, Bitmap>()
fun select(item: Item?) {
_selected.value = item
}
@@ -117,6 +144,7 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) {
val ctx = getApplication<Application>()
runCatching { ctx.contentResolver.delete(item.uri, null, null) }
thumbs.remove(item.name)
full.remove(item.name)
refresh()
}
}
@@ -0,0 +1,144 @@
package com.mag160c.thermal.ui.gallery
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.layout.ContentScale
import java.io.ByteArrayOutputStream
/**
* Plain full-screen photo viewer for the album tab: pinch to zoom, drag to pan,
* double-tap to reset, and delete. Deliberately free of measurement tools — that
* is what the analysis tab is for (the user asked the album to behave like a
* normal gallery app).
*/
@Composable
fun PhotoViewerScreen(
item: GalleryViewModel.Item,
vm: GalleryViewModel,
onClose: () -> Unit,
) {
val context = LocalContext.current
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) }
var askDelete by remember { mutableStateOf(false) }
LaunchedEffect(item.name) {
vm.fullImage(item) { b -> bmp = b }
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
val b = bmp
if (b != null) {
Image(
bitmap = b.asImageBitmap(),
contentDescription = item.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = zoom,
scaleY = zoom,
translationX = pan.x,
translationY = pan.y,
)
.pointerInput(item.name) {
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 8f)
pan += gesturePan
}
}
.pointerInput(item.name) {
detectTapGestures(
onDoubleTap = {
// reset, or zoom to 2x if already at rest
if (zoom > 1.05f) {
zoom = 1f; pan = Offset.Zero
} else {
zoom = 2f
}
},
)
},
)
} else {
Text("载入中…", color = Color.White, modifier = Modifier.align(Alignment.Center))
}
// top bar
Surface(
color = Color(0x99000000),
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onClose) { Text("返回") }
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
color = Color.White,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { askDelete = true }) {
Text("删除", color = Color(0xFFFF8A80))
}
}
}
Text(
"双指缩放 · 拖动平移 · 双击复位",
color = Color(0x99FFFFFF),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(12.dp),
)
}
if (askDelete) {
AlertDialog(
onDismissRequest = { askDelete = false },
title = { Text("删除照片") },
text = { Text("删除后无法恢复:${item.name}") },
confirmButton = {
TextButton(onClick = {
vm.delete(item)
askDelete = false
onClose()
}) { Text("删除") }
},
dismissButton = {
TextButton(onClick = { askDelete = false }) { Text("取消") }
},
)
}
}
@@ -323,31 +323,53 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
}
/**
* Capture: rendered JPEG + raw frame + camera info + probes -> MDT -> MediaStore.
* Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore.
*
* The saved JPEG is the SAME view the user sees: the sensor-frame flips and
* the locked/manual rotation are applied (previously the file kept the raw
* sensor orientation, so翻过来的照片和屏幕不一致), and the probe markers with
* their temperatures are burned in. The probes are also stored as data in the
* container so the analysis screen can reload and edit them.
* Three things must line up, or the offline analysis shows wrong numbers or
* misplaced markers (all three were broken on the device):
* 1. the JPEG is saved in the on-screen orientation (flips then rotation);
* 2. the NUC counts — the CALIBRATED data the live readouts use — are stored
* in that same photo pixel order, so an offline temperature lookup is a
* plain index and reproduces the live values (storing the raw sensor
* response instead gave 145 C / -161 C on a 30 C scene);
* 3. probe coordinates are converted to photo pixels too (they used to be
* stored as sensor coordinates and then drawn as if they were photo
* coordinates, which put the markers in the wrong place).
*/
fun capturePhoto(context: android.content.Context, density: Float = 2f) {
val frame = latestFrame ?: return
val s = session
val st = _state.value
val orientation = com.mag160c.thermal.media.PhotoSaver.Orientation(
rotateDeg = com.mag160c.thermal.ui.live.ImageTransform
.params(userRotateDeg, flipH, flipV).rotDeg,
flipH = flipH,
flipV = flipV,
)
// NUC counts: the CALIBRATED 160x120 data the live readouts use, expanded
// to one entry per PHOTO pixel so the offline lookup needs no index math.
val nuc160 = IntArray(19200)
val haveNuc = s.copyNuc(nuc160)
val nucPhoto = if (haveNuc) {
com.mag160c.thermal.media.PhotoSaver.buildPhotoOrderedCounts(nuc160, orientation)
} else null
// probes: sensor coordinates -> photo pixels
val marks = st.probes.mapNotNull { p ->
p.tempC?.let {
com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it)
p.tempC?.let { t ->
val pos = com.mag160c.thermal.media.PhotoSaver.sensorToImage(
p.x, p.y, 320, 240,
((orientation.rotateDeg % 360) + 360) % 360,
orientation,
)
com.mag160c.thermal.media.PhotoSaver.ProbeMark(pos[0], pos[1], p.label, t)
}
}
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered(
frame = frame,
orientation = com.mag160c.thermal.media.PhotoSaver.Orientation(
rotateDeg = com.mag160c.thermal.ui.live.ImageTransform
.params(userRotateDeg, flipH, flipV).rotDeg,
flipH = flipH,
flipV = flipV,
),
orientation = orientation,
probes = marks,
density = density,
)
@@ -366,11 +388,19 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
info1 = s.lastInfo1,
framePixels = pixels,
probes = probeBlock,
nucPixels = nucPhoto?.let {
com.mag160c.thermal.media.PhotoSaver.countsToBytes(it)
},
)
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
)
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail")
com.mag160c.thermal.media.DebugLog.log(
"vm",
"capture: nuc=${if (haveNuc) "yes" else "no"} probes=${marks.size} " +
"rot=${orientation.rotateDeg} saved=${saved != null}",
)
}
/**
@@ -0,0 +1,134 @@
package com.mag160c.thermal.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The NUC block is what makes offline measurement correct.
*
* Root cause fixed here (2026-09-11): the analysis screen converted the RAW sensor
* frame, which is pre-NUC data, so a 30 C scene reported 145 C max / -161 C min.
* The photo now carries the CALIBRATED counts in its own pixel order, and the
* lookup is a plain index — these tests pin that the re-ordering really is a
* bijection into the photo's layout and that no sample is lost or duplicated.
*/
class PhotoNucMappingTest {
/** Counts encoding a known position, so the mapping can be verified per pixel. */
private fun rampCounts(): IntArray = IntArray(160 * 120) { it }
@Test
fun nucBlockHasOneCountPerPhotoPixel() {
// 1:1 with the photo, so a lookup is counts[iy * photoW + ix] with no
// transforms. The first implementation filled only every 4th entry (a
// 160x120 grid scattered into a 320x240 photo), and every other lookup
// read 0 — which surfaced as -161 C in the analysis panel.
for (rot in intArrayOf(0, 90, 180, 270)) {
val out = PhotoSaver.buildPhotoOrderedCounts(
rampCounts(), PhotoSaver.Orientation(rot, false, false),
)
val expected = if (rot % 180 == 0) 320 * 240 else 240 * 320
assertEquals("dense block for rot=$rot", expected, out.size)
// Every pixel must carry a REAL sample: the 2x upscale makes each
// sensor sample appear ~4 times, so the distinct values must be exactly
// the sensor grid (a sparse/scattered fill would leave most entries at
// the default 0, which is what produced -161 C in the analysis panel).
val distinct = out.toSet()
assertEquals("all 19200 samples present for rot=$rot", 160 * 120, distinct.size)
assertEquals("no out-of-range samples", 160 * 120 - 1, distinct.max())
}
}
@Test
fun centreOfThePhotoHoldsTheCentreSensorSample() {
val out = PhotoSaver.buildPhotoOrderedCounts(
rampCounts(), PhotoSaver.Orientation(90, false, false),
)
val photoW = 240
val photoH = 320
val centre = out[(photoH / 2) * photoW + photoW / 2]
val mid = 160 * 120 / 2
assertTrue(
"centre value $centre should be near the ramp midpoint $mid",
centre in (mid - 4000)..(mid + 4000),
)
}
/**
* The mapping must agree with the marker positions burned into the image:
* a probe stored at photo pixel (x,y) must measure the sensor sample that the
* photo shows at (x,y). Verified by round-tripping through the same functions
* the capture path uses.
*/
@Test
fun probePhotoPixelMapsBackToItsSensorSample() {
for (rot in intArrayOf(0, 90, 180, 270)) {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val o = PhotoSaver.Orientation(rot, flipH, flipV)
val r = ((rot % 360) + 360) % 360
for (sx in intArrayOf(0, 40, 79, 120, 159)) {
for (sy in intArrayOf(0, 30, 59, 90, 119)) {
val photo = PhotoSaver.sensorToImage(sx, sy, 320, 240, r, o)
val back = PhotoSaver.photoToSensor(photo[0], photo[1], 320, 240, r, o)
assertTrue(
"rot=$rot flipH=$flipH flipV=$flipV sensor ($sx,$sy) -> " +
"photo (${photo[0]},${photo[1]}) -> sensor (${back[0]},${back[1]})",
kotlin.math.abs(back[0] - sx) <= 2 && kotlin.math.abs(back[1] - sy) <= 2,
)
}
}
}
}
}
}
@Test
fun countsByteRoundTrip() {
val counts = IntArray(19200) { (it * 7) and 0xFFFF }
val bytes = PhotoSaver.countsToBytes(counts)
assertEquals("two bytes per sample", 38400, bytes.size)
val back = PhotoSaver.bytesToCounts(bytes)
assertEquals(counts.size, back.size)
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
}
@Test
fun countsAreClampedToSixteenBits() {
val counts = intArrayOf(-5, 0, 65535, 70000, 100000)
val back = PhotoSaver.bytesToCounts(PhotoSaver.countsToBytes(counts))
assertEquals(0, back[0])
assertEquals(0, back[1])
assertEquals(65535, back[2])
assertEquals(65535, back[3])
assertEquals(65535, back[4])
}
@Test
fun nucBlockSurvivesAnMdtRoundTrip() {
val counts = IntArray(19200) { (it * 3) and 0xFFFF }
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 1, 2, 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
nucPixels = PhotoSaver.countsToBytes(counts),
)
val parsed = Mdt.parse(mdt)!!
assertTrue("photo must be measurable", parsed.hasTemperatureData)
val back = PhotoSaver.bytesToCounts(parsed.nucPixels!!)
assertEquals(19200, back.size)
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
}
@Test
fun photosWithoutNucAreReportedAsUnmeasurable() {
// older files (and plain captures) must NOT be silently measurable: the
// UI has to say "no temperature data" rather than print a wrong number
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
)
val parsed = Mdt.parse(mdt)!!
assertTrue("no NUC block -> no temperature data", !parsed.hasTemperatureData)
}
}