android: fix recording crash+save, gallery viewer unreachable, back key, settings persistence; photo orientation+annotations, analyzable MDT probes, trace mode

This commit is contained in:
ZXCLI
2026-09-11 23:26:27 +08:00
parent 3704ee797b
commit 15a7127287
18 changed files with 1351 additions and 397 deletions
@@ -15,6 +15,7 @@ import java.io.ByteArrayOutputStream
* 0x5BB5B55C second info block (0x38B from 66c, optional) * 0x5BB5B55C second info block (0x38B from 66c, optional)
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE) * 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
* 0x5BB5B55E text note (UTF-8, optional) * 0x5BB5B55E text note (UTF-8, optional)
* 0x5BB5B55F probe points (UTF-8 lines "x,y,label,tempMc", optional)
*/ */
object Mdt { object Mdt {
const val SECTION_DDT = 0x5BB5B55B const val SECTION_DDT = 0x5BB5B55B
@@ -25,6 +26,34 @@ object Mdt {
const val BLOCK_FRAME = 0x5BB5B55D const val BLOCK_FRAME = 0x5BB5B55D
const val BLOCK_TXT = 0x5BB5B55E const val BLOCK_TXT = 0x5BB5B55E
/**
* Probe points captured with the photo. Stored as UTF-8 text ("x,y,label,tempMc"
* 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<Probe>): 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<Probe> {
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 private fun align4(n: Int): Int = (n + 3) / 4 * 4
fun u32(b: ByteArray, off: Int): Int = fun u32(b: ByteArray, off: Int): Int =
@@ -54,6 +83,7 @@ object Mdt {
info1: ByteArray?, info1: ByteArray?,
framePixels: ByteArray?, framePixels: ByteArray?,
text: ByteArray? = null, text: ByteArray? = null,
probes: ByteArray? = null,
): ByteArray { ): ByteArray {
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320) val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
out.write(jpg, 0, jpg.size) out.write(jpg, 0, jpg.size)
@@ -76,6 +106,7 @@ object Mdt {
emit(BLOCK_FRAME, framePixels) emit(BLOCK_FRAME, framePixels)
} }
text?.let { emit(BLOCK_TXT, it) } text?.let { emit(BLOCK_TXT, it) }
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) }
val bodyBytes = body.toByteArray() val bodyBytes = body.toByteArray()
val header = ByteArray(0x88) val header = ByteArray(0x88)
@@ -121,6 +152,7 @@ object Mdt {
framePixels = blocks[BLOCK_FRAME], framePixels = blocks[BLOCK_FRAME],
// block payloads are padded to 4; strip the NUL padding // block payloads are padded to 4; strip the NUL padding
text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') }, text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') },
probes = parseProbes(blocks[BLOCK_PROBES]),
) )
} }
@@ -148,5 +180,7 @@ object Mdt {
/** Raw 38400 B measurement frame (19200 x u16 LE), null when absent. */ /** Raw 38400 B measurement frame (19200 x u16 LE), null when absent. */
val framePixels: ByteArray?, val framePixels: ByteArray?,
val text: String?, val text: String?,
/** Probe points stored with the photo (empty when none). */
val probes: List<Probe> = emptyList(),
) )
} }
@@ -2,7 +2,6 @@ package com.mag160c.thermal.media
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import android.media.MediaCodec import android.media.MediaCodec
import android.media.MediaCodecInfo 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 * MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor
* .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec * .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec
* handles color conversion; frames arrive as ARGB bitmaps. * 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) { class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
private val fps = 15 private val fps = 15
private val bitRate = 2_000_000 private val bitRate = 2_000_000
private val lock = Any()
private var encoder: MediaCodec? = null private var encoder: MediaCodec? = null
private var inputSurface: android.view.Surface? = null private var inputSurface: android.view.Surface? = null
private var muxer: MediaMuxer? = 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 canvas = Canvas()
private val paint = Paint() 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 @Volatile
var outPath: File? = null var outPath: File? = null
private set private set
fun start(): Boolean { fun start(): Boolean = synchronized(lock) {
if (active.get()) return true if (active.get()) return true
return try { return try {
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply { 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") val tmp = File.createTempFile("mag160c", ".mp4")
muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
outPath = tmp outPath = tmp
frameCount = 0
droppedCount = 0
active.set(true) active.set(true)
true true
} catch (e: Exception) { } catch (e: Exception) {
releaseAll() DebugLog.log("rec", "start failed: ${e.javaClass.simpleName}: ${e.message}")
releaseAllLocked()
false false
} }
} }
fun isRecording(): Boolean = active.get() 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) { fun offerFrame(bmp: Bitmap) {
val surface = inputSurface ?: return
if (!active.get()) return if (!active.get()) return
val c = surface.lockCanvas(null) ?: return
try { try {
c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint) 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 { } finally {
surface.unlockCanvasAndPost(c) surface.unlockCanvasAndPost(c)
} }
drain(false) 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",
)
}
}
} }
/** Stop recording and finalize. Returns the output file. */ /** Stop recording and finalize. Returns the output file, or null. */
fun stop(): File? { fun stop(): File? {
if (!active.getAndSet(false)) return null if (!active.getAndSet(false)) return null
val enc = encoder ?: return null synchronized(lock) {
// drain with EOS val enc = encoder
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 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?.stop() }
runCatching { muxer?.release() } runCatching { muxer?.release() }
muxer = null muxer = null
runCatching { enc.stop() } runCatching { encoder?.stop() }
runCatching { enc.release() } runCatching { encoder?.release() }
encoder = null encoder = null
inputSurface?.release() runCatching { inputSurface?.release() }
inputSurface = null inputSurface = null
DebugLog.log("rec", "stopped: frames=$frameCount dropped=$droppedCount file=${f?.length()}")
return f return f
} }
}
private fun releaseAll() { private fun releaseAllLocked() {
runCatching { encoder?.stop() } runCatching { encoder?.stop() }
runCatching { encoder?.release() } runCatching { encoder?.release() }
encoder = null encoder = null
@@ -108,8 +166,8 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
muxer = null muxer = null
} }
private fun drain(end: Boolean) { /** Caller must hold [lock]; reads the muxer/encoder fields directly. */
val enc = encoder ?: return private fun drainLocked(enc: MediaCodec, end: Boolean) {
val mux = muxer ?: return val mux = muxer ?: return
val info = MediaCodec.BufferInfo() val info = MediaCodec.BufferInfo()
while (true) { 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
}
} }
@@ -3,10 +3,16 @@ package com.mag160c.thermal.media
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.graphics.Bitmap 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.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -21,6 +27,16 @@ object PhotoSaver {
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg" fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
/** Probe annotation burned into a saved photo. */
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. */ /** Encode a rendered ARGB frame to JPEG bytes. */
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray { fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
@@ -35,6 +51,177 @@ object PhotoSaver {
return out.toByteArray() 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<ProbeMark> = 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<ProbeMark>,
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. */ /** Save an MDT container into MediaStore. Returns the media uri string. */
fun saveMdt( fun saveMdt(
context: Context, context: Context,
@@ -67,6 +254,40 @@ object PhotoSaver {
return uri.toString() 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). */ /** Save a plain JPEG (no MDT wrapper). */
fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? { fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? {
val values = ContentValues().apply { val values = ContentValues().apply {
@@ -5,24 +5,41 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.Mdt
import com.mag160c.thermal.media.PhotoSaver
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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( class AnalyzeViewModel(
app: Application, app: Application,
private val containerBytes: ByteArray, private val containerBytes: ByteArray,
private val fileUri: android.net.Uri, private val fileUri: android.net.Uri,
) : AndroidViewModel(app) { ) : AndroidViewModel(app) {
/** A probe point in the SAVED IMAGE's own pixel space. */
data class Probe(val x: Int, val y: Int, val label: String, val tempC: Float)
val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes) val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes)
/** Raw measurement frame (19200 uint16) if present. */ /** Raw measurement frame (19200 uint16) used for temperatures. */
val rawFrame: IntArray? by lazy { val rawFrame: IntArray? by lazy {
parsed?.framePixels?.let { raw -> parsed?.framePixels?.let { raw ->
val out = IntArray(19200) val out = IntArray(19200)
@@ -34,80 +51,190 @@ class AnalyzeViewModel(
} }
/** Millidegree-C map of the measurement frame (19200 entries), if present. */ /** Millidegree-C map of the measurement frame (19200 entries), if present. */
private val _tempMap = androidx.compose.runtime.mutableStateOf<IntArray?>(null) private val tempMap: IntArray? by lazy {
val tempMap: IntArray? get() = _tempMap.value rawFrame?.let { TempMath.tempMapFromPixels(parsed!!.framePixels!!) }
/**
* 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<Pair<Int, Int>?>(null)
var probe: Pair<Int, Int>?
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 _render = MutableStateFlow<Bitmap?>(null) private val _render = MutableStateFlow<Bitmap?>(null)
/** The image to display: the saved JPEG. */
val render: StateFlow<Bitmap?> = _render val render: StateFlow<Bitmap?> = _render
private val _paletteIndex = MutableStateFlow(2) private val _paletteIndex = MutableStateFlow(2)
val paletteIndex: StateFlow<Int> = _paletteIndex val paletteIndex: StateFlow<Int> = _paletteIndex
/** Re-render the raw frame with the given palette + auto window. */ /** Editable probe list (snapshot state so the canvas redraws on change). */
fun render(paletteIdx: Int) { val probes = mutableStateListOf<Probe>()
val raw = rawFrame ?: return
_paletteIndex.value = paletteIdx /** Overall readouts shown in the side panel. */
private val _minTempC = mutableStateOf<Float?>(null)
val minTempC: Float? get() = _minTempC.value
private val _maxTempC = mutableStateOf<Float?>(null)
val maxTempC: Float? get() = _maxTempC.value
private val _centerTempC = mutableStateOf<Float?>(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) { 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 mn = Int.MAX_VALUE
var mx = -1 var mx = Int.MIN_VALUE
for (v in raw) { map?.forEach {
if (v < mn) mn = v if (it < mn) mn = it
if (v > mx) mx = v if (it > mx) mx = it
} }
if (mx <= mn) mx = mn + 1 withContext(Dispatchers.Main) {
val pal = com.mag160c.thermal.core.Palettes.buildAll()[paletteIdx.coerceIn(0, 11)] _render.value = bmp
val argb = IntArray(19200) probes.clear()
val scale = (255 shl 12) / (mx - mn) probes.addAll(loaded)
for (i in argb.indices) { if (map != null && map.isNotEmpty()) {
var g = ((raw[i] - mn) * scale) shr 8 _minTempC.value = mn / 1000f
if (g < 0) g = 0 else if (g > 255) g = 255 _maxTempC.value = mx / 1000f
argb[i] = pal[g] _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 fun decodeNote(): String? = parsed?.text
/** Probe temperature approximation at a raw pixel (millidegrees C). */ /** Save as a NEW photo: annotations baked in, probes stored in the container. */
fun probeTemp(x: Int, y: Int): Int? { fun saveAsNew(
val raw = rawFrame ?: return null context: android.content.Context,
if (x < 0 || y < 0 || x >= 160 || y >= 120) return null notes: String,
return com.mag160c.thermal.core.TempMath.countsToTempMc(raw[y * 160 + x]) 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) { fun saveNote(note: String, onDone: (Boolean) -> Unit) {
val bmp = _render.value val bmp = _render.value
if (bmp == null) { if (bmp == null) {
@@ -115,13 +242,16 @@ class AnalyzeViewModel(
return return
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(bmp) val jpg = PhotoSaver.encodeJpeg(bmp)
val mdt = Mdt.compose( val mdt = Mdt.compose(
jpg = jpg, jpg = jpg,
info0 = parsed?.info0, info0 = parsed?.info0,
info1 = parsed?.info1, info1 = parsed?.info1,
framePixels = parsed?.framePixels, framePixels = parsed?.framePixels,
text = note.toByteArray(Charsets.UTF_8), 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 ok = runCatching {
val ctx = getApplication<Application>() val ctx = getApplication<Application>()
@@ -6,23 +6,24 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -32,12 +33,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@@ -45,15 +46,28 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.mag160c.thermal.core.Palettes import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.gallery.GalleryViewModel 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 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 @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 context = LocalContext.current
val vm = remember(item.name) { val vm = remember(item.name) {
val bytes = runCatching { val bytes = runCatching {
@@ -65,21 +79,63 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
val paletteIdx by vm.paletteIndex.collectAsState() val paletteIdx by vm.paletteIndex.collectAsState()
var zoom by remember { mutableStateOf(1f) } var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) } var pan by remember { mutableStateOf(Offset.Zero) }
var showNote by remember { mutableStateOf(false) }
var reportName by remember { mutableStateOf<String?>(null) }
var note by remember { mutableStateOf(vm.decodeNote() ?: "") } var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
var showNote by remember { mutableStateOf(false) }
var saveResult by remember { mutableStateOf<String?>(null) }
var showPalette by remember { mutableStateOf(false) }
// probes live in the view model so 保存 can serialise them
val probes = vm.probes
LaunchedEffect(Unit) { vm.render(2) } DisposableEffect(item.name) {
onDispose { /* nothing to release yet */ }
}
Box( LaunchedEffect(Unit) { vm.load() }
modifier = Modifier
.fillMaxSize() Column(modifier = Modifier.fillMaxSize().background(Color.Black)) {
.background(Color.Black), // ---- top bar: title + actions ----
Row(
modifier = Modifier.fillMaxWidth().padding(6.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),
)
TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[paletteIdx]) }
TextButton(onClick = { showNote = true }) { Text("备注") }
TextButton(
onClick = {
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( Canvas(
modifier = Modifier modifier = Modifier
.aspectRatio(4f / 3f) .fillMaxSize()
.align(Alignment.Center)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTransformGestures { _, gesturePan, gestureZoom, _ -> detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 4f) zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
@@ -91,210 +147,176 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
} }
} }
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { pos -> vm.probe = tapProbe(vm.probe, pos, zoom, pan, size) } detectTapGestures { pos ->
val rect = imageRect(
Size(size.width.toFloat(), size.height.toFloat()),
zoom, pan, bmp.width, bmp.height,
)
vm.toggleProbeAt(pos, rect)
}
}, },
) { ) {
val img = render?.asImageBitmap() val rect = imageRect(
if (img != null) { Size(size.width.toFloat(), size.height.toFloat()),
val w = size.width * zoom zoom, pan, bmp.width, bmp.height,
val h = size.height * zoom )
val left = (size.width - w) / 2 + pan.x
val top = (size.height - h) / 2 + pan.y
drawImage( drawImage(
image = img, image = img,
dstOffset = androidx.compose.ui.unit.IntOffset(left.toInt(), top.toInt()), dstOffset = androidx.compose.ui.unit.IntOffset(
dstSize = IntSize(w.toInt(), h.toInt()), rect.left.toInt(), rect.top.toInt(),
)
}
val tempMap = vm.tempMap
if (tempMap != null) {
drawTemperatureOsd(tempMap)
vm.probe?.let { drawProbeMarker(it, tempMap, zoom, pan) }
}
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color(0x66000000))
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
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(
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()),
), ),
dstSize = IntSize(rect.width.toInt(), rect.height.toInt()),
) )
drawProbes(probes, rect, bmp.width, bmp.height)
} }
},
modifier = Modifier.padding(start = 8.dp),
) { Text("报告") }
} }
if (reportName != null) { // ---- side panel: the readouts the user asked to keep ----
Text( Column(
"已生成: $reportName",
color = Color.White,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .width(132.dp)
.padding(12.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) { if (showNote) {
NoteEditor( NoteEditor(
initial = note, initial = note,
onSave = { onSave = {
note = it note = it
vm.saveNote(it) { } vm.saveNote(it) { ok -> saveResult = if (ok) "备注已保存" else "备注保存失败" }
showNote = false showNote = false
}, },
onDismiss = { 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),
)
}
}
},
confirmButton = {},
)
} }
} }
private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y) @Composable
private fun TempRow(label: String, value: Float?) {
private const val TEMP_W = 160 Row(
private const val TEMP_H = 120 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. * Rect the bitmap occupies. The saved photo is already rotated, so it is drawn
* NOTE: unlike the live screen (which always draws the frame rotated 90 deg * 1:1 in its own orientation (no extra rotation here).
* 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.
*/ */
private fun imageRect(size: androidx.compose.ui.geometry.Size, zoom: Float, pan: Offset) = private fun imageRect(
androidx.compose.ui.geometry.Rect( size: Size,
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<Int, Int>?,
pos: Offset,
zoom: Float, zoom: Float,
pan: Offset, pan: Offset,
size: IntSize, bmpW: Int,
): Pair<Int, Int>? { bmpH: Int,
val rect = imageRect( ): androidx.compose.ui.geometry.Rect {
androidx.compose.ui.geometry.Size(size.width.toFloat(), size.height.toFloat()), // fit the bitmap into the canvas, preserving aspect
zoom, pan, val scale = minOf(size.width / bmpW, size.height / bmpH) * zoom
) val w = bmpW * scale
if (!rect.contains(pos)) return current val h = bmpH * scale
val x = ((pos.x - rect.left) / rect.width * TEMP_W).toInt().coerceIn(0, TEMP_W - 1) val left = (size.width - w) / 2 + pan.x
val y = ((pos.y - rect.top) / rect.height * TEMP_H).toInt().coerceIn(0, TEMP_H - 1) val top = (size.height - h) / 2 + pan.y
val hit = current?.let { return androidx.compose.ui.geometry.Rect(left, top, left + w, top + h)
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
} }
/** Temperature bar glued to the top of the displayed image (fit-rect based). */ /** White dot + ring + temperature label for every probe. */
private fun DrawScope.drawTemperatureOsd(map: IntArray) { private fun DrawScope.drawProbes(
if (map.size < TEMP_W * TEMP_H) return probes: List<AnalyzeViewModel.Probe>,
val rect = imageRect(size, 1f, Offset.Zero) rect: androidx.compose.ui.geometry.Rect,
var mn = Int.MAX_VALUE bmpW: Int,
var mx = Int.MIN_VALUE bmpH: Int,
for (v in map) { ) {
if (v < mn) mn = v if (probes.isEmpty()) return
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),
)
val paint = android.graphics.Paint().apply { val paint = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE color = android.graphics.Color.WHITE
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
isAntiAlias = true isAntiAlias = true
setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK)
} }
val baseline = rect.top + barH / 2f - (paint.ascent() + paint.descent()) / 2f val dot = android.graphics.Paint().apply {
drawIntoCanvas { canvas -> color = android.graphics.Color.WHITE
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<Int, Int>,
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()
isAntiAlias = true isAntiAlias = true
setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK)
} }
val padH = 6.dp.toPx() for (p in probes) {
val padV = 4.dp.toPx() val dx = p.x.toFloat() / bmpW
val tw = paint.measureText(label) val dy = p.y.toFloat() / bmpH
val boxW = tw + padH * 2 val cx = rect.left + dx * rect.width
val boxH = paint.textSize + padV * 2 val cy = rect.top + dy * rect.height
var boxLeft = cx + 8.dp.toPx() drawCircle(Color.White, 4.dp.toPx(), Offset(cx, cy))
if (boxLeft + boxW > rect.right) boxLeft = cx - 8.dp.toPx() - boxW drawCircle(
var boxTop = cy - 8.dp.toPx() - boxH Color.White, 9.dp.toPx(), Offset(cx, cy),
if (boxTop < rect.top) boxTop = cy + 8.dp.toPx() style = androidx.compose.ui.graphics.drawscope.Stroke(2.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 val label = "${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}"
drawIntoCanvas { canvas -> canvas.nativeCanvas.drawText(label, boxLeft + padH, baseline, paint) } 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) }
}
} }
@Composable @Composable
@@ -303,14 +325,8 @@ private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: ()
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("文字备注") }, title = { Text("文字备注") },
text = { text = { OutlinedTextField(value = text, onValueChange = { text = it }) },
OutlinedTextField(value = text, onValueChange = { text = it }) confirmButton = { TextButton(onClick = { onSave(text) }) { Text("保存") } },
}, dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
confirmButton = {
TextButton(onClick = { onSave(text) }) { Text("保存") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
},
) )
} }
@@ -39,6 +39,8 @@ import com.mag160c.thermal.ui.analyze.AnalyzeViewer
fun GalleryScreen(vm: GalleryViewModel = viewModel()) { fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
val items by vm.items.collectAsState() val items by vm.items.collectAsState()
val context = LocalContext.current val context = LocalContext.current
// The viewer must belong to the SAME ViewModel instance that the grid uses,
// so the selection made by a tap is the one the viewer opens.
var showViewer by remember { mutableStateOf(false) } var showViewer by remember { mutableStateOf(false) }
// runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE) // runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE)
@@ -54,6 +56,13 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
if (granted) vm.refresh() else launcher.launch(perm) if (granted) vm.refresh() else launcher.launch(perm)
} }
// System back closes the viewer instead of leaving the tab (or the app).
androidx.activity.compose.BackHandler(enabled = showViewer) {
showViewer = false
vm.select(null)
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp),
@@ -89,7 +98,7 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
) )
} }
Text( Text(
item.name.removePrefix("MAG160C_"), item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Color.White, color = Color.White,
modifier = Modifier modifier = Modifier
@@ -102,10 +111,24 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
} }
} }
// 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) { if (showViewer) {
val sel = vm.selected.value val sel = vm.selected.collectAsState().value
if (sel != null) { if (sel != null) {
AnalyzeViewer(item = sel, galleryVm = vm) 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,
)
}
}
} }
} }
} }
@@ -264,10 +264,11 @@ class LiveRenderer(
val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density) val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
drawGripText(canvas, text, cx, cy) drawGripText(canvas, text, cx, cy)
} }
// the trace toggle governs BOTH extremes (it used to leave the min // the trace setting chooses which extremes to mark (max / min / both)
// marker drawn, which read as "the switch only works halfway") if (state.traceMode.showsMax) {
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "") drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
}
if (state.traceMode.showsMin) {
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, "") drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, "")
} }
for (p in state.probes) { for (p in state.probes) {
@@ -107,13 +107,20 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
vm.refreshTemps() vm.refreshTemps()
} }
} }
// Apply orientation changes the moment they are made (also from the settings tab) // Apply orientation / palette / trace-mode changes the moment they happen
val orientation by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state // (including changes made on the settings tab), and on first launch.
val settings by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state
.collectAsState() .collectAsState()
LaunchedEffect(orientation) { LaunchedEffect(settings) {
vm.userRotateDeg = orientation.rotateDeg vm.userRotateDeg = settings.rotateDeg
vm.flipH = orientation.flipH vm.flipH = settings.flipH
vm.flipV = orientation.flipV 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 — // 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) ---- // ---- control TOP bar (glued to the portrait top edge) ----
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
@@ -218,16 +252,21 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp), modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp),
) { ) {
val on = state.maxTraceOn val tracing = state.traceMode != LiveViewModel.TraceMode.NONE
Icon( Icon(
painterResource(R.drawable.ic_target), "最高温追踪", painterResource(R.drawable.ic_target), "追踪",
tint = if (on) MaterialTheme.colorScheme.primary tint = if (tracing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
if (on) "追踪·开" else "追踪", when (state.traceMode) {
LiveViewModel.TraceMode.MAX -> "追高"
LiveViewModel.TraceMode.MIN -> "追低"
LiveViewModel.TraceMode.BOTH -> "追高·低"
LiveViewModel.TraceMode.NONE -> "追踪"
},
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (on) MaterialTheme.colorScheme.primary color = if (tracing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, 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) { private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) {
"no_device" -> "未检测到热像仪,请插入MAG160C" "no_device" -> "未检测到热像仪,请插入MAG160C"
"no_permission" -> "USB权限未授予" "no_permission" -> "USB权限未授予"
@@ -468,6 +512,12 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta
"connect_fail" -> "连接流程异常(查看调试日志)" "connect_fail" -> "连接流程异常(查看调试日志)"
"no_handshake" -> "相机无应答,请拔插热像仪重试" "no_handshake" -> "相机无应答,请拔插热像仪重试"
"no_stream_data" -> "已连接但无数据流(10秒),日志已记录" "no_stream_data" -> "已连接但无数据流(10秒),日志已记录"
"saved" -> "已保存到相册(DCIM/MAG160C"
"save_fail" -> "照片保存失败(查看调试日志)"
"recording" -> "录像中…"
"rec_done" -> "录像已保存到相册(DCIM/MAG160C"
"rec_fail" -> "录像启动失败(查看调试日志)"
"rec_save_fail" -> "录像保存失败(查看调试日志)"
else -> { else -> {
val prefix = if (state.status.startsWith("exception:")) { val prefix = if (state.status.startsWith("exception:")) {
"异常:" + state.status.removePrefix("exception:") "异常:" + state.status.removePrefix("exception:")
@@ -2,10 +2,14 @@ package com.mag160c.thermal.ui.live
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.core.TempMath import com.mag160c.thermal.core.TempMath
import com.mag160c.thermal.usb.IrSession import com.mag160c.thermal.usb.IrSession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* Live view state machine: USB permission -> link -> stream -> OSD stats. * 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 maxPos: Int = -1,
val minPos: Int = -1, val minPos: Int = -1,
val identity: IrSession.CameraIdentity? = null, 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<ProbePoint> = emptyList(), val probes: List<ProbePoint> = emptyList(),
/** Visible-light PIP overlay (Phase E). */ /** Visible-light PIP overlay (Phase E). */
val pipOn: Boolean = false, val pipOn: Boolean = false,
@@ -37,6 +46,27 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val pipYf: Float = 0f, 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()) private val _state = MutableStateFlow(LiveState())
val state: StateFlow<LiveState> = _state val state: StateFlow<LiveState> = _state
@@ -252,9 +282,25 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4)) _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() { 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) ---- // ---- 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 frame = latestFrame ?: return
val s = session 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 rawFrame = s.lastRawFrame
val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) { val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) {
rawFrame.copyOfRange(0x1C, 0x1C + 38400) rawFrame.copyOfRange(0x1C, 0x1C + 38400)
} else null } 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( val mdt = com.mag160c.thermal.media.Mdt.compose(
jpg = jpg, jpg = jpg,
info0 = s.lastInfo0, info0 = s.lastInfo0,
info1 = s.lastInfo1, info1 = s.lastInfo1,
framePixels = pixels, framePixels = pixels,
probes = probeBlock,
) )
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt( val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(), context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
@@ -391,13 +467,31 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
if (r.start()) { if (r.start()) {
recorder = r recorder = r
_state.value = _state.value.copy(status = "recording") _state.value = _state.value.copy(status = "recording")
} else {
_state.value = _state.value.copy(status = "rec_fail")
} }
} else { } 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() val file = rec.stop()
recorder = null recorder = null
if (file != null) { if (file == null) {
val name = "MAG160C_V_${com.mag160c.thermal.media.PhotoSaver.fileName()}" _state.value = _state.value.copy(status = "rec_fail")
_state.value = _state.value.copy(status = "rec_done") 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",
)
}
} }
} }
} }
@@ -70,6 +70,9 @@ fun RemoteClientListScreen(
onDispose { scope.cancel() } onDispose { scope.cancel() }
} }
// System back returns to the settings screen instead of leaving the app
androidx.activity.compose.BackHandler(enabled = true) { onBack() }
LaunchedEffect(scanGeneration) { LaunchedEffect(scanGeneration) {
hosts = emptyList() hosts = emptyList()
scanning = true scanning = true
@@ -91,6 +91,11 @@ fun RemoteViewerScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
vm.disconnected.collect { reason -> onDisconnected(reason) } 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()) { Box(modifier = Modifier.fillMaxSize()) {
AndroidView( AndroidView(
@@ -5,33 +5,46 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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 * The settings screen writes [AppSettings] and the renderers need the new values
* new values immediately. Before this existed, the renderers polled AppSettings * at once. Before this existed, the renderers polled AppSettings every 400 ms
* every 400 ms from the live screen's loop — which only runs while the LIVE tab * from the live screen's loop — which only runs while the LIVE tab is composed,
* is composed, so a change made on the settings tab was applied only after * so a change made on the settings tab was applied late, or not until the app
* switching back and hoping the polling had not been torn down. Publishing here * was restarted (the user reported "settings revert to defaults after a
* makes the change take effect at once, wherever it was made. * restart", which was really "they were never applied").
*/ */
object ImageOrientationSettings { object ImageOrientationSettings {
data class State( data class State(
val rotateDeg: Int = 0, val rotateDeg: Int = 0,
val flipH: Boolean = false, val flipH: Boolean = false,
val flipV: 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()) private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state val state: StateFlow<State> = _state
fun publish(rotateDeg: Int, flipH: Boolean, flipV: Boolean) { fun publish(
_state.value = State(rotateDeg, flipH, flipV) 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) { fun publishFrom(context: Context) {
val s = AppSettings(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 var defaultPaletteIndex: Int
get() = sp.getInt("palette", 2) 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 var defaultEmissivityPercent: Int
get() = sp.getInt("emissivity", 100) get() = sp.getInt("emissivity", 100)
@@ -95,10 +124,12 @@ class AppSettings(context: Context) {
sp.edit().putBoolean("imageFlipV", v).apply() sp.edit().putBoolean("imageFlipV", v).apply()
ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, v) ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, v)
} }
init { init {
com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled) com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled)
// seed the observable with the persisted orientation settings // seed the observable with the persisted settings, so a fresh process
ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, imageFlipV) // starts with what the user chose (not the hard-coded defaults)
ImageOrientationSettings.publish(
imageRotateDeg, imageFlipH, imageFlipV, defaultPaletteIndex, traceMode,
)
} }
} }
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mag160c.thermal.core.Palettes import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.live.LiveViewModel
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -42,6 +43,8 @@ fun SettingsScreen(
var flipV by remember { mutableStateOf(settings.imageFlipV) } var flipV by remember { mutableStateOf(settings.imageFlipV) }
// local Compose copy of the language choice, so the row label refreshes // local Compose copy of the language choice, so the row label refreshes
var language by remember { mutableStateOf(settings.language) } 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 // remote-preview server toggle (Phase F); off by default, needs live USB
var remoteOn by remember { mutableStateOf(remoteHostRunning) } var remoteOn by remember { mutableStateOf(remoteHostRunning) }
var showNeedDevice by remember { mutableStateOf(false) } var showNeedDevice by remember { mutableStateOf(false) }
@@ -60,6 +63,15 @@ fun SettingsScreen(
"%.2f".format(settings.defaultEmissivityPercent / 100f), "%.2f".format(settings.defaultEmissivityPercent / 100f),
) { dialog = "emissivity" } ) { dialog = "emissivity" }
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" } 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( SettingRow(
"语言", "语言",
if (language == "zh") "中文" else "跟随系统", if (language == "zh") "中文" else "跟随系统",
@@ -223,6 +235,40 @@ fun SettingsScreen(
}, },
confirmButton = {}, 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( "about" -> AlertDialog(
onDismissRequest = { dialog = null }, onDismissRequest = { dialog = null },
title = { Text("关于") }, title = { Text("关于") },
@@ -105,4 +105,54 @@ class MdtTest {
assertEquals("38400 bytes = 19200 u16 LE", 19200, frame.size / 2) assertEquals("38400 bytes = 19200 u16 LE", 19200, frame.size / 2)
assertTrue("some non-zero payload", frame.any { it.toInt() != 0 }) 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)
}
} }
@@ -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<Int, Int> {
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<Int, Int>, actual: Pair<Int, Int>, 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<Int, Int> {
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))
}
}
Binary file not shown.
+14
View File
@@ -68,6 +68,20 @@
| 34 | 设置页依次点每一行,确认都有反应 | 调色板/发射率/报警温度/语言/旋转USB画面/云同步/关于 → 弹对话框;水平翻转/竖直翻转 → 文案在"已开启/已关闭"间切换;远程预览服务端 → 切换或弹"先连接热像仪";远程预览客户端 → 进入主机列表 | **没有点了没反应的行**(此前"语言"和"关于"是死行) | | 34 | 设置页依次点每一行,确认都有反应 | 调色板/发射率/报警温度/语言/旋转USB画面/云同步/关于 → 弹对话框;水平翻转/竖直翻转 → 文案在"已开启/已关闭"间切换;远程预览服务端 → 切换或弹"先连接热像仪";远程预览客户端 → 进入主机列表 | **没有点了没反应的行**(此前"语言"和"关于"是死行) |
| 35 | 在**设置页**改"竖直翻转"或"旋转USB画面",然后切回实时页 | (无日志) | 画面**立即**按新设置显示(设置变更即时下发;此前靠实时页 400ms 轮询,仅在实时页处于打开状态时才生效) | | 35 | 在**设置页**改"竖直翻转"或"旋转USB画面",然后切回实时页 | (无日志) | 画面**立即**按新设置显示(设置变更即时下发;此前靠实时页 400ms 轮询,仅在实时页处于打开状态时才生效) |
## 第二轮真机修复项(2026-09-11,重点验证)
| # | 操作 | 预期 | 通过标准 |
|---|------|------|----------|
| 36 | 实时页点"录像",等 10 秒,再点"停止" | `[rec] stopped: frames=<N> dropped=<N> file=<字节>`,随后实时页出现"录像已保存到相册"提示 | **不闪退**(此前停止即崩溃);相册/文件管理器 DCIM/MAG160C 出现 `MAG160C_V_*.mp4` 且**能播放**;日志 `frames` 应为几百(15fps×10s≈150 |
| 36b | 若录像仍失败 | `[rec] start failed: …``[rec] frame dropped (…) dropped=<N>` / `[rec] video publish failed: …` | 日志会指出是编码启动、丢帧还是入库失败;**不再有未捕获异常** |
| 37 | 相册页点任意一张照片 | (无日志) | **立即进入查看页**(此前点了没反应);按返回键**回到相册列表**,不是退出软件 |
| 38 | 分析页点图像任意位置 | (无日志) | 该处出现白点+圆环+`Pt* 温度`标签;侧边栏"测温点"列表同步新增一行;**再点同一点可删除** |
| 39 | 分析页点"保存" | 提示"已保存为新照片" | 相册出现一张 `MAG160C_*_edit.jpg`(**原照片仍在**);打开新照片能看到烧录的测温点标记 |
| 40 | 关掉 APP 完全重开,进设置页 | (无日志) | 之前改过的**旋转USB画面/水平翻转/竖直翻转/追踪/默认调色板**都还在(此前会变回默认) |
| 41 | 设置页改"追踪"为"最低温",回实时页 | (无日志) | 顶栏显示"追低",画面上**只标最低温**;改成"最高+最低"→ 两个都标;"关闭"→ 都不标 |
| 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) |
| 43 | 拍照前先在实时页点几个测温点,再拍照 | (无日志) | 照片上带这些测温点与温度;进分析页打开该照片,**测温点仍在**且可继续编辑 |
## 相机(PIP)失败时的表现(设计如此,不算 bug) ## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**: PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
+45
View File
@@ -451,6 +451,51 @@
(CAMERA 权限原本会让相机变必需,已显式声明为可选)。 (CAMERA 权限原本会让相机变必需,已显式声明为可选)。
**未 push**(按用户指令)。 **未 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,真机实测:温度/朝向/远程三类缺陷) ## 用户反馈修复 第十八轮(2026-09-11,真机实测:温度/朝向/远程三类缺陷)
用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理: 用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理: