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))
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))
}
/**
* 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 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(),
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(
}
// ---- 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)
}
HorizontalDivider(Modifier.padding(vertical = 6.dp), color = Color(0x33FFFFFF))
Row(
modifier = Modifier
.width(132.dp)
.fillMaxHeight()
.background(Color(0xFF101010))
.padding(8.dp),
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
) {
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}",
"测温点 ${vm.probes.size}",
color = Color.White,
style = MaterialTheme.typography.labelLarge,
style = MaterialTheme.typography.labelMedium,
)
Text(
"点击图像添加/删除",
if (vm.hasTemperatureData) " 点击图像添加/删除" else " 无温度数据,无法测温",
color = Color.Gray,
style = MaterialTheme.typography.labelSmall,
)
probes.forEach { p ->
vm.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),
" ${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}",
color = Color(0xFFFFD54F),
style = MaterialTheme.typography.labelMedium,
)
}
saveResult?.let {
HorizontalDivider(Modifier.padding(vertical = 6.dp))
Text(it, color = Color(0xFF80FF80), style = MaterialTheme.typography.labelSmall)
}
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),
"仅作为显示参考记录;离线分析不改动已保存的照片。",
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {},
confirmButton = {
TextButton(onClick = { showPalette = false }) { Text("") }
},
)
}
}
/** 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,38 +30,35 @@ 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),
@@ -81,12 +77,10 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
}
Box(
modifier = Modifier
.aspectRatio(4f / 3f)
.aspectRatio(3f / 4f)
.padding(1.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
vm.select(item)
showViewer = true
},
.clickable { onOpen(item) },
) {
val b = bmp
if (b != null) {
@@ -97,35 +91,16 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
modifier = Modifier.fillMaxSize(),
)
}
// 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(4.dp)
.background(Color(0x88000000)),
)
}
}
}
}
// 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,
.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 marks = st.probes.mapNotNull { p ->
p.tempC?.let {
com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it)
}
}
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered(
frame = frame,
orientation = com.mag160c.thermal.media.PhotoSaver.Orientation(
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 { 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 = 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)
}
}
Binary file not shown.
+14
View File
@@ -82,6 +82,20 @@
| 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) |
| 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 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
+44 -1
View File
@@ -489,7 +489,50 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。
- [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`(报警温度)**仍未被
测温管线使用**:发射率需要官方 `CorrectTemperature` 的完整浮点公式(已从
libcxsdk 伪代码定位到 `@000298f0`,但牵涉 T2E/环境温度/`Energe2Temp` 多处