android: unify temperature annotations (one spec, sharp 3x photos), sensor-orientation photos, video annotations, palette cache + render loop, Android 8 API guards

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