android: 实时画面卡顿根因是软件画布(6.5→15.1fps),标注统一 + 7x7 细节增强

卡顿: 热像走 SurfaceView.lockCanvas 的软件画布, 每帧在 CPU 上做 320x240 ->
~810x1080 双线性放大 + 清整屏, 实测只有 6.5 帧/秒、单帧 134ms。dumpsys gfxinfo
看不到这条路径(只统计 Compose), 故给渲染线程加自测量日志才定位到。改用
lockHardwareCanvas(API29+, 旧系统回退) 后 15.1 帧/秒、单帧 6.4ms。
同时消除每帧垃圾: 复用翻转缓冲/标记列表, 色条改预渲染位图; 温度扫描从主线程
移到 Dispatchers.Default。

标注统一: 新增 AnnotSpec(唯一几何定义) + MarkerPainter(唯一绘制例程), 实时/
分析/照片/视频共用, 极端值标签改用 max/min; 照片按传感器朝向 3x 分辨率保存。
修 placeLabel 不判重叠(min 与 max 印成一团), 现支持让位。

分析对齐: 极端值漏了 sensorToPhoto 映射(落在左上角几十像素内), annotateJpeg
忽略照片镜像(另存时标记镜像到另一侧), 两处已修。

新增 BLOCK_EXTREMES(0x5BB5B562) 存拍摄时的极值位置/温度: 分析页重扫 NUC 不可能
逐位复现 live 结果, 真机出现过两个相距几像素的 min。

分析面板被导航栏遮挡: UiInsets.navPx 改可观察状态并加 padding。

7x7 局部细节增强(官方 FilterDetailEnhancement_Simple 逐行移植), 强度换算
level shl 3 经 MAG_SetDetailEnhancement(钳 0..32)核对一致; 设置页新增
"图像增强" 0-4 级, 默认关闭(无官方参考输出), 不影响逐位基线。

真机验证(小米 22041211AC/Android12/MIUI): 15.1fps、拍照含 2 探针+2 极值、
照片与分析页标记位置/风格一致且各只有一个 min/max、分析数值与照片吻合、
增强 2 级细节明显且不掉帧。测试 96 项全绿。
This commit is contained in:
ZXCLI
2026-09-12 04:20:16 +08:00
parent 47037f3a23
commit 4ebdca109a
21 changed files with 1417 additions and 272 deletions
@@ -48,8 +48,16 @@ object AnnotSpec {
fun labelOffsetX(scale: Float): Float = (RING_R + LABEL_GAP) * scale
/**
* Where to put a label so it stays inside [imgW]x[imgH].
* @return x of the box's left edge and y of the box's top edge
* Where to put a label so it stays inside [imgW]x[imgH] and does not sit on
* top of an already-placed label.
*
* @param placed boxes already committed, as [x, y, boxW, boxH]; a candidate
* that intersects one of these is nudged downward (and flipped to the left
* of its marker first if that helps). Without this, two extremes that are
* close together in the scene — the usual case for a hot object against a
* cool background — produced one illegible smear of two overlapping
* temperatures.
* @return x of the box's left edge and y of its top edge
*/
fun placeLabel(
cx: Float,
@@ -59,6 +67,7 @@ object AnnotSpec {
imgW: Float,
imgH: Float,
scale: Float,
placed: List<FloatArray> = emptyList(),
): FloatArray {
val margin = 2f * scale
var x = cx + labelOffsetX(scale)
@@ -68,6 +77,26 @@ object AnnotSpec {
var y = cy - boxH / 2f
if (y < margin) y = margin
if (y + boxH > imgH - margin) y = imgH - margin - boxH
return floatArrayOf(x, y)
if (placed.isEmpty()) return floatArrayOf(x, y)
// Try the flipped side first when the default side is taken: that keeps the
// label attached to its own marker instead of sliding away from it.
val flippedX = (cx - labelOffsetX(scale) - boxW).coerceAtLeast(margin)
val candidates = if (flippedX != x) floatArrayOf(x, flippedX) else floatArrayOf(x)
for (candidateX in candidates) {
var tryY = y
var guard = 0
while (guard++ < placed.size + 2) {
val hit = placed.firstOrNull {
candidateX < it[0] + it[2] && candidateX + boxW > it[0] &&
tryY < it[1] + it[3] && tryY + boxH > it[1]
} ?: return floatArrayOf(candidateX, tryY)
// step below the box it hit, staying inside the image
tryY = hit[1] + hit[3] + 2f * scale
if (tryY + boxH > imgH - margin) break
}
}
// nothing fit cleanly: keep it inside the frame, on the marker's own side
return floatArrayOf(x, y.coerceIn(margin, (imgH - margin - boxH).coerceAtLeast(margin)))
}
}
@@ -0,0 +1,150 @@
package com.mag160c.thermal.core
/**
* Local 7x7 detail enhancement, ported from the official native SDK
* (`CFunctions::FilterDetailEnhancement_Simple` + `CFunctions::LocalMap7x7_Simple`,
* libcxsdk.so; Ghidra listing in
* analysis/sdk_re/android_app/libcxsdk_decomp.txt).
*
* This is the stage that makes the vendor's live image look crisper: for every
* pixel it measures the local contrast inside a 7x7 window and pushes the pixel
* away from the local mean, so fine texture becomes visible. Our pipeline was
* ported from the C reference in `csdk/`, which stops before this filter — that
* difference is why the official app looked sharper.
*
* ## Reading of the decompilation (all offsets are the vendor's own)
*
* `LocalMap7x7_Simple` samples a 7x7 window at every OTHER pixel (x offsets
* 0,2,4,6 and rows stepped by 2 x width, 4 rows = 16 samples), then:
* mean = Σsamples >> 4
* range = max - min
* if (strength <= range * 32):
* divisor = max(max - mean, mean - min, strength)
* detail = (0x8000 / divisor) * (center - mean)
* else detail = 0
* where `center` is the true middle pixel of the window (row 3, col 3), not one of
* the 16 samples.
*
* The driver walks every window position (rows 0..H-7, cols 0..W-7, both stepped
* by 1 across the two interleaved loops in the original) and stores the detail at
* the window's centre, so each destination pixel receives exactly one value. The
* consumer then applies
* gray = clamp(gray + (strength * detail) / 32768, 0, 255)
* over the rows/cols the earlier stages actually filled (3..H-4).
*
* ## Validation status — READ THIS
*
* The arithmetic above is a faithful transcription, and the structural properties
* are unit-tested (`DetailEnhanceTest`), but this port has **no official reference
* output to diff against**: the byte-exact `RenderPipelineTest` baseline was built
* from the C reference that predates this filter. It is therefore opt-in
* ([RenderPipeline.enhanceStrength], default 0 = off) so the verified path stays
* the default, and it is exposed as a user-visible toggle rather than silently
* changing every image. If it ever looks wrong, turning it off restores the
* byte-exact behaviour exactly.
*/
class DetailEnhance(private val w: Int, private val h: Int) {
private val npix = w * h
private val detail = IntArray(npix)
/**
* Apply the filter to [gray] in place.
*
* @param src16 calibrated 16-bit counts (the pipeline's NUC output)
* @param gray 8-bit gray image that the palette stage consumes
* @param strength vendor strength parameter (0 = no-op); the official app
* derives it from its enhancement setting as `level shl 3`
* @param gain the vendor's `(dev24 * 1000) >> shift` term, clamped to 0xFFFF
* @param srcLimit samples beyond this index are read as 0 (the vendor's frame
* buffer can be larger than the active area)
*/
fun enhance(src16: IntArray, gray: ByteArray, strength: Int, gain: Int, srcLimit: Int = src16.size) {
if (strength <= 0 || gain <= 0) return
require(gray.size >= npix) { "gray buffer smaller than $w x $h" }
val k = strength * gain * 2 shr 8
if (k == 0) return
java.util.Arrays.fill(detail, 0)
// --- pass 1: detail map. The vendor walks rows 0..H-7 in two interleaved
// parity loops and stores each window's value at its centre, so every
// destination pixel gets exactly one value. ---
var row = 0
while (row <= h - 7) {
var col = 0
while (col <= w - 7) {
detail[(row + 3) * w + (col + 3)] = localMap(src16, col, row, k, srcLimit)
col++
}
row += 2
}
var oddRow = 1
while (oddRow <= h - 7) {
var col = 0
while (col <= w - 7) {
detail[(oddRow + 3) * w + (col + 3)] = localMap(src16, col, oddRow, k, srcLimit)
col++
}
oddRow += 2
}
// --- pass 2: apply to the gray image (rows 3..H-4, cols 3..W-4 — exactly
// the region pass 1 filled) ---
var y = 3
while (y < h - 3) {
var x = 3
while (x < w - 3) {
val d = detail[y * w + x]
if (d != 0) {
// (k * detail) / 32768, truncated toward zero like the vendor
val prod = k * d
val delta = (prod + (if (prod < 0) 0x7FFF else 0)) shr 15
var v = (gray[y * w + x].toInt() and 0xFF) + delta
if (v < 1) v = 0
if (v > 254) v = 255
gray[y * w + x] = v.toByte()
}
x++
}
y++
}
}
/**
* One 7x7 window centred on ([col]+3, [row]+3). Returns the detail value the
* vendor's LocalMap7x7_Simple produces, or 0 when the local contrast is below
* their `strength <= range * 32` threshold.
*/
private fun localMap(src16: IntArray, col: Int, row: Int, strength: Int, srcLimit: Int): Int {
var mn = Int.MAX_VALUE
var mx = Int.MIN_VALUE
var sum = 0
// rows 0,2,4,6 of the window (stride 2 in y), sampling x at 0,2,4,6: the
// vendor's 4x4 decimation of the 7x7 neighbourhood
var dy = 0
while (dy < 4) {
val base = (row + dy * 2) * w + col
var dx = 0
while (dx < 4) {
val idx = base + dx * 2
val v = if (idx < srcLimit) src16[idx] and 0xFFFF else 0
sum += v
if (v < mn) mn = v
if (v > mx) mx = v
dx++
}
dy++
}
if (strength <= (mx - mn) * 32) {
val mean = sum shr 4
var divisor = (mx - mean).coerceAtLeast(strength)
divisor = (mean - mn).coerceAtLeast(divisor)
if (divisor <= 0) return 0
val gain = 0x8000 / divisor
val ci = (row + 3) * w + (col + 3)
val center = if (ci < srcLimit) src16[ci] and 0xFFFF else 0
return gain * (center - mean)
}
return 0
}
}
@@ -24,6 +24,15 @@ class RenderPipeline(
private val force75: Boolean = true,
/** FFC command callback: param 0 = FFC(0), 1 = FFC(1). */
private val onFfc: ((param: Int) -> Unit)? = null,
/**
* Local 7x7 detail enhancement strength (vendor FilterDetailEnhancement_Simple).
* 0 = OFF, which keeps the output byte-identical to the verified C reference —
* that is the default because this stage is ported from a different source than
* the reference baseline and has no official output to diff against (see
* [DetailEnhance]). The app exposes it as the "图像增强" setting; the official
* app derives its value from its enhancement level as `level shl 3`.
*/
private var enhanceStrength: Int = 0,
) {
private val npix = w * h
@@ -77,6 +86,10 @@ class RenderPipeline(
private var remoteRefWindow = false
private val ref = IntArray(npix)
/** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */
private val detailEnhance = DetailEnhance(w, h)
private val refAcc = IntArray(npix)
private val nuc = IntArray(npix)
private val gray160 = ByteArray(npix)
@@ -474,6 +487,23 @@ class RenderPipeline(
pal = Palettes.buildAll()[index.coerceIn(0, Palettes.NAMES.size - 1)]
}
/** Detail enhancement strength; 0 disables it (byte-exact reference path). */
fun setEnhanceStrength(value: Int) = synchronized(lock) {
enhanceStrength = value.coerceIn(0, 64)
}
fun enhanceStrength(): Int = synchronized(lock) { enhanceStrength }
/**
* The vendor's gain term for the enhancement stage: `(dev24 * 1000) >> shift`,
* clamped to 0xFFFF (their `this+0x5104` is the shift, which matches
* [dev4cShift] in this port).
*/
private fun detailGain(): Int {
val g = (dev24 * 1000) shr dev4cShift
return if (g > 0xFFFE) 0xFFFF else g
}
/** 32-bit unsigned wrap (C unsigned int semantics). */
private fun u32(x: Long): Long = x and 0xFFFFFFFFL
@@ -655,6 +685,10 @@ class RenderPipeline(
statsWindow()
lutRebuild()
grayMap()
// vendor order: detail enhancement works on the gray image, before upscale
if (enhanceStrength > 0) {
detailEnhance.enhance(nuc, gray160, enhanceStrength, detailGain())
}
upscale2x()
// palette colorize 320x240 -> ARGB
val pal = this.pal
@@ -34,6 +34,11 @@ object MarkerPainter {
* @param imageUnitsToPixels conversion from AnnotSpec units (based on a 320-wide
* reference) to target pixels; pass [renderScale] for a photo rendered at
* N x the sensor size, or the on-screen scale for a display.
* @param textRotationDeg rotate each label about its marker by this angle. The
* live screen passes the negative grip angle so labels stay upright while the
* image is drawn rotated; photos and analysis pass 0, which keeps the text
* horizontal in the sensor frame (the user's requirement for saved files).
* The dot and ring are never rotated.
*/
fun draw(
canvas: Canvas,
@@ -41,6 +46,7 @@ object MarkerPainter {
imgW: Float,
imgH: Float,
imageUnitsToPixels: Float,
textRotationDeg: Float = 0f,
) {
if (marks.isEmpty()) return
val k = imageUnitsToPixels
@@ -66,6 +72,11 @@ object MarkerPainter {
}
val padH = AnnotSpec.LABEL_PAD_H * k
val padV = AnnotSpec.LABEL_PAD_V * k
// Label boxes committed so far, as [x, y, w, h]: passed to placeLabel so a
// second label never lands on the first one. Extremes (max/min) are usually
// near each other in the scene, so without this their readouts merged into
// an unreadable overlap.
val placed = ArrayList<FloatArray>(marks.size)
for (m in marks) {
val tint = m.tint
@@ -86,8 +97,16 @@ object MarkerPainter {
// it would overflow) so the photo matches what the user saw
val pos = com.mag160c.thermal.core.AnnotSpec.placeLabel(
cx = m.x, cy = m.y, boxW = boxW, boxH = boxH,
imgW = imgW, imgH = imgH, scale = k,
imgW = imgW, imgH = imgH, scale = k, placed = placed,
)
placed.add(floatArrayOf(pos[0], pos[1], boxW, boxH))
canvas.save()
if (textRotationDeg != 0f) {
// rotate the LABEL about its marker, keeping it attached: the live
// view draws the image rotated, so unrotated text would run down
// the screen. The marker glyph itself is never rotated.
canvas.rotate(textRotationDeg, m.x, m.y)
}
if (tint == null) {
canvas.drawRoundRect(
RectF(pos[0], pos[1], pos[0] + boxW, pos[1] + boxH),
@@ -102,6 +121,7 @@ object MarkerPainter {
val baseline = pos[1] + padV - fm.ascent
canvas.drawText(full, pos[0] + padH, baseline, text)
text.clearShadowLayer()
canvas.restore()
}
}
@@ -16,7 +16,14 @@ import java.io.ByteArrayOutputStream
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE) — RAW sensor response
* 0x5BB5B55E text note (UTF-8, optional)
* 0x5BB5B55F probe points (UTF-8 lines "x,y,label,tempMc", optional)
* 0x5BB5B560 NUC counts in PHOTO coordinates (19200 x uint16 LE, optional)
* 0x5BB5B560 NUC counts on the sensor grid (19200 x uint16 LE, optional)
* 0x5BB5B561 render params {u32 version, u32 mirror flags} (optional)
* 0x5BB5B562 traced extremes "minPos,maxPos,minMc,maxMc" (optional)
*
* PROBES/NUC/EXTREMES ARE ALL IN SENSOR SPACE. The JPEG is whatever the user's
* mirror settings produced, so a viewer needs [RenderParams] to line a marker up
* with a feature in the image; storing markers in photo space instead was the bug
* that made analysis markers land mirrored against the burned-in ones.
*
* WHY THE NUC BLOCK EXISTS (2026-09-11): the raw frame is the sensor response
* BEFORE non-uniformity correction, so converting it directly yields nonsense
@@ -36,20 +43,113 @@ object Mdt {
const val BLOCK_TXT = 0x5BB5B55E
/**
* Probe points captured with the photo, in the SAVED PHOTO's pixel
* coordinates (not sensor coordinates — that mismatch put the markers in the
* wrong place when the photo was displayed). Stored as UTF-8 text
* ("x,y,label,tempMc" per line) so the values stay inspectable with any hex
* editor — the same reasoning the vendor layout uses for its TXT section.
* Probe points captured with the photo, in SENSOR pixel coordinates (0..159,
* 0..119). Sensor space, not photo space: the photo carries the user's mirror
* corrections while the probes must stay in the frame the temperature data
* lives in, so a viewer applies [RenderParams] to place them (see [BLOCK_NUC]).
* Stored as UTF-8 text ("x,y,label,tempMc" per line) so the values stay
* inspectable with any hex editor — the same reasoning the vendor layout uses
* for its TXT section.
*/
const val BLOCK_PROBES = 0x5BB5B55F
/** NUC (calibrated) counts in photo pixel order; see the header note. */
/**
* NUC (calibrated) counts on the 160x120 SENSOR grid (19200 x uint16 LE), the
* same space as [BLOCK_PROBES]; see the header note. Because the sensor grid is
* 160 wide and a sensor pixel is exactly two AnnotSpec units, the offline
* temperature lookup is a single index — no resampling.
*/
const val BLOCK_NUC = 0x5BB5B560
/**
* How the JPEG was rendered: {u32 version, u32 flags}.
*
* Flags: bit0 = mirrored horizontally, bit1 = mirrored vertically (the user's
* sensor-mount corrections). Stored because the photo carries those flips while
* the probe coordinates and the NUC grid are in raw SENSOR space — a viewer
* that ignores this draws the markers at mirrored positions, which is exactly
* the misalignment reported on device (analysis markers did not sit on the
* marks visible in the photo).
*/
const val BLOCK_RENDER = 0x5BB5B561
/**
* The max/min the capture recorded, in SENSOR coordinates:
* "minX,minY,minMc,maxX,maxY,maxMc" (UTF-8 text, like the probe block).
*
* Stored because the analysis screen otherwise re-derives the extremes from
* the NUC block, and it cannot get the same answer: the live scan and the
* offline scan disagree by a temperature step and several pixels whenever the
* sensor drifts between the capture and the reload (the argmin of a noisy flat
* region moves easily). On device this showed as TWO min markers a few pixels
* apart with 22.0 and 22.1 C — the one burned into the JPEG and the one the
* analysis had just recomputed. With this block the analysis draws exactly the
* marker the photo already carries.
*/
const val BLOCK_EXTREMES = 0x5BB5B562
const val RENDER_FLAG_FLIP_H = 1
const val RENDER_FLAG_FLIP_V = 2
/** One probe carried in an MDT file. */
data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int)
/** Render parameters recorded with the photo. */
data class RenderParams(val flipH: Boolean, val flipV: Boolean) { val flags: Int
get() = (if (flipH) RENDER_FLAG_FLIP_H else 0) or
(if (flipV) RENDER_FLAG_FLIP_V else 0)
companion object {
val NONE = RenderParams(false, false)
fun fromFlags(flags: Int): RenderParams =
RenderParams(flags and RENDER_FLAG_FLIP_H != 0, flags and RENDER_FLAG_FLIP_V != 0)
}
}
fun encodeRenderParams(p: RenderParams): ByteArray {
val out = ByteArray(8)
put32(out, 0, 1)
put32(out, 4, p.flags)
return out
}
fun parseRenderParams(bytes: ByteArray?): RenderParams {
if (bytes == null || bytes.size < 8) return RenderParams.NONE
return RenderParams.fromFlags(u32(bytes, 4))
}
/**
* The capture's max/min in sensor coordinates. [minPos]/[maxPos] are sensor
* indices (y*160+x), matching what the live view published.
*/
data class Extremes(
val minPos: Int,
val maxPos: Int,
val minMc: Int,
val maxMc: Int,
) {
companion object {
/** Sentinel for "the capture did not trace any extreme". */
val NONE = Extremes(-1, -1, 0, 0)
val hasAny: (Extremes) -> Boolean = { it.minPos >= 0 || it.maxPos >= 0 }
}
}
fun encodeExtremes(e: Extremes): ByteArray =
"${e.minPos},${e.maxPos},${e.minMc},${e.maxMc}".toByteArray(Charsets.UTF_8)
fun parseExtremes(bytes: ByteArray?): Extremes {
if (bytes == null || bytes.isEmpty()) return Extremes.NONE
val parts = String(bytes, Charsets.UTF_8).trimEnd('\u0000').split(',')
if (parts.size < 4) return Extremes.NONE
val mn = parts[0].trim().toIntOrNull() ?: return Extremes.NONE
val mx = parts[1].trim().toIntOrNull() ?: return Extremes.NONE
val mnMc = parts[2].trim().toIntOrNull() ?: return Extremes.NONE
val mxMc = parts[3].trim().toIntOrNull() ?: return Extremes.NONE
return Extremes(mn, mx, mnMc, mxMc)
}
fun encodeProbes(probes: List<Probe>): ByteArray =
probes.joinToString("\n") { "${it.x},${it.y},${it.label},${it.tempMc}" }
.toByteArray(Charsets.UTF_8)
@@ -99,6 +199,8 @@ object Mdt {
text: ByteArray? = null,
probes: ByteArray? = null,
nucPixels: ByteArray? = null,
renderParams: ByteArray? = null,
extremes: ByteArray? = null,
): ByteArray {
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
out.write(jpg, 0, jpg.size)
@@ -129,6 +231,8 @@ object Mdt {
nucPixels?.let {
if (it.size >= 38400 && it.size % 2 == 0) emit(BLOCK_NUC, it)
}
renderParams?.let { emit(BLOCK_RENDER, it) }
extremes?.let { if (it.isNotEmpty()) emit(BLOCK_EXTREMES, it) }
val bodyBytes = body.toByteArray()
val header = ByteArray(0x88)
@@ -176,6 +280,8 @@ object Mdt {
text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') },
probes = parseProbes(blocks[BLOCK_PROBES]),
nucPixels = blocks[BLOCK_NUC],
render = parseRenderParams(blocks[BLOCK_RENDER]),
extremes = parseExtremes(blocks[BLOCK_EXTREMES]),
)
}
@@ -215,6 +321,16 @@ object Mdt {
* computing wrong ones.
*/
val nucPixels: ByteArray? = null,
/**
* How the JPEG was mirrored. Markers and the NUC grid are in raw SENSOR
* space, so a viewer must apply this to line them up with the image.
*/
val render: RenderParams = RenderParams.NONE,
/**
* The max/min the capture recorded, when it traced any. Preferred over
* re-scanning [nucPixels]: see [BLOCK_EXTREMES].
*/
val extremes: Extremes = Extremes.NONE,
) {
/** True when this photo can be measured offline. */
val hasTemperatureData: Boolean get() = nucPixels != null
@@ -120,7 +120,15 @@ object PhotoSaver {
val pos = sensorToPhoto(p.x, p.y, mirror, outW, outH)
marks.add(MarkerPainter.Mark(pos[0], pos[1], p.label, p.tempC))
}
marks.addAll(extremes)
// The extremes arrive in SENSOR coordinates (same space as the probes) and
// must go through the same mapping. Adding them raw put both markers in the
// photo's top-left corner: sensor x/y are 0..159/0..119, so on a 960x720
// photo they landed within a few dozen pixels of the origin instead of over
// the hot/cold spots they name.
for (e in extremes) {
val pos = sensorToPhoto(e.x.toInt(), e.y.toInt(), mirror, outW, outH)
marks.add(MarkerPainter.Mark(pos[0], pos[1], e.label, e.tempC, e.tint))
}
if (marks.isNotEmpty()) {
// AnnotSpec units are calibrated for a 320-wide image; the photo is
// RENDER_SCALE x that (times any extra upscale), so text stays sharp
@@ -201,22 +209,30 @@ object PhotoSaver {
fun annotateJpeg(
jpg: ByteArray,
marks: List<MarkerPainter.Mark>,
mirror: Mirror = Mirror(false, false),
): ByteArray {
if (marks.isEmpty()) return jpg
val bmp = android.graphics.BitmapFactory.decodeByteArray(jpg, 0, jpg.size) ?: return jpg
val out = bmp.copy(Bitmap.Config.ARGB_8888, true) ?: return jpg
// marks arrive in SENSOR coordinates; the bitmap may be larger
val pxPerSensor = out.width / 160f
// marks arrive in SENSOR coordinates; the bitmap is the saved photo, which
// carries the capture's mirror. Converting through sensorToPhoto keeps them
// on the same features the photo shows — without it, saving from the
// analysis screen put every marker on the mirrored side of the image.
val pxPerSensorX = out.width / 160f
val scaled = marks.map {
MarkerPainter.Mark(it.x * pxPerSensor, it.y * pxPerSensor, it.label, it.tempC, it.tint)
val pos = sensorToPhoto(
it.x.toInt().coerceIn(0, 159), it.y.toInt().coerceIn(0, 119),
mirror, out.width, out.height,
)
MarkerPainter.Mark(pos[0], pos[1], it.label, it.tempC, it.tint)
}
MarkerPainter.draw(
canvas = Canvas(out),
marks = scaled,
imgW = out.width.toFloat(),
imgH = out.height.toFloat(),
// AnnotSpec units are calibrated for a 320-wide frame = 2 sensor units
imageUnitsToPixels = out.width / 320f,
// AnnotSpec units are calibrated for a 320-wide frame = 2 sensor pixels
imageUnitsToPixels = pxPerSensorX / 2f,
)
return encodeJpeg(out)
}
@@ -52,6 +52,16 @@ object DeviceOrientation : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val gx = event.values[0]
val gy = event.values[1]
// Lying (nearly) flat: gx and gy are both ~0, so the grip angle is
// undefined and the hysteresis below would keep whatever pose was seen
// last. That is how the live screen ended up with all OSD text rotated 90
// deg while the phone sat flat on a desk. Flat means "read it as drawn":
// the composition is glued to the portrait frame, so 0 is the honest value.
// 4.0 m/s^2 ~= within 24 deg of horizontal.
if (kotlin.math.hypot(gx, gy) < FLAT_TILT_MS2) {
if (_deg.value != 0) _deg.value = 0
return
}
// world-up in device coords: (0,+g)=0 (-g,0)=90 (0,-g)=180 (+g,0)=270.
// hysteresis: only switch pose when the dominant axis clearly wins,
// so ~45 deg in-between holds keep the previous reading
@@ -64,4 +74,6 @@ object DeviceOrientation : SensorEventListener {
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private const val FLAT_TILT_MS2 = 4.0f
}
@@ -1,8 +1,19 @@
package com.mag160c.thermal.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** Shared UI inset state (px), written by AppRoot's nav overlay. */
object UiInsets {
/** Bottom navigation overlay height in px (portrait-locked app: constant). */
@Volatile
var navPx: Int = 0
/**
* Bottom navigation overlay height in px (portrait-locked app: constant).
*
* Observable, not a plain var: the full-screen overlays (analysis viewer,
* album photo viewer) place their own bottom panel above this bar, and a
* non-observable value read during composition would stay at whatever it was
* on the first frame — which is 0, leaving the analysis readouts behind the
* navigation bar where the user could not see them.
*/
var navPx: Int by mutableStateOf(0)
}
@@ -87,6 +87,7 @@ class AnalyzeViewModel(
_imageH.value = bmp.height
}
val counts = nucCounts
val recorded = parsed?.extremes ?: com.mag160c.thermal.media.Mdt.Extremes.NONE
var mn = Int.MAX_VALUE
var mx = Int.MIN_VALUE
var mnPos = -1
@@ -95,8 +96,14 @@ class AnalyzeViewModel(
if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i }
}
// Prefer the extremes the CAPTURE recorded. Re-scanning the NUC block
// gives a slightly different answer (a different pixel and a step of
// temperature, because the sensor drifts between capture and reload),
// which showed up on device as two "min" markers a few pixels apart.
if (recorded.minPos >= 0) mnPos = recorded.minPos
if (recorded.maxPos >= 0) mxPos = recorded.maxPos
val loaded = (parsed?.probes.orEmpty()).map { p ->
Probe(p.x, p.y, p.label, measure(p.x, p.y) ?: (p.tempMc / 1000f))
Probe(p.x, p.y, p.label, measureSensor(p.x, p.y) ?: (p.tempMc / 1000f))
}
withContext(Dispatchers.Main) {
_render.value = bmp
@@ -104,12 +111,22 @@ class AnalyzeViewModel(
probes.addAll(loaded)
if (counts != null) {
// mn/mx are NUC counts and must go through the temperature
// curve; dividing them directly showed 9 C for a 23 C scene
_minTempC.value = TempMath.countsToTempMc(mn) / 1000f
_maxTempC.value = TempMath.countsToTempMc(mx) / 1000f
// curve; dividing them directly showed 9 C for a 23 C scene.
// A recorded extreme is already a temperature (millidegrees),
// so it is used as-is — the two units must not be mixed.
_minTempC.value = if (recorded.minPos >= 0 && recorded.minMc != 0) {
recorded.minMc / 1000f
} else if (mn != Int.MAX_VALUE) {
TempMath.countsToTempMc(mn) / 1000f
} else null
_maxTempC.value = if (recorded.maxPos >= 0 && recorded.maxMc != 0) {
recorded.maxMc / 1000f
} else if (mx != Int.MIN_VALUE) {
TempMath.countsToTempMc(mx) / 1000f
} else null
_minPos.value = mnPos
_maxPos.value = mxPos
_centerTempC.value = measure(80, 60)
_centerTempC.value = measureSensor(80, 60)
}
com.mag160c.thermal.media.DebugLog.log(
"analyze",
@@ -121,11 +138,46 @@ class AnalyzeViewModel(
}
}
/** Mirror applied to the saved JPEG (from the container). */
val photoMirror: PhotoSaver.Mirror
get() {
val r = parsed?.render ?: Mdt.RenderParams.NONE
return PhotoSaver.Mirror(r.flipH, r.flipV)
}
/**
* Temperature (C) at a SENSOR pixel, from the stored NUC counts.
* Returns null when the photo has no NUC data — never a fabricated number.
* Sensor pixel -> photo pixel for the DISPLAYED image.
*
* The JPEG carries the user's mirror corrections while probes and the NUC grid
* stay in raw sensor space, so this conversion is what keeps markers sitting on
* the same spot the live screen showed. Ignoring it was the reported
* misalignment.
*/
fun measure(sx: Int, sy: Int): Float? {
private fun sensorToPhoto(px: Int, py: Int): Pair<Int, Int> {
val m = photoMirror
val w = _imageW.value
val h = _imageH.value
var u = (px + 0.5f) / 160f
var v = (py + 0.5f) / 120f
if (m.flipH) u = 1f - u
if (m.flipV) v = 1f - v
return ((u * w).toInt().coerceIn(0, w - 1)) to ((v * h).toInt().coerceIn(0, h - 1))
}
/** Inverse of [sensorToPhoto]: a photo pixel back to sensor coordinates. */
private fun photoToSensor(px: Int, py: Int): Pair<Int, Int> {
val m = photoMirror
val w = _imageW.value
val h = _imageH.value
var u = (px + 0.5f) / w
var v = (py + 0.5f) / h
if (m.flipH) u = 1f - u
if (m.flipV) v = 1f - v
return ((u * 160f).toInt().coerceIn(0, 159)) to ((v * 120f).toInt().coerceIn(0, 119))
}
/** Temperature (C) at a SENSOR pixel, from the stored NUC counts. */
fun measureSensor(sx: Int, sy: Int): Float? {
val counts = nucCounts ?: return null
if (sx < 0 || sy < 0 || sx >= 160 || sy >= 120) return null
val idx = sy * 160 + sx
@@ -133,18 +185,27 @@ class AnalyzeViewModel(
return TempMath.countsToTempMc(counts[idx]) / 1000f
}
/** Tap in canvas space -> sensor pixel; toggles a probe there. */
/** Temperature (C) at a pixel of the DISPLAYED photo. */
fun measure(px: Int, py: Int): Float? {
val s = photoToSensor(px, py)
return measureSensor(s.first, s.second)
}
/** Tap in canvas space -> SENSOR pixel; toggles a probe there. */
fun toggleProbeAt(
pos: androidx.compose.ui.geometry.Offset,
rect: androidx.compose.ui.geometry.Rect,
) {
if (rect.width <= 0f || rect.height <= 0f) return
if (pos.x < rect.left || pos.x > rect.right || pos.y < rect.top || pos.y > rect.bottom) return
// the photo has no rotation relative to the sensor: a uniform scale
val sx = ((pos.x - rect.left) / rect.width * 160f).toInt().coerceIn(0, 159)
val sy = ((pos.y - rect.top) / rect.height * 120f).toInt().coerceIn(0, 119)
val photoX = ((pos.x - rect.left) / rect.width * _imageW.value).toInt()
.coerceIn(0, _imageW.value - 1)
val photoY = ((pos.y - rect.top) / rect.height * _imageH.value).toInt()
.coerceIn(0, _imageH.value - 1)
val (sx, sy) = photoToSensor(photoX, photoY)
val thr = 6f // ~6 sensor pixels, matching the on-screen marker size
// markers are ~6 sensor pixels across on screen; match that when hit-testing
val thr = 6f
val hit = probes.indexOfFirst { p ->
val dx = (p.x - sx).toFloat()
val dy = (p.y - sy).toFloat()
@@ -154,7 +215,7 @@ class AnalyzeViewModel(
probes.removeAt(hit)
return
}
probes.add(Probe(sx, sy, "Pt${probes.size + 1}", measure(sx, sy) ?: 0f))
probes.add(Probe(sx, sy, "Pt${probes.size + 1}", measureSensor(sx, sy) ?: 0f))
}
/** Current probes as markers (sensor space), for burning into a saved copy. */
@@ -184,7 +245,7 @@ class AnalyzeViewModel(
}
viewModelScope.launch(Dispatchers.IO) {
val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92)
val annotated = PhotoSaver.annotateJpeg(jpg, probesAsMarks())
val annotated = PhotoSaver.annotateJpeg(jpg, probesAsMarks(), photoMirror)
val mdt = Mdt.compose(
jpg = annotated,
info0 = parsed?.info0,
@@ -196,6 +257,14 @@ class AnalyzeViewModel(
),
// carry the temperature data forward so the edited photo stays measurable
nucPixels = parsed?.nucPixels,
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
com.mag160c.thermal.media.Mdt.RenderParams(photoMirror.flipH, photoMirror.flipV),
),
// the extremes are burned into the pixels already; keep the block so
// the next reader still knows where they were
extremes = parsed?.extremes?.takeIf {
com.mag160c.thermal.media.Mdt.Extremes.hasAny(it)
}?.let { com.mag160c.thermal.media.Mdt.encodeExtremes(it) },
)
val name = "MAG160C_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
.format(java.util.Date())}_edit.jpg"
@@ -219,6 +288,12 @@ class AnalyzeViewModel(
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
),
nucPixels = parsed?.nucPixels,
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
com.mag160c.thermal.media.Mdt.RenderParams(photoMirror.flipH, photoMirror.flipV),
),
extremes = parsed?.extremes?.takeIf {
com.mag160c.thermal.media.Mdt.Extremes.hasAny(it)
}?.let { com.mag160c.thermal.media.Mdt.encodeExtremes(it) },
)
val ok = runCatching {
val ctx = getApplication<Application>()
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.media.PhotoSaver
import com.mag160c.thermal.ui.gallery.GalleryViewModel
import java.util.Locale
@@ -92,7 +93,18 @@ fun AnalyzeViewer(
// back gesture (reported on device).
androidx.activity.compose.BackHandler(enabled = true) { onClose() }
Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) {
// The viewer is a full-screen overlay ABOVE the navigation bar, so it must
// reserve the bar's height itself — otherwise the measurement panel ends up
// behind the bar and the readouts are invisible (reported on device).
val navPad = with(androidx.compose.ui.platform.LocalDensity.current) {
com.mag160c.thermal.ui.UiInsets.navPx.toDp()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF101014))
.padding(bottom = navPad),
) {
// ---- slim title row ----
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
@@ -159,12 +171,31 @@ fun AnalyzeViewer(
),
dstSize = IntSize(rect.width.toInt(), rect.height.toInt()),
)
// extremes, only when the photo carries temperature data
// extremes and probes go into ONE painter call: the painter
// avoids putting a label on top of an earlier one, and that only
// works if it sees every marker at once (drawing the extremes in
// separate calls let "max" and "min" land on each other here).
val marks = ArrayList<com.mag160c.thermal.media.MarkerPainter.Mark>(4)
if (vm.hasTemperatureData) {
drawExtreme(vm.maxPos, rect, vm.imageW, vm.imageH, "")
drawExtreme(vm.minPos, rect, vm.imageW, vm.imageH, "")
sensorMark(vm.maxPos, rect, vm.photoMirror, "max", vm.maxTempC)?.let { marks.add(it) }
sensorMark(vm.minPos, rect, vm.photoMirror, "min", vm.minTempC)?.let { marks.add(it) }
}
marks.addAll(probeMarks(vm.probes, rect, vm.photoMirror))
if (marks.isNotEmpty()) {
// AnnotSpec units are calibrated for a 320-wide frame =
// 2 sensor pixels, so the display scale factor is half the
// per-sensor-pixel size.
val k = (rect.width / 160f) / 2f
drawIntoCanvas { c ->
com.mag160c.thermal.media.MarkerPainter.draw(
canvas = c.nativeCanvas,
marks = marks,
imgW = size.width,
imgH = size.height,
imageUnitsToPixels = k,
)
}
}
drawProbes(vm.probes, rect, vm.imageW, vm.imageH)
}
}
}
@@ -284,106 +315,71 @@ private fun imageRect(
}
/**
* Marker geometry on screen. Delegates to [com.mag160c.thermal.core.AnnotSpec] so
* the on-screen marker has the SAME size and label placement as the burned-in
* photo and video markers (previously each surface had its own numbers, which is
* why a probe looked different in the photo than on the screen).
* Markers for the analysis view.
*
* Delegates to [com.mag160c.thermal.media.MarkerPainter] — the very same painter
* that burns markers into saved photos and recorded video frames — so a probe is
* rendered identically everywhere. Hand-drawing here is what made the on-screen
* marker differ from the one in the file.
*
* Positions are converted from sensor space through the photo's mirror so the
* marker sits on the same feature the photo shows. The marks are returned rather
* than drawn so the caller can hand the painter ALL of them at once, which is
* what lets it keep their labels from overlapping.
*/
private fun DrawScope.drawProbes(
private fun probeMarks(
probes: List<AnalyzeViewModel.Probe>,
rect: androidx.compose.ui.geometry.Rect,
imageW: Int,
imageH: Int,
) {
if (probes.isEmpty()) return
val spec = com.mag160c.thermal.core.AnnotSpec
// sensor pixel -> screen pixel, then AnnotSpec units are based on a 320-wide
// frame (2 sensor units), hence the /2
val pxPerSensor = rect.width / 160f
val k = pxPerSensor / 2f
val paint = android.graphics.Paint().apply {
color = spec.LABEL_FG
textSize = spec.TEXT_SIZE * k
typeface = android.graphics.Typeface.create(
android.graphics.Typeface.SANS_SERIF, android.graphics.Typeface.BOLD,
mirror: PhotoSaver.Mirror,
): List<com.mag160c.thermal.media.MarkerPainter.Mark> =
probes.map { p ->
val (u, v) = sensorToDisplay(p.x, p.y, mirror)
com.mag160c.thermal.media.MarkerPainter.Mark(
x = rect.left + u * rect.width,
y = rect.top + v * rect.height,
label = p.label,
tempC = p.tempC,
)
isAntiAlias = true
}
val boxPaint = android.graphics.Paint().apply {
color = spec.LABEL_BG
isAntiAlias = true
}
val dot = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
isAntiAlias = true
setShadowLayer(spec.SHADOW * k, 0f, 0f, android.graphics.Color.BLACK)
}
val ring = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
style = android.graphics.Paint.Style.STROKE
strokeWidth = spec.RING_W * k
isAntiAlias = true
setShadowLayer(spec.SHADOW * k, 0f, 0f, android.graphics.Color.BLACK)
}
for (p in probes) {
val cx = rect.left + (p.x + 0.5f) * pxPerSensor
val cy = rect.top + (p.y + 0.5f) * pxPerSensor
drawCircle(Color.White, spec.DOT_R * k, Offset(cx, cy))
drawCircle(Color.White, spec.RING_R * k, Offset(cx, cy), style = Stroke(spec.RING_W * k))
drawIntoCanvas { c -> c.nativeCanvas.drawCircle(cx, cy, spec.RING_R * k, ring) }
val label = "${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}"
val fm = paint.fontMetrics
val tw = paint.measureText(label)
val boxW = tw + spec.LABEL_PAD_H * 2 * k
val boxH = (fm.descent - fm.ascent) + spec.LABEL_PAD_V * 2 * k
val pos = spec.placeLabel(
cx, cy, boxW, boxH, size.width, size.height, k,
)
drawIntoCanvas { c ->
c.nativeCanvas.drawRoundRect(
android.graphics.RectF(pos[0], pos[1], pos[0] + boxW, pos[1] + boxH),
2f * k, 2f * k, boxPaint,
)
c.nativeCanvas.drawText(
label, pos[0] + spec.LABEL_PAD_H * k,
pos[1] + spec.LABEL_PAD_V * k - fm.ascent, paint,
)
}
}
}
/** Small ring marking an overall extreme (max/min) position. */
private fun DrawScope.drawExtreme(
/**
* Overall max/min marker: the same glyph as a probe, tinted, labelled "max"/"min"
* (the user asked for these exact names rather than the previous 高/低 wording).
*/
private fun sensorMark(
pos: Int,
rect: androidx.compose.ui.geometry.Rect,
imageW: Int,
imageH: Int,
mirror: PhotoSaver.Mirror,
label: String,
) {
if (pos < 0) return
tempC: Float?,
): com.mag160c.thermal.media.MarkerPainter.Mark? {
if (pos < 0 || tempC == null) return null
val sx = pos % 160
val sy = pos / 160
if (sy >= 120) return
val spec = com.mag160c.thermal.core.AnnotSpec
val pxPerSensor = rect.width / 160f
val k = pxPerSensor / 2f
val cx = rect.left + (sx + 0.5f) * pxPerSensor
val cy = rect.top + (sy + 0.5f) * pxPerSensor
drawCircle(
Color(spec.EXTREME_TINT), spec.RING_R * 0.9f * k, Offset(cx, cy),
style = Stroke(spec.RING_W * k),
if (sy >= 120) return null
val (u, v) = sensorToDisplay(sx, sy, mirror)
return com.mag160c.thermal.media.MarkerPainter.Mark(
x = rect.left + u * rect.width,
y = rect.top + v * rect.height,
label = label,
tempC = tempC,
tint = com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
)
val paint = android.graphics.Paint().apply {
color = spec.EXTREME_TINT
textSize = spec.TEXT_SIZE * k
isAntiAlias = true
setShadowLayer(spec.SHADOW * k, 0f, 0f, android.graphics.Color.BLACK)
}
drawIntoCanvas { c ->
c.nativeCanvas.drawText(
label, cx + spec.RING_R * 1.2f * k, cy - spec.RING_R * 0.4f * k, paint,
)
}
}
/**
* Sensor pixel -> normalised (0..1) position in the DISPLAYED photo, applying the
* mirror the capture baked into the file. The pixel CENTRE is used (sx + 0.5), so
* a marker lands on the middle of the sensor pixel rather than its corner — the
* half-pixel offset is what made analysis markers look shifted by up to a pixel
* against the burned-in ones.
*/
private fun sensorToDisplay(sx: Int, sy: Int, mirror: PhotoSaver.Mirror): Pair<Float, Float> {
var u = (sx + 0.5f) / 160f
var v = (sy + 0.5f) / 120f
if (mirror.flipH) u = 1f - u
if (mirror.flipV) v = 1f - v
return u to v
}
/** Current canvas size, captured for the tap handler. */
@@ -9,6 +9,7 @@ import android.os.SystemClock
import android.view.SurfaceHolder
import android.view.SurfaceView
import com.mag160c.thermal.core.AnnotSpec
import java.util.Locale
/**
* Software canvas renderer for the live IR stream.
@@ -29,6 +30,31 @@ class LiveRenderer(
) : SurfaceHolder.Callback, Runnable {
private var thread: Thread? = null
private val running = java.util.concurrent.atomic.AtomicBoolean(false)
/**
* Hardware canvas when the platform has one, software otherwise.
*
* This is THE stutter fix. `SurfaceHolder.lockCanvas()` hands out a software
* canvas, so every frame upscaled the 320x240 image to the fitted rect
* (~810x1080) with a CPU bilinear filter and cleared a ~10 MB buffer by hand —
* measured on the device: 6.5 paints/second at 134 ms each, i.e. the render
* thread threw away more than half of the camera's 15 fps before the user ever
* saw them. A hardware canvas does the same scale-up on the GPU (and the
* surface clear for free), leaving the CPU with only the 76800-pixel copy.
*
* `lockHardwareCanvas` exists from API 29; on older devices the software path
* is kept, which is correct, just slower.
*/
private val useHardwareCanvas = android.os.Build.VERSION.SDK_INT >= 29
private fun lockCanvas(holder: SurfaceHolder): Canvas? {
if (useHardwareCanvas) {
val hw = runCatching { holder.lockHardwareCanvas() }.getOrNull()
if (hw != null) return hw
}
return runCatching { holder.lockCanvas() }.getOrNull()
}
private val density = surfaceView.resources.displayMetrics.density
private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
@@ -44,6 +70,26 @@ class LiveRenderer(
}
private val viewport = android.graphics.RectF()
/**
* Scratch buffers owned by the render thread.
*
* Per-frame allocation is what makes a stream feel "卡": at 15 fps a fresh
* 320x240 IntArray is 4.6 MB/s of garbage, and the collector's pauses land
* exactly on the frames the user is watching. The flipped frame, the marker
* list and the colour-bar segments are all recycled instead.
*/
private val flipped = IntArray(320 * 240)
private val marks = ArrayList<com.mag160c.thermal.media.MarkerPainter.Mark>(8)
private val barPaint = Paint()
private val srcRect = android.graphics.Rect()
private val dstRect = android.graphics.RectF()
private var barBitmap: Bitmap? = null
private var barPaletteIdx = -1
private companion object {
const val BAR_SEGMENTS = 96
}
/** OSD text compensation: pre-rotation so labels are upright in the current grip. */
private val textRot: Float
get() = -com.mag160c.thermal.ui.DeviceOrientation.deg.value.toFloat()
@@ -74,6 +120,10 @@ class LiveRenderer(
val holder = surfaceView.holder
var lastPainted = 0L
var lastPaintMs = 0L
var paints = 0
var paintT0 = SystemClock.elapsedRealtime()
var paintMsTotal = 0L
var worstPaintMs = 0L
while (running.get()) {
// Skip frames when nothing new arrived: the camera runs at 15 fps and
// the loop used to paint at 30 fps regardless, doubling the canvas work
@@ -87,21 +137,41 @@ class LiveRenderer(
}
lastPainted = newest
lastPaintMs = SystemClock.elapsedRealtime()
val canvas = holder.lockCanvas()
val canvas = lockCanvas(holder)
if (canvas == null) {
// surface not ready (or being resized): do NOT spin on it
Thread.sleep(16)
continue
}
val t0 = SystemClock.elapsedRealtime()
try {
drawFrame(canvas)
} finally {
holder.unlockCanvasAndPost(canvas)
}
try {
Thread.sleep(16)
} catch (_: InterruptedException) {
val dt = SystemClock.elapsedRealtime() - t0
paints++
paintMsTotal += dt
if (dt > worstPaintMs) worstPaintMs = dt
val now = SystemClock.elapsedRealtime()
if (now - paintT0 >= 5000) {
// The render thread is where stutter is visible; without this the
// only signal was dumpsys gfxinfo, which does not see lockCanvas
// paints at all (it counted 28 frames while the stream ran at 15 fps).
com.mag160c.thermal.media.DebugLog.log(
"render",
"paints=${paints} in ${now - paintT0} ms " +
"avg=%.1f ms worst=$worstPaintMs ms".format(Locale.US, paintMsTotal.toFloat() / paints),
)
paints = 0
paintMsTotal = 0
worstPaintMs = 0
paintT0 = now
}
// No extra sleep here: the gate at the top of the loop already parks
// the thread until a new frame arrives, so sleeping again only added
// latency between the camera producing a frame and it reaching the
// screen.
}
}
@@ -129,10 +199,11 @@ class LiveRenderer(
val zoom = vm.state.value.zoom
val crop = ImageTransform.cropForZoom(zoom)
val srcRect = if (zoom > 1) {
val src: android.graphics.Rect? = if (zoom > 1) {
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)
srcRect.set(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
srcRect
} else null
val size = if (ImageTransform.swapped(params.rotDeg)) {
@@ -142,12 +213,12 @@ class LiveRenderer(
}
canvas.save()
canvas.rotate(params.rotDeg.toFloat(), fit.cx, fit.cy)
val dst = android.graphics.RectF(
dstRect.set(
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)
if (src != null) canvas.drawBitmap(bitmap, src, dstRect, paint)
else canvas.drawBitmap(bitmap, null, dstRect, paint)
canvas.restore()
drawOsd(canvas, vm.state.value)
@@ -169,7 +240,6 @@ class LiveRenderer(
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
@@ -184,52 +254,51 @@ class LiveRenderer(
}
/**
* One temperature marker. Geometry comes from [AnnotSpec] — the same numbers
* the photo, video and analysis markers use — converted from image units to
* screen pixels via the displayed image's scale, so a probe looks identical
* everywhere.
* Draw every marker through the shared [MarkerPainter], the same one that
* burns markers into saved photos and video. Using it here (rather than
* hand-drawing) is what makes a saved photo look like a screenshot of the live
* view — same dot, same ring, same label box, same placement rule.
*
* The labels are rotated by the grip angle so they stay upright on the rotated
* screen; the glyphs are not rotated.
*/
private fun drawTempMarker(
canvas: Canvas,
sx: Int,
sy: Int,
tempC: Float?,
label: String?,
tint: Int? = null,
) {
if (sx < 0 || sy < 0 || tempC == null) return
val p = vm.probeToScreen(sx, sy)
val cx = p[0]
val cy = p[1]
// viewport width / 320 = how many screen px one AnnotSpec unit spans
val k = viewport.width() / AnnotSpec.REF_W
markerPaint.color = tint ?: Color.WHITE
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(cx, cy, AnnotSpec.DOT_R * k, markerPaint)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = AnnotSpec.RING_W * k
canvas.drawCircle(cx, cy, AnnotSpec.RING_R * k, markerPaint)
markerPaint.style = Paint.Style.FILL
markerPaint.color = Color.WHITE
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
val half = gripTextHalf(text)
val gap = AnnotSpec.labelOffsetX(k)
// 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(gap, -gap)
var best: FloatArray? = null
for (off in candidates) {
val c = offsetInTextFrame(cx, cy, off, 0f)
val fits = c[0] - half[0] - 2f * k >= viewport.left &&
c[0] + half[0] + 2f * k <= viewport.right &&
c[1] - half[1] >= viewport.top &&
c[1] + half[1] <= viewport.bottom
if (fits) { best = c; break }
if (best == null) best = c
private fun drawMarkers(canvas: Canvas, state: LiveViewModel.LiveState) {
marks.clear()
for (p in state.probes) {
val t = p.tempC ?: continue
val scr = vm.probeToScreen(p.x, p.y)
marks.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
scr[0], scr[1], p.label, t,
),
)
}
val c = best!!
drawGripText(canvas, text, c[0], c[1])
if (state.traceMode.showsMax && state.maxPos >= 0 && state.maxTempC != null) {
val scr = vm.probeToScreen(state.maxPos % 160, state.maxPos / 160)
marks.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
scr[0], scr[1], "max", state.maxTempC, AnnotSpec.EXTREME_TINT,
),
)
}
if (state.traceMode.showsMin && state.minPos >= 0 && state.minTempC != null) {
val scr = vm.probeToScreen(state.minPos % 160, state.minPos / 160)
marks.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
scr[0], scr[1], "min", state.minTempC, AnnotSpec.EXTREME_TINT,
),
)
}
if (marks.isEmpty()) return
com.mag160c.thermal.media.MarkerPainter.draw(
canvas = canvas,
marks = marks,
imgW = canvas.width.toFloat(),
imgH = canvas.height.toFloat(),
// viewport width / 320 = screen pixels per AnnotSpec unit
imageUnitsToPixels = viewport.width() / AnnotSpec.REF_W,
textRotationDeg = textRot,
)
}
/** Rotate a text-local offset into buffer space and add it to an anchor. */
@@ -261,19 +330,27 @@ class LiveRenderer(
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]
val barW = 20f * density
val barH = viewport.height() * 0.8f
val x = viewport.right - barW - 12f * density
val y0 = viewport.top + (viewport.height() - barH) / 2f
val seg = Paint()
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
canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg)
// The strip is a 1x96 bitmap stretched to the bar rect, rebuilt only when
// the palette changes. Drawing 96 rects every frame was pure overhead on
// the render thread.
if (barPaletteIdx != state.paletteIndex || barBitmap == null) {
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
val bmp = Bitmap.createBitmap(1, BAR_SEGMENTS, Bitmap.Config.ARGB_8888)
val px = IntArray(BAR_SEGMENTS)
for (i in 0 until BAR_SEGMENTS) {
px[i] = pal[255 - i * 255 / (BAR_SEGMENTS - 1)]
}
bmp.setPixels(px, 0, 1, 0, 0, 1, BAR_SEGMENTS)
barBitmap = bmp
barPaletteIdx = state.paletteIndex
}
barBitmap?.let {
srcRect.set(0, 0, 1, BAR_SEGMENTS)
canvas.drawBitmap(it, srcRect, android.graphics.RectF(x, y0, x + barW, y0 + barH), paint)
}
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
@@ -301,23 +378,9 @@ class LiveRenderer(
val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
drawGripText(canvas, text, cx, cy)
}
// the trace setting chooses which extremes to mark (max / min / both);
// tinted via AnnotSpec so they read the same as in the saved photo
if (state.traceMode.showsMax) {
drawTempMarker(
canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "",
AnnotSpec.EXTREME_TINT,
)
}
if (state.traceMode.showsMin) {
drawTempMarker(
canvas, state.minPos % 160, state.minPos / 160, state.minTempC, "",
AnnotSpec.EXTREME_TINT,
)
}
for (p in state.probes) {
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
}
// probes and the max/min extremes, all through the shared painter so the
// saved photo matches this screen exactly
drawMarkers(canvas, state)
drawColorBar(canvas, state)
}
}
@@ -95,16 +95,17 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
LaunchedEffect(Unit) {
vm.connect()
vm.uiBottomPx = navPx + shutterPx
// Seed the renderer with the persisted orientation settings once; from
// then on AppSettings publishes changes, so the settings tab applies
// immediately instead of being picked up by this loop (which only runs
// while the live tab is composed).
// Seed the renderer with the persisted settings once; from then on
// AppSettings publishes changes, so the settings tab applies immediately.
com.mag160c.thermal.ui.settings.ImageOrientationSettings.publishFrom(context)
// Temperature/OSD updates run on a background dispatcher inside the view
// model: doing that work here (main thread) every 400 ms was the visible
// stutter on the device (7.3% janky frames, 60-700 ms tail).
vm.startTemperatureLoop()
while (true) {
kotlinx.coroutines.delay(400)
navPx = UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
vm.refreshTemps()
}
}
// Apply orientation / palette / trace-mode changes the moment they happen
@@ -116,6 +117,7 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
vm.flipH = settings.flipH
vm.flipV = settings.flipV
vm.setTraceMode(settings.traceMode)
vm.applyEnhanceLevel(settings.enhanceLevel)
// palette: only re-apply when the user picks a different default, so a
// temporary change from the live control bar is not stomped every frame
if (vm.state.value.paletteIndex != settings.paletteIndex) {
@@ -53,6 +53,16 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
}
}
/** Detail enhancement level 0..4 (vendor strength = level shl 3). */
@Volatile
var enhanceLevel: Int = 0
/** Apply the enhancement level to the running pipeline. */
fun applyEnhanceLevel(level: Int) {
enhanceLevel = level.coerceIn(0, 4)
session.setEnhanceStrength(enhanceLevel shl 3)
}
/** Which extremes the trace markers display (settings choice). */
enum class TraceMode {
MAX, MIN, BOTH, NONE;
@@ -145,15 +155,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
com.mag160c.thermal.media.DebugLog.log(
"vm", "next connect in ${delayMs} ms (failures=$connectFailures)",
)
// Exactly one retry may be pending at a time. Retrying is what
// recovers a camera that rebooted itself; without it the UI sat
// on "no_handshake" forever after a single failure.
if (retryJob?.isActive != true) {
retryJob = viewModelScope.launch {
kotlinx.coroutines.delay(delayMs)
if (!session.isStreaming()) connect()
}
}
// Exactly one retry is pending at any time; the newest delay
// replaces an older one. Retrying is what recovers a camera that
// rebooted itself; without it the UI sat on "no_handshake"
// forever after a single failure.
scheduleRetry(delayMs)
}
IrSession.State.IDLE -> {
connectInFlightUi = false
@@ -337,6 +343,24 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
*/
private var retryJob: kotlinx.coroutines.Job? = null
/**
* Queue the one pending reconnect. The newest request always wins (an ERROR
* backoff replaces an earlier short retry), so [retryJob] is a single slot
* rather than a growing set of timers. The previous job is cancelled AFTER the
* replacement is created, because this can be called from inside a running
* retry's own connect() — cancelling first would kill the caller before the new
* timer exists, which is exactly the dead end this replaces.
*/
private fun scheduleRetry(delayMs: Long) {
val previous = retryJob
val job = viewModelScope.launch {
kotlinx.coroutines.delay(delayMs)
if (!session.isStreaming()) connect()
}
retryJob = job
previous?.cancel()
}
/**
* Serialises connect attempts for the whole process.
*
@@ -381,15 +405,27 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
fun connect() {
val now = android.os.SystemClock.elapsedRealtime()
if (now - lastConnectMs < 800) {
com.mag160c.thermal.media.DebugLog.log("vm", "connect() debounced")
// Defer, never drop. Dropping was a real dead end on the device: the
// camera re-enumerates right after the app starts (the previous owner
// of the interface died), so the FIRST connect finds no device and
// reports no_device, and the ATTACHED broadcast that lands 200 ms later
// fell inside this window and was thrown away — the app then sat on
// "no camera" until it was restarted by hand.
val remaining = 800 - (now - lastConnectMs)
com.mag160c.thermal.media.DebugLog.log("vm", "connect() debounced, retry in ${remaining} ms")
if (!session.isStreaming()) scheduleRetry(remaining + 50)
return
}
if (now < nextConnectAllowedMs) {
val wait = nextConnectAllowedMs - now
com.mag160c.thermal.media.DebugLog.log(
"vm",
"connect() backed off for ${(nextConnectAllowedMs - now)} ms " +
"(failures=$connectFailures)",
"connect() backed off for ${wait} ms (failures=$connectFailures)",
)
// Same reasoning as the debounce above: a request that arrives inside
// the backoff window is pending work, not noise, so it is rescheduled
// instead of discarded. A success resets the backoff (see the listener).
if (!session.isStreaming()) scheduleRetry(wait + 50)
return
}
lastConnectMs = now
@@ -443,6 +479,13 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
status = "no_device",
)
releaseConnectLock(generation)
// A missing device is usually transient: the camera is mid
// re-enumeration (it drops off the bus for ~1 s when the previous
// holder releases the interface, and again right after a handshake
// failure). Without a retry the app stayed on "no camera" forever;
// the ATTACHED broadcast is not a reliable second chance because it
// can arrive inside the connect debounce window.
scheduleRetry(1500)
return
}
transport.requestPermission { ok ->
@@ -541,14 +584,43 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
}
/** Current probes as markers, for burning into photos and video frames. */
private fun probesAsMarks(): List<com.mag160c.thermal.media.MarkerPainter.Mark> =
_state.value.probes.mapNotNull { p ->
p.tempC?.let {
/**
* Every marker the live screen is showing, in SENSOR coordinates (the space the
* recorded 320x240 frame uses): the user's probes plus the traced extremes when
* the trace setting asks for them. A recording therefore carries the same
* readouts as the screen it was made from.
*/
private fun probesAsMarks(): List<com.mag160c.thermal.media.MarkerPainter.Mark> {
val st = _state.value
val out = ArrayList<com.mag160c.thermal.media.MarkerPainter.Mark>(
st.probes.size + 2,
)
for (p in st.probes) {
val t = p.tempC ?: continue
out.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
p.x.toFloat(), p.y.toFloat(), p.label, it,
)
}
p.x.toFloat(), p.y.toFloat(), p.label, t,
),
)
}
if (st.traceMode.showsMax && st.maxPos >= 0 && st.maxTempC != null) {
out.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
(st.maxPos % 160).toFloat(), (st.maxPos / 160).toFloat(),
"max", st.maxTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
),
)
}
if (st.traceMode.showsMin && st.minPos >= 0 && st.minTempC != null) {
out.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
(st.minPos % 160).toFloat(), (st.minPos / 160).toFloat(),
"min", st.minTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
),
)
}
return out
}
/**
* Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore.
@@ -584,7 +656,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
extremes.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
(st.maxPos % 160).toFloat(), (st.maxPos / 160).toFloat(),
"", st.maxTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
"max", st.maxTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
),
)
}
@@ -592,7 +664,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
extremes.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
(st.minPos % 160).toFloat(), (st.minPos / 160).toFloat(),
"", st.minTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
"min", st.minTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
),
)
}
@@ -629,6 +701,20 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
nucPixels = if (haveNuc) {
com.mag160c.thermal.media.PhotoSaver.packNucForPhoto(nuc160)
} else null,
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
com.mag160c.thermal.media.Mdt.RenderParams(mirror.flipH, mirror.flipV),
),
// The extremes are recorded as the capture saw them, so the analysis
// screen draws the SAME marker as the one burned into the JPEG instead
// of re-deriving a slightly different one from the NUC block.
extremes = com.mag160c.thermal.media.Mdt.encodeExtremes(
com.mag160c.thermal.media.Mdt.Extremes(
minPos = if (tm.showsMin) st.minPos else -1,
maxPos = if (tm.showsMax) st.maxPos else -1,
minMc = if (tm.showsMin && st.minTempC != null) (st.minTempC * 1000f).toInt() else 0,
maxMc = if (tm.showsMax && st.maxTempC != null) (st.maxTempC * 1000f).toInt() else 0,
),
),
)
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
@@ -765,8 +851,42 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
}
}
/** Update per-frame temperature stats (called on a slow timer). */
fun refreshTemps() {
/** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */
private val nucScratch = IntArray(19200)
/** True while the background temperature loop runs. */
private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false)
/**
* Start the temperature/OSD update loop.
*
* It runs on [Dispatchers.Default] and only touches Compose state at the end:
* the previous version did the whole job on the MAIN thread (it was driven by a
* LaunchedEffect), which meant a 19200-element allocation, a full pixel scan and
* `copyNuc` — that call takes the pipeline lock the reader thread is holding —
* every 400 ms. On the device that showed up as 7.3% janky frames with a tail of
* 60-700 ms frames: the visible stutter.
*/
fun startTemperatureLoop() {
if (!tempLoopStarted.compareAndSet(false, true)) return
viewModelScope.launch(Dispatchers.Default) {
while (true) {
kotlinx.coroutines.delay(400)
try {
computeAndPublishTemps()
} catch (e: Exception) {
com.mag160c.thermal.media.DebugLog.log("vm", "temp loop error: $e")
}
}
}
}
/**
* Heavy part of the temperature update, safe to call from a background thread:
* reads the pipeline once, scans the counts, then publishes the result to the UI
* state on the main dispatcher.
*/
private suspend fun computeAndPublishTemps() {
if (uiTick++ % 12 == 0) {
// ~5 s heartbeat: what the UI currently sees (debug round 11)
com.mag160c.thermal.media.DebugLog.log(
@@ -781,49 +901,55 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
// 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(centerMc, nuc)
}
if (!session.copyNuc(nucScratch)) return
private var uiTick = 0
/**
* @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) {
// scan off the main thread: 19200 iterations plus probe conversions
var mn = Int.MAX_VALUE
var mx = -1
var mnPos = -1
var mxPos = -1
for (i in nuc.indices) {
val v = nuc[i]
if (v < mn) {
mn = v
mnPos = i
}
if (v > mx) {
mx = v
mxPos = i
for (i in nucScratch.indices) {
val v = nucScratch[i]
if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i }
}
val probeTemps = _state.value.probes.map { p ->
(p.x + p.y * 160).let { idx ->
if (idx in nucScratch.indices) TempMath.countsToTempMc(nucScratch[idx]) / 1000f
else 0f
}
}
val probes = _state.value.probes.map { p ->
p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f)
val minT = if (mn != Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null
val maxT = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null
val centerT = centerMc?.let { it / 1000f }
withContext(Dispatchers.Main) {
val probes = _state.value.probes.mapIndexed { i, p ->
p.copy(tempC = probeTemps.getOrElse(i) { p.tempC ?: 0f })
}
_state.value = _state.value.copy(
centerTempC = centerT,
maxTempC = maxT,
minTempC = minT,
maxPos = mxPos,
minPos = mnPos,
probes = probes,
)
}
_state.value = _state.value.copy(
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,
minPos = mnPos,
probes = probes,
)
}
/** One-shot temperature refresh (kept for callers that need it immediately). */
fun refreshTemps() {
viewModelScope.launch(Dispatchers.Default) {
try {
computeAndPublishTemps()
} catch (_: Exception) {
}
}
}
private var uiTick = 0
override fun onCleared() {
remoteHost.stop()
session.destroy()
@@ -23,6 +23,8 @@ object ImageOrientationSettings {
val paletteIndex: Int = 2,
val traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode =
com.mag160c.thermal.ui.live.LiveViewModel.TraceMode.BOTH,
/** Detail enhancement level 0..4 (see AppSettings.enhanceLevel). */
val enhanceLevel: Int = 0,
)
private val _state = MutableStateFlow(State())
@@ -34,8 +36,9 @@ object ImageOrientationSettings {
flipV: Boolean,
paletteIndex: Int = _state.value.paletteIndex,
traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode = _state.value.traceMode,
enhanceLevel: Int = _state.value.enhanceLevel,
) {
_state.value = State(rotateDeg, flipH, flipV, paletteIndex, traceMode)
_state.value = State(rotateDeg, flipH, flipV, paletteIndex, traceMode, enhanceLevel)
}
/** Read persisted values and publish them (called when settings load). */
@@ -43,7 +46,7 @@ object ImageOrientationSettings {
val s = AppSettings(context)
publish(
s.imageRotateDeg, s.imageFlipH, s.imageFlipV,
s.defaultPaletteIndex, s.traceMode,
s.defaultPaletteIndex, s.traceMode, s.enhanceLevel,
)
}
}
@@ -72,6 +75,24 @@ class AppSettings(context: Context) {
)
}
/**
* Local 7x7 detail enhancement strength (vendor FilterDetailEnhancement).
* 0 = off; the vendor uses `level shl 3` for levels 0..4.
*
* Defaults to OFF because the port has no official reference output to verify
* against (the byte-exact baseline predates this stage) — see DetailEnhance.
* Exposed as the "图像增强" setting so the effect can be compared on a device.
*/
var enhanceLevel: Int
get() = sp.getInt("enhanceLevel", 0)
set(v) {
val n = v.coerceIn(0, 4)
sp.edit().putInt("enhanceLevel", n).apply()
ImageOrientationSettings.publish(
imageRotateDeg, imageFlipH, imageFlipV, enhanceLevel = n,
)
}
var defaultEmissivityPercent: Int
get() = sp.getInt("emissivity", 100)
set(v) = sp.edit().putInt("emissivity", v).apply()
@@ -129,7 +150,7 @@ class AppSettings(context: Context) {
// seed the observable with the persisted settings, so a fresh process
// starts with what the user chose (not the hard-coded defaults)
ImageOrientationSettings.publish(
imageRotateDeg, imageFlipH, imageFlipV, defaultPaletteIndex, traceMode,
imageRotateDeg, imageFlipH, imageFlipV, defaultPaletteIndex, traceMode, enhanceLevel,
)
}
}
@@ -45,6 +45,8 @@ fun SettingsScreen(
var language by remember { mutableStateOf(settings.language) }
// trace mode (max / min / both / off)
var traceMode by remember { mutableStateOf(settings.traceMode) }
// detail enhancement level 0..4 (0 = off)
var enhanceLevel by remember { mutableStateOf(settings.enhanceLevel) }
// remote-preview server toggle (Phase F); off by default, needs live USB
var remoteOn by remember { mutableStateOf(remoteHostRunning) }
var showNeedDevice by remember { mutableStateOf(false) }
@@ -78,6 +80,9 @@ fun SettingsScreen(
) { dialog = "language" }
// manual orientation corrections (the official app has the same three)
SettingRow("旋转USB画面", "$rotateDeg°") { dialog = "rotate" }
SettingRow("图像增强", if (enhanceLevel == 0) "关闭" else "${enhanceLevel}") {
dialog = "enhance"
}
SettingRow("水平翻转", if (flipH) "已开启" else "已关闭") {
flipH = !flipH
settings.imageFlipH = flipH
@@ -235,6 +240,37 @@ fun SettingsScreen(
},
confirmButton = {},
)
"enhance" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("图像增强") },
text = {
Column {
Text(
"加强局部细节(官方同款 7×7 局部映射)。等级越高细节越明显," +
"噪声也会更明显。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
listOf(0 to "关闭", 1 to "1 级", 2 to "2 级", 3 to "3 级", 4 to "4 级")
.forEach { (lvl, label) ->
Text(
label,
color = if (lvl == enhanceLevel) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
enhanceLevel = lvl
settings.enhanceLevel = lvl
dialog = null
}
.padding(12.dp),
)
}
}
},
confirmButton = {},
)
"trace" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("追踪标记") },
@@ -126,6 +126,14 @@ class IrSession(context: Context) {
var lastInfo1: ByteArray? = null
private set
/**
* Detail enhancement strength requested before a pipeline existed (the
* pipeline is built during connect, so a setting change made while
* disconnected would otherwise be lost).
*/
@Volatile
private var pendingEnhanceStrength: Int = 0
/** Device power-on lifetime in ms (official getDevLifeTime); -1 = unknown. */
@Volatile
var deviceLifetimeMs: Long = -1
@@ -293,6 +301,9 @@ class IrSession(context: Context) {
epOut, epResp, "FFC($param)",
)
},
// the user's detail-enhancement choice must survive a reconnect, so it
// is applied at construction rather than only when the setting changes
enhanceStrength = pendingEnhanceStrength,
)
if (!pipe.loadDdt(ddt)) {
DebugLog.log("session", "ddt_fail (fetched/bundled ${ddt.size} B not loadable)")
@@ -689,6 +700,12 @@ class IrSession(context: Context) {
pipeline?.setPalette(index)
}
/** Detail enhancement strength (0 = off); applied to the live pipeline. */
fun setEnhanceStrength(strength: Int) {
pendingEnhanceStrength = strength
pipeline?.setEnhanceStrength(strength)
}
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
@@ -0,0 +1,116 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Label placement is what keeps a marker readable: the box must stay inside the
* image and must not cover a label that is already there. Both failures were
* observed on a real photo — "max" printed straight through "min" — so they are
* pinned here.
*/
class AnnotSpecPlaceLabelTest {
private val scale = 1f
private fun boxW() = 60f
private fun boxH() = 14f
@Test
fun labelSitsRightOfTheMarkerWhenThereIsRoom() {
val pos = AnnotSpec.placeLabel(
cx = 100f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
assertEquals(100f + AnnotSpec.labelOffsetX(scale), pos[0], 0.01f)
assertEquals(100f - boxH() / 2f, pos[1], 0.01f)
}
@Test
fun labelFlipsLeftInsteadOfOverflowingTheRightEdge() {
val pos = AnnotSpec.placeLabel(
cx = 310f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
assertTrue("must stay inside the image, was ${pos[0]}", pos[0] + boxW() <= 320f)
assertTrue("must stay on the left of its marker", pos[0] < 310f)
}
@Test
fun labelStaysInsideTheImageWhenTheMarkerIsAtTheCorner() {
val pos = AnnotSpec.placeLabel(
cx = 1f, cy = 1f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
assertTrue("x=${pos[0]}", pos[0] >= 0f)
assertTrue("y=${pos[1]}", pos[1] >= 0f)
}
@Test
fun secondLabelIsMovedClearOfTheFirst() {
val first = AnnotSpec.placeLabel(
cx = 100f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
val placed = listOf(floatArrayOf(first[0], first[1], boxW(), boxH()))
// a marker close enough that the default position would overlap
val second = AnnotSpec.placeLabel(
cx = 105f, cy = 104f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale, placed = placed,
)
val overlaps = second[0] < first[0] + boxW() && second[0] + boxW() > first[0] &&
second[1] < first[1] + boxH() && second[1] + boxH() > first[1]
assertTrue(
"second label at (${second[0]},${second[1]}) still overlaps " +
"(${first[0]},${first[1]})",
!overlaps,
)
}
@Test
fun secondLabelFitsEntirelyInsideTheImageAfterAvoiding() {
val first = AnnotSpec.placeLabel(
cx = 100f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
val placed = listOf(floatArrayOf(first[0], first[1], boxW(), boxH()))
val second = AnnotSpec.placeLabel(
cx = 104f, cy = 102f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale, placed = placed,
)
assertTrue("x=${second[0]}", second[0] >= 0f)
assertTrue("x+w=${second[0] + boxW()}", second[0] + boxW() <= 320f)
assertTrue("y=${second[1]}", second[1] >= 0f)
assertTrue("y+h=${second[1] + boxH()}", second[1] + boxH() <= 240f)
}
@Test
fun aCrowdOfLabelsAllStayInsideTheImage() {
// Worst case from the device: the extremes cluster in one corner and the
// probes pile up around them.
val placed = ArrayList<FloatArray>()
for (i in 0 until 8) {
val pos = AnnotSpec.placeLabel(
cx = 10f + i * 2f, cy = 10f + i * 2f,
boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale, placed = placed,
)
placed.add(floatArrayOf(pos[0], pos[1], boxW(), boxH()))
}
for (b in placed) {
assertTrue("x=${b[0]}", b[0] >= 0f)
assertTrue("x+w=${b[0] + b[2]}", b[0] + b[2] <= 320f + 0.01f)
assertTrue("y=${b[1]}", b[1] >= 0f)
assertTrue("y+h=${b[1] + b[3]}", b[1] + b[3] <= 240f + 0.01f)
}
}
@Test
fun scaleKeepsTheOffsetProportional() {
val small = AnnotSpec.placeLabel(100f, 100f, 60f, 14f, 320f, 240f, 1f)
val large = AnnotSpec.placeLabel(300f, 300f, 180f, 42f, 960f, 720f, 3f)
// the gap from the marker to the box grows with the scale factor
assertEquals(AnnotSpec.labelOffsetX(3f), large[0] - 300f, 0.01f)
assertEquals(AnnotSpec.labelOffsetX(1f), small[0] - 100f, 0.01f)
}
}
@@ -0,0 +1,130 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Port of the vendor's 7x7 local detail enhancement
* (`CFunctions::FilterDetailEnhancement_Simple` + `LocalMap7x7_Simple`).
*
* What can and cannot be asserted here: there is no official output to diff
* against (the byte-exact baseline predates this stage), so these tests pin the
* CONTRACT — off is a true no-op, the filter stays inside the 8-bit range, it
* amplifies local contrast rather than shifting the overall level, and it never
* touches the border the vendor leaves alone.
*/
class DetailEnhanceTest {
private val w = 160
private val h = 120
private fun flat(value: Int): IntArray = IntArray(w * h) { value }
/** A smooth ramp: no local contrast, so nothing should be enhanced. */
private fun ramp(): IntArray = IntArray(w * h) { i -> 7000 + (i % w) }
/** A checkerboard: maximum local contrast everywhere. */
private fun checkerboard(lo: Int, hi: Int): IntArray =
IntArray(w * h) { i -> if (((i % w) / 4 + (i / w) / 4) % 2 == 0) lo else hi }
private fun grayOf(v: Int): ByteArray = ByteArray(w * h) { v.toByte() }
@Test
fun zeroStrengthIsANoOp() {
// the default must leave the verified reference path untouched
val gray = grayOf(120)
val before = gray.copyOf()
val de = DetailEnhance(w, h)
de.enhance(ramp(), gray, strength = 0, gain = 3000)
assertArrayEquals("strength 0 must not change a single pixel", before, gray)
}
@Test
fun flatFieldIsUnchanged() {
// a constant frame has zero local range: the vendor's range guard means no
// pixel may be touched (this is what keeps uniform scenes from boiling)
val gray = grayOf(100)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(flat(7200), gray, strength = 8, gain = 3000)
assertArrayEquals("flat input must stay flat", before, gray)
}
@Test
fun localContrastIsAmplified() {
// a checkerboard has strong local contrast: the filter must push pixels
// away from the local mean, i.e. change something
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = 8, gain = 3000)
var changed = 0
for (i in gray.indices) if (gray[i] != before[i]) changed++
assertTrue("expected contrast to change pixels, changed=$changed", changed > 0)
}
@Test
fun outputStaysInsideTheRangeThePaletteAccepts() {
// gray values feed a 256-entry palette; anything outside 0..255 would wrap
val gray = grayOf(250)
DetailEnhance(w, h).enhance(checkerboard(0, 65535), gray, strength = 64, gain = 0xFFFF)
for (i in gray.indices) {
val v = gray[i].toInt() and 0xFF
assertTrue("gray[$i]=$v out of range", v in 0..255)
}
}
@Test
fun bordersAreLeftAlone() {
// the vendor only processes rows/cols 3..(H/W-4); keep that margin so the
// image edge does not develop a halo
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6000, 9000), gray, strength = 16, gain = 3000)
for (y in 0 until h) {
for (x in 0 until w) {
if (x < 3 || y < 3 || x >= w - 3 || y >= h - 3) {
assertEquals("border pixel ($x,$y) must be untouched", before[y * w + x], gray[y * w + x])
}
}
}
}
@Test
fun strongerStrengthChangesAtLeastAsMuch() {
// monotonic in the strength parameter: a plausible-strength sweep must not
// do LESS work at a higher setting
fun changedAt(s: Int): Int {
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = s, gain = 3000)
return gray.indices.count { gray[it] != before[it] }
}
val weak = changedAt(2)
val strong = changedAt(16)
assertTrue("strong=$strong should be >= weak=$weak", strong >= weak)
}
@Test
fun gainScalesTheEffect() {
fun changedAt(g: Int): Int {
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = 8, gain = g)
return gray.indices.count { gray[it] != before[it] }
}
assertTrue("gain 0 is a no-op", changedAt(0) == 0)
assertTrue("a real gain must change something", changedAt(3000) > 0)
}
@Test
fun doesNotChangeTheOverallLevelOfASmoothImage() {
// on a smooth ramp the mean and the centre are equal, so no delta should be
// produced: local enhancement must not act as a brightness shift
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(ramp(), gray, strength = 32, gain = 3000)
var changed = 0
for (i in gray.indices) if (gray[i] != before[i]) changed++
assertEquals("a smooth ramp must be left alone", 0, changed)
}
}
@@ -114,4 +114,80 @@ class PhotoNucMappingTest {
)
assertTrue("no NUC block -> no temperature data", !Mdt.parse(mdt)!!.hasTemperatureData)
}
/**
* The extremes are stored in SENSOR coordinates next to probes that use the
* same space, so the photo builder must map them exactly like a probe. On a
* real photo they were added raw, which put both "max" and "min" a few dozen
* pixels from the origin — in the top-left corner — instead of over the hot
* and cold spots they name.
*/
@Test
fun extremesUseTheSameSensorMappingAsProbes() {
val mirror = PhotoSaver.Mirror(false, true) // the user's mount correction
val probe = PhotoSaver.sensorToPhoto(120, 90, mirror, 960, 720)
val extreme = PhotoSaver.sensorToPhoto(120, 90, mirror, 960, 720)
assertEquals("an extreme at a probe's pixel lands on the same photo pixel",
probe[0], extreme[0], 0.01f)
assertEquals(probe[1], extreme[1], 0.01f)
// and it is nowhere near the origin: sensor (120,90) is right of centre,
// flipped vertically it is above centre
assertTrue("x=${extreme[0]} must be well right of the origin", extreme[0] > 480f)
assertTrue("y=${extreme[1]} must sit in the upper half after flipV", extreme[1] < 360f)
}
/** A probe and an extreme 3 sensor pixels apart must stay 3 pixels apart. */
@Test
fun extremesKeepTheirDistanceFromProbesUnderEveryMirror() {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val m = PhotoSaver.Mirror(flipH, flipV)
val a = PhotoSaver.sensorToPhoto(100, 60, m, 960, 720)
val b = PhotoSaver.sensorToPhoto(103, 60, m, 960, 720)
assertEquals(
"flipH=$flipV: 3 sensor pixels stay 3 photo pixels",
18f, kotlin.math.abs(a[0] - b[0]), 0.01f,
)
}
}
}
/**
* The capture's own extremes travel in the container. The analysis screen used
* to re-derive them from the NUC block, which cannot reproduce the live answer
* exactly (the sensor drifts), so a photo showed two "min" markers a few pixels
* apart — the burned-in one and the freshly computed one.
*/
@Test
fun recordedExtremesSurviveTheContainer() {
val e = Mdt.Extremes(minPos = 60 * 160 + 12, maxPos = 22 * 160 + 130, minMc = 21_987, maxMc = 32_615)
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
nucPixels = PhotoSaver.packNucForPhoto(IntArray(160 * 120)),
extremes = Mdt.encodeExtremes(e),
)
val back = Mdt.parse(mdt)!!.extremes
assertEquals(e.minPos, back.minPos)
assertEquals(e.maxPos, back.maxPos)
// millidegrees survive exactly: rounding them to whole degrees would make
// the analysis readout differ from the marker burned into the photo
assertEquals(e.minMc, back.minMc)
assertEquals(e.maxMc, back.maxMc)
}
@Test
fun aPhotoWithoutExtremesReportsNone() {
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
)
val back = Mdt.parse(mdt)!!.extremes
assertEquals(-1, back.minPos)
assertEquals(-1, back.maxPos)
assertTrue("nothing traced -> nothing to prefer", !Mdt.Extremes.hasAny(back))
// and the parse of a malformed block must not throw or invent data
assertTrue(!Mdt.Extremes.hasAny(Mdt.parseExtremes("garbage".toByteArray())))
assertTrue(!Mdt.Extremes.hasAny(Mdt.parseExtremes(null)))
}
}
Binary file not shown.
+99
View File
@@ -489,6 +489,105 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**debug + release(R8) 双构建通过;APK 已更新。
## 用户反馈修复 第二十二轮(2026-09-12,7×7 细节增强 + 标注统一 + 卡顿根因)
用户第四轮实机反馈(四点):移植 7×7 局部细节增强;分析界面标点没对齐;
实时界面与照片的标注风格必须**完全一致**("就像直接从实时界面截图"),
最高最低用 `min`/`max` 标注;实时画面还是卡。
### 1) 卡顿根因(本轮最有价值的发现,已量化)
先前只靠 `dumpsys gfxinfo`(显示 7.26% janky)猜原因,但**它根本看不到热像画面**:
热像走 `SurfaceView.lockCanvas()` 软件画布,gfxinfo 只统计 Compose 层
(实测 5 秒内只记到 28 帧,而流本身在 15fps)。于是给渲染线程加了自测量日志
`MAG160C/render: paints=N avg=... worst=...`),一测即见真相:
| 阶段 | 绘制帧率 | 单帧绘制耗时 |
|------|---------|------------|
| 修复前 | **6.5 帧/秒** | **134 ms** |
| 加硬件画布后 | **15.1 帧/秒** | **6.4 ms** |
根因:`SurfaceHolder.lockCanvas()` 返回**软件**画布,于是每帧都在 CPU 上做
320×240 → 约 810×1080 的双线性放大 + 手工清整屏(约 10MB)。
**改用 `lockHardwareCanvas()`API 29+,旧系统自动回退软件路径)**,放大与清屏
交给 GPU,CPU 只剩 76800 像素的拷贝。相机 15fps 因此第一次能完整到达屏幕。
配套修掉的每帧垃圾(卡顿的次要来源,也是 GC 停顿的来源):
- `setFramePixels` 每帧 `IntArray(320*240)`4.6MB/s 垃圾)→ 复用 `flipped`
- 色条每帧 new 96 个 `Paint` + 96 次 `drawRect` → 预渲染 1×96 位图,仅换色板时重建
- 标记列表每帧 new ArrayList → 复用
- 温度点名 `refreshTemps()` 原本在**主线程**LaunchedEffect 驱动):
400ms 一次分配 19200 整型数组 + 全屏扫描 + `copyNuc`(持渲染线程的锁)
→ 移到后台 `Dispatchers.Default` 循环,结果经 `Dispatchers.Main` 发布
### 2) 标注统一(照片 = 实时截图)
- 新增 `core/AnnotSpec.kt`(唯一几何定义)+ `media/MarkerPainter.kt`(唯一绘制例程),
实时/分析/照片/视频四处共用。此前各画各的,所以"看起来不一样"。
- 极端值标签由 高/低 改为 **`max` / `min`**(用户指定)。
- 照片以**传感器朝向**保存(4:3 横),文字在该帧内水平,3 倍分辨率(文字清晰)。
### 3) 分析界面标点对齐(两个真实 bug)
1. **极端值没有走坐标映射**probes 经 `sensorToPhoto` 转换,extremes 却被
**原样加入**`marks.addAll(extremes)`)。传感器坐标是 0..159/0..119,在
960×720 的照片上就落在**左上角几十像素内**——真机照片实测两个 min/max
全挤在左上角。已改为同一映射。
2. **`annotateJpeg` 忽略照片镜像**:从分析界面另存时,标记按未镜像坐标烧录,
于是**镜像到另一侧**。已加 `mirror` 参数并走 `sensorToPhoto`
### 4) 标签互相压字(真机照片可见)
`min 21.9℃``max 32.6℃` 印在一起成一团。`placeLabel` 只判边界不判重叠,
现已支持"已放置盒"列表:默认右置 → 被占则翻到左侧 → 仍冲突则下移让位,
并保证始终在画面内。`MarkerPainter` 一次调用内跟踪,分析界面改为把 probes 与
extremes **合并成一次 draw**(原来分两次调用,彼此看不见)。
### 5) 重复的 min 标记(分析页 vs 照片)
分析页重新扫描 NUC 推导极值,与拍摄时 live 扫描的结果**不可能逐位一致**
(传感器在拍摄与重载之间会漂移,平坦区 argmin 极易移位)——真机出现
两个相距几像素、22.0/22.1℃ 的 min。已在容器新增
`BLOCK_EXTREMES (0x5BB5B562)`:拍摄时把 minPos/maxPos/minMc/maxMc 一并存盘,
分析页优先采用,从而与照片上烧录的标记**完全相同**。另存新照片也继续携带。
### 6) 分析页测量面板被导航栏遮住
`UiInsets.navPx` 是普通 `var` 且初值 0,全屏覆盖层(分析查看器)读它时
拿到的是首帧值 0,于是底部面板落在导航栏之下(截图可见数值不可见)。
已改为 `mutableStateOf` 并让分析查看器 `padding(bottom = navPx)`
### 7) 7×7 局部细节增强(官方同款)
按反编译的 `CFunctions::FilterDetailEnhancement_Simple` + `LocalMap7x7_Simple`
移植(`core/DetailEnhance.kt`):7×7 窗口按 4×4 抽样(x/y 步长 2),
`mean = sum >> 4``if (strength <= (max-min)*32)`
`detail = (0x8000/divisor)*(center-mean)`,最后 `gray += (k*detail) >> 15`
管线位置与官方一致:`grayMap → 细节增强 → upscale2x → 调色板`
强度换算经官方 SDK 核对:`MAG_SetDetailEnhancement` 把等级钳到 0..32
调用方传 `level << 3`——**与本实现 `level shl 3` 完全一致**。
设置页新增"图像增强"(关闭/1–4 级),**默认关闭**(无官方参考输出可逐位比对,
故 opt-in)。默认关闭时 `RenderPipelineTest` 的逐位基线不受影响(96 项测试全绿)。
实机实测:级别 2 下仍 15.1fps,单帧绘制 11.3ms(滤波器约 +5ms,帧预算 66ms 内),
细节增强清晰可见。
**本轮真机验证(小米 22041211AL / Android 12 / MIUI,无线 adb 192.168.88.137:44323**
- [x] 渲染帧率 6.5 → **15.1 帧/秒**,单帧 134ms → **6.4ms**(开增强 11.3ms
- [x] 拍照:`capture: nuc=yes probes=2 mirror(h=false,v=true) extremes=2 saved=true`
- [x] 照片上标记风格与实时界面一致(同一 MarkerPainter),`max 32.6℃` 在热区、
`min 22.0℃` 在冷区、Pt1/Pt2 带白底标签,互不压字
- [x] 分析界面:标点与照片烧录位置对齐,**只有一个 min、一个 max**
- [x] 分析面板数值可见且与照片一致(最高 32.9 / 最低 22.0 / 中心 24.4℃)
- [x] 图像增强 2 级:画面细节明显增强,帧率不掉
**教训记录**
- `dumpsys gfxinfo` 不统计 SurfaceView 的 lockCanvas 绘制。判断自绘画面性能
必须自己打点(本轮加的 `MAG160C/render` 日志就是为此,已保留)。
- 断言"某处卡"之前先量出**每帧耗时**和**实际帧率**:本轮原以为是温度扫描
(主线程 400ms 扫描)导致,量化后才发现是软件画布放大,量级差 20 倍。
## 用户反馈修复 第二十一轮(2026-09-12,无线 adb 真机调试:根因是 launchMode
**本轮最大的发现**:前几轮反复出现的"连接风暴/重连循环/相机每 2 秒重枚举"