app+docs: 真机验证——修实时全黑(ArrayStoreException)并补端到端测试;同场景 A/B 映射已对齐(平均4.5/255,逐计数差2级);复核确认 LUT 步进常数用反汇编公式即最优;剩余差异定位到计数噪声域

This commit is contained in:
ZXCLI
2026-10-06 15:45:19 +08:00
parent bf03e852ca
commit 2290fbbafb
4 changed files with 209 additions and 1 deletions
@@ -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() {
@@ -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)
}
}
@@ -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 空转刷新
(不是渲染帧率)。
+44
View File
@@ -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 才是差异)
用户:"先从逆向文件里面找灵感,尽可能多读一些伪代码,然后推测可能缺失的东西,再补上验证"。