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