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 c9aba54..cc3fc54 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 @@ -15,6 +15,7 @@ import java.io.ByteArrayOutputStream * 0x5BB5B55C second info block (0x38B from 66c, optional) * 0x5BB5B55D raw measurement frame (19200 x uint16 LE) * 0x5BB5B55E text note (UTF-8, optional) + * 0x5BB5B55F probe points (UTF-8 lines "x,y,label,tempMc", optional) */ object Mdt { const val SECTION_DDT = 0x5BB5B55B @@ -25,6 +26,34 @@ object Mdt { const val BLOCK_FRAME = 0x5BB5B55D 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. + */ + const val BLOCK_PROBES = 0x5BB5B55F + + /** One probe carried in an MDT file. */ + data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int) + + fun encodeProbes(probes: List): ByteArray = + probes.joinToString("\n") { "${it.x},${it.y},${it.label},${it.tempMc}" } + .toByteArray(Charsets.UTF_8) + + /** Parse the probe block; malformed lines are skipped rather than failing. */ + fun parseProbes(bytes: ByteArray?): List { + if (bytes == null || bytes.isEmpty()) return emptyList() + val text = String(bytes, Charsets.UTF_8).trimEnd('\u0000') + return text.lineSequence().mapNotNull { line -> + val parts = line.split(',') + if (parts.size < 4) return@mapNotNull null + val x = parts[0].trim().toIntOrNull() ?: return@mapNotNull null + val y = parts[1].trim().toIntOrNull() ?: return@mapNotNull null + val temp = parts[3].trim().toIntOrNull() ?: return@mapNotNull null + Probe(x, y, parts[2], temp) + }.toList() + } + private fun align4(n: Int): Int = (n + 3) / 4 * 4 fun u32(b: ByteArray, off: Int): Int = @@ -54,6 +83,7 @@ object Mdt { info1: ByteArray?, framePixels: ByteArray?, text: ByteArray? = null, + probes: ByteArray? = null, ): ByteArray { val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320) out.write(jpg, 0, jpg.size) @@ -76,6 +106,7 @@ object Mdt { emit(BLOCK_FRAME, framePixels) } text?.let { emit(BLOCK_TXT, it) } + probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) } val bodyBytes = body.toByteArray() val header = ByteArray(0x88) @@ -121,6 +152,7 @@ object Mdt { framePixels = blocks[BLOCK_FRAME], // block payloads are padded to 4; strip the NUL padding text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') }, + probes = parseProbes(blocks[BLOCK_PROBES]), ) } @@ -148,5 +180,7 @@ object Mdt { /** Raw 38400 B measurement frame (19200 x u16 LE), null when absent. */ val framePixels: ByteArray?, val text: String?, + /** Probe points stored with the photo (empty when none). */ + val probes: List = emptyList(), ) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt index dd23989..8035d9f 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt @@ -2,7 +2,6 @@ package com.mag160c.thermal.media import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Matrix import android.graphics.Paint import android.media.MediaCodec import android.media.MediaCodecInfo @@ -15,11 +14,20 @@ import java.util.concurrent.atomic.AtomicBoolean * MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor * .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec * handles color conversion; frames arrive as ARGB bitmaps. + * + * THREADING (2026-09-11 fix): frames arrive on the USB reader thread while + * start/stop run on the UI thread. The first version read [inputSurface] and + * then called lockCanvas on it, so a stop() in between released the Surface and + * lockCanvas threw on the reader thread — an uncaught exception that killed the + * app the moment recording stopped. Every surface/encoder access is now under + * one lock, and [offerFrame] additionally swallows codec-level errors: a dropped + * frame is always preferable to a crash. */ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { private val fps = 15 private val bitRate = 2_000_000 + private val lock = Any() private var encoder: MediaCodec? = null private var inputSurface: android.view.Surface? = null private var muxer: MediaMuxer? = null @@ -29,11 +37,21 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { private val canvas = Canvas() private val paint = Paint() + /** Frames accepted since start (diagnostics). */ + @Volatile + var frameCount: Int = 0 + private set + + /** Frames dropped because the encoder was busy or gone (diagnostics). */ + @Volatile + var droppedCount: Int = 0 + private set + @Volatile var outPath: File? = null private set - fun start(): Boolean { + fun start(): Boolean = synchronized(lock) { if (active.get()) return true return try { val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply { @@ -54,50 +72,90 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { val tmp = File.createTempFile("mag160c", ".mp4") muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) outPath = tmp + frameCount = 0 + droppedCount = 0 active.set(true) true } catch (e: Exception) { - releaseAll() + DebugLog.log("rec", "start failed: ${e.javaClass.simpleName}: ${e.message}") + releaseAllLocked() false } } fun isRecording(): Boolean = active.get() - /** Push one frame bitmap (called from the frame callback thread). */ + /** + * Push one frame bitmap (called from the frame callback thread). + * Never throws: encoding errors drop the frame and are logged once. + */ fun offerFrame(bmp: Bitmap) { - val surface = inputSurface ?: return if (!active.get()) return - val c = surface.lockCanvas(null) ?: return try { - c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint) - } finally { - surface.unlockCanvasAndPost(c) + synchronized(lock) { + if (!active.get()) return // stop() won the race + val surface = inputSurface ?: return + val enc = encoder ?: return + val c = surface.lockCanvas(null) ?: run { + droppedCount++ + return + } + try { + c.drawBitmap( + bmp, null, + android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), + paint, + ) + } finally { + surface.unlockCanvasAndPost(c) + } + drainLocked(enc, false) + frameCount++ + } + } catch (e: Exception) { + // a released surface / stopped codec must not take down the reader thread + droppedCount++ + if (droppedCount == 1 || droppedCount % 60 == 0) { + DebugLog.log( + "rec", + "frame dropped (${e.javaClass.simpleName}: ${e.message}) " + + "dropped=$droppedCount frames=$frameCount", + ) + } } - drain(false) } - /** Stop recording and finalize. Returns the output file. */ + /** Stop recording and finalize. Returns the output file, or null. */ fun stop(): File? { if (!active.getAndSet(false)) return null - val enc = encoder ?: return null - // drain with EOS - val idx = enc.dequeueInputBuffer(10_000) - if (idx >= 0) enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) - drain(true) - val f = outPath - runCatching { muxer?.stop() } - runCatching { muxer?.release() } - muxer = null - runCatching { enc.stop() } - runCatching { enc.release() } - encoder = null - inputSurface?.release() - inputSurface = null - return f + synchronized(lock) { + val enc = encoder + val f = outPath + try { + if (enc != null) { + val idx = enc.dequeueInputBuffer(10_000) + if (idx >= 0) { + enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + drainLocked(enc, true) + } + } catch (e: Exception) { + DebugLog.log("rec", "drain on stop failed: ${e.javaClass.simpleName}: ${e.message}") + } + runCatching { muxer?.stop() } + runCatching { muxer?.release() } + muxer = null + runCatching { encoder?.stop() } + runCatching { encoder?.release() } + encoder = null + runCatching { inputSurface?.release() } + inputSurface = null + DebugLog.log("rec", "stopped: frames=$frameCount dropped=$droppedCount file=${f?.length()}") + return f + } } - private fun releaseAll() { + private fun releaseAllLocked() { runCatching { encoder?.stop() } runCatching { encoder?.release() } encoder = null @@ -108,8 +166,8 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { muxer = null } - private fun drain(end: Boolean) { - val enc = encoder ?: return + /** Caller must hold [lock]; reads the muxer/encoder fields directly. */ + private fun drainLocked(enc: MediaCodec, end: Boolean) { val mux = muxer ?: return val info = MediaCodec.BufferInfo() while (true) { @@ -135,4 +193,14 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { } } } + + /** + * Copy a finished recording into the system gallery (DCIM/MAG160C). + * Returns the created name, or null when the copy failed. + */ + fun publishToGallery(context: android.content.Context, file: File): String? { + val name = "MAG160C_V_${PhotoSaver.fileName().removePrefix("MAG160C_").removeSuffix(".jpg")}.mp4" + val uri = PhotoSaver.saveVideo(context, file, name) + return if (uri != null) name else null + } } 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 cddbddc..dbfcd62 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 @@ -3,10 +3,16 @@ package com.mag160c.thermal.media import android.content.ContentValues import android.content.Context import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Typeface import android.os.Build import android.os.Environment import android.provider.MediaStore import java.io.ByteArrayOutputStream +import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -21,6 +27,16 @@ object PhotoSaver { fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg" + /** Probe annotation burned into a saved photo. */ + data class ProbeMark(val x: Int, val y: Int, val label: String, val tempC: Float) + + /** + * 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 + * corrections — see ui/live/ImageTransform). + */ + data class Orientation(val rotateDeg: Int, val flipH: Boolean, val flipV: Boolean) + /** Encode a rendered ARGB frame to JPEG bytes. */ fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray { val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) @@ -35,6 +51,177 @@ object PhotoSaver { return out.toByteArray() } + /** + * Render a photo exactly as the live view presents it: apply the sensor-frame + * flips, then the locked/user rotation, then burn in the probe markers with + * their temperatures. + * + * @param frame 320x240 ARGB render of the sensor image (unrotated) + * @param orientation the same rotation/flip the live view used + * @param probes probe points in SENSOR coordinates, with their temperatures + * @return JPEG bytes of the rotated, annotated image + */ + fun encodeRendered( + frame: IntArray, + w: Int = 320, + h: Int = 240, + orientation: Orientation, + probes: List = emptyList(), + density: Float = 2f, + quality: Int = 92, + ): ByteArray { + val src = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + src.setPixels(frame, 0, w, 0, 0, w, h) + + // 1. flips act on the sensor frame (same order as ImageTransform) + var work = src + if (orientation.flipH || orientation.flipV) { + val m = Matrix().apply { + setScale( + if (orientation.flipH) -1f else 1f, + if (orientation.flipV) -1f else 1f, + w / 2f, h / 2f, + ) + } + work = Bitmap.createBitmap(src, 0, 0, w, h, m, true) + } + + // 2. rotation (90/180/270); the probe coordinates ride along + val rot = ((orientation.rotateDeg % 360) + 360) % 360 + if (rot != 0) { + val m = Matrix().apply { postRotate(rot.toFloat()) } + val rotated = Bitmap.createBitmap(work, 0, 0, work.width, work.height, m, true) + work = rotated + } + + // marker positions in the OUTPUT image, following the same transform + val outW = if (rot % 180 == 0) w else h + val outH = if (rot % 180 == 0) h else w + + val outBmp = if (work.width == outW && work.height == outH) { + work.copy(Bitmap.Config.ARGB_8888, true) + } else { + Bitmap.createScaledBitmap(work, outW, outH, true) + } + + if (probes.isNotEmpty()) { + val canvas = Canvas(outBmp) + 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) + } + val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + setShadowLayer(3f * density, 0f, 0f, Color.BLACK) + } + val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 13f * density + typeface = Typeface.SANS_SERIF + setShadowLayer(3f * density, 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) + } + } + return encodeJpeg(outBmp, quality) + } + + /** + * Sensor pixel -> position in the FINAL (rotated, flipped) photo. + * + * The bitmap transforms above are clockwise for positive angles, so a source + * point (x,y) in a w x h buffer lands at: + * rot 0 -> (x, y) + * rot 90 -> (h - y, x) (output h x w) + * rot 180 -> (w - x, h - y) + * rot 270 -> (y, w - x) (output h x w) + * Flips are applied first, exactly like the bitmap pipeline. + */ + internal fun sensorToImage( + sx: Int, + sy: Int, + w: Int, + h: Int, + rot: Int, + orientation: Orientation, + ): IntArray { + var u = (sx + 0.5f) / 160f + var v = (sy + 0.5f) / 120f + if (orientation.flipH) u = 1f - u + if (orientation.flipV) v = 1f - v + val x = u * w + val y = v * h + return when (((rot % 360) + 360) % 360) { + 90 -> intArrayOf((h - y).toInt(), x.toInt()) + 180 -> intArrayOf((w - x).toInt(), (h - y).toInt()) + 270 -> intArrayOf(y.toInt(), (w - x).toInt()) + else -> intArrayOf(x.toInt(), y.toInt()) + } + } + + /** + * Burn probe markers onto an ALREADY-RENDERED JPEG (analysis "save as new"). + * The image is not rotated here — the caller's bitmap is already in its final + * orientation, so the probe coordinates are its own pixel coordinates. + */ + fun annotateJpeg( + jpg: ByteArray, + probes: List, + density: Float = 2f, + ): ByteArray { + if (probes.isEmpty()) return jpg + val bmp = android.graphics.BitmapFactory.decodeByteArray(jpg, 0, jpg.size) + ?: return jpg + val out = bmp.copy(android.graphics.Bitmap.Config.ARGB_8888, true) ?: return jpg + val canvas = Canvas(out) + 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) + } + val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + setShadowLayer(3f * density, 0f, 0f, Color.BLACK) + } + val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 13f * density + typeface = Typeface.SANS_SERIF + setShadowLayer(3f * density, 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) + } + return encodeJpeg(out) + } + /** Save an MDT container into MediaStore. Returns the media uri string. */ fun saveMdt( context: Context, @@ -67,6 +254,40 @@ object PhotoSaver { return uri.toString() } + /** + * Copy a finished recording into DCIM/MAG160C as a playable video. + * Returns the created MediaStore uri, or null when it could not be stored. + */ + fun saveVideo(context: Context, src: File, displayName: String): String? { + if (!src.isFile || src.length() == 0L) return null + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4") + if (Build.VERSION.SDK_INT >= 29) { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C") + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + val uri = resolver.insert( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values, + ) ?: return null + return try { + resolver.openOutputStream(uri)?.use { out -> + src.inputStream().use { input -> input.copyTo(out) } + } + if (Build.VERSION.SDK_INT >= 29) { + val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } + resolver.update(uri, done, null, null) + } + uri.toString() + } catch (e: Exception) { + DebugLog.log("rec", "video publish failed: $e") + resolver.delete(uri, null, null) + null + } + } + /** Save a plain JPEG (no MDT wrapper). */ fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? { val values = ContentValues().apply { 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 b54742d..32fb566 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 @@ -5,24 +5,41 @@ 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 com.mag160c.thermal.core.TempMath import com.mag160c.thermal.media.Mdt +import com.mag160c.thermal.media.PhotoSaver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.Locale /** - * Offline MDT analysis state: loaded container, palette re-render, probes. + * Offline MDT analysis (redesigned 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. */ 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. */ + 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) if present. */ + /** Raw measurement frame (19200 uint16) used for temperatures. */ val rawFrame: IntArray? by lazy { parsed?.framePixels?.let { raw -> val out = IntArray(19200) @@ -34,80 +51,190 @@ class AnalyzeViewModel( } /** Millidegree-C map of the measurement frame (19200 entries), if present. */ - private val _tempMap = androidx.compose.runtime.mutableStateOf(null) - val tempMap: IntArray? get() = _tempMap.value - - /** - * Selected probe in sensor coordinates (x 0..159, y 0..119). - * Backed by snapshot state so the viewer's draw phase invalidates on change. - */ - private val _probe = androidx.compose.runtime.mutableStateOf?>(null) - var probe: Pair? - get() = _probe.value - set(value) { - _probe.value = value - } - - init { - val frame = parsed?.framePixels - if (frame != null) { - viewModelScope.launch(Dispatchers.Default) { - val map = com.mag160c.thermal.core.TempMath.tempMapFromPixels(frame) - withContext(Dispatchers.Main) { _tempMap.value = map } - } - } - } - - /** Temperature (millidegree C) at a sensor pixel from the decoded map. */ - fun probeTempMc(x: Int, y: Int): Int? { - val map = _tempMap.value ?: return null - if (x < 0 || y < 0 || x >= 160 || y >= 120) return null - val idx = y * 160 + x - return if (idx < map.size) map[idx] else null + private val tempMap: IntArray? by lazy { + rawFrame?.let { TempMath.tempMapFromPixels(parsed!!.framePixels!!) } } private val _render = MutableStateFlow(null) + + /** The image to display: the saved JPEG. */ val render: StateFlow = _render private val _paletteIndex = MutableStateFlow(2) val paletteIndex: StateFlow = _paletteIndex - /** Re-render the raw frame with the given palette + auto window. */ - fun render(paletteIdx: Int) { - val raw = rawFrame ?: return - _paletteIndex.value = paletteIdx + /** 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) + val maxTempC: Float? get() = _maxTempC.value + private val _centerTempC = mutableStateOf(null) + val centerTempC: Float? get() = _centerTempC.value + + /** Image size of the displayed (saved) photo, in pixels. */ + private val _imageW = mutableStateOf(320) + val imageW: Int get() = _imageW.value + private val _imageH = mutableStateOf(240) + val imageH: Int get() = _imageH.value + + fun load() { viewModelScope.launch(Dispatchers.Default) { + val jpg = parsed?.jpg + val bmp = jpg?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } + if (bmp != null) { + _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 var mn = Int.MAX_VALUE - var mx = -1 - for (v in raw) { - if (v < mn) mn = v - if (v > mx) mx = v + var mx = Int.MIN_VALUE + map?.forEach { + if (it < mn) mn = it + if (it > mx) mx = it } - if (mx <= mn) mx = mn + 1 - val pal = com.mag160c.thermal.core.Palettes.buildAll()[paletteIdx.coerceIn(0, 11)] - val argb = IntArray(19200) - val scale = (255 shl 12) / (mx - mn) - for (i in argb.indices) { - var g = ((raw[i] - mn) * scale) shr 8 - if (g < 0) g = 0 else if (g > 255) g = 255 - argb[i] = pal[g] + withContext(Dispatchers.Main) { + _render.value = bmp + probes.clear() + probes.addAll(loaded) + if (map != null && map.isNotEmpty()) { + _minTempC.value = mn / 1000f + _maxTempC.value = mx / 1000f + _centerTempC.value = map[60 * 160 + 80] / 1000f + } } - val bmp = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888) - bmp.setPixels(argb, 0, 160, 0, 0, 160, 120) - withContext(Dispatchers.Main) { _render.value = bmp } } } + /** + * 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). + */ + 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() + .coerceIn(0, _imageW.value - 1) + 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 + val hit = probes.indexOfFirst { p -> + val dx = (p.x - ix).toFloat() + val dy = (p.y - iy).toFloat() + dx * dx + dy * dy < thr * thr + } + if (hit >= 0) { + 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 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) { + _paletteIndex.value = idx + } + fun decodeNote(): String? = parsed?.text - /** Probe temperature approximation at a raw pixel (millidegrees C). */ - fun probeTemp(x: Int, y: Int): Int? { - val raw = rawFrame ?: return null - if (x < 0 || y < 0 || x >= 160 || y >= 120) return null - return com.mag160c.thermal.core.TempMath.countsToTempMc(raw[y * 160 + x]) + /** Save as a NEW photo: annotations baked in, probes stored in the container. */ + fun saveAsNew( + context: android.content.Context, + notes: String, + density: Float, + onDone: (Boolean) -> Unit, + ) { + val bmp = _render.value + if (bmp == null) { + onDone(false) + return + } + 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, + info0 = parsed?.info0, + info1 = parsed?.info1, + framePixels = parsed?.framePixels, + text = notes.takeIf { it.isNotEmpty() }?.toByteArray(Charsets.UTF_8), + probes = Mdt.encodeProbes( + probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) }, + ), + ) + val name = "MAG160C_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + .format(java.util.Date())}_edit.jpg" + val ok = PhotoSaver.saveMdt(context, mdt, name) != null + withContext(Dispatchers.Main) { onDone(ok) } + } } - /** Save note: rewrite the container in place (jpg = current render). */ + /** Save the note into the ORIGINAL container (kept for the note editor). */ fun saveNote(note: String, onDone: (Boolean) -> Unit) { val bmp = _render.value if (bmp == null) { @@ -115,13 +242,16 @@ class AnalyzeViewModel( return } viewModelScope.launch(Dispatchers.IO) { - val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(bmp) + val jpg = PhotoSaver.encodeJpeg(bmp) val mdt = Mdt.compose( jpg = jpg, info0 = parsed?.info0, info1 = parsed?.info1, framePixels = parsed?.framePixels, text = note.toByteArray(Charsets.UTF_8), + probes = Mdt.encodeProbes( + probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) }, + ), ) 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 9f06542..c88f2a6 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 @@ -6,23 +6,24 @@ 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.aspectRatio +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.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.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 @@ -32,12 +33,12 @@ 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.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize @@ -45,15 +46,28 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.mag160c.thermal.core.Palettes import com.mag160c.thermal.ui.gallery.GalleryViewModel - -/** - * Single-file MDT analysis viewer: pinch zoom/pan, palette re-render, - * text note editing. - */ import java.util.Locale +/** + * Offline MDT analysis (redesigned 2026-09-11 per the user's request): + * + * - 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. + */ @Composable -fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) { +fun AnalyzeViewer( + item: GalleryViewModel.Item, + galleryVm: GalleryViewModel, + onClose: () -> Unit = {}, + density: Float = 2f, +) { val context = LocalContext.current val vm = remember(item.name) { val bytes = runCatching { @@ -65,236 +79,244 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) { val paletteIdx by vm.paletteIndex.collectAsState() var zoom by remember { mutableStateOf(1f) } var pan by remember { mutableStateOf(Offset.Zero) } - var showNote by remember { mutableStateOf(false) } - var reportName by remember { mutableStateOf(null) } 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 - LaunchedEffect(Unit) { vm.render(2) } + DisposableEffect(item.name) { + onDispose { /* nothing to release yet */ } + } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black), - ) { - Canvas( - modifier = Modifier - .aspectRatio(4f / 3f) - .align(Alignment.Center) - .pointerInput(Unit) { - detectTransformGestures { _, gesturePan, gestureZoom, _ -> - zoom = (zoom * gestureZoom).coerceIn(1f, 4f) - pan += gesturePan - if (zoom <= 1.01f) { - zoom = 1f - pan = Offset.Zero - } - } - } - .pointerInput(Unit) { - detectTapGestures { pos -> vm.probe = tapProbe(vm.probe, pos, zoom, pan, size) } - }, - ) { - val img = render?.asImageBitmap() - if (img != null) { - val w = size.width * zoom - val h = size.height * zoom - val left = (size.width - w) / 2 + pan.x - val top = (size.height - h) / 2 + pan.y - drawImage( - image = img, - dstOffset = androidx.compose.ui.unit.IntOffset(left.toInt(), top.toInt()), - dstSize = IntSize(w.toInt(), h.toInt()), - ) - } - val tempMap = vm.tempMap - if (tempMap != null) { - drawTemperatureOsd(tempMap) - vm.probe?.let { drawProbeMarker(it, tempMap, zoom, pan) } - } - } + LaunchedEffect(Unit) { vm.load() } + + Column(modifier = Modifier.fillMaxSize().background(Color.Black)) { + // ---- top bar: title + actions ---- Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(Color(0x66000000)) - .horizontalScroll(rememberScrollState()) - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth().padding(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Palettes.NAMES.forEachIndexed { idx, name -> - Text( - name, - color = if (paletteIdx == idx) MaterialTheme.colorScheme.primary else Color.White, - modifier = Modifier - .clickable { vm.render(idx) } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) - } - } - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp), - ) { - Button(onClick = { showNote = true }) { Text("备注") } - Button( + 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), + ) + TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[paletteIdx]) } + TextButton(onClick = { showNote = true }) { Text("备注") } + TextButton( onClick = { - val bmp = render - if (bmp != null) { - reportName = com.mag160c.thermal.media.PdfReport.generate( - context, bmp, - com.mag160c.thermal.media.PdfReport.ReportData( - date = java.text.SimpleDateFormat("yyyy/MM/dd", Locale.getDefault()) - .format(java.util.Date()), - time = java.text.SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - .format(java.util.Date()), - ), + vm.saveAsNew(context, notes = note, density = density) { ok -> + saveResult = if (ok) "已保存为新照片" else "保存失败" + if (ok) galleryVm.refresh() + } + }, + ) { Text("保存") } + } + HorizontalDivider() + + 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, + ) { + val img = bmp.asImageBitmap() + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, gesturePan, gestureZoom, _ -> + zoom = (zoom * gestureZoom).coerceIn(1f, 4f) + pan += gesturePan + if (zoom <= 1.01f) { + zoom = 1f + pan = Offset.Zero + } + } + } + .pointerInput(Unit) { + detectTapGestures { pos -> + val rect = imageRect( + Size(size.width.toFloat(), size.height.toFloat()), + zoom, pan, bmp.width, bmp.height, + ) + vm.toggleProbeAt(pos, rect) + } + }, + ) { + val rect = imageRect( + Size(size.width.toFloat(), size.height.toFloat()), + zoom, pan, bmp.width, bmp.height, + ) + drawImage( + image = img, + dstOffset = androidx.compose.ui.unit.IntOffset( + rect.left.toInt(), rect.top.toInt(), + ), + dstSize = IntSize(rect.width.toInt(), rect.height.toInt()), + ) + drawProbes(probes, rect, bmp.width, bmp.height) + } + } + // ---- 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), + ) + } + saveResult?.let { + HorizontalDivider(Modifier.padding(vertical = 6.dp)) + Text(it, color = Color(0xFF80FF80), style = MaterialTheme.typography.labelSmall) + } + } + } + } + + if (showNote) { + NoteEditor( + initial = note, + onSave = { + note = it + vm.saveNote(it) { ok -> saveResult = if (ok) "备注已保存" else "备注保存失败" } + showNote = false + }, + onDismiss = { showNote = false }, + ) + } + if (showPalette) { + AlertDialog( + onDismissRequest = { showPalette = false }, + 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), ) } - }, - modifier = Modifier.padding(start = 8.dp), - ) { Text("报告") } - } - if (reportName != null) { - Text( - "已生成: $reportName", - color = Color.White, - modifier = Modifier - .align(Alignment.TopStart) - .padding(12.dp), - ) - } - if (showNote) { - NoteEditor( - initial = note, - onSave = { - note = it - vm.saveNote(it) { } - showNote = false - }, - onDismiss = { showNote = false }, - ) - } + } + }, + confirmButton = {}, + ) } } -private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y) - -private const val TEMP_W = 160 -private const val TEMP_H = 120 +@Composable +private fun TempRow(label: String, value: Float?) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = Color.Gray, style = MaterialTheme.typography.labelSmall) + Text( + value?.let { "%.1f℃".format(Locale.US, it) } ?: "--", + color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + } +} /** - * Image rect actually occupied by the bitmap for the current zoom/pan. - * NOTE: unlike the live screen (which always draws the frame rotated 90 deg - * CW), this viewer draws it in native orientation, so the tap mapping below is - * the direct one — copying the live 90-deg inverse map here would return the - * temperature of a wrongly transposed pixel. + * 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 imageRect(size: androidx.compose.ui.geometry.Size, zoom: Float, pan: Offset) = - androidx.compose.ui.geometry.Rect( - left = (size.width - size.width * zoom) / 2 + pan.x, - top = (size.height - size.height * zoom) / 2 + pan.y, - right = (size.width - size.width * zoom) / 2 + pan.x + size.width * zoom, - bottom = (size.height - size.height * zoom) / 2 + pan.y + size.height * zoom, - ) - -/** Tap -> sensor pixel; tapping the same spot again clears the probe. */ -private fun PointerInputScope.tapProbe( - current: Pair?, - pos: Offset, +private fun imageRect( + size: Size, zoom: Float, pan: Offset, - size: IntSize, -): Pair? { - val rect = imageRect( - androidx.compose.ui.geometry.Size(size.width.toFloat(), size.height.toFloat()), - zoom, pan, - ) - if (!rect.contains(pos)) return current - val x = ((pos.x - rect.left) / rect.width * TEMP_W).toInt().coerceIn(0, TEMP_W - 1) - val y = ((pos.y - rect.top) / rect.height * TEMP_H).toInt().coerceIn(0, TEMP_H - 1) - val hit = current?.let { - val dx = (it.first - x) * rect.width / TEMP_W - val dy = (it.second - y) * rect.height / TEMP_H - dx * dx + dy * dy <= (6.dp.toPx() * 6.dp.toPx()) - } ?: false - return if (hit) null else x to y + bmpW: Int, + bmpH: Int, +): 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 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) } -/** Temperature bar glued to the top of the displayed image (fit-rect based). */ -private fun DrawScope.drawTemperatureOsd(map: IntArray) { - if (map.size < TEMP_W * TEMP_H) return - val rect = imageRect(size, 1f, Offset.Zero) - var mn = Int.MAX_VALUE - var mx = Int.MIN_VALUE - for (v in map) { - if (v < mn) mn = v - if (v > mx) mx = v - } - val center = map[(TEMP_H / 2) * TEMP_W + TEMP_W / 2] - val text = "中心 %.1f℃ 最低 %.1f℃ 最高 %.1f℃".format( - Locale.US, center / 1000f, mn / 1000f, mx / 1000f, - ) - val barH = 28.dp.toPx() - val radius = 4.dp.toPx() - drawRoundRect( - color = Color(0x99000000), - topLeft = Offset(rect.left, rect.top), - size = androidx.compose.ui.geometry.Size(rect.width, barH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius, radius), - ) +/** White dot + ring + temperature label for every probe. */ +private fun DrawScope.drawProbes( + probes: List, + rect: androidx.compose.ui.geometry.Rect, + bmpW: Int, + bmpH: Int, +) { + if (probes.isEmpty()) return val paint = android.graphics.Paint().apply { color = android.graphics.Color.WHITE textSize = 12.sp.toPx() isAntiAlias = true + setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK) } - val baseline = rect.top + barH / 2f - (paint.ascent() + paint.descent()) / 2f - drawIntoCanvas { canvas -> - canvas.nativeCanvas.drawText(text, rect.left + 12.dp.toPx(), baseline, paint) - } -} - -/** White dot + ring marker at the probed pixel, with a temperature label. */ -private fun DrawScope.drawProbeMarker( - probe: Pair, - map: IntArray, - zoom: Float, - pan: Offset, -) { - val idx = probe.second * TEMP_W + probe.first - if (idx < 0 || idx >= map.size) return - val rect = imageRect(size, zoom, pan) - val cx = rect.left + (probe.first + 0.5f) / TEMP_W * rect.width - val cy = rect.top + (probe.second + 0.5f) / TEMP_H * rect.height - val dotR = 4.dp.toPx() - val ringR = 9.dp.toPx() - drawCircle(Color.White, dotR, Offset(cx, cy)) - drawCircle(Color.White, ringR, Offset(cx, cy), style = androidx.compose.ui.graphics.drawscope.Stroke(2.dp.toPx())) - - val label = "%.1f℃".format(Locale.US, map[idx] / 1000f) - val paint = android.graphics.Paint().apply { - color = android.graphics.Color.BLACK - textSize = 12.sp.toPx() + val dot = android.graphics.Paint().apply { + color = android.graphics.Color.WHITE isAntiAlias = true + setShadowLayer(3f, 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 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) } } - val padH = 6.dp.toPx() - val padV = 4.dp.toPx() - val tw = paint.measureText(label) - val boxW = tw + padH * 2 - val boxH = paint.textSize + padV * 2 - var boxLeft = cx + 8.dp.toPx() - if (boxLeft + boxW > rect.right) boxLeft = cx - 8.dp.toPx() - boxW - var boxTop = cy - 8.dp.toPx() - boxH - if (boxTop < rect.top) boxTop = cy + 8.dp.toPx() - val radius = 4.dp.toPx() - drawRoundRect( - color = Color(0xF0FFFFFF), - topLeft = Offset(boxLeft, boxTop), - size = androidx.compose.ui.geometry.Size(boxW, boxH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius, radius), - ) - val baseline = boxTop + boxH / 2f - (paint.ascent() + paint.descent()) / 2f - drawIntoCanvas { canvas -> canvas.nativeCanvas.drawText(label, boxLeft + padH, baseline, paint) } } @Composable @@ -303,14 +325,8 @@ private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () AlertDialog( onDismissRequest = onDismiss, title = { Text("文字备注") }, - text = { - OutlinedTextField(value = text, onValueChange = { text = it }) - }, - confirmButton = { - TextButton(onClick = { onSave(text) }) { Text("保存") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("取消") } - }, + text = { OutlinedTextField(value = text, onValueChange = { text = it }) }, + confirmButton = { TextButton(onClick = { onSave(text) }) { Text("保存") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, ) } 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 f388ba3..578ac97 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 @@ -39,6 +39,8 @@ import com.mag160c.thermal.ui.analyze.AnalyzeViewer fun GalleryScreen(vm: GalleryViewModel = viewModel()) { 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) @@ -54,58 +56,79 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) { if (granted) vm.refresh() else launcher.launch(perm) } - 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(), + // 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)), ) } - Text( - item.name.removePrefix("MAG160C_"), - 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, ) } } } } - - if (showViewer) { - val sel = vm.selected.value - if (sel != null) { - AnalyzeViewer(item = sel, galleryVm = vm) - } - } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt index 588bce6..56a83ce 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -264,10 +264,11 @@ class LiveRenderer( val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density) drawGripText(canvas, text, cx, cy) } - // the trace toggle governs BOTH extremes (it used to leave the min - // marker drawn, which read as "the switch only works halfway") - if (state.maxTraceOn) { + // the trace setting chooses which extremes to mark (max / min / both) + if (state.traceMode.showsMax) { drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "高") + } + if (state.traceMode.showsMin) { drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, "低") } for (p in state.probes) { diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index cbf44ad..2d2898c 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -107,13 +107,20 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) vm.refreshTemps() } } - // Apply orientation changes the moment they are made (also from the settings tab) - val orientation by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state + // Apply orientation / palette / trace-mode changes the moment they happen + // (including changes made on the settings tab), and on first launch. + val settings by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state .collectAsState() - LaunchedEffect(orientation) { - vm.userRotateDeg = orientation.rotateDeg - vm.flipH = orientation.flipH - vm.flipV = orientation.flipV + LaunchedEffect(settings) { + vm.userRotateDeg = settings.rotateDeg + vm.flipH = settings.flipH + vm.flipV = settings.flipV + vm.setTraceMode(settings.traceMode) + // palette: only re-apply when the user picks a different default, so a + // temporary change from the live control bar is not stomped every frame + if (vm.state.value.paletteIndex != settings.paletteIndex) { + vm.setPalette(settings.paletteIndex) + } } // The PIP overlay owns the camera: it releases on any of these exits — @@ -162,6 +169,33 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) ) } + // Transient confirmation for actions taken WHILE streaming (photo saved, + // recording finished, save failure). These statuses are not shown by the + // block above, which only covers the disconnected case — without this the + // user got no feedback at all for拍照/录像. + if (state.connected && state.status in LIVE_ACTION_STATUSES) { + Surface( + color = MaterialTheme.colorScheme.inverseSurface, + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = (navPx / density).dp + 96.dp) + .graphicsLayer { rotationZ = -phi.toFloat() }, + ) { + Text( + statusText(state), + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + // clear the transient message so it does not linger + LaunchedEffect(state.status) { + kotlinx.coroutines.delay(2500) + vm.clearTransientStatus() + } + } + // ---- control TOP bar (glued to the portrait top edge) ---- Surface( color = MaterialTheme.colorScheme.surfaceVariant, @@ -218,16 +252,21 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp), ) { - val on = state.maxTraceOn + val tracing = state.traceMode != LiveViewModel.TraceMode.NONE Icon( - painterResource(R.drawable.ic_target), "最高温追踪", - tint = if (on) MaterialTheme.colorScheme.primary + painterResource(R.drawable.ic_target), "追踪", + tint = if (tracing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - if (on) "追踪·开" else "追踪", + when (state.traceMode) { + LiveViewModel.TraceMode.MAX -> "追高" + LiveViewModel.TraceMode.MIN -> "追低" + LiveViewModel.TraceMode.BOTH -> "追高·低" + LiveViewModel.TraceMode.NONE -> "追踪" + }, style = MaterialTheme.typography.labelSmall, - color = if (on) MaterialTheme.colorScheme.primary + color = if (tracing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -459,6 +498,11 @@ private fun PipOverlay( } } +/** Statuses that are a one-shot confirmation of a user action while streaming. */ +private val LIVE_ACTION_STATUSES = setOf( + "saved", "save_fail", "rec_done", "rec_fail", "rec_save_fail", +) + private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) { "no_device" -> "未检测到热像仪,请插入MAG160C" "no_permission" -> "USB权限未授予" @@ -468,6 +512,12 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta "connect_fail" -> "连接流程异常(查看调试日志)" "no_handshake" -> "相机无应答,请拔插热像仪重试" "no_stream_data" -> "已连接但无数据流(10秒),日志已记录" + "saved" -> "已保存到相册(DCIM/MAG160C)" + "save_fail" -> "照片保存失败(查看调试日志)" + "recording" -> "录像中…" + "rec_done" -> "录像已保存到相册(DCIM/MAG160C)" + "rec_fail" -> "录像启动失败(查看调试日志)" + "rec_save_fail" -> "录像保存失败(查看调试日志)" else -> { val prefix = if (state.status.startsWith("exception:")) { "异常:" + state.status.removePrefix("exception:") 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 090e14c..b201e9e 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 @@ -2,10 +2,14 @@ package com.mag160c.thermal.ui.live import android.app.Application import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope import com.mag160c.thermal.core.TempMath import com.mag160c.thermal.usb.IrSession +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Live view state machine: USB permission -> link -> stream -> OSD stats. @@ -25,7 +29,12 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { val maxPos: Int = -1, val minPos: Int = -1, val identity: IrSession.CameraIdentity? = null, - val maxTraceOn: Boolean = true, + /** + * Which extremes the trace markers show. Replaces the old boolean + * toggle: the user asked to choose max / min / both in settings + * (2026-09-11). NONE = tracing off. + */ + val traceMode: TraceMode = TraceMode.BOTH, val probes: List = emptyList(), /** Visible-light PIP overlay (Phase E). */ val pipOn: Boolean = false, @@ -37,6 +46,27 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { val pipYf: Float = 0f, ) + /** Clear a one-shot status message (photo saved / recording done). */ + fun clearTransientStatus() { + if (_state.value.status in setOf("saved", "save_fail", "rec_done", "rec_fail", "rec_save_fail")) { + _state.value = _state.value.copy(status = "") + } + } + + /** Which extremes the trace markers display (settings choice). */ + enum class TraceMode { + MAX, MIN, BOTH, NONE; + + val showsMax: Boolean get() = this == MAX || this == BOTH + val showsMin: Boolean get() = this == MIN || this == BOTH + + companion object { + /** Persisted as an int; unknown values fall back to BOTH. */ + fun fromOrdinal(v: Int): TraceMode = + entries.getOrElse(v) { BOTH } + } + } + private val _state = MutableStateFlow(LiveState()) val state: StateFlow = _state @@ -252,9 +282,25 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { _state.value = _state.value.copy(zoom = z.coerceIn(1, 4)) } - /** Toggle max-temperature trace marker. */ + /** Toggle tracing on/off straight from the control bar. */ fun toggleMaxTrace() { - _state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn) + val s = _state.value + // from any tracing mode -> NONE; from NONE -> whatever the user last chose + _state.value = if (s.traceMode == TraceMode.NONE) { + s.copy(traceMode = lastTraceMode) + } else { + lastTraceMode = s.traceMode + s.copy(traceMode = TraceMode.NONE) + } + } + + /** Remembered non-NONE choice, so the control-bar toggle can restore it. */ + private var lastTraceMode: TraceMode = TraceMode.BOTH + + /** Set the trace mode from the settings screen. */ + fun setTraceMode(mode: TraceMode) { + if (mode != TraceMode.NONE) lastTraceMode = mode + _state.value = _state.value.copy(traceMode = mode) } // ---- visible-light PIP (Phase E) ---- @@ -276,20 +322,50 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { ) } - /** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */ - fun capturePhoto(context: android.content.Context) { + /** + * Capture: rendered JPEG + raw frame + camera info + 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. + */ + fun capturePhoto(context: android.content.Context, density: Float = 2f) { val frame = latestFrame ?: return val s = session - val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame) + 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( + rotateDeg = com.mag160c.thermal.ui.live.ImageTransform + .params(userRotateDeg, flipH, flipV).rotDeg, + flipH = flipH, + flipV = flipV, + ), + probes = marks, + density = density, + ) val rawFrame = s.lastRawFrame val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) { rawFrame.copyOfRange(0x1C, 0x1C + 38400) } else null + val probeBlock = com.mag160c.thermal.media.Mdt.encodeProbes( + marks.map { + com.mag160c.thermal.media.Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) + }, + ) val mdt = com.mag160c.thermal.media.Mdt.compose( jpg = jpg, info0 = s.lastInfo0, info1 = s.lastInfo1, framePixels = pixels, + probes = probeBlock, ) val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt( context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(), @@ -391,13 +467,31 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { if (r.start()) { recorder = r _state.value = _state.value.copy(status = "recording") + } else { + _state.value = _state.value.copy(status = "rec_fail") } } else { + // finalize + PUBLISH: the finished file used to be dropped on the + // floor (only the status text changed, nothing appeared in the gallery) val file = rec.stop() recorder = null - if (file != null) { - val name = "MAG160C_V_${com.mag160c.thermal.media.PhotoSaver.fileName()}" - _state.value = _state.value.copy(status = "rec_done") + if (file == null) { + _state.value = _state.value.copy(status = "rec_fail") + return + } + viewModelScope.launch(Dispatchers.IO) { + val name = try { + rec.publishToGallery(context, file) + } catch (e: Exception) { + com.mag160c.thermal.media.DebugLog.log("rec", "publish threw: $e") + null + } + runCatching { file.delete() } + withContext(Dispatchers.Main) { + _state.value = _state.value.copy( + status = if (name != null) "rec_done" else "rec_save_fail", + ) + } } } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt index 2225c1b..ee962a3 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt @@ -70,6 +70,9 @@ fun RemoteClientListScreen( onDispose { scope.cancel() } } + // System back returns to the settings screen instead of leaving the app + androidx.activity.compose.BackHandler(enabled = true) { onBack() } + LaunchedEffect(scanGeneration) { hosts = emptyList() scanning = true diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt index 5eb396c..3e53ac4 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt @@ -91,6 +91,11 @@ fun RemoteViewerScreen( LaunchedEffect(Unit) { vm.disconnected.collect { reason -> onDisconnected(reason) } } + // System back disconnects and returns to the host list, instead of exiting + androidx.activity.compose.BackHandler(enabled = true) { + vm.disconnect() + onDisconnected("已断开") + } Box(modifier = Modifier.fillMaxSize()) { AndroidView( diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt index d4e08e8..b71c8bc 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt @@ -5,33 +5,46 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** - * Process-wide observable copy of the image-orientation settings. + * Process-wide observable copy of the settings the LIVE/REMOTE screens must + * apply immediately (image orientation, palette, trace mode). * - * The settings screen writes [AppSettings] and the live/remote renderers need the - * new values immediately. Before this existed, the renderers polled AppSettings - * every 400 ms from the live screen's loop — which only runs while the LIVE tab - * is composed, so a change made on the settings tab was applied only after - * switching back and hoping the polling had not been torn down. Publishing here - * makes the change take effect at once, wherever it was made. + * The settings screen writes [AppSettings] and the renderers need the new values + * at once. Before this existed, the renderers polled AppSettings every 400 ms + * from the live screen's loop — which only runs while the LIVE tab is composed, + * so a change made on the settings tab was applied late, or not until the app + * was restarted (the user reported "settings revert to defaults after a + * restart", which was really "they were never applied"). */ object ImageOrientationSettings { data class State( val rotateDeg: Int = 0, val flipH: Boolean = false, val flipV: Boolean = false, + val paletteIndex: Int = 2, + val traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode = + com.mag160c.thermal.ui.live.LiveViewModel.TraceMode.BOTH, ) private val _state = MutableStateFlow(State()) val state: StateFlow = _state - fun publish(rotateDeg: Int, flipH: Boolean, flipV: Boolean) { - _state.value = State(rotateDeg, flipH, flipV) + fun publish( + rotateDeg: Int, + flipH: Boolean, + flipV: Boolean, + paletteIndex: Int = _state.value.paletteIndex, + traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode = _state.value.traceMode, + ) { + _state.value = State(rotateDeg, flipH, flipV, paletteIndex, traceMode) } - /** Read persisted values and publish them (called once when settings load). */ + /** Read persisted values and publish them (called when settings load). */ fun publishFrom(context: Context) { val s = AppSettings(context) - publish(s.imageRotateDeg, s.imageFlipH, s.imageFlipV) + publish( + s.imageRotateDeg, s.imageFlipH, s.imageFlipV, + s.defaultPaletteIndex, s.traceMode, + ) } } @@ -41,7 +54,23 @@ class AppSettings(context: Context) { var defaultPaletteIndex: Int get() = sp.getInt("palette", 2) - set(v) = sp.edit().putInt("palette", v).apply() + set(v) { + sp.edit().putInt("palette", v).apply() + ImageOrientationSettings.publish( + imageRotateDeg, imageFlipH, imageFlipV, paletteIndex = v, + ) + } + + /** Extrema shown by the trace markers (max / min / both / none). */ + var traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode + get() = com.mag160c.thermal.ui.live.LiveViewModel.TraceMode + .fromOrdinal(sp.getInt("traceMode", 2)) + set(v) { + sp.edit().putInt("traceMode", v.ordinal).apply() + ImageOrientationSettings.publish( + imageRotateDeg, imageFlipH, imageFlipV, traceMode = v, + ) + } var defaultEmissivityPercent: Int get() = sp.getInt("emissivity", 100) @@ -95,10 +124,12 @@ class AppSettings(context: Context) { sp.edit().putBoolean("imageFlipV", v).apply() ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, v) } - init { com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled) - // seed the observable with the persisted orientation settings - ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, imageFlipV) + // seed the observable with the persisted settings, so a fresh process + // starts with what the user chose (not the hard-coded defaults) + ImageOrientationSettings.publish( + imageRotateDeg, imageFlipH, imageFlipV, defaultPaletteIndex, traceMode, + ) } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt index 5198150..812cbcd 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.mag160c.thermal.core.Palettes +import com.mag160c.thermal.ui.live.LiveViewModel @Composable fun SettingsScreen( @@ -42,6 +43,8 @@ fun SettingsScreen( var flipV by remember { mutableStateOf(settings.imageFlipV) } // local Compose copy of the language choice, so the row label refreshes var language by remember { mutableStateOf(settings.language) } + // trace mode (max / min / both / off) + var traceMode by remember { mutableStateOf(settings.traceMode) } // remote-preview server toggle (Phase F); off by default, needs live USB var remoteOn by remember { mutableStateOf(remoteHostRunning) } var showNeedDevice by remember { mutableStateOf(false) } @@ -60,6 +63,15 @@ fun SettingsScreen( "%.2f".format(settings.defaultEmissivityPercent / 100f), ) { dialog = "emissivity" } SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" } + SettingRow( + "追踪", + when (traceMode) { + LiveViewModel.TraceMode.MAX -> "最高温" + LiveViewModel.TraceMode.MIN -> "最低温" + LiveViewModel.TraceMode.BOTH -> "最高+最低" + LiveViewModel.TraceMode.NONE -> "关闭" + }, + ) { dialog = "trace" } SettingRow( "语言", if (language == "zh") "中文" else "跟随系统", @@ -223,6 +235,40 @@ fun SettingsScreen( }, confirmButton = {}, ) + "trace" -> AlertDialog( + onDismissRequest = { dialog = null }, + title = { Text("追踪标记") }, + text = { + Column { + Text( + "选择画面中追踪标记显示哪一端温度。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + listOf( + LiveViewModel.TraceMode.MAX to "最高温", + LiveViewModel.TraceMode.MIN to "最低温", + LiveViewModel.TraceMode.BOTH to "最高+最低", + LiveViewModel.TraceMode.NONE to "关闭", + ).forEach { (mode, label) -> + Text( + label, + color = if (mode == traceMode) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .clickable { + traceMode = mode + settings.traceMode = mode + dialog = null + } + .padding(14.dp), + ) + } + } + }, + confirmButton = {}, + ) "about" -> AlertDialog( onDismissRequest = { dialog = null }, title = { Text("关于") }, diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt index b584699..276a2ba 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/media/MdtTest.kt @@ -105,4 +105,54 @@ class MdtTest { assertEquals("38400 bytes = 19200 u16 LE", 19200, frame.size / 2) assertTrue("some non-zero payload", frame.any { it.toInt() != 0 }) } + + // ---- probe block (2026-09-11: photos carry their measurement points) ---- + + @Test + fun probeRoundTrip() { + val probes = listOf( + Mdt.Probe(12, 34, "Pt1", 24_500), + Mdt.Probe(159, 119, "Pt2", -3_250), + Mdt.Probe(0, 0, "", 1_000_000), + ) + val mdt = Mdt.compose( + fakeJpg(), ByteArray(0x38) { 1 }, null, pixels(), + probes = Mdt.encodeProbes(probes), + ) + val parsed = Mdt.parse(mdt)!! + assertEquals("all probes survive the round trip", probes, parsed.probes) + } + + @Test + fun photoWithoutProbesParsesAsEmptyList() { + // older photos (and plain captures with no probes) must not break + val mdt = Mdt.compose(fakeJpg(), null, null, pixels(), text = "note".toByteArray()) + val parsed = Mdt.parse(mdt)!! + assertTrue("no probe block -> empty list", parsed.probes.isEmpty()) + assertEquals("note", parsed.text) + } + + @Test + fun malformedProbeLinesAreSkippedNotFatal() { + val bad = "12,34,Pt1,24500\nbroken line\n5,6\n,,\n7,8,Pt2,1000" + .toByteArray(Charsets.UTF_8) + val probes = Mdt.parseProbes(bad) + assertEquals("only the two valid lines survive", 2, probes.size) + assertEquals(Mdt.Probe(12, 34, "Pt1", 24_500), probes[0]) + assertEquals(Mdt.Probe(7, 8, "Pt2", 1_000), probes[1]) + } + + @Test + fun probesAndNoteCoexist() { + val probes = listOf(Mdt.Probe(80, 60, "中心", 30_000)) + val mdt = Mdt.compose( + fakeJpg(), null, null, pixels(), + text = "现场 A 区".toByteArray(Charsets.UTF_8), + probes = Mdt.encodeProbes(probes), + ) + val parsed = Mdt.parse(mdt)!! + assertEquals("现场 A 区", parsed.text) + assertEquals(probes, parsed.probes) + assertNotNull(parsed.framePixels) + } } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoOrientationTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoOrientationTest.kt new file mode 100644 index 0000000..bd2de79 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoOrientationTest.kt @@ -0,0 +1,123 @@ +package com.mag160c.thermal.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Photo orientation + annotation geometry. + * + * On-device reports this batch fixes (2026-09-11): + * - saved photos kept the RAW sensor orientation, so a photo taken with + * "竖直翻转" enabled looked different from the screen; + * - probe points were not stored or drawn on the photo at all. + * + * The pixel mapping below is what burns the markers into the saved image. It must + * agree with the bitmap transform (flips first, then clockwise rotation) or the + * markers land on the wrong spot. + */ +class PhotoOrientationTest { + + private fun rot(deg: Int, flipH: Boolean = false, flipV: Boolean = false) = + PhotoSaver.Orientation(deg, flipH, flipV) + + /** + * Sensor pixel -> photo pixel, w x h being the SOURCE buffer size. + * Coordinates are sampled at pixel CENTRES (x+0.5), which is what the + * production code does so a marker sits in the middle of its pixel; the + * resulting half-pixel offset is expected in the assertions below. + */ + private fun map( + sx: Int, sy: Int, + w: Int = 320, h: Int = 240, + orientation: PhotoSaver.Orientation, + ): Pair { + val rot = ((orientation.rotateDeg % 360) + 360) % 360 + val p = PhotoSaver.sensorToImage(sx, sy, w, h, rot, orientation) + return p[0] to p[1] + } + + /** Assert with a tolerance of one pixel (centre sampling + rounding). */ + private fun assertNear(expected: Pair, actual: Pair, msg: String) { + val dx = kotlin.math.abs(expected.first - actual.first) + val dy = kotlin.math.abs(expected.second - actual.second) + assertTrue("$msg: expected ~$expected but was $actual", dx <= 1 && dy <= 1) + } + + @Test + fun unlockedOrientationMapsCentreToCentre() { + // 90 deg (the locked default) turns the 4:3 buffer into a 3:4 photo; the + // sensor centre must stay at the photo centre + val (x, y) = map(79, 59, orientation = rot(90)) + val outW = 240 + val outH = 320 + assertTrue("centre x within a pixel or two of $outW/2: $x", kotlin.math.abs(x - outW / 2) <= 3) + assertTrue("centre y within a pixel or two of $outH/2: $y", kotlin.math.abs(y - outH / 2) <= 3) + } + + @Test + fun rotationMovesPointsTheWayTheBitmapDoes() { + // sensor corner pixel (0,0); sampling at its centre puts it just inside + // rot 0 -> near (0, 0) + // rot 90 -> near (h, 0) (right edge, top) + // rot 180 -> near (w, h) (bottom-right) + // rot 270 -> near (0, w) (left, bottom) + assertNear(0 to 0, map(0, 0, orientation = rot(0)), "rot 0") + assertNear(240 to 0, map(0, 0, orientation = rot(90)), "rot 90") + assertNear(320 to 240, map(0, 0, orientation = rot(180)), "rot 180") + assertNear(0 to 320, map(0, 0, orientation = rot(270)), "rot 270") + } + + @Test + fun flipHIsAppliedBeforeRotation() { + // with flipH the sensor's left column becomes the photo's right column + val plain = map(0, 0, orientation = rot(0)) + val flipped = map(0, 0, orientation = rot(0, flipH = true)) + assertNear(0 to 0, plain, "no flip") + assertNear(320 to 0, flipped, "flipH") + assertEquals("y unchanged by flipH", plain.second, flipped.second) + } + + @Test + fun flipVIsAppliedBeforeRotation() { + val plain = map(0, 0, orientation = rot(0)) + val flipped = map(0, 0, orientation = rot(0, flipV = true)) + assertNear(0 to 0, plain, "no flip") + assertNear(0 to 240, flipped, "flipV") + assertEquals("x unchanged by flipV", plain.first, flipped.first) + } + + /** + * The user's device finding, expressed for the photo pipeline: a vertical + * flip with the locked 90 rotation puts a point where a horizontal flip with + * 270 does. Markers must follow the same rule as the bitmap. + */ + @Test + fun flipVWith90MatchesFlipHWith270ForPhotoMarkers() { + for (sx in intArrayOf(0, 40, 79, 159)) { + for (sy in intArrayOf(0, 30, 59, 119)) { + val a = map(sx, sy, orientation = rot(90, flipV = true)) + val b = map(sx, sy, orientation = rot(270, flipH = true)) + val dx = kotlin.math.abs(a.first - b.first) + val dy = kotlin.math.abs(a.second - b.second) + assertTrue("($sx,$sy) -> a=$a b=$b", dx <= 1 && dy <= 1) + } + } + } + + @Test + fun photoSizeFollowsTheRotation() { + // 90/270 swap the axes (this is why a portrait photo of a landscape frame + // is the normal case), 0/180 keep them + fun sizeFor(deg: Int): Pair { + val rot = ((deg % 360) + 360) % 360 + val w = 320 + val h = 240 + return (if (rot % 180 == 0) w else h) to (if (rot % 180 == 0) h else w) + } + assertEquals(240 to 320, sizeFor(90)) + assertEquals(240 to 320, sizeFor(270)) + assertEquals(320 to 240, sizeFor(0)) + assertEquals(320 to 240, sizeFor(180)) + } +} diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index ed39a93..c327025 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:1c5d27923fa62cc4b49df87796fdce196827bd42a059e538e9efd9840fcf8130 +oid sha256:42d11494cc14928adb1752a6104c714ac65186ae2f07d13292767098f3054bac size 12663188 diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md index 02f5936..bf9a829 100644 --- a/docs/android_app/real_device_checklist.md +++ b/docs/android_app/real_device_checklist.md @@ -68,6 +68,20 @@ | 34 | 设置页依次点每一行,确认都有反应 | 调色板/发射率/报警温度/语言/旋转USB画面/云同步/关于 → 弹对话框;水平翻转/竖直翻转 → 文案在"已开启/已关闭"间切换;远程预览服务端 → 切换或弹"先连接热像仪";远程预览客户端 → 进入主机列表 | **没有点了没反应的行**(此前"语言"和"关于"是死行) | | 35 | 在**设置页**改"竖直翻转"或"旋转USB画面",然后切回实时页 | (无日志) | 画面**立即**按新设置显示(设置变更即时下发;此前靠实时页 400ms 轮询,仅在实时页处于打开状态时才生效) | +## 第二轮真机修复项(2026-09-11,重点验证) + +| # | 操作 | 预期 | 通过标准 | +|---|------|------|----------| +| 36 | 实时页点"录像",等 10 秒,再点"停止" | `[rec] stopped: frames= dropped= file=<字节>`,随后实时页出现"录像已保存到相册"提示 | **不闪退**(此前停止即崩溃);相册/文件管理器 DCIM/MAG160C 出现 `MAG160C_V_*.mp4` 且**能播放**;日志 `frames` 应为几百(15fps×10s≈150) | +| 36b | 若录像仍失败 | `[rec] start failed: …` 或 `[rec] frame dropped (…) dropped=` / `[rec] video publish failed: …` | 日志会指出是编码启动、丢帧还是入库失败;**不再有未捕获异常** | +| 37 | 相册页点任意一张照片 | (无日志) | **立即进入查看页**(此前点了没反应);按返回键**回到相册列表**,不是退出软件 | +| 38 | 分析页点图像任意位置 | (无日志) | 该处出现白点+圆环+`Pt* 温度`标签;侧边栏"测温点"列表同步新增一行;**再点同一点可删除** | +| 39 | 分析页点"保存" | 提示"已保存为新照片" | 相册出现一张 `MAG160C_*_edit.jpg`(**原照片仍在**);打开新照片能看到烧录的测温点标记 | +| 40 | 关掉 APP 完全重开,进设置页 | (无日志) | 之前改过的**旋转USB画面/水平翻转/竖直翻转/追踪/默认调色板**都还在(此前会变回默认) | +| 41 | 设置页改"追踪"为"最低温",回实时页 | (无日志) | 顶栏显示"追低",画面上**只标最低温**;改成"最高+最低"→ 两个都标;"关闭"→ 都不标 | +| 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) | +| 43 | 拍照前先在实时页点几个测温点,再拍照 | (无日志) | 照片上带这些测温点与温度;进分析页打开该照片,**测温点仍在**且可继续编辑 | + ## 相机(PIP)失败时的表现(设计如此,不算 bug) PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**: diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index bec1462..983d5a3 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -451,6 +451,51 @@ (CAMERA 权限原本会让相机变必需,已显式声明为可选)。 **未 push**(按用户指令)。 +## 用户反馈修复 第十九轮(2026-09-11,第二轮真机:相册/录像/分析/设置持久化) + +用户第二轮实机测试报出以下问题,本轮全部处理: + +- [x] **录像停止即闪退**(严重)。两处根因: + ① `Mp4Recorder.offerFrame` 先取 `inputSurface` 再判断,而 `stop()` 会 release + 它——采集线程随后 `lockCanvas` 在已释放 Surface 上抛异常,**该异常发生在 + USB 读线程且无人捕获 → 进程崩溃**。现改为所有 surface/encoder 访问同锁, + 且 `offerFrame` 整体 try/catch 吞掉编码层异常(丢帧优于崩溃)。 + ② **录完的文件从未保存**(只改了状态文案,相册里什么都没有)。现新增 + `MediaStore.Video` 保存(`PhotoSaver.saveVideo`)+ 临时文件清理, + 状态区分 `rec_done`/`rec_save_fail`/`rec_fail`。 +- [x] **相册点照片打不开**:`AnalyzeViewer` 被放在 `fillMaxSize()` 的 Column + **之后**,布局到屏幕外,所以点击像"没反应"。改为 `Box` 内**覆盖层**, + 并加 `BackHandler` 让返回键关闭查看器。 +- [x] **远程预览列表按返回键直接退出软件**:缺 `BackHandler`。列表页与查看页 + 均补上(列表→返回设置;查看页→断开并回列表)。 +- [x] **设置不生效/不持久**(用户:"每次重开设置就变回默认"):根因是 + **默认调色板/追踪模式从未被实时页读取**(只有方向设置接了)。 + `AppSettings` 全部相关 setter 现在都 publish 到 `ImageOrientationSettings`, + `init` 用持久化值播种;实时页 collect 后即时应用调色板+追踪模式。 + (`defaultEmissivityPercent`/`alarmTempC` 仍未被管线使用——见"诚实记录"。) +- [x] **照片要按设置竖直翻转/旋转后再保存**:新增 + `PhotoSaver.encodeRendered(frame, orientation, probes)`,拍照时按**与屏幕 + 一致**的方向(翻转→旋转)生成 JPEG,不再保存传感器原始朝向。 +- [x] **照片上烧录测温点+温度**:拍照时把探针(含 `Pt* 温度` 标签)绘到 JPEG 上。 +- [x] **MDT 新增探针数据块**(`0x5BB5B55F`,UTF-8 文本行 `x,y,label,tempMc`), + 分析页可**重新载入**原有测温点并可编辑。 +- [x] **分析页改造**(按用户要求):只显示保存的原始渲染图(不再按调色板重渲染, + 避免"改调色板看起来照片被改了");点击图像添加测温点、再点删除; + "保存"生成**新照片**(标注烧录、探针入库),原文件不动; + 最高/最低/中心温度与测温点列表显示在**侧边栏**。 +- [x] **追踪模式设置项**:设置页新增"追踪"(最高温/最低温/最高+最低/关闭), + 顶栏按钮显示当前模式(追高/追低/追高·低/追踪),录入 `TraceMode`。 +- [x] **操作反馈**:拍照/录像完成后在实时页显示 2.5 秒提示(此前这些状态只在 + "未连接"分支显示,正常出图时用户看不到任何反馈)。 +- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 + +**诚实记录(本轮未做)**: +- `defaultEmissivityPercent`(默认发射率)与 `alarmTempC`(报警温度)**仍未被 + 测温管线使用**:发射率需要官方 `CorrectTemperature` 的完整浮点公式(已从 + libcxsdk 伪代码定位到 `@000298f0`,但牵涉 T2E/环境温度/`Energe2Temp` 多处 + 状态,属独立议题),报警温度需要超温提示 UI。当前这两项**只保存与显示**, + 不声称已生效。 + ## 用户反馈修复 第十八轮(2026-09-11,真机实测:温度/朝向/远程三类缺陷) 用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理: