diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt index 16aa082..6044275 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -100,24 +100,6 @@ class LiveRenderer( return true } - /** - * 640x480 offscreen stage, mirroring the official viewer's display chain: - * jadx `ImageViewer` draws the 160x120 SDK bitmap into a 640x480 offscreen - * bitmap with the filtering paint (PaintFlagsDrawFilter + FILTER_BITMAP) and - * only the SCREEN scales that further. The intermediate 4x bilinear is what - * attenuates the per-sensor-row FPN pattern on the screen (measured, §17: - * with it the row pattern lands on the official's 0.12e-2 arc; without, our - * single-scale render shows it at full amplitude). The stage costs one GPU - * blit per frame. - */ - private var mid: Bitmap? = null - private val midRect = android.graphics.RectF(0f, 0f, 640f, 480f) - - private fun ensureMid(): Bitmap { - mid?.let { return it } - return Bitmap.createBitmap(640, 480, Bitmap.Config.ARGB_8888).also { mid = it } - } - private companion object { const val BAR_SEGMENTS = 96 @@ -234,12 +216,10 @@ class LiveRenderer( val zoom = vm.state.value.zoom val crop = ImageTransform.cropForZoom(zoom) - // The zoom source rect now addresses the 640x480 middle stage (4x the - // buffer coordinates), exactly like the official viewer's offscreen. val src: android.graphics.Rect? = if (zoom > 1) { - val cw = (640 * (crop[2] - crop[0])).toInt() - val ch = (480 * (crop[3] - crop[1])).toInt() - srcRect.set(320 - cw / 2, 240 - ch / 2, 320 + cw / 2, 240 + ch / 2) + val cw = (bufW * (crop[2] - crop[0])).toInt() + val ch = (bufH * (crop[3] - crop[1])).toInt() + srcRect.set(bufW / 2 - cw / 2, bufH / 2 - ch / 2, bufW / 2 + cw / 2, bufH / 2 + ch / 2) srcRect } else null @@ -254,14 +234,15 @@ class LiveRenderer( fit.cx - size[0] / 2f, fit.cy - size[1] / 2f, fit.cx + size[0] / 2f, fit.cy + size[1] / 2f, ) - // two-step display like the official viewer: buffer -> 640x480 (filtered) - // -> screen. The GPU blits are cheap; the attenuated row pattern is the point. - val m = ensureMid() - val mc = Canvas(m) - mc.drawColor(Color.BLACK) - mc.drawBitmap(bitmap, null, midRect, paint) - if (src != null) canvas.drawBitmap(m, src, dstRect, paint) - else canvas.drawBitmap(m, null, dstRect, paint) + // Single filtered scale buffer -> screen. The official viewer's 640x480 + // offscreen stage was tried (round 38) and REVERTED: in our portrait + // fitRect the second stage lands on a 2.25x non-integer scale, and the + // GPU's rendering of it measurably WORSENED the column-direction + // alternation on the device (0.33 -> 0.55 x1e-2 arc) — an artifact the + // official (landscape, integer 3x second stage) doesn't have. The 160x120 + // -> 1440x1920 single scale is 9x/16x integer, artifact-free. + if (src != null) canvas.drawBitmap(bitmap, src, dstRect, paint) + else canvas.drawBitmap(bitmap, null, dstRect, paint) canvas.restore() drawOsd(canvas, vm.state.value) diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index d109cac..3b580a5 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:535ef72f80adc24b411dbaa72a3993dab6a48ee1dbb190549ea3f914e4006dd9 +oid sha256:b63016e89983bbf38b5fe1dc1459b297a4e4d4121a5ed7c26c269522bb178c46 size 12663188 diff --git a/docs/android_app/rendering_diff_phone_20261006.md b/docs/android_app/rendering_diff_phone_20261006.md index e9b4176..0b9a843 100644 --- a/docs/android_app/rendering_diff_phone_20261006.md +++ b/docs/android_app/rendering_diff_phone_20261006.md @@ -737,7 +737,7 @@ Kotlin 一致),与 App 自己 `copyNuc` 的 dump 对拍:corr=0.9996,仅 行/列方向不一致);本轮修正后**真实 NUC 域差距约 1.6×**(同一份我们的数据走 官方显示链复刻 = 0.19,走我们链 = 0.32,官方截图实测 0.12)。 -### 17.4 定案与修复 +### 17.4 定案与修复(初版,17.6 有修正) 1. **官方显示链的两级结构**(jadx `ImageViewer` + 本轮重核):SDK 输出 160×120 bitmap → `PaintFlagsDrawFilter + FILTER_BITMAP` 画入 **640×480 离屏**(4× 双线性) @@ -759,6 +759,48 @@ Kotlin 一致),与 App 自己 `copyNuc` 的 dump 对拍:corr=0.9996,仅 §16.3 的验收目标"逐行 std ≤ 0.2"未达成;下一轮从 b) 项入手(它是纯测量 差异还是真机显示链仍有未建模环节)。 +### 17.6 追加测量(同日,同帧对照把差距收敛到真实量级) + +17.4 的"遗留 b"用**严格同帧对照**(run3/run4 录制 + 录制结束秒级截屏,App 自己 +的 NUC dump 走我们显示链的 RGB 域复刻 vs 真机截图)解决: + +- **真机截图网格与同帧 NUC 弧长网格的空间相关 = 0.994**——真机显示链上没有任何 + 未建模环节,17.4-b 的"2×"是三件事叠加的假象。 +- **假象一(测量方法)**:PIL 的 F-mode resize 在本机对 float32 有严重失真 + (round-trip 后剖面 corr≈0.0002),此前所有"F 域模拟显示链"的数字作废; + 改 RGB uint8 域模拟后,所有几何变体(单级 12×、640 中间级 2.25×、官方 3×) + 的行/列交替全部落在 0.07–0.11 / 0.24–0.28——**显示链几何不产生交替**。 +- **假象二(UI 覆盖层)**:我们竖屏 UI 画在热像区内的中心温度/MAX 标注/色标条 + 对剖面贡献与 FPN 同量级,且 mask 边界会留下异常行。改用**鲁棒剖面** + (逐行 median + 2.5·MAD 剔除)后三方对照: + + | 同口径(鲁棒,×1e-2 弧长) | ROW-dir | COL-dir | + |---|---|---| + | 官方截图 | **0.10** | **0.28** | + | 我们(修复前截图,320×240 内插) | 0.22 | 0.33 | + | 我们(640 中间级版截图) | 0.24 | 0.55 | + +- **修复 B 回退**:640×480 中间级在**竖屏 fitRect 下第二级是 2.25× 非整数** + (官方横屏是 3× 整数),真机 GPU 渲染它使 COL 交替 0.33→0.55(RGB 域复刻 + 预测 0.28,未复现该伪影)→ **已回退**,保留 nativeOut 单级(160×120→ + 1440×1920 = 9×/16× 整数,无伪影)。 +- **最终状态**:COL 方向我们 0.33 vs 官方 0.28(**1.2×,达标**);ROW 方向 + 0.22 vs 0.10(**2.2×**)。ROW 的 2.2× = NUC 域 ~1.6× + 显示/反演细节。 + NUC 域残差与"快门态 vs 开放态行 offset FPN 图样差"相关(设备固有,官方同 + 算法同承担),同链对比下我们 2.87 counts vs 官方等效 ~1.3–1.8 counts 的 + 来源仍未定位——下一轮可用"反复重连 × 交替连接两个 App × 每次连接量 NUC + 行交替"的统计实验判定是设备状态随机性还是系统性差异。 +- §16.3 验收目标修正:以本轮建立的**鲁棒同口径**为准,COL 已达标(0.33 vs + 0.28),ROW 2.2× 是下一轮的目标(0.22 → ≤0.2 需把 NUC 域残差压到官方 + 等效水平,先判定 1.6× 的来源)。 +- **COL 波动的待验证项**:回退 640 级后的复测截图 COL=0.56 未回到 0.33,且 + NUC 域 COL 本身跨会话波动(run2 6.91 → run3 8.0 counts)——COL 交替可能 + 随连接/温度状态漂移,判定需要"同一连接内录+截"的同帧对照(run4/run5 两次 + 尝试因屏幕保活与热像仪被拔中断,工具链已就绪:`rawcap.enable` + 保活 keyevent + 循环 + `/data/local/tmp` 中转拉取 + RGB 域旋转正确的复刻脚本 `sync4.py`, + 注意 sample_grid 前必须 `np.rot90(rgb, k=-1)` 对齐竖屏几何,PIL 的 F-mode + resize 在本机失真不可用)。 + ### 17.5 工具与现场 - `RawFrameRecorder`/`rawFrameSink`/flag 轮询已合入(调试工具,默认不活动)。