app+docs: 真机验证——修实时全黑(ArrayStoreException)并补端到端测试;同场景 A/B 映射已对齐(平均4.5/255,逐计数差2级);复核确认 LUT 步进常数用反汇编公式即最优;剩余差异定位到计数噪声域
This commit is contained in:
@@ -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 空转刷新
|
||||
(不是渲染帧率)。
|
||||
|
||||
@@ -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 才是差异)
|
||||
|
||||
用户:"先从逆向文件里面找灵感,尽可能多读一些伪代码,然后推测可能缺失的东西,再补上验证"。
|
||||
|
||||
Reference in New Issue
Block a user