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:
@@ -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,9 +11,19 @@ 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
|
||||
@@ -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,35 +47,120 @@ 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 (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, 960, 720)
|
||||
val back = PhotoSaver.photoToSensor(p[0].toInt(), p[1].toInt(), m, 960, 720)
|
||||
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 sensor($sx,$sy) -> photo(${p[0]},${p[1]}) -> ${back}",
|
||||
kotlin.math.abs(back.first - sx) <= 1 && kotlin.math.abs(back.second - sy) <= 1,
|
||||
"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() {
|
||||
@@ -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.
@@ -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 局部细节增强;分析界面标点没对齐;
|
||||
|
||||
LFS
BIN
Binary file not shown.
Reference in New Issue
Block a user