app+csdk+docs: 修正上一轮最近邻2×的退化(颗粒/边沿台阶)——照片改为从160×120单级6×放大(官方ImageViewer同款),实时缓冲与csdk回退为插值2×;实测冷区|dx| 0.76×官方(更平滑),119单测全绿

This commit is contained in:
ZXCLI
2026-10-06 17:57:17 +08:00
parent 4a3916a2b7
commit 595309735e
9 changed files with 124 additions and 19 deletions
@@ -50,12 +50,25 @@ import com.mag160c.thermal.core.AnnotSpec
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
/**
* Native sensor frame size (160x120), the SAME grid the probes/extremes and the
* MDT's NUC block use.
*
* It used to be 320x240 - the pipeline's internally 2x-upscaled buffer - and the
* photo was then scaled 3x on top of that: a two-stage upscale whose intermediate
* stage either interpolates (a full/half amplitude column pattern at the sensor's
* Nyquist) or replicates (halving the effective resolution: 2-sensor-pixel
* blocks, visible as a coarse staircase along hot/cold edges). The official app
* upscales ONCE from the native 160x120 (`ImageViewer` draws the native bitmap
* into a 640x480 buffer with a filtering paint), so the saved photo is now a
* single 6x bilinear step from the sensor grid: 1-sensor-pixel granules, no
* interlace pattern. [RENDER_SCALE] keeps the output at the same 960x720.
*/
const val SENSOR_W = 160
const val SENSOR_H = 120
/** Saved photo is this many times the sensor frame (crisp text). */
const val RENDER_SCALE = 3
const val RENDER_SCALE = 6
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
@@ -658,6 +658,21 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
* stays in one fixed space rather than being resampled, and the rotation lives
* only in the presentation layer.
*/
/**
* Exact 160x120 sensor-grid copy of the 320x240 display buffer. The pipeline's
* 2x upscale keeps the source value at every even index (`o[2x] = src[x]`), so
* sampling (2x, 2y) recovers the native pixels without any resampling.
*/
private fun sensorFrame(display: IntArray): IntArray {
val out = IntArray(160 * 120)
var i = 0
for (y in 0 until 120) {
val row = y * 2 * 320
for (x in 0 until 160) out[i++] = display[row + x * 2]
}
return out
}
fun capturePhoto(context: android.content.Context, density: Float = 2f) {
val frame = latestFrame ?: return
val s = session
@@ -690,8 +705,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
)
}
// PhotoSaver works in the NATIVE sensor grid (160x120) and upscales once, so
// hand it the exact sensor pixels: the pipeline's 320x240 buffer is a 2x
// upscale whose even samples are the untouched source values.
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeRendered(
frame = frame,
frame = sensorFrame(frame),
mirror = mirror,
rotDeg = rotDeg,
probes = st.probes.mapNotNull { p ->
@@ -324,10 +324,13 @@ class IrSession(context: Context) {
// anchor, no auto-centre search). See RenderPipeline.lutRebuildAndroid
// and docs/android_app/rendering_diff_phone_20261006.md §10.
androidLut = true,
// the official Android display path has no interpolating 2x stage
// (nearest-neighbour draw of the native 160x120 bitmap); see the doc
// comment on RenderPipeline.upscaleNearest
upscaleNearest = true,
// Keep the interpolating 2x for the live buffer: the nearest variant
// halves the effective resolution (2-sensor-pixel granules, a visible
// staircase along hot/cold edges - measured, and reported by the user),
// while the interpolated one keeps 1-sensor-pixel granules. The photo no
// longer depends on this buffer: it is a single 6x step from the sensor
// grid (PhotoSaver.SENSOR_W/H = 160x120).
upscaleNearest = false,
// baselineFrames stays at the verified 4: extending the window to 8 made
// the output *unstable* (frame-to-frame gray noise 3.95 -> 8.8), i.e. the
// device re-opens the shutter earlier than our schedule assumes, so the
@@ -24,8 +24,13 @@ class PhotoNucMappingTest {
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 frame handed to the writer is the NATIVE sensor grid, not the
// pipeline's 2x display buffer: the photo is then a single upscale (no
// two-stage interlace, no halved effective resolution).
assertEquals(160, PhotoSaver.SENSOR_W)
assertEquals(120, PhotoSaver.SENSOR_H)
assertEquals("output stays 960x720", 960, PhotoSaver.SENSOR_W * PhotoSaver.RENDER_SCALE)
assertEquals(720, PhotoSaver.SENSOR_H * PhotoSaver.RENDER_SCALE)
// the saved photo therefore stays 4:3 landscape — the sensor's own
// orientation, with no display rotation baked in
assertTrue(