app+csdk+docs: 修正上一轮最近邻2×的退化(颗粒/边沿台阶)——照片改为从160×120单级6×放大(官方ImageViewer同款),实时缓冲与csdk回退为插值2×;实测冷区|dx| 0.76×官方(更平滑),119单测全绿
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user