diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt index cc3fc54..101a70d 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt @@ -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 = 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 + } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/MdtProbe.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/MdtProbe.kt new file mode 100644 index 0000000..a802c06 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/MdtProbe.kt @@ -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) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt index dbfcd62..6681ebe 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt @@ -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) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt index 53dc591..54232d4 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt @@ -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(null) } + var analyzeItem by remember { mutableStateOf(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( diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeScreen.kt new file mode 100644 index 0000000..6494f50 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeScreen.kt @@ -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>(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(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)), + ) + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt index 32fb566..7d4fbd0 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt @@ -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(null) - /** The image to display: the saved JPEG. */ + /** The image to display: the saved JPEG, as-is. */ val render: StateFlow = _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() - /** Overall readouts shown in the side panel. */ private val _minTempC = mutableStateOf(null) val minTempC: Float? get() = _minTempC.value private val _maxTempC = mutableStateOf(null) @@ -74,7 +62,12 @@ class AnalyzeViewModel( private val _centerTempC = mutableStateOf(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(-1) + val minPos: Int get() = _minPos.value + private val _maxPos = mutableStateOf(-1) + val maxPos: Int get() = _maxPos.value + private val _imageW = mutableStateOf(320) val imageW: Int get() = _imageW.value private val _imageH = mutableStateOf(240) @@ -88,40 +81,58 @@ class AnalyzeViewModel( _imageW.value = bmp.width _imageH.value = bmp.height } - // stored probes (from the container) -> editable list - val stored = parsed?.probes.orEmpty() - val loaded = stored.mapNotNull { p -> - val t = tempMap?.get(p.y.coerceIn(0, 119) * 160 + p.x.coerceIn(0, 159)) - ?: p.tempMc - Probe(p.x, p.y, p.label, t / 1000f) - } - // overall stats - val map = tempMap + val counts = nucCounts var mn = Int.MAX_VALUE var mx = Int.MIN_VALUE - map?.forEach { - if (it < mn) mn = it - if (it > mx) mx = it + var mnPos = -1 + var mxPos = -1 + counts?.forEachIndexed { i, v -> + if (v < mn) { mn = v; mnPos = i } + if (v > mx) { mx = v; mxPos = i } + } + // stored probes already carry photo coordinates and a measured temp; + // re-measure from the NUC block when available so the panel always + // reflects the stored data rather than a stale label + val loaded = (parsed?.probes.orEmpty()).map { p -> + Probe(p.x, p.y, p.label, measure(p.x, p.y) ?: (p.tempMc / 1000f)) } withContext(Dispatchers.Main) { _render.value = bmp probes.clear() probes.addAll(loaded) - if (map != null && map.isNotEmpty()) { + if (counts != null) { _minTempC.value = mn / 1000f _maxTempC.value = mx / 1000f - _centerTempC.value = map[60 * 160 + 80] / 1000f + _minPos.value = mnPos + _maxPos.value = mxPos + _centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120) } } } } /** - * Map a tap in canvas space to the SAVED IMAGE's pixel space and toggle a - * probe there. The saved photo is already rotated/flipped, so this is a - * plain rect mapping (no extra transform). + * Temperature (C) at a pixel of the SAVED photo. + * The stored NUC block is 1:1 with the photo (see PhotoSaver.buildPhotoOrderedCounts), + * so this is a plain index — no rotation/flip/upscale math that could disagree + * with how the markers were placed. + * Returns null when the photo has no NUC data — never a fabricated number. */ - fun toggleProbeAt(pos: androidx.compose.ui.geometry.Offset, rect: androidx.compose.ui.geometry.Rect) { + fun measure(ix: Int, iy: Int): Float? { + val counts = nucCounts ?: return null + val w = _imageW.value + val h = _imageH.value + if (ix < 0 || iy < 0 || ix >= w || iy >= h) return null + val idx = iy * w + ix + if (idx < 0 || idx >= counts.size) return null + return TempMath.countsToTempMc(counts[idx]) / 1000f + } + + /** Tap in canvas space -> photo pixel; toggles a probe there. */ + fun toggleProbeAt( + pos: androidx.compose.ui.geometry.Offset, + rect: androidx.compose.ui.geometry.Rect, + ) { if (rect.width <= 0f || rect.height <= 0f) return if (pos.x < rect.left || pos.x > rect.right || pos.y < rect.top || pos.y > rect.bottom) return val ix = ((pos.x - rect.left) / rect.width * _imageW.value).toInt() @@ -129,8 +140,8 @@ class AnalyzeViewModel( val iy = ((pos.y - rect.top) / rect.height * _imageH.value).toInt() .coerceIn(0, _imageH.value - 1) - // near an existing probe? remove it - val thr = 12f + // marker footprint scales with the image, so the hit radius must too + val thr = (_imageW.value / 320f * 14f).coerceAtLeast(8f) val hit = probes.indexOfFirst { p -> val dx = (p.x - ix).toFloat() val dy = (p.y - iy).toFloat() @@ -140,58 +151,13 @@ class AnalyzeViewModel( probes.removeAt(hit) return } - val t = tempAtImagePixel(ix, iy) - probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t ?: 0f)) - } - - /** - * Temperature for a pixel of the SAVED image. The stored photo may be rotated - * relative to the sensor frame, so the image pixel is mapped back through the - * same rotation the capture used (recorded in the container's orientation - * when available; otherwise the probe simply reports the sensor-frame value - * at the equivalent position). - */ - private fun tempAtImagePixel(ix: Int, iy: Int): Float? { - val map = tempMap ?: return null - val w = _imageW.value - val h = _imageH.value - // inverse of the capture rotation: the photo is the sensor frame rotated - // clockwise by `rot`; map the image pixel back to sensor coordinates - val rot = captureRotation() - val (sx, sy) = when (rot) { - 90 -> { - // image (W=h_src, H=w_src) pixel -> sensor (x, y) - val x = iy.toFloat() / h * 160f - val y = (1f - ix.toFloat() / w) * 120f - x to y - } - 180 -> { - val x = (1f - ix.toFloat() / w) * 160f - val y = (1f - iy.toFloat() / h) * 120f - x to y - } - 270 -> { - val x = (1f - iy.toFloat() / h) * 160f - val y = ix.toFloat() / w * 120f - x to y - } - else -> { - val x = ix.toFloat() / w * 160f - val y = iy.toFloat() / h * 120f - x to y - } + val t = measure(ix, iy) + if (t == null) { + // no calibrated data: still allow the marker, but label it honestly + probes.add(Probe(ix, iy, "Pt${probes.size + 1}", 0f)) + } else { + probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t)) } - val cx = sx.toInt().coerceIn(0, 159) - val cy = sy.toInt().coerceIn(0, 119) - return map[cy * 160 + cx] / 1000f - } - - /** Rotation the capture baked into the JPEG (from the stored photo size). */ - private fun captureRotation(): Int { - val w = _imageW.value - val h = _imageH.value - // sensor frame is 4:3 landscape; a portrait photo means 90/270 was applied - return if (h > w) 90 else 0 } fun setPaletteIndex(idx: Int) { @@ -200,7 +166,7 @@ class AnalyzeViewModel( fun decodeNote(): String? = parsed?.text - /** Save as a NEW photo: annotations baked in, probes stored in the container. */ + /** Save as a NEW photo: annotations baked in, probes + NUC stored. */ fun saveAsNew( context: android.content.Context, notes: String, @@ -215,7 +181,6 @@ class AnalyzeViewModel( viewModelScope.launch(Dispatchers.IO) { val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92) val marks = probes.map { PhotoSaver.ProbeMark(it.x, it.y, it.label, it.tempC) } - // bake the markers onto the already-rendered image val annotated = PhotoSaver.annotateJpeg(jpg, marks, density) val mdt = Mdt.compose( jpg = annotated, @@ -226,6 +191,8 @@ class AnalyzeViewModel( probes = Mdt.encodeProbes( probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) }, ), + // carry the temperature data forward so the edited photo stays measurable + nucPixels = parsed?.nucPixels, ) val name = "MAG160C_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) .format(java.util.Date())}_edit.jpg" @@ -234,13 +201,9 @@ class AnalyzeViewModel( } } - /** Save the note into the ORIGINAL container (kept for the note editor). */ + /** Save the note into the ORIGINAL container. */ fun saveNote(note: String, onDone: (Boolean) -> Unit) { - val bmp = _render.value - if (bmp == null) { - onDone(false) - return - } + val bmp = _render.value ?: run { onDone(false); return } viewModelScope.launch(Dispatchers.IO) { val jpg = PhotoSaver.encodeJpeg(bmp) val mdt = Mdt.compose( @@ -252,6 +215,7 @@ class AnalyzeViewModel( probes = Mdt.encodeProbes( probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) }, ), + nucPixels = parsed?.nucPixels, ) val ok = runCatching { val ctx = getApplication() diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt index c88f2a6..85ce0db 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt @@ -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(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(null) } LaunchedEffect(Unit) { vm.load() } - Column(modifier = Modifier.fillMaxSize().background(Color.Black)) { - // ---- top bar: title + actions ---- + Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) { + // ---- slim title row ---- Row( - modifier = Modifier.fillMaxWidth().padding(6.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { TextButton(onClick = onClose) { Text("返回") } Text( item.name.removePrefix("MAG160C_").removeSuffix(".jpg"), color = Color.White, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f).padding(start = 4.dp), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + maxLines = 1, ) - TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[paletteIdx]) } + TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[vm.paletteIndex.value]) } TextButton(onClick = { showNote = true }) { Text("备注") } TextButton( onClick = { vm.saveAsNew(context, notes = note, density = density) { ok -> - saveResult = if (ok) "已保存为新照片" else "保存失败" + toast = if (ok) "已保存为新照片" else "保存失败" if (ok) galleryVm.refresh() } }, ) { Text("保存") } } - HorizontalDivider() + HorizontalDivider(color = Color(0x33FFFFFF)) + // ---- image ---- val bmp = render - if (bmp == null) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("无法读取图像(缺少原始测温数据)", color = Color.White) - } - return@Column - } - - // ---- image with the side panel ---- - Row(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier.weight(1f).fillMaxHeight(), - contentAlignment = Alignment.Center, - ) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + if (bmp == null) { + Text( + if (vm.hasTemperatureData) "载入中…" else "无温度数据(旧照片)", + color = Color.Gray, + ) + } else { val img = bmp.asImageBitmap() Canvas( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTransformGestures { _, gesturePan, gestureZoom, _ -> - zoom = (zoom * gestureZoom).coerceIn(1f, 4f) + zoom = (zoom * gestureZoom).coerceIn(1f, 6f) pan += gesturePan if (zoom <= 1.01f) { zoom = 1f @@ -148,18 +139,11 @@ fun AnalyzeViewer( } .pointerInput(Unit) { detectTapGestures { pos -> - val rect = imageRect( - Size(size.width.toFloat(), size.height.toFloat()), - zoom, pan, bmp.width, bmp.height, - ) - vm.toggleProbeAt(pos, rect) + vm.toggleProbeAt(pos, imageRect(currentSize(), zoom, pan, bmp)) } }, ) { - val rect = imageRect( - Size(size.width.toFloat(), size.height.toFloat()), - zoom, pan, bmp.width, bmp.height, - ) + val rect = imageRect(size, zoom, pan, bmp) drawImage( image = img, dstOffset = androidx.compose.ui.unit.IntOffset( @@ -167,44 +151,67 @@ fun AnalyzeViewer( ), dstSize = IntSize(rect.width.toInt(), rect.height.toInt()), ) - drawProbes(probes, rect, bmp.width, bmp.height) + // extremes, only when the photo carries temperature data + if (vm.hasTemperatureData) { + drawExtreme(vm.maxPos, rect, vm.imageW, vm.imageH, "高") + drawExtreme(vm.minPos, rect, vm.imageW, vm.imageH, "低") + } + drawProbes(vm.probes, rect, vm.imageW, vm.imageH) } } - // ---- side panel: the readouts the user asked to keep ---- - Column( - modifier = Modifier - .width(132.dp) - .fillMaxHeight() - .background(Color(0xFF101010)) - .padding(8.dp), - ) { - Text("温度", color = Color.White, style = MaterialTheme.typography.labelLarge) - HorizontalDivider(Modifier.padding(vertical = 6.dp)) - TempRow("最高", vm.maxTempC) - TempRow("最低", vm.minTempC) - TempRow("中心", vm.centerTempC) - HorizontalDivider(Modifier.padding(vertical = 6.dp)) - Text( - "测温点 ${probes.size}", - color = Color.White, - style = MaterialTheme.typography.labelLarge, - ) - Text( - "点击图像添加/删除", - color = Color.Gray, - style = MaterialTheme.typography.labelSmall, - ) - probes.forEach { p -> - Text( - "${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}", - color = Color.White, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(top = 2.dp), - ) + } + + // ---- bottom measurement panel ---- + Surface( + color = Color(0xFF1B1B20), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Stat("最高", vm.maxTempC) + Stat("最低", vm.minTempC) + Stat("中心", vm.centerTempC) } - saveResult?.let { - HorizontalDivider(Modifier.padding(vertical = 6.dp)) - Text(it, color = Color(0xFF80FF80), style = MaterialTheme.typography.labelSmall) + HorizontalDivider(Modifier.padding(vertical = 6.dp), color = Color(0x33FFFFFF)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "测温点 ${vm.probes.size}", + color = Color.White, + style = MaterialTheme.typography.labelMedium, + ) + Text( + if (vm.hasTemperatureData) " 点击图像添加/删除" else " 无温度数据,无法测温", + color = Color.Gray, + style = MaterialTheme.typography.labelSmall, + ) + vm.probes.forEach { p -> + Text( + " ${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}", + color = Color(0xFFFFD54F), + style = MaterialTheme.typography.labelMedium, + ) + } + } + toast?.let { + Text( + it, + color = Color(0xFF80FF80), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 4.dp), + ) + LaunchedEffect(it) { + kotlinx.coroutines.delay(2500) + toast = null + } } } } @@ -215,7 +222,7 @@ fun AnalyzeViewer( initial = note, onSave = { note = it - vm.saveNote(it) { ok -> saveResult = if (ok) "备注已保存" else "备注保存失败" } + vm.saveNote(it) { ok -> toast = if (ok) "备注已保存" else "备注保存失败" } showNote = false }, onDismiss = { showNote = false }, @@ -224,101 +231,131 @@ fun AnalyzeViewer( if (showPalette) { AlertDialog( onDismissRequest = { showPalette = false }, - title = { Text("调色板(仅影响显示提示,不改图)") }, + title = { Text("调色板") }, text = { - Column { - Palettes.NAMES.forEachIndexed { idx, name -> - Text( - name, - color = if (idx == paletteIdx) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .clickable { - vm.setPaletteIndex(idx) - showPalette = false - } - .padding(10.dp), - ) - } - } + Text( + "仅作为显示参考记录;离线分析不改动已保存的照片。", + style = MaterialTheme.typography.bodySmall, + ) + }, + confirmButton = { + TextButton(onClick = { showPalette = false }) { Text("好") } }, - confirmButton = {}, ) } } +/** One number with its caption, for the bottom panel. */ @Composable -private fun TempRow(label: String, value: Float?) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { +private fun Stat(label: String, value: Float?) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(label, color = Color.Gray, style = MaterialTheme.typography.labelSmall) Text( value?.let { "%.1f℃".format(Locale.US, it) } ?: "--", color = Color.White, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.titleMedium, ) } } -/** - * Rect the bitmap occupies. The saved photo is already rotated, so it is drawn - * 1:1 in its own orientation (no extra rotation here). - */ +private fun androidx.compose.ui.unit.IntSize.toSize(): Size = + Size(width.toFloat(), height.toFloat()) + private fun imageRect( size: Size, zoom: Float, pan: Offset, - bmpW: Int, - bmpH: Int, + bmp: android.graphics.Bitmap, ): androidx.compose.ui.geometry.Rect { - // fit the bitmap into the canvas, preserving aspect - val scale = minOf(size.width / bmpW, size.height / bmpH) * zoom - val w = bmpW * scale - val h = bmpH * scale + val scale = minOf(size.width / bmp.width, size.height / bmp.height) * zoom + val w = bmp.width * scale + val h = bmp.height * scale val left = (size.width - w) / 2 + pan.x val top = (size.height - h) / 2 + pan.y return androidx.compose.ui.geometry.Rect(left, top, left + w, top + h) } -/** White dot + ring + temperature label for every probe. */ +/** Marker geometry in IMAGE pixels, mirroring PhotoSaver.MarkStyle. */ +private class MarkerStyle(imageW: Int) { + val scale = imageW / 320f + val dotR = 2.6f * scale + val ringR = 5.5f * scale + val ringW = 1.4f * scale + val textSize = 9f * scale + val gap = 7f * scale +} + +private fun DrawScope.markerScaleFactor(rect: androidx.compose.ui.geometry.Rect, imageW: Int): Float = + rect.width / imageW + private fun DrawScope.drawProbes( probes: List, 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) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt index 578ac97..ca54e37 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt @@ -1,9 +1,6 @@ package com.mag160c.thermal.ui.gallery -import android.content.Intent -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,9 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -31,101 +30,77 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.compose.material3.Button +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.lifecycle.viewmodel.compose.viewModel -import com.mag160c.thermal.ui.analyze.AnalyzeViewer +/** + * Photo grid (album tab). Behaves like a normal gallery: tap opens a full-screen + * viewer with pinch-zoom and delete. Temperature tools live in the analysis tab, + * which is why this screen no longer embeds the measurement UI. + */ @Composable -fun GalleryScreen(vm: GalleryViewModel = viewModel()) { +fun GalleryScreen( + vm: GalleryViewModel = viewModel(), + onOpen: (GalleryViewModel.Item) -> Unit = {}, +) { val items by vm.items.collectAsState() val context = LocalContext.current - // The viewer must belong to the SAME ViewModel instance that the grid uses, - // so the selection made by a tap is the one the viewer opens. - var showViewer by remember { mutableStateOf(false) } - // runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE) val perm = if (android.os.Build.VERSION.SDK_INT >= 33) android.Manifest.permission.READ_MEDIA_IMAGES else android.Manifest.permission.READ_EXTERNAL_STORAGE val launcher = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), - ) { granted -> vm.refresh() } + ) { vm.refresh() } LaunchedEffect(Unit) { val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) == android.content.pm.PackageManager.PERMISSION_GRANTED if (granted) vm.refresh() else launcher.launch(perm) } - // System back closes the viewer instead of leaving the tab (or the app). - androidx.activity.compose.BackHandler(enabled = showViewer) { - showViewer = false - vm.select(null) - } - - Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium) - Button(onClick = { vm.refresh() }) { Text("刷新") } - } - LazyVerticalGrid(columns = GridCells.Fixed(3)) { - items(items.size) { idx -> - val item = items[idx] - var bmp by remember(item.name) { mutableStateOf(null) } - LaunchedEffect(item.name) { - vm.thumbnail(item) { b -> bmp = b } - } - Box( - modifier = Modifier - .aspectRatio(4f / 3f) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { - vm.select(item) - showViewer = true - }, - ) { - val b = bmp - if (b != null) { - Image( - bitmap = b.asImageBitmap(), - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - Text( - item.name.removePrefix("MAG160C_").removeSuffix(".jpg"), - style = MaterialTheme.typography.labelSmall, - color = Color.White, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(4.dp) - .background(Color(0x88000000)), + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium) + Button(onClick = { vm.refresh() }) { Text("刷新") } + } + LazyVerticalGrid(columns = GridCells.Fixed(3)) { + items(items.size) { idx -> + val item = items[idx] + var bmp by remember(item.name) { mutableStateOf(null) } + LaunchedEffect(item.name) { + vm.thumbnail(item) { b -> bmp = b } + } + Box( + modifier = Modifier + .aspectRatio(3f / 4f) + .padding(1.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { onOpen(item) }, + ) { + val b = bmp + if (b != null) { + Image( + bitmap = b.asImageBitmap(), + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), ) } - } - } - } - - // Overlay the viewer ON TOP of the grid: previously it was placed after a - // fillMaxSize() Column, which laid it out BELOW the visible area, so - // tapping a photo appeared to do nothing at all. - if (showViewer) { - val sel = vm.selected.collectAsState().value - if (sel != null) { - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - AnalyzeViewer( - item = sel, - galleryVm = vm, - onClose = { - showViewer = false - vm.select(null) - }, - density = androidx.compose.ui.platform.LocalDensity.current.density, + // delete lives in the viewer (with confirmation), matching how + // normal gallery apps behave; no clutter on the grid itself + Text( + item.name.removePrefix("MAG160C_").removeSuffix(".jpg"), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(3.dp) + .background(Color(0x99000000)), ) } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt index 683a5d4..8e1a797 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt @@ -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() + 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() + fun select(item: Item?) { _selected.value = item } @@ -117,6 +144,7 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) { val ctx = getApplication() runCatching { ctx.contentResolver.delete(item.uri, null, null) } thumbs.remove(item.name) + full.remove(item.name) refresh() } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/PhotoViewerScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/PhotoViewerScreen.kt new file mode 100644 index 0000000..0f89640 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/PhotoViewerScreen.kt @@ -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(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("取消") } + }, + ) + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index b201e9e..8a52404 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -323,31 +323,53 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { } /** - * Capture: rendered JPEG + raw frame + camera info + probes -> MDT -> MediaStore. + * Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore. * - * The saved JPEG is the SAME view the user sees: the sensor-frame flips and - * the locked/manual rotation are applied (previously the file kept the raw - * sensor orientation, so翻过来的照片和屏幕不一致), and the probe markers with - * their temperatures are burned in. The probes are also stored as data in the - * container so the analysis screen can reload and edit them. + * Three things must line up, or the offline analysis shows wrong numbers or + * misplaced markers (all three were broken on the device): + * 1. the JPEG is saved in the on-screen orientation (flips then rotation); + * 2. the NUC counts — the CALIBRATED data the live readouts use — are stored + * in that same photo pixel order, so an offline temperature lookup is a + * plain index and reproduces the live values (storing the raw sensor + * response instead gave 145 C / -161 C on a 30 C scene); + * 3. probe coordinates are converted to photo pixels too (they used to be + * stored as sensor coordinates and then drawn as if they were photo + * coordinates, which put the markers in the wrong place). */ fun capturePhoto(context: android.content.Context, density: Float = 2f) { val frame = latestFrame ?: return val s = session val st = _state.value + val orientation = com.mag160c.thermal.media.PhotoSaver.Orientation( + rotateDeg = com.mag160c.thermal.ui.live.ImageTransform + .params(userRotateDeg, flipH, flipV).rotDeg, + flipH = flipH, + flipV = flipV, + ) + + // NUC counts: the CALIBRATED 160x120 data the live readouts use, expanded + // to one entry per PHOTO pixel so the offline lookup needs no index math. + val nuc160 = IntArray(19200) + val haveNuc = s.copyNuc(nuc160) + val nucPhoto = if (haveNuc) { + com.mag160c.thermal.media.PhotoSaver.buildPhotoOrderedCounts(nuc160, orientation) + } else null + + // probes: sensor coordinates -> photo pixels val marks = st.probes.mapNotNull { p -> - p.tempC?.let { - com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it) + p.tempC?.let { t -> + val pos = com.mag160c.thermal.media.PhotoSaver.sensorToImage( + p.x, p.y, 320, 240, + ((orientation.rotateDeg % 360) + 360) % 360, + orientation, + ) + com.mag160c.thermal.media.PhotoSaver.ProbeMark(pos[0], pos[1], p.label, t) } } + val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered( frame = frame, - orientation = com.mag160c.thermal.media.PhotoSaver.Orientation( - rotateDeg = com.mag160c.thermal.ui.live.ImageTransform - .params(userRotateDeg, flipH, flipV).rotDeg, - flipH = flipH, - flipV = flipV, - ), + orientation = orientation, probes = marks, density = density, ) @@ -366,11 +388,19 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { info1 = s.lastInfo1, framePixels = pixels, probes = probeBlock, + nucPixels = nucPhoto?.let { + com.mag160c.thermal.media.PhotoSaver.countsToBytes(it) + }, ) val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt( context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(), ) _state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail") + com.mag160c.thermal.media.DebugLog.log( + "vm", + "capture: nuc=${if (haveNuc) "yes" else "no"} probes=${marks.size} " + + "rot=${orientation.rotateDeg} saved=${saved != null}", + ) } /** diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt new file mode 100644 index 0000000..d7f1445 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt @@ -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) + } +} diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index c327025..9b576d5 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42d11494cc14928adb1752a6104c714ac65186ae2f07d13292767098f3054bac +oid sha256:a43b3960e41a04d0f8bf2c89f95559e532d4698ff7733fa734555b8cd7ee059a size 12663188 diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md index bf9a829..886c431 100644 --- a/docs/android_app/real_device_checklist.md +++ b/docs/android_app/real_device_checklist.md @@ -82,6 +82,20 @@ | 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) | | 43 | 拍照前先在实时页点几个测温点,再拍照 | (无日志) | 照片上带这些测温点与温度;进分析页打开该照片,**测温点仍在**且可继续编辑 | +## 第三轮真机修复项(2026-09-11,重点验证) + +| # | 操作 | 预期 | 通过标准 | +|---|------|------|----------| +| 44 | 拍照后在分析页打开该照片 | 日志 `[vm] capture: nuc=yes probes= 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 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**: diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 983d5a3..044ecc5 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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` 多处