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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mag160c.thermal.R import com.mag160c.thermal.R
import com.mag160c.thermal.ui.gallery.GalleryScreen
import com.mag160c.thermal.ui.live.LiveScreen import com.mag160c.thermal.ui.live.LiveScreen
import com.mag160c.thermal.ui.settings.SettingsScreen import com.mag160c.thermal.ui.settings.SettingsScreen
@@ -43,12 +44,12 @@ fun AppRoot() {
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val content: @Composable () -> Unit = { val content: @Composable () -> Unit = {
when (tab) { when (tab) {
0 -> LiveScreen() 0 -> LiveScreen()
1 -> Placeholder("媒体库(阶段4实现)") 1 -> GalleryScreen()
2 -> Placeholder("MDT 离线分析(阶段5实现)") 2 -> GalleryScreen()
else -> SettingsScreen() else -> SettingsScreen()
} }
} }
if (isLandscape) { if (isLandscape) {
@@ -111,15 +111,15 @@ class LiveRenderer(
} }
private fun drawIdle(canvas: Canvas, w: Float, h: Float) { private fun drawIdle(canvas: Canvas, w: Float, h: Float) {
textPaint.color = Color.GRAY // status text is shown by the Compose overlay; keep the canvas black
canvas.drawText("等待热像仪连接…", w / 2f - 90, h / 2f, textPaint)
textPaint.color = Color.WHITE
} }
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) { private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
textPaint.color = Color.WHITE textPaint.color = Color.WHITE
val ox = viewport.left + 10f
val oy = viewport.top + textPaint.textSize + 8f
state.centerTempC?.let { state.centerTempC?.let {
canvas.drawText("中心 %.1f℃".format(it), 10f, textPaint.textSize + 8f, textPaint) canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
} }
if (state.maxTraceOn) { if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos, state.maxTempC) drawTempMarker(canvas, state.maxPos, state.maxTempC)
@@ -122,7 +122,9 @@ private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
Text( 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, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -7,6 +7,8 @@ import com.mag160c.thermal.core.TempMath
import com.mag160c.thermal.usb.IrSession import com.mag160c.thermal.usb.IrSession
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** /**
* Live view state machine: USB permission -> link -> stream -> OSD stats. * 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() { fun connect() {
val context = getApplication<Application>() val context = getApplication<Application>()
val transport = com.mag160c.thermal.usb.UsbTransport(context) val transport = com.mag160c.thermal.usb.UsbTransport(context)
val dev = transport.findDevice()
if (dev == null) {
startDemo()
return
}
transport.requestPermission { ok -> transport.requestPermission { ok ->
if (ok) { if (ok) {
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() } 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 disconnect() = session.stop()
fun triggerFfc() = session.triggerFfc() fun triggerFfc() = session.triggerFfc()
@@ -206,11 +294,47 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/** Update per-frame temperature stats (called on a slow timer). */ /** Update per-frame temperature stats (called on a slow timer). */
fun refreshTemps() { 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 if (!session.isStreaming()) return
val center = session.probeTemp(80, 60) val center = session.probeTemp(80, 60)
val nuc = IntArray(19200) val nuc = IntArray(19200)
val ok = session.copyNuc(nuc) if (!session.copyNuc(nuc)) return
if (!ok) return updateTemps(center, nuc)
}
private val demoNuc = IntArray(19200)
private fun updateTemps(center: Int?, nuc: IntArray) {
var mn = Int.MAX_VALUE var mn = Int.MAX_VALUE
var mx = -1 var mx = -1
var mnPos = -1 var mnPos = -1
@@ -226,7 +350,6 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
mxPos = i mxPos = i
} }
} }
// probe temperatures
val probes = _state.value.probes.map { p -> val probes = _state.value.probes.map { p ->
p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f) 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() { override fun onCleared() {
demoPipeline = null
session.destroy() session.destroy()
super.onCleared() 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"> <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="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="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> </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"> <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,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" /> <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> </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"> <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" /> <path android:pathData="M12,12m-4,0a4,4 0 1,1 8,0a4,4 0 1,1 -8,0" android:fillColor="#FFD32F2F" />
</vector> </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"> <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" /> <path android:pathData="M16,16 L21,21" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
</vector> </vector>
Binary file not shown.