app+csdk+docs: 按反汇编定案修复——官方安卓路径无 160→320 插值级(ImageViewer 无 FILTER_BITMAP 最近邻放大),我们的双线性 2× 自造交错细纹;实测冷区细条纹比 1.16/1.29→1.00/0.93,csdk 同加 upscale_nearest,119 单测全绿

This commit is contained in:
ZXCLI
2026-10-06 17:09:00 +08:00
parent 0be75fa8c3
commit 4a3916a2b7
10 changed files with 188 additions and 1 deletions
@@ -68,6 +68,23 @@ class RenderPipeline(
* they were baselined on.
*/
var androidLut: Boolean = false,
/**
* Replicate pixels instead of interpolating in the 160x120 -> 320x240 stage.
*
* The official ANDROID path has no such interpolation: its SDK hands the app a
* 160x120 bitmap (`ImageViewer` allocates `Bitmap.createBitmap(fpaWidth,
* fpaHeight)`) and the app draws it with `new Paint(1)` - anti-alias only, no
* FILTER_BITMAP - i.e. nearest-neighbour. The bilinear version we ported from
* the PC SDK fabricates an interleaved full/half-amplitude pattern at the
* sensor's Nyquist frequency (even columns keep the sample, odd columns get the
* neighbour average), which reads as extra fine stripes exactly where the
* palette is steepest: the dark/cold regions. Measured on device: fine-scale
* column-stripe ratio vs the official 1.16-1.29x with bilinear, wide-scale
* (real FPN) 0.87-0.55x.
*
* false keeps the PC-reference bytes for the C-parity golden tests.
*/
var upscaleNearest: Boolean = false,
/**
* `BasePara2.fpaTempFix`, added to the frame-tail FPA before the per-pixel
* calibration tables are interpolated (the official app passes
@@ -617,6 +634,20 @@ class RenderPipeline(
val W2 = W * 2
val s = gray160
val o = gray320
if (upscaleNearest) {
for (y in 0 until H) {
val r = y * W
val o0 = y * W2 * 2
val o1 = o0 + W2
for (x in 0 until W) {
val v = s[r + x]
val c = x * 2
o[o0 + c] = v; o[o0 + c + 1] = v
o[o1 + c] = v; o[o1 + c + 1] = v
}
}
return
}
for (y in 0 until H - 1) {
val r0 = y * W
val r1 = r0 + W
@@ -324,6 +324,10 @@ 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,
// 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
@@ -192,3 +192,66 @@ class MdtRawNucPairTest {
return cov / kotlin.math.sqrt(va * vb)
}
}
/**
* The 160x120 -> 320x240 stage must not fabricate an interleaved pattern when the
* official-parity path is on: the official Android display draws the native 160x120
* bitmap with a non-filtering paint (nearest), so a bilinear 2x upscale would add a
* full/half-amplitude column pattern that the official image does not have - most
* visible in the dark (cold) regions where the palette is steepest.
*/
class UpscaleParityTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
private fun frame(counter: Int): ByteArray {
val f = ByteArray(0x38 + 38400)
MagProtocolBytes.putU32(f, 0, 0x1BB1B11B)
MagProtocolBytes.putU32(f, 4, counter)
MagProtocolBytes.putU32(f, 8, 38400)
MagProtocolBytes.putU32(f, 12, 0)
for (i in 0 until 19200) {
// smooth ramp + a little structure so the LUT has spread
val x = i % 160
val y = i / 160
MagProtocolBytes.putU16(f, 0x1C + i * 2, 11500 + x * 4 + y * 3)
}
MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, 19000)
return f
}
private fun render(nearest: Boolean): IntArray {
val p = RenderPipeline(
w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2,
force75 = false, androidLut = true, upscaleNearest = nearest,
onFfc = {},
)
assertTrue("DDT must load", p.loadDdt(res("mag160c_official.ddt")))
val out = IntArray(320 * 240)
var c = 0
while (c < 200) {
if (p.frame(frame(c), true, out)) return out
c++
}
throw AssertionError("never rendered")
}
@Test
fun nearestUpscaleReplicatesPixelPairs() {
val nearest = render(true)
val bilinear = render(false)
assertFalse("the two upscale modes must differ", nearest.contentEquals(bilinear))
// with nearest, row pairs are identical; with bilinear they are not
var sameRows = 0
var sameRowsBilinear = 0
for (y in 0 until 239 step 2) {
for (x in 0 until 320) {
if (nearest[y * 320 + x] == nearest[(y + 1) * 320 + x]) sameRows++
if (bilinear[y * 320 + x] == bilinear[(y + 1) * 320 + x]) sameRowsBilinear++
}
}
assertTrue("nearest must replicate every second row ($sameRows/38240)",
sameRows > 320 * 119)
}
}