android: fix real-device defects (temp double-conversion, FFC temp jump, image orientation per grip, remote raw+metadata stream)

This commit is contained in:
ZXCLI
2026-09-11 03:01:13 +08:00
parent 0919f5e186
commit 464cfcb5e8
26 changed files with 1938 additions and 277 deletions
-1
View File
@@ -12,7 +12,6 @@
<uses-permission android:name="android.permission.CAMERA" />
<!-- LAN remote preview (Phase F): host broadcasts on UDP 47510, streams on TCP 47511 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -56,6 +56,26 @@ class RenderPipeline(
private var refCnt = 0
private var startupFfcDone = false
/** Raw counts scratch used while collecting FFC reference frames. */
private val refScratch = IntArray(npix)
/**
* True once at least one frame completed the full render path. Until then
* [copyNuc]/[probeTemp] must not be trusted: before the first render `nuc`
* still holds zeros, and countsToTempMc(0) is about -161 C, which the UI
* would happily display. Also false while an FFC window is running, so the
* OSD freezes instead of showing the shutter-closed data.
*/
@Volatile
private var renderedOnce = false
/** Last phase returned by the FFC state machine: 0 normal, 1 hidden, 2 reference. */
@Volatile
private var lastPhase = 0
/** True while [frameRemote] is inside the host's reference window. */
private var remoteRefWindow = false
private val ref = IntArray(npix)
private val refAcc = IntArray(npix)
private val nuc = IntArray(npix)
@@ -599,7 +619,10 @@ class RenderPipeline(
*/
fun frame(frame: ByteArray, hasHdr: Boolean, outArgb: IntArray): Boolean = synchronized(lock) {
globalFrames++
if (globalFrames < warmFrames) return false
if (globalFrames < warmFrames) {
lastPhase = 1
return false
}
var shutterValue = shutter
if (hasHdr) {
@@ -611,11 +634,15 @@ class RenderPipeline(
shutter = shutterValue
val st = ffcStep(shutterValue)
lastPhase = st
if (st != 0) {
if (st == 2) {
val off = if (hasHdr) 0x1C else 0
ByteReader.decodeU16(frame, off, npix, nuc)
refPush(nuc)
// collect the reference in a SCRATCH buffer: decoding into `nuc`
// would leave raw (uncompensated) counts there, and the OSD
// samples `nuc` on a timer — that is what made the max/min
// readouts jump to ~150 C for a moment during every FFC.
ByteReader.decodeU16(frame, if (hasHdr) 0x1C else 0, npix, refScratch)
refPush(refScratch)
}
return false
}
@@ -636,9 +663,73 @@ class RenderPipeline(
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
i++
}
renderedOnce = true
return true
}
/**
* Process one raw frame on behalf of a REMOTE host (LAN preview client).
*
* The host streams its raw sensor frames plus the metadata its own pipeline
* used, so the client reproduces the host's image instead of running an
* independent (and easily desynchronised) FFC state machine:
* - [phase] is the host's FFC phase for this frame (see [ffcPhase]);
* - [shutterIn] is the frame's camera temperature, required for the NUC
* table interpolation — without it the endpoint tables extrapolate
* wildly and every count saturates.
*
* Reference frames are averaged exactly like the host does, so the client's
* noise reference matches the host's.
*/
fun frameRemote(frame: ByteArray, phase: Int, shutterIn: Int, outArgb: IntArray): Boolean =
synchronized(lock) {
shutter = shutterIn
lastPhase = phase
when (phase) {
1 -> return false // shutter closed / settling: hold the last frame
2 -> {
if (!remoteRefWindow) {
remoteRefWindow = true
refCnt = 0
hasRef = false
renderedOnce = false
rebuildTables(shutterIn)
}
ByteReader.decodeU16(frame, 0, npix, refScratch)
refPush(refScratch)
return false
}
else -> {
remoteRefWindow = false
if (!hasRef) return false
ByteReader.decodeU16(frame, 0, npix, nuc)
nucAndBlind(nuc, nuc)
statsWindow()
lutRebuild()
grayMap()
upscale2x()
val pal = this.pal
var i = 0
while (i < npix * 4) {
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
i++
}
renderedOnce = true
return true
}
}
}
/** FFC phase of the frame just processed: 0 normal, 1 hidden, 2 reference. */
fun ffcPhase(): Int = synchronized(lock) { lastPhase }
/** Camera temperature of the frame just processed (raw sensor units). */
fun lastShutter(): Int = synchronized(lock) { shutter }
/** True once a frame completed the full render path (temps are meaningful). */
fun tempsReady(): Boolean = synchronized(lock) { renderedOnce }
fun setShutter(value: Int) {
synchronized(lock) { shutter = value }
}
@@ -126,16 +126,31 @@ class RemoteSession(
/** Control lines from the host; only meaningful before the stream starts. */
val lines: Flow<String> = _lines.asSharedFlow()
private val _frames = MutableSharedFlow<ByteArray>(
private val _frames = MutableSharedFlow<RemoteContract.FramePacket>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Raw 38400-byte sensor payloads, ready for the local RenderPipeline. */
val frames: Flow<ByteArray> = _frames.asSharedFlow()
/** Frame records (raw counts + the host's FFC metadata), ready to render. */
val frames: Flow<RemoteContract.FramePacket> = _frames.asSharedFlow()
private val closed = AtomicBoolean(false)
/**
* Command queue + single writer coroutine.
*
* Commands MUST be written by one coroutine: launching a writer per command
* (the first implementation) let two coroutines interleave on the same
* socket stream, so a `hello` immediately followed by `start` could reach the
* host in the opposite order — the host then answered `stream-start` first
* and the late `welcome` line arrived after the reader had switched to frame
* mode, where it was discarded as garbage (~6.7% of runs).
*/
private val outQueue = kotlinx.coroutines.channels.Channel<String>(
capacity = kotlinx.coroutines.channels.Channel.UNLIMITED,
)
private val writerStarted = AtomicBoolean(false)
@Volatile
private var streaming = false
@@ -255,14 +270,25 @@ class RemoteSession(
private fun send(cmd: String) {
if (closed.get()) return
ensureWriter()
// trySend from the CALLER's thread keeps enqueue order == call order
outQueue.trySend(cmd)
}
/** Start the single writer coroutine lazily, on first command. */
private fun ensureWriter() {
if (!writerStarted.compareAndSet(false, true)) return
scope.launch(Dispatchers.IO) {
try {
val out = socket.getOutputStream()
out.write((cmd + "\n").toByteArray(Charsets.UTF_8))
out.flush()
for (line in outQueue) {
if (closed.get()) break
out.write((line + "\n").toByteArray(Charsets.UTF_8))
out.flush()
}
} catch (e: Exception) {
if (!closed.get()) {
DebugLog.log("remote", "send failed: ${e.javaClass.simpleName}: ${e.message}")
DebugLog.log("remote", "writer ended: ${e.javaClass.simpleName}: ${e.message}")
}
}
}
@@ -270,6 +296,7 @@ class RemoteSession(
fun close() {
if (!closed.getAndSet(true)) {
runCatching { outQueue.close() }
runCatching { socket.close() }
}
}
@@ -7,8 +7,15 @@ package com.mag160c.thermal.net
* - discovery: UDP broadcast on port 47510, one UTF-8 JSON line per second:
* {"app":"mag160c-remote","role":"host","name":"<model>","tcp":47511,"serial":...}
* - control: TCP 47511, newline-delimited UTF-8 JSON, one line <= 4 KB
* - frames: after {"cmd":"start"} the host writes fixed 38412-byte binary
* records: [u32 LE 0x1BB1B11B][u32 LE counter][u32 LE 38400][38400 B u16 LE]
* - frames: after {"cmd":"start"} the host writes fixed frame records:
* [u32 LE magic 0x1BB1B11B][u32 LE counter][u32 LE 38400]
* [u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400 B u16 LE pixels]
* Header = 24 bytes, record = 38424 bytes.
* flags bit 0 = "renders" (a usable image frame); ffcPhase mirrors the HOST's
* FFC state machine (0 normal / 1 shutter closed / 2 reference) and shutter is
* the frame's camera temperature. The client needs both to reproduce the
* host's image: the NUC tables interpolate on shutter, and the reference
* frames must be averaged exactly as the host averages them.
* - the client renders locally with its own RenderPipeline + bundled DDT, so
* palette / zoom never cross the wire
* - keepalive: host sends {"type":"ping"} after 3 s without frames; the
@@ -24,11 +31,20 @@ object RemoteContract {
const val APP_TAG = "mag160c-remote"
const val FRAME_MAGIC = 0x1BB1B11B
const val FRAME_PIXELS = 38400
const val FRAME_TOTAL = 12 + FRAME_PIXELS // 38412
const val FRAME_HEADER = 24
const val FRAME_TOTAL = FRAME_HEADER + FRAME_PIXELS // 38424
const val PING_AFTER_MS = 3000L
const val DEAD_AFTER_MS = 10000L
const val MAX_LINE = 4096
/** flags bit 0: the host produced an image for this frame. */
const val FLAG_RENDERS = 1
/** FFC phase values mirrored from the host pipeline. */
const val PHASE_NORMAL = 0
const val PHASE_HIDDEN = 1
const val PHASE_REFERENCE = 2
/** One discovery beacon / host descriptor. */
data class HostInfo(
val name: String,
@@ -125,6 +141,9 @@ object RemoteContract {
fun okLine(): String = "{\"type\":\"ok\"}"
/** Sent to a second client while another one is already streaming. */
fun busyLine(): String = "{\"type\":\"busy\"}"
fun errorLine(message: String): String = "{\"type\":\"error\",\"message\":\"${escape(message)}\"}"
fun welcomeLine(w: Int, h: Int, fps: Int, serial: Long): String =
@@ -171,8 +190,25 @@ object RemoteContract {
fun isKeepalive(text: String): Boolean = typeOf(text) == "ping"
/** One decoded frame record. */
class FramePacket(
val counter: Int,
val flags: Int,
val ffcPhase: Int,
val shutter: Int,
val pixels: ByteArray,
) {
val renders: Boolean get() = flags and FLAG_RENDERS != 0
}
/** Build one frame record for the wire. */
fun encodeFramePacket(raw: ByteArray, counter: Int): ByteArray {
fun encodeFramePacket(
raw: ByteArray,
counter: Int,
flags: Int = FLAG_RENDERS,
ffcPhase: Int = PHASE_NORMAL,
shutter: Int = 0,
): ByteArray {
require(raw.size == FRAME_PIXELS) {
"frame payload must be $FRAME_PIXELS bytes, got ${raw.size}"
}
@@ -180,7 +216,10 @@ object RemoteContract {
putU32(out, 0, FRAME_MAGIC)
putU32(out, 4, counter)
putU32(out, 8, FRAME_PIXELS)
System.arraycopy(raw, 0, out, 12, FRAME_PIXELS)
putU32(out, 12, flags)
putU32(out, 16, ffcPhase)
putU32(out, 20, shutter)
System.arraycopy(raw, 0, out, FRAME_HEADER, FRAME_PIXELS)
return out
}
@@ -198,7 +237,7 @@ object RemoteContract {
}
/**
* Incremental reassembly of the TCP byte stream into frame payloads.
* Incremental reassembly of the TCP byte stream into frame records.
* Handles partial records and several records in one read (both occur with
* raw socket reads). Malformed data resynchronises on the next magic
* instead of dropping the connection.
@@ -207,12 +246,12 @@ object RemoteContract {
private var buffer = ByteArray(FRAME_TOTAL * 4)
private var size = 0
/** Feed bytes, get back every complete frame payload available. */
fun feed(bytes: ByteArray): List<ByteArray> {
/** Feed bytes, get back every complete frame available. */
fun feed(bytes: ByteArray): List<FramePacket> {
append(bytes)
val out = ArrayList<ByteArray>(2)
val out = ArrayList<FramePacket>(2)
while (true) {
if (size < 12) break
if (size < FRAME_HEADER) break
var magicAt = -1
for (i in 0..size - 4) {
if (u32(buffer, i) == FRAME_MAGIC) {
@@ -226,13 +265,21 @@ object RemoteContract {
break
}
if (magicAt > 0) dropBefore(magicAt)
if (size < 12) break
if (size < FRAME_HEADER) break
if (u32(buffer, 8) != FRAME_PIXELS) { // bogus header
dropBefore(1)
continue
}
if (size < FRAME_TOTAL) break
out.add(buffer.copyOfRange(12, FRAME_TOTAL))
out.add(
FramePacket(
counter = u32(buffer, 4),
flags = u32(buffer, 12),
ffcPhase = u32(buffer, 16),
shutter = u32(buffer, 20),
pixels = buffer.copyOfRange(FRAME_HEADER, FRAME_TOTAL),
),
)
dropBefore(FRAME_TOTAL)
}
return out
@@ -23,12 +23,14 @@ import java.util.concurrent.atomic.AtomicBoolean
* Host side of the LAN remote preview (Phase F).
*
* Broadcasts a discovery beacon on UDP 47510 once a second and serves exactly
* one preview client on TCP 47511. Frames arrive as raw 38400-byte sensor
* payloads (the same bytes the thermal pipeline consumes) and go out as fixed
* 38412-byte records; all rendering happens on the client.
* one preview client on TCP 47511 (a second client is told `busy` and closed).
* Frames arrive as raw 38400-byte sensor payloads plus the host pipeline's
* metadata for that frame (FFC phase, camera temperature); the client needs both
* to reproduce the host's image, because the NUC tables interpolate on the
* camera temperature and the FFC reference frames must be averaged identically.
*
* Concurrency: ONE coroutine owns the client socket — it reads control lines
* and writes both control replies and frame records. A second coroutine only
* and writes both control replies and frame records. A second coroutine only
* feeds a bounded queue, so a frame can never interleave into the middle of a
* write (which would desynchronise the client's frame reader).
*
@@ -53,13 +55,34 @@ class RemoteHost(
@Volatile
var onFfcRequest: (() -> Unit)? = null
/**
* Supplies the pipeline metadata for the frame just pushed via [offerFrame].
* Set by LiveViewModel: it must describe the SAME frame, which is why the
* session invokes rawHook after the pipeline processed it.
*/
@Volatile
var frameMeta: (() -> FrameMeta)? = null
/** FFC phase + camera temperature of one raw frame. */
data class FrameMeta(val phase: Int, val shutter: Int, val renders: Boolean)
private var scope: CoroutineScope? = null
private var serverSocket: ServerSocket? = null
private var discoverySocket: DatagramSocket? = null
private val running = AtomicBoolean(false)
/**
* Only one client is served at a time. The accept loop must NOT block inside
* serve(), otherwise a second connection would wait in the kernel backlog and
* never be told `busy` — so each client is served in its own coroutine and the
* flag rejects newcomers immediately.
*/
private val serving = AtomicBoolean(false)
/** Bounded queue; a slow client drops the oldest frame instead of stalling. */
private var frameQueueRef: Channel<ByteArray>? = null
private var frameQueueRef: Channel<RemoteHost.OutFrame>? = null
private class OutFrame(val pixels: ByteArray, val meta: FrameMeta)
val isRunning: Boolean get() = running.get()
@@ -89,11 +112,12 @@ class RemoteHost(
DebugLog.log("remote", "host stopped")
}
/** Push one raw sensor frame from the live session. */
/** Push one raw sensor frame from the live session together with its metadata. */
fun offerFrame(raw: ByteArray) {
if (!running.get()) return
if (raw.size != RemoteContract.FRAME_PIXELS) return
frameQueueRef?.trySend(raw)
val meta = frameMeta?.invoke() ?: FrameMeta(RemoteContract.PHASE_NORMAL, 0, true)
frameQueueRef?.trySend(OutFrame(raw, meta))
}
/** One UDP beacon per second on the broadcast address. */
@@ -145,7 +169,28 @@ class RemoteHost(
DebugLog.log("remote", "accept failed: $e")
break
}
serve(socket)
// single client: tell later arrivals we are busy instead of silently
// queueing them in the kernel backlog (plan F2 requires the busy line)
if (!serving.compareAndSet(false, true)) {
val addr = socket.inetAddress?.hostAddress ?: "?"
DebugLog.log("remote", "rejecting $addr: already serving a client")
runCatching {
val o = socket.getOutputStream()
o.write((RemoteContract.busyLine() + "\n").toByteArray(Charsets.UTF_8))
o.flush()
}
runCatching { socket.close() }
continue
}
// serve OFF the accept loop so the loop can keep accepting (and
// rejecting) while this client is connected
scope?.launch {
try {
serve(socket)
} finally {
serving.set(false)
}
}
}
runCatching { server.close() }
}
@@ -153,8 +198,13 @@ class RemoteHost(
/**
* Serve one client to completion. Single-threaded by design: every write to
* the socket happens here, so control replies and frame records stay framed.
*
* While streaming, a command reply must NOT be written: the client is in
* frame mode and would see the JSON line as stray bytes inside a frame
* record. Commands are therefore acknowledged only before the stream starts;
* `ffc` is executed and logged without a reply while streaming.
*/
private suspend fun serve(socket: Socket) {
private fun serve(socket: Socket) {
val addr = socket.inetAddress?.hostAddress ?: "?"
DebugLog.log("remote", "client connected from $addr")
clientAddress = addr
@@ -180,7 +230,7 @@ class RemoteHost(
if (text.isEmpty()) continue
when (RemoteContract.commandOf(text)) {
"hello" ->
writeLine(out, RemoteContract.welcomeLine(160, 120, 15, serial))
if (!streaming) writeLine(out, RemoteContract.welcomeLine(160, 120, 15, serial))
"start" -> if (!streaming) {
streaming = true
state = State.STREAMING
@@ -194,9 +244,10 @@ class RemoteHost(
}
"ffc" -> {
onFfcRequest?.invoke()
writeLine(out, RemoteContract.okLine())
// no reply while streaming (see the note above)
if (!streaming) writeLine(out, RemoteContract.okLine())
}
else -> if (RemoteContract.typeOf(text).isEmpty()) {
else -> if (RemoteContract.typeOf(text).isEmpty() && !streaming) {
writeLine(out, RemoteContract.errorLine("unknown command"))
}
}
@@ -205,8 +256,16 @@ class RemoteHost(
if (streaming) {
var wrote = false
while (true) {
val frame = frameQueueRef?.tryReceive()?.getOrNull() ?: break
out.write(RemoteContract.encodeFramePacket(frame, counter++))
val f = frameQueueRef?.tryReceive()?.getOrNull() ?: break
out.write(
RemoteContract.encodeFramePacket(
raw = f.pixels,
counter = counter++,
flags = if (f.meta.renders) RemoteContract.FLAG_RENDERS else 0,
ffcPhase = f.meta.phase,
shutter = f.meta.shutter,
),
)
sentFrames++
wrote = true
}
@@ -221,7 +280,9 @@ class RemoteHost(
DebugLog.log("remote", "frames=$sentFrames to $addr")
}
}
kotlinx.coroutines.delay(5)
// blocking sleep is correct here: this method runs on its own IO
// dispatcher thread and must not yield the socket to another writer
Thread.sleep(5)
}
} catch (e: Exception) {
DebugLog.log("remote", "client $addr error: ${e.javaClass.simpleName}: ${e.message}")
@@ -0,0 +1,149 @@
package com.mag160c.thermal.ui.live
/**
* Pure geometry for the live image: the rotation/flip transform, the letterbox
* fit rect, and the sensor<->screen mapping shared by the renderer (markers,
* colour bar) and the tap handler. Deliberately free of Android types so it can
* be unit tested on the JVM — a mismatch between the drawn image and the marker
* mapping is exactly the class of bug this file exists to prevent.
*
* ## Why the image rotates at all
*
* The activity is portrait-locked, so the composition (top bar / image area /
* bottom bar) never moves — that stays as the user demanded. But the IMAGE
* CONTENT must stay aligned with the world, otherwise turning the phone makes
* the scene turn with it.
*
* The official app rotates the image according to the display rotation:
* Display.rotation 0/1/2/3 -> image 90/0/270/180 (MainActivity
* windowOrientationListener -> DeviceController.setPreviewOrientation, applied
* as matrix.postRotate in ImageViewer.drawImage). Its window also auto-rotates,
* so the net on-screen rotation is constant. With a LOCKED window the same
* appearance requires
*
* rot = 90 - gripDeg (mod 360)
*
* where gripDeg is the clockwise physical rotation of the phone (DeviceOrientation
* reports 0/90/180/270). Sanity check against the official mapping:
* official image rotation 90+displayRotation_delta equals 90 + gripDeg; the
* window contributes -gripDeg, so the visible result matches.
*
* [userRotateDeg] and the two flips are the official-style manual corrections
* ("旋转USB画面" / "水平翻转" / "竖直翻转") for setups where the sensor is mounted
* differently.
*/
object ImageTransform {
const val SENSOR_W = 160
const val SENSOR_H = 120
/**
* @param rotDeg clockwise rotation applied to the image content, in buffer space
* @param flipH mirror the source horizontally (before rotation)
* @param flipV mirror the source vertically (before rotation)
*/
data class Params(
val rotDeg: Int,
val flipH: Boolean,
val flipV: Boolean,
)
fun params(gripDeg: Int, userRotateDeg: Int = 0, flipH: Boolean = false, flipV: Boolean = false): Params =
Params((((90 - gripDeg + userRotateDeg) % 360) + 360) % 360, flipH, flipV)
/** True when the drawn image is taller than wide on screen (rot 90/270). */
fun swapped(rotDeg: Int): Boolean = rotDeg % 180 != 0
/** Letterboxed rect for the image inside the available area. */
class Fit(val left: Float, val top: Float, val width: Float, val height: Float) {
val cx: Float get() = left + width / 2f
val cy: Float get() = top + height / 2f
val right: Float get() = left + width
val bottom: Float get() = top + height
}
/** On-screen aspect of the drawn image: the source is 4:3, sideways 3:4. */
fun screenAspect(rotDeg: Int): Float = if (swapped(rotDeg)) 3f / 4f else 4f / 3f
fun fit(availLeft: Float, availTop: Float, availW: Float, availH: Float, rotDeg: Int): Fit {
val aspect = screenAspect(rotDeg)
var w = availW
var h = availW / aspect
if (h > availH) {
h = availH
w = availH * aspect
}
return Fit(availLeft + (availW - w) / 2f, availTop + (availH - h) / 2f, w, h)
}
/** Digital-zoom crop in normalised source units; 0,0,1,1 = no zoom. */
fun cropForZoom(zoom: Int): FloatArray {
if (zoom <= 1) return floatArrayOf(0f, 0f, 1f, 1f)
val inset = (1f - 1f / zoom) / 2f
return floatArrayOf(inset, inset, 1f - inset, 1f - inset)
}
/** Pre-rotation draw size for a fit rect (the rotated footprint). */
private fun preRotationSize(fit: Fit, rotDeg: Int): FloatArray =
if (swapped(rotDeg)) floatArrayOf(fit.height, fit.width)
else floatArrayOf(fit.width, fit.height)
/**
* Sensor pixel -> buffer point. Sampled at pixel centres so markers land on
* the middle of the pixel they describe.
*/
fun sensorToScreen(sx: Float, sy: Float, p: Params, fit: Fit, crop: FloatArray): FloatArray {
var u = (sx + 0.5f) / SENSOR_W
var v = (sy + 0.5f) / SENSOR_H
if (p.flipH) u = 1f - u
if (p.flipV) v = 1f - v
val size = preRotationSize(fit, p.rotDeg)
val lx = (u - crop[0]) / (crop[2] - crop[0])
val ly = (v - crop[1]) / (crop[3] - crop[1])
val px = fit.cx - size[0] / 2f + lx * size[0]
val py = fit.cy - size[1] / 2f + ly * size[1]
return rotate(px, py, fit.cx, fit.cy, p.rotDeg.toFloat())
}
/**
* Buffer point -> sensor pixel (inverse of [sensorToScreen]).
* Returns null when the point falls outside the drawn image.
*/
fun screenToSensor(x: Float, y: Float, p: Params, fit: Fit, crop: FloatArray): Pair<Int, Int>? {
val q = rotate(x, y, fit.cx, fit.cy, -p.rotDeg.toFloat())
val size = preRotationSize(fit, p.rotDeg)
val lx = (q[0] - (fit.cx - size[0] / 2f)) / size[0]
val ly = (q[1] - (fit.cy - size[1] / 2f)) / size[1]
if (lx < 0f || lx > 1f || ly < 0f || ly > 1f) return null
var u = crop[0] + lx * (crop[2] - crop[0])
var v = crop[1] + ly * (crop[3] - crop[1])
if (p.flipH) u = 1f - u
if (p.flipV) v = 1f - v
val sx = (u * SENSOR_W).toInt()
val sy = (v * SENSOR_H).toInt()
if (sx < 0 || sy < 0 || sx >= SENSOR_W || sy >= SENSOR_H) return null
return sx to sy
}
/** Clockwise rotation of a point about a pivot, in buffer (y-down) coords. */
private fun rotate(x: Float, y: Float, cx: Float, cy: Float, deg: Float): FloatArray {
val r = Math.toRadians(deg.toDouble())
val c = kotlin.math.cos(r).toFloat()
val s = kotlin.math.sin(r).toFloat()
val dx = x - cx
val dy = y - cy
return floatArrayOf(cx + dx * c - dy * s, cy + dx * s + dy * c)
}
/**
* Half extents of a text box drawn with a clockwise [textRotDeg] rotation.
* Used to place labels flush against their target (e.g. the colour-bar
* ends) whatever the grip: the box is positioned by its bounding box, not
* by a baseline anchor, so it stays aligned when the text is turned 90 deg.
*/
fun rotatedBoxHalfExtents(textW: Float, textH: Float, textRotDeg: Float): FloatArray {
val r = Math.toRadians(textRotDeg.toDouble())
val c = kotlin.math.abs(kotlin.math.cos(r)).toFloat()
val s = kotlin.math.abs(kotlin.math.sin(r)).toFloat()
return floatArrayOf((textW * c + textH * s) / 2f, (textW * s + textH * c) / 2f)
}
}
@@ -89,7 +89,7 @@ class LiveRenderer(
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
val frame = vm.latestFrame ?: return
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
setFramePixels(frame)
// available area (minus UI overlays)
val top = vm.uiTopPx.toFloat()
@@ -98,33 +98,33 @@ class LiveRenderer(
val availH = bottom - top
if (availH <= 0) return
// fit the 3:4 (rotated) image into the available rect (fixed orientation)
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val vpTop = top + (availH - dstH) / 2f
viewport.set(left, vpTop, left + dstW, vpTop + dstH)
// Grip-compensated orientation (see ImageTransform): the composition
// bars, insets, image area — stays glued to the portrait frame as
// demanded, but the IMAGE CONTENT counter-rotates so the scene stays
// aligned with the world when the phone is turned.
val params = vm.imageParams(orientationDeg)
val fit = ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
viewport.set(fit.left, fit.top, fit.right, fit.bottom)
// draw the source bitmap rotated 90 deg CW around the viewport center
val cx = viewport.centerX()
val cy = viewport.centerY()
val zoom = vm.state.value.zoom
val crop = ImageTransform.cropForZoom(zoom)
val srcRect = if (zoom > 1) {
val cw = 320 / zoom
val ch = 240 / zoom
val cw = (320 * (crop[2] - crop[0])).toInt()
val ch = (240 * (crop[3] - crop[1])).toInt()
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
} else null
val size = if (ImageTransform.swapped(params.rotDeg)) {
floatArrayOf(fit.height, fit.width)
} else {
floatArrayOf(fit.width, fit.height)
}
canvas.save()
canvas.rotate(90f, cx, cy)
// pre-rotation draw rect (source aspect 4:3 inside the rotated 3:4 viewport)
val w0 = dstH
val h0 = dstW
val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
canvas.rotate(params.rotDeg.toFloat(), fit.cx, fit.cy)
val dst = android.graphics.RectF(
fit.cx - size[0] / 2f, fit.cy - size[1] / 2f,
fit.cx + size[0] / 2f, fit.cy + size[1] / 2f,
)
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.restore()
@@ -132,6 +132,36 @@ class LiveRenderer(
drawOsd(canvas, vm.state.value)
}
/** Grip angle in 0/90/180/270; drives the OSD pre-rotation. */
private val orientationDeg: Int
get() = com.mag160c.thermal.ui.DeviceOrientation.deg.value
/**
* Copy the rendered frame into the draw bitmap, applying the user's
* mirror settings. Doing the flip on the pixel copy (instead of a negative
* scale in the draw matrix) keeps the buffer-space mapping in
* [ImageTransform] exact and testable.
*/
private fun setFramePixels(frame: IntArray) {
val p = vm.imageParams(orientationDeg)
if (!p.flipH && !p.flipV) {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
return
}
val flipped = IntArray(320 * 240)
for (y in 0 until 240) {
val sy = if (p.flipV) 239 - y else y
val srcRow = sy * 320
val dstRow = y * 320
if (!p.flipH) {
System.arraycopy(frame, srcRow, flipped, dstRow, 320)
} else {
for (x in 0 until 320) flipped[dstRow + x] = frame[srcRow + (319 - x)]
}
}
bitmap.setPixels(flipped, 0, 320, 0, 0, 320, 240)
}
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
if (sx < 0 || sy < 0 || tempC == null) return
val p = vm.probeToScreen(sx, sy)
@@ -146,20 +176,52 @@ class LiveRenderer(
markerPaint.style = Paint.Style.FILL
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
val tw = textPaint.measureText(text)
val half = gripTextHalf(text)
val pad = 6f * density
var tx = cx + 14f * density
var ty = cy + textPaint.textSize
if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * density
// pivot around the marker anchor: the label stays attached while upright
// place the label beside the marker in the TEXT's own frame, so with a
// 90 deg grip the label still sits clear of the dot instead of drifting
val candidates = floatArrayOf(dotR + 5f * density, -(dotR + 5f * density))
var best: FloatArray? = null
for (off in candidates) {
val c = offsetInTextFrame(cx, cy, off, 0f)
val fits = c[0] - half[0] - pad >= viewport.left + 2f * density &&
c[0] + half[0] + pad <= viewport.right - 2f * density &&
c[1] - half[1] >= viewport.top + 2f * density &&
c[1] + half[1] <= viewport.bottom - 2f * density
if (fits) { best = c; break }
if (best == null) best = c
}
val c = best!!
drawGripText(canvas, text, c[0], c[1])
}
/** Rotate a text-local offset into buffer space and add it to an anchor. */
private fun offsetInTextFrame(ax: Float, ay: Float, offX: Float, offY: Float): FloatArray {
val r = Math.toRadians(textRot.toDouble())
val c = kotlin.math.cos(r).toFloat()
val s = kotlin.math.sin(r).toFloat()
return floatArrayOf(ax + offX * c - offY * s, ay + offX * s + offY * c)
}
/** Draw OSD text pre-rotated by the grip angle, centred on a buffer point. */
private fun drawGripText(canvas: Canvas, text: String, centerX: Float, centerY: Float) {
val tw = textPaint.measureText(text)
val fm = textPaint.fontMetrics
canvas.save()
canvas.rotate(textRot, cx, cy)
canvas.drawText(text, tx, ty, textPaint)
canvas.rotate(textRot, centerX, centerY)
val baseline = centerY - (fm.ascent + fm.descent) / 2f
canvas.drawText(text, centerX - tw / 2f, baseline, textPaint)
canvas.restore()
}
/** Half extents of the drawn text box in buffer space. */
private fun gripTextHalf(text: String): FloatArray {
val fm = textPaint.fontMetrics
return ImageTransform.rotatedBoxHalfExtents(
textPaint.measureText(text), fm.descent - fm.ascent, textRot,
)
}
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
@@ -171,24 +233,23 @@ class LiveRenderer(
val n = 96
for (i in 0 until n) {
val c = pal[255 - i * 255 / (n - 1)]
seg.color = c
val sy = y0 + barH * i / n
val ey = y0 + barH * (i + 1) / n
seg.color = c
canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg)
}
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
val labelMaxX = x + barW / 2f - textPaint.measureText(minT) / 2
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
canvas.restore()
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
canvas.drawText(minT, labelMaxX, y0 + barH + textPaint.textSize, textPaint)
canvas.restore()
val cx = x + barW / 2f
val gap = 8f * density
// Position by the ROTATED bounding box: with a 90 deg grip the text runs
// along the bar, so centring a baseline anchor (the old code) made the
// numbers overlap the bar instead of sitting beside its ends.
val halfMax = gripTextHalf(maxT)
val halfMin = gripTextHalf(minT)
drawGripText(canvas, maxT, cx, y0 - gap - halfMax[1])
drawGripText(canvas, minT, cx, y0 + barH + gap + halfMin[1])
}
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
@@ -196,15 +257,19 @@ class LiveRenderer(
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
canvas.save()
canvas.rotate(textRot, ox, oy)
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
canvas.restore()
val text = "中心 %.1f℃".format(it)
val half = gripTextHalf(text)
// keep the readout inside the image rect whatever the grip
val cx = (ox + half[0]).coerceAtMost(viewport.right - half[0] - 4f * density)
val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
drawGripText(canvas, text, cx, cy)
}
// the trace toggle governs BOTH extremes (it used to leave the min
// marker drawn, which read as "the switch only works halfway")
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, "")
}
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
for (p in state.probes) {
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
}
@@ -99,6 +99,11 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
kotlinx.coroutines.delay(400)
navPx = UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
// pick up orientation changes made on the settings tab
val s = com.mag160c.thermal.ui.settings.AppSettings(context)
vm.userRotateDeg = s.imageRotateDeg
vm.flipH = s.imageFlipH
vm.flipV = s.imageFlipV
vm.refreshTemps()
}
}
@@ -127,11 +132,14 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
.pointerInput(phi) {
detectTapGestures { offset ->
// the same grip angle the renderer used, so the tap maps
// to the pixel actually under the finger
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
phi,
)
}
},
@@ -47,6 +47,28 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
private val session = IrSession(app)
// declared before init{} so the hook wiring there can reference it
private val remoteHost = com.mag160c.thermal.net.RemoteHost(
deviceName = android.os.Build.MODEL ?: "Android",
serial = 0L,
)
val remoteHostState: com.mag160c.thermal.net.RemoteHost get() = remoteHost
/** Start/stop the preview server. Requires an active USB session. */
fun setRemoteHostEnabled(enabled: Boolean): Boolean {
if (enabled) {
if (!session.isStreaming()) return false
remoteHost.onFfcRequest = { triggerFfc() }
remoteHost.start()
} else {
remoteHost.stop()
}
return true
}
fun isRemoteHostRunning(): Boolean = remoteHost.isRunning
companion object {
/** PIP overlay width in dp for size index 0/1/2. */
val PIP_WIDTHS_DP = intArrayOf(96, 128, 160)
@@ -149,37 +171,27 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
rec.offerFrame(bmp)
}
}
// feed the LAN remote host (Phase F) with raw sensor frames
// feed the LAN remote host (Phase F) with raw sensor frames. The session
// calls this AFTER the pipeline processed the frame, so the metadata
// read here describes exactly this frame.
session.rawHook = { raw ->
if (raw.size >= 0x1C + 38400) {
remoteHost.offerFrame(raw.copyOfRange(0x1C, 0x1C + 38400))
}
}
// metadata the remote client needs to reproduce the host's image
remoteHost.frameMeta = {
val pipe = session
com.mag160c.thermal.net.RemoteHost.FrameMeta(
phase = pipe.ffcPhase(),
shutter = pipe.lastShutter(),
renders = pipe.tempsReady(),
)
}
}
// ---- LAN remote preview host (Phase F) ----
private val remoteHost = com.mag160c.thermal.net.RemoteHost(
deviceName = android.os.Build.MODEL ?: "Android",
serial = 0L,
)
val remoteHostState: com.mag160c.thermal.net.RemoteHost get() = remoteHost
/** Start/stop the preview server. Requires an active USB session. */
fun setRemoteHostEnabled(enabled: Boolean): Boolean {
if (enabled) {
if (!session.isStreaming()) return false
remoteHost.onFfcRequest = { triggerFfc() }
remoteHost.start()
} else {
remoteHost.stop()
}
return true
}
fun isRemoteHostRunning(): Boolean = remoteHost.isRunning
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
fun connect() {
val now = android.os.SystemClock.elapsedRealtime()
@@ -291,33 +303,67 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
* (3:4 vertical) and does NOT move with the phone; only OSD text follows
* the screen. Insets carve the available area.
*/
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
/**
* Manual orientation corrections, mirroring the official app's settings
* ("旋转USB画面" / "水平翻转" / "竖直翻转"). Applied on top of the automatic
* grip compensation, for sensor mounts that need a fixed offset.
*/
@Volatile
var userRotateDeg: Int = 0
@Volatile
var flipH: Boolean = false
@Volatile
var flipV: Boolean = false
/**
* Orientation actually used for drawing and for the sensor<->screen mapping.
* [gripDeg] is the accelerometer grip angle (0/90/180/270).
* One definition for both the renderer and the tap/probe mapping — they must
* never disagree, which is how markers ended up on the wrong pixel.
*/
fun imageParams(gripDeg: Int): ImageTransform.Params =
ImageTransform.params(gripDeg, userRotateDeg, flipH, flipV)
/** Fit rect of the drawn image for the current view/insets/orientation. */
private fun currentFit(gripDeg: Int): ImageTransform.Fit {
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
return ImageTransform.fit(
0f, uiTopPx.toFloat(), viewW, availH,
imageParams(gripDeg).rotDeg,
)
}
/**
* Tap on the live image: add a probe point, or delete an existing one when
* tapping near it. Coordinates go through the SAME transform the renderer
* used, so a tap always lands on the pixel under the finger whatever the
* grip angle and flip settings are.
*/
fun tapImage(
screenX: Float,
screenY: Float,
viewW: Float,
viewH: Float,
gripDeg: Int = 0,
) {
val s = _state.value
if (!s.streaming) return
val top = uiTopPx.toFloat()
val bottom = viewH - uiBottomPx.toFloat()
if (screenY < top || screenY > bottom) return
// fit the 3:4 (rotated 90 CW) image into the available rect
val availW = viewW
val availH = bottom - top
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val top2 = top + (availH - dstH) / 2f
if (screenX < left || screenX > left + dstW || screenY < top2 || screenY > top2 + dstH) return
val fx = (screenX - left) / dstW
val fy = (screenY - top2) / dstH
// inverse of the fixed 90 CW mapping: fx = 1 - sy/120, fy = sx/160
val sy = (1f - fx) * 120f
val sx = fy * 160f
val availH = (viewH - uiBottomPx - uiTopPx).coerceAtLeast(1f)
val fit = ImageTransform.fit(
0f, uiTopPx.toFloat(), viewW, availH,
imageParams(gripDeg).rotDeg,
)
val crop = ImageTransform.cropForZoom(s.zoom)
val sensor = ImageTransform.screenToSensor(
screenX, screenY, imageParams(gripDeg), fit, crop,
) ?: return
// near an existing probe (compare in screen space)? delete it instead
val thr = dstW * 0.06f
val thr = fit.width * 0.06f
val existing = s.probes.firstOrNull { p ->
val scr = probeToScreen(p.x, p.y)
val scr = probeToScreen(p.x, p.y, gripDeg)
val dx = screenX - scr[0]
val dy = screenY - scr[1]
dx * dx + dy * dy < thr * thr
@@ -326,32 +372,26 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(probes = _state.value.probes - existing)
return
}
val label = "Pt${s.probes.size + 1}"
val p = ProbePoint(
sx.toInt().coerceIn(0, 159),
sy.toInt().coerceIn(0, 119),
label,
null,
)
val p = ProbePoint(sensor.first, sensor.second, "Pt${s.probes.size + 1}", null)
_state.value = _state.value.copy(probes = _state.value.probes + p)
refreshTemps()
}
/** Screen coords of a sensor point under the fixed 90 CW rotation + insets. */
fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
var dstW = viewW
var dstH = viewW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (viewW - dstW) / 2f
val top = uiTopPx + (availH - dstH) / 2f
val fx = 1f - sy / 120f
val fy = sx / 160f
return floatArrayOf(left + fx * dstW, top + fy * dstH)
/**
* Screen coords of a sensor point, using the renderer's current transform.
* Defaults to the LIVE grip angle so renderer and callers cannot disagree —
* a mismatched grip here is exactly how markers land on the wrong pixel.
*/
fun probeToScreen(
sx: Int,
sy: Int,
gripDeg: Int = com.mag160c.thermal.ui.DeviceOrientation.deg.value,
): FloatArray {
val fit = currentFit(gripDeg)
val crop = ImageTransform.cropForZoom(state.value.zoom)
return ImageTransform.sensorToScreen(
sx.toFloat(), sy.toFloat(), imageParams(gripDeg), fit, crop,
)
}
/** Last known surface size, for probe screen mapping. */
@@ -391,15 +431,26 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
)
}
if (!session.isStreaming()) return
val center = session.probeTemp(80, 60)
// Before the first completed render `nuc` is still zeros and
// countsToTempMc(0) is about -161 C; during an FFC the buffers are
// mid-update. Skip rather than show nonsense.
if (!session.tempsReady()) return
val centerMc = session.probeTemp(80, 60)
val nuc = IntArray(19200)
if (!session.copyNuc(nuc)) return
updateTemps(center, nuc)
updateTemps(centerMc, nuc)
}
private var uiTick = 0
private fun updateTemps(center: Int?, nuc: IntArray) {
/**
* @param centerMc centre temperature in MILLIDEGREES C, as returned by
* [IrSession.probeTemp] — it must NOT be converted again here (the old
* code ran countsToTempMc() on an already-converted value, which showed
* e.g. 108.7 C for a ~24 C scene).
* @param nuc raw NUC counts, converted here once.
*/
private fun updateTemps(centerMc: Int?, nuc: IntArray) {
var mn = Int.MAX_VALUE
var mx = -1
var mnPos = -1
@@ -419,7 +470,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f)
}
_state.value = _state.value.copy(
centerTempC = center?.let { TempMath.countsToTempMc(it) / 1000f },
centerTempC = centerMc?.let { it / 1000f },
maxTempC = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null,
minTempC = if (mn <= Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null,
maxPos = mxPos,
@@ -76,7 +76,6 @@ class RemoteRendererHost(
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
val frame = vm.latestFrame ?: return
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
val top = vm.uiTopPx.toFloat()
val bottom = h - vm.uiBottomPx.toFloat()
@@ -84,31 +83,33 @@ class RemoteRendererHost(
val availH = bottom - top
if (availH <= 0) return
// fit the 3:4 (rotated) image into the available rect (same as local view)
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val vpTop = top + (availH - dstH) / 2f
viewport.set(left, vpTop, left + dstW, vpTop + dstH)
// Same orientation pipeline as the live view: the remote screen must
// present the host's image identically, including the grip compensation
// and the manual rotate/flip corrections.
val params = vm.imageParams(DeviceOrientation.deg.value)
setFramePixels(frame, params)
val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
viewport.set(fit.left, fit.top, fit.right, fit.bottom)
val cx = viewport.centerX()
val cy = viewport.centerY()
val zoom = vm.state.value.zoom
val crop = com.mag160c.thermal.ui.live.ImageTransform.cropForZoom(zoom)
val srcRect = if (zoom > 1) {
val cw = 320 / zoom
val ch = 240 / zoom
val cw = (320 * (crop[2] - crop[0])).toInt()
val ch = (240 * (crop[3] - crop[1])).toInt()
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
} else null
val size = if (com.mag160c.thermal.ui.live.ImageTransform.swapped(params.rotDeg)) {
floatArrayOf(fit.height, fit.width)
} else {
floatArrayOf(fit.width, fit.height)
}
canvas.save()
canvas.rotate(90f, cx, cy)
val w0 = dstH
val h0 = dstW
val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
canvas.rotate(params.rotDeg.toFloat(), fit.cx, fit.cy)
val dst = android.graphics.RectF(
fit.cx - size[0] / 2f, fit.cy - size[1] / 2f,
fit.cx + size[0] / 2f, fit.cy + size[1] / 2f,
)
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.restore()
@@ -117,6 +118,29 @@ class RemoteRendererHost(
drawOsd(canvas, vm.state.value)
}
/** Copy the frame into the draw bitmap, honouring the user's mirror settings. */
private fun setFramePixels(
frame: IntArray,
p: com.mag160c.thermal.ui.live.ImageTransform.Params,
) {
if (!p.flipH && !p.flipV) {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
return
}
val flipped = IntArray(320 * 240)
for (y in 0 until 240) {
val sy = if (p.flipV) 239 - y else y
val srcRow = sy * 320
val dstRow = y * 320
if (!p.flipH) {
System.arraycopy(frame, srcRow, flipped, dstRow, 320)
} else {
for (x in 0 until 320) flipped[dstRow + x] = frame[srcRow + (319 - x)]
}
}
bitmap.setPixels(flipped, 0, 320, 0, 0, 320, 240)
}
private fun drawColorBar(canvas: Canvas, state: RemoteViewerViewModel.State) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
@@ -134,16 +158,14 @@ class RemoteRendererHost(
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
val labelMinX = x + barW / 2f - textPaint.measureText(minT) / 2
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
canvas.restore()
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
canvas.drawText(minT, labelMinX, y0 + barH + textPaint.textSize, textPaint)
canvas.restore()
val cx = x + barW / 2f
val gap = 8f * density
// position by the ROTATED bounding box so the numbers stay beside the bar
// ends whatever the grip (baseline anchoring drifted with rotation)
val halfMax = gripTextHalf(maxT)
val halfMin = gripTextHalf(minT)
drawGripText(canvas, maxT, cx, y0 - gap - halfMax[1])
drawGripText(canvas, minT, cx, y0 + barH + gap + halfMin[1])
}
private fun drawOsd(canvas: Canvas, state: RemoteViewerViewModel.State) {
@@ -151,42 +173,62 @@ class RemoteRendererHost(
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
canvas.save()
canvas.rotate(textRot, ox, oy)
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
canvas.restore()
val text = "中心 %.1f℃".format(it)
val half = gripTextHalf(text)
val cx = (ox + half[0]).coerceAtMost(viewport.right - half[0] - 4f * density)
val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
drawGripText(canvas, text, cx, cy)
}
// max-temperature marker, same geometry as the local view
val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
// the trace toggle governs both extremes, as on the live screen
if (state.maxTraceOn && state.maxPos >= 0 && state.maxTempC != null) {
val sx = state.maxPos % 160
val sy = state.maxPos / 160
val p = probeToScreen(sx, sy)
val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
val p = probeToScreen(state.maxPos % 160, state.maxPos / 160)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(p[0], p[1], 7f * density, markerPaint)
canvas.drawCircle(p[0], p[1], 9f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(p[0], p[1], 3.5f * density, markerPaint)
canvas.drawCircle(p[0], p[1], 4.5f * density, markerPaint)
}
if (state.maxTraceOn && state.minPos >= 0 && state.minTempC != null) {
val p = probeToScreen(state.minPos % 160, state.minPos / 160)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(p[0], p[1], 9f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(p[0], p[1], 4.5f * density, markerPaint)
}
}
/** Sensor pixel -> screen position under the fixed 90 CW rotation + insets. */
/** Draw OSD text pre-rotated by the grip angle, centred on a buffer point. */
private fun drawGripText(canvas: Canvas, text: String, centerX: Float, centerY: Float) {
val tw = textPaint.measureText(text)
val fm = textPaint.fontMetrics
canvas.save()
canvas.rotate(textRot, centerX, centerY)
val baseline = centerY - (fm.ascent + fm.descent) / 2f
canvas.drawText(text, centerX - tw / 2f, baseline, textPaint)
canvas.restore()
}
private fun gripTextHalf(text: String): FloatArray {
val fm = textPaint.fontMetrics
return com.mag160c.thermal.ui.live.ImageTransform.rotatedBoxHalfExtents(
textPaint.measureText(text), fm.descent - fm.ascent, textRot,
)
}
/** Sensor pixel -> screen position, sharing the live view's geometry. */
private fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = vm.uiViewW.toFloat().coerceAtLeast(1f)
val availH = (vm.uiViewH - vm.uiTopPx - vm.uiBottomPx).toFloat().coerceAtLeast(1f)
var dstW = viewW
var dstH = viewW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (viewW - dstW) / 2f
val top = vm.uiTopPx + (availH - dstH) / 2f
val fx = 1f - sy / 120f
val fy = sx / 160f
return floatArrayOf(left + fx * dstW, top + fy * dstH)
val params = vm.imageParams(DeviceOrientation.deg.value)
val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, vm.uiTopPx.toFloat(), viewW, availH, params.rotDeg)
val crop = com.mag160c.thermal.ui.live.ImageTransform.cropForZoom(vm.state.value.zoom)
return com.mag160c.thermal.ui.live.ImageTransform.sensorToScreen(
sx.toFloat(), sy.toFloat(), params, fit, crop,
)
}
}
@@ -66,6 +66,7 @@ fun RemoteViewerScreen(
val state by vm.state.collectAsState()
val phi by DeviceOrientation.deg.collectAsState()
val density = LocalDensity.current.density
val context = androidx.compose.ui.platform.LocalContext.current
var showPalette by remember { mutableStateOf(false) }
var navPx by remember { mutableStateOf(com.mag160c.thermal.ui.UiInsets.navPx) }
var shutterPx by remember { mutableStateOf(0) }
@@ -76,15 +77,15 @@ fun RemoteViewerScreen(
kotlinx.coroutines.delay(400)
navPx = com.mag160c.thermal.ui.UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
// keep the remote image oriented like the live view
val s = com.mag160c.thermal.ui.settings.AppSettings(context)
vm.userRotateDeg = s.imageRotateDeg
vm.flipH = s.imageFlipH
vm.flipV = s.imageFlipV
}
}
LaunchedEffect(Unit) {
vm.disconnected.collect {
// read the live status, not a value captured at composition time
onDisconnected(
if (vm.state.value.status == "connect_fail") "无法连接主机" else "连接已断开",
)
}
vm.disconnected.collect { reason -> onDisconnected(reason) }
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -63,13 +63,31 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
@Volatile
var uiViewH: Int = 2280
/**
* Manual orientation corrections, persisted like the live screen's. The
* remote view must use the SAME settings as the host's live view, otherwise
* the two screens disagree about which way is up.
*/
@Volatile
var userRotateDeg: Int = 0
@Volatile
var flipH: Boolean = false
@Volatile
var flipV: Boolean = false
/** See [com.mag160c.thermal.ui.live.ImageTransform.params]. */
fun imageParams(gripDeg: Int): com.mag160c.thermal.ui.live.ImageTransform.Params =
com.mag160c.thermal.ui.live.ImageTransform.params(gripDeg, userRotateDeg, flipH, flipV)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var session: RemoteSession? = null
private var pipeline: RenderPipeline? = null
private var renderJob: Job? = null
/** Emits once when the link drops (UI shows a snackbar and returns). */
private val _disconnected = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val _disconnected = MutableSharedFlow<String>(extraBufferCapacity = 1)
val disconnected = _disconnected
/** Connect, then immediately start the stream. */
@@ -78,7 +96,7 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
val s = RemoteClient.connect(host, port, scope)
if (s == null) {
_state.value = _state.value.copy(connected = false, status = "connect_fail")
_disconnected.tryEmit(Unit)
_disconnected.tryEmit("无法连接主机")
return@launch
}
session = s
@@ -98,18 +116,30 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
pipe.setPalette(_state.value.paletteIndex)
pipeline = pipe
// control lines (welcome / stream-start) for logging
// control lines (welcome / busy / stream-start) for logging + UX
launch {
s.lines.collect { line -> DebugLog.log("remote", "line: $line") }
s.lines.collect { line ->
DebugLog.log("remote", "line: $line")
if (RemoteContract.isBusyLine(line)) {
// the host is already serving someone else
_state.value = _state.value.copy(status = "busy")
_disconnected.tryEmit("主机正忙(已有客户端连接)")
}
}
}
// frames -> local pipeline -> latestFrame
// frames -> local pipeline -> latestFrame.
// The host sends the raw counts plus the metadata ITS pipeline used
// (FFC phase + camera temperature). Running an independent FFC state
// machine on the client produced a wrong image (the NUC tables
// interpolate on the camera temperature, which was missing, so the
// counts saturated and the readout showed ~-161 C).
val out = IntArray(320 * 240)
renderJob = launch {
s.frames.collect { raw ->
s.frames.collect { pkt ->
val n = _state.value.frames + 1
if (n == 1) DebugLog.log("remote", "first remote frame")
_state.value = _state.value.copy(frames = n)
if (pipe.frame(raw, false, out)) {
if (pipe.frameRemote(pkt.pixels, pkt.ffcPhase, pkt.shutter, out)) {
latestFrame = out.copyOf()
}
}
@@ -117,7 +147,7 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
launch {
s.closedFlow.collect {
_state.value = _state.value.copy(connected = false, status = "disconnected")
_disconnected.tryEmit(Unit)
_disconnected.tryEmit("连接已断开")
}
}
s.hello("mag160c-client")
@@ -132,7 +162,10 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
private fun refreshTemps() {
val pipe = pipeline ?: return
// pipeline.probeTemp already returns millidegrees C (counts -> temp)
// gate on a completed render: before the first one the buffers hold
// zeros and countsToTempMc(0) reads about -161 C
if (!pipe.tempsReady()) return
// pipeline.probeTemp already returns millidegrees C
val centerMc = pipe.probeTemp(80, 60)
val nuc = IntArray(19200)
pipe.copyNuc(nuc)
@@ -33,6 +33,22 @@ class AppSettings(context: Context) {
com.mag160c.thermal.cloud.CloudClient.setEnabled(v)
}
/**
* Manual image orientation corrections, mirroring the official app's
* "旋转USB画面" / "水平翻转" / "竖直翻转" settings.
*/
var imageRotateDeg: Int
get() = sp.getInt("imageRotate", 0)
set(v) = sp.edit().putInt("imageRotate", ((v % 360) + 360) % 360).apply()
var imageFlipH: Boolean
get() = sp.getBoolean("imageFlipH", false)
set(v) = sp.edit().putBoolean("imageFlipH", v).apply()
var imageFlipV: Boolean
get() = sp.getBoolean("imageFlipV", false)
set(v) = sp.edit().putBoolean("imageFlipV", v).apply()
init {
com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled)
}
@@ -34,6 +34,10 @@ fun SettingsScreen(
var dialog by remember { mutableStateOf<String?>(null) }
// mirror the persisted flag in Compose state so the row label updates
var cloudEnabled by remember { mutableStateOf(settings.cloudEnabled) }
// orientation corrections: local Compose state + persistence
var rotateDeg by remember { mutableStateOf(settings.imageRotateDeg) }
var flipH by remember { mutableStateOf(settings.imageFlipH) }
var flipV by remember { mutableStateOf(settings.imageFlipV) }
// remote-preview server toggle (Phase F); off by default, needs live USB
var remoteOn by remember { mutableStateOf(remoteHostRunning) }
var showNeedDevice by remember { mutableStateOf(false) }
@@ -46,6 +50,16 @@ fun SettingsScreen(
) { dialog = "emissivity" }
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
SettingRow("语言", settings.language) { dialog = "language" }
// manual orientation corrections (the official app has the same three)
SettingRow("旋转USB画面", "$rotateDeg°") { dialog = "rotate" }
SettingRow("水平翻转", if (flipH) "已开启" else "已关闭") {
flipH = !flipH
settings.imageFlipH = flipH
}
SettingRow("竖直翻转", if (flipV) "已开启" else "已关闭") {
flipV = !flipV
settings.imageFlipV = flipV
}
SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" }
SettingRow("远程预览服务端", if (remoteOn) "已开启" else "已关闭") {
if (remoteOn) {
@@ -114,8 +128,30 @@ fun SettingsScreen(
initialC = settings.alarmTempC,
onDone = { settings.alarmTempC = it; dialog = null },
)
"cloud" -> AlertDialog(
"rotate" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("旋转USB画面") },
text = {
Column {
listOf(0, 90, 180, 270).forEach { deg ->
Text(
"$deg°",
color = if (deg == rotateDeg) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
rotateDeg = deg
settings.imageRotateDeg = deg
dialog = null
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
"cloud" -> AlertDialog( onDismissRequest = { dialog = null },
title = { Text("云同步") },
text = {
Text(
@@ -539,8 +539,11 @@ class IrSession(context: Context) {
)
}
lastRawFrame = frameBuf.copyOf()
lastRawFrame?.let { raw -> rawHook?.invoke(raw) }
val rendered = pipe.frame(frameBuf, true, out)
// After frame(): the host-side pipeline metadata (FFC phase and
// camera temperature) refers to THIS frame, which is what a
// remote client needs to reproduce the image.
lastRawFrame?.let { raw -> rawHook?.invoke(raw) }
if (rendered) {
renderCount++
listener?.onFrameReady(out)
@@ -589,6 +592,15 @@ class IrSession(context: Context) {
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
/** True once a frame completed the render path (temperatures are meaningful). */
fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
/** FFC phase of the frame just processed: 0 normal, 1 hidden, 2 reference. */
fun ffcPhase(): Int = pipeline?.ffcPhase() ?: 0
/** Camera temperature of the frame just processed (raw sensor units). */
fun lastShutter(): Int = pipeline?.lastShutter() ?: 0
/** Snapshot of the current NUC counts (already blind-compensated). */
fun copyNuc(out: IntArray): Boolean {
val p = pipeline ?: return false
@@ -0,0 +1,120 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Regressions for the on-device temperature defects (2026-09-11):
*
* 1. the OSD showed ~150 C for a moment during every FFC, because the FFC
* reference frames were decoded into the same `nuc` buffer the OSD samples;
* 2. before the first completed render `nuc` is all zeros and
* countsToTempMc(0) is about -161 C, which the UI displayed;
* 3. the centre readout was double-converted (countsToTempMc applied to an
* already-converted value), showing e.g. 108.7 C for a ~24 C scene.
*/
class PipelineTemperatureStateTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
private fun frame(buf: ByteArray, counter: Int, filler: Int): ByteArray {
// 0x38 header + 38400 payload + 0x38 tail, as the camera sends it
val f = ByteArray(0x38 + 38400)
MagProtocolBytes.putU32(f, 0, 0x1BB1B11B)
MagProtocolBytes.putU32(f, 4, counter)
MagProtocolBytes.putU32(f, 8, 38400)
MagProtocolBytes.putU32(f, 12, 0)
for (i in 0 until 38400) f[0x1C + i] = ((filler + i) and 0xFF).toByte()
MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 4, counter)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, 7000) // fpaTemp / shutter
MagProtocolBytes.putU32(f, 0x1C + 38400 + 12, 0)
return f
}
private fun pipeline(): RenderPipeline = RenderPipeline(
w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2,
force75 = false, onFfc = {},
).also { assertTrue("DDT must load", it.loadDdt(res("mag160c_official.ddt"))) }
@Test
fun temperaturesAreNotReadyBeforeTheFirstCompletedRender() {
val p = pipeline()
assertFalse("fresh pipeline must not report ready temps", p.tempsReady())
val out = IntArray(320 * 240)
// warm-up frames return false (nothing rendered yet)
assertFalse(p.frame(frame(ByteArray(0x38 + 38400), 0, 10), true, out))
assertFalse("still warming", p.tempsReady())
}
@Test
fun ffcReferenceWindowDoesNotLeakRawCountsIntoNuc() {
val p = pipeline()
val out = IntArray(320 * 240)
// drive the pipeline until it has rendered at least once
var counter = 0
var rendered = 0
while (rendered == 0 && counter < 200) {
if (p.frame(frame(ByteArray(0x38 + 38400), counter, counter), true, out)) rendered++
counter++
}
assertEquals("a frame must render", 1, rendered)
assertTrue("temps ready after a render", p.tempsReady())
val good = IntArray(19200)
p.copyNuc(good)
val goodMax = good.max()
// trigger a manual FFC and walk through the whole cycle, sampling `nuc`
// the way the OSD timer does
p.requestFfc()
var sawInflated = false
for (i in 0 until 40) {
// deliberately feed a very different raw frame during the window: if
// it were decoded into `nuc`, the sampled maximum would explode
p.frame(frame(ByteArray(0x38 + 38400), counter, 250), true, out)
counter++
val sample = IntArray(19200)
p.copyNuc(sample)
val mx = sample.max()
// raw counts are stored as sent (up to 250+... here) and NUC output is
// blind-compensated; anything near the full 16-bit range means raw
// data leaked into the OSD buffer
if (mx > 60000) sawInflated = true
}
assertFalse(
"raw counts must never appear in the OSD buffer during an FFC " +
"(max seen would be ~65535 -> about 150 C)",
sawInflated,
)
}
@Test
fun zeroCountsConvertToTheDocumentedFloorNotAPlausibleTemperature() {
// This is WHY the UI has to gate on tempsReady(): zero counts are a
// legal-looking number that converts to -161 C, not an error.
val t = TempMath.countsToTempMc(0)
assertEquals(-160995, t)
assertEquals(-161.0f, t / 1000f, 0.05f)
// and the double-conversion bug: converting an already-converted value
val correct = TempMath.countsToTempMc(7100)
assertTrue("a ~24 C scene", correct in 0..60_000)
val doubleConverted = TempMath.countsToTempMc(correct)
assertTrue(
"double conversion produces a wildly wrong number ($doubleConverted mC)",
doubleConverted < -50_000,
)
}
}
/** Minimal little-endian writer for the test frame header. */
internal object MagProtocolBytes {
fun putU32(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()
}
}
@@ -2,6 +2,7 @@ package com.mag160c.thermal.net
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -9,8 +10,9 @@ import org.junit.Test
import java.util.Random
/**
* Phase F wire contract: encode/feed round trip plus the two cases a raw socket
* read always produces — truncated records and several records glued together.
* Phase F wire contract: encode/feed round trip plus the cases a raw socket read
* always produces — truncated records and several records glued together — and
* the frame metadata the client needs to reproduce the host's image.
*/
class RemoteContractTest {
private fun payload(seed: Int): ByteArray {
@@ -22,22 +24,44 @@ class RemoteContractTest {
@Test
fun encodeFramePacketLayoutMatchesTheContract() {
val raw = payload(1)
val pkt = RemoteContract.encodeFramePacket(raw, counter = 7)
assertEquals(38412, pkt.size)
val pkt = RemoteContract.encodeFramePacket(
raw, counter = 7, flags = RemoteContract.FLAG_RENDERS,
ffcPhase = RemoteContract.PHASE_REFERENCE, shutter = 1234,
)
assertEquals(38424, pkt.size)
assertEquals(RemoteContract.FRAME_MAGIC, RemoteContract.u32(pkt, 0))
assertEquals(7, RemoteContract.u32(pkt, 4))
assertEquals(RemoteContract.FRAME_PIXELS, RemoteContract.u32(pkt, 8))
assertArrayEquals(raw, pkt.copyOfRange(12, pkt.size))
assertEquals(RemoteContract.FLAG_RENDERS, RemoteContract.u32(pkt, 12))
assertEquals(RemoteContract.PHASE_REFERENCE, RemoteContract.u32(pkt, 16))
assertEquals(1234, RemoteContract.u32(pkt, 20))
assertArrayEquals(raw, pkt.copyOfRange(RemoteContract.FRAME_HEADER, pkt.size))
}
@Test
fun singleFrameRoundTrip() {
fun singleFrameRoundTripPreservesMetadata() {
val raw = payload(2)
val reader = RemoteContract.FramePacketReader()
val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 1))
val pkt = RemoteContract.encodeFramePacket(raw, 1, RemoteContract.FLAG_RENDERS, 2, 4242)
val frames = RemoteContract.FramePacketReader().feed(pkt)
assertEquals(1, frames.size)
assertArrayEquals(raw, frames[0])
assertEquals(0, reader.pending())
val f = frames[0]
assertEquals(1, f.counter)
assertEquals(2, f.ffcPhase)
assertEquals(4242, f.shutter)
assertTrue(f.renders)
assertArrayEquals(raw, f.pixels)
}
@Test
fun nonRenderingFrameIsFlagged() {
// the host marks frames it could not render (FFC/hidden) so the client
// does not mistake them for image data
val pkt = RemoteContract.encodeFramePacket(
payload(21), 5, flags = 0, ffcPhase = RemoteContract.PHASE_HIDDEN, shutter = 90,
)
val f = RemoteContract.FramePacketReader().feed(pkt)[0]
assertFalse("flags=0 means not rendered", f.renders)
assertEquals(RemoteContract.PHASE_HIDDEN, f.ffcPhase)
}
@Test
@@ -50,7 +74,8 @@ class RemoteContractTest {
assertTrue("still incomplete", reader.feed(pkt.copyOfRange(7, 1000)).isEmpty())
val frames = reader.feed(pkt.copyOfRange(1000, pkt.size))
assertEquals(1, frames.size)
assertArrayEquals(raw, frames[0])
assertArrayEquals(raw, frames[0].pixels)
assertEquals(0, reader.pending())
}
@Test
@@ -58,23 +83,24 @@ class RemoteContractTest {
val raws = listOf(payload(4), payload(5), payload(6))
val glued = java.io.ByteArrayOutputStream()
raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
val reader = RemoteContract.FramePacketReader()
val frames = reader.feed(glued.toByteArray())
val frames = RemoteContract.FramePacketReader().feed(glued.toByteArray())
assertEquals(3, frames.size)
frames.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) }
frames.forEachIndexed { i, f ->
assertEquals(i, f.counter)
assertArrayEquals(raws[i], f.pixels)
}
}
@Test
fun gluedWithRaggedBoundaries() {
// 3 frames, fed in chunks that straddle record boundaries unevenly
val raws = List(3) { payload(10 + it) }
val glued = java.io.ByteArrayOutputStream()
raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
val bytes = glued.toByteArray()
val reader = RemoteContract.FramePacketReader()
val got = ArrayList<ByteArray>()
val got = ArrayList<RemoteContract.FramePacket>()
var pos = 0
val chunkPattern = intArrayOf(5, 38411, 1, 20000, 100, 40000)
val chunkPattern = intArrayOf(5, 38423, 1, 20000, 100, 40000)
var ci = 0
while (pos < bytes.size) {
val n = minOf(chunkPattern[ci % chunkPattern.size], bytes.size - pos)
@@ -83,17 +109,16 @@ class RemoteContractTest {
ci++
}
assertEquals(3, got.size)
got.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) }
got.forEachIndexed { i, f -> assertArrayEquals(raws[i], f.pixels) }
}
@Test
fun garbageBeforeMagicResynchronises() {
val raw = payload(20)
val pkt = RemoteContract.encodeFramePacket(raw, 3)
val reader = RemoteContract.FramePacketReader()
val frames = reader.feed(byteArrayOf(1, 2, 3, 4, 5) + pkt)
val frames = RemoteContract.FramePacketReader().feed(byteArrayOf(1, 2, 3, 4, 5) + pkt)
assertEquals(1, frames.size)
assertArrayEquals(raw, frames[0])
assertArrayEquals(raw, frames[0].pixels)
}
@Test
@@ -102,10 +127,9 @@ class RemoteContractTest {
val pkt = RemoteContract.encodeFramePacket(raw, 4)
val bad = RemoteContract.encodeFramePacket(payload(22), 5).copyOf()
RemoteContract.putU32(bad, 8, 12345) // wrong payload length
val reader = RemoteContract.FramePacketReader()
val frames = reader.feed(bad + pkt)
val frames = RemoteContract.FramePacketReader().feed(bad + pkt)
assertEquals("only the valid frame is delivered", 1, frames.size)
assertArrayEquals(raw, frames[0])
assertArrayEquals(raw, frames[0].pixels)
}
@Test
@@ -135,12 +159,20 @@ class RemoteContractTest {
assertEquals("ffc", RemoteContract.commandOf(RemoteContract.cmd("ffc")))
assertEquals("welcome", RemoteContract.typeOf(RemoteContract.welcomeLine(160, 120, 15, 1)))
assertTrue(RemoteContract.isKeepalive(RemoteContract.pingLine()))
assertTrue(!RemoteContract.isKeepalive(RemoteContract.okLine()))
assertEquals("type", RemoteContract.typeOf(RemoteContract.streamStartLine()).let { "type" })
assertFalse(RemoteContract.isKeepalive(RemoteContract.okLine()))
assertEquals("stream-start", RemoteContract.typeOf(RemoteContract.streamStartLine()))
assertEquals("stream-stop", RemoteContract.typeOf(RemoteContract.streamStopLine()))
}
@Test
fun busyLineIsRecognised() {
// the host rejects a second client with this line; the client must
// surface it instead of silently waiting forever
assertTrue(RemoteContract.isBusyLine(RemoteContract.busyLine()))
assertFalse(RemoteContract.isBusyLine(RemoteContract.okLine()))
assertFalse(RemoteContract.isBusyLine(RemoteContract.pingLine()))
}
@Test
fun welcomeParsing() {
val w = RemoteContract.parseWelcome(RemoteContract.welcomeLine(160, 120, 15, 160043865L))
@@ -163,6 +195,6 @@ class RemoteContractTest {
val raw = payload(31)
val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 2))
assertEquals(1, frames.size)
assertArrayEquals(raw, frames[0])
assertArrayEquals(raw, frames[0].pixels)
}
}
@@ -50,7 +50,7 @@ class RemoteLoopbackTest {
assertEquals(160, parsed!!.w)
// start -> stream-start, then frames
val received = ArrayList<ByteArray>()
val received = ArrayList<RemoteContract.FramePacket>()
val collector = scope.launch {
session.frames.collect { received.add(it) }
}
@@ -80,7 +80,7 @@ class RemoteLoopbackTest {
sent.forEachIndexed { i, expect ->
assertTrue(
"frame $i must round-trip byte-exactly",
expect.contentEquals(received[i]),
expect.contentEquals(received[i].pixels),
)
}
@@ -131,4 +131,88 @@ class RemoteLoopbackTest {
assertEquals(RemoteContract.CONTROL_PORT, info.tcpPort)
assertEquals(160043865L, info.serial)
}
/**
* Regression for the command-ordering defect: commands must reach the host in
* call order. The first implementation launched one writer coroutine per
* command, so `hello` immediately followed by `start` could arrive swapped and
* the client lost the `welcome` line (~6.7% of runs).
*/
@Test
fun commandOrderIsPreservedUnderRapidSends() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val host = RemoteHost("jvm-order", 7L)
try {
host.start()
delay(300)
val session = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
assertNotNull(session)
session!!
val seen = java.util.Collections.synchronizedList(ArrayList<String>())
val collector = scope.launch { session.lines.collect { seen.add(RemoteContract.typeOf(it)) } }
delay(100)
// a) hello immediately followed by start must yield welcome first
session.hello("order-test")
session.startStream()
withTimeout(5000) {
while (!seen.contains("welcome") || !seen.contains("stream-start")) delay(20)
}
val iWelcome = seen.indexOf("welcome")
val iStart = seen.indexOf("stream-start")
assertTrue("welcome ($iWelcome) must precede stream-start ($iStart)", iWelcome < iStart)
// b) many rapid commands must all reach the host, in order, and none
// may garble the frame stream. Replies are intentionally not sent
// while streaming (a JSON line between frame records would be read
// as stray bytes), so the host callback is the observable.
val ffcSeen = java.util.concurrent.atomic.AtomicInteger(0)
host.onFfcRequest = { ffcSeen.incrementAndGet() }
repeat(200) { session.requestFfc() }
withTimeout(8000) {
while (ffcSeen.get() < 200) delay(20)
}
assertEquals("all 200 ffc requests reached the host", 200, ffcSeen.get())
collector.cancel()
session.close()
} finally {
host.stop()
scope.cancel()
}
}
/**
* A second client must be told the host is busy instead of being left to
* hang in the kernel backlog (plan F2).
*/
@Test
fun secondClientIsRejectedWithBusy(): Unit = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val host = RemoteHost("jvm-busy", 8L)
try {
host.start()
delay(300)
val first = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
assertNotNull("first client connects", first)
// start streaming so the host is definitely occupied
first!!.startStream()
withTimeout(5000) { first.lines.first { RemoteContract.typeOf(it) == "stream-start" } }
val second = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
assertNotNull("second client still connects at TCP level", second)
val busy = withTimeout(5000) {
second!!.lines.first { RemoteContract.isBusyLine(it) }
}
assertTrue(RemoteContract.isBusyLine(busy))
// the first client must be unaffected
assertEquals(RemoteHost.State.STREAMING, host.state)
second?.close()
first?.close()
} finally {
host.stop()
scope.cancel()
}
}
}
@@ -0,0 +1,90 @@
package com.mag160c.thermal.ui.live
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The orientation rule must reproduce the official app's on-screen result.
*
* Official mapping (MainActivity windowOrientationListener -> DeviceController.
* setPreviewOrientation, applied as matrix.postRotate in ImageViewer.drawImage):
* Display.rotation 0 -> image 90
* Display.rotation 1 -> image 0
* Display.rotation 2 -> image 270
* Display.rotation 3 -> image 180
* and the official WINDOW auto-rotates, so the visible result is
* image_rot - display_rotation*90 (mod 360) = 90 in all four cases.
*
* The display-rotation index -> degrees convention is taken from the official
* app's own camera code (VisibleCameraHelper.setPreviewOrientation):
* case 0 -> 0, case 1 -> 90, case 2 -> 180, case 3 -> 270
* i.e. index N means an N*90 CLOCKWISE device rotation, the same sign our grip
* angle uses. That makes the locked-window compensation rot = 90 - grip.
*
* Our Activity is portrait-LOCKED, so the window never rotates and the image
* must supply the whole difference: rotating the IMAGE by (90 - grip) gives the
* same constant 90 relative to the user.
*/
class ImageTransformOrientationTest {
/** The official app's image rotation for a given display rotation. */
private fun officialImageRot(displayRotation: Int): Int = when (displayRotation) {
0 -> 90
1 -> 0
2 -> 270
else -> 180
}
@Test
fun officialMappingIsReproducedByTheGripRule() {
// Display.rotation index N corresponds to a CLOCKWISE device rotation of
// N*90 (the standard camera2 convention, where deviceOrientation is
// displayRotation*90). Our grip angle uses the same sign, so grip = N*90.
for (dr in 0..3) {
val grip = dr * 90
val official = officialImageRot(dr)
val ours = ImageTransform.params(grip).rotDeg
assertEquals(
"displayRotation=$dr (grip $grip) must match the official image rotation",
official, ours,
)
}
}
@Test
fun visibleOrientationIsGripIndependent() {
// On the official app the window rotates with the grip, so the visible
// image rotation is constant: image_rot - gripWindowContribution.
// For our locked window the visible rotation IS the image rotation
// measured against the world, and the rule keeps it at 90 for every grip.
for (grip in intArrayOf(0, 90, 180, 270)) {
val rot = ImageTransform.params(grip).rotDeg
val visible = ((rot + grip) % 360 + 360) % 360
assertEquals("grip=$grip keeps the world-aligned result", 90, visible)
}
}
@Test
fun turningThePhoneTurnsTheImageTheOppositeWay() {
// The reported defect: turning the phone right made the picture go the
// other way. The compensation must be OPPOSITE in sign to the grip.
val upright = ImageTransform.params(0).rotDeg // 90
val turnedRight = ImageTransform.params(90).rotDeg // 0
val turnedLeft = ImageTransform.params(270).rotDeg // 180
assertEquals(upright, (turnedRight + 90) % 360)
assertEquals(upright, (turnedLeft + 270) % 360)
assertTrue("rot must decrease as the grip increases", turnedRight < upright)
}
@Test
fun landscapeUsesTheWideFootprint() {
// holds for a landscape grip: the image is drawn 4:3 (not 3:4), which is
// the "must be rotated 180 in landscape" complaint
val landscape = ImageTransform.params(90).rotDeg
assertEquals(0, landscape)
assertEquals(4f / 3f, ImageTransform.screenAspect(landscape), 1e-4f)
val portrait = ImageTransform.params(0).rotDeg
assertEquals(3f / 4f, ImageTransform.screenAspect(portrait), 1e-4f)
}
}
@@ -0,0 +1,138 @@
package com.mag160c.thermal.ui.live
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Geometry shared by the renderer and the tap handler. These tests exist because
* a mismatch between "what is drawn" and "where a tap maps" is invisible in unit
* tests but immediately wrong on a device — the sensor<->screen pair must be
* exact inverses for every grip angle and flip combination.
*/
class ImageTransformTest {
private val fit = ImageTransform.fit(
availLeft = 0f, availTop = 100f, availW = 1080f, availH = 1900f, rotDeg = 90,
)
@Test
fun rotationFollowsTheGripCompensationRule() {
// rot = 90 - grip: upright portrait draws the sensor 90 deg CW (sideways
// 3:4 image); turning the phone a quarter turn draws it upright 4:3
assertEquals(90, ImageTransform.params(0).rotDeg)
assertEquals(0, ImageTransform.params(90).rotDeg)
assertEquals(270, ImageTransform.params(180).rotDeg)
assertEquals(180, ImageTransform.params(270).rotDeg)
// the manual correction adds on top and stays normalised
assertEquals(180, ImageTransform.params(0, userRotateDeg = 90).rotDeg)
assertEquals(0, ImageTransform.params(0, userRotateDeg = 270).rotDeg)
}
@Test
fun fitUsesTheCorrectAspectForEachRotation() {
// 90/270 -> 3:4 portrait footprint; 0/180 -> 4:3 landscape footprint
assertTrue(ImageTransform.swapped(90))
assertTrue(ImageTransform.swapped(270))
assertFalse(ImageTransform.swapped(0))
assertFalse(ImageTransform.swapped(180))
// 1080x1900 area: the 3:4 footprint is 1080x1440, width-limited
val portrait = ImageTransform.fit(0f, 0f, 1080f, 1900f, 90)
assertEquals(1080f, portrait.width, 1f)
assertEquals(1440f, portrait.height, 1f)
// a short wide area makes the same rotation height-limited
val shortArea = ImageTransform.fit(0f, 0f, 1080f, 800f, 90)
assertEquals(800f, shortArea.height, 1f)
assertEquals(600f, shortArea.width, 1f)
// unrotated the footprint is 4:3 landscape
val wide = ImageTransform.fit(0f, 0f, 1080f, 400f, 0)
assertEquals(400f * 4f / 3f, wide.width, 1f) // height-limited
assertEquals(400f, wide.height, 1f)
}
@Test
fun sensorToScreenAndBackAreInversesForEveryGrip() {
val crop = ImageTransform.cropForZoom(1)
for (grip in intArrayOf(0, 90, 180, 270)) {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val p = ImageTransform.params(grip, 0, flipH, flipV)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
for (sx in intArrayOf(0, 37, 80, 159)) {
for (sy in intArrayOf(0, 22, 60, 119)) {
val scr = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), p, f, crop)
val back = ImageTransform.screenToSensor(scr[0], scr[1], p, f, crop)
assertNotNull(
"grip=$grip flipH=$flipH flipV=$flipV pixel=($sx,$sy)",
back,
)
assertEquals(
"grip=$grip flipH=$flipH flipV=$flipV sx",
sx, back!!.first,
)
assertEquals(
"grip=$grip flipH=$flipH flipV=$flipV sy",
sy, back.second,
)
}
}
}
}
}
}
@Test
fun screenPointsOutsideTheImageMapToNull() {
val p = ImageTransform.params(0)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
val crop = ImageTransform.cropForZoom(1)
// above the image and below it
assertNull(ImageTransform.screenToSensor(f.cx, 10f, p, f, crop))
assertNull(ImageTransform.screenToSensor(f.cx, 2000f, p, f, crop))
// far left of the image
assertNull(ImageTransform.screenToSensor(-500f, f.cy, p, f, crop))
}
@Test
fun zoomConfinesTheMappingToTheCroppedArea() {
val p = ImageTransform.params(0)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
val crop = ImageTransform.cropForZoom(2)
// the crop insets by 1/4 on each side for 2x zoom
assertEquals(0.25f, crop[0], 1e-4f)
assertEquals(0.75f, crop[2], 1e-4f)
// the centre still maps to the centre
val centre = ImageTransform.screenToSensor(f.cx, f.cy, p, f, crop)
assertNotNull(centre)
// the corner of the drawn rect maps to the CROP edge — that is what
// "zoomed in" means. Which sensor pixel depends on the rotation, so just
// require it to be inside the cropped quarter rather than at the border.
val corner = ImageTransform.screenToSensor(f.left + 1f, f.top + 1f, p, f, crop)
assertNotNull(corner)
val c = corner!!
assertTrue("corner lands inside the crop: ($c)", c.first in 40..119 || c.second in 30..89)
// and a point outside the drawn rect is rejected
assertNull(ImageTransform.screenToSensor(f.left - 50f, f.cy, p, f, crop))
}
@Test
fun rotatedTextBoxExtentsFollowTheRotation() {
// unrotated: half extents are half the box
val flat = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 0f)
assertEquals(50f, flat[0], 1e-3f)
assertEquals(10f, flat[1], 1e-3f)
// rotated 90 deg: the extents swap
val turned = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 90f)
assertEquals(10f, turned[0], 1e-3f)
assertEquals(50f, turned[1], 1e-3f)
// an unrotated and a 180-rotated box occupy the same footprint
val flip = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 180f)
assertEquals(50f, flip[0], 1e-3f)
assertEquals(10f, flip[1], 1e-3f)
}
}
Binary file not shown.
+42 -15
View File
@@ -66,8 +66,9 @@ android/app/src/main/kotlin/com/mag160c/thermal/
│ ├─ LiveScreen.kt 稳定单分支相机布局:顶栏5控制项(FFC/变倍/追踪/调色板/画中画)
│ │ +底部快门区(相册/拍照/录像)+SurfaceView+PIP浮层
│ ├─ PipCameraView.kt 可见光PIP相机引擎(Camera2最小实现,异常只记日志)
LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)钉在竖屏框架固定区域,
文字恒屏幕水平;色标条/圆圈标记/中心温OSD
ImageTransform.kt 方向/翻转/letterbox/缩放几何 + 传感器↔屏幕互逆映射(纯 Kotlin,单测覆盖)
└─ LiveRenderer.kt SurfaceView 软件渲染:图像按握持角补偿旋转(rot=90-φ)、
│ letterbox+变倍、色标条/圆环标记/中心温OSD(标签按旋转包围盒定位)
├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限)
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/温度条+点击测温/备注回写/PDF报告)
├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/云同步/远程预览两行/关于)
@@ -129,25 +130,39 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
- 旧 analysis/protocol_spec.md 的 66f/670 描述("prepare/version query")不准,
以 magcx_official_flow.md 为准。
### 4.3 实时画面渲染(最终约定:构图钉死竖屏框架
### 4.3 实时画面渲染(2026-09-11 修订:图像内容随握持朝向补偿
- **Activity 锁定竖屏**manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
几何仍钉死在图像上)。对话框与其他页签暂不补偿
中间、物理底边;手机怎么物理旋转,构图都不动。**不要改回 fullSensor。**
- **图像内容补偿旋转(本轮修订,勿回退)**:构图不动,但**画面内容**必须随握持朝向
反向补偿,否则转身后场景跟着转、与镜头实际指向脱节。规则:
`rot = 90 - φ`(φ = DeviceOrientation 握持角 0/90/180/270)。
依据官方实现:`MainActivity.windowOrientationListener` 把 Display.rotation
(0/1/2/3) 映射成图像 90/0/270/180,再经 `ImageViewer.drawImage` 的
`matrix.postRotate` 应用;官方**窗口会随传感器旋转**,故可见结果恒为 90
我们锁了窗口,图像就要承担全部差值 → `90 - φ`。
显示旋转索引→角度约定取自官方自家相机代码
`VisibleCameraHelper.setPreviewOrientation`case 0→0/1→90/2→180/3→270
即索引 N = 顺时针 N×90)。
- **手工修正项**(官方"旋转USB画面/水平翻转/竖直翻转"同款):设置页三项,
叠加在自动补偿之上,持久化于 `AppSettings.imageRotateDeg/imageFlipH/imageFlipV`
用于传感器安装方向特殊的机器。翻转在像素拷贝阶段完成,保证几何映射可测。
- **图标/文字按握持角补偿**`graphicsLayer rotationZ=-φ`Compose/
`canvas.rotate(-φ)`(Canvas)。**标签一律按旋转后包围盒定位**(
`ImageTransform.rotatedBoxHalfExtents` + `drawGripText`),不要再拿基线锚点
摆位——色标条两端数字曾因此与色条错位。
- **几何单一来源**`ui/live/ImageTransform.kt`(纯 Kotlin,可单测)同时供
渲染器与点击/探针映射使用(`sensorToScreen` / `screenToSensor` 互为逆映射,
已对 0/90/180/270 × 翻转组合做往返测试)。渲染与取温映射**必须**用同一套参数,
否则标记会落到错误像素上。
- **加速度计符号约定(第七轮教训,勿改回)**:真机 TYPE_ACCELEROMETER 静止读数
指向世界上方(竖屏正持 y=+9.81);模拟器虚拟传感器是反的重力约定(y=-9.81)。
DeviceOrientation 映射按**真机约定**写,模拟器测试须用反号值驱动
(φ=0→`adb emu sensor set acceleration 0:9.81:0`)。
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
`fx=1-sy/120, fy=sx/160`
- 顶栏=4 个相机控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称),恒在顶部并带小字
标签,`safeDrawing` 顶部inset 适配挖孔屏。
- **追踪开关控制最高+最低两个标记**(此前只关最高,最低永远画着)。
- 顶栏=5 个控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称 / 画中画),恒在顶部并带
小字标签,`safeDrawing` 顶部 inset 适配挖孔屏
- 底部(仅实时页)导航栏上方为**相机快门区**:相册快捷入口 / 大快门拍照 /
录像-停止;快门区高度并入 `vm.uiBottomPx`= 导航高+快门区高)。
- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`**必须挂在 safeDrawing inset 之前**
@@ -158,6 +173,18 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
`connect()` 置 `status="no_device"`LiveScreen 显示占位文案"未检测到热像仪,
请插入MAG160C",渲染器无帧黑底;startDemo/合成帧代码已删除。
### 4.3.1 温度显示(2026-09-11 修复三个真机缺陷)
- **中心温度**`probeTemp()` 返回的**已经是毫度**,界面只许除以 1000 一次。
旧代码又调了一次 `countsToTempMc()`,真机显示 108.7℃(实际约 24℃)。
- **FFC 期间的温度跳变**:FFC 参考帧过去被解码进 `nuc`OSD 采样的同一缓冲区),
未补偿的原始 counts 直接参与显示 → 最高/最低短暂跳到 ~150℃。
现在参考帧走独立 `refScratch` 缓冲。
- **上电/未就绪**:首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0) = -161.0℃`
(看着像合法读数)。`RenderPipeline.tempsReady()` / `IrSession.tempsReady()`
门控 UI 取温;远程页同样门控。
- 回归测试:`core/PipelineTemperatureStateTest.kt`。
### 4.4 温度
- 温度 = 毫度 int(÷1000 = ℃)。`counts_to_temp_mc`(NUC域→T2E逆映射)用于探针/OSD,
在真机上需按 DDT 标定核对绝对值(待办)。
+14 -3
View File
@@ -324,12 +324,23 @@ class PipCameraEngine(val context, val textureView) :
`{"type":"welcome","w":160,"h":120,"fps":15,"serial":...}`
- `{"cmd":"start"}` → `{"type":"stream-start"}` 后开始二进制帧
- `{"cmd":"stop"}` → `{"type":"stream-stop"}`
- `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`
- `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`**仅未出流时回复**;出流后
客户端处于帧模式,主机只执行不回复,见下)
- **图像帧**stream-start 之后,TCP 二进制流):
`[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400][38400B 原始 u16LE 像素]`
共 38412B/帧。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
`[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400]`
`[u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400B 原始 u16LE 像素]`
头部 24B,共 **38424B/帧**。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
(调色板/变倍全在客户端本地,无需回传)。
- `flags` bit0 = 主机本帧是否渲染成功;`ffcPhase` = 主机管线 FFC 阶段
(0 正常 / 1 快门关闭 / 2 参考帧采集);`shutter` = 本帧相机温度(原始单位)。
- **为什么必须带这几项**2026-09-11 修订):NUC 表按快门温度插值,缺了它
counts 会饱和(真机表现为满屏噪声 + 读数 -161℃);参考帧还必须在客户端
按同一规则平均。早期版本只传像素、客户端独立跑 FFC 状态机,画面不正确。
- 客户端相应入口:`RenderPipeline.frameRemote(frame, phase, shutter, out)`。
- **保活**:主机 3s 无帧发 `{"type":"ping"}`;客户端 10s 无任何数据判死重连。
- **单客户端**:已有客户端时,第二连接立即收到 `{"type":"busy"}` 并被关闭。
- **流中不写控制回复**stream-start 之后客户端处于帧模式,此时主机不得再写
JSON 行(会被当作帧内杂散字节);`ffc` 在流中执行但不回复。
### F2. 文件与职责
1. `net/RemoteContract.kt`:常量(端口/魔数)、JSON data class、
+16 -1
View File
@@ -39,7 +39,9 @@
| 17 | A:设置页 → "远程预览服务端"(点一下) | `[remote] host started (name=<机型> serial=0)` | 该行文案变为"已开启" |
| 18 | A:若第 17 步弹"先连接热像仪" | (无日志) | 说明 USB 会话不活跃:先回到实时页确认出流 |
| 19 | B:设置页 → "远程预览客户端" → "查找主机" | (B 端)列表出现 A 的主机名 | 10 秒内列出 A(卡片显示 `<主机名>``<IP>:47511`);未列出可用"手动添加"填 A 的 IP |
| 20 | B:点该卡片(或手动 IP 后点"连接" | A 端:`[remote] client connected from <B的IP>`B 端:`[remote] client connected to <A的IP>:47511``[remote] line: {"type":"welcome",...}``[remote] line: {"type":"stream-start"}``[remote] first remote frame` | B 显示 A 的热像实时画面(同一构图:竖屏 3:4、顶栏 4 项、右侧色标条 |
| 20 | B:点该卡片(或手动 IP 后点"连接" | A 端:`[remote] client connected from <B的IP>`B 端:`[remote] client connected to <A的IP>:47511``[remote] line: {"type":"welcome",...}``[remote] line: {"type":"stream-start"}``[remote] first remote frame` | B 显示 A 的热像实时画面,**温度读数与 A 端一致**(同一场景下中心温/最高最低相差应在 1℃ 内);画面朝向与 A 端相同(两机握持姿态不同时,各自按自己的姿态补偿 |
| 20b | B 端确认温度不再是 -161℃、画面不是满屏噪声 | B 端 `[remote] first remote frame` 之后读数正常 | 这是 2026-09-11 修复项:协议改为随帧传 FFC 阶段+相机温度(`[flags][ffcPhase][shutter]`,帧记录 38424B)。若仍异常,检查两端 APK 是否同版本(新旧协议不兼容) |
| 20c | 第三台设备(或 A 本机再连一次)尝试连接同一主机 | A 端:`[remote] rejecting <IP>: already serving a client` | 后到者立即收到 `{"type":"busy"}`,B 端弹"主机正忙(已有客户端连接)"并返回列表;**已在流的那台不受影响** |
| 21 | B:顶栏点变倍/追踪/调色板 | (无日志,全部本地) | 立即生效、无卡顿(调色板/变倍不下发到 A) |
| 22 | B:顶栏点 FFC | A 端出现一次快门校正;B 画面随之更新 | FFC 经 A 的热像仪执行 |
| 23 | B:点底部红色"断开"圆钮 | A 端:`[remote] client <IP> disconnected (frames=<N>)` | B 返回主机列表并弹出"已断开"提示;A 服务端保持"已开启"待重连 |
@@ -49,6 +51,19 @@
中途断网/主机退出时 B 显示"连接已断开";A 端相机与本地画面**始终不受网络影响**;
日志中 `frames=…` 每 300 帧记一次。
## 方向与温度(2026-09-11 修复项,重点验证)
| # | 操作 | 预期 | 通过标准 |
|---|------|------|----------|
| 25 | 竖屏正持,观察画面 | (无日志) | 画面正常;中心温读数与手摸/环境常识一致(**不是 -161℃、不是 108℃ 这类离谱值**) |
| 26 | 手持手机**顺时针转 90°**(横过来),观察画面内容 | (无日志) | **场景方向不变**(画面内容跟着手反向补偿),顶栏/底栏文字仍可读;不是整个场景跟着转 |
| 27 | 点"追踪"关闭 | (无日志) | **最高温和最低温两个标记同时消失**;再点开→两个都出现(此前最低温标记关不掉) |
| 28 | 点 FFC,紧盯最高/最低温读数 | `[cmd] FFC(0) write=8/8` | 读数**不出现 ~150℃ 的瞬时跳变**(可短暂保持不变,但不得跳到离谱值) |
| 29 | 设置页"旋转USB画面"依次选 0/90/180/270° | (无日志) | 画面按所选角度整体旋转,用于修正传感器安装方向 |
| 30 | 设置页"水平翻转"/"竖直翻转"开关 | (无日志) | 画面镜像;与官方 app 的同名设置表现一致 |
| 31 | 横屏持机时观察色标条 | (无日志) | 色标条**两端**的最高/最低温数字紧贴色条两端,**不与色条重叠**、不偏移 |
| 32 | 分析页打开一张 MDT,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 |
## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
+52
View File
@@ -451,6 +451,58 @@
(CAMERA 权限原本会让相机变必需,已显式声明为可选)。
**未 push**(按用户指令)。
## 用户反馈修复 第十八轮(2026-09-11,真机实测:温度/朝向/远程三类缺陷)
用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理:
- [x] **中心温度测量错误**`probeTemp()` 返回的**已是毫度**`updateTemps` 又调了
一次 `countsToTempMc()` → 真机显示 108.7℃(实际约 24℃)。
远程页同一 bug(显示 -161℃)。两处都改为只除 1000 一次,并在参数注释里
写明单位契约。
- [x] **FFC 时最高/最低温跳到 ~150℃**FFC 参考帧被解码进 `nuc`OSD 采样的同一
缓冲区),未补偿的原始 counts 被当成温度。改为独立 `refScratch` 缓冲;
另加 `tempsReady()` 门控(首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0)`
= **-161.0℃**,正是远程截图显示的读数)。
新增回归测试 `PipelineTemperatureStateTest`3 项)。
- [x] **追踪开关只关最高温**:最低温标记未受开关控制(`LiveRenderer.drawOsd`)。
现在开=最高+最低都画("高"/"低"),关=都不画。
- [x] **画面转向反了 / 横屏应翻 180°**:原先"图像恒 90°CW 不动"的约定在真机上
表现为转身后场景跟着转。改为**图像内容按握持角补偿**:`rot = 90 - φ`
依据官方实现(`MainActivity.windowOrientationListener` 把 Display.rotation
映射成图像 90/0/270/180,经 `ImageViewer.drawImage``matrix.postRotate`
应用;官方窗口随传感器转,可见结果恒 90);显示旋转索引→角度取官方自家
相机代码 `VisibleCameraHelper.setPreviewOrientation`N→N×90)。
构图(条栏/图像区绝对位置)仍钉死竖屏框架不变。
- [x] **新增官方同款方向设置**:设置页"旋转USB画面"0/90/180/270)、
"水平翻转"、"竖直翻转",叠加在自动补偿之上并持久化。
- [x] **色标条两端最高/最低温与色条错位**:标签原先按未旋转的基线锚点定位。
改为**按旋转后包围盒**定位(`ImageTransform.rotatedBoxHalfExtents` +
`drawGripText`),任意握持角都贴着色条两端。
- [x] **几何单一来源**:新增 `ui/live/ImageTransform.kt`(纯 Kotlin),渲染器与
点击/探针映射共用同一套参数与互逆映射;新增 `ImageTransformTest`6 项,
覆盖 0/90/180/270 × 两种翻转的往返一致性)+ `ImageTransformOrientationTest`
(4 项,锁官方映射与"转身时图像反向")。
- [x] **远程预览画面不正确**(用户截图:-161℃、满屏噪声):根因是协议只传像素,
客户端独立跑 FFC 状态机且**拿不到帧的相机温度**,而 NUC 表要按快门温度插值
→ counts 饱和、参考帧缺失。协议改为传元数据:
`[magic][counter][38400][flags][ffcPhase][shutter][像素]`(头 24B,记录 38424B),
客户端用 `RenderPipeline.frameRemote(frame, phase, shutter, out)` 复刻主机状态。
(用户建议"传原始数据本地渲染"正是此方向;此前的错误在于只传了数据的一半。)
- [x] **顺带修核查报告的两项证伪**`RemoteSession.send()` 改为**单写协程 + 队列**
(原先每条命令各起协程写同一 socket,`hello`/`start` 可能乱序,~6.7% 丢
`welcome`;新增 `commandOrderIsPreservedUnderRapidSends` 回归);
主机实现"后来者写 busy"accept 循环不再阻塞在 serve 上,第二客户端立即
收到 `{"type":"busy"}`;新增 `secondClientIsRejectedWithBusy`)。
另删除未使用的 `ACCESS_NETWORK_STATE` 权限。
- [x] 单测 44 → **61 项全绿**debug + release(R8) 双构建通过;APK 已更新
12.66MB)。`screenOrientation=portrait` 复核保持、camera 系列仍
not-required、多余权限已消失。
⚠️ **规格偏离(已告知用户)**:本轮推翻了第四/五轮"图像恒 90°CW 不动"的约定
(用户实测该约定导致转向错误)。构图绝对位置不变,仅图像**内容**随握持角补偿。
## 本轮(2026-09-10 执行计划 A→F)总结
七个阶段全部落地,每阶段一次 commit:
+454
View File
@@ -0,0 +1,454 @@
# Phase A→F 独立核查报告(可交给修复模型)
> **核查者立场**:本报告由独立核查 AI 撰写,不采信执行者(下称"执行者")的
> 总结/session_state/checklist 叙述,全部结论来自 ① git 提交内容
> ② 仓库内官方逆向产物 ③ 核查者亲自重跑的命令与亲自编写的独立工具
> ④ 明确标注"无法判定"的真机项。
>
> **核查范围**`06c1f30..8e1312a`7 个提交),基线 `087e15c`
> 核查时 HEAD = `0919f5e`(含核查指南提交)。
> **本轮核查未修改任何源码、未提交、未 push**;本报告文件是唯一新增物
> (未提交,`git status` 会显示为 untracked)。
>
> **总体结论**:第一关硬门槛(git 完整性 / 禁改清单 / debug+release 构建 /
> 44 项单测)**全部通过**。逐阶段核查发现 **2 项证伪**(Phase F 命令写入
> 未串行化、Phase F busy 未实现)、**1 项 Phase Z 越界**Manifest 改动)、
> **2 项低风险偏差**,其余声明证实;需真机/双设备的项一律"无法判定"。
> Phase C 最高风险项(索引 4/8/9 映射)经独立复核**证实**,且本次找到了比
> 执行者更强的决定性证据。
---
## 0. 修复任务清单(给修复模型)
按优先级排列。每条含:位置、现象、根因、建议改法、改完怎么验、不修的风险。
**修复时仍须遵守 `execution_plan.md` §0 的禁改清单**(竖屏锁定、命令字节序、
握手序列、LiveRenderer 构图、analysis/ 只读)。
### FIX-1(高)`RemoteSession.send()` 并发写同一 socket 导致命令乱序
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt:256-269`
- **现象(已复现)**`hello()` 紧接 `startStream()` 时,主机可能先收到 `start`
主机随即回 `stream-start`,客户端 reader 切到帧模式
`RemoteClient.kt:194`),后到的 `welcome` 文本被 `FramePacketReader` 当垃圾丢弃。
- **实测数据**:裸 socket 抓字节,5 次运行中 1 次观测到 `hello`/`start` 顺序颠倒;
面向真实 `RemoteHost` 的生产序列压测 **120 次中 8 次丢失 `welcome` 行(≈6.7%**
帧本身始终正常(30/30 各轮均收到)。
- **根因**`send()` 每条命令 `scope.launch(Dispatchers.IO)` 新协程写同一个
`socket.getOutputStream()`。多协程竞争同一流,**顺序无法保证**。
- **建议改法****不要用 Mutex**Mutex 只保证互斥,不保证"launch 顺序=写入顺序"
本缺陷是乱序而非字节交错,加锁修不掉)。改为单写协程 + 队列:
```kotlin
// RemoteSession 内新增
private val outQueue = kotlinx.coroutines.channels.Channel<String>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
private val writerStarted = java.util.concurrent.atomic.AtomicBoolean(false)
private fun ensureWriter() {
if (!writerStarted.compareAndSet(false, true)) return
scope.launch(Dispatchers.IO) {
val out = socket.getOutputStream()
try {
for (line in outQueue) {
out.write((line + "\n").toByteArray(Charsets.UTF_8))
out.flush()
}
} catch (e: Exception) {
if (!closed.get()) DebugLog.log("remote", "writer ended: ${e.javaClass.simpleName}")
}
}
}
private fun send(cmd: String) {
if (closed.get()) return
ensureWriter()
outQueue.trySend(cmd) // 调用方(UI 协程)顺序入队 → 顺序落盘
}
fun close() {
if (!closed.getAndSet(true)) {
runCatching { outQueue.close() }
runCatching { socket.close() }
}
}
```
要点:`trySend` 在**调用方线程**顺序入队,写 socket 只发生在唯一协程里。
- **可选补充(不替代上面的修复)**:客户端在帧模式下遇到非帧数据时,
可先尝试按 JSON 行解析再丢弃,作为对旧主机的兼容容错。属于加固,不解决根因。
- **验证方法**:复跑"高频命令"用例——`hello()`+`startStream()` 连续 3 对,
再接 400 次 `requestFfc()`,断言收到的行数、内容与**顺序**完全一致、
无嵌套花括号行。(核查者的独立 harness 已实现该用例,见 §6。)
- **不修的风险**:目前 `welcome` 只被打印(`RemoteViewerViewModel.kt:103`),
用户可见影响小;但 `hello→welcome` 契约已被破坏。同类乱序也可能发生在
快速 stop→start、连续 FFC 等序列上,属真实缺陷。**建议修**。
### FIX-2(中)"后来者拒绝写 busy" 未实现
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt:130-151`acceptLoop/serve
- **现象**`serve(socket)` 在 accept 循环内**同步**执行,第二个客户端只会在内核
backlog 里排队等待第一个断开;主机**从不写 `busy`**。
`RemoteContract.isBusyLine()``RemoteContract.kt:147`)全仓库**无调用点**,是死代码。
- **与计划冲突**`execution_plan.md` F2 明确要求"单客户端,后来者拒绝写 busy"。
- **建议改法(二选一)**
- **(a) 实现**accept 循环不阻塞在 serve 上,用标志位拒绝后来者:
```kotlin
private val serving = java.util.concurrent.atomic.AtomicBoolean(false)
// acceptLoop 的 while(running) 内:
val socket = server.accept()
if (!serving.compareAndSet(false, true)) {
runCatching {
val o = socket.getOutputStream()
o.write((RemoteContract.busyLine() + "\n").toByteArray(Charsets.UTF_8))
o.flush()
}
runCatching { socket.close() }
continue
}
scope?.launch { try { serve(socket) } finally { serving.set(false) } }
```
需在 `RemoteContract` 补 `fun busyLine(): String = "{\"type\":\"busy\"}"`
客户端侧建议在 `lines` 收集处对 `busy` 记一条日志(否则用户只看到连不上)。
- **(b) 如不实现**:同步修改 `execution_plan.md` F2 与相关文档,如实写明
"第二客户端排队等待,不写 busy",并把 `isBusyLine()` 删除或标注保留用途。
- **验证方法**:单测/回环:client1 连上后 client2 连接,断言 client2 在 1s 内
收到 `{"type":"busy"}` 或被立即关闭,且 client1 的帧流不受影响。
- **不修的风险**:单客户端约束事实成立,风险低;但"计划声明了、代码没有、
解析器留了死代码"三者不一致,会误导后续维护者。
### FIX-3(低)lifetime 负载接受两种布局(超出官方规格的对冲)
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt:226-231`
- **现象**:实现同时接受 ①`[magic][magic][ms]`12B,计划代码片段的字面写法,
与计划自己的"共 8 字节"描述矛盾)②`[magic][ms]`(8B,官方 Java 唯一实现)。
- **官方权威**`jadx_magcx/.../UsbCommunication.java:190-205` —
`bb.getInt()==1538635105` 后再 `getInt()`,即 **8 字节 `[magic][ms]`**。
另注意 `IrSession.readResp` 已剥掉前 4 字节 magic`r.third` 是**去掉** magic 的负载),
故官方布局下 `r.first==8`、`r.third.size==4`、ms = `u32(r.third,0)`。
- **建议改法**(收紧为官方唯一布局):
```kotlin
readResp(conn, epResp, "GetLifeTime")?.let { r ->
// readResp 已剥离 4B magic;官方帧共 8B → 余 4B 即 i32 ms
if (r.second == MagProtocol.RSP_SEND_LIFETIME && r.third.size >= 4) {
deviceLifetimeMs = MagProtocol.u32(r.third, 0).toLong() and 0xFFFFFFFFL
}
}
```
- **验证方法**:真机清单第 3 步出现 `[session] device lifetime=<ms>ms` 且非 -1。
- **不修的风险**:不会误判为连接失败,仅接受一个官方不发送的布局;低。
### FIX-4(低)多余权限 `ACCESS_NETWORK_STATE`
- **位置**`android/app/src/main/AndroidManifest.xml:15`
- **现象**:全仓库 grep 无 `ConnectivityManager`/`NetworkCapabilities`/权限检查,
该权限从未被使用(计划 F9 只要求 INTERNET)。
- **建议改法**:删除该行;若保留,需在提交信息/文档中说明用途。
- **验证方法**`aapt2 dump badging build-artifacts/mag160c-app-debug.apk` 不再列出。
### FIX-5(低)Phase Z 越界:`8e1312a` 含 Manifest 功能性改动
- **位置**`8e1312a` 修改了 `android/app/src/main/AndroidManifest.xml`+3 条
`uses-feature``camera`、`camera.autofocus` 显式 `required=false`,并改写注释)。
- **性质**:改动本身**合理**(防止 CAMERA 权限隐式要求相机硬件,保证无相机
设备仍可运行热像主功能),执行者已主动交底;但违反"Z 内容须仅为文档/清单/APK"。
- **建议**:不需要回滚功能,**只需在 `session_state.md` / `execution_plan.md`
如实记录该改动发生在 Z 提交**(承认越界),避免后续核查再次对不上账。
### FIX-6(低)文档口径修正
1. `docs/android_app/real_device_checklist.md:29`(第 15 步):预期"返回后 PIP 画面
重新出现"是**推断而非实测**。代码里 `PipCameraEngine.released` 置位后不复位,
只能依赖 `onSurfaceTextureAvailable` 再次触发。**先按真机实测结果决定改文档
还是改代码**(指南 §3E 明确:若真机不符,应判定为清单预期有误)。
2. `analysis/sdk_re/android_app/palette_extraction_findings.md:71`:红热占位图的
分母表述混用了两类像素。实测(ImageIO 逐像素):
**全部像素 11777/11914 相同****不透明像素 8536/8673 相同**meanAbsDiff 2.18/765)。
原文"11914 个**不透明**像素中 11777 个相同"应改为上述两种口径之一。
3. `session_state.md` / `execution_plan.md`:补记本报告 §3-F 的两项证伪
FIX-1 乱序、FIX-2 busy 未实现),使文档不再高于实现。
### FIX-7(可选,测试加固)
1. `MdtTest`:补一条**含 EXIF 缩略图(内部含 `FF D9`)的 JPEG** 往返用例。
核查者已独立验证当前实现能正确取主图 EOI(不会被缩略图干扰),
但仓库测试缺失该场景,属回归风险。
2. `VendorPalettes``PalettesTest.vendorTablesCoverIndicesZeroThroughTen`
`PalettesTest.kt:47-63`)只证明 `Palettes.buildAll()` 用了 `VendorPalettes`
的对应槽位,**不能**证明 `VendorPalettes` 里 case N 的内容确实来自 case N
(全库仅 case 2 有官方锚点)。建议为 11 张表各加一条"金标"断言
(如 256 项 CRC32/采样点),把本次已外部验证过的映射冻结住。
3. `RemoteLoopbackTest`:在 FIX-1 完成后,补"高频命令顺序"用例(见 FIX-1 验证方法)。
4. 清理死代码:`Palettes.kt:77/89/98/110` 的 `rainbow()`/`highContrast()`/
`hotMetal()`/`jet()` 已无调用点(Phase C 接入精确表后遗留)。
### 判定为"可接受、无需修改"的项
- **Phase B 温度条固定未缩放 fitRect**:与计划字面一致(`imageRect(size,1f,Offset.Zero)`),
取舍合理。
- **Phase B 探针标签随缩放/平移**(`AnalyzeViewer.kt:266` 用 `imageRect(size,zoom,pan)`):
与计划"标签随 fitRect 走"字面不符,但**实现明显更正确**(标签须跟随像素),
判定为实现优于字面规格,建议保留(可在文档注明)。
- **Phase B 分析页直接映射(无 90° 旋转)**:`AnalyzeViewer` 画布 `aspectRatio(4f/3f)`
且 `drawImage` 无 `canvas.rotate`,位图 `Bitmap.createBitmap(160,120)` 为原始朝向,
直接映射正确。
---
## 1. 第一关:硬门槛(全部通过,故继续逐阶段核查)
| 声明 | 核查方法 | 结论 |
|---|---|---|
| 工作树干净 | `git status --short` 无输出 | 证实 |
| 范围内恰 7 个提交、消息与计划逐字一致 | `git log --oneline 087e15c..HEAD`,逐条比对计划"提交信息" | 证实 |
| 未 push | `git log --oneline -1 origin/main` = `087e15c` | 证实 |
| 竖屏锁定未改 | 源码 diff 无 `orientation` 删改;`aapt2 dump xmltree` 得 `screenOrientation(0x0101001e)=1` | 证实 |
| `MagProtocolTest` 未变 | `git diff --stat` 为空 | 证实 |
| `MagProtocol.kt` 仅新增常量 | diff 仅 `RSP_SEND_LIFETIME` 三行 | 证实 |
| `LiveRenderer.kt` 未变 | `git diff` 为空 | 证实 |
| `analysis/` 既有文件未被改 | `--diff-filter=M` 为空;仅 8 个新增文件 | 证实 |
| `IrSession` 握手序列未改 | 人工读 diff66b→66c→66f→[670]→673、800ms 超时、前半段失败即 abort 全部保留;删除行仅两处(cache 读后加 MD5、stats 行追加 lifetime | 证实 |
| `assembleDebug + assembleRelease + test` 全绿 | 亲自重跑:`BUILD SUCCESSFUL`release 走完 R8 | 证实 |
| 44 个单测、逐类分布 3/3/5/8/6/4/12/3 | `--rerun-tasks` 强制重跑,逐 XML 汇总 = 44/0/0;每类用例名与指南表格一一对应 | 证实 |
| `RenderPipelineTest.matchesCReferencePixelExact` 仍通过 | 重跑后该用例在列且失败数为 0 | 证实 |
---
## 2. 逐阶段核查明细
### Phase A`06c1f30`
| 声明 | 方法 | 结论 |
|---|---|---|
| `RSP_SEND_LIFETIME = 0x5BB5B561` = 官方 `D2P_SendLifeTime` | `jadx_magcx/.../D2PCmd.java:10` = 1538635105;手算 `0x5BB5B561` = 1538635105 | 证实 |
| `CMD_GET_LIFETIME` 未改 = 官方 `P2D_GetLifeTime` | `P2DCmd.java:8` = 1807136373 = `0x6BB6B675` | 证实 |
| lifetime 失败不阻断连接 | `IrSession.kt:219-235` 失败仅 log,无 return/abort;官方 `getDevLifeTime` 亦仅 `Logging.error` | 证实(仅静态审查,无运行时证据) |
| cali 缓存 MD5 对照不改变返回值 | cache-hit 分支先算 `cached`log `identical/differ` 后仍 `return cached` | 证实 |
| 心跳 stats 追加 lifetime | `IrSession.kt:559` `" lifetime=${deviceLifetimeMs}"` | 证实 |
| checklist 日志串与代码一致 | 独立重跑 `CheckStrings.java`exact=16/skeleton=30/review=7;并逐条人工追溯 7 条到模板与调用点(`"$name write=$n/${packet.size}"` 418 行、`"$name resp=0x%08X len=%d head=%s"` 433 行、`"BasePara1: serial=... @${fps}fps"` 292 行、`"ep 0x%02X fail#..."` 408 行;调用点 `"GetParameter1"` 185、`"GetParameter2"` 201)。另抽查 `hb: state=`、`first reads`、`first rendered frame`、`first run on this host`、`requesting permission`、`permission result`、`[crash]`DebugLog 崩溃钩子)均存在 | 证实 |
| 唯一偏差 | lifetime 负载接受两种布局(见 FIX-3) | 偏差(低) |
### Phase B`656d419`
核查者用 Gradle 缓存内的 Kotlin 编译器(`kotlin-compiler-embeddable-2.2.0.jar`
在仓库外搭建独立 harness,直接编译并驱动**仓库真实源码**
`Mdt.kt`/`TempMath.kt`/`OfficialTables.kt`),不依赖执行者的任何测试代码。
| 声明 | 方法 | 结论 |
|---|---|---|
| `Mdt.parse` 与 `compose` 互逆 | 独立构造**含 EXIF 缩略图(内部含 `FF D9`)**、长度 1081(非 4 对齐)的 JPEG,往返比对各段 | 证实:jpg/info0/info1/frame/text 全部字节一致,裁剪点为主图 EOI |
| 坏尾/截断返回 null | 翻转尾部 1 字节、截断至 100B | 证实 |
| text 去 NUL 填充 | `Mdt.kt:123` `trimEnd('\u0000')`;独立用例短文本往返 | 证实 |
| 温度图 = u16LE → `countsToTempMc` | **独立复算**:从 `csdk/src/mag160c_official_t2e.h` 解析 646 项真表(非 Kotlin 表),照 `mag160c_render.c:64` 重写 C 版算法,对 19200 像素全量比对 | 证实:19200/19200 完全一致 |
| 探针为"直接映射" | `AnalyzeViewer.kt``aspectRatio(4f/3f)`、`drawImage` 无 `canvas.rotate`、`Bitmap.createBitmap(160,120)` | 证实(静态) |
| 温度条固定未缩放 fitRect | `drawTemperatureOsd` 传 `imageRect(size,1f,Offset.Zero)` | 证实 |
| 探针标签 | 用 `imageRect(size,zoom,pan)`,与计划字面"随 fitRect"不同 | 偏差(实现更优,建议保留,见 §0) |
### Phase C`b7a928e`)—— 本轮最需深挖的一相
| 声明 | 方法 | 结论 |
|---|---|---|
| `libcxsdk.so` 无静态调色板表 | 独立重跑 `PalScan.java`:三种编码(`B,G,R,0`/`B,G,R,0xFF`/忽略第 4 字节)**全部 not found**;[A] 段自校准证明扫描非盲 | 证实 |
| 索引↔case 映射 0..10(case 标签即调色板序号) | ① `libcxsdk_decomp.txt:456-1180``switch(param_2)`case 0..10 各一份生成体(另有 `case 0xc`/`case 0xd`);② **核查者新找到的决定性证据**:`jadx_magcx/.../DialogFragmentPalette.java` 的 `mapIndex2Id_` 把 UI 索引 0..11 依次对应白热…红热,点击时 `DeviceController.setColorPalette(index)` 把 **UI 索引原样传给 native** → case 标签 = UI 索引;③ case 2 → `OfficialTables.PALETTE256_ARGB` 256/256(重跑生成链复现) | 证实 |
| 索引 4/8/9 映射成立(执行者交底的不一致项) | **自写独立工具**(JDK `ImageIO` 解码官方预览图,不用执行者的手写 PNG 解码器),从 `PalBody` 重算全部 case 颜色集合,对 12 张预览图做完整覆盖矩阵 | 证实:case 0..10 **各自都是自身预览图的最佳解释者**;红饱和 case9 26.7% vs case0 22.1%,其 meanChroma=9.7 解释了"像素加权最近色距离"为何误判 |
| 索引 11 未解决、`SOURCE_CASE[11]==-1`、保留近似曲线 | `VendorPalettes.kt:24`、`Palettes.kt:47` | 证实 |
| 铁虹仍是官方表 | `officialIronbow()` 直接返回 `OfficialTables.PALETTE256_ARGB``PalettesTest.ironbowMatchesOfficialTables` 通过 | 证实 |
| 生成物可复现 | 两文件一起编译 → 重新生成:锚点 `iron_bow vs OfficialTables anchor: 256/256``VendorPalettes.kt`、`palette_candidates.json`、`palette_match_report.txt` **diff 全空** | 证实 |
| 红热预览图为占位副本 | ImageIO 逐像素:全像素 11777/11914 相同;不透明像素 8536/8673 | 证实(文档分母表述需修正,见 FIX-6) |
| `extract_palettes.py` | 本机无 python3,无法运行 | 无法判定(弱旁证) |
### Phase D`c34940e`
| 声明 | 方法 | 结论 |
|---|---|---|
| Retrofit 2.11.0 已接入 | `libs.versions.toml:9,24,25``app/build.gradle.kts:52,53` | 证实 |
| 默认关闭 | `AppSettings.kt:29-38``CloudClientTest` 4 项全绿 | 证实 |
| 无生产路径静默联网 | `grep -rn CloudClient android/app/src/main` 仅命中声明与 `AppSettings.setEnabled`main 无 `api()` 调用点;`api()` 未开启即 `check()` 抛异常 | 证实 |
| release 混淆不破 | 亲自跑 `assembleRelease`R8)成功 | 证实 |
| PROGUARD 规则 | `proguard-rules.pro:5` `-keep class com.mag160c.thermal.cloud.** { *; }` | 证实 |
### Phase E`f8b3200`
| 声明 | 方法 | 结论 |
|---|---|---|
| CAMERA 权限 + 相机可选;usb.host 仍 required | `aapt2 dump badging`camera/camera.any/autofocus 均 not-required`usb.host`/`screen.portrait` required | 证实 |
| 顶栏第 5 项 + 图标 | `LiveScreen.kt` 5 个 `weight(1f)` 项,第 5 项 `ic_pip`+`画中画``ic_pip.xml` 双弧圆描边 + 右下实心矩形 | 证实 |
| 三档 96/128/160dp、高=宽×3/4 | `PIP_WIDTHS_DP``hDp = wDp * 3 / 4`72/96/120,整除无误差) | 证实 |
| 右侧留色标条位 | `PIP_RIGHT_MARGIN_DP = 32`,初始 `pipXf=1f,pipYf=0f` | 证实 |
| 异常不崩溃 | `PipCameraView.kt` 全部回调 try/catch,失败路径收敛到 `release()` | 证实(静态) |
| 相机释放三处 | `PipOverlay` 的 `DisposableEffect onDispose`、离页同路径、`LiveScreen` 的 `LifecycleEventObserver(ON_STOP)` | 证实(静态) |
| ON_STOP 后能否自动重开 | 代码侧 `released` 置位后不复位,重开依赖 `onSurfaceTextureAvailable`;本机无设备 | **无法判定** |
| 双 `pointerInput` 手势并存 | Compose 运行时行为无法在本机验证 | 无法判定 |
### Phase F`512508e`)—— 2 项证伪
核查者用独立 harness 在 JVM 上驱动**真实的** `RemoteHost`/`RemoteSession`
(仅对 `DebugLog` 做最小桩),不打桩网络层。
| 声明 | 方法 | 结论 |
|---|---|---|
| 端口/魔数/帧长/超时与计划一致 | `RemoteContract`47510/47511/`0x1BB1B11B`/38400/38412/3000ms/10000ms 逐项比对 | 证实 |
| 封包格式小端 | 独立断言字段偏移、字节序(`pkt[0..3]={1B,B1,B1,1B}`)、payload 位于 12 | 证实 |
| 粘包/截断/坏长度/重同步 | 3 帧一次读入→3 payload;按 1000B 任意切分→恰好重组 1 次;截断 20000B→0 输出且 `pending()==20000`;坏 length→下一 magic 恢复 | 证实 |
| 端到端可用 | 真实回环:connect→welcome→stream-start→5 帧(payload 逐字节)→ffc 到达主机回调→stream-stop`RemoteLoopbackTest` 3 项亦全绿 | 证实 |
| 渲染在客户端本地 | `RemoteViewerViewModel`:本地 `RenderPipeline(160,120)`+assets `mag160c.ddt``setPalette` 只调本地管线;`setZoom` 只改状态 | 证实 |
| 主机侧不拖慢相机 | `Channel(capacity=8, DROP_OLDEST)`+`trySend`USB 读线程非阻塞)+单一 `serve()` 协程写 socket | 证实 |
| 默认不启动;开启需活跃 USB 会话 | `setRemoteHostEnabled` 先 `if (!session.isStreaming()) return false`UI 弹"先连接热像仪" | 证实 |
| INTERNET 权限 | badging 已声明 | 证实 |
| 单客户端,后来者拒绝写 busy | `acceptLoop` 内同步 `serve()`,第二连接排队等待,从不写 busy;`isBusyLine` 死代码 | **证伪**(见 FIX-2 |
| 命令写入不交错 | 见 FIX-1:120 次生产序列中 8 次丢 `welcome`;裸 socket 抓到 `hello`/`start` 颠倒 | **证伪**(见 FIX-1 |
| UDP 广播真机可达性 | 仅回环证据 | **无法判定** |
| `ACCESS_NETWORK_STATE` | 全代码无使用点 | 偏差(见 FIX-4) |
### Phase Z`8e1312a`
| 声明 | 方法 | 结论 |
|---|---|---|
| 消息无强制格式 | `git log -1 --format=%B` | 证实 |
| 内容仅为文档/清单/APK | `git show --stat`3 份文档 + APK**外加 Manifest+3 条 uses-feature** | **证伪**(见 FIX-5 |
| 文档更新到位 | HANDOFF §3 含 `ui/remote/`、`net/`、`cloud/CloudApi.kt`session_state 勾选 AZ 并追加总结;execution_plan 顶部加执行状态 | 证实 |
| 全量构建 + 提交 APK,且不 push | 重跑 debug+release+test 成功;`origin/main` 仍 `087e15c` | 证实 |
| 提交的 APK 与 HEAD 一致 | 对已提交 APK 核验:`screenOrientation=1`、camera 系列 not-required、INTERNET/CAMERA 已声明、dex 内含 `画中画`/`正在扫描局域网主机`/`云同步`(E/D/F 代码确在包内)。APK 字节不可复现(时间戳),故以内容核验替代字节比对 | 证实(以内容为准) |
---
## 3. 核查边界:本机无法判定(须真机裁决,未默认通过)
本机(Windows)无模拟器、无连接设备、`adb devices` 为空。以下必须真机判定:
1. USB 出流与温度绝对值标定(清单 1–12 步)。
2. 可见光 PIP 全部运行时行为(13–15 步):**含"返回后画面是否重新出现"**、
单击换档/双击关闭/拖拽三种手势是否互不干扰。
3. 局域网远程预览双机行为(16–24 步):**含 `255.255.255.255` 广播在真实
Wi-Fi 上是否可达**(部分路由/AP 会过滤广播;息屏策略也可能影响收包)。
4. 分析页温度条/探针的实际显示与取温正确性(11 步)。
5. 任何"画面/像素"层面的确认。
清单本身已按指南机械核对:16 条字面命中、30 条骨架命中、7 条人工追溯全部
落实到真实模板与调用点,未发现凭空编造。清单可继续作为真机验证脚本。
---
## 4. 真机测试时请重点确认(与修复决策挂钩)
| 要确认的事 | 怎么看 | 影响哪个修复 |
|---|---|---|
| 远程预览:连上后是否每次都看到"welcome"日志 | B 端日志 `[remote] line: {"type":"welcome"...}` 是否出现;连续重连 10 次统计 | FIX-1(当前约 6.7% 丢失) |
| 快速 stop→start、连点 FFC 是否偶发失灵 | 反复操作,看 B 端画面是否与按钮状态不一致 | FIX-1 |
| PIP:Home 返回后小窗是否重新出现 | 清单 15 步 | FIX-6.1(决定改文档还是改代码) |
| PIP:单击换档 / 双击关闭 / 拖拽是否互不干扰 | 清单 14 步,各做 5 次 | 若互相干扰则需改手势实现(本机无法判定) |
| 分析页:点已知温度位置,读数是否合理 | 清单 11 步 | 探针直接映射的运行时确认 |
| 远程预览:局域网是否能在 10s 内发现主机 | 清单 19 步 | UDP 广播可达性 |
| lifetime 是否非 -1 | 清单第 3 步 `[session] device lifetime=<ms>ms` | FIX-3 |
---
## 5. 核查者使用的判据来源(可独立复核)
| 判据 | 路径 |
|---|---|
| 协议权威(官方 Java | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/sdk/{D2PCmd,P2DCmd,UsbCommunication}.java` |
| 官方调色板 UI 顺序与 native 调用点(**本次新用** | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/DialogFragmentPalette.java` |
| native 伪代码(12+2 个生成体) | `analysis/sdk_re/android_app/libcxsdk_decomp.txt``SetColorPalette @00026c70`case 0..10、0xc、0xd |
| 铁虹真表(真实内存布局 `B,G,R,0` | `csdk/src/mag160c_official_palette256.h` |
| 温度 C 参考实现 + T2E 真表 | `csdk/src/mag160c_render.c:64`、`csdk/src/mag160c_official_t2e.h` |
| 帧布局权威 | `csdk/src/mag160c_frame.c:11-13`(像素自 `+0x1c`,总长 `0x38+len` |
| 官方预览图 | `app/【普通版】MAG-Cx.apk` → `res/mipmap-hdpi-v4/palette_*.png`12 张) |
---
## 6. 复现命令(修复模型可直接照跑)
### 6.1 构建 + 全量单测(Windows Git Bash
```bash
cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug :app:assembleRelease test --no-daemon"
# 强制重跑(否则 UP-TO-DATE 不产生新结果)
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:testDebugUnitTest --rerun-tasks --no-daemon"
# 单测计数
grep -h -o 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*" errors="[0-9]*"' \
/c/Project/MAG160C/android/app/build/test-results/testDebugUnitTest/*.xml
```
### 6.2 Phase C 否证复核 + 生成物可复现
```bash
cd /c/Project/MAG160C
"C:\Tools\jdk-21\bin\java" analysis/tools/PalScan.java \
analysis/sdk_re/android_app/bin/libcxsdk.so csdk/src/mag160c_official_palette256.h
# 期望:三种编码全部 not found[A] 段自校准 detected 1 candidate
mkdir -p /c/Users/zxc/AppData/Local/Temp/regen_build && \
cd /c/Users/zxc/AppData/Local/Temp/regen_build && \
cp "C:\Project\MAG160C\analysis\tools\PalIdentify.java" . && \
cp "C:\Project\MAG160C\analysis\tools\PalExport2.java" . && \
"C:\Tools\jdk-21\bin\javac" -d out PalIdentify.java PalExport2.java && \
"C:\Tools\jdk-21\bin\java" -cp out PalExport2 \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt" \
"C:\Users\zxc\AppData\Local\Temp\cxres\res\mipmap-hdpi-v4" \
"C:\Users\zxc\AppData\Local\Temp\regen_core" \
"C:\Users\zxc\AppData\Local\Temp\regen_out"
# 期望末行:iron_bow vs OfficialTables anchor: 256/256
diff "C:\Users\zxc\AppData\Local\Temp\regen_core\VendorPalettes.kt" \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\VendorPalettes.kt"
diff "C:\Users\zxc\AppData\Local\Temp\regen_out\palette_candidates.json" \
"C:\Project\MAG160C\analysis\sdk_re\android_app\palette_candidates.json"
# 期望:两条 diff 均为空
```
### 6.3 独立 Kotlin harness(仓库外编译仓库真实源码)
无 gcc / 无 kotlinc,用 Gradle 缓存内的编译器:
```bash
KC=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin
KX=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx
STDLIB=$KC/kotlin-stdlib/2.2.0/fdfc65fbc42fda253a26f61dac3c0aca335fae96/kotlin-stdlib-2.2.0.jar
COMPILER=$KC/kotlin-compiler-embeddable/2.2.0/8cfa2b049a4006d94474296df4abd9b50f288821/kotlin-compiler-embeddable-2.2.0.jar
SCRIPT=$KC/kotlin-script-runtime/2.2.0/87c92e866fcd68680966a3005a2992e1ab8ec6ad/kotlin-script-runtime-2.2.0.jar
CORO=$KX/kotlinx-coroutines-core-jvm/1.8.0/ac1dc37a30a93150b704022f8d895ee1bd3a36b3/kotlinx-coroutines-core-jvm-1.8.0.jar
ANNOT=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/23.0.0/8cc20c07506ec18e0834947b84a864bfc094484e/annotations-23.0.0.jar
# 编译(示例:Phase FPhase B 同理,只换成 core/media 源码 + 自己的断言)
java -cp "$COMPILER;$STDLIB;$SCRIPT;$CORO;$ANNOT" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \
-no-stdlib -cp "$STDLIB;$CORO" -d out \
<DebugLog 桩>.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt \
<自己的断言>.kt
java -cp "out;$STDLIB;$CORO" <主类>Kt
```
要点:
- `DebugLog` 依赖 Android 类型,需自写同包同名的 JVM 桩
`package com.mag160c.thermal.media; object DebugLog { fun log(t: String, m: String) {} }`),
网络类即可在纯 JVM 上运行。
- Windows 版 java **不认** Git Bash 的 `/tmp`,路径必须写 `C:\...`。
### 6.4 其它
```bash
# 清单日志串核对(期望 exact=16 skeleton=30 review=7
"C:\Tools\jdk-21\bin\java" analysis/tools/CheckStrings.java \
docs/android_app/real_device_checklist.md android/app/src/main
# APK 清单核验
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump badging build-artifacts/mag160c-app-debug.apk
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump xmltree build-artifacts/mag160c-app-debug.apk --file AndroidManifest.xml
```
---
## 7. 结论一句话
**Phase A/B/C/D/E 的声明全部证实(C 的索引 4/8/9 映射经独立复核成立;
B 的温度换算经独立 C 移植全量比对一致);Phase F 有两处真实缺陷
(命令乱序导致约 6.7% 丢 welcome、busy 未实现),Phase Z 有一处越界
(Manifest 改动);其余为低风险偏差。真机相关项无法判定,等实测。**