diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt index 86172fc..26c881a 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt @@ -541,9 +541,10 @@ class RenderPipeline( private fun lutRebuildAndroid() { val lutRange = ((dev24 * 1000) shr dev4cShift).coerceAtLeast(0x80) val fresh = androidLutOf(nuc, winLo, winHi, statMean, lutRange, lutBrightness) + // element copy on purpose: System.arraycopy cannot move ints into a ByteArray + for (j in 0 until 1023) lut[j] = fresh[j].toByte() // lut[1023] is intentionally left untouched: the vendor loop stops at 1022, // and MapTemperature can index 1023 for pixels at/above winHi. - System.arraycopy(fresh, 0, lut, 0, 1023) } private fun grayMap() { diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt new file mode 100644 index 0000000..c74883a --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/AndroidLutPipelineTest.kt @@ -0,0 +1,96 @@ +package com.mag160c.thermal.core + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * End-to-end guard for the Android-LUT live configuration. + * + * `AndroidLutTest` pins the pure LUT function, but the *instance* path (stats -> + * androidLutOf -> the byte LUT -> gray -> upscale -> palette) can still break on + * its own — a `System.arraycopy` from the int LUT into the byte LUT threw + * ArrayStoreException on every frame and left the live view black while the pure + * test stayed green. This test therefore drives real frames through the pipeline + * with `androidLut = true` and asserts the output actually renders. + */ +class AndroidLutPipelineTest { + private fun res(name: String): ByteArray = + javaClass.classLoader.getResourceAsStream(name)!!.readBytes() + + private fun frame(counter: Int, filler: Int, fpa: Int = 19000): 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 38400) f[0x1C + i] = ((filler + i) and 0xFF).toByte() + MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C) + MagProtocolBytes.putU32(f, 0x1C + 38400 + 4, counter) + MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, fpa) + MagProtocolBytes.putU32(f, 0x1C + 38400 + 12, 0) + return f + } + + private fun pipeline(androidLut: Boolean, fpaFix: Int = 1699): RenderPipeline = + RenderPipeline( + w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2, + force75 = false, androidLut = androidLut, fpaTempFix = fpaFix, onFfc = {}, + ).also { assertTrue("DDT must load", it.loadDdt(res("mag160c_official.ddt"))) } + + private fun renderOnce(p: RenderPipeline): IntArray { + val out = IntArray(320 * 240) + var counter = 0 + while (counter < 200) { + if (p.frame(frame(counter, counter), true, out)) return out + counter++ + } + throw AssertionError("pipeline never rendered") + } + + @Test + fun androidLutPipelineRendersNonBlackFrames() { + val out = renderOnce(pipeline(androidLut = true)) + val distinct = out.toSet().size + assertTrue("expected a mapped palette image, got $distinct distinct colours", distinct > 4) + // the warm-up frame is black on purpose; a broken LUT would leave every + // frame black, which is what this guards against + assertFalse("every pixel is black - LUT or palette path is broken", + out.all { (it and 0x00FFFFFF) == 0 }) + } + + @Test + fun androidLutAndPcLutBothRenderAndDiffer() { + val a = renderOnce(pipeline(androidLut = true)) + val b = renderOnce(pipeline(androidLut = false)) + assertFalse("the two LUT variants must not produce identical pixels", a.contentEquals(b)) + } + + @Test + fun fpaTempFixShiftsTheInterpolatedTables() { + // the fix moves the interpolation temperature, so with a DDT whose endpoint + // spacing is thousands of units the rendered pixels must change + val withFix = renderOnce(pipeline(androidLut = true, fpaFix = 1699)) + val without = renderOnce(pipeline(androidLut = true, fpaFix = 0)) + assertFalse("fpaTempFix had no effect on the per-pixel tables", + withFix.contentEquals(without)) + } + + @Test + fun androidLutRendersFramesAfterAnFfc() { + // a manual FFC restarts the reference windows and swaps the tables; the + // Android LUT must survive that transition + val p = pipeline(androidLut = true) + renderOnce(p) + p.requestFfc() + renderOnce(p) + val out = IntArray(320 * 240) + var counter = 100 + var ok = false + while (counter < 400 && !ok) { + ok = p.frame(frame(counter, counter), true, out) + counter++ + } + assertTrue("no frame rendered after the FFC", ok) + } +} diff --git a/docs/android_app/rendering_diff_phone_20261006.md b/docs/android_app/rendering_diff_phone_20261006.md index 77d8937..95d7e69 100644 --- a/docs/android_app/rendering_diff_phone_20261006.md +++ b/docs/android_app/rendering_diff_phone_20261006.md @@ -349,3 +349,70 @@ ffcTriggerTemperature=250, enlargeRange=8, baseLineAcc=4, fpaTempFix=1699`。 min/max 还要多采每行的第 1 列,共 5 列;我们只采了求和用的 4 列),会让门槛略偏严, 属可选功能的精度问题,不影响默认路径; 3. 同步 csdk(Android 版 LUT + fpaTempFix + 温度链)。 + +## 11. 真机验证(2026-10-06 下午,手机解锁后) + +### 11.1 先修掉一个"实时全黑"的 bug(并补上会拦住它的测试) + +Android LUT 落地时把 `IntArray` 的 LUT 用 `System.arraycopy` 拷进 `ByteArray` 的 `lut` +——**每帧抛 `ArrayStoreException`**,实时画面整块全黑(SurfaceView 仍在刷,logcat 无异常)。 +黄金向量测试只覆盖纯函数 `androidLutOf()`,没覆盖实例路径,所以没拦住。已改为逐元素拷贝, +并新增 `AndroidLutPipelineTest`(4 个端到端用例:`androidLut=true` 能渲染出非全黑/单色画面、 +两种 LUT 结果必须不同、`fpaTempFix` 必须改变插值表、手动 FFC 之后仍能渲染)。 +单测 113 → **117 全绿**。 + +### 11.2 同场景 A/B:映射已经对齐 + +手机(22041211AC,官方 App 与我们的 App 前后 2 分钟各拍一张,同一静止场景), +用**调色板索引空间**在同一 160×120 网格上比对(避开两个 App 不同的放大倍数): + +| 指标 | 官方 | 我们 | +|---|---|---| +| 索引分位 p5/p25/p50/p75/p95 | 82 / 89 / **96** / 117 / 133 | 85 / 92 / **98** / 114 / 134 | +| 逐计数中位索引(9490→10330 共 21 档)差值 | — | **中位 2.0(最大 4.5)** | +| 全图平均 \|Δ索引\| | — | **4.47 / 255**,中位 3.6,85.1% ≤8,99.2% ≤16 | + +对比上一轮的旧 LUT:低端被压死(我们 p5/p25 = 8/17,官方 45/52,等效系统偏差 ≈35 级) +→ **"官方对比度更高、更细腻"的映射差异已消除**。 + +### 11.3 复核那个遗留常数 k:反汇编公式是对的 + +用我们照片里存的 **NUC 计数**逐像素对官方照片做模型比对(新的一对照片, +计数窗 [9142,10383],span=1241,公式 k=19): + +| 步进常数 | 平均 \|Δ索引\| | 中位 | ≤8 占比 | +|---|---|---|---| +| **k = 16·span/range(反汇编公式,=19)** | **4.64** | **3.9** | **82.9%** | +| k = 16(上一轮"拟合最优") | 5.76 | 5.1 | 73.6% | +| k = 12 | 9.16 | 8.1 | 49.2% | +| k = 24 | 4.92 | 4.1 | 80.8% | + +→ **按反汇编公式实现即为最优**;上一轮"数据偏好 ≈16"是当时那对照片(曝光/窗宽都不同)的产物, +**结论作废,代码不动**(§10.4 的遗留项关闭)。 + +### 11.4 遗留并已定位到域:我们的"计数"噪声偏大 + +同一个 LUT 作用在**我们的 NUC 计数**上得到的 HF(传感器网格)明显高于官方成品: + +| 数据 | HF \|dx\| / \|dy\| | +|---|---| +| 官方照片(其渲染输出) | 2.46 / 2.22 | +| 官方实时截图(1.2 s ×3) | 1.61 / 1.51,帧间 1.83/1.77 | +| 我们的实时截图 | 2.35 / 2.39,帧间 2.34/2.40 | +| 我们的 NUC → 同一个 LUT(模型) | 4.65 / 4.25 | + +即:**映射一致、噪声不一致**,且证据指向"我们的计数本身比官方毛"(同 LUT 下模型 HF 高 1.9×)。 +排除项:FFC 之后立刻截图(新鲜参考)几乎不改善(帧间 2.44→2.34)→ 不是参考老化; +我们的实时帧率实测 **15.0 fps**、单帧绘制 6-9 ms(与第 22 轮记录一致,无回归)。 +下一步方向:把**实时 FFC 参考采集**与已验证逐字节一致的离线/PC 路径对齐(同一个参考下 +PC 端 NUC 是逐字节相同的,所以嫌疑在"实时状态机采到的参考帧")。 + +### 11.5 运维笔记(真机调试踩坑) + +1. **正在推流时 force-stop App 会把相机卡死**(命令端点 halt、`GetCaliInfo` 超时、此后无流数据)。 + 恢复:`su -c 'echo 0 > /sys/bus/usb/devices/2-1/authorized; sleep 2; echo 1 > ...'` 重新枚举; + 切换 App 时**先按 HOME 让 App 正常停流**。 +2. MIUI 的 USB 授权在**每次重装/重新枚举**后都要重新授权;要勾"一律打开 MAG160C" + 必须点**复选框本体**(x≈186),点标签中部(x≈718)不会勾上;授权后 App 需要重启进程才生效。 +3. 相机被卡住时 App 的统计会显示 `reads=0 frames=0 timeouts=N`,UI 以 ~3.3 fps 空转刷新 + (不是渲染帧率)。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 17ccee5..138a1e4 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,50 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 +## 用户反馈修复 第三十二轮(2026-10-06 下午,手机解锁后真机验证:渲染已对齐,剩噪声) + +用户:"手机我开了,你进行测试"。 + +### 1) 先修掉实时全黑(我上一轮引入的 bug) + +`System.arraycopy(IntArray → ByteArray)` 每帧抛 `ArrayStoreException` → 实时画面全黑(SurfaceView +仍刷、logcat 无异常)。黄金向量测试只覆盖纯函数,没覆盖实例路径。已改逐元素拷贝 + 新增 +`AndroidLutPipelineTest`(4 个端到端用例)。单测 **117 全绿**。 + +### 2) 同场景 A/B:映射对齐 + +官方/我们前后 2 分钟各拍一张(同一静止场景),调色板索引空间、同一 160×120 网格: + +- 分位:官方 [82,89,**96**,117,133] vs 我们 [85,92,**98**,114,134]; +- 逐计数中位索引差:**中位 2.0 级**;全图平均 |Δ| = **4.47/255**,99.2% ≤16。 + +对比旧 LUT(低端压死,我们 8/17 vs 官方 45/52,系统偏差 ≈35 级)→ **"官方对比度更高/更细腻" +的映射差异已消除**。 + +### 3) 步进常数 k 复核:反汇编公式即最优(旧结论作废) + +用照片里存的 NUC 计数对官方照片做模型比对(新的一对,span=1241): + +| k | 平均误差 | ≤8 占比 | +|---|---|---| +| **16·span/range(反汇编,=19)** | **4.64** | **82.9%** | +| 16(上轮"拟合最优") | 5.76 | 73.6% | +| 24 / 12 | 4.92 / 9.16 | 80.8% / 49.2% | + +→ 代码不动,§10.4 的遗留项关闭(上轮"偏好 16"是当时那对照片的产物)。 + +### 4) 剩余问题已定位到"计数噪声"域 + +同 LUT 下我们的 NUC 计数渲染出的 HF(4.65/4.25)明显高于官方成品(照片 2.46/2.22), +即**映射一致、噪声不一致**;FFC 后立刻截图几乎不改善(帧间 2.44→2.34)→ 不是参考老化; +实时帧率 15.0 fps、单帧 6-9 ms(无回归)。→ 下一步查**实时 FFC 参考采集**与离线/PC +路径(PC 端 NUC 逐字节一致)的差别。 + +### 5) 运维踩坑(已写进渲染文档 §11.5) + +推流中 force-stop 会把相机卡死(需 USB 重新枚举 `/sys/bus/usb/devices/2-1/authorized`); +切换 App 先按 HOME 正常停流;MIUI 每次重装/重枚举都要重新授权,勾"一律打开"要点**复选框本体**。 + ## 用户反馈修复 第三十一轮(2026-10-06,按真机 libcxsdk 反汇编定案:显示 LUT 才是差异) 用户:"先从逆向文件里面找灵感,尽可能多读一些伪代码,然后推测可能缺失的东西,再补上验证"。