android: 媒体库+MDT离线分析查看器(缩放/调色板/备注) 阶段4-5a

This commit is contained in:
ZXCLI
2026-09-06 19:55:22 +08:00
parent ed0a0882d8
commit 29a9b85cbe
7 changed files with 501 additions and 8 deletions
@@ -45,14 +45,14 @@ object Mdt {
* @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base)
* @param info0 camera info block from command 0x6BB6B66B (0x38B)
* @param info1 second cached info block (0x38B) or null
* @param rawFrame latest raw USB frame (0x38-byte header + 38400B pixels)
* @param framePixels raw measurement frame (38400 bytes, 19200 x uint16 LE) or null
* @param text UTF-8 note bytes or null
*/
fun compose(
jpg: ByteArray,
info0: ByteArray?,
info1: ByteArray?,
rawFrame: ByteArray?,
framePixels: ByteArray?,
text: ByteArray? = null,
): ByteArray {
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
@@ -72,9 +72,8 @@ object Mdt {
}
info0?.let { emit(BLOCK_INFO0, it) }
info1?.let { emit(BLOCK_INFO1, it) }
val raw = rawFrame
if (raw != null && raw.size >= 0x1C + 38400) {
emit(BLOCK_FRAME, raw.copyOfRange(0x1C, 0x1C + 38400))
if (framePixels != null && framePixels.size == 38400) {
emit(BLOCK_FRAME, framePixels)
}
text?.let { emit(BLOCK_TXT, it) }
@@ -25,9 +25,13 @@ object PhotoSaver {
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bmp.setPixels(frame, 0, w, 0, 0, w, h)
val out = ByteArrayOutputStream(w * h / 4)
return encodeJpeg(bmp, quality)
}
/** Encode an existing bitmap to JPEG bytes. */
fun encodeJpeg(bmp: Bitmap, quality: Int = 92): ByteArray {
val out = ByteArrayOutputStream(bmp.width * bmp.height / 4)
bmp.compress(Bitmap.CompressFormat.JPEG, quality, out)
bmp.recycle()
return out.toByteArray()
}
@@ -0,0 +1,103 @@
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 com.mag160c.thermal.media.Mdt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Offline MDT analysis state: loaded container, palette re-render, probes.
*/
class AnalyzeViewModel(
app: Application,
private val containerBytes: ByteArray,
private val fileUri: android.net.Uri,
) : AndroidViewModel(app) {
val parsed: Mdt.Parsed? = Mdt.parse(containerBytes)
/** Raw measurement frame (19200 uint16) if present. */
val rawFrame: IntArray? by lazy {
parsed?.frame?.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
}
}
private val _render = MutableStateFlow<Bitmap?>(null)
val render: StateFlow<Bitmap?> = _render
private val _paletteIndex = MutableStateFlow(2)
val paletteIndex: StateFlow<Int> = _paletteIndex
/** Re-render the raw frame with the given palette + auto window. */
fun render(paletteIdx: Int) {
val raw = rawFrame ?: return
_paletteIndex.value = paletteIdx
viewModelScope.launch(Dispatchers.Default) {
var mn = Int.MAX_VALUE
var mx = -1
for (v in raw) {
if (v < mn) mn = v
if (v > mx) mx = v
}
if (mx <= mn) mx = mn + 1
val pal = com.mag160c.thermal.core.Palettes.buildAll()[paletteIdx.coerceIn(0, 11)]
val argb = IntArray(19200)
val scale = (255 shl 12) / (mx - mn)
for (i in argb.indices) {
var g = ((raw[i] - mn) * scale) shr 8
if (g < 0) g = 0 else if (g > 255) g = 255
argb[i] = pal[g]
}
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 }
}
}
fun decodeNote(): String? {
val t = parsed?.text ?: return null
return String(t, Charsets.UTF_8)
}
/** Probe temperature approximation at a raw pixel (millidegrees C). */
fun probeTemp(x: Int, y: Int): Int? {
val raw = rawFrame ?: return null
if (x < 0 || y < 0 || x >= 160 || y >= 120) return null
return com.mag160c.thermal.core.TempMath.countsToTempMc(raw[y * 160 + x])
}
/** Save note: rewrite the container in place (jpg = current render). */
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
val bmp = _render.value
if (bmp == null) {
onDone(false)
return
}
viewModelScope.launch(Dispatchers.IO) {
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(bmp)
val mdt = Mdt.compose(
jpg = jpg,
info0 = parsed?.info0,
info1 = parsed?.info1,
framePixels = parsed?.frame,
text = note.toByteArray(Charsets.UTF_8),
)
val ok = runCatching {
val ctx = getApplication<Application>()
ctx.contentResolver.openOutputStream(fileUri, "w")?.use { it.write(mdt) } != null
}.getOrDefault(false)
withContext(Dispatchers.Main) { onDone(ok) }
}
}
}
@@ -0,0 +1,157 @@
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.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.gallery.GalleryViewModel
/**
* Single-file MDT analysis viewer: pinch zoom/pan, palette re-render,
* text note editing.
*/
@Composable
fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
val context = LocalContext.current
val vm = remember(item.name) {
val bytes = runCatching {
context.contentResolver.openInputStream(item.uri)?.use { it.readBytes() }
}.getOrNull() ?: ByteArray(0)
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 showNote by remember { mutableStateOf(false) }
var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
LaunchedEffect(Unit) { vm.render(2) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
Canvas(
modifier = Modifier
.aspectRatio(4f / 3f)
.align(Alignment.Center)
.pointerInput(Unit) {
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
pan += gesturePan
if (zoom <= 1.01f) {
zoom = 1f
pan = Offset.Zero
}
}
},
) {
val img = render?.asImageBitmap()
if (img != null) {
val w = size.width * zoom
val h = size.height * zoom
val left = (size.width - w) / 2 + pan.x
val top = (size.height - h) / 2 + pan.y
drawImage(
image = img,
dstOffset = androidx.compose.ui.unit.IntOffset(left.toInt(), top.toInt()),
dstSize = IntSize(w.toInt(), h.toInt()),
)
}
}
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),
)
}
}
Button(
onClick = { showNote = true },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp),
) {
Text("备注")
}
if (showNote) {
NoteEditor(
initial = note,
onSave = {
note = it
vm.saveNote(it) { }
showNote = false
},
onDismiss = { showNote = false },
)
}
}
}
private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y)
@Composable
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("文字备注") },
text = {
OutlinedTextField(value = text, onValueChange = { text = it })
},
confirmButton = {
TextButton(onClick = { onSave(text) }) { Text("保存") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
},
)
}
@@ -0,0 +1,103 @@
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
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.MaterialTheme
import androidx.compose.material3.Switch
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.material3.Button
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.ui.analyze.AnalyzeViewer
@Composable
fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
val items by vm.items.collectAsState()
val context = LocalContext.current
var showViewer by remember { mutableStateOf(false) }
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(if (isLandscape()) 4 else 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_"),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(4.dp)
.background(Color(0x88000000)),
)
}
}
}
}
if (showViewer) {
val sel = vm.selected.value
if (sel != null) {
AnalyzeViewer(item = sel, galleryVm = vm)
}
}
}
@Composable
private fun isLandscape(): Boolean =
androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE
@@ -0,0 +1,123 @@
package com.mag160c.thermal.ui.gallery
import android.app.Application
import android.content.ContentUris
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.provider.MediaStore
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.media.Mdt
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Media library: lists MDT thermal files under DCIM/MAG160C via MediaStore
* with async embedded-JPEG thumbnails.
*/
class GalleryViewModel(app: Application) : AndroidViewModel(app) {
data class Item(
val id: Long,
val uri: android.net.Uri,
val name: String,
val size: Long,
val dateMs: Long,
)
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items
private val _selected = MutableStateFlow<Item?>(null)
val selected: StateFlow<Item?> = _selected
private val thumbs = ConcurrentHashMap<String, Bitmap>()
init {
refresh()
}
fun refresh() {
viewModelScope.launch(Dispatchers.IO) {
val ctx = getApplication<Application>()
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val proj = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.SIZE,
)
val list = ArrayList<Item>()
ctx.contentResolver.query(
uri, proj,
"${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ?",
arrayOf("%DCIM/MAG160C%"),
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { c ->
while (c.moveToNext()) {
val id = c.getLong(0)
val name = c.getString(1) ?: ""
val date = c.getLong(2) * 1000
val size = c.getLong(3)
if (size < 152 + 136) continue
list.add(Item(id, ContentUris.withAppendedId(uri, id), name, size, date))
}
}
// validate MDT containers
val valid = list.filter { isMdt(it) }
withContext(Dispatchers.Main) { _items.value = valid }
}
}
private fun isMdt(item: Item): Boolean = runCatching {
val ctx = getApplication<Application>()
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
val buf = ByteArray(152)
if (s.skip(item.size - 152) != item.size - 152) return false
var off = 0
while (off < 152) {
val n = s.read(buf, off, 152 - off)
if (n <= 0) return false
off += n
}
Mdt.u32(buf, 0) == Mdt.SECTION_TAIL
} ?: false
}.getOrDefault(false)
/** Embedded-JPEG thumbnail (async). */
fun thumbnail(item: Item, onReady: (Bitmap) -> Unit) {
thumbs[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) {
thumbs[item.name] = bmp
withContext(Dispatchers.Main) { onReady(bmp) }
}
}
}
fun select(item: Item?) {
_selected.value = item
}
fun delete(item: Item) {
viewModelScope.launch(Dispatchers.IO) {
val ctx = getApplication<Application>()
runCatching { ctx.contentResolver.delete(item.uri, null, null) }
thumbs.remove(item.name)
refresh()
}
}
}
@@ -100,11 +100,15 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val frame = latestFrame ?: return
val s = session
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame)
val rawFrame = s.lastRawFrame
val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) {
rawFrame.copyOfRange(0x1C, 0x1C + 38400)
} else null
val mdt = com.mag160c.thermal.media.Mdt.compose(
jpg = jpg,
info0 = s.lastInfo0,
info1 = s.lastInfo1,
rawFrame = s.lastRawFrame,
framePixels = pixels,
)
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),