android: fix analysis temperatures (store calibrated NUC data), marker sizing, bottom data panel; split album (zoom/delete) from analysis tab
This commit is contained in:
@@ -13,9 +13,18 @@ import java.io.ByteArrayOutputStream
|
||||
* DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
|
||||
* 0x5BB5B55B camera info (0x38B from command 66b)
|
||||
* 0x5BB5B55C second info block (0x38B from 66c, optional)
|
||||
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
|
||||
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE) — RAW sensor response
|
||||
* 0x5BB5B55E text note (UTF-8, optional)
|
||||
* 0x5BB5B55F probe points (UTF-8 lines "x,y,label,tempMc", optional)
|
||||
* 0x5BB5B560 NUC counts in PHOTO coordinates (19200 x uint16 LE, optional)
|
||||
*
|
||||
* WHY THE NUC BLOCK EXISTS (2026-09-11): the raw frame is the sensor response
|
||||
* BEFORE non-uniformity correction, so converting it directly yields nonsense
|
||||
* (the analysis panel showed 145 C max / -161 C min for a 30 C scene). The live
|
||||
* readouts use the pipeline's NUC output (counts), which is what the calibration
|
||||
* tables are valid for. The photo therefore carries that corrected data, already
|
||||
* transformed into the saved photo's own pixel order, so an offline temperature
|
||||
* lookup is a plain index into it and gives the same numbers the live view showed.
|
||||
*/
|
||||
object Mdt {
|
||||
const val SECTION_DDT = 0x5BB5B55B
|
||||
@@ -27,12 +36,17 @@ object Mdt {
|
||||
const val BLOCK_TXT = 0x5BB5B55E
|
||||
|
||||
/**
|
||||
* Probe points captured with the photo. Stored as UTF-8 text ("x,y,label,tempMc"
|
||||
* per line) so the values stay inspectable with any hex editor — the same
|
||||
* reasoning the vendor layout uses for its TXT section.
|
||||
* Probe points captured with the photo, in the SAVED PHOTO's pixel
|
||||
* coordinates (not sensor coordinates — that mismatch put the markers in the
|
||||
* wrong place when the photo was displayed). Stored as UTF-8 text
|
||||
* ("x,y,label,tempMc" per line) so the values stay inspectable with any hex
|
||||
* editor — the same reasoning the vendor layout uses for its TXT section.
|
||||
*/
|
||||
const val BLOCK_PROBES = 0x5BB5B55F
|
||||
|
||||
/** NUC (calibrated) counts in photo pixel order; see the header note. */
|
||||
const val BLOCK_NUC = 0x5BB5B560
|
||||
|
||||
/** One probe carried in an MDT file. */
|
||||
data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int)
|
||||
|
||||
@@ -84,6 +98,7 @@ object Mdt {
|
||||
framePixels: ByteArray?,
|
||||
text: ByteArray? = null,
|
||||
probes: ByteArray? = null,
|
||||
nucPixels: ByteArray? = null,
|
||||
): ByteArray {
|
||||
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
|
||||
out.write(jpg, 0, jpg.size)
|
||||
@@ -107,6 +122,7 @@ object Mdt {
|
||||
}
|
||||
text?.let { emit(BLOCK_TXT, it) }
|
||||
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) }
|
||||
nucPixels?.let { if (it.size == 38400) emit(BLOCK_NUC, it) }
|
||||
|
||||
val bodyBytes = body.toByteArray()
|
||||
val header = ByteArray(0x88)
|
||||
@@ -153,6 +169,7 @@ object Mdt {
|
||||
// block payloads are padded to 4; strip the NUL padding
|
||||
text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') },
|
||||
probes = parseProbes(blocks[BLOCK_PROBES]),
|
||||
nucPixels = blocks[BLOCK_NUC],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,10 +194,23 @@ object Mdt {
|
||||
val jpg: ByteArray,
|
||||
val info0: ByteArray?,
|
||||
val info1: ByteArray?,
|
||||
/** Raw 38400 B measurement frame (19200 x u16 LE), null when absent. */
|
||||
/**
|
||||
* Raw 38400 B sensor response (19200 x u16 LE). NOTE: this is PRE-NUC
|
||||
* data — do not convert it to temperature directly (see [nucPixels]).
|
||||
*/
|
||||
val framePixels: ByteArray?,
|
||||
val text: String?,
|
||||
/** Probe points stored with the photo (empty when none). */
|
||||
/** Probe points in the saved photo's pixel coordinates (empty when none). */
|
||||
val probes: List<Probe> = emptyList(),
|
||||
)
|
||||
/**
|
||||
* Calibrated NUC counts in the saved photo's pixel order (19200 x u16 LE),
|
||||
* valid input for TempMath.countsToTempMc. Null for photos taken before
|
||||
* 2026-09-11 — callers must then refuse to show temperatures rather than
|
||||
* computing wrong ones.
|
||||
*/
|
||||
val nucPixels: ByteArray? = null,
|
||||
) {
|
||||
/** True when this photo can be measured offline. */
|
||||
val hasTemperatureData: Boolean get() = nucPixels != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.mag160c.thermal.media
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Cheap "can this photo be measured offline?" check for the analysis list.
|
||||
*
|
||||
* A measurable photo carries the NUC block (see [Mdt.BLOCK_NUC]). Only the tail
|
||||
* and the DDT block table are read — never the whole file — so filtering a large
|
||||
* album stays fast.
|
||||
*/
|
||||
object MdtProbe {
|
||||
fun isMeasurable(context: Context, uri: Uri): Boolean = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val all = input.readBytes()
|
||||
val parsed = Mdt.parse(all) ?: return@use false
|
||||
parsed.hasTemperatureData
|
||||
} ?: false
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -27,9 +27,126 @@ object PhotoSaver {
|
||||
|
||||
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
|
||||
|
||||
/** Probe annotation burned into a saved photo. */
|
||||
/** Probe annotation burned into a saved photo (photo pixel coordinates). */
|
||||
data class ProbeMark(val x: Int, val y: Int, val label: String, val tempC: Float)
|
||||
|
||||
/**
|
||||
* Marker geometry relative to the IMAGE, not to screen density.
|
||||
*
|
||||
* The first version sized markers with screen density (4.5*density dot,
|
||||
* 9*density ring) while drawing into a 320x240 bitmap, so the rings came out
|
||||
* ~36 px across on a 320 px-wide photo — enormous (the user's "测温点太大了").
|
||||
* Sizes are now derived from the image width, keeping the same proportion the
|
||||
* live screen shows.
|
||||
*/
|
||||
private class MarkStyle(imageWidth: Int) {
|
||||
val scale = imageWidth / 320f
|
||||
val dotR = 2.6f * scale
|
||||
val ringR = 5.5f * scale
|
||||
val ringW = 1.4f * scale
|
||||
val textSize = 9f * scale
|
||||
val labelGap = 7f * scale
|
||||
val shadow = 1.5f * scale
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the NUC block for a photo: ONE count per photo pixel, so an offline
|
||||
* lookup is literally `counts[iy * photoWidth + ix]`.
|
||||
*
|
||||
* Why 1:1 with the photo and not the 160x120 sensor grid: the photo is
|
||||
* displayed in its own pixel space, and any attempt to keep a smaller grid
|
||||
* forces every caller to re-derive the rotation/flip/upscale — the exact class
|
||||
* of index confusion that produced both wrong temperatures and misplaced
|
||||
* markers. Costs 4x the bytes (153 KB) and removes the ambiguity entirely.
|
||||
*/
|
||||
fun buildPhotoOrderedCounts(
|
||||
nuc160: IntArray,
|
||||
orientation: Orientation,
|
||||
srcW: Int = 320,
|
||||
srcH: Int = 240,
|
||||
): IntArray {
|
||||
require(nuc160.size >= 160 * 120) { "expected 19200 NUC samples, got ${nuc160.size}" }
|
||||
val rot = ((orientation.rotateDeg % 360) + 360) % 360
|
||||
val outW = if (rot % 180 == 0) srcW else srcH
|
||||
val outH = if (rot % 180 == 0) srcH else srcW
|
||||
val out = IntArray(outW * outH)
|
||||
for (iy in 0 until outH) {
|
||||
for (ix in 0 until outW) {
|
||||
val s = photoToSensor(ix, iy, srcW, srcH, rot, orientation)
|
||||
out[iy * outW + ix] = nuc160[s[1] * 160 + s[0]]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo pixel -> SENSOR pixel: the exact inverse of the transform
|
||||
* [encodeRendered] applies to the bitmap (flips, then clockwise rotation) with
|
||||
* the 2x upscale in between.
|
||||
*
|
||||
* Buffer coords come from the documented forward map
|
||||
* rot 0 (bx,by) -> (bx, by) rot 180 -> (W-bx, H-by)
|
||||
* rot 90 (bx,by) -> (H-by, bx) rot 270 -> (by, W-bx)
|
||||
* inverted below; then the flips are undone, then the 2x upscale.
|
||||
*/
|
||||
fun photoToSensor(
|
||||
ix: Int,
|
||||
iy: Int,
|
||||
srcW: Int = 320,
|
||||
srcH: Int = 240,
|
||||
rot: Int,
|
||||
orientation: Orientation,
|
||||
): IntArray {
|
||||
val W = srcW
|
||||
val H = srcH
|
||||
var bx: Int
|
||||
var by: Int
|
||||
when (((rot % 360) + 360) % 360) {
|
||||
90 -> {
|
||||
bx = iy
|
||||
by = H - ix
|
||||
}
|
||||
180 -> {
|
||||
bx = W - ix
|
||||
by = H - iy
|
||||
}
|
||||
270 -> {
|
||||
bx = W - iy
|
||||
by = ix
|
||||
}
|
||||
else -> {
|
||||
bx = ix
|
||||
by = iy
|
||||
}
|
||||
}
|
||||
if (orientation.flipH) bx = W - bx
|
||||
if (orientation.flipV) by = H - by
|
||||
val sx = (bx / 2).coerceIn(0, 159)
|
||||
val sy = (by / 2).coerceIn(0, 119)
|
||||
return intArrayOf(sx, sy)
|
||||
}
|
||||
|
||||
/** Pack an IntArray of counts as u16 little-endian. */
|
||||
fun countsToBytes(counts: IntArray): ByteArray {
|
||||
val out = ByteArray(counts.size * 2)
|
||||
for (i in counts.indices) {
|
||||
val v = counts[i].coerceIn(0, 0xFFFF)
|
||||
out[i * 2] = (v and 0xFF).toByte()
|
||||
out[i * 2 + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Unpack u16 little-endian bytes into counts. */
|
||||
fun bytesToCounts(bytes: ByteArray): IntArray {
|
||||
val n = bytes.size / 2
|
||||
val out = IntArray(n)
|
||||
for (i in 0 until n) {
|
||||
out[i] = (bytes[i * 2].toInt() and 0xFF) or ((bytes[i * 2 + 1].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Orientation applied to a saved photo, mirroring what the live view showed
|
||||
* (the image is locked to the portrait frame plus the user's manual
|
||||
@@ -106,41 +223,61 @@ object PhotoSaver {
|
||||
|
||||
if (probes.isNotEmpty()) {
|
||||
val canvas = Canvas(outBmp)
|
||||
val s = MarkStyle(outW)
|
||||
// Probes are given in PHOTO pixel coordinates already (the capture
|
||||
// converts them together with the NUC data), so no extra transform.
|
||||
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2.5f * density
|
||||
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||
strokeWidth = s.ringW
|
||||
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
|
||||
}
|
||||
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.FILL
|
||||
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
|
||||
}
|
||||
val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 13f * density
|
||||
textSize = s.textSize
|
||||
typeface = Typeface.SANS_SERIF
|
||||
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
|
||||
}
|
||||
for (p in probes) {
|
||||
val pos = sensorToImage(p.x, p.y, w, h, rot, orientation)
|
||||
val sx = pos[0].toFloat()
|
||||
val sy = pos[1].toFloat()
|
||||
canvas.drawCircle(sx, sy, 4.5f * density, dot)
|
||||
canvas.drawCircle(sx, sy, 9f * density, ring)
|
||||
val label = (if (p.label.isNotEmpty()) "${p.label} " else "") +
|
||||
"%.1f℃".format(p.tempC)
|
||||
val tw = text.measureText(label)
|
||||
var tx = sx + 13f * density
|
||||
if (tx + tw > outW - 2f * density) tx = sx - 13f * density - tw
|
||||
val ty = (sy + text.textSize).coerceAtMost(outH - 4f * density)
|
||||
canvas.drawText(label, tx, ty, text)
|
||||
val sx = p.x.toFloat()
|
||||
val sy = p.y.toFloat()
|
||||
drawMarker(canvas, sx, sy, p.label, p.tempC, s, ring, dot, text, outW, outH)
|
||||
}
|
||||
}
|
||||
return encodeJpeg(outBmp, quality)
|
||||
}
|
||||
|
||||
/** One marker: filled dot, ring, and a temperature label that stays inside. */
|
||||
private fun drawMarker(
|
||||
canvas: Canvas,
|
||||
sx: Float,
|
||||
sy: Float,
|
||||
label: String,
|
||||
tempC: Float,
|
||||
s: MarkStyle,
|
||||
ring: Paint,
|
||||
dot: Paint,
|
||||
text: Paint,
|
||||
outW: Int,
|
||||
outH: Int,
|
||||
) {
|
||||
canvas.drawCircle(sx, sy, s.dotR, dot)
|
||||
canvas.drawCircle(sx, sy, s.ringR, ring)
|
||||
val full = (if (label.isNotEmpty()) "$label " else "") + "%.1f℃".format(tempC)
|
||||
val tw = text.measureText(full)
|
||||
val half = text.textSize / 2f
|
||||
var tx = sx + s.ringR + s.labelGap
|
||||
if (tx + tw > outW - 2f * s.scale) tx = sx - s.ringR - s.labelGap - tw
|
||||
if (tx < 2f * s.scale) tx = 2f * s.scale
|
||||
val ty = (sy + half).coerceIn(half + 2f * s.scale, outH - 2f * s.scale)
|
||||
canvas.drawText(full, tx, ty, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor pixel -> position in the FINAL (rotated, flipped) photo.
|
||||
*
|
||||
@@ -189,35 +326,29 @@ object PhotoSaver {
|
||||
?: return jpg
|
||||
val out = bmp.copy(android.graphics.Bitmap.Config.ARGB_8888, true) ?: return jpg
|
||||
val canvas = Canvas(out)
|
||||
val s = MarkStyle(out.width)
|
||||
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2.5f * density
|
||||
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||
strokeWidth = s.ringW
|
||||
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
|
||||
}
|
||||
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.FILL
|
||||
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
|
||||
}
|
||||
val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 13f * density
|
||||
textSize = s.textSize
|
||||
typeface = Typeface.SANS_SERIF
|
||||
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||
setShadowLayer(s.shadow, 0f, 0f, Color.BLACK)
|
||||
}
|
||||
for (p in probes) {
|
||||
val sx = p.x.toFloat()
|
||||
val sy = p.y.toFloat()
|
||||
canvas.drawCircle(sx, sy, 4.5f * density, dot)
|
||||
canvas.drawCircle(sx, sy, 9f * density, ring)
|
||||
val label = (if (p.label.isNotEmpty()) "${p.label} " else "") +
|
||||
"%.1f℃".format(p.tempC)
|
||||
val tw = text.measureText(label)
|
||||
var tx = sx + 13f * density
|
||||
if (tx + tw > out.width - 2f * density) tx = sx - 13f * density - tw
|
||||
val ty = (sy + text.textSize).coerceAtMost(out.height - 4f * density)
|
||||
canvas.drawText(label, tx, ty, text)
|
||||
drawMarker(
|
||||
canvas, p.x.toFloat(), p.y.toFloat(), p.label, p.tempC, s,
|
||||
ring, dot, text, out.width, out.height,
|
||||
)
|
||||
}
|
||||
return encodeJpeg(out)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,15 @@ fun AppRoot() {
|
||||
onDispose { DeviceOrientation.stop() }
|
||||
}
|
||||
|
||||
// Album taps open a plain photo viewer (zoom/delete); analysis-tab taps open
|
||||
// the measurement viewer. Keeping them separate is what makes the two tabs
|
||||
// different (the user reported they looked identical).
|
||||
var viewerItem by remember { mutableStateOf<com.mag160c.thermal.ui.gallery.GalleryViewModel.Item?>(null) }
|
||||
var analyzeItem by remember { mutableStateOf<com.mag160c.thermal.ui.gallery.GalleryViewModel.Item?>(null) }
|
||||
// the same view model instance the grids use, so selection/refresh stay in sync
|
||||
val galleryVm: com.mag160c.thermal.ui.gallery.GalleryViewModel =
|
||||
androidx.lifecycle.viewmodel.compose.viewModel()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
remoteHost != null -> RemoteViewerScreen(
|
||||
@@ -92,11 +101,27 @@ fun AppRoot() {
|
||||
remotePort = port
|
||||
},
|
||||
)
|
||||
viewerItem != null -> com.mag160c.thermal.ui.gallery.PhotoViewerScreen(
|
||||
item = viewerItem!!,
|
||||
vm = galleryVm,
|
||||
onClose = { viewerItem = null },
|
||||
)
|
||||
analyzeItem != null -> com.mag160c.thermal.ui.analyze.AnalyzeViewer(
|
||||
item = analyzeItem!!,
|
||||
galleryVm = galleryVm,
|
||||
onClose = { analyzeItem = null },
|
||||
density = androidx.compose.ui.platform.LocalDensity.current.density,
|
||||
)
|
||||
tab == 0 -> LiveScreen(vm = liveVm, onOpenGallery = { tab = 1 })
|
||||
tab == 1 || tab == 2 -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(bottom = 84.dp),
|
||||
) {
|
||||
GalleryScreen()
|
||||
// album = browse/zoom/delete; analysis = measure a measurable photo
|
||||
tab == 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
||||
GalleryScreen(vm = galleryVm, onOpen = { viewerItem = it })
|
||||
}
|
||||
tab == 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
||||
com.mag160c.thermal.ui.analyze.AnalyzeScreen(
|
||||
vm = galleryVm,
|
||||
onOpen = { analyzeItem = it },
|
||||
)
|
||||
}
|
||||
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
||||
SettingsScreen(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.mag160c.thermal.ui.analyze
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.Image
|
||||
import com.mag160c.thermal.ui.gallery.GalleryViewModel
|
||||
|
||||
/**
|
||||
* Analysis tab.
|
||||
*
|
||||
* Previously this tab rendered the SAME photo grid as the album tab, which is why
|
||||
* the two looked identical (user report). It now has its own purpose: pick a photo
|
||||
* to measure, with a short explanation of what the analysis offers, and it only
|
||||
* lists photos that actually carry temperature data.
|
||||
*/
|
||||
@Composable
|
||||
fun AnalyzeScreen(
|
||||
vm: GalleryViewModel,
|
||||
onOpen: (GalleryViewModel.Item) -> Unit,
|
||||
) {
|
||||
val items by vm.items.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var analyzed by remember { mutableStateOf<List<GalleryViewModel.Item>>(emptyList()) }
|
||||
|
||||
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
|
||||
android.Manifest.permission.READ_MEDIA_IMAGES
|
||||
else android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { vm.refresh() }
|
||||
LaunchedEffect(Unit) {
|
||||
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (granted) vm.refresh() else launcher.launch(perm)
|
||||
}
|
||||
// only photos with temperature data can be measured
|
||||
LaunchedEffect(items) {
|
||||
analyzed = items.filter { com.mag160c.thermal.media.MdtProbe.isMeasurable(context, it.uri) }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("离线分析 (${analyzed.size})", style = MaterialTheme.typography.titleMedium)
|
||||
Button(onClick = { vm.refresh() }) { Text("刷新") }
|
||||
}
|
||||
Text(
|
||||
"选择一张照片进入分析:查看测温点、点击图像增删测温点、保存为新照片。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
if (analyzed.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"暂无可分析的照片\n(需要先在实时页拍照)",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.padding(top = 4.dp)) {
|
||||
items(analyzed.size) { idx ->
|
||||
val item = analyzed[idx]
|
||||
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(item.name) { vm.thumbnail(item) { b -> bmp = b } }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(3f / 4f)
|
||||
.padding(1.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { onOpen(item) },
|
||||
) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
Image(
|
||||
bitmap = b.asImageBitmap(),
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(3.dp)
|
||||
.background(Color(0x99000000)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package com.mag160c.thermal.ui.analyze
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mag160c.thermal.core.TempMath
|
||||
import com.mag160c.thermal.media.Mdt
|
||||
import com.mag160c.thermal.media.PhotoSaver
|
||||
@@ -18,46 +18,35 @@ import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Offline MDT analysis (redesigned 2026-09-11).
|
||||
* Offline MDT analysis (2026-09-11).
|
||||
*
|
||||
* What is shown is the photo AS SAVED — the rendered, already-rotated JPEG. The
|
||||
* raw frame is used only to measure temperatures; the image is never re-coloured
|
||||
* here (changing the palette used to silently produce a different-looking photo,
|
||||
* which was confusing for a measurement record).
|
||||
*
|
||||
* Probes come from the container (each saved photo carries its probe list) and
|
||||
* can be added/removed by tapping; "保存" writes a NEW photo with the annotations
|
||||
* baked in and the updated probes stored, leaving the original file untouched.
|
||||
* Temperatures come from the NUC block stored in the photo — the CALIBRATED
|
||||
* counts the live screen measures — addressed by the photo's own pixel index.
|
||||
* The earlier version converted the RAW sensor frame, which is pre-NUC data and
|
||||
* produced 145 C max / -161 C min on a 30 C scene; photos taken before this
|
||||
* change have no NUC block, so they report "no temperature data" instead of
|
||||
* inventing numbers.
|
||||
*/
|
||||
class AnalyzeViewModel(
|
||||
app: Application,
|
||||
private val containerBytes: ByteArray,
|
||||
private val fileUri: android.net.Uri,
|
||||
) : AndroidViewModel(app) {
|
||||
/** A probe point in the SAVED IMAGE's own pixel space. */
|
||||
/** A probe point in the SAVED PHOTO's pixel space. */
|
||||
data class Probe(val x: Int, val y: Int, val label: String, val tempC: Float)
|
||||
|
||||
val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes)
|
||||
|
||||
/** Raw measurement frame (19200 uint16) used for temperatures. */
|
||||
val rawFrame: IntArray? by lazy {
|
||||
parsed?.framePixels?.let { raw ->
|
||||
val out = IntArray(19200)
|
||||
for (i in out.indices) {
|
||||
out[i] = (raw[i * 2].toInt() and 0xFF) or ((raw[i * 2 + 1].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
out
|
||||
}
|
||||
/** Calibrated counts in photo pixel order (null for older photos). */
|
||||
private val nucCounts: IntArray? by lazy {
|
||||
parsed?.nucPixels?.let { PhotoSaver.bytesToCounts(it) }
|
||||
}
|
||||
|
||||
/** Millidegree-C map of the measurement frame (19200 entries), if present. */
|
||||
private val tempMap: IntArray? by lazy {
|
||||
rawFrame?.let { TempMath.tempMapFromPixels(parsed!!.framePixels!!) }
|
||||
}
|
||||
val hasTemperatureData: Boolean get() = nucCounts != null && parsed?.hasTemperatureData == true
|
||||
|
||||
private val _render = MutableStateFlow<Bitmap?>(null)
|
||||
|
||||
/** The image to display: the saved JPEG. */
|
||||
/** The image to display: the saved JPEG, as-is. */
|
||||
val render: StateFlow<Bitmap?> = _render
|
||||
|
||||
private val _paletteIndex = MutableStateFlow(2)
|
||||
@@ -66,7 +55,6 @@ class AnalyzeViewModel(
|
||||
/** Editable probe list (snapshot state so the canvas redraws on change). */
|
||||
val probes = mutableStateListOf<Probe>()
|
||||
|
||||
/** Overall readouts shown in the side panel. */
|
||||
private val _minTempC = mutableStateOf<Float?>(null)
|
||||
val minTempC: Float? get() = _minTempC.value
|
||||
private val _maxTempC = mutableStateOf<Float?>(null)
|
||||
@@ -74,7 +62,12 @@ class AnalyzeViewModel(
|
||||
private val _centerTempC = mutableStateOf<Float?>(null)
|
||||
val centerTempC: Float? get() = _centerTempC.value
|
||||
|
||||
/** Image size of the displayed (saved) photo, in pixels. */
|
||||
/** Min/max position, in photo pixels, for the on-image markers. */
|
||||
private val _minPos = mutableStateOf<Int>(-1)
|
||||
val minPos: Int get() = _minPos.value
|
||||
private val _maxPos = mutableStateOf<Int>(-1)
|
||||
val maxPos: Int get() = _maxPos.value
|
||||
|
||||
private val _imageW = mutableStateOf(320)
|
||||
val imageW: Int get() = _imageW.value
|
||||
private val _imageH = mutableStateOf(240)
|
||||
@@ -88,40 +81,58 @@ class AnalyzeViewModel(
|
||||
_imageW.value = bmp.width
|
||||
_imageH.value = bmp.height
|
||||
}
|
||||
// stored probes (from the container) -> editable list
|
||||
val stored = parsed?.probes.orEmpty()
|
||||
val loaded = stored.mapNotNull { p ->
|
||||
val t = tempMap?.get(p.y.coerceIn(0, 119) * 160 + p.x.coerceIn(0, 159))
|
||||
?: p.tempMc
|
||||
Probe(p.x, p.y, p.label, t / 1000f)
|
||||
}
|
||||
// overall stats
|
||||
val map = tempMap
|
||||
val counts = nucCounts
|
||||
var mn = Int.MAX_VALUE
|
||||
var mx = Int.MIN_VALUE
|
||||
map?.forEach {
|
||||
if (it < mn) mn = it
|
||||
if (it > mx) mx = it
|
||||
var mnPos = -1
|
||||
var mxPos = -1
|
||||
counts?.forEachIndexed { i, v ->
|
||||
if (v < mn) { mn = v; mnPos = i }
|
||||
if (v > mx) { mx = v; mxPos = i }
|
||||
}
|
||||
// stored probes already carry photo coordinates and a measured temp;
|
||||
// re-measure from the NUC block when available so the panel always
|
||||
// reflects the stored data rather than a stale label
|
||||
val loaded = (parsed?.probes.orEmpty()).map { p ->
|
||||
Probe(p.x, p.y, p.label, measure(p.x, p.y) ?: (p.tempMc / 1000f))
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
_render.value = bmp
|
||||
probes.clear()
|
||||
probes.addAll(loaded)
|
||||
if (map != null && map.isNotEmpty()) {
|
||||
if (counts != null) {
|
||||
_minTempC.value = mn / 1000f
|
||||
_maxTempC.value = mx / 1000f
|
||||
_centerTempC.value = map[60 * 160 + 80] / 1000f
|
||||
_minPos.value = mnPos
|
||||
_maxPos.value = mxPos
|
||||
_centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a tap in canvas space to the SAVED IMAGE's pixel space and toggle a
|
||||
* probe there. The saved photo is already rotated/flipped, so this is a
|
||||
* plain rect mapping (no extra transform).
|
||||
* Temperature (C) at a pixel of the SAVED photo.
|
||||
* The stored NUC block is 1:1 with the photo (see PhotoSaver.buildPhotoOrderedCounts),
|
||||
* so this is a plain index — no rotation/flip/upscale math that could disagree
|
||||
* with how the markers were placed.
|
||||
* Returns null when the photo has no NUC data — never a fabricated number.
|
||||
*/
|
||||
fun toggleProbeAt(pos: androidx.compose.ui.geometry.Offset, rect: androidx.compose.ui.geometry.Rect) {
|
||||
fun measure(ix: Int, iy: Int): Float? {
|
||||
val counts = nucCounts ?: return null
|
||||
val w = _imageW.value
|
||||
val h = _imageH.value
|
||||
if (ix < 0 || iy < 0 || ix >= w || iy >= h) return null
|
||||
val idx = iy * w + ix
|
||||
if (idx < 0 || idx >= counts.size) return null
|
||||
return TempMath.countsToTempMc(counts[idx]) / 1000f
|
||||
}
|
||||
|
||||
/** Tap in canvas space -> photo pixel; toggles a probe there. */
|
||||
fun toggleProbeAt(
|
||||
pos: androidx.compose.ui.geometry.Offset,
|
||||
rect: androidx.compose.ui.geometry.Rect,
|
||||
) {
|
||||
if (rect.width <= 0f || rect.height <= 0f) return
|
||||
if (pos.x < rect.left || pos.x > rect.right || pos.y < rect.top || pos.y > rect.bottom) return
|
||||
val ix = ((pos.x - rect.left) / rect.width * _imageW.value).toInt()
|
||||
@@ -129,8 +140,8 @@ class AnalyzeViewModel(
|
||||
val iy = ((pos.y - rect.top) / rect.height * _imageH.value).toInt()
|
||||
.coerceIn(0, _imageH.value - 1)
|
||||
|
||||
// near an existing probe? remove it
|
||||
val thr = 12f
|
||||
// marker footprint scales with the image, so the hit radius must too
|
||||
val thr = (_imageW.value / 320f * 14f).coerceAtLeast(8f)
|
||||
val hit = probes.indexOfFirst { p ->
|
||||
val dx = (p.x - ix).toFloat()
|
||||
val dy = (p.y - iy).toFloat()
|
||||
@@ -140,58 +151,13 @@ class AnalyzeViewModel(
|
||||
probes.removeAt(hit)
|
||||
return
|
||||
}
|
||||
val t = tempAtImagePixel(ix, iy)
|
||||
probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t ?: 0f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperature for a pixel of the SAVED image. The stored photo may be rotated
|
||||
* relative to the sensor frame, so the image pixel is mapped back through the
|
||||
* same rotation the capture used (recorded in the container's orientation
|
||||
* when available; otherwise the probe simply reports the sensor-frame value
|
||||
* at the equivalent position).
|
||||
*/
|
||||
private fun tempAtImagePixel(ix: Int, iy: Int): Float? {
|
||||
val map = tempMap ?: return null
|
||||
val w = _imageW.value
|
||||
val h = _imageH.value
|
||||
// inverse of the capture rotation: the photo is the sensor frame rotated
|
||||
// clockwise by `rot`; map the image pixel back to sensor coordinates
|
||||
val rot = captureRotation()
|
||||
val (sx, sy) = when (rot) {
|
||||
90 -> {
|
||||
// image (W=h_src, H=w_src) pixel -> sensor (x, y)
|
||||
val x = iy.toFloat() / h * 160f
|
||||
val y = (1f - ix.toFloat() / w) * 120f
|
||||
x to y
|
||||
}
|
||||
180 -> {
|
||||
val x = (1f - ix.toFloat() / w) * 160f
|
||||
val y = (1f - iy.toFloat() / h) * 120f
|
||||
x to y
|
||||
}
|
||||
270 -> {
|
||||
val x = (1f - iy.toFloat() / h) * 160f
|
||||
val y = ix.toFloat() / w * 120f
|
||||
x to y
|
||||
}
|
||||
else -> {
|
||||
val x = ix.toFloat() / w * 160f
|
||||
val y = iy.toFloat() / h * 120f
|
||||
x to y
|
||||
}
|
||||
val t = measure(ix, iy)
|
||||
if (t == null) {
|
||||
// no calibrated data: still allow the marker, but label it honestly
|
||||
probes.add(Probe(ix, iy, "Pt${probes.size + 1}", 0f))
|
||||
} else {
|
||||
probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t))
|
||||
}
|
||||
val cx = sx.toInt().coerceIn(0, 159)
|
||||
val cy = sy.toInt().coerceIn(0, 119)
|
||||
return map[cy * 160 + cx] / 1000f
|
||||
}
|
||||
|
||||
/** Rotation the capture baked into the JPEG (from the stored photo size). */
|
||||
private fun captureRotation(): Int {
|
||||
val w = _imageW.value
|
||||
val h = _imageH.value
|
||||
// sensor frame is 4:3 landscape; a portrait photo means 90/270 was applied
|
||||
return if (h > w) 90 else 0
|
||||
}
|
||||
|
||||
fun setPaletteIndex(idx: Int) {
|
||||
@@ -200,7 +166,7 @@ class AnalyzeViewModel(
|
||||
|
||||
fun decodeNote(): String? = parsed?.text
|
||||
|
||||
/** Save as a NEW photo: annotations baked in, probes stored in the container. */
|
||||
/** Save as a NEW photo: annotations baked in, probes + NUC stored. */
|
||||
fun saveAsNew(
|
||||
context: android.content.Context,
|
||||
notes: String,
|
||||
@@ -215,7 +181,6 @@ class AnalyzeViewModel(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92)
|
||||
val marks = probes.map { PhotoSaver.ProbeMark(it.x, it.y, it.label, it.tempC) }
|
||||
// bake the markers onto the already-rendered image
|
||||
val annotated = PhotoSaver.annotateJpeg(jpg, marks, density)
|
||||
val mdt = Mdt.compose(
|
||||
jpg = annotated,
|
||||
@@ -226,6 +191,8 @@ class AnalyzeViewModel(
|
||||
probes = Mdt.encodeProbes(
|
||||
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
|
||||
),
|
||||
// carry the temperature data forward so the edited photo stays measurable
|
||||
nucPixels = parsed?.nucPixels,
|
||||
)
|
||||
val name = "MAG160C_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
.format(java.util.Date())}_edit.jpg"
|
||||
@@ -234,13 +201,9 @@ class AnalyzeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the note into the ORIGINAL container (kept for the note editor). */
|
||||
/** Save the note into the ORIGINAL container. */
|
||||
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
|
||||
val bmp = _render.value
|
||||
if (bmp == null) {
|
||||
onDone(false)
|
||||
return
|
||||
}
|
||||
val bmp = _render.value ?: run { onDone(false); return }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val jpg = PhotoSaver.encodeJpeg(bmp)
|
||||
val mdt = Mdt.compose(
|
||||
@@ -252,6 +215,7 @@ class AnalyzeViewModel(
|
||||
probes = Mdt.encodeProbes(
|
||||
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
|
||||
),
|
||||
nucPixels = parsed?.nucPixels,
|
||||
)
|
||||
val ok = runCatching {
|
||||
val ctx = getApplication<Application>()
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
package com.mag160c.thermal.ui.analyze
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -49,17 +50,15 @@ import com.mag160c.thermal.ui.gallery.GalleryViewModel
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Offline MDT analysis (redesigned 2026-09-11 per the user's request):
|
||||
* Offline MDT analysis (layout revised 2026-09-11 per user feedback).
|
||||
*
|
||||
* - the photo is shown AS SAVED (the rendered, rotated image — no re-render of
|
||||
* the raw frame with a different palette);
|
||||
* - tapping the image adds a temperature probe; tapping an existing one removes
|
||||
* it (the same interaction as the live screen);
|
||||
* - "保存" writes a NEW photo (rotation/annotations baked in, probes stored in
|
||||
* the container) and keeps the original untouched;
|
||||
* - the side panel shows the overall min / max / centre temperatures, and the
|
||||
* palette selector only affects that panel's number formatting hint — the
|
||||
* image itself is never re-coloured here.
|
||||
* Layout: the photo occupies the top area, the measurements are a compact panel
|
||||
* along the BOTTOM (the side column wasted most of a portrait screen and looked
|
||||
* unbalanced), with the actions in a slim title row.
|
||||
*
|
||||
* The image shown is the saved photo as-is; tapping adds/removes a probe. Marker
|
||||
* sizes scale with the IMAGE, so they look the same as on the live screen instead
|
||||
* of covering the photo.
|
||||
*/
|
||||
@Composable
|
||||
fun AnalyzeViewer(
|
||||
@@ -76,69 +75,61 @@ fun AnalyzeViewer(
|
||||
AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri)
|
||||
}
|
||||
val render by vm.render.collectAsState()
|
||||
val paletteIdx by vm.paletteIndex.collectAsState()
|
||||
var zoom by remember { mutableStateOf(1f) }
|
||||
var pan by remember { mutableStateOf(Offset.Zero) }
|
||||
var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
|
||||
var showNote by remember { mutableStateOf(false) }
|
||||
var saveResult by remember { mutableStateOf<String?>(null) }
|
||||
var showPalette by remember { mutableStateOf(false) }
|
||||
// probes live in the view model so 保存 can serialise them
|
||||
val probes = vm.probes
|
||||
|
||||
DisposableEffect(item.name) {
|
||||
onDispose { /* nothing to release yet */ }
|
||||
}
|
||||
var toast by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) { vm.load() }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
// ---- top bar: title + actions ----
|
||||
Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) {
|
||||
// ---- slim title row ----
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onClose) { Text("返回") }
|
||||
Text(
|
||||
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f).padding(start = 4.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[paletteIdx]) }
|
||||
TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[vm.paletteIndex.value]) }
|
||||
TextButton(onClick = { showNote = true }) { Text("备注") }
|
||||
TextButton(
|
||||
onClick = {
|
||||
vm.saveAsNew(context, notes = note, density = density) { ok ->
|
||||
saveResult = if (ok) "已保存为新照片" else "保存失败"
|
||||
toast = if (ok) "已保存为新照片" else "保存失败"
|
||||
if (ok) galleryVm.refresh()
|
||||
}
|
||||
},
|
||||
) { Text("保存") }
|
||||
}
|
||||
HorizontalDivider()
|
||||
HorizontalDivider(color = Color(0x33FFFFFF))
|
||||
|
||||
// ---- image ----
|
||||
val bmp = render
|
||||
if (bmp == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("无法读取图像(缺少原始测温数据)", color = Color.White)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// ---- image with the side panel ----
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (bmp == null) {
|
||||
Text(
|
||||
if (vm.hasTemperatureData) "载入中…" else "无温度数据(旧照片)",
|
||||
color = Color.Gray,
|
||||
)
|
||||
} else {
|
||||
val img = bmp.asImageBitmap()
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
|
||||
zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
|
||||
zoom = (zoom * gestureZoom).coerceIn(1f, 6f)
|
||||
pan += gesturePan
|
||||
if (zoom <= 1.01f) {
|
||||
zoom = 1f
|
||||
@@ -148,18 +139,11 @@ fun AnalyzeViewer(
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { pos ->
|
||||
val rect = imageRect(
|
||||
Size(size.width.toFloat(), size.height.toFloat()),
|
||||
zoom, pan, bmp.width, bmp.height,
|
||||
)
|
||||
vm.toggleProbeAt(pos, rect)
|
||||
vm.toggleProbeAt(pos, imageRect(currentSize(), zoom, pan, bmp))
|
||||
}
|
||||
},
|
||||
) {
|
||||
val rect = imageRect(
|
||||
Size(size.width.toFloat(), size.height.toFloat()),
|
||||
zoom, pan, bmp.width, bmp.height,
|
||||
)
|
||||
val rect = imageRect(size, zoom, pan, bmp)
|
||||
drawImage(
|
||||
image = img,
|
||||
dstOffset = androidx.compose.ui.unit.IntOffset(
|
||||
@@ -167,44 +151,67 @@ fun AnalyzeViewer(
|
||||
),
|
||||
dstSize = IntSize(rect.width.toInt(), rect.height.toInt()),
|
||||
)
|
||||
drawProbes(probes, rect, bmp.width, bmp.height)
|
||||
// extremes, only when the photo carries temperature data
|
||||
if (vm.hasTemperatureData) {
|
||||
drawExtreme(vm.maxPos, rect, vm.imageW, vm.imageH, "高")
|
||||
drawExtreme(vm.minPos, rect, vm.imageW, vm.imageH, "低")
|
||||
}
|
||||
drawProbes(vm.probes, rect, vm.imageW, vm.imageH)
|
||||
}
|
||||
}
|
||||
// ---- side panel: the readouts the user asked to keep ----
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(132.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color(0xFF101010))
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Text("温度", color = Color.White, style = MaterialTheme.typography.labelLarge)
|
||||
HorizontalDivider(Modifier.padding(vertical = 6.dp))
|
||||
TempRow("最高", vm.maxTempC)
|
||||
TempRow("最低", vm.minTempC)
|
||||
TempRow("中心", vm.centerTempC)
|
||||
HorizontalDivider(Modifier.padding(vertical = 6.dp))
|
||||
Text(
|
||||
"测温点 ${probes.size}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
"点击图像添加/删除",
|
||||
color = Color.Gray,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
probes.forEach { p ->
|
||||
Text(
|
||||
"${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- bottom measurement panel ----
|
||||
Surface(
|
||||
color = Color(0xFF1B1B20),
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Stat("最高", vm.maxTempC)
|
||||
Stat("最低", vm.minTempC)
|
||||
Stat("中心", vm.centerTempC)
|
||||
}
|
||||
saveResult?.let {
|
||||
HorizontalDivider(Modifier.padding(vertical = 6.dp))
|
||||
Text(it, color = Color(0xFF80FF80), style = MaterialTheme.typography.labelSmall)
|
||||
HorizontalDivider(Modifier.padding(vertical = 6.dp), color = Color(0x33FFFFFF))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"测温点 ${vm.probes.size}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Text(
|
||||
if (vm.hasTemperatureData) " 点击图像添加/删除" else " 无温度数据,无法测温",
|
||||
color = Color.Gray,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
vm.probes.forEach { p ->
|
||||
Text(
|
||||
" ${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}",
|
||||
color = Color(0xFFFFD54F),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
toast?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Color(0xFF80FF80),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
LaunchedEffect(it) {
|
||||
kotlinx.coroutines.delay(2500)
|
||||
toast = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,7 +222,7 @@ fun AnalyzeViewer(
|
||||
initial = note,
|
||||
onSave = {
|
||||
note = it
|
||||
vm.saveNote(it) { ok -> saveResult = if (ok) "备注已保存" else "备注保存失败" }
|
||||
vm.saveNote(it) { ok -> toast = if (ok) "备注已保存" else "备注保存失败" }
|
||||
showNote = false
|
||||
},
|
||||
onDismiss = { showNote = false },
|
||||
@@ -224,101 +231,131 @@ fun AnalyzeViewer(
|
||||
if (showPalette) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showPalette = false },
|
||||
title = { Text("调色板(仅影响显示提示,不改图)") },
|
||||
title = { Text("调色板") },
|
||||
text = {
|
||||
Column {
|
||||
Palettes.NAMES.forEachIndexed { idx, name ->
|
||||
Text(
|
||||
name,
|
||||
color = if (idx == paletteIdx) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
vm.setPaletteIndex(idx)
|
||||
showPalette = false
|
||||
}
|
||||
.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"仅作为显示参考记录;离线分析不改动已保存的照片。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showPalette = false }) { Text("好") }
|
||||
},
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One number with its caption, for the bottom panel. */
|
||||
@Composable
|
||||
private fun TempRow(label: String, value: Float?) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
private fun Stat(label: String, value: Float?) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label, color = Color.Gray, style = MaterialTheme.typography.labelSmall)
|
||||
Text(
|
||||
value?.let { "%.1f℃".format(Locale.US, it) } ?: "--",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect the bitmap occupies. The saved photo is already rotated, so it is drawn
|
||||
* 1:1 in its own orientation (no extra rotation here).
|
||||
*/
|
||||
private fun androidx.compose.ui.unit.IntSize.toSize(): Size =
|
||||
Size(width.toFloat(), height.toFloat())
|
||||
|
||||
private fun imageRect(
|
||||
size: Size,
|
||||
zoom: Float,
|
||||
pan: Offset,
|
||||
bmpW: Int,
|
||||
bmpH: Int,
|
||||
bmp: android.graphics.Bitmap,
|
||||
): androidx.compose.ui.geometry.Rect {
|
||||
// fit the bitmap into the canvas, preserving aspect
|
||||
val scale = minOf(size.width / bmpW, size.height / bmpH) * zoom
|
||||
val w = bmpW * scale
|
||||
val h = bmpH * scale
|
||||
val scale = minOf(size.width / bmp.width, size.height / bmp.height) * zoom
|
||||
val w = bmp.width * scale
|
||||
val h = bmp.height * scale
|
||||
val left = (size.width - w) / 2 + pan.x
|
||||
val top = (size.height - h) / 2 + pan.y
|
||||
return androidx.compose.ui.geometry.Rect(left, top, left + w, top + h)
|
||||
}
|
||||
|
||||
/** White dot + ring + temperature label for every probe. */
|
||||
/** Marker geometry in IMAGE pixels, mirroring PhotoSaver.MarkStyle. */
|
||||
private class MarkerStyle(imageW: Int) {
|
||||
val scale = imageW / 320f
|
||||
val dotR = 2.6f * scale
|
||||
val ringR = 5.5f * scale
|
||||
val ringW = 1.4f * scale
|
||||
val textSize = 9f * scale
|
||||
val gap = 7f * scale
|
||||
}
|
||||
|
||||
private fun DrawScope.markerScaleFactor(rect: androidx.compose.ui.geometry.Rect, imageW: Int): Float =
|
||||
rect.width / imageW
|
||||
|
||||
private fun DrawScope.drawProbes(
|
||||
probes: List<AnalyzeViewModel.Probe>,
|
||||
rect: androidx.compose.ui.geometry.Rect,
|
||||
bmpW: Int,
|
||||
bmpH: Int,
|
||||
imageW: Int,
|
||||
imageH: Int,
|
||||
) {
|
||||
if (probes.isEmpty()) return
|
||||
val k = markerScaleFactor(rect, imageW)
|
||||
val st = MarkerStyle(imageW)
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.WHITE
|
||||
textSize = 12.sp.toPx()
|
||||
textSize = st.textSize * k
|
||||
isAntiAlias = true
|
||||
setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK)
|
||||
setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK)
|
||||
}
|
||||
val dot = android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.WHITE
|
||||
isAntiAlias = true
|
||||
setShadowLayer(3f, 0f, 0f, android.graphics.Color.BLACK)
|
||||
setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK)
|
||||
}
|
||||
for (p in probes) {
|
||||
val dx = p.x.toFloat() / bmpW
|
||||
val dy = p.y.toFloat() / bmpH
|
||||
val cx = rect.left + dx * rect.width
|
||||
val cy = rect.top + dy * rect.height
|
||||
drawCircle(Color.White, 4.dp.toPx(), Offset(cx, cy))
|
||||
drawCircle(
|
||||
Color.White, 9.dp.toPx(), Offset(cx, cy),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(2.dp.toPx()),
|
||||
)
|
||||
val cx = rect.left + (p.x + 0.5f) / imageW * rect.width
|
||||
val cy = rect.top + (p.y + 0.5f) / imageH * rect.height
|
||||
drawCircle(Color.White, st.dotR * k, Offset(cx, cy))
|
||||
drawCircle(Color.White, st.ringR * k, Offset(cx, cy), style = Stroke(st.ringW * k))
|
||||
val label = "${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}"
|
||||
val tw = paint.measureText(label)
|
||||
var tx = cx + 12.dp.toPx()
|
||||
if (tx + tw > size.width - 2.dp.toPx()) tx = cx - 12.dp.toPx() - tw
|
||||
drawIntoCanvas { canvas -> canvas.nativeCanvas.drawText(label, tx, cy + 4.dp.toPx(), paint) }
|
||||
var tx = cx + (st.ringR + st.gap) * k
|
||||
if (tx + tw > rect.right - 2f * k) tx = cx - (st.ringR + st.gap) * k - tw
|
||||
val ty = cy + st.textSize * k * 0.4f
|
||||
drawIntoCanvas { c -> c.nativeCanvas.drawText(label, tx, ty, paint) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Small ring marking an overall extreme (max/min) position. */
|
||||
private fun DrawScope.drawExtreme(
|
||||
pos: Int,
|
||||
rect: androidx.compose.ui.geometry.Rect,
|
||||
imageW: Int,
|
||||
imageH: Int,
|
||||
label: String,
|
||||
) {
|
||||
if (pos < 0) return
|
||||
val px = pos % imageW
|
||||
val py = pos / imageW
|
||||
if (py >= imageH) return
|
||||
val k = markerScaleFactor(rect, imageW)
|
||||
val st = MarkerStyle(imageW)
|
||||
val cx = rect.left + (px + 0.5f) / imageW * rect.width
|
||||
val cy = rect.top + (py + 0.5f) / imageH * rect.height
|
||||
drawCircle(Color(0xFFFFD54F), st.ringR * 0.9f * k, Offset(cx, cy), style = Stroke(st.ringW * k))
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.rgb(255, 213, 79)
|
||||
textSize = st.textSize * k
|
||||
isAntiAlias = true
|
||||
setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK)
|
||||
}
|
||||
drawIntoCanvas { c ->
|
||||
c.nativeCanvas.drawText(label, cx + st.ringR * 1.3f * k, cy - st.ringR * 0.6f * k, paint)
|
||||
}
|
||||
}
|
||||
|
||||
/** Current canvas size, captured for the tap handler. */
|
||||
private fun androidx.compose.ui.input.pointer.PointerInputScope.currentSize(): Size {
|
||||
// PointerInputScope exposes `size` as IntSize
|
||||
return Size(size.width.toFloat(), size.height.toFloat())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.mag160c.thermal.ui.gallery
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,9 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -31,101 +30,77 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mag160c.thermal.ui.analyze.AnalyzeViewer
|
||||
|
||||
/**
|
||||
* Photo grid (album tab). Behaves like a normal gallery: tap opens a full-screen
|
||||
* viewer with pinch-zoom and delete. Temperature tools live in the analysis tab,
|
||||
* which is why this screen no longer embeds the measurement UI.
|
||||
*/
|
||||
@Composable
|
||||
fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
|
||||
fun GalleryScreen(
|
||||
vm: GalleryViewModel = viewModel(),
|
||||
onOpen: (GalleryViewModel.Item) -> Unit = {},
|
||||
) {
|
||||
val items by vm.items.collectAsState()
|
||||
val context = LocalContext.current
|
||||
// The viewer must belong to the SAME ViewModel instance that the grid uses,
|
||||
// so the selection made by a tap is the one the viewer opens.
|
||||
var showViewer by remember { mutableStateOf(false) }
|
||||
|
||||
// runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE)
|
||||
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
|
||||
android.Manifest.permission.READ_MEDIA_IMAGES
|
||||
else android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> vm.refresh() }
|
||||
) { vm.refresh() }
|
||||
LaunchedEffect(Unit) {
|
||||
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (granted) vm.refresh() else launcher.launch(perm)
|
||||
}
|
||||
|
||||
// System back closes the viewer instead of leaving the tab (or the app).
|
||||
androidx.activity.compose.BackHandler(enabled = showViewer) {
|
||||
showViewer = false
|
||||
vm.select(null)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
|
||||
Button(onClick = { vm.refresh() }) { Text("刷新") }
|
||||
}
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
|
||||
items(items.size) { idx ->
|
||||
val item = items[idx]
|
||||
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(item.name) {
|
||||
vm.thumbnail(item) { b -> bmp = b }
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(4f / 3f)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable {
|
||||
vm.select(item)
|
||||
showViewer = true
|
||||
},
|
||||
) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
Image(
|
||||
bitmap = b.asImageBitmap(),
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(4.dp)
|
||||
.background(Color(0x88000000)),
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
|
||||
Button(onClick = { vm.refresh() }) { Text("刷新") }
|
||||
}
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
|
||||
items(items.size) { idx ->
|
||||
val item = items[idx]
|
||||
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(item.name) {
|
||||
vm.thumbnail(item) { b -> bmp = b }
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(3f / 4f)
|
||||
.padding(1.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { onOpen(item) },
|
||||
) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
Image(
|
||||
bitmap = b.asImageBitmap(),
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay the viewer ON TOP of the grid: previously it was placed after a
|
||||
// fillMaxSize() Column, which laid it out BELOW the visible area, so
|
||||
// tapping a photo appeared to do nothing at all.
|
||||
if (showViewer) {
|
||||
val sel = vm.selected.collectAsState().value
|
||||
if (sel != null) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
AnalyzeViewer(
|
||||
item = sel,
|
||||
galleryVm = vm,
|
||||
onClose = {
|
||||
showViewer = false
|
||||
vm.select(null)
|
||||
},
|
||||
density = androidx.compose.ui.platform.LocalDensity.current.density,
|
||||
// delete lives in the viewer (with confirmation), matching how
|
||||
// normal gallery apps behave; no clutter on the grid itself
|
||||
Text(
|
||||
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(3.dp)
|
||||
.background(Color(0x99000000)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,33 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-resolution image for the album viewer. Reads the container's embedded
|
||||
* JPEG (the MDT file is not a plain JPEG, so the system decoder cannot be
|
||||
* handed the file directly).
|
||||
*/
|
||||
fun fullImage(item: Item, onReady: (Bitmap) -> Unit) {
|
||||
full[item.name]?.let { onReady(it); return }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val ctx = getApplication<Application>()
|
||||
val bmp = runCatching {
|
||||
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
|
||||
val all = s.readBytes()
|
||||
val parsed = Mdt.parse(all)
|
||||
if (parsed != null) {
|
||||
BitmapFactory.decodeByteArray(parsed.jpg, 0, parsed.jpg.size)
|
||||
} else null
|
||||
}
|
||||
}.getOrNull()
|
||||
if (bmp != null) {
|
||||
full[item.name] = bmp
|
||||
withContext(Dispatchers.Main) { onReady(bmp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val full = ConcurrentHashMap<String, Bitmap>()
|
||||
|
||||
fun select(item: Item?) {
|
||||
_selected.value = item
|
||||
}
|
||||
@@ -117,6 +144,7 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val ctx = getApplication<Application>()
|
||||
runCatching { ctx.contentResolver.delete(item.uri, null, null) }
|
||||
thumbs.remove(item.name)
|
||||
full.remove(item.name)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.mag160c.thermal.ui.gallery
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Plain full-screen photo viewer for the album tab: pinch to zoom, drag to pan,
|
||||
* double-tap to reset, and delete. Deliberately free of measurement tools — that
|
||||
* is what the analysis tab is for (the user asked the album to behave like a
|
||||
* normal gallery app).
|
||||
*/
|
||||
@Composable
|
||||
fun PhotoViewerScreen(
|
||||
item: GalleryViewModel.Item,
|
||||
vm: GalleryViewModel,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
var zoom by remember { mutableStateOf(1f) }
|
||||
var pan by remember { mutableStateOf(Offset.Zero) }
|
||||
var askDelete by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(item.name) {
|
||||
vm.fullImage(item) { b -> bmp = b }
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
Image(
|
||||
bitmap = b.asImageBitmap(),
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = zoom,
|
||||
scaleY = zoom,
|
||||
translationX = pan.x,
|
||||
translationY = pan.y,
|
||||
)
|
||||
.pointerInput(item.name) {
|
||||
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
|
||||
zoom = (zoom * gestureZoom).coerceIn(1f, 8f)
|
||||
pan += gesturePan
|
||||
}
|
||||
}
|
||||
.pointerInput(item.name) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = {
|
||||
// reset, or zoom to 2x if already at rest
|
||||
if (zoom > 1.05f) {
|
||||
zoom = 1f; pan = Offset.Zero
|
||||
} else {
|
||||
zoom = 2f
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Text("载入中…", color = Color.White, modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
// top bar
|
||||
Surface(
|
||||
color = Color(0x99000000),
|
||||
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onClose) { Text("返回") }
|
||||
Text(
|
||||
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { askDelete = true }) {
|
||||
Text("删除", color = Color(0xFFFF8A80))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"双指缩放 · 拖动平移 · 双击复位",
|
||||
color = Color(0x99FFFFFF),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (askDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { askDelete = false },
|
||||
title = { Text("删除照片") },
|
||||
text = { Text("删除后无法恢复:${item.name}") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.delete(item)
|
||||
askDelete = false
|
||||
onClose()
|
||||
}) { Text("删除") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { askDelete = false }) { Text("取消") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -323,31 +323,53 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture: rendered JPEG + raw frame + camera info + probes -> MDT -> MediaStore.
|
||||
* Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore.
|
||||
*
|
||||
* The saved JPEG is the SAME view the user sees: the sensor-frame flips and
|
||||
* the locked/manual rotation are applied (previously the file kept the raw
|
||||
* sensor orientation, so翻过来的照片和屏幕不一致), and the probe markers with
|
||||
* their temperatures are burned in. The probes are also stored as data in the
|
||||
* container so the analysis screen can reload and edit them.
|
||||
* Three things must line up, or the offline analysis shows wrong numbers or
|
||||
* misplaced markers (all three were broken on the device):
|
||||
* 1. the JPEG is saved in the on-screen orientation (flips then rotation);
|
||||
* 2. the NUC counts — the CALIBRATED data the live readouts use — are stored
|
||||
* in that same photo pixel order, so an offline temperature lookup is a
|
||||
* plain index and reproduces the live values (storing the raw sensor
|
||||
* response instead gave 145 C / -161 C on a 30 C scene);
|
||||
* 3. probe coordinates are converted to photo pixels too (they used to be
|
||||
* stored as sensor coordinates and then drawn as if they were photo
|
||||
* coordinates, which put the markers in the wrong place).
|
||||
*/
|
||||
fun capturePhoto(context: android.content.Context, density: Float = 2f) {
|
||||
val frame = latestFrame ?: return
|
||||
val s = session
|
||||
val st = _state.value
|
||||
val orientation = com.mag160c.thermal.media.PhotoSaver.Orientation(
|
||||
rotateDeg = com.mag160c.thermal.ui.live.ImageTransform
|
||||
.params(userRotateDeg, flipH, flipV).rotDeg,
|
||||
flipH = flipH,
|
||||
flipV = flipV,
|
||||
)
|
||||
|
||||
// NUC counts: the CALIBRATED 160x120 data the live readouts use, expanded
|
||||
// to one entry per PHOTO pixel so the offline lookup needs no index math.
|
||||
val nuc160 = IntArray(19200)
|
||||
val haveNuc = s.copyNuc(nuc160)
|
||||
val nucPhoto = if (haveNuc) {
|
||||
com.mag160c.thermal.media.PhotoSaver.buildPhotoOrderedCounts(nuc160, orientation)
|
||||
} else null
|
||||
|
||||
// probes: sensor coordinates -> photo pixels
|
||||
val marks = st.probes.mapNotNull { p ->
|
||||
p.tempC?.let {
|
||||
com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it)
|
||||
p.tempC?.let { t ->
|
||||
val pos = com.mag160c.thermal.media.PhotoSaver.sensorToImage(
|
||||
p.x, p.y, 320, 240,
|
||||
((orientation.rotateDeg % 360) + 360) % 360,
|
||||
orientation,
|
||||
)
|
||||
com.mag160c.thermal.media.PhotoSaver.ProbeMark(pos[0], pos[1], p.label, t)
|
||||
}
|
||||
}
|
||||
|
||||
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered(
|
||||
frame = frame,
|
||||
orientation = com.mag160c.thermal.media.PhotoSaver.Orientation(
|
||||
rotateDeg = com.mag160c.thermal.ui.live.ImageTransform
|
||||
.params(userRotateDeg, flipH, flipV).rotDeg,
|
||||
flipH = flipH,
|
||||
flipV = flipV,
|
||||
),
|
||||
orientation = orientation,
|
||||
probes = marks,
|
||||
density = density,
|
||||
)
|
||||
@@ -366,11 +388,19 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
info1 = s.lastInfo1,
|
||||
framePixels = pixels,
|
||||
probes = probeBlock,
|
||||
nucPixels = nucPhoto?.let {
|
||||
com.mag160c.thermal.media.PhotoSaver.countsToBytes(it)
|
||||
},
|
||||
)
|
||||
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
|
||||
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
|
||||
)
|
||||
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail")
|
||||
com.mag160c.thermal.media.DebugLog.log(
|
||||
"vm",
|
||||
"capture: nuc=${if (haveNuc) "yes" else "no"} probes=${marks.size} " +
|
||||
"rot=${orientation.rotateDeg} saved=${saved != null}",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.mag160c.thermal.media
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The NUC block is what makes offline measurement correct.
|
||||
*
|
||||
* Root cause fixed here (2026-09-11): the analysis screen converted the RAW sensor
|
||||
* frame, which is pre-NUC data, so a 30 C scene reported 145 C max / -161 C min.
|
||||
* The photo now carries the CALIBRATED counts in its own pixel order, and the
|
||||
* lookup is a plain index — these tests pin that the re-ordering really is a
|
||||
* bijection into the photo's layout and that no sample is lost or duplicated.
|
||||
*/
|
||||
class PhotoNucMappingTest {
|
||||
|
||||
/** Counts encoding a known position, so the mapping can be verified per pixel. */
|
||||
private fun rampCounts(): IntArray = IntArray(160 * 120) { it }
|
||||
|
||||
@Test
|
||||
fun nucBlockHasOneCountPerPhotoPixel() {
|
||||
// 1:1 with the photo, so a lookup is counts[iy * photoW + ix] with no
|
||||
// transforms. The first implementation filled only every 4th entry (a
|
||||
// 160x120 grid scattered into a 320x240 photo), and every other lookup
|
||||
// read 0 — which surfaced as -161 C in the analysis panel.
|
||||
for (rot in intArrayOf(0, 90, 180, 270)) {
|
||||
val out = PhotoSaver.buildPhotoOrderedCounts(
|
||||
rampCounts(), PhotoSaver.Orientation(rot, false, false),
|
||||
)
|
||||
val expected = if (rot % 180 == 0) 320 * 240 else 240 * 320
|
||||
assertEquals("dense block for rot=$rot", expected, out.size)
|
||||
// Every pixel must carry a REAL sample: the 2x upscale makes each
|
||||
// sensor sample appear ~4 times, so the distinct values must be exactly
|
||||
// the sensor grid (a sparse/scattered fill would leave most entries at
|
||||
// the default 0, which is what produced -161 C in the analysis panel).
|
||||
val distinct = out.toSet()
|
||||
assertEquals("all 19200 samples present for rot=$rot", 160 * 120, distinct.size)
|
||||
assertEquals("no out-of-range samples", 160 * 120 - 1, distinct.max())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun centreOfThePhotoHoldsTheCentreSensorSample() {
|
||||
val out = PhotoSaver.buildPhotoOrderedCounts(
|
||||
rampCounts(), PhotoSaver.Orientation(90, false, false),
|
||||
)
|
||||
val photoW = 240
|
||||
val photoH = 320
|
||||
val centre = out[(photoH / 2) * photoW + photoW / 2]
|
||||
val mid = 160 * 120 / 2
|
||||
assertTrue(
|
||||
"centre value $centre should be near the ramp midpoint $mid",
|
||||
centre in (mid - 4000)..(mid + 4000),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapping must agree with the marker positions burned into the image:
|
||||
* a probe stored at photo pixel (x,y) must measure the sensor sample that the
|
||||
* photo shows at (x,y). Verified by round-tripping through the same functions
|
||||
* the capture path uses.
|
||||
*/
|
||||
@Test
|
||||
fun probePhotoPixelMapsBackToItsSensorSample() {
|
||||
for (rot in intArrayOf(0, 90, 180, 270)) {
|
||||
for (flipH in booleanArrayOf(false, true)) {
|
||||
for (flipV in booleanArrayOf(false, true)) {
|
||||
val o = PhotoSaver.Orientation(rot, flipH, flipV)
|
||||
val r = ((rot % 360) + 360) % 360
|
||||
for (sx in intArrayOf(0, 40, 79, 120, 159)) {
|
||||
for (sy in intArrayOf(0, 30, 59, 90, 119)) {
|
||||
val photo = PhotoSaver.sensorToImage(sx, sy, 320, 240, r, o)
|
||||
val back = PhotoSaver.photoToSensor(photo[0], photo[1], 320, 240, r, o)
|
||||
assertTrue(
|
||||
"rot=$rot flipH=$flipH flipV=$flipV sensor ($sx,$sy) -> " +
|
||||
"photo (${photo[0]},${photo[1]}) -> sensor (${back[0]},${back[1]})",
|
||||
kotlin.math.abs(back[0] - sx) <= 2 && kotlin.math.abs(back[1] - sy) <= 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countsByteRoundTrip() {
|
||||
val counts = IntArray(19200) { (it * 7) and 0xFFFF }
|
||||
val bytes = PhotoSaver.countsToBytes(counts)
|
||||
assertEquals("two bytes per sample", 38400, bytes.size)
|
||||
val back = PhotoSaver.bytesToCounts(bytes)
|
||||
assertEquals(counts.size, back.size)
|
||||
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countsAreClampedToSixteenBits() {
|
||||
val counts = intArrayOf(-5, 0, 65535, 70000, 100000)
|
||||
val back = PhotoSaver.bytesToCounts(PhotoSaver.countsToBytes(counts))
|
||||
assertEquals(0, back[0])
|
||||
assertEquals(0, back[1])
|
||||
assertEquals(65535, back[2])
|
||||
assertEquals(65535, back[3])
|
||||
assertEquals(65535, back[4])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nucBlockSurvivesAnMdtRoundTrip() {
|
||||
val counts = IntArray(19200) { (it * 3) and 0xFFFF }
|
||||
val mdt = Mdt.compose(
|
||||
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 1, 2, 0xFF.toByte(), 0xD9.toByte()),
|
||||
info0 = null, info1 = null, framePixels = ByteArray(38400),
|
||||
nucPixels = PhotoSaver.countsToBytes(counts),
|
||||
)
|
||||
val parsed = Mdt.parse(mdt)!!
|
||||
assertTrue("photo must be measurable", parsed.hasTemperatureData)
|
||||
val back = PhotoSaver.bytesToCounts(parsed.nucPixels!!)
|
||||
assertEquals(19200, back.size)
|
||||
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photosWithoutNucAreReportedAsUnmeasurable() {
|
||||
// older files (and plain captures) must NOT be silently measurable: the
|
||||
// UI has to say "no temperature data" rather than print a wrong number
|
||||
val mdt = Mdt.compose(
|
||||
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
|
||||
info0 = null, info1 = null, framePixels = ByteArray(38400),
|
||||
)
|
||||
val parsed = Mdt.parse(mdt)!!
|
||||
assertTrue("no NUC block -> no temperature data", !parsed.hasTemperatureData)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -82,6 +82,20 @@
|
||||
| 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) |
|
||||
| 43 | 拍照前先在实时页点几个测温点,再拍照 | (无日志) | 照片上带这些测温点与温度;进分析页打开该照片,**测温点仍在**且可继续编辑 |
|
||||
|
||||
## 第三轮真机修复项(2026-09-11,重点验证)
|
||||
|
||||
| # | 操作 | 预期 | 通过标准 |
|
||||
|---|------|------|----------|
|
||||
| 44 | 拍照后在分析页打开该照片 | 日志 `[vm] capture: nuc=yes probes=<n> rot=90 saved=true` | 顶部显示的照片与实时页看到的一致(方向、标注) |
|
||||
| 45 | 看分析页底部数据面板 | (无日志) | 最高/最低/中心温度**与拍照瞬间实时页读数一致**(同一数据源);**不再是 145℃ / -161℃ / 76℃ 这类错误值**;数据在屏幕**底部**(不是右侧竖栏) |
|
||||
| 46 | 分析页对照实时页的测温点位置 | (无日志) | 照片上测温点的**位置与拍照时屏幕上的一致**(此前会偏移/错位) |
|
||||
| 47 | 观察分析页与照片上的测温点标记大小 | (无日志) | 圆点/圆环**小而不遮挡画面**(此前环直径约占图宽 1/9,盖住内容);与实时页观感接近 |
|
||||
| 48 | 分析页点图像新增一个测温点,再点"保存" | 提示"已保存为新照片" | 新照片的测温点位置正确、标记大小正常、温度合理;**原照片仍在** |
|
||||
| 49 | 相册 tab 点一张照片 | (无日志) | 进入**全屏查看**:双指可缩放(1–8×)、拖动可平移、双击复位;**不是**直接进测温分析页 |
|
||||
| 50 | 相册查看页点"删除" | (无日志) | 弹确认对话框;确认后照片消失、返回网格 |
|
||||
| 51 | 相册 tab 与 分析 tab 对比 | (无日志) | **两个界面明显不同**:相册是浏览/缩放/删除;分析只列出可测温的照片并进入测温页(此前两个 tab 渲染同一个网格) |
|
||||
| 52 | 分析页打开一张**本轮之前拍的**旧照片 | (无日志) | 显示"无温度数据",**不显示编造的温度**(旧照片没有 NUC 数据块,这是预期行为) |
|
||||
|
||||
## 相机(PIP)失败时的表现(设计如此,不算 bug)
|
||||
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
|
||||
|
||||
|
||||
@@ -489,7 +489,50 @@
|
||||
"未连接"分支显示,正常出图时用户看不到任何反馈)。
|
||||
- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。
|
||||
|
||||
**诚实记录(本轮未做)**:
|
||||
## 用户反馈修复 第二十轮(2026-09-11,第三轮真机:分析测温/标注/相册/界面)
|
||||
|
||||
用户第三轮实机测试(含截图)报出以下问题,本轮全部处理:
|
||||
|
||||
- [x] **分析温度全错**(截图显示最高 145.1℃ / 最低 -161.0℃ / 中心 76.5℃)。
|
||||
双重根因:
|
||||
① **数据源错了**:实时读数用的是 `copyNuc()` 的**NUC 补偿后 counts**,
|
||||
而照片里存的 `BLOCK_FRAME` 是**传感器原始响应**,标定表对它无效 →
|
||||
直接换算就是垃圾值。新增 **`BLOCK_NUC` (0x5BB5B560)**:存拍照当刻的
|
||||
**NUC counts**,分析端用它测量(与实时屏幕同一数据源)。
|
||||
② **我自己引入的索引 bug**:第一版把 160×120 的样本"散布"进 320×240 的
|
||||
照片空间,76800 个槽位只填了 19200,其余全 0 → `countsToTempMc(0)`
|
||||
= **-161.0℃**(正是截图里的最低温)。改为
|
||||
`buildPhotoOrderedCounts()`:**每个照片像素一个样本**,查表就是
|
||||
`counts[iy*photoW+ix]`,不做任何旋转/翻转/缩放换算。
|
||||
- [x] **测温点位置标错**:探针原先按**传感器坐标**存储,显示时却当**照片像素**用。
|
||||
现在拍照时即转换到照片像素(`sensorToImage`),并且新增
|
||||
`photoToSensor()` 反变换用于测温;两者互逆性有单测覆盖(4 旋转 × 2 翻转)。
|
||||
- [x] **测温点太大**:标注尺寸原先用**屏幕密度**(4.5×density 的点、9×density 的环)
|
||||
画进 320×240 的位图 → 环直径约 36px / 320px 图宽,视觉上盖住画面。
|
||||
新增 `MarkStyle`:尺寸按**图像宽度**比例(点 2.6、环 5.5、字 9 @320px),
|
||||
与实时屏幕观感一致。实时页/分析页/保存照片三处统一。
|
||||
- [x] **分析页布局**:数据面板从**右侧竖栏**改为**底部横条**(用户要求),
|
||||
三个数值(最高/最低/中心)等分排布 + 测温点横向滚动列表,标题栏收窄。
|
||||
- [x] **分析页不显示极值标记**:现在在图上用小环标出最高/最低位置(带"高/低"字样)。
|
||||
- [x] **相册与分析界面一样**:分析 tab 原先**直接渲染相册的网格**(同一个
|
||||
`GalleryScreen`)。拆分为:
|
||||
- `GalleryScreen`(相册 tab):像正常相册一样,点开进入**全屏查看器**
|
||||
(双指缩放 1–8×、拖动平移、双击复位、**删除**含确认对话框);
|
||||
- `AnalyzeScreen`(分析 tab):只列出**带温度数据**的照片
|
||||
(`MdtProbe.isMeasurable`),点开进入测温分析页。
|
||||
- [x] **旧照片优雅降级**:没有 `BLOCK_NUC` 的照片(本轮之前拍的)在分析页显示
|
||||
"无温度数据",**不再编造温度**;新拍照片都带该数据块。
|
||||
- [x] 单测 76 → **83 项全绿**(新增 `PhotoNucMappingTest`:密集性/中心对齐/
|
||||
探针往返/字节往返/无 NUC 判定);debug + release(R8) 双构建通过;APK 已更新。
|
||||
|
||||
**已知取舍(诚实记录)**:
|
||||
- `BLOCK_NUC` 是**照片分辨率**(76800 样本 = 153KB,未压缩),照片文件因此增大
|
||||
约 150KB。选择它的理由:分析端查表无需任何坐标换算,从根上消除"索引对不上"
|
||||
这一类缺陷(本轮的两个温度 bug 都源于此)。若日后要压缩,需保证查找端使用
|
||||
同一套映射函数。
|
||||
- 默认发射率/报警温度仍未接入管线(同第十八轮记录)。
|
||||
|
||||
**诚实记录(第十八轮未做)**:
|
||||
- `defaultEmissivityPercent`(默认发射率)与 `alarmTempC`(报警温度)**仍未被
|
||||
测温管线使用**:发射率需要官方 `CorrectTemperature` 的完整浮点公式(已从
|
||||
libcxsdk 伪代码定位到 `@000298f0`,但牵涉 T2E/环境温度/`Energe2Temp` 多处
|
||||
|
||||
Reference in New Issue
Block a user