From 29a9b85cbe76ecc7c538a8bb810be420c2ecd301 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sun, 6 Sep 2026 19:55:22 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E5=AA=92=E4=BD=93=E5=BA=93+MDT?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=88=86=E6=9E=90=E6=9F=A5=E7=9C=8B=E5=99=A8?= =?UTF-8?q?(=E7=BC=A9=E6=94=BE/=E8=B0=83=E8=89=B2=E6=9D=BF/=E5=A4=87?= =?UTF-8?q?=E6=B3=A8)=20=E9=98=B6=E6=AE=B54-5a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/mag160c/thermal/media/Mdt.kt | 9 +- .../com/mag160c/thermal/media/PhotoSaver.kt | 8 +- .../thermal/ui/analyze/AnalyzeViewModel.kt | 103 ++++++++++++ .../thermal/ui/analyze/AnalyzeViewer.kt | 157 ++++++++++++++++++ .../thermal/ui/gallery/GalleryScreen.kt | 103 ++++++++++++ .../thermal/ui/gallery/GalleryViewModel.kt | 123 ++++++++++++++ .../mag160c/thermal/ui/live/LiveViewModel.kt | 6 +- 7 files changed, 501 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt index 8e20dd9..2af087a 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt @@ -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) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt index 726efce..cddbddc 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt @@ -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() } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt new file mode 100644 index 0000000..98c5d34 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt @@ -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(null) + val render: StateFlow = _render + + private val _paletteIndex = MutableStateFlow(2) + val paletteIndex: StateFlow = _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() + ctx.contentResolver.openOutputStream(fileUri, "w")?.use { it.write(mdt) } != null + }.getOrDefault(false) + withContext(Dispatchers.Main) { onDone(ok) } + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt new file mode 100644 index 0000000..c11749c --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt @@ -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("取消") } + }, + ) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt new file mode 100644 index 0000000..2a45e96 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt @@ -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(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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt new file mode 100644 index 0000000..683a5d4 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryViewModel.kt @@ -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>(emptyList()) + val items: StateFlow> = _items + + private val _selected = MutableStateFlow(null) + val selected: StateFlow = _selected + + private val thumbs = ConcurrentHashMap() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + val ctx = getApplication() + 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() + 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() + 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() + 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() + runCatching { ctx.contentResolver.delete(item.uri, null, null) } + thumbs.remove(item.name) + refresh() + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index ec2d3fb..d22d546 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -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(),