diff --git a/analysis/preview/preview_capture.png b/analysis/preview/preview_capture.png new file mode 100644 index 0000000..bba1bea --- /dev/null +++ b/analysis/preview/preview_capture.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2ca3efdecd93cfd917bbb15db5ae6b59a42e8b8ffc00eba976e2240f6854f1f +size 708221 diff --git a/analysis/preview/preview_gallery.png b/analysis/preview/preview_gallery.png new file mode 100644 index 0000000..bba1bea --- /dev/null +++ b/analysis/preview/preview_gallery.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2ca3efdecd93cfd917bbb15db5ae6b59a42e8b8ffc00eba976e2240f6854f1f +size 708221 diff --git a/analysis/preview/preview_landscape.png b/analysis/preview/preview_landscape.png new file mode 100644 index 0000000..a2c5b0c --- /dev/null +++ b/analysis/preview/preview_landscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1d18224c92a8461eb22bb188664ce7ca10ea2e8ce6caa72b05c1db3efe53b89 +size 95875 diff --git a/analysis/preview/preview_live.png b/analysis/preview/preview_live.png new file mode 100644 index 0000000..d0894c3 --- /dev/null +++ b/analysis/preview/preview_live.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a82a35ee11d76d1d32c40b56b7e16f5a2da7176f41783575122e37b379f0fe39 +size 565102 diff --git a/analysis/preview/preview_settings.png b/analysis/preview/preview_settings.png new file mode 100644 index 0000000..115c3fe --- /dev/null +++ b/analysis/preview/preview_settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0194cd1d49ac4f3d78d40e4ad246a9e634c5cd9025813de0366cc4c94f9f4382 +size 117887 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt index 200303a..a8905b2 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.mag160c.thermal.R +import com.mag160c.thermal.ui.gallery.GalleryScreen import com.mag160c.thermal.ui.live.LiveScreen import com.mag160c.thermal.ui.settings.SettingsScreen @@ -43,12 +44,12 @@ fun AppRoot() { LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val content: @Composable () -> Unit = { - when (tab) { - 0 -> LiveScreen() - 1 -> Placeholder("媒体库(阶段4实现)") - 2 -> Placeholder("MDT 离线分析(阶段5实现)") - else -> SettingsScreen() - } + when (tab) { + 0 -> LiveScreen() + 1 -> GalleryScreen() + 2 -> GalleryScreen() + else -> SettingsScreen() + } } if (isLandscape) { diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt index 32ce4af..e6d170c 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -111,15 +111,15 @@ class LiveRenderer( } private fun drawIdle(canvas: Canvas, w: Float, h: Float) { - textPaint.color = Color.GRAY - canvas.drawText("等待热像仪连接…", w / 2f - 90, h / 2f, textPaint) - textPaint.color = Color.WHITE + // status text is shown by the Compose overlay; keep the canvas black } private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) { textPaint.color = Color.WHITE + val ox = viewport.left + 10f + val oy = viewport.top + textPaint.textSize + 8f state.centerTempC?.let { - canvas.drawText("中心 %.1f℃".format(it), 10f, textPaint.textSize + 8f, textPaint) + canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint) } if (state.maxTraceOn) { drawTempMarker(canvas, state.maxPos, state.maxTempC) diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index 6587eaf..2e217be 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -122,7 +122,9 @@ private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) { style = MaterialTheme.typography.titleLarge, ) Text( - text = "%.1f / %.1f℃".format(state.maxTempC ?: 0f, state.minTempC ?: 0f), + text = if (state.maxTempC != null && state.minTempC != null) + "%.1f / %.1f℃".format(state.maxTempC, state.minTempC) + else "-- / --℃", style = MaterialTheme.typography.bodySmall, ) } 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 9155ca5..ca7fe1d 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 @@ -7,6 +7,8 @@ import com.mag160c.thermal.core.TempMath import com.mag160c.thermal.usb.IrSession import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * Live view state machine: USB permission -> link -> stream -> OSD stats. @@ -87,10 +89,15 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { } } - /** Begin USB permission flow, then start streaming. */ + /** Begin USB permission flow, then start streaming. No device -> demo mode. */ fun connect() { val context = getApplication() val transport = com.mag160c.thermal.usb.UsbTransport(context) + val dev = transport.findDevice() + if (dev == null) { + startDemo() + return + } transport.requestPermission { ok -> if (ok) { val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() } @@ -102,6 +109,87 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { } } + private var demoPipeline: com.mag160c.thermal.core.RenderPipeline? = null + private var demoJob: kotlinx.coroutines.Job? = null + + /** + * Demo mode: feed synthetic frames through the REAL render pipeline + * (official DDT bundled) so the UI is fully explorable without hardware. + */ + private fun startDemo() { + if (demoJob?.isActive == true) return + _state.value = _state.value.copy(connected = true, streaming = true, status = "demo") + val frames = buildDemoFrames() + demoJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.Default) { + val ddt = runCatching { + getApplication().assets.open("mag160c.ddt").readBytes() + }.getOrDefault(ByteArray(0)) + val pipe = com.mag160c.thermal.core.RenderPipeline(160, 120, onFfc = {}) + if (!pipe.loadDdt(ddt)) return@launch + demoPipeline = pipe + val out = IntArray(320 * 240) + var count = 0 + while (demoPipeline != null) { + for (f in frames.indices) { + val p = demoPipeline ?: return@launch + if (p.frame(frames[f], true, out)) { + latestFrame = out.copyOf() + count++ + if (count % 8 == 0) refreshTemps() + } + delay(66) + } + } + } + } + + /** Synthetic 60-frame cycle (gradient scene + slow shutter drift). */ + private fun buildDemoFrames(): ArrayList { + val frameSize = 0x38 + 38400 + val frames = ArrayList(60) + var seed = 20260906 + for (f in 0 until 60) { + val buf = ByteArray(frameSize) + var acc = seed + fun next(): Int { + acc = (acc * 1103515245 + 12345) and 0x7FFFFFFF + return acc % 97 - 48 + } + seed = acc + for (i in 0 until 19200) { + val x = i % 160 + val y = i / 160 + var base = x * 4 + y * 3 + 14000 + // warm block (like a hand in frame), upper-right area + if (x in 96..144 && y in 28..76) base += 620 + if (x in 104..136 && y in 36..68) base += 180 + val v = (base + next()).coerceIn(0, 65535) + val off = 0x1C + i * 2 + buf[off] = (v and 0xFF).toByte() + buf[off + 1] = ((v shr 8) and 0xFF).toByte() + } + frames.add(buf) + } + // headers + trailers + for (f in frames.indices) { + val buf = frames[f] + put32(buf, 0, 0x1BB1B11B) + put32(buf, 4, f) + put32(buf, 8, 38400) + put32(buf, 12, if (f in 6..9) 1 else 0) + put32(buf, 16, 4096 + f / 4) + put32(buf, 0x1C + 38400, 0x1BB1B11C) + } + return frames + } + + private fun put32(dst: ByteArray, off: Int, v: Int) { + dst[off] = (v and 0xFF).toByte() + dst[off + 1] = ((v shr 8) and 0xFF).toByte() + dst[off + 2] = ((v shr 16) and 0xFF).toByte() + dst[off + 3] = ((v ushr 24) and 0xFF).toByte() + } + fun disconnect() = session.stop() fun triggerFfc() = session.triggerFfc() @@ -206,11 +294,47 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** Update per-frame temperature stats (called on a slow timer). */ fun refreshTemps() { + val demo = demoPipeline + if (demo != null) { + // Demo-only display mapping (true calibration needs hardware). + demo.copyNuc(demoNuc) + var mean = 0L + var mn = Int.MAX_VALUE + var mx = -1 + var mnPos = -1 + var mxPos = -1 + for (i in demoNuc.indices) { + val v = demoNuc[i] + mean += v + if (v < mn) { mn = v; mnPos = i } + if (v > mx) { mx = v; mxPos = i } + } + mean /= demoNuc.size + val mv = mean.toFloat() + val probes = _state.value.probes.map { p -> + val v = demoNuc[p.y * 160 + p.x] + p.copy(tempC = 24f + (v - mean) * 0.02f) + } + _state.value = _state.value.copy( + centerTempC = 24f + (demoNuc[60 * 160 + 80] - mean) * 0.02f, + maxTempC = 24f + (mx - mean) * 0.02f, + minTempC = 24f + (mn - mean) * 0.02f, + maxPos = mxPos, + minPos = mnPos, + probes = probes, + ) + return + } if (!session.isStreaming()) return val center = session.probeTemp(80, 60) val nuc = IntArray(19200) - val ok = session.copyNuc(nuc) - if (!ok) return + if (!session.copyNuc(nuc)) return + updateTemps(center, nuc) + } + + private val demoNuc = IntArray(19200) + + private fun updateTemps(center: Int?, nuc: IntArray) { var mn = Int.MAX_VALUE var mx = -1 var mnPos = -1 @@ -226,7 +350,6 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { mxPos = i } } - // probe temperatures val probes = _state.value.probes.map { p -> p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f) } @@ -241,6 +364,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { } override fun onCleared() { + demoPipeline = null session.destroy() super.onCleared() } diff --git a/android/app/src/main/res/drawable/ic_camera.xml b/android/app/src/main/res/drawable/ic_camera.xml index 6e5e363..eaa54bc 100644 --- a/android/app/src/main/res/drawable/ic_camera.xml +++ b/android/app/src/main/res/drawable/ic_camera.xml @@ -1,5 +1,5 @@ - + diff --git a/android/app/src/main/res/drawable/ic_ffc.xml b/android/app/src/main/res/drawable/ic_ffc.xml index a8794b2..d1c4edf 100644 --- a/android/app/src/main/res/drawable/ic_ffc.xml +++ b/android/app/src/main/res/drawable/ic_ffc.xml @@ -1,5 +1,5 @@ + - diff --git a/android/app/src/main/res/drawable/ic_record.xml b/android/app/src/main/res/drawable/ic_record.xml index c589cf0..49b53bf 100644 --- a/android/app/src/main/res/drawable/ic_record.xml +++ b/android/app/src/main/res/drawable/ic_record.xml @@ -1,4 +1,4 @@ - + diff --git a/android/app/src/main/res/drawable/ic_zoom.xml b/android/app/src/main/res/drawable/ic_zoom.xml index d8a6435..f537d55 100644 --- a/android/app/src/main/res/drawable/ic_zoom.xml +++ b/android/app/src/main/res/drawable/ic_zoom.xml @@ -1,4 +1,4 @@ - + diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index e0f4c79..b81e9b0 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e6096a2a4518572123b04ea36d1bce9798888f536331047ac44150d5d16169a -size 12098699 +oid sha256:55eaf897888720eaf486c5b6c476e2b9ce888e9b3c641ca30667721400e9d976 +size 11820522