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:
@@ -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.
Reference in New Issue
Block a user