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)
}
}