diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/AnnotSpec.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/AnnotSpec.kt index a39a326..5f565de 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/AnnotSpec.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/AnnotSpec.kt @@ -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 = 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))) } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/DetailEnhance.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/DetailEnhance.kt new file mode 100644 index 0000000..76978ea --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/DetailEnhance.kt @@ -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 + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt index 243298e..dc0fa42 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/MarkerPainter.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/MarkerPainter.kt index 975a032..e7d6e94 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/MarkerPainter.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/MarkerPainter.kt @@ -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(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() } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt index 16a56bc..0f3d580 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt @@ -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): 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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt index 75ce72a..6807d7f 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt @@ -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, + 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) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt index 3675ffb..3d647ba 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt @@ -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 } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt index b44252d..f811606 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt @@ -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) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt index ec977af..3e2209f 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt @@ -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 { + 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 { + 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() diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt index 58634ff..fa85338 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt @@ -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(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, 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 = + 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 { + 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. */ diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt index 4613dff..7f32a7c 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -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(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) } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index 2d2898c..6fc29ff 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index 343b92e..ad2ed6e 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -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 = - _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 { + val st = _state.value + val out = ArrayList( + 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() diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt index b71c8bc..c51e558 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt @@ -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, ) } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt index 812cbcd..a3a02b9 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt @@ -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("追踪标记") }, diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt index 2ae32b2..fde3931 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -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) diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/AnnotSpecPlaceLabelTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/AnnotSpecPlaceLabelTest.kt new file mode 100644 index 0000000..0554663 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/AnnotSpecPlaceLabelTest.kt @@ -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() + 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) + } +} diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/DetailEnhanceTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/DetailEnhanceTest.kt new file mode 100644 index 0000000..ca364f5 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/DetailEnhanceTest.kt @@ -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) + } +} diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt index ee30b3e..05b454c 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt @@ -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))) + } } diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 1c38879..9d45c8e 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07402bd08d21792466dd5f75c5be2eae4fee6505061b7df36ac3226f6bd7d1bf +oid sha256:43308e6a7bfa9fac854d5b74c74c335a41f94d81fbc85c38942d2a72ab021afc size 12663188 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 22d5cd8..4fa96d6 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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 秒重枚举"