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:
@@ -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<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
|
||||
|
||||
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<Probe> = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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. */
|
||||
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 {
|
||||
|
||||
@@ -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<IntArray?>(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<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 tempMap: IntArray? by lazy {
|
||||
rawFrame?.let { TempMath.tempMapFromPixels(parsed!!.framePixels!!) }
|
||||
}
|
||||
|
||||
private val _render = MutableStateFlow<Bitmap?>(null)
|
||||
|
||||
/** The image to display: the saved JPEG. */
|
||||
val render: StateFlow<Bitmap?> = _render
|
||||
|
||||
private val _paletteIndex = MutableStateFlow(2)
|
||||
val paletteIndex: StateFlow<Int> = _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<Probe>()
|
||||
|
||||
/** Overall readouts shown in the side panel. */
|
||||
private val _minTempC = mutableStateOf<Float?>(null)
|
||||
val minTempC: Float? get() = _minTempC.value
|
||||
private val _maxTempC = mutableStateOf<Float?>(null)
|
||||
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) {
|
||||
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<Application>()
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
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(
|
||||
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<Int, Int>?,
|
||||
pos: Offset,
|
||||
private fun imageRect(
|
||||
size: Size,
|
||||
zoom: Float,
|
||||
pan: Offset,
|
||||
size: IntSize,
|
||||
): Pair<Int, Int>? {
|
||||
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<AnalyzeViewModel.Probe>,
|
||||
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<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()
|
||||
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("取消") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<android.graphics.Bitmap?>(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<android.graphics.Bitmap?>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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<ProbePoint> = 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<LiveState> = _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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> = _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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("关于") },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -68,6 +68,20 @@
|
||||
| 34 | 设置页依次点每一行,确认都有反应 | 调色板/发射率/报警温度/语言/旋转USB画面/云同步/关于 → 弹对话框;水平翻转/竖直翻转 → 文案在"已开启/已关闭"间切换;远程预览服务端 → 切换或弹"先连接热像仪";远程预览客户端 → 进入主机列表 | **没有点了没反应的行**(此前"语言"和"关于"是死行) |
|
||||
| 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 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
|
||||
|
||||
|
||||
@@ -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,真机实测:温度/朝向/远程三类缺陷)
|
||||
|
||||
用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理:
|
||||
|
||||
Reference in New Issue
Block a user