diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt index 66c88c2..0bf791f 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt @@ -1,6 +1,8 @@ package com.mag160c.thermal +import android.os.Build import android.os.Bundle +import android.view.View import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -14,17 +16,48 @@ class MainActivity : ComponentActivity() { // early crashes are captured; logging never throws) com.mag160c.thermal.media.DebugLog.init(applicationContext) com.mag160c.thermal.media.DebugLog.startFile(applicationContext) - enableEdgeToEdge() - // immersive: hide the status bar (swipe to reveal) - window.insetsController?.let { c -> - c.hide(android.view.WindowInsets.Type.statusBars()) - c.systemBarsBehavior = - android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + // enableEdgeToEdge exists since API 21 but reaches for the modern inset + // APIs internally; keep it for API 30+ and fall back below, where the + // window flags are the only supported route. + if (Build.VERSION.SDK_INT >= 30) { + runCatching { enableEdgeToEdge() } } + hideStatusBar() setContent { Mag160cTheme { AppRoot() } } } + + /** + * Immersive status bar (swipe to reveal), on every supported Android version. + * + * `Window.insetsController` and `WindowInsetsController` are API 30+, but this + * app supports API 26+ — the previous unconditional use would have thrown + * NoSuchMethodError on Android 8/9 the moment the app started. API 26-29 uses + * the pre-30 window flags instead; the deprecated flags work through API 29 and + * are still honoured on 30+ as a compatibility path. + */ + private fun hideStatusBar() { + if (Build.VERSION.SDK_INT >= 30) { + window.insetsController?.let { c -> + c.hide(android.view.WindowInsets.Type.statusBars()) + c.systemBarsBehavior = + android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + // API 26-29: the deprecated system-UI flags are the supported route. + // FLAG_LAYOUT_NO_LIMITS is deliberately NOT set: it would push content + // under the navigation bar as well, which the renderer's inset + // accounting (uiTopPx/uiBottomPx) does not expect. + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + } + } } 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 new file mode 100644 index 0000000..a39a326 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/AnnotSpec.kt @@ -0,0 +1,73 @@ +package com.mag160c.thermal.core + +/** + * One definition of how temperature markers look and where their labels sit, + * shared by every surface that draws them: the live screen, the analysis screen, + * saved photos and recorded videos. + * + * Why this exists: each surface used to invent its own sizes and offsets, so a + * probe looked different on screen than in the saved photo, and text came out + * blurry whenever a small bitmap was stretched. Everything below is expressed + * relative to the RENDERED IMAGE (320x240 buffer units), never in screen dp, so a + * marker has identical proportions on screen, in a photo and in a video frame. + */ +object AnnotSpec { + /** Reference width the constants below are calibrated for. */ + const val REF_W = 320f + + // ---- marker geometry (multiples of the image scale factor) ---- + const val DOT_R = 2.6f + const val RING_R = 5.5f + const val RING_W = 1.4f + + /** Label text height in image units; see [FontRaster] for crisp rendering. */ + const val TEXT_SIZE = 9f + const val LABEL_GAP = 7f + const val LABEL_PAD_H = 3f + const val LABEL_PAD_V = 1.5f + const val SHADOW = 1.2f + + /** Label box colour (translucent white) and text colour. */ + const val LABEL_BG = 0xF0FFFFFF.toInt() + const val LABEL_FG = 0xFF000000.toInt() + + /** Extreme markers use the same glyph as probes, tinted. */ + const val EXTREME_TINT = 0xFFFFD54F.toInt() + + fun scaleFor(imageWidth: Int): Float = imageWidth / REF_W + + /** Half extents of a text box in image units, for label placement. */ + fun halfExtents(textW: Float, textH: Float): FloatArray = + floatArrayOf(textW / 2f, textH / 2f) + + /** + * Default label anchor relative to the marker centre: to the RIGHT of the + * ring, vertically centred. The same rule everywhere, so a marker that sits + * clear of the image edge on screen also sits clear of it in the photo. + */ + 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 + */ + fun placeLabel( + cx: Float, + cy: Float, + boxW: Float, + boxH: Float, + imgW: Float, + imgH: Float, + scale: Float, + ): FloatArray { + val margin = 2f * scale + var x = cx + labelOffsetX(scale) + // flip to the left of the marker when it would overflow the right edge + if (x + boxW > imgW - margin) x = cx - labelOffsetX(scale) - boxW + if (x < margin) x = margin + var y = cy - boxH / 2f + if (y < margin) y = margin + if (y + boxH > imgH - margin) y = imgH - margin - boxH + return floatArrayOf(x, y) + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt index 8155361..80e6a05 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/Palettes.kt @@ -31,8 +31,20 @@ object Palettes { "Winter", "Hot metal", "Jet", "Red saturation", "High contrast", "Red hot", ) + /** + * All twelve palettes, built ONCE. + * + * [buildAll] used to construct the tables on every call, and the renderers call + * it once per frame (for the colour bar) — that means 12 x 256 entries plus the + * trigonometric/gamma math for several curves, 15 times a second, on the render + * thread. That was a measurable part of the stutter reported on Android 12. + */ + val ALL: List by lazy { buildAllInternal() } + /** Build all palettes as ARGB int arrays (256 entries each). */ - fun buildAll(): List = listOf( + fun buildAll(): List = ALL + + private fun buildAllInternal(): List = listOf( VendorPalettes.WHITE_HOT, // 0 白热 — extracted libcxsdk case 0 VendorPalettes.BLACK_HOT, // 1 黑热 — extracted libcxsdk case 1 officialIronbow(), // 2 铁虹 — official table (anchor-verified) 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 new file mode 100644 index 0000000..975a032 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/MarkerPainter.kt @@ -0,0 +1,116 @@ +package com.mag160c.thermal.media + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import com.mag160c.thermal.core.AnnotSpec + +/** + * Draws temperature markers (dot + ring + temperature label) onto a bitmap with + * the SAME geometry the live screen uses, at whatever resolution the caller is + * rendering. + * + * Text crispness: the saved photo used to be written at the sensor's 320x240 and + * then displayed scaled up on a phone screen, which is why the labels looked + * blurry. Rendering at a higher factor ([AnnotSpec.TEXT_SIZE] multiplied by that + * factor) keeps the characters sharp at the size the user actually views. + */ +object MarkerPainter { + /** A probe to draw, in the coordinate space of the target bitmap. */ + data class Mark( + val x: Float, + val y: Float, + val label: String, + val tempC: Float, + val tint: Int? = null, + ) + + /** + * @param canvas target + * @param marks marks in the SAME pixel space as [imgW]/[imgH] + * @param imgW/imgH target image size + * @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. + */ + fun draw( + canvas: Canvas, + marks: List, + imgW: Float, + imgH: Float, + imageUnitsToPixels: Float, + ) { + if (marks.isEmpty()) return + val k = imageUnitsToPixels + val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = AnnotSpec.RING_W * k + setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK) + } + val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK) + } + val box = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = AnnotSpec.LABEL_BG + style = Paint.Style.FILL + } + val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = AnnotSpec.LABEL_FG + textSize = AnnotSpec.TEXT_SIZE * k + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + val padH = AnnotSpec.LABEL_PAD_H * k + val padV = AnnotSpec.LABEL_PAD_V * k + + for (m in marks) { + val tint = m.tint + val ringColor = tint ?: Color.WHITE + val dotColor = tint ?: Color.WHITE + ring.color = ringColor + dot.color = dotColor + canvas.drawCircle(m.x, m.y, AnnotSpec.DOT_R * k, dot) + canvas.drawCircle(m.x, m.y, AnnotSpec.RING_R * k, ring) + + val full = (if (m.label.isNotEmpty()) "${m.label} " else "") + + "%.1f℃".format(m.tempC) + val tw = text.measureText(full) + val fm = text.fontMetrics + val boxW = tw + padH * 2 + val boxH = (fm.descent - fm.ascent) + padV * 2 + // same placement rule as on screen (right of the ring, flipped when + // 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, + ) + if (tint == null) { + canvas.drawRoundRect( + RectF(pos[0], pos[1], pos[0] + boxW, pos[1] + boxH), + 2f * k, 2f * k, box, + ) + text.color = AnnotSpec.LABEL_FG + } else { + // extreme markers carry no box: tinted text keeps the image clear + text.color = tint + text.setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK) + } + val baseline = pos[1] + padV - fm.ascent + canvas.drawText(full, pos[0] + padH, baseline, text) + text.clearShadowLayer() + } + } + + /** Convenience: draw the given marks over an existing bitmap. */ + fun drawOn( + canvas: Canvas, + marks: List, + bitmapW: Int, + bitmapH: Int, + renderScale: Float, + ) = draw(canvas, marks, bitmapW.toFloat(), bitmapH.toFloat(), renderScale) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt index 16371a8..6ffab97 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt @@ -42,6 +42,18 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { var frameCount: Int = 0 private set + /** + * Markers burned into every recorded frame (sensor coordinates). Set by the + * view model while recording so the video carries the same temperature + * annotations the user sees, not a bare image. + */ + @Volatile + var marks: List = emptyList() + + /** Mirror corrections applied to recorded frames (sensor-mount semantics). */ + @Volatile + var mirror: PhotoSaver.Mirror = PhotoSaver.Mirror(false, false) + /** Frames dropped because the encoder was busy or gone (diagnostics). */ @Volatile var droppedCount: Int = 0 @@ -106,6 +118,20 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint, ) + // Burn the temperature annotations into the recorded frame so + // the video shows the same readouts as the live screen. + val m = marks + if (m.isNotEmpty()) { + MarkerPainter.draw( + canvas = c, + marks = m, + imgW = width.toFloat(), + imgH = height.toFloat(), + // the encoder receives the 320x240 render, so + // AnnotSpec units (based on 320) map 1:1 + imageUnitsToPixels = width / 320f, + ) + } } finally { surface.unlockCanvasAndPost(c) } 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 6681ebe..75ce72a 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 @@ -21,141 +21,45 @@ import java.util.Locale * Save captured photos into MediaStore under DCIM/MAG160C (system gallery * visible, no rogue folders). The stored file is a self-contained MDT * container (JPG + temperature frame + note) named by capture time. + * + * ## Orientation policy (user decision, 2026-09-12) + * + * The saved photo is written in the SENSOR's own orientation — the same 4:3 + * landscape frame the sensor delivers — and the burned-in text runs horizontally + * in that frame. So photo orientation and text direction both match the sensor. + * The display rotation (90 deg on the portrait screen) is NOT baked in: a + * measurement record should record what the sensor saw, and this keeps the photo + * compatible with the vendor's own MDT files. + * + * The user's manual flip corrections (水平翻转/竖直翻转) ARE applied, because they + * describe how the sensor is mounted rather than how it is displayed. + * + * ## Resolution policy + * + * Rendered at [RENDER_SCALE]x the sensor size (3x -> 960x720). Text drawn at the + * sensor's 320x240 was legible but visibly soft once the photo was viewed at full + * screen; the same layout at 3x is sharp. */ object PhotoSaver { private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH) + /** Sensor frame size used by the pipeline. */ + const val SENSOR_W = 320 + const val SENSOR_H = 240 + + /** Saved photo is this many times the sensor frame (crisp text). */ + const val RENDER_SCALE = 3 + fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg" - /** Probe annotation burned into a saved photo (photo pixel coordinates). */ + /** Probe annotation burned into a saved photo (SENSOR coordinates). */ data class ProbeMark(val x: Int, val y: Int, val label: String, val tempC: Float) - /** - * Marker geometry relative to the IMAGE, not to screen density. - * - * The first version sized markers with screen density (4.5*density dot, - * 9*density ring) while drawing into a 320x240 bitmap, so the rings came out - * ~36 px across on a 320 px-wide photo — enormous (the user's "测温点太大了"). - * Sizes are now derived from the image width, keeping the same proportion the - * live screen shows. - */ - private class MarkStyle(imageWidth: Int) { - val scale = imageWidth / 320f - val dotR = 2.6f * scale - val ringR = 5.5f * scale - val ringW = 1.4f * scale - val textSize = 9f * scale - val labelGap = 7f * scale - val shadow = 1.5f * scale - } - - /** - * Build the NUC block for a photo: ONE count per photo pixel, so an offline - * lookup is literally `counts[iy * photoWidth + ix]`. - * - * Why 1:1 with the photo and not the 160x120 sensor grid: the photo is - * displayed in its own pixel space, and any attempt to keep a smaller grid - * forces every caller to re-derive the rotation/flip/upscale — the exact class - * of index confusion that produced both wrong temperatures and misplaced - * markers. Costs 4x the bytes (153 KB) and removes the ambiguity entirely. - */ - fun buildPhotoOrderedCounts( - nuc160: IntArray, - orientation: Orientation, - srcW: Int = 320, - srcH: Int = 240, - ): IntArray { - require(nuc160.size >= 160 * 120) { "expected 19200 NUC samples, got ${nuc160.size}" } - val rot = ((orientation.rotateDeg % 360) + 360) % 360 - val outW = if (rot % 180 == 0) srcW else srcH - val outH = if (rot % 180 == 0) srcH else srcW - val out = IntArray(outW * outH) - for (iy in 0 until outH) { - for (ix in 0 until outW) { - val s = photoToSensor(ix, iy, srcW, srcH, rot, orientation) - out[iy * outW + ix] = nuc160[s[1] * 160 + s[0]] - } - } - return out - } - - /** - * Photo pixel -> SENSOR pixel: the exact inverse of the transform - * [encodeRendered] applies to the bitmap (flips, then clockwise rotation) with - * the 2x upscale in between. - * - * Buffer coords come from the documented forward map - * rot 0 (bx,by) -> (bx, by) rot 180 -> (W-bx, H-by) - * rot 90 (bx,by) -> (H-by, bx) rot 270 -> (by, W-bx) - * inverted below; then the flips are undone, then the 2x upscale. - */ - fun photoToSensor( - ix: Int, - iy: Int, - srcW: Int = 320, - srcH: Int = 240, - rot: Int, - orientation: Orientation, - ): IntArray { - val W = srcW - val H = srcH - var bx: Int - var by: Int - when (((rot % 360) + 360) % 360) { - 90 -> { - bx = iy - by = H - ix - } - 180 -> { - bx = W - ix - by = H - iy - } - 270 -> { - bx = W - iy - by = ix - } - else -> { - bx = ix - by = iy - } - } - if (orientation.flipH) bx = W - bx - if (orientation.flipV) by = H - by - val sx = (bx / 2).coerceIn(0, 159) - val sy = (by / 2).coerceIn(0, 119) - return intArrayOf(sx, sy) - } - - /** Pack an IntArray of counts as u16 little-endian. */ - fun countsToBytes(counts: IntArray): ByteArray { - val out = ByteArray(counts.size * 2) - for (i in counts.indices) { - val v = counts[i].coerceIn(0, 0xFFFF) - out[i * 2] = (v and 0xFF).toByte() - out[i * 2 + 1] = ((v shr 8) and 0xFF).toByte() - } - return out - } - - /** Unpack u16 little-endian bytes into counts. */ - fun bytesToCounts(bytes: ByteArray): IntArray { - val n = bytes.size / 2 - val out = IntArray(n) - for (i in 0 until n) { - out[i] = (bytes[i * 2].toInt() and 0xFF) or ((bytes[i * 2 + 1].toInt() and 0xFF) shl 8) - } - return out - } - - /** - * Orientation applied to a saved photo, mirroring what the live view showed - * (the image is locked to the portrait frame plus the user's manual - * corrections — see ui/live/ImageTransform). - */ - data class Orientation(val rotateDeg: Int, val flipH: Boolean, val flipV: Boolean) + /** The user's manual mirror corrections (sensor-mount semantics). */ + data class Mirror(val flipH: Boolean, val flipV: Boolean) /** Encode a rendered ARGB frame to JPEG bytes. */ - fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray { + fun encodeJpeg(frame: IntArray, w: Int = SENSOR_W, h: Int = SENSOR_H, quality: Int = 92): ByteArray { val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) bmp.setPixels(frame, 0, w, 0, 0, w, h) return encodeJpeg(bmp, quality) @@ -169,193 +73,156 @@ object PhotoSaver { } /** - * Render a photo exactly as the live view presents it: apply the sensor-frame - * flips, then the locked/user rotation, then burn in the probe markers with - * their temperatures. + * Render the sensor frame for saving: mirrored per the user's settings, scaled + * by [RENDER_SCALE], with the probe markers burned in. * - * @param frame 320x240 ARGB render of the sensor image (unrotated) - * @param orientation the same rotation/flip the live view used - * @param probes probe points in SENSOR coordinates, with their temperatures - * @return JPEG bytes of the rotated, annotated image + * @param frame 320x240 ARGB render of the sensor image + * @param mirror manual flip corrections (sensor mounting) + * @param probes probes in SENSOR coordinates (0..159, 0..119) + * @param extremes optional max/min markers, also sensor coordinates */ fun encodeRendered( frame: IntArray, - w: Int = 320, - h: Int = 240, - orientation: Orientation, + mirror: Mirror = Mirror(false, false), probes: List = emptyList(), - density: Float = 2f, - quality: Int = 92, + extremes: List = emptyList(), + w: Int = SENSOR_W, + h: Int = SENSOR_H, + quality: Int = 95, ): ByteArray { val src = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) src.setPixels(frame, 0, w, 0, 0, w, h) - // 1. flips act on the sensor frame (same order as ImageTransform) + // mirror (sensor-mount correction) — NO display rotation: the photo must + // match the sensor's own orientation var work = src - if (orientation.flipH || orientation.flipV) { + if (mirror.flipH || mirror.flipV) { val m = Matrix().apply { setScale( - if (orientation.flipH) -1f else 1f, - if (orientation.flipV) -1f else 1f, + if (mirror.flipH) -1f else 1f, + if (mirror.flipV) -1f else 1f, w / 2f, h / 2f, ) } work = Bitmap.createBitmap(src, 0, 0, w, h, m, true) } - // 2. rotation (90/180/270); the probe coordinates ride along - val rot = ((orientation.rotateDeg % 360) + 360) % 360 - if (rot != 0) { - val m = Matrix().apply { postRotate(rot.toFloat()) } - val rotated = Bitmap.createBitmap(work, 0, 0, work.width, work.height, m, true) - work = rotated - } - - // marker positions in the OUTPUT image, following the same transform - val outW = if (rot % 180 == 0) w else h - val outH = if (rot % 180 == 0) h else w - - val outBmp = if (work.width == outW && work.height == outH) { + val outW = w * RENDER_SCALE + val outH = h * RENDER_SCALE + val out = if (work.width == outW && work.height == outH) { work.copy(Bitmap.Config.ARGB_8888, true) } else { Bitmap.createScaledBitmap(work, outW, outH, true) } - if (probes.isNotEmpty()) { - val canvas = Canvas(outBmp) - val s = MarkStyle(outW) - // Probes are given in PHOTO pixel coordinates already (the capture - // converts them together with the NUC data), so no extra transform. - val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - style = Paint.Style.STROKE - strokeWidth = s.ringW - setShadowLayer(s.shadow, 0f, 0f, Color.BLACK) - } - val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - style = Paint.Style.FILL - setShadowLayer(s.shadow, 0f, 0f, Color.BLACK) - } - val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - textSize = s.textSize - typeface = Typeface.SANS_SERIF - setShadowLayer(s.shadow, 0f, 0f, Color.BLACK) - } - for (p in probes) { - val sx = p.x.toFloat() - val sy = p.y.toFloat() - drawMarker(canvas, sx, sy, p.label, p.tempC, s, ring, dot, text, outW, outH) - } + val marks = ArrayList(probes.size + extremes.size) + for (p in probes) { + val pos = sensorToPhoto(p.x, p.y, mirror, outW, outH) + marks.add(MarkerPainter.Mark(pos[0], pos[1], p.label, p.tempC)) } - return encodeJpeg(outBmp, quality) - } - - /** One marker: filled dot, ring, and a temperature label that stays inside. */ - private fun drawMarker( - canvas: Canvas, - sx: Float, - sy: Float, - label: String, - tempC: Float, - s: MarkStyle, - ring: Paint, - dot: Paint, - text: Paint, - outW: Int, - outH: Int, - ) { - canvas.drawCircle(sx, sy, s.dotR, dot) - canvas.drawCircle(sx, sy, s.ringR, ring) - val full = (if (label.isNotEmpty()) "$label " else "") + "%.1f℃".format(tempC) - val tw = text.measureText(full) - val half = text.textSize / 2f - var tx = sx + s.ringR + s.labelGap - if (tx + tw > outW - 2f * s.scale) tx = sx - s.ringR - s.labelGap - tw - if (tx < 2f * s.scale) tx = 2f * s.scale - val ty = (sy + half).coerceIn(half + 2f * s.scale, outH - 2f * s.scale) - canvas.drawText(full, tx, ty, text) + marks.addAll(extremes) + 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 + // and the layout keeps the same proportions as the live screen. + MarkerPainter.draw( + canvas = Canvas(out), + marks = marks, + imgW = outW.toFloat(), + imgH = outH.toFloat(), + imageUnitsToPixels = outW / 320f, + ) + } + return encodeJpeg(out, quality) } /** - * Sensor pixel -> position in the FINAL (rotated, flipped) photo. - * - * The bitmap transforms above are clockwise for positive angles, so a source - * point (x,y) in a w x h buffer lands at: - * rot 0 -> (x, y) - * rot 90 -> (h - y, x) (output h x w) - * rot 180 -> (w - x, h - y) - * rot 270 -> (y, w - x) (output h x w) - * Flips are applied first, exactly like the bitmap pipeline. + * Sensor pixel -> saved-photo pixel. The photo keeps the sensor's orientation, + * so this is a uniform scale (plus the optional mirror) — deliberately no + * rotation, because every rotation in the chain is a chance to disagree with + * the temperature data that is stored alongside. */ - internal fun sensorToImage( - sx: Int, - sy: Int, - w: Int, - h: Int, - rot: Int, - orientation: Orientation, - ): IntArray { + fun sensorToPhoto(sx: Int, sy: Int, mirror: Mirror, photoW: Int, photoH: Int): FloatArray { var u = (sx + 0.5f) / 160f var v = (sy + 0.5f) / 120f - if (orientation.flipH) u = 1f - u - if (orientation.flipV) v = 1f - v - val x = u * w - val y = v * h - return when (((rot % 360) + 360) % 360) { - 90 -> intArrayOf((h - y).toInt(), x.toInt()) - 180 -> intArrayOf((w - x).toInt(), (h - y).toInt()) - 270 -> intArrayOf(y.toInt(), (w - x).toInt()) - else -> intArrayOf(x.toInt(), y.toInt()) - } + if (mirror.flipH) u = 1f - u + if (mirror.flipV) v = 1f - v + return floatArrayOf(u * photoW, v * photoH) + } + + /** Inverse of [sensorToPhoto]: photo pixel -> sensor pixel. */ + fun photoToSensor(px: Int, py: Int, mirror: Mirror, photoW: Int, photoH: Int): Pair { + var u = (px + 0.5f) / photoW + var v = (py + 0.5f) / photoH + if (mirror.flipH) u = 1f - u + if (mirror.flipV) v = 1f - v + val sx = (u * 160f).toInt().coerceIn(0, 159) + val sy = (v * 120f).toInt().coerceIn(0, 119) + return sx to sy } /** - * Burn probe markers onto an ALREADY-RENDERED JPEG (analysis "save as new"). - * The image is not rotated here — the caller's bitmap is already in its final - * orientation, so the probe coordinates are its own pixel coordinates. + * Pack the NUC (calibrated) counts for offline measurement. + * + * Stored on the SENSOR grid (160x120 -> 38400 bytes): the photo has no + * rotation relative to the sensor, so the analysis lookup is a uniform scale + * (photo pixel / (photoW/160)) and no per-pixel rotation math is involved. An + * earlier version stored one sample per photo pixel — 4x the size and it + * silently vanished whenever the size check did not match the actual rotation. + */ + fun packNucForPhoto(nuc160: IntArray): ByteArray { + require(nuc160.size >= 160 * 120) { "expected 19200 NUC samples, got ${nuc160.size}" } + val out = ByteArray(160 * 120 * 2) + for (i in 0 until 160 * 120) { + val v = nuc160[i].coerceIn(0, 0xFFFF) + out[i * 2] = (v and 0xFF).toByte() + out[i * 2 + 1] = ((v shr 8) and 0xFF).toByte() + } + return out + } + + /** Unpack the NUC block (160x120 counts). */ + fun unpackNuc(bytes: ByteArray): IntArray { + val n = minOf(160 * 120, bytes.size / 2) + val out = IntArray(n) + for (i in 0 until n) { + out[i] = (bytes[i * 2].toInt() and 0xFF) or ((bytes[i * 2 + 1].toInt() and 0xFF) shl 8) + } + return out + } + + /** + * Burn markers onto an ALREADY-RENDERED JPEG (analysis "save as new"). + * + * Drawn with [MarkerPainter], i.e. the same geometry as the live screen, at + * the resolution of the file being saved — so labels stay sharp instead of + * being upscaled from a smaller bitmap (the reported blurriness). */ fun annotateJpeg( jpg: ByteArray, - probes: List, - density: Float = 2f, + marks: List, ): ByteArray { - if (probes.isEmpty()) return jpg - val bmp = android.graphics.BitmapFactory.decodeByteArray(jpg, 0, jpg.size) - ?: return jpg - val out = bmp.copy(android.graphics.Bitmap.Config.ARGB_8888, true) ?: return jpg - val canvas = Canvas(out) - val s = MarkStyle(out.width) - val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - style = Paint.Style.STROKE - strokeWidth = s.ringW - setShadowLayer(s.shadow, 0f, 0f, Color.BLACK) - } - val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - style = Paint.Style.FILL - setShadowLayer(s.shadow, 0f, 0f, Color.BLACK) - } - val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - textSize = s.textSize - typeface = Typeface.SANS_SERIF - setShadowLayer(s.shadow, 0f, 0f, Color.BLACK) - } - for (p in probes) { - drawMarker( - canvas, p.x.toFloat(), p.y.toFloat(), p.label, p.tempC, s, - ring, dot, text, out.width, out.height, - ) + 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 + val scaled = marks.map { + MarkerPainter.Mark(it.x * pxPerSensor, it.y * pxPerSensor, 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, + ) return encodeJpeg(out) } /** Save an MDT container into MediaStore. Returns the media uri string. */ - fun saveMdt( - context: Context, + fun saveMdt( context: Context, mdt: ByteArray, displayName: String, ): String? { @@ -444,5 +311,4 @@ object PhotoSaver { } return uri.toString() } - } 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 8eb6b61..ec977af 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.mag160c.thermal.core.TempMath import com.mag160c.thermal.media.Mdt +import com.mag160c.thermal.media.MarkerPainter import com.mag160c.thermal.media.PhotoSaver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -18,28 +19,32 @@ import kotlinx.coroutines.withContext import java.util.Locale /** - * Offline MDT analysis (2026-09-11). + * Offline MDT analysis (sensor-space, 2026-09-12). * - * Temperatures come from the NUC block stored in the photo — the CALIBRATED - * counts the live screen measures — addressed by the photo's own pixel index. - * The earlier version converted the RAW sensor frame, which is pre-NUC data and - * produced 145 C max / -161 C min on a 30 C scene; photos taken before this - * change have no NUC block, so they report "no temperature data" instead of - * inventing numbers. + * The saved photo keeps the SENSOR's orientation and the NUC block is stored on + * the sensor grid, so everything here works in sensor coordinates: + * - a photo pixel maps to a sensor pixel by a uniform scale; + * - a probe is stored and edited in sensor coordinates; + * - the displayed image is the saved JPEG, so what the user measures is exactly + * what the file contains. + * + * Temperatures come from the NUC block — the CALIBRATED counts the live screen + * measures. Photos without it (older files) report "no temperature data" rather + * than inventing numbers. */ class AnalyzeViewModel( app: Application, private val containerBytes: ByteArray, private val fileUri: android.net.Uri, ) : AndroidViewModel(app) { - /** A probe point in the SAVED PHOTO's pixel space. */ + /** A probe point in SENSOR coordinates (0..159, 0..119). */ data class Probe(val x: Int, val y: Int, val label: String, val tempC: Float) val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes) - /** Calibrated counts in photo pixel order (null for older photos). */ + /** Calibrated counts on the 160x120 sensor grid (null for older photos). */ private val nucCounts: IntArray? by lazy { - parsed?.nucPixels?.let { PhotoSaver.bytesToCounts(it) } + parsed?.nucPixels?.let { PhotoSaver.unpackNuc(it) } } val hasTemperatureData: Boolean get() = nucCounts != null && parsed?.hasTemperatureData == true @@ -62,15 +67,15 @@ class AnalyzeViewModel( private val _centerTempC = mutableStateOf(null) val centerTempC: Float? get() = _centerTempC.value - /** Min/max position, in photo pixels, for the on-image markers. */ + /** Min/max position in SENSOR coordinates, for the on-image markers. */ private val _minPos = mutableStateOf(-1) val minPos: Int get() = _minPos.value private val _maxPos = mutableStateOf(-1) val maxPos: Int get() = _maxPos.value - private val _imageW = mutableStateOf(320) + private val _imageW = mutableStateOf(PhotoSaver.SENSOR_W) val imageW: Int get() = _imageW.value - private val _imageH = mutableStateOf(240) + private val _imageH = mutableStateOf(PhotoSaver.SENSOR_H) val imageH: Int get() = _imageH.value fun load() { @@ -90,9 +95,6 @@ class AnalyzeViewModel( if (v < mn) { mn = v; mnPos = i } if (v > mx) { mx = v; mxPos = i } } - // stored probes already carry photo coordinates and a measured temp; - // re-measure from the NUC block when available so the panel always - // reflects the stored data rather than a stale label val loaded = (parsed?.probes.orEmpty()).map { p -> Probe(p.x, p.y, p.label, measure(p.x, p.y) ?: (p.tempMc / 1000f)) } @@ -101,20 +103,14 @@ class AnalyzeViewModel( probes.clear() probes.addAll(loaded) if (counts != null) { - // mn/mx are NUC COUNTS — they must go through the temperature - // curve like any other sample. The first version divided them - // by 1000 instead, so the panel showed 9.2/10.4 C for a - // 22.6-32.9 C scene while the centre readout (which did use - // the curve) was right. + // 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 _minPos.value = mnPos _maxPos.value = mxPos - _centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120) + _centerTempC.value = measure(80, 60) } - // Log what the panel shows: the Compose text is drawn on a canvas - // and never appears in the view hierarchy, so a field log is the - // only way to verify the numbers from adb. com.mag160c.thermal.media.DebugLog.log( "analyze", "loaded ${bmp?.width}x${bmp?.height} measurable=$hasTemperatureData " + @@ -126,61 +122,55 @@ class AnalyzeViewModel( } /** - * Temperature (C) at a pixel of the SAVED photo. - * The stored NUC block is 1:1 with the photo (see PhotoSaver.buildPhotoOrderedCounts), - * so this is a plain index — no rotation/flip/upscale math that could disagree - * with how the markers were placed. + * Temperature (C) at a SENSOR pixel, from the stored NUC counts. * Returns null when the photo has no NUC data — never a fabricated number. */ - fun measure(ix: Int, iy: Int): Float? { + fun measure(sx: Int, sy: Int): Float? { val counts = nucCounts ?: return null - val w = _imageW.value - val h = _imageH.value - if (ix < 0 || iy < 0 || ix >= w || iy >= h) return null - val idx = iy * w + ix - if (idx < 0 || idx >= counts.size) return null + if (sx < 0 || sy < 0 || sx >= 160 || sy >= 120) return null + val idx = sy * 160 + sx + if (idx >= counts.size) return null return TempMath.countsToTempMc(counts[idx]) / 1000f } - /** Tap in canvas space -> photo pixel; toggles a probe there. */ + /** 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 - val ix = ((pos.x - rect.left) / rect.width * _imageW.value).toInt() - .coerceIn(0, _imageW.value - 1) - val iy = ((pos.y - rect.top) / rect.height * _imageH.value).toInt() - .coerceIn(0, _imageH.value - 1) + // 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) - // marker footprint scales with the image, so the hit radius must too - val thr = (_imageW.value / 320f * 14f).coerceAtLeast(8f) + val thr = 6f // ~6 sensor pixels, matching the on-screen marker size val hit = probes.indexOfFirst { p -> - val dx = (p.x - ix).toFloat() - val dy = (p.y - iy).toFloat() + val dx = (p.x - sx).toFloat() + val dy = (p.y - sy).toFloat() dx * dx + dy * dy < thr * thr } if (hit >= 0) { probes.removeAt(hit) return } - val t = measure(ix, iy) - if (t == null) { - // no calibrated data: still allow the marker, but label it honestly - probes.add(Probe(ix, iy, "Pt${probes.size + 1}", 0f)) - } else { - probes.add(Probe(ix, iy, "Pt${probes.size + 1}", t)) - } + probes.add(Probe(sx, sy, "Pt${probes.size + 1}", measure(sx, sy) ?: 0f)) } + /** Current probes as markers (sensor space), for burning into a saved copy. */ + fun probesAsMarks(): List = + probes.map { MarkerPainter.Mark(it.x.toFloat(), it.y.toFloat(), it.label, it.tempC) } + fun setPaletteIndex(idx: Int) { _paletteIndex.value = idx } fun decodeNote(): String? = parsed?.text - /** Save as a NEW photo: annotations baked in, probes + NUC stored. */ + /** + * Save as a NEW photo: markers burned in at the photo's own resolution (so the + * labels stay sharp) and the probes stored in the container. + */ fun saveAsNew( context: android.content.Context, notes: String, @@ -194,8 +184,7 @@ class AnalyzeViewModel( } viewModelScope.launch(Dispatchers.IO) { val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92) - val marks = probes.map { PhotoSaver.ProbeMark(it.x, it.y, it.label, it.tempC) } - val annotated = PhotoSaver.annotateJpeg(jpg, marks, density) + val annotated = PhotoSaver.annotateJpeg(jpg, probesAsMarks()) val mdt = Mdt.compose( jpg = annotated, info0 = parsed?.info0, 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 2938bfe..58634ff 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 @@ -75,6 +75,9 @@ fun AnalyzeViewer( AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri) } val render by vm.render.collectAsState() + // collected (not read via .value inside composition) so recomposition is + // driven by state, and lint's StateFlowValueCalledInComposition stays clean + val paletteIdx by vm.paletteIndex.collectAsState() var zoom by remember { mutableStateOf(1f) } var pan by remember { mutableStateOf(Offset.Zero) } var note by remember { mutableStateOf(vm.decodeNote() ?: "") } @@ -103,7 +106,7 @@ fun AnalyzeViewer( modifier = Modifier.weight(1f), maxLines = 1, ) - TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[vm.paletteIndex.value]) } + TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[paletteIdx]) } TextButton(onClick = { showNote = true }) { Text("备注") } TextButton( onClick = { @@ -280,19 +283,12 @@ private fun imageRect( return androidx.compose.ui.geometry.Rect(left, top, left + w, top + h) } -/** Marker geometry in IMAGE pixels, mirroring PhotoSaver.MarkStyle. */ -private class MarkerStyle(imageW: Int) { - val scale = imageW / 320f - val dotR = 2.6f * scale - val ringR = 5.5f * scale - val ringW = 1.4f * scale - val textSize = 9f * scale - val gap = 7f * scale -} - -private fun DrawScope.markerScaleFactor(rect: androidx.compose.ui.geometry.Rect, imageW: Int): Float = - rect.width / imageW - +/** + * 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). + */ private fun DrawScope.drawProbes( probes: List, rect: androidx.compose.ui.geometry.Rect, @@ -300,30 +296,59 @@ private fun DrawScope.drawProbes( imageH: Int, ) { if (probes.isEmpty()) return - val k = markerScaleFactor(rect, imageW) - val st = MarkerStyle(imageW) + 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 = android.graphics.Color.WHITE - textSize = st.textSize * k + color = spec.LABEL_FG + textSize = spec.TEXT_SIZE * k + typeface = android.graphics.Typeface.create( + android.graphics.Typeface.SANS_SERIF, android.graphics.Typeface.BOLD, + ) + isAntiAlias = true + } + val boxPaint = android.graphics.Paint().apply { + color = spec.LABEL_BG isAntiAlias = true - setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK) } val dot = android.graphics.Paint().apply { color = android.graphics.Color.WHITE isAntiAlias = true - setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK) + 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) / imageW * rect.width - val cy = rect.top + (p.y + 0.5f) / imageH * rect.height - drawCircle(Color.White, st.dotR * k, Offset(cx, cy)) - drawCircle(Color.White, st.ringR * k, Offset(cx, cy), style = Stroke(st.ringW * k)) + 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) - var tx = cx + (st.ringR + st.gap) * k - if (tx + tw > rect.right - 2f * k) tx = cx - (st.ringR + st.gap) * k - tw - val ty = cy + st.textSize * k * 0.4f - drawIntoCanvas { c -> c.nativeCanvas.drawText(label, tx, ty, paint) } + 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, + ) + } } } @@ -336,22 +361,28 @@ private fun DrawScope.drawExtreme( label: String, ) { if (pos < 0) return - val px = pos % imageW - val py = pos / imageW - if (py >= imageH) return - val k = markerScaleFactor(rect, imageW) - val st = MarkerStyle(imageW) - val cx = rect.left + (px + 0.5f) / imageW * rect.width - val cy = rect.top + (py + 0.5f) / imageH * rect.height - drawCircle(Color(0xFFFFD54F), st.ringR * 0.9f * k, Offset(cx, cy), style = Stroke(st.ringW * k)) + 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), + ) val paint = android.graphics.Paint().apply { - color = android.graphics.Color.rgb(255, 213, 79) - textSize = st.textSize * k + color = spec.EXTREME_TINT + textSize = spec.TEXT_SIZE * k isAntiAlias = true - setShadowLayer(2f * k, 0f, 0f, android.graphics.Color.BLACK) + setShadowLayer(spec.SHADOW * k, 0f, 0f, android.graphics.Color.BLACK) } drawIntoCanvas { c -> - c.nativeCanvas.drawText(label, cx + st.ringR * 1.3f * k, cy - st.ringR * 0.6f * k, paint) + c.nativeCanvas.drawText( + label, cx + spec.RING_R * 1.2f * k, cy - spec.RING_R * 0.4f * k, paint, + ) } } 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 56a83ce..4613dff 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 @@ -5,8 +5,10 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Typeface +import android.os.SystemClock import android.view.SurfaceHolder import android.view.SurfaceView +import com.mag160c.thermal.core.AnnotSpec /** * Software canvas renderer for the live IR stream. @@ -70,15 +72,34 @@ class LiveRenderer( override fun run() { val holder = surfaceView.holder + var lastPainted = 0L + var lastPaintMs = 0L while (running.get()) { - val canvas = holder.lockCanvas() ?: continue + // 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 + // for no benefit (a source of the reported stutter). A repaint is still + // forced periodically so OSD text (which changes on its own timer) and + // a resize settle. + val newest = vm.framesRcvd.toLong() + if (newest == lastPainted && SystemClock.elapsedRealtime() - lastPaintMs < 300) { + Thread.sleep(8) + continue + } + lastPainted = newest + lastPaintMs = SystemClock.elapsedRealtime() + val canvas = holder.lockCanvas() + if (canvas == null) { + // surface not ready (or being resized): do NOT spin on it + Thread.sleep(16) + continue + } try { drawFrame(canvas) } finally { holder.unlockCanvasAndPost(canvas) } try { - Thread.sleep(33) + Thread.sleep(16) } catch (_: InterruptedException) { } } @@ -162,32 +183,48 @@ class LiveRenderer( bitmap.setPixels(flipped, 0, 320, 0, 0, 320, 240) } - private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) { + /** + * 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. + */ + 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] - val dotR = 4.5f * density + // 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, dotR, markerPaint) + canvas.drawCircle(cx, cy, AnnotSpec.DOT_R * k, markerPaint) markerPaint.style = Paint.Style.STROKE - markerPaint.strokeWidth = 2.5f * density - canvas.drawCircle(cx, cy, dotR + 5f * density, markerPaint) + 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 pad = 6f * density + 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(dotR + 5f * density, -(dotR + 5f * density)) + 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] - pad >= viewport.left + 2f * density && - c[0] + half[0] + pad <= viewport.right - 2f * density && - c[1] - half[1] >= viewport.top + 2f * density && - c[1] + half[1] <= viewport.bottom - 2f * density + 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 } @@ -264,12 +301,19 @@ 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) + // 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, "高") + 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, "低") + 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) 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 1ef621f..343b92e 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 @@ -185,8 +185,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { } } + /** + * Count of frames pushed to the UI. The renderer uses it to skip repainting + * when no new frame arrived (the camera is 15 fps; painting faster wastes + * canvas time and shows up as stutter). + */ @Volatile - private var framesRcvd = 0 + var framesRcvd = 0 + private set private var usbDetachReceiver: android.content.BroadcastReceiver? = null @@ -259,6 +265,17 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { if (rec != null && rec.isRecording()) { val bmp = android.graphics.Bitmap.createBitmap(320, 240, android.graphics.Bitmap.Config.ARGB_8888) bmp.setPixels(argb, 0, 320, 0, 0, 320, 240) + // Hand over the CURRENT annotations so the recording shows the same + // markers and temperatures as the screen. Read per frame because the + // user can add or remove probes while recording. + rec.marks = probesAsMarks() + if (rec.frameCount == 0) { + // evidence the recording really carries annotations: the first + // frame logs how many markers and which extremes were burned in + com.mag160c.thermal.media.DebugLog.log( + "rec", "annotation: ${rec.marks.size} markers burned into video frames", + ) + } rec.offerFrame(bmp) } } @@ -523,64 +540,84 @@ 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 { + com.mag160c.thermal.media.MarkerPainter.Mark( + p.x.toFloat(), p.y.toFloat(), p.label, it, + ) + } + } + /** * Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore. * - * Three things must line up, or the offline analysis shows wrong numbers or - * misplaced markers (all three were broken on the device): - * 1. the JPEG is saved in the on-screen orientation (flips then rotation); - * 2. the NUC counts — the CALIBRATED data the live readouts use — are stored - * in that same photo pixel order, so an offline temperature lookup is a - * plain index and reproduces the live values (storing the raw sensor - * response instead gave 145 C / -161 C on a 30 C scene); - * 3. probe coordinates are converted to photo pixels too (they used to be - * stored as sensor coordinates and then drawn as if they were photo - * coordinates, which put the markers in the wrong place). + * Orientation (user decision 2026-09-12): the saved photo keeps the SENSOR's + * orientation, and the burned-in text is horizontal in that same frame, so + * text direction and image direction always agree with the sensor. The portrait + * display rotation is deliberately NOT baked in — a measurement record should + * show what the sensor saw, and this stays compatible with the vendor's own MDT + * files. The user's manual mirror corrections are applied (they describe the + * sensor mounting, not the display). + * + * The NUC counts are stored on the SENSOR grid (160x120) because the photo has + * no rotation relative to the sensor: the analysis lookup is then a uniform + * scale, with no per-pixel rotation math that could disagree with where the + * markers were drawn. */ fun capturePhoto(context: android.content.Context, density: Float = 2f) { val frame = latestFrame ?: return val s = session val st = _state.value - val orientation = com.mag160c.thermal.media.PhotoSaver.Orientation( - rotateDeg = com.mag160c.thermal.ui.live.ImageTransform - .params(userRotateDeg, flipH, flipV).rotDeg, - flipH = flipH, - flipV = flipV, - ) + val mirror = com.mag160c.thermal.media.PhotoSaver.Mirror(flipH, flipV) - // NUC counts: the CALIBRATED 160x120 data the live readouts use, expanded - // to one entry per PHOTO pixel so the offline lookup needs no index math. + // NUC counts: the calibrated 160x120 data the live readouts use val nuc160 = IntArray(19200) val haveNuc = s.copyNuc(nuc160) - val nucPhoto = if (haveNuc) { - com.mag160c.thermal.media.PhotoSaver.buildPhotoOrderedCounts(nuc160, orientation) - } else null - // probes: sensor coordinates -> photo pixels - val marks = st.probes.mapNotNull { p -> - p.tempC?.let { t -> - val pos = com.mag160c.thermal.media.PhotoSaver.sensorToImage( - p.x, p.y, 320, 240, - ((orientation.rotateDeg % 360) + 360) % 360, - orientation, - ) - com.mag160c.thermal.media.PhotoSaver.ProbeMark(pos[0], pos[1], p.label, t) - } + // max/min markers, in sensor coordinates, so the photo carries the same + // extremes the screen showed (only when the trace setting asks for them) + val extremes = ArrayList(2) + val tm = st.traceMode + if (tm.showsMax && st.maxPos >= 0 && st.maxTempC != null) { + 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, + ), + ) + } + if (tm.showsMin && st.minPos >= 0 && st.minTempC != null) { + 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, + ), + ) } val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered( frame = frame, - orientation = orientation, - probes = marks, - density = density, + mirror = mirror, + probes = st.probes.mapNotNull { p -> + p.tempC?.let { + com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it) + } + }, + extremes = extremes, ) val rawFrame = s.lastRawFrame val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) { rawFrame.copyOfRange(0x1C, 0x1C + 38400) } else null + // probes are stored in SENSOR coordinates — the same space the photo uses, + // so the analysis screen needs no conversion either val probeBlock = com.mag160c.thermal.media.Mdt.encodeProbes( - marks.map { - com.mag160c.thermal.media.Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) + st.probes.mapNotNull { p -> + p.tempC?.let { + com.mag160c.thermal.media.Mdt.Probe(p.x, p.y, p.label, (it * 1000).toInt()) + } }, ) val mdt = com.mag160c.thermal.media.Mdt.compose( @@ -589,9 +626,9 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { info1 = s.lastInfo1, framePixels = pixels, probes = probeBlock, - nucPixels = nucPhoto?.let { - com.mag160c.thermal.media.PhotoSaver.countsToBytes(it) - }, + nucPixels = if (haveNuc) { + com.mag160c.thermal.media.PhotoSaver.packNucForPhoto(nuc160) + } else null, ) val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt( context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(), @@ -599,8 +636,9 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { _state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail") com.mag160c.thermal.media.DebugLog.log( "vm", - "capture: nuc=${if (haveNuc) "yes" else "no"} probes=${marks.size} " + - "rot=${orientation.rotateDeg} saved=${saved != null}", + "capture: nuc=${if (haveNuc) "yes" else "no"} probes=${st.probes.size} " + + "mirror(h=${mirror.flipH},v=${mirror.flipV}) " + + "extremes=${extremes.size} saved=${saved != null}", ) } 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 cf22e29..ee30b3e 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 @@ -5,79 +5,72 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * The NUC block is what makes offline measurement correct. + * Photo geometry and the NUC block (sensor-space design, 2026-09-12). * - * Root cause fixed here (2026-09-11): the analysis screen converted the RAW sensor - * frame, which is pre-NUC data, so a 30 C scene reported 145 C max / -161 C min. - * The photo now carries the CALIBRATED counts in its own pixel order, and the - * lookup is a plain index — these tests pin that the re-ordering really is a - * bijection into the photo's layout and that no sample is lost or duplicated. + * Policy being verified: + * - the saved photo keeps the SENSOR's orientation (4:3 landscape), so photo + * direction and burned-in text direction both match the sensor; + * - the user's manual mirrors are applied (sensor-mount corrections); + * - the NUC block is stored on the 160x120 sensor grid, because the photo has no + * rotation relative to the sensor — the offline lookup is a uniform scale. */ class PhotoNucMappingTest { - /** Counts encoding a known position, so the mapping can be verified per pixel. */ private fun rampCounts(): IntArray = IntArray(160 * 120) { it } - @Test - fun nucBlockHasOneCountPerPhotoPixel() { - // 1:1 with the photo, so a lookup is counts[iy * photoW + ix] with no - // transforms. The first implementation filled only every 4th entry (a - // 160x120 grid scattered into a 320x240 photo), and every other lookup - // read 0 — which surfaced as -161 C in the analysis panel. - for (rot in intArrayOf(0, 90, 180, 270)) { - val out = PhotoSaver.buildPhotoOrderedCounts( - rampCounts(), PhotoSaver.Orientation(rot, false, false), - ) - val expected = if (rot % 180 == 0) 320 * 240 else 240 * 320 - assertEquals("dense block for rot=$rot", expected, out.size) - // Every pixel must carry a REAL sample: the 2x upscale makes each - // sensor sample appear ~4 times, so the distinct values must be exactly - // the sensor grid (a sparse/scattered fill would leave most entries at - // the default 0, which is what produced -161 C in the analysis panel). - val distinct = out.toSet() - assertEquals("all 19200 samples present for rot=$rot", 160 * 120, distinct.size) - assertEquals("no out-of-range samples", 160 * 120 - 1, distinct.max()) - } - } + private val noMirror = PhotoSaver.Mirror(false, false) @Test - fun centreOfThePhotoHoldsTheCentreSensorSample() { - val out = PhotoSaver.buildPhotoOrderedCounts( - rampCounts(), PhotoSaver.Orientation(90, false, false), - ) - val photoW = 240 - val photoH = 320 - val centre = out[(photoH / 2) * photoW + photoW / 2] - val mid = 160 * 120 / 2 + fun renderScaleIsAppliedToTheSavedPhotoSize() { + // keeps the sharpness fix pinned: text drawn at 1x was visibly soft + assertTrue("photo is rendered larger than the sensor frame", PhotoSaver.RENDER_SCALE >= 2) + assertEquals(320, PhotoSaver.SENSOR_W) + assertEquals(240, PhotoSaver.SENSOR_H) + // the saved photo therefore stays 4:3 landscape — the sensor's own + // orientation, with no display rotation baked in assertTrue( - "centre value $centre should be near the ramp midpoint $mid", - centre in (mid - 4000)..(mid + 4000), + "sensor frame is landscape (4:3)", + PhotoSaver.SENSOR_W > PhotoSaver.SENSOR_H, ) } - /** - * The mapping must agree with the marker positions burned into the image: - * a probe stored at photo pixel (x,y) must measure the sensor sample that the - * photo shows at (x,y). Verified by round-tripping through the same functions - * the capture path uses. - */ @Test - fun probePhotoPixelMapsBackToItsSensorSample() { - for (rot in intArrayOf(0, 90, 180, 270)) { - for (flipH in booleanArrayOf(false, true)) { - for (flipV in booleanArrayOf(false, true)) { - val o = PhotoSaver.Orientation(rot, flipH, flipV) - val r = ((rot % 360) + 360) % 360 - for (sx in intArrayOf(0, 40, 79, 120, 159)) { - for (sy in intArrayOf(0, 30, 59, 90, 119)) { - val photo = PhotoSaver.sensorToImage(sx, sy, 320, 240, r, o) - val back = PhotoSaver.photoToSensor(photo[0], photo[1], 320, 240, r, o) - assertTrue( - "rot=$rot flipH=$flipH flipV=$flipV sensor ($sx,$sy) -> " + - "photo (${photo[0]},${photo[1]}) -> sensor (${back[0]},${back[1]})", - kotlin.math.abs(back[0] - sx) <= 2 && kotlin.math.abs(back[1] - sy) <= 2, - ) - } + fun sensorToPhotoIsAPlainScaleWithoutMirror() { + val pos = PhotoSaver.sensorToPhoto(0, 0, noMirror, 960, 720) + assertEquals(3f, pos[0], 0.01f) // sensor pixel 0 centre -> 1.5/160 of width + assertEquals(3f, pos[1], 0.01f) + val mid = PhotoSaver.sensorToPhoto(79, 59, noMirror, 960, 720) + assertEquals(960f / 2f, mid[0], 4f) + assertEquals(720f / 2f, mid[1], 4f) + } + + @Test + fun mirrorFlipsTheMappingConsistently() { + val h = PhotoSaver.Mirror(true, false) + val left = PhotoSaver.sensorToPhoto(0, 0, noMirror, 960, 720) + val flipped = PhotoSaver.sensorToPhoto(0, 0, h, 960, 720) + assertEquals("flipH moves x to the far side", 960f, flipped[0], 4f) + assertEquals("y is untouched by flipH", left[1], flipped[1], 0.01f) + + val v = PhotoSaver.Mirror(false, true) + val flippedV = PhotoSaver.sensorToPhoto(0, 0, v, 960, 720) + assertEquals("flipV moves y to the bottom", 720f, flippedV[1], 4f) + } + + /** Photo pixel and sensor pixel must round-trip for every mirror combination. */ + @Test + fun photoToSensorInvertsSensorToPhoto() { + for (flipH in booleanArrayOf(false, true)) { + for (flipV in booleanArrayOf(false, true)) { + val m = PhotoSaver.Mirror(flipH, flipV) + for (sx in intArrayOf(0, 40, 79, 120, 159)) { + for (sy in intArrayOf(0, 30, 59, 90, 119)) { + val p = PhotoSaver.sensorToPhoto(sx, sy, m, 960, 720) + val back = PhotoSaver.photoToSensor(p[0].toInt(), p[1].toInt(), m, 960, 720) + assertTrue( + "flipH=$flipH flipV=$flipV sensor($sx,$sy) -> photo(${p[0]},${p[1]}) -> ${back}", + kotlin.math.abs(back.first - sx) <= 1 && kotlin.math.abs(back.second - sy) <= 1, + ) } } } @@ -85,90 +78,40 @@ class PhotoNucMappingTest { } @Test - fun countsByteRoundTrip() { - val counts = IntArray(19200) { (it * 7) and 0xFFFF } - val bytes = PhotoSaver.countsToBytes(counts) - assertEquals("two bytes per sample", 38400, bytes.size) - val back = PhotoSaver.bytesToCounts(bytes) - assertEquals(counts.size, back.size) - for (i in counts.indices) assertEquals("sample $i", counts[i], back[i]) - } - - @Test - fun countsAreClampedToSixteenBits() { - val counts = intArrayOf(-5, 0, 65535, 70000, 100000) - val back = PhotoSaver.bytesToCounts(PhotoSaver.countsToBytes(counts)) - assertEquals(0, back[0]) - assertEquals(0, back[1]) - assertEquals(65535, back[2]) - assertEquals(65535, back[3]) - assertEquals(65535, back[4]) - } - - @Test - fun nucBlockSurvivesAnMdtRoundTrip() { - val counts = IntArray(19200) { (it * 3) and 0xFFFF } - val mdt = Mdt.compose( - jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 1, 2, 0xFF.toByte(), 0xD9.toByte()), - info0 = null, info1 = null, framePixels = ByteArray(38400), - nucPixels = PhotoSaver.countsToBytes(counts), - ) - val parsed = Mdt.parse(mdt)!! - assertTrue("photo must be measurable", parsed.hasTemperatureData) - val back = PhotoSaver.bytesToCounts(parsed.nucPixels!!) + fun nucBlockIsTheSensorGrid() { + val counts = rampCounts() + val bytes = PhotoSaver.packNucForPhoto(counts) + // 160x120 u16 = 38400 bytes: the photo and the sensor share an orientation, + // so no re-ordering is needed (the old per-photo-pixel packing was 4x + // bigger AND silently dropped when its size check did not match) + assertEquals(38400, bytes.size) + val back = PhotoSaver.unpackNuc(bytes) assertEquals(19200, back.size) for (i in counts.indices) assertEquals("sample $i", counts[i], back[i]) } - /** - * Regression for the on-device failure (2026-09-12): the real NUC block is - * 1:1 with the PHOTO (240x320 = 153600 B), but compose() accepted only exactly - * 38400 bytes and silently dropped anything else — so every photo taken with - * the live orientation came out unmeasurable while the capture log still said - * "nuc=yes". - */ @Test - fun fullSizePhotoNucBlockIsStored() { - val photoCounts = PhotoSaver.buildPhotoOrderedCounts( - IntArray(19200) { it }, PhotoSaver.Orientation(90, false, false), - ) - assertEquals("240x320 photo", 240 * 320, photoCounts.size) - val bytes = PhotoSaver.countsToBytes(photoCounts) - assertEquals("two bytes per photo pixel", 153600, bytes.size) - + fun nucRoundTripSurvivesAnMdtContainer() { + val counts = rampCounts() val mdt = Mdt.compose( - jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()), + jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 1, 2, 0xFF.toByte(), 0xD9.toByte()), info0 = null, info1 = null, framePixels = ByteArray(38400), - nucPixels = bytes, + nucPixels = PhotoSaver.packNucForPhoto(counts), ) val parsed = Mdt.parse(mdt)!! - assertTrue( - "a 153600-byte block must be stored, not silently dropped", - parsed.hasTemperatureData, - ) - assertEquals(153600, parsed.nucPixels!!.size) - } - - @Test - fun oddSizedNucBlocksAreRejected() { - // a malformed length would corrupt the u16 lookup; it must not be stored - val mdt = Mdt.compose( - jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()), - info0 = null, info1 = null, framePixels = null, - nucPixels = ByteArray(100), - ) - assertTrue("too small -> dropped", !Mdt.parse(mdt)!!.hasTemperatureData) + assertTrue("photo must be measurable", parsed.hasTemperatureData) + val back = PhotoSaver.unpackNuc(parsed.nucPixels!!) + for (i in counts.indices) assertEquals("sample $i", counts[i], back[i]) } @Test fun photosWithoutNucAreReportedAsUnmeasurable() { - // older files (and plain captures) must NOT be silently measurable: the - // UI has to say "no temperature data" rather than print a wrong number + // older files must NOT be silently measurable: the UI has to say "no + // temperature data" rather than print a wrong number val mdt = Mdt.compose( jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()), info0 = null, info1 = null, framePixels = ByteArray(38400), ) - val parsed = Mdt.parse(mdt)!! - assertTrue("no NUC block -> no temperature data", !parsed.hasTemperatureData) + assertTrue("no NUC block -> no temperature data", !Mdt.parse(mdt)!!.hasTemperatureData) } } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoOrientationTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoOrientationTest.kt deleted file mode 100644 index bd2de79..0000000 --- a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoOrientationTest.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.mag160c.thermal.media - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Photo orientation + annotation geometry. - * - * On-device reports this batch fixes (2026-09-11): - * - saved photos kept the RAW sensor orientation, so a photo taken with - * "竖直翻转" enabled looked different from the screen; - * - probe points were not stored or drawn on the photo at all. - * - * The pixel mapping below is what burns the markers into the saved image. It must - * agree with the bitmap transform (flips first, then clockwise rotation) or the - * markers land on the wrong spot. - */ -class PhotoOrientationTest { - - private fun rot(deg: Int, flipH: Boolean = false, flipV: Boolean = false) = - PhotoSaver.Orientation(deg, flipH, flipV) - - /** - * Sensor pixel -> photo pixel, w x h being the SOURCE buffer size. - * Coordinates are sampled at pixel CENTRES (x+0.5), which is what the - * production code does so a marker sits in the middle of its pixel; the - * resulting half-pixel offset is expected in the assertions below. - */ - private fun map( - sx: Int, sy: Int, - w: Int = 320, h: Int = 240, - orientation: PhotoSaver.Orientation, - ): Pair { - val rot = ((orientation.rotateDeg % 360) + 360) % 360 - val p = PhotoSaver.sensorToImage(sx, sy, w, h, rot, orientation) - return p[0] to p[1] - } - - /** Assert with a tolerance of one pixel (centre sampling + rounding). */ - private fun assertNear(expected: Pair, actual: Pair, msg: String) { - val dx = kotlin.math.abs(expected.first - actual.first) - val dy = kotlin.math.abs(expected.second - actual.second) - assertTrue("$msg: expected ~$expected but was $actual", dx <= 1 && dy <= 1) - } - - @Test - fun unlockedOrientationMapsCentreToCentre() { - // 90 deg (the locked default) turns the 4:3 buffer into a 3:4 photo; the - // sensor centre must stay at the photo centre - val (x, y) = map(79, 59, orientation = rot(90)) - val outW = 240 - val outH = 320 - assertTrue("centre x within a pixel or two of $outW/2: $x", kotlin.math.abs(x - outW / 2) <= 3) - assertTrue("centre y within a pixel or two of $outH/2: $y", kotlin.math.abs(y - outH / 2) <= 3) - } - - @Test - fun rotationMovesPointsTheWayTheBitmapDoes() { - // sensor corner pixel (0,0); sampling at its centre puts it just inside - // rot 0 -> near (0, 0) - // rot 90 -> near (h, 0) (right edge, top) - // rot 180 -> near (w, h) (bottom-right) - // rot 270 -> near (0, w) (left, bottom) - assertNear(0 to 0, map(0, 0, orientation = rot(0)), "rot 0") - assertNear(240 to 0, map(0, 0, orientation = rot(90)), "rot 90") - assertNear(320 to 240, map(0, 0, orientation = rot(180)), "rot 180") - assertNear(0 to 320, map(0, 0, orientation = rot(270)), "rot 270") - } - - @Test - fun flipHIsAppliedBeforeRotation() { - // with flipH the sensor's left column becomes the photo's right column - val plain = map(0, 0, orientation = rot(0)) - val flipped = map(0, 0, orientation = rot(0, flipH = true)) - assertNear(0 to 0, plain, "no flip") - assertNear(320 to 0, flipped, "flipH") - assertEquals("y unchanged by flipH", plain.second, flipped.second) - } - - @Test - fun flipVIsAppliedBeforeRotation() { - val plain = map(0, 0, orientation = rot(0)) - val flipped = map(0, 0, orientation = rot(0, flipV = true)) - assertNear(0 to 0, plain, "no flip") - assertNear(0 to 240, flipped, "flipV") - assertEquals("x unchanged by flipV", plain.first, flipped.first) - } - - /** - * The user's device finding, expressed for the photo pipeline: a vertical - * flip with the locked 90 rotation puts a point where a horizontal flip with - * 270 does. Markers must follow the same rule as the bitmap. - */ - @Test - fun flipVWith90MatchesFlipHWith270ForPhotoMarkers() { - for (sx in intArrayOf(0, 40, 79, 159)) { - for (sy in intArrayOf(0, 30, 59, 119)) { - val a = map(sx, sy, orientation = rot(90, flipV = true)) - val b = map(sx, sy, orientation = rot(270, flipH = true)) - val dx = kotlin.math.abs(a.first - b.first) - val dy = kotlin.math.abs(a.second - b.second) - assertTrue("($sx,$sy) -> a=$a b=$b", dx <= 1 && dy <= 1) - } - } - } - - @Test - fun photoSizeFollowsTheRotation() { - // 90/270 swap the axes (this is why a portrait photo of a landscape frame - // is the normal case), 0/180 keep them - fun sizeFor(deg: Int): Pair { - val rot = ((deg % 360) + 360) % 360 - val w = 320 - val h = 240 - return (if (rot % 180 == 0) w else h) to (if (rot % 180 == 0) h else w) - } - assertEquals(240 to 320, sizeFor(90)) - assertEquals(240 to 320, sizeFor(270)) - assertEquals(320 to 240, sizeFor(0)) - assertEquals(320 to 240, sizeFor(180)) - } -} diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 67c957c..1c38879 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:50e9db722933bab62525241b1d2fe912f93a2c65c1b6a8c8729d9d9ebf2b0567 -size 12764891 +oid sha256:07402bd08d21792466dd5f75c5be2eae4fee6505061b7df36ac3226f6bd7d1bf +size 12663188