android: 相册页接线+演示模式(真实管线渲染合成帧+demo温度显示映射)

This commit is contained in:
ZXCLI
2026-09-06 23:15:05 +08:00
parent 430bf373e4
commit f8eab28857
14 changed files with 163 additions and 21 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -45,8 +46,8 @@ fun AppRoot() {
val content: @Composable () -> Unit = {
when (tab) {
0 -> LiveScreen()
1 -> Placeholder("媒体库(阶段4实现)")
2 -> Placeholder("MDT 离线分析(阶段5实现)")
1 -> GalleryScreen()
2 -> GalleryScreen()
else -> SettingsScreen()
}
}
@@ -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)
@@ -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,
)
}
@@ -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<Application>()
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<Application>().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<ByteArray> {
val frameSize = 0x38 + 38400
val frames = ArrayList<ByteArray>(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()
}
@@ -1,5 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M4,7 L4,19 L20,19 L20,7 Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M9,7 L9,5 L15,5 L15,7" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M12,13m-3,0a3,3 0 1,1 6,0a3,3 0 1,1 -6,0" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M12,13m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
</vector>
+1 -1
View File
@@ -1,5 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5,12a7,7 0 1,0 14,0a7,7 0 1,0 -14,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M12,3 L12,7 M12,17 L12,21 M3,12 L7,12 M17,12 L21,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
<path android:pathData="M12,5a7,7 0 1,0 0.001,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M12,12m-2.5,0a2.5,2.5 0 1,1 5,0a2.5,2.5 0 1,1 -5,0" android:fillColor="#FF000000" />
</vector>
@@ -1,4 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,4a8,8 0 1,0 0.001,0z" android:strokeColor="#FFD32F2F" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M12,8a4,4 0 1,0 8,0a4,4 0 1,0 -8,0z" android:strokeColor="#FFD32F2F" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M12,12m-4,0a4,4 0 1,1 8,0a4,4 0 1,1 -8,0" android:fillColor="#FFD32F2F" />
</vector>
@@ -1,4 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M10.5,3a7.5,7.5 0 1,0 0.001,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M3,10.5a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M16,16 L21,21" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
</vector>
Binary file not shown.