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,
|
* 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,
|
* 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
|
* ## Style (redesigned 2026-09-12, at the user's request)
|
||||||
* 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
|
* "工业感" — modelled on the spot meters the big thermal brands use (FLIR, Testo,
|
||||||
* relative to the RENDERED IMAGE (320x240 buffer units), never in screen dp, so a
|
* Hikmicro). Those overlays are drawn as thin white line work directly on the
|
||||||
* marker has identical proportions on screen, in a photo and in a video frame.
|
* 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 {
|
object AnnotSpec {
|
||||||
/** Reference width the constants below are calibrated for. */
|
/** Reference width the constants below are calibrated for. */
|
||||||
const val REF_W = 320f
|
const val REF_W = 320f
|
||||||
|
|
||||||
// ---- marker geometry (multiples of the image scale factor) ----
|
// ---- reticle (multiples of the image scale factor) ----
|
||||||
const val DOT_R = 2.6f
|
/** Half side of the square reticle. */
|
||||||
const val RING_R = 5.5f
|
const val SPOT_R = 4.2f
|
||||||
const val RING_W = 1.4f
|
|
||||||
|
|
||||||
/** Label text height in image units; see [FontRaster] for crisp rendering. */
|
/** Length of each arm beyond the square. */
|
||||||
const val TEXT_SIZE = 9f
|
const val SPOT_ARM = 3.0f
|
||||||
const val LABEL_GAP = 7f
|
|
||||||
const val LABEL_PAD_H = 3f
|
/** 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 LABEL_PAD_V = 1.5f
|
||||||
const val SHADOW = 1.2f
|
|
||||||
|
|
||||||
/** Label box colour (translucent white) and text colour. */
|
/** Marker / text colour. Monochrome on purpose: see the class note. */
|
||||||
const val LABEL_BG = 0xF0FFFFFF.toInt()
|
const val MARK_WHITE = 0xFFFFFFFF.toInt()
|
||||||
const val LABEL_FG = 0xFF000000.toInt()
|
|
||||||
|
|
||||||
/** Extreme markers use the same glyph as probes, tinted. */
|
/** The dark outline that makes [MARK_WHITE] readable on any palette. */
|
||||||
const val EXTREME_TINT = 0xFFFFD54F.toInt()
|
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
|
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
|
* 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.
|
* 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
|
* 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
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.RectF
|
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import com.mag160c.thermal.core.AnnotSpec
|
import com.mag160c.thermal.core.AnnotSpec
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws temperature markers (dot + ring + temperature label) onto a bitmap with
|
* Draws temperature markers onto a canvas with the SAME geometry every surface
|
||||||
* the SAME geometry the live screen uses, at whatever resolution the caller is
|
* uses (live screen, analysis screen, saved photos, recorded video), at whatever
|
||||||
* rendering.
|
* 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
|
* 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
|
* 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 marks marks in the SAME pixel space as [imgW]/[imgH]
|
||||||
* @param imgW/imgH target image size
|
* @param imgW/imgH target image size
|
||||||
* @param imageUnitsToPixels conversion from AnnotSpec units (based on a 320-wide
|
* @param imageUnitsToPixels conversion from AnnotSpec units (based on a 320-wide
|
||||||
* reference) to target pixels; pass [renderScale] for a photo rendered at
|
* reference) to target pixels; pass the render scale for a photo or video
|
||||||
* N x the sensor size, or the on-screen scale for a display.
|
* 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
|
* @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
|
* 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
|
* 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).
|
* horizontal in the saved frame. The reticle itself is never rotated.
|
||||||
* The dot and ring are never rotated.
|
|
||||||
*/
|
*/
|
||||||
fun draw(
|
fun draw(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
@@ -50,77 +51,87 @@ object MarkerPainter {
|
|||||||
) {
|
) {
|
||||||
if (marks.isEmpty()) return
|
if (marks.isEmpty()) return
|
||||||
val k = imageUnitsToPixels
|
val k = imageUnitsToPixels
|
||||||
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
val typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
color = Color.WHITE
|
|
||||||
|
// 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
|
style = Paint.Style.STROKE
|
||||||
strokeWidth = AnnotSpec.RING_W * k
|
strokeWidth = AnnotSpec.SPOT_W * k
|
||||||
setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK)
|
strokeCap = Paint.Cap.BUTT
|
||||||
|
color = AnnotSpec.MARK_WHITE
|
||||||
}
|
}
|
||||||
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
// The readout is white text inside a dark outline. That is what keeps it
|
||||||
color = Color.WHITE
|
// 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
|
style = Paint.Style.FILL
|
||||||
setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK)
|
color = AnnotSpec.MARK_WHITE
|
||||||
}
|
|
||||||
val box = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = AnnotSpec.LABEL_BG
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
}
|
|
||||||
val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = AnnotSpec.LABEL_FG
|
|
||||||
textSize = AnnotSpec.TEXT_SIZE * k
|
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 padH = AnnotSpec.LABEL_PAD_H * k
|
||||||
val padV = AnnotSpec.LABEL_PAD_V * k
|
val padV = AnnotSpec.LABEL_PAD_V * k
|
||||||
// Label boxes committed so far, as [x, y, w, h]: passed to placeLabel so a
|
// Label rects committed so far: passed to placeLabel so a second label never
|
||||||
// second label never lands on the first one. Extremes (max/min) are usually
|
// lands on the first one. Extremes (max/min) are usually near each other in
|
||||||
// near each other in the scene, so without this their readouts merged into
|
// the scene, so without this their readouts merged into an unreadable overlap.
|
||||||
// an unreadable overlap.
|
|
||||||
val placed = ArrayList<FloatArray>(marks.size)
|
val placed = ArrayList<FloatArray>(marks.size)
|
||||||
|
|
||||||
for (m in marks) {
|
for (m in marks) {
|
||||||
val tint = m.tint
|
val color = m.tint ?: AnnotSpec.MARK_WHITE
|
||||||
val ringColor = tint ?: Color.WHITE
|
line.color = color
|
||||||
val dotColor = tint ?: Color.WHITE
|
val r = AnnotSpec.SPOT_R * k
|
||||||
ring.color = ringColor
|
val arm = AnnotSpec.SPOT_ARM * k
|
||||||
dot.color = dotColor
|
// square reticle, then one arm out of each side: the arms pin down which
|
||||||
canvas.drawCircle(m.x, m.y, AnnotSpec.DOT_R * k, dot)
|
// pixel is measured, the open centre keeps that pixel visible
|
||||||
canvas.drawCircle(m.x, m.y, AnnotSpec.RING_R * k, ring)
|
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 "") +
|
val full = (if (m.label.isNotEmpty()) "${m.label} " else "") +
|
||||||
"%.1f℃".format(m.tempC)
|
"%.1f℃".format(m.tempC)
|
||||||
val tw = text.measureText(full)
|
val tw = textFill.measureText(full)
|
||||||
val fm = text.fontMetrics
|
val fm = textFill.fontMetrics
|
||||||
val boxW = tw + padH * 2
|
val boxW = tw + padH * 2
|
||||||
val boxH = (fm.descent - fm.ascent) + padV * 2
|
val boxH = (fm.descent - fm.ascent) + padV * 2
|
||||||
// same placement rule as on screen (right of the ring, flipped when
|
// same placement rule as everywhere else (right of the reticle, flipped
|
||||||
// it would overflow) so the photo matches what the user saw
|
// when it would overflow, pushed down when it would collide)
|
||||||
val pos = com.mag160c.thermal.core.AnnotSpec.placeLabel(
|
val pos = com.mag160c.thermal.core.AnnotSpec.placeLabel(
|
||||||
cx = m.x, cy = m.y, boxW = boxW, boxH = boxH,
|
cx = m.x, cy = m.y, boxW = boxW, boxH = boxH,
|
||||||
imgW = imgW, imgH = imgH, scale = k, placed = placed,
|
imgW = imgW, imgH = imgH, scale = k, placed = placed,
|
||||||
)
|
)
|
||||||
placed.add(floatArrayOf(pos[0], pos[1], boxW, boxH))
|
placed.add(floatArrayOf(pos[0], pos[1], boxW, boxH))
|
||||||
|
|
||||||
canvas.save()
|
canvas.save()
|
||||||
if (textRotationDeg != 0f) {
|
if (textRotationDeg != 0f) {
|
||||||
// rotate the LABEL about its marker, keeping it attached: the live
|
// rotate the LABEL about its marker, keeping it attached: the live
|
||||||
// view draws the image rotated, so unrotated text would run down
|
// 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)
|
canvas.rotate(textRotationDeg, m.x, m.y)
|
||||||
}
|
}
|
||||||
if (tint == null) {
|
// leader: ties the readout to its own reticle when several are on screen
|
||||||
canvas.drawRoundRect(
|
val labelY = pos[1] + boxH / 2f
|
||||||
RectF(pos[0], pos[1], pos[0] + boxW, pos[1] + boxH),
|
if (pos[0] > m.x) {
|
||||||
2f * k, 2f * k, box,
|
canvas.drawLine(m.x + r + arm, m.y, pos[0], labelY, line)
|
||||||
)
|
|
||||||
text.color = AnnotSpec.LABEL_FG
|
|
||||||
} else {
|
} else {
|
||||||
// extreme markers carry no box: tinted text keeps the image clear
|
canvas.drawLine(m.x - r - arm, m.y, pos[0] + boxW, labelY, line)
|
||||||
text.color = tint
|
|
||||||
text.setShadowLayer(AnnotSpec.SHADOW * k, 0f, 0f, Color.BLACK)
|
|
||||||
}
|
}
|
||||||
val baseline = pos[1] + padV - fm.ascent
|
val baseline = pos[1] + padV - fm.ascent
|
||||||
canvas.drawText(full, pos[0] + padH, baseline, text)
|
// outline first, fill second: half the stroke lands inside the glyph and
|
||||||
text.clearShadowLayer()
|
// 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()
|
canvas.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,17 +62,23 @@ object Mdt {
|
|||||||
const val BLOCK_NUC = 0x5BB5B560
|
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
|
* Flags: bit0 = mirrored horizontally, bit1 = mirrored vertically (the user's
|
||||||
* sensor-mount corrections). Stored because the photo carries those flips while
|
* sensor-mount corrections), applied BEFORE the rotation, exactly as the live
|
||||||
* the probe coordinates and the NUC grid are in raw SENSOR space — a viewer
|
* renderer does. Version 2 adds the clockwise rotation the capture baked in
|
||||||
* that ignores this draws the markers at mirrored positions, which is exactly
|
* (0/90/180/270) — photos are saved in the orientation the user was looking at,
|
||||||
* the misalignment reported on device (analysis markers did not sit on the
|
* so a reader that assumes an unrotated frame draws every marker in the wrong
|
||||||
* marks visible in the photo).
|
* 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
|
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:
|
* The max/min the capture recorded, in SENSOR coordinates:
|
||||||
* "minX,minY,minMc,maxX,maxY,maxMc" (UTF-8 text, like the probe block).
|
* "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)
|
data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int)
|
||||||
|
|
||||||
/** Render parameters recorded with the photo. */
|
/** 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
|
get() = (if (flipH) RENDER_FLAG_FLIP_H else 0) or
|
||||||
(if (flipV) RENDER_FLAG_FLIP_V else 0)
|
(if (flipV) RENDER_FLAG_FLIP_V else 0)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val NONE = RenderParams(false, false)
|
val NONE = RenderParams(false, false, 0)
|
||||||
|
|
||||||
fun fromFlags(flags: Int): RenderParams =
|
fun fromFlags(flags: Int, rotDeg: Int = 0): RenderParams =
|
||||||
RenderParams(flags and RENDER_FLAG_FLIP_H != 0, flags and RENDER_FLAG_FLIP_V != 0)
|
RenderParams(
|
||||||
|
flags and RENDER_FLAG_FLIP_H != 0,
|
||||||
|
flags and RENDER_FLAG_FLIP_V != 0,
|
||||||
|
rotDeg,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encodeRenderParams(p: RenderParams): ByteArray {
|
fun encodeRenderParams(p: RenderParams): ByteArray {
|
||||||
val out = ByteArray(8)
|
val out = ByteArray(12)
|
||||||
put32(out, 0, 1)
|
put32(out, 0, RENDER_VERSION)
|
||||||
put32(out, 4, p.flags)
|
put32(out, 4, p.flags)
|
||||||
|
put32(out, 8, p.rotDeg)
|
||||||
return out
|
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 {
|
fun parseRenderParams(bytes: ByteArray?): RenderParams {
|
||||||
if (bytes == null || bytes.size < 8) return RenderParams.NONE
|
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
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor
|
* MP4 (H.264) recorder for the live stream, replacing the vendor .mgs / FFmpeg
|
||||||
* .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec
|
* recording paths. Uses a Surface-fed encoder so the codec handles color
|
||||||
* handles color conversion; frames arrive as ARGB bitmaps.
|
* 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
|
* 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
|
||||||
@@ -25,7 +35,13 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
*/
|
*/
|
||||||
class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
||||||
private val fps = 15
|
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 val lock = Any()
|
||||||
private var encoder: MediaCodec? = null
|
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 active = AtomicBoolean(false)
|
||||||
private val canvas = Canvas()
|
private val canvas = Canvas()
|
||||||
private val paint = Paint()
|
private val paint = Paint()
|
||||||
|
private val frameMatrix = android.graphics.Matrix()
|
||||||
|
|
||||||
/** Frames accepted since start (diagnostics). */
|
/** Frames accepted since start (diagnostics). */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -54,6 +71,10 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var mirror: PhotoSaver.Mirror = PhotoSaver.Mirror(false, false)
|
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). */
|
/** Frames dropped because the encoder was busy or gone (diagnostics). */
|
||||||
@Volatile
|
@Volatile
|
||||||
var droppedCount: Int = 0
|
var droppedCount: Int = 0
|
||||||
@@ -113,23 +134,42 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
c.drawBitmap(
|
// One matrix does flip-then-rotate and fits the source into the
|
||||||
bmp, null,
|
// encoded frame, so the reader thread does a single draw call
|
||||||
android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()),
|
// instead of transforming bitmaps.
|
||||||
paint,
|
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
|
// Burn the temperature annotations into the recorded frame so
|
||||||
// the video shows the same readouts as the live screen.
|
// the video shows the same readouts as the live screen.
|
||||||
val m = marks
|
val mks = marks
|
||||||
if (m.isNotEmpty()) {
|
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(
|
MarkerPainter.draw(
|
||||||
canvas = c,
|
canvas = c,
|
||||||
marks = m,
|
marks = converted,
|
||||||
imgW = width.toFloat(),
|
imgW = width.toFloat(),
|
||||||
imgH = height.toFloat(),
|
imgH = height.toFloat(),
|
||||||
// the encoder receives the 320x240 render, so
|
// AnnotSpec units measure the IMAGE, whose long side is
|
||||||
// AnnotSpec units (based on 320) map 1:1
|
// 320 units whatever the rotation — using the frame's
|
||||||
imageUnitsToPixels = width / 320f,
|
// width instead made markers shrink in portrait mode.
|
||||||
|
imageUnitsToPixels = maxOf(width, height) / 320f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -16,29 +16,36 @@ import java.io.File
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import com.mag160c.thermal.core.AnnotSpec
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save captured photos into MediaStore under DCIM/MAG160C (system gallery
|
* Save captured photos into MediaStore under DCIM/MAG160C (system gallery
|
||||||
* visible, no rogue folders). The stored file is a self-contained MDT
|
* visible, no rogue folders). The stored file is a self-contained MDT
|
||||||
* container (JPG + temperature frame + note) named by capture time.
|
* 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
|
* The saved photo is written in the SAME orientation the user is looking at: the
|
||||||
* landscape frame the sensor delivers — and the burned-in text runs horizontally
|
* display rotation (90 deg for the portrait-locked live view) plus the user's
|
||||||
* in that frame. So photo orientation and text direction both match the sensor.
|
* manual rotate correction are baked in, so a photo taken while holding the phone
|
||||||
* The display rotation (90 deg on the portrait screen) is NOT baked in: a
|
* upright comes out upright (3:4 portrait), not as the sensor's 4:3 landscape
|
||||||
* measurement record should record what the sensor saw, and this keeps the photo
|
* frame. The user reported the landscape file as a bug: "竖屏拍照的时候,出来的
|
||||||
* compatible with the vendor's own MDT files.
|
* 照片不是竖屏的啊".
|
||||||
*
|
*
|
||||||
* The user's manual flip corrections (水平翻转/竖直翻转) ARE applied, because they
|
* The user's manual flips (水平翻转/竖直翻转) are applied BEFORE the rotation,
|
||||||
* describe how the sensor is mounted rather than how it is displayed.
|
* 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
|
* ## Resolution policy
|
||||||
*
|
*
|
||||||
* Rendered at [RENDER_SCALE]x the sensor size (3x -> 960x720). Text drawn at the
|
* Rendered at [RENDER_SCALE]x the sensor size (3x -> 960x720, or 720x960 when the
|
||||||
* sensor's 320x240 was legible but visibly soft once the photo was viewed at full
|
* rotation makes it portrait). Text drawn at the sensor's 320x240 was legible but
|
||||||
* screen; the same layout at 3x is sharp.
|
* visibly soft once the photo was viewed at full screen; the same layout at 3x is
|
||||||
|
* sharp.
|
||||||
*/
|
*/
|
||||||
object PhotoSaver {
|
object PhotoSaver {
|
||||||
private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
|
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
|
* Render the sensor frame for saving: mirrored and rotated per the user's
|
||||||
* by [RENDER_SCALE], with the probe markers burned in.
|
* settings, scaled by [RENDER_SCALE], with the markers burned in.
|
||||||
*
|
*
|
||||||
* @param frame 320x240 ARGB render of the sensor image
|
* @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 probes probes in SENSOR coordinates (0..159, 0..119)
|
||||||
* @param extremes optional max/min markers, also sensor coordinates
|
* @param extremes optional max/min markers, also sensor coordinates
|
||||||
*/
|
*/
|
||||||
fun encodeRendered(
|
fun encodeRendered(
|
||||||
frame: IntArray,
|
frame: IntArray,
|
||||||
mirror: Mirror = Mirror(false, false),
|
mirror: Mirror = Mirror(false, false),
|
||||||
|
rotDeg: Int = 0,
|
||||||
probes: List<ProbeMark> = emptyList(),
|
probes: List<ProbeMark> = emptyList(),
|
||||||
extremes: List<MarkerPainter.Mark> = emptyList(),
|
extremes: List<MarkerPainter.Mark> = emptyList(),
|
||||||
w: Int = SENSOR_W,
|
w: Int = SENSOR_W,
|
||||||
@@ -93,8 +103,9 @@ object PhotoSaver {
|
|||||||
val src = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
val src = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
src.setPixels(frame, 0, w, 0, 0, w, h)
|
src.setPixels(frame, 0, w, 0, 0, w, h)
|
||||||
|
|
||||||
// mirror (sensor-mount correction) — NO display rotation: the photo must
|
// 1) mirror (sensor-mount correction), 2) rotate (display orientation).
|
||||||
// match the sensor's own 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
|
var work = src
|
||||||
if (mirror.flipH || mirror.flipV) {
|
if (mirror.flipH || mirror.flipV) {
|
||||||
val m = Matrix().apply {
|
val m = Matrix().apply {
|
||||||
@@ -106,9 +117,13 @@ object PhotoSaver {
|
|||||||
}
|
}
|
||||||
work = Bitmap.createBitmap(src, 0, 0, w, h, m, true)
|
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 outW = work.width * RENDER_SCALE
|
||||||
val outH = h * RENDER_SCALE
|
val outH = work.height * RENDER_SCALE
|
||||||
val out = if (work.width == outW && work.height == outH) {
|
val out = if (work.width == outW && work.height == outH) {
|
||||||
work.copy(Bitmap.Config.ARGB_8888, true)
|
work.copy(Bitmap.Config.ARGB_8888, true)
|
||||||
} else {
|
} else {
|
||||||
@@ -117,7 +132,7 @@ object PhotoSaver {
|
|||||||
|
|
||||||
val marks = ArrayList<MarkerPainter.Mark>(probes.size + extremes.size)
|
val marks = ArrayList<MarkerPainter.Mark>(probes.size + extremes.size)
|
||||||
for (p in probes) {
|
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))
|
marks.add(MarkerPainter.Mark(pos[0], pos[1], p.label, p.tempC))
|
||||||
}
|
}
|
||||||
// The extremes arrive in SENSOR coordinates (same space as the probes) and
|
// 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
|
// photo they landed within a few dozen pixels of the origin instead of over
|
||||||
// the hot/cold spots they name.
|
// the hot/cold spots they name.
|
||||||
for (e in extremes) {
|
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))
|
marks.add(MarkerPainter.Mark(pos[0], pos[1], e.label, e.tempC, e.tint))
|
||||||
}
|
}
|
||||||
if (marks.isNotEmpty()) {
|
if (marks.isNotEmpty()) {
|
||||||
// AnnotSpec units are calibrated for a 320-wide image; the photo is
|
// AnnotSpec units measure the IMAGE: its long side is 320 units whichever
|
||||||
// RENDER_SCALE x that (times any extra upscale), so text stays sharp
|
// way it is rotated, so the scale comes from the long side. Using the
|
||||||
// and the layout keeps the same proportions as the live screen.
|
// frame width would make a portrait photo's markers smaller than a
|
||||||
|
// landscape one's for the same picture.
|
||||||
MarkerPainter.draw(
|
MarkerPainter.draw(
|
||||||
canvas = Canvas(out),
|
canvas = Canvas(out),
|
||||||
marks = marks,
|
marks = marks,
|
||||||
imgW = outW.toFloat(),
|
imgW = outW.toFloat(),
|
||||||
imgH = outH.toFloat(),
|
imgH = outH.toFloat(),
|
||||||
imageUnitsToPixels = outW / 320f,
|
imageUnitsToPixels = maxOf(outW, outH) / AnnotSpec.REF_W,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return encodeJpeg(out, quality)
|
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,
|
* Rotate [src] by [rot] degrees CLOCKWISE, returning a bitmap of the rotated
|
||||||
* so this is a uniform scale (plus the optional mirror) — deliberately no
|
* size. The pivot+translate pair is what keeps the result inside the new
|
||||||
* rotation, because every rotation in the chain is a chance to disagree with
|
* bitmap's bounds: rotating about the centre moves the content to
|
||||||
* the temperature data that is stored alongside.
|
* [-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 u = (sx + 0.5f) / 160f
|
||||||
var v = (sy + 0.5f) / 120f
|
var v = (sy + 0.5f) / 120f
|
||||||
if (mirror.flipH) u = 1f - u
|
if (mirror.flipH) u = 1f - u
|
||||||
if (mirror.flipV) v = 1f - v
|
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. */
|
/** Inverse of [sensorToPhoto]: photo pixel -> sensor pixel. */
|
||||||
fun photoToSensor(px: Int, py: Int, mirror: Mirror, photoW: Int, photoH: Int): Pair<Int, Int> {
|
fun photoToSensor(
|
||||||
var u = (px + 0.5f) / photoW
|
px: Int,
|
||||||
var v = (py + 0.5f) / photoH
|
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.flipH) u = 1f - u
|
||||||
if (mirror.flipV) v = 1f - v
|
if (mirror.flipV) v = 1f - v
|
||||||
val sx = (u * 160f).toInt().coerceIn(0, 159)
|
val sx = (u * 160f).toInt().coerceIn(0, 159)
|
||||||
@@ -210,19 +289,19 @@ object PhotoSaver {
|
|||||||
jpg: ByteArray,
|
jpg: ByteArray,
|
||||||
marks: List<MarkerPainter.Mark>,
|
marks: List<MarkerPainter.Mark>,
|
||||||
mirror: Mirror = Mirror(false, false),
|
mirror: Mirror = Mirror(false, false),
|
||||||
|
rotDeg: Int = 0,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
if (marks.isEmpty()) return jpg
|
if (marks.isEmpty()) return jpg
|
||||||
val bmp = android.graphics.BitmapFactory.decodeByteArray(jpg, 0, jpg.size) ?: 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
|
val out = bmp.copy(Bitmap.Config.ARGB_8888, true) ?: return jpg
|
||||||
// marks arrive in SENSOR coordinates; the bitmap is the saved photo, which
|
// marks arrive in SENSOR coordinates; the bitmap is the saved photo, which
|
||||||
// carries the capture's mirror. Converting through sensorToPhoto keeps them
|
// carries the capture's mirror AND rotation. Converting through sensorToPhoto
|
||||||
// on the same features the photo shows — without it, saving from the
|
// keeps them on the same features the photo shows — without it, saving from
|
||||||
// analysis screen put every marker on the mirrored side of the image.
|
// the analysis screen put every marker on the mirrored/rotated wrong side.
|
||||||
val pxPerSensorX = out.width / 160f
|
|
||||||
val scaled = marks.map {
|
val scaled = marks.map {
|
||||||
val pos = sensorToPhoto(
|
val pos = sensorToPhoto(
|
||||||
it.x.toInt().coerceIn(0, 159), it.y.toInt().coerceIn(0, 119),
|
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)
|
MarkerPainter.Mark(pos[0], pos[1], it.label, it.tempC, it.tint)
|
||||||
}
|
}
|
||||||
@@ -231,8 +310,8 @@ object PhotoSaver {
|
|||||||
marks = scaled,
|
marks = scaled,
|
||||||
imgW = out.width.toFloat(),
|
imgW = out.width.toFloat(),
|
||||||
imgH = out.height.toFloat(),
|
imgH = out.height.toFloat(),
|
||||||
// AnnotSpec units are calibrated for a 320-wide frame = 2 sensor pixels
|
// AnnotSpec units measure the IMAGE; its long side is 320 units (see encodeRendered)
|
||||||
imageUnitsToPixels = pxPerSensorX / 2f,
|
imageUnitsToPixels = maxOf(out.width, out.height) / AnnotSpec.REF_W,
|
||||||
)
|
)
|
||||||
return encodeJpeg(out)
|
return encodeJpeg(out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,36 +152,34 @@ class AnalyzeViewModel(
|
|||||||
return PhotoSaver.Mirror(r.flipH, r.flipV)
|
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.
|
* Sensor pixel -> photo pixel for the DISPLAYED image.
|
||||||
*
|
*
|
||||||
* The JPEG carries the user's mirror corrections while probes and the NUC grid
|
* The JPEG carries the user's mirror corrections AND the capture rotation while
|
||||||
* stay in raw sensor space, so this conversion is what keeps markers sitting on
|
* probes and the NUC grid stay in raw sensor space, so this conversion is what
|
||||||
* the same spot the live screen showed. Ignoring it was the reported
|
* keeps markers sitting on the same spot the live screen showed. Ignoring it was
|
||||||
* misalignment.
|
* the reported misalignment.
|
||||||
*/
|
*/
|
||||||
private fun sensorToPhoto(px: Int, py: Int): Pair<Int, Int> {
|
private fun sensorToPhoto(px: Int, py: Int): Pair<Int, Int> {
|
||||||
val m = photoMirror
|
|
||||||
val w = _imageW.value
|
val w = _imageW.value
|
||||||
val h = _imageH.value
|
val h = _imageH.value
|
||||||
var u = (px + 0.5f) / 160f
|
val p = PhotoSaver.sensorToPhoto(px, py, photoMirror, photoRotDeg, w, h)
|
||||||
var v = (py + 0.5f) / 120f
|
return (p[0].toInt().coerceIn(0, w - 1)) to (p[1].toInt().coerceIn(0, h - 1))
|
||||||
if (m.flipH) u = 1f - u
|
|
||||||
if (m.flipV) v = 1f - v
|
|
||||||
return ((u * w).toInt().coerceIn(0, w - 1)) to ((v * h).toInt().coerceIn(0, h - 1))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inverse of [sensorToPhoto]: a photo pixel back to sensor coordinates. */
|
/** Inverse of [sensorToPhoto]: a photo pixel back to sensor coordinates. */
|
||||||
private fun photoToSensor(px: Int, py: Int): Pair<Int, Int> {
|
private fun photoToSensor(px: Int, py: Int): Pair<Int, Int> =
|
||||||
val m = photoMirror
|
PhotoSaver.photoToSensor(px, py, photoMirror, photoRotDeg, _imageW.value, _imageH.value)
|
||||||
val w = _imageW.value
|
|
||||||
val h = _imageH.value
|
|
||||||
var u = (px + 0.5f) / w
|
|
||||||
var v = (py + 0.5f) / h
|
|
||||||
if (m.flipH) u = 1f - u
|
|
||||||
if (m.flipV) v = 1f - v
|
|
||||||
return ((u * 160f).toInt().coerceIn(0, 159)) to ((v * 120f).toInt().coerceIn(0, 119))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Temperature (C) at a SENSOR pixel, from the stored NUC counts. */
|
/** Temperature (C) at a SENSOR pixel, from the stored NUC counts. */
|
||||||
fun measureSensor(sx: Int, sy: Int): Float? {
|
fun measureSensor(sx: Int, sy: Int): Float? {
|
||||||
@@ -252,7 +250,7 @@ class AnalyzeViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92)
|
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(
|
val mdt = Mdt.compose(
|
||||||
jpg = annotated,
|
jpg = annotated,
|
||||||
info0 = parsed?.info0,
|
info0 = parsed?.info0,
|
||||||
@@ -265,7 +263,7 @@ class AnalyzeViewModel(
|
|||||||
// carry the temperature data forward so the edited photo stays measurable
|
// carry the temperature data forward so the edited photo stays measurable
|
||||||
nucPixels = parsed?.nucPixels,
|
nucPixels = parsed?.nucPixels,
|
||||||
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
|
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 extremes are burned into the pixels already; keep the block so
|
||||||
// the next reader still knows where they were
|
// the next reader still knows where they were
|
||||||
@@ -296,7 +294,7 @@ class AnalyzeViewModel(
|
|||||||
),
|
),
|
||||||
nucPixels = parsed?.nucPixels,
|
nucPixels = parsed?.nucPixels,
|
||||||
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
|
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 {
|
extremes = parsed?.extremes?.takeIf {
|
||||||
com.mag160c.thermal.media.Mdt.Extremes.hasAny(it)
|
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
|
// 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
|
// works if it sees every marker at once (drawing the extremes in
|
||||||
// separate calls let "max" and "min" land on each other here).
|
// 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)
|
val marks = ArrayList<com.mag160c.thermal.media.MarkerPainter.Mark>(4)
|
||||||
|
marks.addAll(probeMarks(vm.probes, rect, vm.photoMirror, vm.photoRotDeg))
|
||||||
if (vm.hasTemperatureData) {
|
if (vm.hasTemperatureData) {
|
||||||
sensorMark(vm.maxPos, rect, vm.photoMirror, "max", vm.maxTempC)?.let { marks.add(it) }
|
sensorMark(vm.maxPos, rect, vm.photoMirror, vm.photoRotDeg, "MAX", vm.maxTempC)
|
||||||
sensorMark(vm.minPos, rect, vm.photoMirror, "min", vm.minTempC)?.let { marks.add(it) }
|
?.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()) {
|
if (marks.isNotEmpty()) {
|
||||||
// AnnotSpec units are calibrated for a 320-wide frame =
|
// AnnotSpec units measure the IMAGE, whose long side is 320
|
||||||
// 2 sensor pixels, so the display scale factor is half the
|
// units whichever way it is rotated — the same rule the live,
|
||||||
// per-sensor-pixel size.
|
// photo and video paths use. Deriving it from rect.width (the
|
||||||
val k = (rect.width / 160f) / 2f
|
// 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 ->
|
drawIntoCanvas { c ->
|
||||||
com.mag160c.thermal.media.MarkerPainter.draw(
|
com.mag160c.thermal.media.MarkerPainter.draw(
|
||||||
canvas = c.nativeCanvas,
|
canvas = c.nativeCanvas,
|
||||||
@@ -331,9 +340,10 @@ private fun probeMarks(
|
|||||||
probes: List<AnalyzeViewModel.Probe>,
|
probes: List<AnalyzeViewModel.Probe>,
|
||||||
rect: androidx.compose.ui.geometry.Rect,
|
rect: androidx.compose.ui.geometry.Rect,
|
||||||
mirror: PhotoSaver.Mirror,
|
mirror: PhotoSaver.Mirror,
|
||||||
|
rotDeg: Int,
|
||||||
): List<com.mag160c.thermal.media.MarkerPainter.Mark> =
|
): List<com.mag160c.thermal.media.MarkerPainter.Mark> =
|
||||||
probes.map { p ->
|
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(
|
com.mag160c.thermal.media.MarkerPainter.Mark(
|
||||||
x = rect.left + u * rect.width,
|
x = rect.left + u * rect.width,
|
||||||
y = rect.top + v * rect.height,
|
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).
|
* (the user asked for these exact names rather than the previous 高/低 wording).
|
||||||
*/
|
*/
|
||||||
private fun sensorMark(
|
private fun sensorMark(
|
||||||
pos: Int,
|
pos: Int,
|
||||||
rect: androidx.compose.ui.geometry.Rect,
|
rect: androidx.compose.ui.geometry.Rect,
|
||||||
mirror: PhotoSaver.Mirror,
|
mirror: PhotoSaver.Mirror,
|
||||||
|
rotDeg: Int,
|
||||||
label: String,
|
label: String,
|
||||||
tempC: Float?,
|
tempC: Float?,
|
||||||
): com.mag160c.thermal.media.MarkerPainter.Mark? {
|
): com.mag160c.thermal.media.MarkerPainter.Mark? {
|
||||||
@@ -357,7 +368,7 @@ private fun sensorMark(
|
|||||||
val sx = pos % 160
|
val sx = pos % 160
|
||||||
val sy = pos / 160
|
val sy = pos / 160
|
||||||
if (sy >= 120) return null
|
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(
|
return com.mag160c.thermal.media.MarkerPainter.Mark(
|
||||||
x = rect.left + u * rect.width,
|
x = rect.left + u * rect.width,
|
||||||
y = rect.top + v * rect.height,
|
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
|
* Sensor pixel -> normalised (0..1) position in the DISPLAYED photo.
|
||||||
* 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
|
* Delegates to [PhotoSaver.sensorToPhoto] with a 1x1 target so the analysis screen
|
||||||
* half-pixel offset is what made analysis markers look shifted by up to a pixel
|
* and the file it is displaying cannot disagree — the mirror AND the capture
|
||||||
* against the burned-in ones.
|
* 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> {
|
private fun sensorToDisplay(
|
||||||
var u = (sx + 0.5f) / 160f
|
sx: Int,
|
||||||
var v = (sy + 0.5f) / 120f
|
sy: Int,
|
||||||
if (mirror.flipH) u = 1f - u
|
mirror: PhotoSaver.Mirror,
|
||||||
if (mirror.flipV) v = 1f - v
|
rotDeg: Int,
|
||||||
return u to v
|
): 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. */
|
/** Current canvas size, captured for the tap handler. */
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ class LiveRenderer(
|
|||||||
val scr = vm.probeToScreen(state.maxPos % 160, state.maxPos / 160)
|
val scr = vm.probeToScreen(state.maxPos % 160, state.maxPos / 160)
|
||||||
marks.add(
|
marks.add(
|
||||||
com.mag160c.thermal.media.MarkerPainter.Mark(
|
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)
|
val scr = vm.probeToScreen(state.minPos % 160, state.minPos / 160)
|
||||||
marks.add(
|
marks.add(
|
||||||
com.mag160c.thermal.media.MarkerPainter.Mark(
|
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,
|
marks = marks,
|
||||||
imgW = canvas.width.toFloat(),
|
imgW = canvas.width.toFloat(),
|
||||||
imgH = canvas.height.toFloat(),
|
imgH = canvas.height.toFloat(),
|
||||||
// viewport width / 320 = screen pixels per AnnotSpec unit
|
// AnnotSpec units measure the IMAGE, whose long side is 320 units however
|
||||||
imageUnitsToPixels = viewport.width() / AnnotSpec.REF_W,
|
// 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,
|
textRotationDeg = textRot,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
/** Color-bar footprint kept clear at the right edge (20dp bar + 2x12dp). */
|
/** Color-bar footprint kept clear at the right edge (20dp bar + 2x12dp). */
|
||||||
const val PIP_RIGHT_MARGIN_DP = 32
|
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
|
private var usbReceiver: android.content.BroadcastReceiver? = null
|
||||||
@@ -619,7 +627,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
out.add(
|
out.add(
|
||||||
com.mag160c.thermal.media.MarkerPainter.Mark(
|
com.mag160c.thermal.media.MarkerPainter.Mark(
|
||||||
(st.maxPos % 160).toFloat(), (st.maxPos / 160).toFloat(),
|
(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(
|
out.add(
|
||||||
com.mag160c.thermal.media.MarkerPainter.Mark(
|
com.mag160c.thermal.media.MarkerPainter.Mark(
|
||||||
(st.minPos % 160).toFloat(), (st.minPos / 160).toFloat(),
|
(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.
|
* Capture: rendered JPEG + NUC data + probes -> MDT -> MediaStore.
|
||||||
*
|
*
|
||||||
* Orientation (user decision 2026-09-12): the saved photo keeps the SENSOR's
|
* Orientation (user decision 2026-09-12, revised): the saved photo is written in
|
||||||
* orientation, and the burned-in text is horizontal in that same frame, so
|
* the orientation the user is LOOKING AT — the display rotation plus the manual
|
||||||
* text direction and image direction always agree with the sensor. The portrait
|
* rotate correction are baked into the JPEG, so holding the phone upright gives
|
||||||
* display rotation is deliberately NOT baked in — a measurement record should
|
* an upright (3:4) photo instead of the sensor's 4:3 landscape frame. The manual
|
||||||
* show what the sensor saw, and this stays compatible with the vendor's own MDT
|
* flips are applied first, exactly as the live renderer does. [Mdt.RenderParams]
|
||||||
* files. The user's manual mirror corrections are applied (they describe the
|
* records both so a reader can map the sensor-space measurement data onto the
|
||||||
* sensor mounting, not the display).
|
* rotated pixels.
|
||||||
*
|
*
|
||||||
* The NUC counts are stored on the SENSOR grid (160x120) because the photo has
|
* The NUC counts are stored on the SENSOR grid (160x120), unrotated: the data
|
||||||
* no rotation relative to the sensor: the analysis lookup is then a uniform
|
* stays in one fixed space rather than being resampled, and the rotation lives
|
||||||
* scale, with no per-pixel rotation math that could disagree with where the
|
* only in the presentation layer.
|
||||||
* markers were drawn.
|
|
||||||
*/
|
*/
|
||||||
fun capturePhoto(context: android.content.Context, density: Float = 2f) {
|
fun capturePhoto(context: android.content.Context, density: Float = 2f) {
|
||||||
val frame = latestFrame ?: return
|
val frame = latestFrame ?: return
|
||||||
val s = session
|
val s = session
|
||||||
val st = _state.value
|
val st = _state.value
|
||||||
val mirror = com.mag160c.thermal.media.PhotoSaver.Mirror(flipH, flipV)
|
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
|
// NUC counts: the calibrated 160x120 data the live readouts use
|
||||||
val nuc160 = IntArray(19200)
|
val nuc160 = IntArray(19200)
|
||||||
@@ -668,7 +678,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
extremes.add(
|
extremes.add(
|
||||||
com.mag160c.thermal.media.MarkerPainter.Mark(
|
com.mag160c.thermal.media.MarkerPainter.Mark(
|
||||||
(st.maxPos % 160).toFloat(), (st.maxPos / 160).toFloat(),
|
(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(
|
extremes.add(
|
||||||
com.mag160c.thermal.media.MarkerPainter.Mark(
|
com.mag160c.thermal.media.MarkerPainter.Mark(
|
||||||
(st.minPos % 160).toFloat(), (st.minPos / 160).toFloat(),
|
(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(
|
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered(
|
||||||
frame = frame,
|
frame = frame,
|
||||||
mirror = mirror,
|
mirror = mirror,
|
||||||
|
rotDeg = rotDeg,
|
||||||
probes = st.probes.mapNotNull { p ->
|
probes = st.probes.mapNotNull { p ->
|
||||||
p.tempC?.let {
|
p.tempC?.let {
|
||||||
com.mag160c.thermal.media.PhotoSaver.ProbeMark(p.x, p.y, p.label, it)
|
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)
|
com.mag160c.thermal.media.PhotoSaver.packNucForPhoto(nuc160)
|
||||||
} else null,
|
} else null,
|
||||||
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
|
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
|
// 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
|
// 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. */
|
/** Last known surface size, for probe screen mapping. */
|
||||||
@Volatile
|
@Volatile
|
||||||
var uiViewW: Int = 1080
|
var uiViewW: Int = 1080
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var uiViewH: Int = 2280
|
var uiViewH: Int = 2280
|
||||||
|
|
||||||
@@ -830,7 +840,20 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
fun toggleRecording(context: android.content.Context) {
|
fun toggleRecording(context: android.content.Context) {
|
||||||
val rec = recorder
|
val rec = recorder
|
||||||
if (rec == null) {
|
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()) {
|
if (r.start()) {
|
||||||
recorder = r
|
recorder = r
|
||||||
_state.value = _state.value.copy(status = "recording")
|
_state.value = _state.value.copy(status = "recording")
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ class PhotoNucMappingTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sensorToPhotoIsAPlainScaleWithoutMirror() {
|
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[0], 0.01f) // sensor pixel 0 centre -> 1.5/160 of width
|
||||||
assertEquals(3f, pos[1], 0.01f)
|
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(960f / 2f, mid[0], 4f)
|
||||||
assertEquals(720f / 2f, mid[1], 4f)
|
assertEquals(720f / 2f, mid[1], 4f)
|
||||||
}
|
}
|
||||||
@@ -47,35 +47,120 @@ class PhotoNucMappingTest {
|
|||||||
@Test
|
@Test
|
||||||
fun mirrorFlipsTheMappingConsistently() {
|
fun mirrorFlipsTheMappingConsistently() {
|
||||||
val h = PhotoSaver.Mirror(true, false)
|
val h = PhotoSaver.Mirror(true, false)
|
||||||
val left = PhotoSaver.sensorToPhoto(0, 0, noMirror, 960, 720)
|
val left = PhotoSaver.sensorToPhoto(0, 0, noMirror, 0, 960, 720)
|
||||||
val flipped = PhotoSaver.sensorToPhoto(0, 0, h, 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("flipH moves x to the far side", 960f, flipped[0], 4f)
|
||||||
assertEquals("y is untouched by flipH", left[1], flipped[1], 0.01f)
|
assertEquals("y is untouched by flipH", left[1], flipped[1], 0.01f)
|
||||||
|
|
||||||
val v = PhotoSaver.Mirror(false, true)
|
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)
|
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
|
@Test
|
||||||
fun photoToSensorInvertsSensorToPhoto() {
|
fun photoToSensorInvertsSensorToPhoto() {
|
||||||
for (flipH in booleanArrayOf(false, true)) {
|
for (flipH in booleanArrayOf(false, true)) {
|
||||||
for (flipV in booleanArrayOf(false, true)) {
|
for (flipV in booleanArrayOf(false, true)) {
|
||||||
val m = PhotoSaver.Mirror(flipH, flipV)
|
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 (sx in intArrayOf(0, 40, 79, 120, 159)) {
|
||||||
for (sy in intArrayOf(0, 30, 59, 90, 119)) {
|
for (sy in intArrayOf(0, 30, 59, 90, 119)) {
|
||||||
val p = PhotoSaver.sensorToPhoto(sx, sy, m, 960, 720)
|
val p = PhotoSaver.sensorToPhoto(sx, sy, m, rot, w, h)
|
||||||
val back = PhotoSaver.photoToSensor(p[0].toInt(), p[1].toInt(), m, 960, 720)
|
val back = PhotoSaver.photoToSensor(
|
||||||
|
p[0].toInt(), p[1].toInt(), m, rot, w, h,
|
||||||
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"flipH=$flipH flipV=$flipV sensor($sx,$sy) -> photo(${p[0]},${p[1]}) -> ${back}",
|
"flipH=$flipH flipV=$flipV rot=$rot sensor($sx,$sy) " +
|
||||||
kotlin.math.abs(back.first - sx) <= 1 && kotlin.math.abs(back.second - sy) <= 1,
|
"-> 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
|
@Test
|
||||||
fun nucBlockIsTheSensorGrid() {
|
fun nucBlockIsTheSensorGrid() {
|
||||||
@@ -125,8 +210,8 @@ class PhotoNucMappingTest {
|
|||||||
@Test
|
@Test
|
||||||
fun extremesUseTheSameSensorMappingAsProbes() {
|
fun extremesUseTheSameSensorMappingAsProbes() {
|
||||||
val mirror = PhotoSaver.Mirror(false, true) // the user's mount correction
|
val mirror = PhotoSaver.Mirror(false, true) // the user's mount correction
|
||||||
val probe = PhotoSaver.sensorToPhoto(120, 90, mirror, 960, 720)
|
val probe = PhotoSaver.sensorToPhoto(120, 90, mirror, 0, 960, 720)
|
||||||
val extreme = PhotoSaver.sensorToPhoto(120, 90, mirror, 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",
|
assertEquals("an extreme at a probe's pixel lands on the same photo pixel",
|
||||||
probe[0], extreme[0], 0.01f)
|
probe[0], extreme[0], 0.01f)
|
||||||
assertEquals(probe[1], extreme[1], 0.01f)
|
assertEquals(probe[1], extreme[1], 0.01f)
|
||||||
@@ -142,8 +227,8 @@ class PhotoNucMappingTest {
|
|||||||
for (flipH in booleanArrayOf(false, true)) {
|
for (flipH in booleanArrayOf(false, true)) {
|
||||||
for (flipV in booleanArrayOf(false, true)) {
|
for (flipV in booleanArrayOf(false, true)) {
|
||||||
val m = PhotoSaver.Mirror(flipH, flipV)
|
val m = PhotoSaver.Mirror(flipH, flipV)
|
||||||
val a = PhotoSaver.sensorToPhoto(100, 60, m, 960, 720)
|
val a = PhotoSaver.sensorToPhoto(100, 60, m, 0, 960, 720)
|
||||||
val b = PhotoSaver.sensorToPhoto(103, 60, m, 960, 720)
|
val b = PhotoSaver.sensorToPhoto(103, 60, m, 0, 960, 720)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"flipH=$flipV: 3 sensor pixels stay 3 photo pixels",
|
"flipH=$flipV: 3 sensor pixels stay 3 photo pixels",
|
||||||
18f, kotlin.math.abs(a[0] - b[0]), 0.01f,
|
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 已更新。
|
- [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 细节增强 + 标注统一 + 卡顿根因)
|
## 用户反馈修复 第二十二轮(2026-09-12,7×7 细节增强 + 标注统一 + 卡顿根因)
|
||||||
|
|
||||||
用户第四轮实机反馈(四点):移植 7×7 局部细节增强;分析界面标点没对齐;
|
用户第四轮实机反馈(四点):移植 7×7 局部细节增强;分析界面标点没对齐;
|
||||||
|
|||||||
LFS
BIN
Binary file not shown.
Reference in New Issue
Block a user