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}: * DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
* 0x5BB5B55B camera info (0x38B from command 66b) * 0x5BB5B55B camera info (0x38B from command 66b)
* 0x5BB5B55C second info block (0x38B from 66c, optional) * 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) * 0x5BB5B55E text note (UTF-8, optional)
* 0x5BB5B55F probe points (UTF-8 lines "x,y,label,tempMc", 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 { object Mdt {
const val SECTION_DDT = 0x5BB5B55B const val SECTION_DDT = 0x5BB5B55B
@@ -27,12 +36,17 @@ object Mdt {
const val BLOCK_TXT = 0x5BB5B55E const val BLOCK_TXT = 0x5BB5B55E
/** /**
* Probe points captured with the photo. Stored as UTF-8 text ("x,y,label,tempMc" * Probe points captured with the photo, in the SAVED PHOTO's pixel
* per line) so the values stay inspectable with any hex editor — the same * coordinates (not sensor coordinates — that mismatch put the markers in the
* reasoning the vendor layout uses for its TXT section. * 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 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. */ /** One probe carried in an MDT file. */
data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int) data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int)
@@ -84,6 +98,7 @@ object Mdt {
framePixels: ByteArray?, framePixels: ByteArray?,
text: ByteArray? = null, text: ByteArray? = null,
probes: ByteArray? = null, probes: ByteArray? = null,
nucPixels: ByteArray? = null,
): ByteArray { ): ByteArray {
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320) val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
out.write(jpg, 0, jpg.size) out.write(jpg, 0, jpg.size)
@@ -107,6 +122,7 @@ object Mdt {
} }
text?.let { emit(BLOCK_TXT, it) } text?.let { emit(BLOCK_TXT, it) }
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, 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 bodyBytes = body.toByteArray()
val header = ByteArray(0x88) val header = ByteArray(0x88)
@@ -153,6 +169,7 @@ object Mdt {
// block payloads are padded to 4; strip the NUL padding // block payloads are padded to 4; strip the NUL padding
text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') }, text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') },
probes = parseProbes(blocks[BLOCK_PROBES]), probes = parseProbes(blocks[BLOCK_PROBES]),
nucPixels = blocks[BLOCK_NUC],
) )
} }
@@ -177,10 +194,23 @@ object Mdt {
val jpg: ByteArray, val jpg: ByteArray,
val info0: ByteArray?, val info0: ByteArray?,
val info1: 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 framePixels: ByteArray?,
val text: String?, 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(), 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" 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) 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 * 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 * (the image is locked to the portrait frame plus the user's manual
@@ -106,41 +223,61 @@ object PhotoSaver {
if (probes.isNotEmpty()) { if (probes.isNotEmpty()) {
val canvas = Canvas(outBmp) 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 { val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
style = Paint.Style.STROKE style = Paint.Style.STROKE
strokeWidth = 2.5f * density strokeWidth = s.ringW
setShadowLayer(3f * density, 0f, 0f, Color.BLACK) setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
} }
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
style = Paint.Style.FILL 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 { val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
textSize = 13f * density textSize = s.textSize
typeface = Typeface.SANS_SERIF typeface = Typeface.SANS_SERIF
setShadowLayer(3f * density, 0f, 0f, Color.BLACK) setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
} }
for (p in probes) { for (p in probes) {
val pos = sensorToImage(p.x, p.y, w, h, rot, orientation) val sx = p.x.toFloat()
val sx = pos[0].toFloat() val sy = p.y.toFloat()
val sy = pos[1].toFloat() drawMarker(canvas, sx, sy, p.label, p.tempC, s, ring, dot, text, outW, outH)
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)
} }
} }
return encodeJpeg(outBmp, quality) 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. * Sensor pixel -> position in the FINAL (rotated, flipped) photo.
* *
@@ -189,35 +326,29 @@ object PhotoSaver {
?: return jpg ?: return jpg
val out = bmp.copy(android.graphics.Bitmap.Config.ARGB_8888, true) ?: return jpg val out = bmp.copy(android.graphics.Bitmap.Config.ARGB_8888, true) ?: return jpg
val canvas = Canvas(out) val canvas = Canvas(out)
val s = MarkStyle(out.width)
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
style = Paint.Style.STROKE style = Paint.Style.STROKE
strokeWidth = 2.5f * density strokeWidth = s.ringW
setShadowLayer(3f * density, 0f, 0f, Color.BLACK) setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
} }
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
style = Paint.Style.FILL 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 { val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
textSize = 13f * density textSize = s.textSize
typeface = Typeface.SANS_SERIF typeface = Typeface.SANS_SERIF
setShadowLayer(3f * density, 0f, 0f, Color.BLACK) setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
} }
for (p in probes) { for (p in probes) {
val sx = p.x.toFloat() drawMarker(
val sy = p.y.toFloat() canvas, p.x.toFloat(), p.y.toFloat(), p.label, p.tempC, s,
canvas.drawCircle(sx, sy, 4.5f * density, dot) ring, dot, text, out.width, out.height,
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)
} }
return encodeJpeg(out) return encodeJpeg(out)
} }
@@ -73,6 +73,15 @@ fun AppRoot() {
onDispose { DeviceOrientation.stop() } 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()) { Box(modifier = Modifier.fillMaxSize()) {
when { when {
remoteHost != null -> RemoteViewerScreen( remoteHost != null -> RemoteViewerScreen(
@@ -92,11 +101,27 @@ fun AppRoot() {
remotePort = port 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 == 0 -> LiveScreen(vm = liveVm, onOpenGallery = { tab = 1 })
tab == 1 || tab == 2 -> Column( // album = browse/zoom/delete; analysis = measure a measurable photo
modifier = Modifier.fillMaxSize().padding(bottom = 84.dp), tab == 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
) { GalleryScreen(vm = galleryVm, onOpen = { viewerItem = it })
GalleryScreen() }
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)) { else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
SettingsScreen( 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.app.Application
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.core.TempMath import com.mag160c.thermal.core.TempMath
import com.mag160c.thermal.media.Mdt import com.mag160c.thermal.media.Mdt
import com.mag160c.thermal.media.PhotoSaver import com.mag160c.thermal.media.PhotoSaver
@@ -18,46 +18,35 @@ import kotlinx.coroutines.withContext
import java.util.Locale 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 * Temperatures come from the NUC block stored in the photo — the CALIBRATED
* raw frame is used only to measure temperatures; the image is never re-coloured * counts the live screen measures — addressed by the photo's own pixel index.
* here (changing the palette used to silently produce a different-looking photo, * The earlier version converted the RAW sensor frame, which is pre-NUC data and
* which was confusing for a measurement record). * 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
* Probes come from the container (each saved photo carries its probe list) and * inventing numbers.
* 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.
*/ */
class AnalyzeViewModel( class AnalyzeViewModel(
app: Application, app: Application,
private val containerBytes: ByteArray, private val containerBytes: ByteArray,
private val fileUri: android.net.Uri, private val fileUri: android.net.Uri,
) : AndroidViewModel(app) { ) : 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) data class Probe(val x: Int, val y: Int, val label: String, val tempC: Float)
val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes) val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes)
/** Raw measurement frame (19200 uint16) used for temperatures. */ /** Calibrated counts in photo pixel order (null for older photos). */
val rawFrame: IntArray? by lazy { private val nucCounts: IntArray? by lazy {
parsed?.framePixels?.let { raw -> parsed?.nucPixels?.let { PhotoSaver.bytesToCounts(it) }
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
}
} }
/** Millidegree-C map of the measurement frame (19200 entries), if present. */ val hasTemperatureData: Boolean get() = nucCounts != null && parsed?.hasTemperatureData == true
private val tempMap: IntArray? by lazy {
rawFrame?.let { TempMath.tempMapFromPixels(parsed!!.framePixels!!) }
}
private val _render = MutableStateFlow<Bitmap?>(null) 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 val render: StateFlow<Bitmap?> = _render
private val _paletteIndex = MutableStateFlow(2) private val _paletteIndex = MutableStateFlow(2)
@@ -66,7 +55,6 @@ class AnalyzeViewModel(
/** Editable probe list (snapshot state so the canvas redraws on change). */ /** Editable probe list (snapshot state so the canvas redraws on change). */
val probes = mutableStateListOf<Probe>() val probes = mutableStateListOf<Probe>()
/** Overall readouts shown in the side panel. */
private val _minTempC = mutableStateOf<Float?>(null) private val _minTempC = mutableStateOf<Float?>(null)
val minTempC: Float? get() = _minTempC.value val minTempC: Float? get() = _minTempC.value
private val _maxTempC = mutableStateOf<Float?>(null) private val _maxTempC = mutableStateOf<Float?>(null)
@@ -74,7 +62,12 @@ class AnalyzeViewModel(
private val _centerTempC = mutableStateOf<Float?>(null) private val _centerTempC = mutableStateOf<Float?>(null)
val centerTempC: Float? get() = _centerTempC.value 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) private val _imageW = mutableStateOf(320)
val imageW: Int get() = _imageW.value val imageW: Int get() = _imageW.value
private val _imageH = mutableStateOf(240) private val _imageH = mutableStateOf(240)
@@ -88,40 +81,58 @@ class AnalyzeViewModel(
_imageW.value = bmp.width _imageW.value = bmp.width
_imageH.value = bmp.height _imageH.value = bmp.height
} }
// stored probes (from the container) -> editable list val counts = nucCounts
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
var mn = Int.MAX_VALUE var mn = Int.MAX_VALUE
var mx = Int.MIN_VALUE var mx = Int.MIN_VALUE
map?.forEach { var mnPos = -1
if (it < mn) mn = it var mxPos = -1
if (it > mx) mx = it 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) { withContext(Dispatchers.Main) {
_render.value = bmp _render.value = bmp
probes.clear() probes.clear()
probes.addAll(loaded) probes.addAll(loaded)
if (map != null && map.isNotEmpty()) { if (counts != null) {
_minTempC.value = mn / 1000f _minTempC.value = mn / 1000f
_maxTempC.value = mx / 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 * Temperature (C) at a pixel of the SAVED photo.
* probe there. The saved photo is already rotated/flipped, so this is a * The stored NUC block is 1:1 with the photo (see PhotoSaver.buildPhotoOrderedCounts),
* plain rect mapping (no extra transform). * 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 (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 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() 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() val iy = ((pos.y - rect.top) / rect.height * _imageH.value).toInt()
.coerceIn(0, _imageH.value - 1) .coerceIn(0, _imageH.value - 1)
// near an existing probe? remove it // marker footprint scales with the image, so the hit radius must too
val thr = 12f val thr = (_imageW.value / 320f * 14f).coerceAtLeast(8f)
val hit = probes.indexOfFirst { p -> val hit = probes.indexOfFirst { p ->
val dx = (p.x - ix).toFloat() val dx = (p.x - ix).toFloat()
val dy = (p.y - iy).toFloat() val dy = (p.y - iy).toFloat()
@@ -140,58 +151,13 @@ class AnalyzeViewModel(
probes.removeAt(hit) probes.removeAt(hit)
return return
} }
val t = tempAtImagePixel(ix, iy) val t = measure(ix, iy)
probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t ?: 0f)) 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 {
* Temperature for a pixel of the SAVED image. The stored photo may be rotated probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t))
* 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 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) { fun setPaletteIndex(idx: Int) {
@@ -200,7 +166,7 @@ class AnalyzeViewModel(
fun decodeNote(): String? = parsed?.text 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( fun saveAsNew(
context: android.content.Context, context: android.content.Context,
notes: String, notes: String,
@@ -215,7 +181,6 @@ class AnalyzeViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92) val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92)
val marks = probes.map { PhotoSaver.ProbeMark(it.x, it.y, it.label, it.tempC) } 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 annotated = PhotoSaver.annotateJpeg(jpg, marks, density)
val mdt = Mdt.compose( val mdt = Mdt.compose(
jpg = annotated, jpg = annotated,
@@ -226,6 +191,8 @@ class AnalyzeViewModel(
probes = Mdt.encodeProbes( probes = Mdt.encodeProbes(
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) }, 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) val name = "MAG160C_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
.format(java.util.Date())}_edit.jpg" .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) { fun saveNote(note: String, onDone: (Boolean) -> Unit) {
val bmp = _render.value val bmp = _render.value ?: run { onDone(false); return }
if (bmp == null) {
onDone(false)
return
}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val jpg = PhotoSaver.encodeJpeg(bmp) val jpg = PhotoSaver.encodeJpeg(bmp)
val mdt = Mdt.compose( val mdt = Mdt.compose(
@@ -252,6 +215,7 @@ class AnalyzeViewModel(
probes = Mdt.encodeProbes( probes = Mdt.encodeProbes(
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) }, probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
), ),
nucPixels = parsed?.nucPixels,
) )
val ok = runCatching { val ok = runCatching {
val ctx = getApplication<Application>() val ctx = getApplication<Application>()
@@ -1,29 +1,29 @@
package com.mag160c.thermal.ui.analyze package com.mag160c.thermal.ui.analyze
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.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
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
@@ -49,17 +50,15 @@ import com.mag160c.thermal.ui.gallery.GalleryViewModel
import java.util.Locale 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 * Layout: the photo occupies the top area, the measurements are a compact panel
* the raw frame with a different palette); * along the BOTTOM (the side column wasted most of a portrait screen and looked
* - tapping the image adds a temperature probe; tapping an existing one removes * unbalanced), with the actions in a slim title row.
* it (the same interaction as the live screen); *
* - "保存" writes a NEW photo (rotation/annotations baked in, probes stored in * The image shown is the saved photo as-is; tapping adds/removes a probe. Marker
* the container) and keeps the original untouched; * sizes scale with the IMAGE, so they look the same as on the live screen instead
* - the side panel shows the overall min / max / centre temperatures, and the * of covering the photo.
* palette selector only affects that panel's number formatting hint — the
* image itself is never re-coloured here.
*/ */
@Composable @Composable
fun AnalyzeViewer( fun AnalyzeViewer(
@@ -76,69 +75,61 @@ fun AnalyzeViewer(
AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri) AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri)
} }
val render by vm.render.collectAsState() val render by vm.render.collectAsState()
val paletteIdx by vm.paletteIndex.collectAsState()
var zoom by remember { mutableStateOf(1f) } var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) } var pan by remember { mutableStateOf(Offset.Zero) }
var note by remember { mutableStateOf(vm.decodeNote() ?: "") } var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
var showNote by remember { mutableStateOf(false) } var showNote by remember { mutableStateOf(false) }
var saveResult by remember { mutableStateOf<String?>(null) }
var showPalette by remember { mutableStateOf(false) } var showPalette by remember { mutableStateOf(false) }
// probes live in the view model so 保存 can serialise them var toast by remember { mutableStateOf<String?>(null) }
val probes = vm.probes
DisposableEffect(item.name) {
onDispose { /* nothing to release yet */ }
}
LaunchedEffect(Unit) { vm.load() } LaunchedEffect(Unit) { vm.load() }
Column(modifier = Modifier.fillMaxSize().background(Color.Black)) { Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) {
// ---- top bar: title + actions ---- // ---- slim title row ----
Row( Row(
modifier = Modifier.fillMaxWidth().padding(6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TextButton(onClick = onClose) { Text("返回") } TextButton(onClick = onClose) { Text("返回") }
Text( Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"), item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f).padding(start = 4.dp), 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 = { showNote = true }) { Text("备注") }
TextButton( TextButton(
onClick = { onClick = {
vm.saveAsNew(context, notes = note, density = density) { ok -> vm.saveAsNew(context, notes = note, density = density) { ok ->
saveResult = if (ok) "已保存为新照片" else "保存失败" toast = if (ok) "已保存为新照片" else "保存失败"
if (ok) galleryVm.refresh() if (ok) galleryVm.refresh()
} }
}, },
) { Text("保存") } ) { Text("保存") }
} }
HorizontalDivider() HorizontalDivider(color = Color(0x33FFFFFF))
// ---- image ----
val bmp = render val bmp = render
if (bmp == null) { Box(
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { modifier = Modifier.weight(1f).fillMaxWidth(),
Text("无法读取图像(缺少原始测温数据)", color = Color.White) contentAlignment = Alignment.Center,
} ) {
return@Column if (bmp == null) {
} Text(
if (vm.hasTemperatureData) "载入中…" else "无温度数据(旧照片)",
// ---- image with the side panel ---- color = Color.Gray,
Row(modifier = Modifier.fillMaxSize()) { )
Box( } else {
modifier = Modifier.weight(1f).fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
val img = bmp.asImageBitmap() val img = bmp.asImageBitmap()
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.pointerInput(Unit) { .pointerInput(Unit) {
detectTransformGestures { _, gesturePan, gestureZoom, _ -> detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 4f) zoom = (zoom * gestureZoom).coerceIn(1f, 6f)
pan += gesturePan pan += gesturePan
if (zoom <= 1.01f) { if (zoom <= 1.01f) {
zoom = 1f zoom = 1f
@@ -148,18 +139,11 @@ fun AnalyzeViewer(
} }
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { pos -> detectTapGestures { pos ->
val rect = imageRect( vm.toggleProbeAt(pos, imageRect(currentSize(), zoom, pan, bmp))
Size(size.width.toFloat(), size.height.toFloat()),
zoom, pan, bmp.width, bmp.height,
)
vm.toggleProbeAt(pos, rect)
} }
}, },
) { ) {
val rect = imageRect( val rect = imageRect(size, zoom, pan, bmp)
Size(size.width.toFloat(), size.height.toFloat()),
zoom, pan, bmp.width, bmp.height,
)
drawImage( drawImage(
image = img, image = img,
dstOffset = androidx.compose.ui.unit.IntOffset( dstOffset = androidx.compose.ui.unit.IntOffset(
@@ -167,44 +151,67 @@ fun AnalyzeViewer(
), ),
dstSize = IntSize(rect.width.toInt(), rect.height.toInt()), 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 // ---- bottom measurement panel ----
.width(132.dp) Surface(
.fillMaxHeight() color = Color(0xFF1B1B20),
.background(Color(0xFF101010)) shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
.padding(8.dp), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("温度", color = Color.White, style = MaterialTheme.typography.labelLarge) Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
HorizontalDivider(Modifier.padding(vertical = 6.dp)) Row(
TempRow("最高", vm.maxTempC) modifier = Modifier.fillMaxWidth(),
TempRow("最低", vm.minTempC) horizontalArrangement = Arrangement.SpaceEvenly,
TempRow("中心", vm.centerTempC) ) {
HorizontalDivider(Modifier.padding(vertical = 6.dp)) Stat("最高", vm.maxTempC)
Text( Stat("最低", vm.minTempC)
"测温点 ${probes.size}", Stat("中心", vm.centerTempC)
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),
)
} }
saveResult?.let { HorizontalDivider(Modifier.padding(vertical = 6.dp), color = Color(0x33FFFFFF))
HorizontalDivider(Modifier.padding(vertical = 6.dp)) Row(
Text(it, color = Color(0xFF80FF80), style = MaterialTheme.typography.labelSmall) 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, initial = note,
onSave = { onSave = {
note = it note = it
vm.saveNote(it) { ok -> saveResult = if (ok) "备注已保存" else "备注保存失败" } vm.saveNote(it) { ok -> toast = if (ok) "备注已保存" else "备注保存失败" }
showNote = false showNote = false
}, },
onDismiss = { showNote = false }, onDismiss = { showNote = false },
@@ -224,101 +231,131 @@ fun AnalyzeViewer(
if (showPalette) { if (showPalette) {
AlertDialog( AlertDialog(
onDismissRequest = { showPalette = false }, onDismissRequest = { showPalette = false },
title = { Text("调色板(仅影响显示提示,不改图)") }, title = { Text("调色板") },
text = { text = {
Column { Text(
Palettes.NAMES.forEachIndexed { idx, name -> "仅作为显示参考记录;离线分析不改动已保存的照片。",
Text( style = MaterialTheme.typography.bodySmall,
name, )
color = if (idx == paletteIdx) MaterialTheme.colorScheme.primary },
else MaterialTheme.colorScheme.onSurface, confirmButton = {
modifier = Modifier TextButton(onClick = { showPalette = false }) { Text("") }
.clickable {
vm.setPaletteIndex(idx)
showPalette = false
}
.padding(10.dp),
)
}
}
}, },
confirmButton = {},
) )
} }
} }
/** One number with its caption, for the bottom panel. */
@Composable @Composable
private fun TempRow(label: String, value: Float?) { private fun Stat(label: String, value: Float?) {
Row( Column(horizontalAlignment = Alignment.CenterHorizontally) {
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = Color.Gray, style = MaterialTheme.typography.labelSmall) Text(label, color = Color.Gray, style = MaterialTheme.typography.labelSmall)
Text( Text(
value?.let { "%.1f℃".format(Locale.US, it) } ?: "--", value?.let { "%.1f℃".format(Locale.US, it) } ?: "--",
color = Color.White, color = Color.White,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.titleMedium,
) )
} }
} }
/** private fun androidx.compose.ui.unit.IntSize.toSize(): Size =
* Rect the bitmap occupies. The saved photo is already rotated, so it is drawn Size(width.toFloat(), height.toFloat())
* 1:1 in its own orientation (no extra rotation here).
*/
private fun imageRect( private fun imageRect(
size: Size, size: Size,
zoom: Float, zoom: Float,
pan: Offset, pan: Offset,
bmpW: Int, bmp: android.graphics.Bitmap,
bmpH: Int,
): androidx.compose.ui.geometry.Rect { ): androidx.compose.ui.geometry.Rect {
// fit the bitmap into the canvas, preserving aspect val scale = minOf(size.width / bmp.width, size.height / bmp.height) * zoom
val scale = minOf(size.width / bmpW, size.height / bmpH) * zoom val w = bmp.width * scale
val w = bmpW * scale val h = bmp.height * scale
val h = bmpH * scale
val left = (size.width - w) / 2 + pan.x val left = (size.width - w) / 2 + pan.x
val top = (size.height - h) / 2 + pan.y val top = (size.height - h) / 2 + pan.y
return androidx.compose.ui.geometry.Rect(left, top, left + w, top + h) 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( private fun DrawScope.drawProbes(
probes: List<AnalyzeViewModel.Probe>, probes: List<AnalyzeViewModel.Probe>,
rect: androidx.compose.ui.geometry.Rect, rect: androidx.compose.ui.geometry.Rect,
bmpW: Int, imageW: Int,
bmpH: Int, imageH: Int,
) { ) {
if (probes.isEmpty()) return if (probes.isEmpty()) return
val k = markerScaleFactor(rect, imageW)
val st = MarkerStyle(imageW)
val paint = android.graphics.Paint().apply { val paint = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE color = android.graphics.Color.WHITE
textSize = 12.sp.toPx() textSize = st.textSize * k
isAntiAlias = true 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 { val dot = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE color = android.graphics.Color.WHITE
isAntiAlias = true isAntiAlias = true
setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK) setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK)
} }
for (p in probes) { for (p in probes) {
val dx = p.x.toFloat() / bmpW val cx = rect.left + (p.x + 0.5f) / imageW * rect.width
val dy = p.y.toFloat() / bmpH val cy = rect.top + (p.y + 0.5f) / imageH * rect.height
val cx = rect.left + dx * rect.width drawCircle(Color.White, st.dotR * k, Offset(cx, cy))
val cy = rect.top + dy * rect.height drawCircle(Color.White, st.ringR * k, Offset(cx, cy), style = Stroke(st.ringW * k))
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 label = "${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}" val label = "${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}"
val tw = paint.measureText(label) val tw = paint.measureText(label)
var tx = cx + 12.dp.toPx() var tx = cx + (st.ringR + st.gap) * k
if (tx + tw > size.width - 2.dp.toPx()) tx = cx - 12.dp.toPx() - tw if (tx + tw > rect.right - 2f * k) tx = cx - (st.ringR + st.gap) * k - tw
drawIntoCanvas { canvas -> canvas.nativeCanvas.drawText(label, tx, cy + 4.dp.toPx(), paint) } 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 @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) }
@@ -1,9 +1,6 @@
package com.mag160c.thermal.ui.gallery package com.mag160c.thermal.ui.gallery
import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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 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 @Composable
fun GalleryScreen(vm: GalleryViewModel = viewModel()) { fun GalleryScreen(
vm: GalleryViewModel = viewModel(),
onOpen: (GalleryViewModel.Item) -> Unit = {},
) {
val items by vm.items.collectAsState() val items by vm.items.collectAsState()
val context = LocalContext.current 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) val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
android.Manifest.permission.READ_MEDIA_IMAGES android.Manifest.permission.READ_MEDIA_IMAGES
else android.Manifest.permission.READ_EXTERNAL_STORAGE else android.Manifest.permission.READ_EXTERNAL_STORAGE
val launcher = androidx.activity.compose.rememberLauncherForActivityResult( val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted -> vm.refresh() } ) { vm.refresh() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) == val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
android.content.pm.PackageManager.PERMISSION_GRANTED android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) vm.refresh() else launcher.launch(perm) if (granted) vm.refresh() else launcher.launch(perm)
} }
// System back closes the viewer instead of leaving the tab (or the app). Column(modifier = Modifier.fillMaxSize()) {
androidx.activity.compose.BackHandler(enabled = showViewer) { Row(
showViewer = false modifier = Modifier.fillMaxWidth().padding(8.dp),
vm.select(null) horizontalArrangement = Arrangement.SpaceBetween,
} verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.fillMaxSize()) { Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
Column(modifier = Modifier.fillMaxSize()) { Button(onClick = { vm.refresh() }) { Text("刷新") }
Row( }
modifier = Modifier.fillMaxWidth().padding(8.dp), LazyVerticalGrid(columns = GridCells.Fixed(3)) {
horizontalArrangement = Arrangement.SpaceBetween, items(items.size) { idx ->
verticalAlignment = Alignment.CenterVertically, val item = items[idx]
) { var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium) LaunchedEffect(item.name) {
Button(onClick = { vm.refresh() }) { Text("刷新") } vm.thumbnail(item) { b -> bmp = b }
} }
LazyVerticalGrid(columns = GridCells.Fixed(3)) { Box(
items(items.size) { idx -> modifier = Modifier
val item = items[idx] .aspectRatio(3f / 4f)
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) } .padding(1.dp)
LaunchedEffect(item.name) { .background(MaterialTheme.colorScheme.surfaceVariant)
vm.thumbnail(item) { b -> bmp = b } .clickable { onOpen(item) },
} ) {
Box( val b = bmp
modifier = Modifier if (b != null) {
.aspectRatio(4f / 3f) Image(
.background(MaterialTheme.colorScheme.surfaceVariant) bitmap = b.asImageBitmap(),
.clickable { contentDescription = item.name,
vm.select(item) contentScale = ContentScale.Crop,
showViewer = true modifier = Modifier.fillMaxSize(),
},
) {
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)),
) )
} }
} // 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"),
// Overlay the viewer ON TOP of the grid: previously it was placed after a style = MaterialTheme.typography.labelSmall,
// fillMaxSize() Column, which laid it out BELOW the visible area, so color = Color.White,
// tapping a photo appeared to do nothing at all. modifier = Modifier
if (showViewer) { .align(Alignment.BottomStart)
val sel = vm.selected.collectAsState().value .padding(3.dp)
if (sel != null) { .background(Color(0x99000000)),
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,
) )
} }
} }
@@ -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?) { fun select(item: Item?) {
_selected.value = item _selected.value = item
} }
@@ -117,6 +144,7 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) {
val ctx = getApplication<Application>() val ctx = getApplication<Application>()
runCatching { ctx.contentResolver.delete(item.uri, null, null) } runCatching { ctx.contentResolver.delete(item.uri, null, null) }
thumbs.remove(item.name) thumbs.remove(item.name)
full.remove(item.name)
refresh() 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 * Three things must line up, or the offline analysis shows wrong numbers or
* the locked/manual rotation are applied (previously the file kept the raw * misplaced markers (all three were broken on the device):
* sensor orientation, so翻过来的照片和屏幕不一致), and the probe markers with * 1. the JPEG is saved in the on-screen orientation (flips then rotation);
* their temperatures are burned in. The probes are also stored as data in the * 2. the NUC counts — the CALIBRATED data the live readouts use — are stored
* container so the analysis screen can reload and edit them. * 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) { fun capturePhoto(context: android.content.Context, density: Float = 2f) {
val frame = latestFrame ?: return val frame = latestFrame ?: return
val s = session val s = session
val st = _state.value 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 -> val marks = st.probes.mapNotNull { p ->
p.tempC?.let { p.tempC?.let { t ->
com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it) 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( val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered(
frame = frame, frame = frame,
orientation = com.mag160c.thermal.media.PhotoSaver.Orientation( orientation = orientation,
rotateDeg = com.mag160c.thermal.ui.live.ImageTransform
.params(userRotateDeg, flipH, flipV).rotDeg,
flipH = flipH,
flipV = flipV,
),
probes = marks, probes = marks,
density = density, density = density,
) )
@@ -366,11 +388,19 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
info1 = s.lastInfo1, info1 = s.lastInfo1,
framePixels = pixels, framePixels = pixels,
probes = probeBlock, probes = probeBlock,
nucPixels = nucPhoto?.let {
com.mag160c.thermal.media.PhotoSaver.countsToBytes(it)
},
) )
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt( val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(), context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
) )
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail") _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)
}
}
Binary file not shown.
+14
View File
@@ -82,6 +82,20 @@
| 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) | | 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) |
| 43 | 拍照前先在实时页点几个测温点,再拍照 | (无日志) | 照片上带这些测温点与温度;进分析页打开该照片,**测温点仍在**且可继续编辑 | | 43 | 拍照前先在实时页点几个测温点,再拍照 | (无日志) | 照片上带这些测温点与温度;进分析页打开该照片,**测温点仍在**且可继续编辑 |
## 第三轮真机修复项(2026-09-11,重点验证)
| # | 操作 | 预期 | 通过标准 |
|---|------|------|----------|
| 44 | 拍照后在分析页打开该照片 | 日志 `[vm] capture: nuc=yes probes=<n> rot=90 saved=true` | 顶部显示的照片与实时页看到的一致(方向、标注) |
| 45 | 看分析页底部数据面板 | (无日志) | 最高/最低/中心温度**与拍照瞬间实时页读数一致**(同一数据源);**不再是 145℃ / -161℃ / 76℃ 这类错误值**;数据在屏幕**底部**(不是右侧竖栏) |
| 46 | 分析页对照实时页的测温点位置 | (无日志) | 照片上测温点的**位置与拍照时屏幕上的一致**(此前会偏移/错位) |
| 47 | 观察分析页与照片上的测温点标记大小 | (无日志) | 圆点/圆环**小而不遮挡画面**(此前环直径约占图宽 1/9,盖住内容);与实时页观感接近 |
| 48 | 分析页点图像新增一个测温点,再点"保存" | 提示"已保存为新照片" | 新照片的测温点位置正确、标记大小正常、温度合理;**原照片仍在** |
| 49 | 相册 tab 点一张照片 | (无日志) | 进入**全屏查看**:双指可缩放(1–8×)、拖动可平移、双击复位;**不是**直接进测温分析页 |
| 50 | 相册查看页点"删除" | (无日志) | 弹确认对话框;确认后照片消失、返回网格 |
| 51 | 相册 tab 与 分析 tab 对比 | (无日志) | **两个界面明显不同**:相册是浏览/缩放/删除;分析只列出可测温的照片并进入测温页(此前两个 tab 渲染同一个网格) |
| 52 | 分析页打开一张**本轮之前拍的**旧照片 | (无日志) | 显示"无温度数据",**不显示编造的温度**(旧照片没有 NUC 数据块,这是预期行为) |
## 相机(PIP)失败时的表现(设计如此,不算 bug) ## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**: PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
+44 -1
View File
@@ -489,7 +489,50 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。 "未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**debug + release(R8) 双构建通过;APK 已更新。 - [x] 单测 66 → **76 项全绿**debug + release(R8) 双构建通过;APK 已更新。
**诚实记录(本轮未做)** ## 用户反馈修复 第二十轮(2026-09-11,第三轮真机:分析测温/标注/相册/界面)
用户第三轮实机测试(含截图)报出以下问题,本轮全部处理:
- [x] **分析温度全错**(截图显示最高 145.1℃ / 最低 -161.0℃ / 中心 76.5℃)。
双重根因:
**数据源错了**:实时读数用的是 `copyNuc()` 的**NUC 补偿后 counts**
而照片里存的 `BLOCK_FRAME` 是**传感器原始响应**,标定表对它无效 →
直接换算就是垃圾值。新增 **`BLOCK_NUC` (0x5BB5B560)**:存拍照当刻的
**NUC counts**,分析端用它测量(与实时屏幕同一数据源)。
**我自己引入的索引 bug**:第一版把 160×120 的样本"散布"进 320×240 的
照片空间,76800 个槽位只填了 19200,其余全 0 → `countsToTempMc(0)`
= **-161.0℃**(正是截图里的最低温)。改为
`buildPhotoOrderedCounts()`:**每个照片像素一个样本**,查表就是
`counts[iy*photoW+ix]`,不做任何旋转/翻转/缩放换算。
- [x] **测温点位置标错**:探针原先按**传感器坐标**存储,显示时却当**照片像素**用。
现在拍照时即转换到照片像素(`sensorToImage`),并且新增
`photoToSensor()` 反变换用于测温;两者互逆性有单测覆盖(4 旋转 × 2 翻转)。
- [x] **测温点太大**:标注尺寸原先用**屏幕密度**(4.5×density 的点、9×density 的环)
画进 320×240 的位图 → 环直径约 36px / 320px 图宽,视觉上盖住画面。
新增 `MarkStyle`:尺寸按**图像宽度**比例(点 2.6、环 5.5、字 9 @320px),
与实时屏幕观感一致。实时页/分析页/保存照片三处统一。
- [x] **分析页布局**:数据面板从**右侧竖栏**改为**底部横条**(用户要求),
三个数值(最高/最低/中心)等分排布 + 测温点横向滚动列表,标题栏收窄。
- [x] **分析页不显示极值标记**:现在在图上用小环标出最高/最低位置(带"高/低"字样)。
- [x] **相册与分析界面一样**:分析 tab 原先**直接渲染相册的网格**(同一个
`GalleryScreen`)。拆分为:
- `GalleryScreen`(相册 tab):像正常相册一样,点开进入**全屏查看器**
(双指缩放 1–8×、拖动平移、双击复位、**删除**含确认对话框);
- `AnalyzeScreen`(分析 tab):只列出**带温度数据**的照片
`MdtProbe.isMeasurable`),点开进入测温分析页。
- [x] **旧照片优雅降级**:没有 `BLOCK_NUC` 的照片(本轮之前拍的)在分析页显示
"无温度数据",**不再编造温度**;新拍照片都带该数据块。
- [x] 单测 76 → **83 项全绿**(新增 `PhotoNucMappingTest`:密集性/中心对齐/
探针往返/字节往返/无 NUC 判定);debug + release(R8) 双构建通过;APK 已更新。
**已知取舍(诚实记录)**
- `BLOCK_NUC` 是**照片分辨率**76800 样本 = 153KB,未压缩),照片文件因此增大
约 150KB。选择它的理由:分析端查表无需任何坐标换算,从根上消除"索引对不上"
这一类缺陷(本轮的两个温度 bug 都源于此)。若日后要压缩,需保证查找端使用
同一套映射函数。
- 默认发射率/报警温度仍未接入管线(同第十八轮记录)。
**诚实记录(第十八轮未做)**
- `defaultEmissivityPercent`(默认发射率)与 `alarmTempC`(报警温度)**仍未被 - `defaultEmissivityPercent`(默认发射率)与 `alarmTempC`(报警温度)**仍未被
测温管线使用**:发射率需要官方 `CorrectTemperature` 的完整浮点公式(已从 测温管线使用**:发射率需要官方 `CorrectTemperature` 的完整浮点公式(已从
libcxsdk 伪代码定位到 `@000298f0`,但牵涉 T2E/环境温度/`Energe2Temp` 多处 libcxsdk 伪代码定位到 `@000298f0`,但牵涉 T2E/环境温度/`Energe2Temp` 多处