android: 相册页接线+演示模式(真实管线渲染合成帧+demo温度显示映射)
This commit is contained in:
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,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.
Reference in New Issue
Block a user