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)
|
* 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.
@@ -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 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
|
||||||
|
|
||||||
|
|||||||
@@ -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,真机实测:温度/朝向/远程三类缺陷)
|
||||||
|
|
||||||
用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理:
|
用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理:
|
||||||
|
|||||||
Reference in New Issue
Block a user