android: 照片改为竖屏方向 + 标注风格重做(去白底,准星+描边字)+ 录像 3x 编码

照片方向(推翻上一轮的"传感器朝向"决定,用户明确否掉):
把显示旋转(锁定 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 渲染块兼容)。
This commit is contained in:
ZXCLI
2026-09-12 14:34:54 +08:00
parent 9e64c74ffe
commit 4c940fe773
13 changed files with 611 additions and 231 deletions
@@ -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
@@ -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<FloatArray>(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()
}
}
@@ -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)
}
/**
@@ -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 {
@@ -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<ProbeMark> = emptyList(),
extremes: List<MarkerPainter.Mark> = 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<MarkerPainter.Mark>(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<Int, Int> {
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<Int, Int> {
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<MarkerPainter.Mark>,
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)
}
@@ -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<Int, Int> {
val m = photoMirror
val w = _imageW.value
val h = _imageH.value
var u = (px + 0.5f) / 160f
var v = (py + 0.5f) / 120f
if (m.flipH) u = 1f - u
if (m.flipV) v = 1f - v
return ((u * w).toInt().coerceIn(0, w - 1)) to ((v * h).toInt().coerceIn(0, h - 1))
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<Int, Int> {
val m = photoMirror
val w = _imageW.value
val h = _imageH.value
var u = (px + 0.5f) / w
var v = (py + 0.5f) / h
if (m.flipH) u = 1f - u
if (m.flipV) v = 1f - v
return ((u * 160f).toInt().coerceIn(0, 159)) to ((v * 120f).toInt().coerceIn(0, 119))
}
private fun photoToSensor(px: Int, py: Int): Pair<Int, Int> =
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)
@@ -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<com.mag160c.thermal.media.MarkerPainter.Mark>(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<AnalyzeViewModel.Probe>,
rect: androidx.compose.ui.geometry.Rect,
mirror: PhotoSaver.Mirror,
rotDeg: Int,
): List<com.mag160c.thermal.media.MarkerPainter.Mark> =
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<Float, Float> {
var u = (sx + 0.5f) / 160f
var v = (sy + 0.5f) / 120f
if (mirror.flipH) u = 1f - u
if (mirror.flipV) v = 1f - v
return u to v
private fun sensorToDisplay(
sx: Int,
sy: Int,
mirror: PhotoSaver.Mirror,
rotDeg: Int,
): Pair<Float, Float> {
val u = PhotoSaver.sensorToPhoto(sx, sy, mirror, rotDeg, 1, 1)
return u[0] to u[1]
}
/** Current canvas size, captured for the tap handler. */
@@ -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,
)
}
@@ -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")
@@ -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,
Binary file not shown.
+70
View File
@@ -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.9Pt1 25.0 / Pt2 23.2
- [x] 录像 720×960 竖屏、96 帧 0 丢弃、首帧日志确认极值标记烧入
- [x] 103 项测试全绿
## 用户反馈修复 第二十二轮(2026-09-12,7×7 细节增强 + 标注统一 + 卡顿根因)
用户第四轮实机反馈(四点):移植 7×7 局部细节增强;分析界面标点没对齐;
BIN
View File
Binary file not shown.