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)
}
}
+4
View File
@@ -301,6 +301,10 @@ build-artifacts\csdk_test_display.exe
同场景照片逐像素平均差 4.5/255、逐计数中位差 2 级,见 §10/§11)。
⚠️ **本机没有 C 编译器**(gcc/clang/NDK 都没有),所以这段 C **未编译**——
算法本身由 Kotlin golden 测试锁定,合入前请在带编译器的环境编译 + 跑回归。
- 同一节还有个 `upscale_nearest`:官方**安卓**路径没有 160→320 的插值级
(SDK 给 App 的位图就是 160×120,App 用 `new Paint(1)`(无 FILTER_BITMAP)最近邻放大),
而 PC 版的双线性会展出一层"全幅/半幅"交错细纹(暗/低温区最明显)。
真机实测:冷区细尺度列条纹比 1.16/1.29(双线性)→ **1.00/0.93**(最近邻)。
- 顺带(同一条链):**曲线插值温度要加 `BasePara2.fpaTempFix`**。官方 App 传给 SDK 的是
`帧尾 FPA + fpaTempFix`(真机 1699),`SelectCaliCurve`/`GenerateVolatileLens`
用它选/插值每像素标定表;温度换算链路仍用帧尾原值。C 侧**已实现**
+8
View File
@@ -60,6 +60,14 @@ typedef struct mag160c_render_cfg_t {
int fpa_temp_fix;
/* Brightness offset for the Android LUT base; the official app passes 0. */
int lut_brightness;
/* 1 = replicate pixels in the 160x120 -> 320x240 stage instead of interpolating.
The official ANDROID path has no such stage at all (its SDK hands the app a
160x120 bitmap which ImageViewer draws with a non-filtering paint = nearest),
while the PC SDK's bilinear version fabricates an interleaved full/half
amplitude column pattern at the sensor Nyquist - visible as extra fine stripes
in the dark/cold regions. Measured on device: fine-scale cold-region stripe
ratio vs the official 1.16-1.29x (bilinear) -> 1.00/0.93 (nearest). */
int upscale_nearest;
mag160c_render_ffc_cb_t ffc_cb;
void *ffc_user;
mag160c_render_post_nuc_cb_t post_nuc_cb;
+15 -1
View File
@@ -59,7 +59,7 @@ struct mag160c_render_t {
mag160c_render_post_gray_cb_t post_gray_cb;
void *post_gray_user;
int ffc_period, ffc_drift, warm_frames, cdf_pivot_75, force75;
int android_lut, fpa_temp_fix, lut_brightness;
int android_lut, fpa_temp_fix, lut_brightness, upscale_nearest;
};
static int counts_to_temp_mc(int counts) {
@@ -524,6 +524,19 @@ static void upscale2x(mag160c_render_t *r) {
const unsigned char *s = r->gray160;
unsigned char *o = r->gray320;
int W = r->w, H = r->h, W2 = W * 2;
if (r->upscale_nearest) {
/* Android parity: no interpolation stage (see mag160c_render.h) */
for (int y = 0; y < H; ++y) {
const unsigned char *row = s + y * W;
unsigned char *o0 = o + y * W2 * 2, *o1 = o0 + W2;
for (int x = 0; x < W; ++x) {
unsigned char v = row[x];
o0[x * 2] = v; o0[x * 2 + 1] = v;
o1[x * 2] = v; o1[x * 2 + 1] = v;
}
}
return;
}
for (int y = 0; y < H - 1; ++y) {
const unsigned char *r0 = s + y * W, *r1 = r0 + W;
unsigned char *o0 = o + y * W2 * 2, *o1 = o0 + W2;
@@ -667,6 +680,7 @@ mag160c_error_t mag160c_render_init(mag160c_render_t **out,
r->android_lut = cfg->android_lut;
r->fpa_temp_fix = cfg->fpa_temp_fix;
r->lut_brightness = cfg->lut_brightness;
r->upscale_nearest = cfg->upscale_nearest;
r->force75 = cfg->startup_force_ffc;
r->dev24 = 5;
r->dev4c_shift = 3;
+1
View File
@@ -618,6 +618,7 @@ int WINAPI WinMain(HINSTANCE inst, HINSTANCE prev, LPSTR cmd, int show) {
cfg.cdf_pivot_75 = 1; /* kept for the PC path */
cfg.android_lut = 1;
cfg.fpa_temp_fix = 1699; /* BasePara2.fpaTempFix of device 160043865 */
cfg.upscale_nearest = 1; /* official Android path has no interpolating 2x stage */
cfg.ffc_cb = ffc_cb;
cfg.post_nuc_cb = post_nuc_cb;
cfg.post_gray_cb = post_gray_cb;
+1
View File
@@ -134,6 +134,7 @@ int main(int argc, char **argv) {
cfg.cdf_pivot_75 = 1; /* kept for the PC path */
cfg.android_lut = 1;
cfg.fpa_temp_fix = 1699; /* BasePara2.fpaTempFix of device 160043865 */
cfg.upscale_nearest = 1; /* official Android path has no interpolating 2x stage */
cfg.ffc_cb = ffc_cb;
mag160c_render_t *render = NULL;
if (mag160c_render_init(&render, &cfg) != MAG160C_OK) {
@@ -528,3 +528,41 @@ JPEG 只能解释一部分:把我们那张(960×720 q95,202 KB)按 640×
验证:临时把 `fpaTempFix = 0` 出两版,量冷区 HF(同样一张场景、同样流程)。
2. band0 的 gain 插值本身与 PC 参考路径的差异(PC 端 NUC 逐字节一致,说明差异在
Android 的 `GenerateVolatileLens` 重生成路径,而非表本身)。
## 14. 定案并修复:"低温区条纹更多"= 我们自造的 2× 交错图案(反汇编依据 + 实测验证)
### 14.1 依据(官方安卓路径没有插值级)
`ImageViewer`(官方 App,jadx 源码):
- `this.bmpIr_ = Bitmap.createBitmap(fpaWidth, fpaHeight, ARGB_8888)`(**160×120**,即 SDK 直接给原生尺寸);
- `this.paint_ = new Paint(1)` —— 只有 ANTI_ALIAS,**没有 FILTER_BITMAP**;离屏 `cachedbmp_ = Bitmap.createBitmap(640, 480)` 就用这些 paint 画进去(= **最近邻**)。
而我们的 `upscale2x`(移植自 PC SDK)是**双线性**:偶列取原样本、奇列取邻均值 →
在传感器 Nyquist 上**人为造出一层"全幅/半幅"交错的细竖条纹**,且**只在色带最陡的暗/低温区**最扎眼。
### 14.2 实测验证(同场景 A/B,冷区=最冷 25% 像素)
| 冷区指标(照片,160×120 网格) | 修复前(双线性 2×) | 修复后(最近邻 2×) |
|---|---|---|
| 细尺度列条纹(去趋势宽 5)之比 | 1.16 ✗ | **1.00 ✔** |
| 细尺度列条纹(去趋势宽 9)之比 | 1.29 ✗ | **0.93 ✔** |
| 宽尺度列条纹(21 / 41)之比 | 0.90 / 0.87 ✔ | 0.82 / 0.72 ✔ |
| 列剖面相关性(同一 FPN 图案) | +0.815 | +0.832 ✔ |
| \|dx\| HF 之比 | 1.27 | 1.67(见 14.3) |
→ **用户报的"细条纹"就是这层自造交错**:去掉后细尺度条纹与官方一致(1.00/0.93),
而真实 FPN(宽尺度)我们本来就比官方更轻。
### 14.3 顺带说明:HF 比值反而变大不是变差
重建后的 `|dx|` HF 从 1.27 升到 1.67,是因为**旧的交错本身就是一次列方向的平滑**
(奇列被平均掉一半),它把这个指标"压低"了——去掉假平滑后露出的是真实逐像素噪声。
即旧值 1.27 是"假图案 + 被平滑的噪声"的合成,新值是纯噪声。逐像素噪声的差异
(同一场景、同一映射下)仍待查,方向已收窄到"照片路径的 JPEG/缩放"与"设备帧噪声"两者,
方法学与数据见 §12/§13。
### 14.4 落地
- `RenderPipeline.upscaleNearest`(构造参数,实时会话 = true;默认 false 保持 C 参考字节不变);
- csdk:`mag160c_render.h/.c` 增加 `upscale_nearest`(两个 demo 默认 1),同一段注释说明依据;
- 单测新增 `UpscaleParityTest`(两种模式必须不同 + 最近邻必须逐行复制),共 **119 全绿**。
+23
View File
@@ -489,6 +489,29 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。
## 用户反馈修复 第三十五轮(2026-10-06 夜,"低温区条纹更多":定位为我们自造的 2× 交错并修复)
用户:"你依据反汇编出来的东西进行修复"。
### 依据(官方安卓路径没有插值级)
`ImageViewer`:`Bitmap.createBitmap(fpaWidth,fpaHeight)` = **160×120** 原生尺寸,
`new Paint(1)`(仅抗锯齿、**无 FILTER_BITMAP**)+ 离屏 640×480 → **最近邻**放大。
我们的 `upscale2x`(PC SDK 移植)是双线性(偶列原值、奇列邻均值)→ 在传感器 Nyquist 上
自造"全幅/半幅"交错细竖纹,恰好在暗/低温区最显眼。
### 实测(同场景 A/B,冷区=最冷 25%)
细尺度列条纹比(我们/官方):**1.16 / 1.29(双线性)→ 1.00 / 0.93(最近邻)** ✔;
宽尺度(真实 FPN)0.87/0.90 → 0.72/0.82(我们本来更轻 ✔);列剖面相关性 0.815→0.832。
`|dx|` HF 比 1.27→1.67 是**旧交错充当了列平滑**所致(假图案被去掉后露出真实噪声),
不是变差;逐像素噪声差异的排查见 §12/§13。
### 落地
`RenderPipeline.upscaleNearest`(实时 true,默认 false 保 C 参考字节不变);
csdk 增加 `upscale_nearest`(两个 demo 默认 1);新增 `UpscaleParityTest`;**119 单测全绿**。
## 用户反馈修复 第三十四轮(2026-10-06 晚,"低温区条纹更多":冷区噪声超预算 1.65×,定位中)
用户:"不对吧,还是有差别,我换了个场景,背景的低温区域在我们的app里面明显条纹更多"。