From 4c940fe773409599e2c1e88f9bd42b48227f8578 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sat, 12 Sep 2026 14:34:54 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E7=85=A7=E7=89=87=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=AB=96=E5=B1=8F=E6=96=B9=E5=90=91=20+=20=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC=E9=87=8D=E5=81=9A=EF=BC=88=E5=8E=BB=E7=99=BD?= =?UTF-8?q?=E5=BA=95=EF=BC=8C=E5=87=86=E6=98=9F+=E6=8F=8F=E8=BE=B9?= =?UTF-8?q?=E5=AD=97=EF=BC=89+=20=E5=BD=95=E5=83=8F=203x=20=E7=BC=96?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照片方向(推翻上一轮的"传感器朝向"决定,用户明确否掉): 把显示旋转(锁定 90° + 手动旋转)与手动翻转一起烘进 JPEG,竖屏持机拍出来就是 竖屏 720x960,顺序与实时渲染器一致(先镜像再旋转)。温度数据不跟着转——probes/ NUC/extremes 仍在原始传感器空间,旋转只存在于呈现层;BLOCK_RENDER 升到 version 2 多存 rotDeg,旧文件(8B v1 块)解析为 rot=0,符合它们确实是传感器朝向的事实。 分析页映射改为直接复用 PhotoSaver.sensorToPhoto(...,1,1),杜绝两处各算一套。 标注风格(参考 FLIR/Testo 的仪表做法): - 图标改为细线方形准星 + 四短臂(原为圆环+圆点):方框限定测量区域、四臂指明确切 像素、中心镂空不遮挡被测点。 - 文字白字+深色描边(先描边后填充),去掉白色底板——白底挡住被测画面且像消费级 App; 描边让文字在黑冷端和白色热端都读得清。 - 极值改用 MAX/MIN 大写 + 引线把读数连到自己的准星;颜色仍取白色(彩色标点在铁虹 橙黄区会糊掉,区分靠文字)。 标注尺寸统一:AnnotSpec 的 320 单位是图像长边,实时界面原用 viewport.width()(竖屏 时是短边)去除 320,使实时标点只有照片的 3/4 大——这正是用户早先"照片标点太大"的 由来。现四处统一 max(w,h)/320。 录像清晰度:编码尺寸改为传感器 x3(竖屏 720x960),码率随像素数放大;帧的翻转+旋转 用一个 Matrix 一次 drawBitmap 完成,标点经 sensorToPhoto 落在同一变换下(矩阵复合 结果与 sensorToPhoto 按坐标推导核对一致)。实测 tkhd 720x960 / avc1 / 96帧 0丢失。 分析页"每个标记出现两次":不是坐标错,而是标签避让与绘制顺序有关——拍摄按 probes→MAX→MIN、分析按 MAX→MIN→probes,避让把标签推到不同位置。改为同序后叠加 完全重合(真机裁剪对比确认)。 排查方法:验证"文件里标记位置是否正确"不靠肉眼看截图叠加,直接解析 MDT 算期望像素 再统计该处中性白色像素数——据此一次证伪"旋转没生效":rot=90 处 markerPixels= 204/219/296/719,rot=0 处全为 0。 103 项测试全绿(新增 mirror x rotation 往返、旋转角点、v1→v2 渲染块兼容)。 --- .../com/mag160c/thermal/core/AnnotSpec.kt | 78 ++++++--- .../mag160c/thermal/media/MarkerPainter.kt | 113 ++++++------ .../kotlin/com/mag160c/thermal/media/Mdt.kt | 50 ++++-- .../com/mag160c/thermal/media/Mp4Recorder.kt | 72 ++++++-- .../com/mag160c/thermal/media/PhotoSaver.kt | 161 +++++++++++++----- .../thermal/ui/analyze/AnalyzeViewModel.kt | 44 +++-- .../thermal/ui/analyze/AnalyzeViewer.kt | 57 ++++--- .../mag160c/thermal/ui/live/LiveRenderer.kt | 12 +- .../mag160c/thermal/ui/live/LiveViewModel.kt | 59 +++++-- .../thermal/media/PhotoNucMappingTest.kt | 121 +++++++++++-- build-artifacts/mag160c-app-debug.apk | 2 +- docs/android_app/session_state.md | 70 ++++++++ v_live.png | 3 - 13 files changed, 611 insertions(+), 231 deletions(-) delete mode 100644 v_live.png 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 5f565de..6bf533a 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 @@ -3,36 +3,70 @@ 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. + * saved photos and recorded video. * - * 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. + * ## Style (redesigned 2026-09-12, at the user's request) + * + * "工业感" — modelled on the spot meters the big thermal brands use (FLIR, Testo, + * Hikmicro). Those overlays are drawn as thin white line work directly on the + * image, with the readout as outlined text; nothing is filled, because a filled + * plate hides the very pixels the reading is about and reads as a consumer app. + * + * The glyph is a square reticle with four arms: the arms make the exact measured + * pixel unambiguous, and the open centre keeps the measured area visible. The + * label carries a dark outline instead of a light background, which is what keeps + * white text legible over both the black cold end and the white hot end of every + * palette. + * + * 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 + // ---- reticle (multiples of the image scale factor) ---- + /** Half side of the square reticle. */ + const val SPOT_R = 4.2f - /** 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 + /** Length of each arm beyond the square. */ + const val SPOT_ARM = 3.0f + + /** Stroke width of the reticle and the leader. */ + const val SPOT_W = 1.0f + + // ---- label ---- + /** Label text height in image units. */ + const val TEXT_SIZE = 9.5f + + /** Outline half-width around each glyph; this replaces the old white plate. */ + const val TEXT_OUTLINE = 1.1f + + /** Gap between the reticle arm and the start of the text. */ + const val LABEL_GAP = 4f + + /** + * Padding around the text used ONLY for the label-collision rect. There is no + * filled plate any more, so this is small — just enough that two labels cannot + * touch glyph to glyph. + */ + const val LABEL_PAD_H = 1.5f 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() + /** Marker / text colour. Monochrome on purpose: see the class note. */ + const val MARK_WHITE = 0xFFFFFFFF.toInt() - /** Extreme markers use the same glyph as probes, tinted. */ - const val EXTREME_TINT = 0xFFFFD54F.toInt() + /** The dark outline that makes [MARK_WHITE] readable on any palette. */ + const val MARK_OUTLINE = 0xFF000000.toInt() + + /** + * Colour of the max/min markers. Deliberately the same white as the probes: + * that is what the reference instruments do, and it stays readable over the + * saturated orange/yellow that a coloured marker would disappear into. The + * extremes are told apart by their `MAX` / `MIN` text. + */ + const val EXTREME_TINT = MARK_WHITE fun scaleFor(imageWidth: Int): Float = imageWidth / REF_W @@ -42,10 +76,10 @@ object AnnotSpec { /** * 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 + * reticle, 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 + fun labelOffsetX(scale: Float): Float = (SPOT_R + SPOT_ARM + LABEL_GAP) * scale /** * Where to put a label so it stays inside [imgW]x[imgH] and does not sit on 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 e7d6e94..cd8950a 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 @@ -1,16 +1,18 @@ 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. + * Draws temperature markers onto a canvas with the SAME geometry every surface + * uses (live screen, analysis screen, saved photos, recorded video), at whatever + * resolution the caller is rendering. + * + * The look is the instrument convention: a thin square reticle with four arms and + * an outlined readout beside it, drawn straight onto the image with nothing + * filled. See [AnnotSpec] for why. * * 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 @@ -32,13 +34,12 @@ object MarkerPainter { * @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. + * reference) to target pixels; pass the render scale for a photo or video + * 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. + * horizontal in the saved frame. The reticle itself is never rotated. */ fun draw( canvas: Canvas, @@ -50,77 +51,87 @@ object MarkerPainter { ) { if (marks.isEmpty()) return val k = imageUnitsToPixels - val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE + val typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + + // Thin white line work, no fills: the reticle, the arms and the leader. + val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = AnnotSpec.RING_W * k - setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK) + strokeWidth = AnnotSpec.SPOT_W * k + strokeCap = Paint.Cap.BUTT + color = AnnotSpec.MARK_WHITE } - val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE + // The readout is white text inside a dark outline. That is what keeps it + // readable over the black cold end AND the white hot end of every palette — + // the reason there is no filled plate behind it. + val textFill = Paint(Paint.ANTI_ALIAS_FLAG).apply { 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 + color = AnnotSpec.MARK_WHITE textSize = AnnotSpec.TEXT_SIZE * k - typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + this.typeface = typeface + } + val textOutline = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = AnnotSpec.TEXT_OUTLINE * k + strokeJoin = Paint.Join.ROUND + color = AnnotSpec.MARK_OUTLINE + textSize = AnnotSpec.TEXT_SIZE * k + this.typeface = typeface } 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. + // Label rects committed so far: 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 - 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 color = m.tint ?: AnnotSpec.MARK_WHITE + line.color = color + val r = AnnotSpec.SPOT_R * k + val arm = AnnotSpec.SPOT_ARM * k + // square reticle, then one arm out of each side: the arms pin down which + // pixel is measured, the open centre keeps that pixel visible + canvas.drawRect(m.x - r, m.y - r, m.x + r, m.y + r, line) + canvas.drawLine(m.x - r - arm, m.y, m.x - r, m.y, line) + canvas.drawLine(m.x + r, m.y, m.x + r + arm, m.y, line) + canvas.drawLine(m.x, m.y - r - arm, m.x, m.y - r, line) + canvas.drawLine(m.x, m.y + r, m.x, m.y + r + arm, line) val full = (if (m.label.isNotEmpty()) "${m.label} " else "") + "%.1f℃".format(m.tempC) - val tw = text.measureText(full) - val fm = text.fontMetrics + val tw = textFill.measureText(full) + val fm = textFill.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 + // same placement rule as everywhere else (right of the reticle, flipped + // when it would overflow, pushed down when it would collide) val pos = com.mag160c.thermal.core.AnnotSpec.placeLabel( cx = m.x, cy = m.y, boxW = boxW, boxH = boxH, 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. + // the screen. The reticle 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), - 2f * k, 2f * k, box, - ) - text.color = AnnotSpec.LABEL_FG + // leader: ties the readout to its own reticle when several are on screen + val labelY = pos[1] + boxH / 2f + if (pos[0] > m.x) { + canvas.drawLine(m.x + r + arm, m.y, pos[0], labelY, line) } else { - // extreme markers carry no box: tinted text keeps the image clear - text.color = tint - text.setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK) + canvas.drawLine(m.x - r - arm, m.y, pos[0] + boxW, labelY, line) } val baseline = pos[1] + padV - fm.ascent - canvas.drawText(full, pos[0] + padH, baseline, text) - text.clearShadowLayer() + // outline first, fill second: half the stroke lands inside the glyph and + // is covered, so the visible result is a dark halo around white text + textOutline.color = AnnotSpec.MARK_OUTLINE + canvas.drawText(full, pos[0] + padH, baseline, textOutline) + textFill.color = color + canvas.drawText(full, pos[0] + padH, baseline, textFill) 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 0f3d580..7e0a2cc 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 @@ -62,17 +62,23 @@ object Mdt { const val BLOCK_NUC = 0x5BB5B560 /** - * How the JPEG was rendered: {u32 version, u32 flags}. + * How the JPEG was rendered: {u32 version, u32 flags[, u32 rotDeg]}. * * 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). + * sensor-mount corrections), applied BEFORE the rotation, exactly as the live + * renderer does. Version 2 adds the clockwise rotation the capture baked in + * (0/90/180/270) — photos are saved in the orientation the user was looking at, + * so a reader that assumes an unrotated frame draws every marker in the wrong + * place. An 8-byte block (version 1, older files) means no rotation. + * + * All of this is needed because probes and the NUC grid are in raw SENSOR space: + * a viewer must apply mirror-then-rotation to line them up with the image. */ const val BLOCK_RENDER = 0x5BB5B561 + /** Current version of the [BLOCK_RENDER] payload. */ + const val RENDER_VERSION = 2 + /** * The max/min the capture recorded, in SENSOR coordinates: * "minX,minY,minMc,maxX,maxY,maxMc" (UTF-8 text, like the probe block). @@ -95,28 +101,46 @@ object Mdt { 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 + data class RenderParams( + val flipH: Boolean, + val flipV: Boolean, + /** Clockwise rotation baked into the JPEG (0/90/180/270). */ + val rotDeg: Int = 0, + ) { + 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) + val NONE = RenderParams(false, false, 0) - fun fromFlags(flags: Int): RenderParams = - RenderParams(flags and RENDER_FLAG_FLIP_H != 0, flags and RENDER_FLAG_FLIP_V != 0) + fun fromFlags(flags: Int, rotDeg: Int = 0): RenderParams = + RenderParams( + flags and RENDER_FLAG_FLIP_H != 0, + flags and RENDER_FLAG_FLIP_V != 0, + rotDeg, + ) } } fun encodeRenderParams(p: RenderParams): ByteArray { - val out = ByteArray(8) - put32(out, 0, 1) + val out = ByteArray(12) + put32(out, 0, RENDER_VERSION) put32(out, 4, p.flags) + put32(out, 8, p.rotDeg) return out } + /** + * Parse the render block. A version-1 block (8 bytes) carries no rotation, which + * is correct for the photos written before rotation was baked in — they really + * are in sensor orientation. + */ fun parseRenderParams(bytes: ByteArray?): RenderParams { if (bytes == null || bytes.size < 8) return RenderParams.NONE - return RenderParams.fromFlags(u32(bytes, 4)) + val flags = u32(bytes, 4) + val rot = if (bytes.size >= 12) u32(bytes, 8) else 0 + return RenderParams.fromFlags(flags, rot) } /** 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 6ffab97..b8cd6e0 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 @@ -11,12 +11,22 @@ import java.io.File import java.util.concurrent.atomic.AtomicBoolean /** - * MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor - * .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec - * handles color conversion; frames arrive as ARGB bitmaps. + * MP4 (H.264) recorder for the live stream, replacing the vendor .mgs / FFmpeg + * recording paths. Uses a Surface-fed encoder so the codec handles color + * conversion; frames arrive as ARGB bitmaps. + * + * ENCODED SIZE: the caller passes the output size (see + * [com.mag160c.thermal.ui.live.LiveViewModel.RECORD_SCALE]). It is deliberately + * larger than the 320x240 sensor frame — the image gains nothing, but the burned-in + * temperature readouts are only as sharp as the frame they are drawn into, and at + * 320x240 they were blurry when the video was watched full screen. + * + * ORIENTATION: [mirror] and [rotDeg] are applied to every frame through one matrix, + * exactly the order the still-photo path uses (flip first, then rotate), so a video + * and a photo taken at the same moment show the same scene the same way up. * * THREADING (2026-09-11 fix): frames arrive on the USB reader thread while - * start/stop run on the UI thread. The first version read [inputSurface] and + * start/stop run on the UI thread. The first version read [inputSurface] and * then called lockCanvas on it, so a stop() in between released the Surface and * lockCanvas threw on the reader thread — an uncaught exception that killed the * app the moment recording stopped. Every surface/encoder access is now under @@ -25,7 +35,13 @@ import java.util.concurrent.atomic.AtomicBoolean */ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { private val fps = 15 - private val bitRate = 2_000_000 + + /** Scale the bitrate with the pixel count so a bigger frame keeps its quality. */ + private val bitRate = (width.toLong() * height * 10).toInt().coerceIn(2_000_000, 20_000_000) + + /** Source frame the pipeline produces (sensor render). */ + private val srcW = 320 + private val srcH = 240 private val lock = Any() private var encoder: MediaCodec? = null @@ -36,6 +52,7 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { private val active = AtomicBoolean(false) private val canvas = Canvas() private val paint = Paint() + private val frameMatrix = android.graphics.Matrix() /** Frames accepted since start (diagnostics). */ @Volatile @@ -54,6 +71,10 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { @Volatile var mirror: PhotoSaver.Mirror = PhotoSaver.Mirror(false, false) + /** Clockwise rotation baked into recorded frames (display orientation). */ + @Volatile + var rotDeg: Int = 0 + /** Frames dropped because the encoder was busy or gone (diagnostics). */ @Volatile var droppedCount: Int = 0 @@ -113,23 +134,42 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { return } try { - c.drawBitmap( - bmp, null, - android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), - paint, - ) + // One matrix does flip-then-rotate and fits the source into the + // encoded frame, so the reader thread does a single draw call + // instead of transforming bitmaps. + val m = mirror + val rot = PhotoSaver.normalizeDeg(rotDeg) + val swapped = rot == 90 || rot == 270 + val scale = width.toFloat() / if (swapped) srcH else srcW + frameMatrix.reset() + frameMatrix.postTranslate(-srcW / 2f, -srcH / 2f) + if (m.flipH) frameMatrix.postScale(-1f, 1f) + if (m.flipV) frameMatrix.postScale(1f, -1f) + frameMatrix.postRotate(rot.toFloat()) + frameMatrix.postScale(scale, scale) + frameMatrix.postTranslate(width / 2f, height / 2f) + c.drawBitmap(bmp, frameMatrix, 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()) { + val mks = marks + if (mks.isNotEmpty()) { + val converted = mks.map { mk -> + val p = PhotoSaver.sensorToPhoto( + mk.x.toInt().coerceIn(0, 159), + mk.y.toInt().coerceIn(0, 119), + m, rot, width, height, + ) + MarkerPainter.Mark(p[0], p[1], mk.label, mk.tempC, mk.tint) + } MarkerPainter.draw( canvas = c, - marks = m, + marks = converted, imgW = width.toFloat(), imgH = height.toFloat(), - // the encoder receives the 320x240 render, so - // AnnotSpec units (based on 320) map 1:1 - imageUnitsToPixels = width / 320f, + // AnnotSpec units measure the IMAGE, whose long side is + // 320 units whatever the rotation — using the frame's + // width instead made markers shrink in portrait mode. + imageUnitsToPixels = maxOf(width, height) / 320f, ) } } finally { 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 6807d7f..bfd9b74 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 @@ -16,29 +16,36 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import com.mag160c.thermal.core.AnnotSpec /** * 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) + * ## Orientation policy (user decision, 2026-09-12 — REVISED) * - * 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 saved photo is written in the SAME orientation the user is looking at: the + * display rotation (90 deg for the portrait-locked live view) plus the user's + * manual rotate correction are baked in, so a photo taken while holding the phone + * upright comes out upright (3:4 portrait), not as the sensor's 4:3 landscape + * frame. The user reported the landscape file as a bug: "竖屏拍照的时候,出来的 + * 照片不是竖屏的啊". * - * The user's manual flip corrections (水平翻转/竖直翻转) ARE applied, because they - * describe how the sensor is mounted rather than how it is displayed. + * The user's manual flips (水平翻转/竖直翻转) are applied BEFORE the rotation, + * exactly as the live renderer does it, so the file shows what the screen showed. + * + * The temperature data does NOT follow this rotation: probes, the NUC grid and the + * extremes stay in raw SENSOR space, and [Mdt.RenderParams] records the mirror and + * the rotation so any reader can map them onto the pixels. Keeping the data in one + * fixed space is what stops the photo and the measurement from drifting apart. * * ## 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. + * Rendered at [RENDER_SCALE]x the sensor size (3x -> 960x720, or 720x960 when the + * rotation makes it portrait). 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) @@ -73,17 +80,20 @@ object PhotoSaver { } /** - * Render the sensor frame for saving: mirrored per the user's settings, scaled - * by [RENDER_SCALE], with the probe markers burned in. + * Render the sensor frame for saving: mirrored and rotated per the user's + * settings, scaled by [RENDER_SCALE], with the markers burned in. * * @param frame 320x240 ARGB render of the sensor image - * @param mirror manual flip corrections (sensor mounting) + * @param mirror manual flip corrections (sensor mounting), applied first + * @param rotDeg clockwise rotation baked in afterwards — the live display + * rotation plus the user's manual correction, so the file matches the screen * @param probes probes in SENSOR coordinates (0..159, 0..119) * @param extremes optional max/min markers, also sensor coordinates */ fun encodeRendered( frame: IntArray, mirror: Mirror = Mirror(false, false), + rotDeg: Int = 0, probes: List = emptyList(), extremes: List = emptyList(), w: Int = SENSOR_W, @@ -93,8 +103,9 @@ object PhotoSaver { val src = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) src.setPixels(frame, 0, w, 0, 0, w, h) - // mirror (sensor-mount correction) — NO display rotation: the photo must - // match the sensor's own orientation + // 1) mirror (sensor-mount correction), 2) rotate (display orientation). + // This is the same order the live renderer applies, so the file matches + // what the user was looking at when they pressed the shutter. var work = src if (mirror.flipH || mirror.flipV) { val m = Matrix().apply { @@ -106,9 +117,13 @@ object PhotoSaver { } work = Bitmap.createBitmap(src, 0, 0, w, h, m, true) } + val rot = normalizeDeg(rotDeg) + if (rot != 0) { + work = rotateBitmap(work, rot) + } - val outW = w * RENDER_SCALE - val outH = h * RENDER_SCALE + val outW = work.width * RENDER_SCALE + val outH = work.height * RENDER_SCALE val out = if (work.width == outW && work.height == outH) { work.copy(Bitmap.Config.ARGB_8888, true) } else { @@ -117,7 +132,7 @@ object PhotoSaver { val marks = ArrayList(probes.size + extremes.size) for (p in probes) { - val pos = sensorToPhoto(p.x, p.y, mirror, outW, outH) + val pos = sensorToPhoto(p.x, p.y, mirror, rot, outW, outH) marks.add(MarkerPainter.Mark(pos[0], pos[1], p.label, p.tempC)) } // The extremes arrive in SENSOR coordinates (same space as the probes) and @@ -126,42 +141,106 @@ object PhotoSaver { // 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) + val pos = sensorToPhoto(e.x.toInt(), e.y.toInt(), mirror, rot, 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 - // and the layout keeps the same proportions as the live screen. + // AnnotSpec units measure the IMAGE: its long side is 320 units whichever + // way it is rotated, so the scale comes from the long side. Using the + // frame width would make a portrait photo's markers smaller than a + // landscape one's for the same picture. MarkerPainter.draw( canvas = Canvas(out), marks = marks, imgW = outW.toFloat(), imgH = outH.toFloat(), - imageUnitsToPixels = outW / 320f, + imageUnitsToPixels = maxOf(outW, outH) / AnnotSpec.REF_W, ) } return encodeJpeg(out, quality) } + /** Rotation normalized to 0/90/180/270. */ + fun normalizeDeg(deg: Int): Int { + val d = ((deg % 360) + 360) % 360 + return when { + d < 45 || d >= 315 -> 0 + d < 135 -> 90 + d < 225 -> 180 + else -> 270 + } + } + /** - * 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. + * Rotate [src] by [rot] degrees CLOCKWISE, returning a bitmap of the rotated + * size. The pivot+translate pair is what keeps the result inside the new + * bitmap's bounds: rotating about the centre moves the content to + * [-w/2..w/2]x[-h/2..h/2]-ish, so it is shifted by half the size difference. */ - fun sensorToPhoto(sx: Int, sy: Int, mirror: Mirror, photoW: Int, photoH: Int): FloatArray { + private fun rotateBitmap(src: Bitmap, rot: Int): Bitmap { + val w = src.width + val h = src.height + val swapped = rot == 90 || rot == 270 + val w2 = if (swapped) h else w + val h2 = if (swapped) w else h + val m = Matrix().apply { + setRotate(rot.toFloat(), w / 2f, h / 2f) + postTranslate((w2 - w) / 2f, (h2 - h) / 2f) + } + return Bitmap.createBitmap(src, 0, 0, w, h, m, true) + } + + /** + * Sensor pixel -> saved-photo pixel, through the mirror and then the rotation. + * + * Both are affine and order-sensitive (a flip followed by a rotation is not the + * same as the rotation followed by the flip), so this mirrors the bitmap path + * exactly: normalize in sensor space, flip, then rotate clockwise. + */ + fun sensorToPhoto( + sx: Int, + sy: Int, + mirror: Mirror, + rotDeg: Int, + photoW: Int, + photoH: Int, + ): FloatArray { 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 floatArrayOf(u * photoW, v * photoH) + val r = normalizeDeg(rotDeg) + val ru: Float + val rv: Float + when (r) { + 90 -> { ru = 1f - v; rv = u } + 180 -> { ru = 1f - u; rv = 1f - v } + 270 -> { ru = v; rv = 1f - u } + else -> { ru = u; rv = v } + } + return floatArrayOf(ru * photoW, rv * 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 + fun photoToSensor( + px: Int, + py: Int, + mirror: Mirror, + rotDeg: Int, + photoW: Int, + photoH: Int, + ): Pair { + val ru = (px + 0.5f) / photoW + val rv = (py + 0.5f) / photoH + val r = normalizeDeg(rotDeg) + var u: Float + var v: Float + when (r) { + 90 -> { u = rv; v = 1f - ru } + 180 -> { u = 1f - ru; v = 1f - rv } + 270 -> { u = 1f - rv; v = ru } + else -> { u = ru; v = rv } + } if (mirror.flipH) u = 1f - u if (mirror.flipV) v = 1f - v val sx = (u * 160f).toInt().coerceIn(0, 159) @@ -210,19 +289,19 @@ object PhotoSaver { jpg: ByteArray, marks: List, mirror: Mirror = Mirror(false, false), + rotDeg: Int = 0, ): 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 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 + // carries the capture's mirror AND rotation. 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/rotated wrong side. val scaled = marks.map { val pos = sensorToPhoto( it.x.toInt().coerceIn(0, 159), it.y.toInt().coerceIn(0, 119), - mirror, out.width, out.height, + mirror, rotDeg, out.width, out.height, ) MarkerPainter.Mark(pos[0], pos[1], it.label, it.tempC, it.tint) } @@ -231,8 +310,8 @@ object PhotoSaver { marks = scaled, imgW = out.width.toFloat(), imgH = out.height.toFloat(), - // AnnotSpec units are calibrated for a 320-wide frame = 2 sensor pixels - imageUnitsToPixels = pxPerSensorX / 2f, + // AnnotSpec units measure the IMAGE; its long side is 320 units (see encodeRendered) + imageUnitsToPixels = maxOf(out.width, out.height) / AnnotSpec.REF_W, ) return encodeJpeg(out) } 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 67c6dbb..199a2c9 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 @@ -152,36 +152,34 @@ class AnalyzeViewModel( return PhotoSaver.Mirror(r.flipH, r.flipV) } + /** + * Clockwise rotation baked into the saved JPEG (0/90/180/270), from the + * container. Photos are written in the orientation the user was looking at, so + * this must be applied when mapping sensor-space measurement data onto the + * pixels. Version-1 files carry no rotation and report 0, which is correct for + * them. + */ + val photoRotDeg: Int + get() = (parsed?.render ?: Mdt.RenderParams.NONE).rotDeg + /** * 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. + * The JPEG carries the user's mirror corrections AND the capture rotation 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. */ 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)) + val p = PhotoSaver.sensorToPhoto(px, py, photoMirror, photoRotDeg, w, h) + return (p[0].toInt().coerceIn(0, w - 1)) to (p[1].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)) - } + private fun photoToSensor(px: Int, py: Int): Pair = + PhotoSaver.photoToSensor(px, py, photoMirror, photoRotDeg, _imageW.value, _imageH.value) /** Temperature (C) at a SENSOR pixel, from the stored NUC counts. */ fun measureSensor(sx: Int, sy: Int): Float? { @@ -252,7 +250,7 @@ class AnalyzeViewModel( } viewModelScope.launch(Dispatchers.IO) { val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92) - val annotated = PhotoSaver.annotateJpeg(jpg, probesAsMarks(), photoMirror) + val annotated = PhotoSaver.annotateJpeg(jpg, probesAsMarks(), photoMirror, photoRotDeg) val mdt = Mdt.compose( jpg = annotated, info0 = parsed?.info0, @@ -265,7 +263,7 @@ 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), + com.mag160c.thermal.media.Mdt.RenderParams(photoMirror.flipH, photoMirror.flipV, photoRotDeg), ), // the extremes are burned into the pixels already; keep the block so // the next reader still knows where they were @@ -296,7 +294,7 @@ class AnalyzeViewModel( ), nucPixels = parsed?.nucPixels, renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams( - com.mag160c.thermal.media.Mdt.RenderParams(photoMirror.flipH, photoMirror.flipV), + com.mag160c.thermal.media.Mdt.RenderParams(photoMirror.flipH, photoMirror.flipV, photoRotDeg), ), extremes = parsed?.extremes?.takeIf { com.mag160c.thermal.media.Mdt.Extremes.hasAny(it) 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 fa85338..b7ef283 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 @@ -175,17 +175,26 @@ fun AnalyzeViewer( // 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). + // Probes FIRST, then MAX, then MIN — the exact order the capture + // used when it burned the same markers into the JPEG. Label + // collision avoidance is order-dependent, so a different order + // placed the live labels beside the burned-in ones and the screen + // showed every marker twice with two different offsets. In the + // capture's order the overlay retraces the burned-in markers. val marks = ArrayList(4) + marks.addAll(probeMarks(vm.probes, rect, vm.photoMirror, vm.photoRotDeg)) if (vm.hasTemperatureData) { - 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) } + sensorMark(vm.maxPos, rect, vm.photoMirror, vm.photoRotDeg, "MAX", vm.maxTempC) + ?.let { marks.add(it) } + sensorMark(vm.minPos, rect, vm.photoMirror, vm.photoRotDeg, "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 + // AnnotSpec units measure the IMAGE, whose long side is 320 + // units whichever way it is rotated — the same rule the live, + // photo and video paths use. Deriving it from rect.width (the + // short side in portrait) shrank the markers on a portrait photo. + val k = maxOf(rect.width, rect.height) / com.mag160c.thermal.core.AnnotSpec.REF_W drawIntoCanvas { c -> com.mag160c.thermal.media.MarkerPainter.draw( canvas = c.nativeCanvas, @@ -331,9 +340,10 @@ private fun probeMarks( probes: List, rect: androidx.compose.ui.geometry.Rect, mirror: PhotoSaver.Mirror, + rotDeg: Int, ): List = probes.map { p -> - val (u, v) = sensorToDisplay(p.x, p.y, mirror) + val (u, v) = sensorToDisplay(p.x, p.y, mirror, rotDeg) com.mag160c.thermal.media.MarkerPainter.Mark( x = rect.left + u * rect.width, y = rect.top + v * rect.height, @@ -343,13 +353,14 @@ private fun probeMarks( } /** - * Overall max/min marker: the same glyph as a probe, tinted, labelled "max"/"min" + * Overall max/min marker: the same reticle as a probe, 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, mirror: PhotoSaver.Mirror, + rotDeg: Int, label: String, tempC: Float?, ): com.mag160c.thermal.media.MarkerPainter.Mark? { @@ -357,7 +368,7 @@ private fun sensorMark( val sx = pos % 160 val sy = pos / 160 if (sy >= 120) return null - val (u, v) = sensorToDisplay(sx, sy, mirror) + val (u, v) = sensorToDisplay(sx, sy, mirror, rotDeg) return com.mag160c.thermal.media.MarkerPainter.Mark( x = rect.left + u * rect.width, y = rect.top + v * rect.height, @@ -368,18 +379,22 @@ private fun sensorMark( } /** - * 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. + * Sensor pixel -> normalised (0..1) position in the DISPLAYED photo. + * + * Delegates to [PhotoSaver.sensorToPhoto] with a 1x1 target so the analysis screen + * and the file it is displaying cannot disagree — the mirror AND the capture + * rotation are applied in the one place that also produced the JPEG. The pixel + * CENTRE is used (sx + 0.5), so a marker lands on the middle of the sensor pixel + * rather than its corner. */ -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 +private fun sensorToDisplay( + sx: Int, + sy: Int, + mirror: PhotoSaver.Mirror, + rotDeg: Int, +): Pair { + val u = PhotoSaver.sensorToPhoto(sx, sy, mirror, rotDeg, 1, 1) + return u[0] to u[1] } /** 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 7f32a7c..4aad3f1 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 @@ -277,7 +277,7 @@ class LiveRenderer( 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, + scr[0], scr[1], "MAX", state.maxTempC, AnnotSpec.EXTREME_TINT, ), ) } @@ -285,7 +285,7 @@ class LiveRenderer( 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, + scr[0], scr[1], "MIN", state.minTempC, AnnotSpec.EXTREME_TINT, ), ) } @@ -295,8 +295,12 @@ class LiveRenderer( marks = marks, imgW = canvas.width.toFloat(), imgH = canvas.height.toFloat(), - // viewport width / 320 = screen pixels per AnnotSpec unit - imageUnitsToPixels = viewport.width() / AnnotSpec.REF_W, + // AnnotSpec units measure the IMAGE, whose long side is 320 units however + // it is rotated — the same rule the photo and video paths use, so a marker + // is the same size relative to the picture everywhere. Using the viewport + // WIDTH (the short side in portrait) made live markers 3/4 the size of the + // ones burned into a photo of the same scene. + imageUnitsToPixels = maxOf(viewport.width(), viewport.height()) / AnnotSpec.REF_W, textRotationDeg = textRot, ) } 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 b436439..d55aef7 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 @@ -118,6 +118,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** Color-bar footprint kept clear at the right edge (20dp bar + 2x12dp). */ const val PIP_RIGHT_MARGIN_DP = 32 + + /** + * Video is encoded at this multiple of the sensor frame (3x = 960x720, or + * 720x960 in portrait). The sensor data is only 160x120 either way, so this + * buys nothing for the image — it is what makes the burned-in readouts sharp, + * which is the whole point: at 1x the temperature text was visibly blurry. + */ + const val RECORD_SCALE = 3 } private var usbReceiver: android.content.BroadcastReceiver? = null @@ -619,7 +627,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { 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, + "MAX", st.maxTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT, ), ) } @@ -627,7 +635,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { 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, + "MIN", st.minTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT, ), ) } @@ -637,24 +645,26 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** * Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore. * - * 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). + * Orientation (user decision 2026-09-12, revised): the saved photo is written in + * the orientation the user is LOOKING AT — the display rotation plus the manual + * rotate correction are baked into the JPEG, so holding the phone upright gives + * an upright (3:4) photo instead of the sensor's 4:3 landscape frame. The manual + * flips are applied first, exactly as the live renderer does. [Mdt.RenderParams] + * records both so a reader can map the sensor-space measurement data onto the + * rotated pixels. * - * 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. + * The NUC counts are stored on the SENSOR grid (160x120), unrotated: the data + * stays in one fixed space rather than being resampled, and the rotation lives + * only in the presentation layer. */ fun capturePhoto(context: android.content.Context, density: Float = 2f) { val frame = latestFrame ?: return val s = session val st = _state.value val mirror = com.mag160c.thermal.media.PhotoSaver.Mirror(flipH, flipV) + // Same rotation the live view draws the image with, so the file matches the + // screen the user was looking at. + val rotDeg = imageParams().rotDeg // NUC counts: the calibrated 160x120 data the live readouts use val nuc160 = IntArray(19200) @@ -668,7 +678,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { extremes.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, + "MAX", st.maxTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT, ), ) } @@ -676,7 +686,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { extremes.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, + "MIN", st.minTempC, com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT, ), ) } @@ -684,6 +694,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered( frame = frame, mirror = mirror, + rotDeg = rotDeg, probes = st.probes.mapNotNull { p -> p.tempC?.let { com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it) @@ -714,7 +725,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { 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), + com.mag160c.thermal.media.Mdt.RenderParams(mirror.flipH, mirror.flipV, rotDeg), ), // 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 @@ -822,7 +833,6 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** Last known surface size, for probe screen mapping. */ @Volatile var uiViewW: Int = 1080 - @Volatile var uiViewH: Int = 2280 @@ -830,7 +840,20 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { fun toggleRecording(context: android.content.Context) { val rec = recorder if (rec == null) { - val r = com.mag160c.thermal.media.Mp4Recorder() + // Same orientation and scale policy as a photo, so the recording matches + // both the live screen and the stills: the capture rotation is baked in + // and the frame is encoded at RECORD_SCALE x the sensor size. Encoding at + // the native 320x240 made the burned-in readouts blurry once the video was + // viewed full screen ("视频里面的温度标点糊糊的"). + val p = imageParams() + val portrait = p.rotDeg == 90 || p.rotDeg == 270 + val r = com.mag160c.thermal.media.Mp4Recorder( + width = if (portrait) 240 * RECORD_SCALE else 320 * RECORD_SCALE, + height = if (portrait) 320 * RECORD_SCALE else 240 * RECORD_SCALE, + ).apply { + mirror = com.mag160c.thermal.media.PhotoSaver.Mirror(flipH, flipV) + rotDeg = p.rotDeg + } if (r.start()) { recorder = r _state.value = _state.value.copy(status = "recording") 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 05b454c..bfe7087 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 @@ -36,10 +36,10 @@ class PhotoNucMappingTest { @Test fun sensorToPhotoIsAPlainScaleWithoutMirror() { - val pos = PhotoSaver.sensorToPhoto(0, 0, noMirror, 960, 720) + val pos = PhotoSaver.sensorToPhoto(0, 0, noMirror, 0, 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) + val mid = PhotoSaver.sensorToPhoto(79, 59, noMirror, 0, 960, 720) assertEquals(960f / 2f, mid[0], 4f) assertEquals(720f / 2f, mid[1], 4f) } @@ -47,36 +47,121 @@ class PhotoNucMappingTest { @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) + val left = PhotoSaver.sensorToPhoto(0, 0, noMirror, 0, 960, 720) + val flipped = PhotoSaver.sensorToPhoto(0, 0, h, 0, 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) + val flippedV = PhotoSaver.sensorToPhoto(0, 0, v, 0, 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. */ + /** Photo pixel and sensor pixel must round-trip for every mirror/rotation. */ @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, - ) + for (rot in intArrayOf(0, 90, 180, 270)) { + val w = if (rot == 90 || rot == 270) 720 else 960 + val h = if (rot == 90 || rot == 270) 960 else 720 + 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, rot, w, h) + val back = PhotoSaver.photoToSensor( + p[0].toInt(), p[1].toInt(), m, rot, w, h, + ) + assertTrue( + "flipH=$flipH flipV=$flipV rot=$rot sensor($sx,$sy) " + + "-> photo(${p[0]},${p[1]}) -> ${back}", + kotlin.math.abs(back.first - sx) <= 1 && + kotlin.math.abs(back.second - sy) <= 1, + ) + } } } } } } + /** + * The rotation turns the sensor's landscape frame into the upright one the user + * was looking at: rotating 90 clockwise sends the sensor's left edge to the top, + * so sensor (0,0) lands at the photo's TOP-RIGHT and the photo is taller than it + * is wide. This is the fix for "竖屏拍照的时候,出来的照片不是竖屏的啊". + */ + @Test + fun rotatingNinetyMakesThePhotoPortraitAndMapsCornersCorrectly() { + val noMirror = PhotoSaver.Mirror(false, false) + val tl = PhotoSaver.sensorToPhoto(0, 0, noMirror, 90, 720, 960) + // sensor top-left -> photo top-right corner + assertEquals("x", 720f, tl[0], 8f) + assertEquals("y", 0f, tl[1], 8f) + val br = PhotoSaver.sensorToPhoto(159, 119, noMirror, 90, 720, 960) + assertEquals("x", 0f, br[0], 8f) + assertEquals("y", 960f, br[1], 8f) + // the sensor's x axis becomes the photo's VERTICAL axis: stepping sx moves + // down the photo, while the photo's x stays put + val xTop = PhotoSaver.sensorToPhoto(0, 60, noMirror, 90, 720, 960) + val xBottom = PhotoSaver.sensorToPhoto(159, 60, noMirror, 90, 720, 960) + assertTrue( + "sensor x runs down the photo (y: ${xTop[1]} -> ${xBottom[1]})", + xBottom[1] - xTop[1] > 900f, + ) + assertEquals("photo x is set by sensor y, so it is unchanged", xTop[0], xBottom[0], 0.01f) + // and the sensor's y axis becomes the photo's horizontal axis, mirrored + val yLeft = PhotoSaver.sensorToPhoto(80, 0, noMirror, 90, 720, 960) + val yRight = PhotoSaver.sensorToPhoto(80, 119, noMirror, 90, 720, 960) + assertEquals("photo y is set by sensor x, so it is unchanged", yLeft[1], yRight[1], 0.01f) + assertTrue("stepping sensor y moves left across the photo", yLeft[0] > yRight[0] + 600f) + } + + /** 180 flips both axes and keeps the landscape aspect. */ + @Test + fun rotating180IsAPointReflection() { + val m = PhotoSaver.Mirror(false, false) + val a = PhotoSaver.sensorToPhoto(0, 0, m, 180, 960, 720) + val b = PhotoSaver.sensorToPhoto(159, 119, m, 180, 960, 720) + assertEquals(960f, a[0], 8f) + assertEquals(720f, a[1], 8f) + assertEquals(0f, b[0], 8f) + assertEquals(0f, b[1], 8f) + } + + /** Rotation normalizes any angle to 0/90/180/270. */ + @Test + fun rotationNormalizesToQuarterTurns() { + assertEquals(0, PhotoSaver.normalizeDeg(0)) + assertEquals(90, PhotoSaver.normalizeDeg(90)) + assertEquals(180, PhotoSaver.normalizeDeg(180)) + assertEquals(270, PhotoSaver.normalizeDeg(270)) + assertEquals(0, PhotoSaver.normalizeDeg(360)) + assertEquals(90, PhotoSaver.normalizeDeg(-270)) + assertEquals(270, PhotoSaver.normalizeDeg(-90)) + } + + /** + * A version-1 render block (written before the rotation was baked in) reports no + * rotation, so old photos keep being interpreted as sensor-oriented instead of + * being silently turned. + */ + @Test + fun renderParamsUpgradeFromVersionOne() { + val v1 = ByteArray(8) + Mdt.put32(v1, 0, 1) + Mdt.put32(v1, 4, Mdt.RENDER_FLAG_FLIP_V) + val parsed = Mdt.parseRenderParams(v1) + assertTrue("flipV survives", parsed.flipV) + assertEquals("no rotation in a v1 block", 0, parsed.rotDeg) + + val v2 = Mdt.encodeRenderParams(Mdt.RenderParams(true, true, 90)) + val back = Mdt.parseRenderParams(v2) + assertTrue(back.flipH) + assertTrue(back.flipV) + assertEquals(90, back.rotDeg) + } + @Test fun nucBlockIsTheSensorGrid() { val counts = rampCounts() @@ -125,8 +210,8 @@ class PhotoNucMappingTest { @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) + val probe = PhotoSaver.sensorToPhoto(120, 90, mirror, 0, 960, 720) + val extreme = PhotoSaver.sensorToPhoto(120, 90, mirror, 0, 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) @@ -142,8 +227,8 @@ class PhotoNucMappingTest { 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) + val a = PhotoSaver.sensorToPhoto(100, 60, m, 0, 960, 720) + val b = PhotoSaver.sensorToPhoto(103, 60, m, 0, 960, 720) assertEquals( "flipH=$flipV: 3 sensor pixels stay 3 photo pixels", 18f, kotlin.math.abs(a[0] - b[0]), 0.01f, diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index a4bff42..cdbcc95 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:6f582585ff0275a63a3196a946b3f6f9de7262119d79714aae255553d0e3b461 +oid sha256:a43716ab5996e2315c344cd18f1157ef26a3644498daf863b42911615e166b0c size 12741424 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 53c8ce5..53cb438 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,76 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 +## 用户反馈修复 第二十三轮(2026-09-12,照片竖屏 / 标注风格重做 / 录像清晰度) + +用户第五轮反馈(三点):竖屏拍照出来的照片不是竖屏;录像里的温度标点糊; +温度标点不要白底,参考大牌热成像重做一套、要有工业感。 + +### 1) 照片改为按"用户看到的方向"保存(推翻上一轮的决定) + +上一轮把照片定为**传感器朝向**(4:3 横),用户明确否掉了。现在把**显示旋转** +(锁定 90° + 设置里的手动旋转)连同手动翻转一起烘进 JPEG:竖屏持机拍出来就是 +竖屏(720×960)。顺序与实时渲染器完全一致——**先镜像、再旋转**。 + +- 温度数据**不跟着转**:probes / NUC / extremes 仍留在原始传感器空间,旋转只存在于 + 呈现层;`BLOCK_RENDER` 升级到 **version 2**,多存一个 `rotDeg`。旧文件(8 字节 + v1 块)解析为 rot=0,正好符合它们确实是传感器朝向的事实——不会被静默转错。 +- `sensorToPhoto` / `photoToSensor` 增加旋转参数(正向 (u,v)→(1-v,u),逆向对应), + 并有 16 组 mirror×rotation 的往返单测。 +- 分析页的映射改为**直接复用** `PhotoSaver.sensorToPhoto(..., 1, 1)`,让"分析显示的 + 位置"和"烧进照片的位置"不可能各算一套。 + +### 2) 标注风格重做(去白底,工业感) + +按 FLIR/Testo 那类仪表的做法重做(`AnnotSpec` + `MarkerPainter`): + +- **图标**:细线方形准星 + 四根短臂(原来是不带臂的圆环+圆点)。方框限定测量区域、 + 四臂指明确切像素、中间镂空不遮挡被测点。 +- **文字**:白字 + 深色描边(先描边后填充),**不再有白色底板**。描边是为了在黑冷的 + 和白色的两端都读得清——白底会挡住被测画面,而且看着像消费级 App。 +- 极值标签改用 **MAX / MIN** 大写,并加一条**引线**把读数与自己的准星连起来。 +- 极值颜色与测点一样是白色:这是参考仪表的做法,彩色标点在铁虹的橙黄区会糊掉, + 区分靠 MAX/MIN 文字。 + +### 3) 标注尺寸统一(顺带修掉的不一致) + +AnnotSpec 的 320 单位是**图像长边**。实时界面原来用 `viewport.width()`(竖屏时是短边) +去除 320,导致实时标点只有照片标点的 3/4 大——正是用户早先"照片标点太大"的由来。 +现在实时/照片/录像/分析**统一用 `max(w,h)/320`**,同一画面里标点相对图像的尺寸完全一致。 + +### 4) 录像清晰度 + +原来按原生 320×240 编码,文字只有 9.5px、放大后必然糊。现在: + +- 编码尺寸 = 传感器 × **3**(竖屏 720×960,横屏 960×720),码率随像素数自动放大 + (`w*h*10`,下限 2Mbps)。 +- 帧的翻转+旋转用**一个 Matrix** 在 reader 线程上一次 drawBitmap 完成; + 标点经 `sensorToPhoto` 落在同一变换下。矩阵与映射的一致性我按坐标推导核对过: + 矩阵复合结果 = `sensorToPhoto`(rot90 时 u'=1-v、v'=u),两者不会各转各的。 +- 真机实测:`tkhd 720 x 960`、avc1、96 帧 / 0 丢弃 / 7.3MB。 + +### 5) 分析页"每个标记出现两次" + +照片里已经烧录了标记,分析页再叠一层,两层**标签位置不同**(准星位置是对的)。 +根因不是坐标错误,而是**标签避让与顺序有关**:拍摄时按 probes→MAX→MIN 的顺序绘制, +分析页按 MAX→MIN→probes,碰撞避让把标签推到不同位置。已改为与分析页一致的顺序, +叠加后完全重合(真机裁剪对比确认)。 + +**排查方法记录**:判断"文件里的标记位置对不对"不要靠肉眼看截图叠加,直接**解析 MDT** +(`analysis` 里已有的块格式)算出每个标记的期望像素,再统计该处的中性白色像素数。 +本轮据此一次性证伪了"旋转没生效"的猜测:rot=90 处 markerPixels=204/219/296/719, +rot=0 处全为 0。 + +**本轮真机验证(小米 22041211AC / Android 12 / MIUI)**: + +- [x] 照片为 **720×960 竖屏**,方向与竖屏实时画面一致 +- [x] MDT 内 `flags=2 flipV=true rot=90`,四个标记在旋转后的期望位置均有标记像素 +- [x] 照片标注为新风格(准星+描边字、无白底),MAX/MIN 大写带引线,标签互不重叠 +- [x] 分析页叠加后与烧录标记**完全重合**(每个标记只有一套) +- [x] 面板数值与照片一致(40.3 / 21.3 / 24.9,Pt1 25.0 / Pt2 23.2) +- [x] 录像 720×960 竖屏、96 帧 0 丢弃、首帧日志确认极值标记烧入 +- [x] 103 项测试全绿 + ## 用户反馈修复 第二十二轮(2026-09-12,7×7 细节增强 + 标注统一 + 卡顿根因) 用户第四轮实机反馈(四点):移植 7×7 局部细节增强;分析界面标点没对齐; diff --git a/v_live.png b/v_live.png deleted file mode 100644 index d673369..0000000 --- a/v_live.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0de82a1fdcc9fc66b1dd606a9e82fc165f73ed3c75e71d034a464384f4586cce -size 1539185