android: 修正 7x7 细节增强的混叠系数(原实现把增益乘了两遍,画面满噪点)
官方 FilterDetailEnhancement_Simple 把「增益缩放后」的 k = strength*gain*2>>8 只 传给 map(决定阈值与 divisor 下限),混叠系数用的是**原始 strength**。本移植最初 两处都用 k,等于把细节再乘一遍增益:真机 level 2 满屏噪点。改为原始 strength 后 HF 能量与关闭档基本相同(0.47-0.51 vs 0.46-0.47),边缘仍被增强。 细节校正: - 一个有用的推论:每档的边缘强度其实与档位无关(化简为 128/gain),档位只决定 哪些窗口通过对比度阈值——所以调高档位是"纳入更多弱对比区域",不是"推得更狠"。 据此把档位从 0-4 扩到 0/1/2/3/4/6/8(官方 MAG_SetDetailEnhancement 钳 0..32)。 - 官方 App 自身不调用 SetDetailEnhancement(jadx 全量搜索无此符号),SetEX 是数码 变焦 ROI 与增强无关;故无法据此对齐官方默认档,保持用户可见设置、默认关闭。 测试: - 新增 blendUsesTheRawStrengthNotTheGainScaledOne 黄金值用例(推导写在注释里), 把 bug 改回去会立刻失败(已实测验证)。 - 原 gainScalesTheEffect 改为 gainDoesNotAmplifyNoiseOnAFlatField + gainBelowOneIsANoOp, 因为修正后增益不再缩放混叠幅度。98 项测试全绿。 顺带修复(同一轮真机复现): - no_device 死锁:首次 connect 撞上相机重枚举时停在「无相机」,且紧随其后的 ATTACHED 广播被 800ms 防抖丢弃 → 永久无画面。防抖/退避改为延后重试,no_device 也安排 1.5s 重试(scheduleRetry 单槽位、最新优先)。 - 手机平放时加速度计 x/y≈0,迟滞逻辑保留旧姿态角导致 OSD 文字整体转 90°; 改为 tilt<4m/s² 判定平放、姿态归零。 - 录像帧缺 max/min 标记:probesAsMarks() 现在一并返回,录像与实时画面一致。 真机(小米 22041211AC/Android12/MIUI):15.1fps 稳定、单帧 6-9ms(开增强 11ms)、 拍照与分析页标记一致且各只有一个 min/max、设置跨冷启动持久化、 屏幕熄灭时渲染停止无空转(CPU 3.7%)。
This commit is contained in:
@@ -32,6 +32,18 @@ package com.mag160c.thermal.core
|
||||
* gray = clamp(gray + (strength * detail) / 32768, 0, 255)
|
||||
* over the rows/cols the earlier stages actually filled (3..H-4).
|
||||
*
|
||||
* ## The two strength values are NOT interchangeable
|
||||
*
|
||||
* The driver passes a gain-scaled strength to the map
|
||||
* k = strength * ((dev24 * 1000) >> shift) * 2 >> 8
|
||||
* and that scaled value is what the map's threshold and divisor floor compare
|
||||
* against, so it sets how much noise the filter is allowed to amplify. The final
|
||||
* blend, however, multiplies the detail by the **raw** strength. Using the scaled
|
||||
* value in both places — as this port first did — amplifies grain by that same
|
||||
* gain factor (measured: visibly speckled at level 2, where the vendor is
|
||||
* near-silent on flat areas). The divisor floor is what keeps flat, noisy regions
|
||||
* quiet while edges still get pushed.
|
||||
*
|
||||
* ## Validation status — READ THIS
|
||||
*
|
||||
* The arithmetic above is a faithful transcription, and the structural properties
|
||||
@@ -61,8 +73,9 @@ class DetailEnhance(private val w: Int, private val h: Int) {
|
||||
fun enhance(src16: IntArray, gray: ByteArray, strength: Int, gain: Int, srcLimit: Int = src16.size) {
|
||||
if (strength <= 0 || gain <= 0) return
|
||||
require(gray.size >= npix) { "gray buffer smaller than $w x $h" }
|
||||
val k = strength * gain * 2 shr 8
|
||||
if (k == 0) return
|
||||
// map coefficient (threshold + divisor floor): gain-scaled
|
||||
val mapStrength = strength * gain * 2 shr 8
|
||||
if (mapStrength == 0) return
|
||||
|
||||
java.util.Arrays.fill(detail, 0)
|
||||
|
||||
@@ -73,7 +86,8 @@ class DetailEnhance(private val w: Int, private val h: Int) {
|
||||
while (row <= h - 7) {
|
||||
var col = 0
|
||||
while (col <= w - 7) {
|
||||
detail[(row + 3) * w + (col + 3)] = localMap(src16, col, row, k, srcLimit)
|
||||
detail[(row + 3) * w + (col + 3)] =
|
||||
localMap(src16, col, row, mapStrength, srcLimit)
|
||||
col++
|
||||
}
|
||||
row += 2
|
||||
@@ -82,22 +96,26 @@ class DetailEnhance(private val w: Int, private val h: Int) {
|
||||
while (oddRow <= h - 7) {
|
||||
var col = 0
|
||||
while (col <= w - 7) {
|
||||
detail[(oddRow + 3) * w + (col + 3)] = localMap(src16, col, oddRow, k, srcLimit)
|
||||
detail[(oddRow + 3) * w + (col + 3)] =
|
||||
localMap(src16, col, oddRow, mapStrength, srcLimit)
|
||||
col++
|
||||
}
|
||||
oddRow += 2
|
||||
}
|
||||
|
||||
// --- pass 2: apply to the gray image (rows 3..H-4, cols 3..W-4 — exactly
|
||||
// the region pass 1 filled) ---
|
||||
// the region pass 1 filled). The coefficient here is the RAW strength, not
|
||||
// [mapStrength]: that is what the vendor's blend uses. ---
|
||||
var y = 3
|
||||
while (y < h - 3) {
|
||||
var x = 3
|
||||
while (x < w - 3) {
|
||||
val d = detail[y * w + x]
|
||||
if (d != 0) {
|
||||
// (k * detail) / 32768, truncated toward zero like the vendor
|
||||
val prod = k * d
|
||||
// (strength * detail) / 32768, truncated toward zero like the
|
||||
// vendor (their `+ ((x >> 31) >> 17)` is the usual
|
||||
// divide-by-power-of-two correction)
|
||||
val prod = strength * d
|
||||
val delta = (prod + (if (prod < 0) 0x7FFF else 0)) shr 15
|
||||
var v = (gray[y * w + x].toInt() and 0xFF) + delta
|
||||
if (v < 1) v = 0
|
||||
|
||||
@@ -487,9 +487,14 @@ class RenderPipeline(
|
||||
pal = Palettes.buildAll()[index.coerceIn(0, Palettes.NAMES.size - 1)]
|
||||
}
|
||||
|
||||
/** Detail enhancement strength; 0 disables it (byte-exact reference path). */
|
||||
/**
|
||||
* Detail enhancement strength; 0 disables it (byte-exact reference path).
|
||||
*
|
||||
* The vendor's ceiling: `MAG_SetDetailEnhancement` accepts a level 0..32 and the
|
||||
* pipeline passes `level << 3`, so the strength tops out at 256.
|
||||
*/
|
||||
fun setEnhanceStrength(value: Int) = synchronized(lock) {
|
||||
enhanceStrength = value.coerceIn(0, 64)
|
||||
enhanceStrength = value.coerceIn(0, 256)
|
||||
}
|
||||
|
||||
fun enhanceStrength(): Int = synchronized(lock) { enhanceStrength }
|
||||
|
||||
@@ -59,7 +59,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
/** Apply the enhancement level to the running pipeline. */
|
||||
fun applyEnhanceLevel(level: Int) {
|
||||
enhanceLevel = level.coerceIn(0, 4)
|
||||
// The vendor's own range: MAG_SetDetailEnhancement accepts levels 0..32 and
|
||||
// the pipeline derives the filter strength as `level << 3`. 0 keeps the
|
||||
// byte-exact verified path.
|
||||
enhanceLevel = level.coerceIn(0, 32)
|
||||
session.setEnhanceStrength(enhanceLevel shl 3)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ object ImageOrientationSettings {
|
||||
val paletteIndex: Int = 2,
|
||||
val traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode =
|
||||
com.mag160c.thermal.ui.live.LiveViewModel.TraceMode.BOTH,
|
||||
/** Detail enhancement level 0..4 (see AppSettings.enhanceLevel). */
|
||||
/** Detail enhancement level 0..32 (see AppSettings.enhanceLevel). */
|
||||
val enhanceLevel: Int = 0,
|
||||
)
|
||||
|
||||
@@ -76,8 +76,11 @@ class AppSettings(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Local 7x7 detail enhancement strength (vendor FilterDetailEnhancement).
|
||||
* 0 = off; the vendor uses `level shl 3` for levels 0..4.
|
||||
* Local 7x7 detail enhancement level (vendor FilterDetailEnhancement_Simple).
|
||||
*
|
||||
* 0 = off. The pipeline strength is `level shl 3`, and the vendor's own setter
|
||||
* (`MAG_SetDetailEnhancement`) accepts levels 0..32, so the value is stored as
|
||||
* entered and only clamped to that range.
|
||||
*
|
||||
* Defaults to OFF because the port has no official reference output to verify
|
||||
* against (the byte-exact baseline predates this stage) — see DetailEnhance.
|
||||
@@ -86,7 +89,7 @@ class AppSettings(context: Context) {
|
||||
var enhanceLevel: Int
|
||||
get() = sp.getInt("enhanceLevel", 0)
|
||||
set(v) {
|
||||
val n = v.coerceIn(0, 4)
|
||||
val n = v.coerceIn(0, 32)
|
||||
sp.edit().putInt("enhanceLevel", n).apply()
|
||||
ImageOrientationSettings.publish(
|
||||
imageRotateDeg, imageFlipH, imageFlipV, enhanceLevel = n,
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -246,14 +247,31 @@ fun SettingsScreen(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"加强局部细节(官方同款 7×7 局部映射)。等级越高细节越明显," +
|
||||
"噪声也会更明显。",
|
||||
"加强局部细节(官方同款 7×7 局部映射)。档位沿用官方 SDK 自己的" +
|
||||
"定义:档位越高,纳入增强的弱对比区域越多(边缘强度本身不变)," +
|
||||
"画面更通透,弱细节里的噪声也会一起显现。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
listOf(0 to "关闭", 1 to "1 级", 2 to "2 级", 3 to "3 级", 4 to "4 级")
|
||||
.forEach { (lvl, label) ->
|
||||
// Vendor levels: MAG_SetDetailEnhancement accepts 0..32 and the
|
||||
// pipeline uses `level << 3`. Extra headroom is safe because the
|
||||
// per-edge amplitude is level-independent (128/gain); the level
|
||||
// only decides which windows clear the contrast threshold.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 320.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
listOf(
|
||||
0 to "关闭",
|
||||
1 to "1 级(轻微)",
|
||||
2 to "2 级",
|
||||
3 to "3 级",
|
||||
4 to "4 级(推荐)",
|
||||
6 to "6 级",
|
||||
8 to "8 级(强)",
|
||||
).forEach { (lvl, label) ->
|
||||
Text(
|
||||
label,
|
||||
color = if (lvl == enhanceLevel) MaterialTheme.colorScheme.primary
|
||||
@@ -264,10 +282,12 @@ fun SettingsScreen(
|
||||
settings.enhanceLevel = lvl
|
||||
dialog = null
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
)
|
||||
|
||||
@@ -105,15 +105,83 @@ class DetailEnhanceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gainScalesTheEffect() {
|
||||
fun changedAt(g: Int): Int {
|
||||
fun gainBelowOneIsANoOp() {
|
||||
// the map coefficient is (strength * gain * 2) >> 8, so a gain that rounds
|
||||
// it to zero disables the stage entirely
|
||||
val gray = grayOf(128)
|
||||
val before = gray.copyOf()
|
||||
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = 8, gain = g)
|
||||
return gray.indices.count { gray[it] != before[it] }
|
||||
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = 8, gain = 0)
|
||||
assertArrayEquals("gain 0 must not change a single pixel", before, gray)
|
||||
}
|
||||
assertTrue("gain 0 is a no-op", changedAt(0) == 0)
|
||||
assertTrue("a real gain must change something", changedAt(3000) > 0)
|
||||
|
||||
/**
|
||||
* The bug this pins: the blend coefficient is the RAW strength while only the
|
||||
* map's threshold/divisor floor is gain-scaled. Multiplying the blend by the
|
||||
* gain as well amplified flat-field grain by the gain factor — on the device
|
||||
* that showed as a heavily speckled image at level 2, where the vendor's own
|
||||
* filter is quiet on flat areas.
|
||||
*/
|
||||
@Test
|
||||
fun gainDoesNotAmplifyNoiseOnAFlatField() {
|
||||
// a "flat" field with a few counts of sensor noise
|
||||
val noisy = IntArray(w * h) { 7200 + ((it * 37) % 5) - 2 }
|
||||
fun spreadAt(g: Int): Int {
|
||||
val gray = grayOf(128)
|
||||
DetailEnhance(w, h).enhance(noisy, gray, strength = 16, gain = g)
|
||||
val vals = (3 until h - 3).flatMap { y ->
|
||||
(3 until w - 3).map { x -> gray[y * w + x].toInt() and 0xFF }
|
||||
}
|
||||
return vals.max() - vals.min()
|
||||
}
|
||||
val low = spreadAt(1000)
|
||||
val high = spreadAt(0xFFFF)
|
||||
// A 65x gain increase must not scale the output spread anywhere near 65x:
|
||||
// the divisor floor holds the detail term down in low-contrast regions.
|
||||
assertTrue(
|
||||
"gain 65535 spread=$high must stay close to gain 1000 spread=$low",
|
||||
high <= low + 8,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Golden value for the blend coefficient, derived from the decompilation.
|
||||
*
|
||||
* `FilterDetailEnhancement_Simple` passes `param_1 * gain * 2 >> 8` to the map
|
||||
* (threshold + divisor floor) but blends with plain `param_1`. Getting that
|
||||
* backwards multiplies the detail by the gain, which is how this port first
|
||||
* behaved — the image looked speckled at level 2 while the vendor's own filter
|
||||
* is quiet on flat areas.
|
||||
*
|
||||
* Derivation with the sample window below (16x16 image, window at row 0 col 0,
|
||||
* centre (3,3)):
|
||||
* 15 samples at 7200, one at 7240, centre 7240
|
||||
* sum = 15*7200 + 7240 = 115240
|
||||
* mean = 115240 >> 4 = 7202
|
||||
* min/max = 7200 / 7240 -> the threshold passes: 375 <= (40)*32 = 1280
|
||||
* divisor = max(7240-7202, 7202-7200, 375) = 375
|
||||
* detail = (0x8000/375) * (7240-7202) = 87 * 38 = 3306
|
||||
* blend = 16 * 3306 >> 15 = 1 (the buggy version: 375*3306>>15 = 37)
|
||||
* so the destination pixel goes 128 -> 129.
|
||||
*/
|
||||
@Test
|
||||
fun blendUsesTheRawStrengthNotTheGainScaledOne() {
|
||||
val size = 16
|
||||
val src = IntArray(size * size) { 0 }
|
||||
// the 16 samples of the window (rows 0,2,4,6 x cols 0,2,4,6)
|
||||
for (dy in 0 until 4) {
|
||||
for (dx in 0 until 4) {
|
||||
src[(dy * 2) * size + dx * 2] = 7200
|
||||
}
|
||||
}
|
||||
src[0] = 7240
|
||||
src[3 * size + 3] = 7240 // window centre
|
||||
val gray = ByteArray(size * size) { 128.toByte() }
|
||||
DetailEnhance(size, size).enhance(src, gray, strength = 16, gain = 3000)
|
||||
assertEquals(
|
||||
"centre pixel must move by exactly 1 (raw-strength blend)",
|
||||
129,
|
||||
gray[3 * size + 3].toInt() and 0xFF,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Binary file not shown.
@@ -562,17 +562,43 @@ extremes **合并成一次 draw**(原来分两次调用,彼此看不见)
|
||||
按反编译的 `CFunctions::FilterDetailEnhancement_Simple` + `LocalMap7x7_Simple`
|
||||
移植(`core/DetailEnhance.kt`):7×7 窗口按 4×4 抽样(x/y 步长 2),
|
||||
`mean = sum >> 4`,`if (strength <= (max-min)*32)` 时
|
||||
`detail = (0x8000/divisor)*(center-mean)`,最后 `gray += (k*detail) >> 15`。
|
||||
`detail = (0x8000/divisor)*(center-mean)`,最后 `gray += (strength*detail) >> 15`。
|
||||
管线位置与官方一致:`grayMap → 细节增强 → upscale2x → 调色板`。
|
||||
强度换算经官方 SDK 核对:`MAG_SetDetailEnhancement` 把等级钳到 0..32,
|
||||
调用方传 `level << 3`——**与本实现 `level shl 3` 完全一致**。
|
||||
设置页新增"图像增强"(关闭/1–4 级),**默认关闭**(无官方参考输出可逐位比对,
|
||||
故 opt-in)。默认关闭时 `RenderPipelineTest` 的逐位基线不受影响(96 项测试全绿)。
|
||||
|
||||
实机实测:级别 2 下仍 15.1fps,单帧绘制 11.3ms(滤波器约 +5ms,帧预算 66ms 内),
|
||||
细节增强清晰可见。
|
||||
**两个 strength 不可互换(本轮抓到的实现 bug)**:官方把**增益缩放后**的
|
||||
`k = strength * gain * 2 >> 8` 传给 map(决定阈值与 divisor 下限),
|
||||
但**混叠系数用的是原始 strength**。本移植最初两处都用 `k`,等于把细节
|
||||
再乘一遍增益——真机 level 2 画面满屏噪点。修正后 HF 能量与关闭档几乎相同
|
||||
(0.47~0.51 对 0.46~0.47),边缘仍被增强。
|
||||
数值由 `blendUsesTheRawStrengthNotTheGainScaledOne` 黄金值测试锁定
|
||||
(推导写在测试注释里;把 bug 改回去该测试立刻失败,已验证)。
|
||||
|
||||
**本轮真机验证(小米 22041211AL / Android 12 / MIUI,无线 adb 192.168.88.137:44323)**:
|
||||
**档位换算经官方 SDK 核对**:`MAG_SetDetailEnhancement` 把档位钳到 **0..32**,
|
||||
`SetEX` 与之无关(那是数码变焦 ROI)。**官方 App 本身不调用
|
||||
SetDetailEnhancement**(jadx 全量搜索无此符号),所以官方实时画面用的是
|
||||
SDK 内部默认档——无法据此对齐具体档位,故本应用把它做成用户可见设置
|
||||
(0/1/2/3/4/6/8 档),默认**关闭**(关闭时 `RenderPipelineTest` 逐位基线不受影响)。
|
||||
一个有用的推论:每档的边缘强度其实与档位无关(化简为 `128/gain`),
|
||||
档位只决定哪些窗口通过对比度阈值——所以调高档位是"纳入更多弱对比区域",
|
||||
而不是"把边缘推得更狠"。
|
||||
|
||||
实机实测:级别 2 下仍 15.1fps,单帧绘制 11.3ms(滤波器约 +5ms,帧预算 66ms 内)。
|
||||
|
||||
### 8) 其他顺带修复(本轮真机复现)
|
||||
|
||||
- **`no_device` 死锁**:首次 connect 若赶上相机正在重枚举(`findDevice()` 返回
|
||||
null)就停在"无相机",而 200ms 后到达的 ATTACHED 广播又落在 800ms 防抖窗口里
|
||||
被丢弃 → 永久无画面,必须手动重启 App。现在防抖与退避都改为**延后重试而非丢弃**,
|
||||
且 `no_device` 本身也会安排一次 1.5s 重试(`scheduleRetry` 单槽位、最新请求优先)。
|
||||
- **平放时 OSD 文字整体转 90°**:手机平放在桌面时加速度计 x/y 都接近 0,
|
||||
迟滞逻辑保留了上一次的姿态角。现在 tilt < 4 m/s²(约 24°)判定为"平放",
|
||||
姿态角归零。
|
||||
- **视频录制缺少 max/min 标记**:实时画面显示的两个极值没有烧进录像帧,
|
||||
现在 `probesAsMarks()` 一并返回(录像因此与实时画面一致)。
|
||||
- **屏幕熄灭时的行为(实测)**:渲染线程随 SurfaceView 停止绘制(无空转),
|
||||
USB 读取继续,整机 CPU 占用 3.7%——后台不烧电、不崩溃。
|
||||
|
||||
**本轮真机验证(小米 22041211AC / Android 12 / MIUI,无线 adb 192.168.88.137:44323)**:
|
||||
|
||||
- [x] 渲染帧率 6.5 → **15.1 帧/秒**,单帧 134ms → **6.4ms**(开增强 11.3ms)
|
||||
- [x] 拍照:`capture: nuc=yes probes=2 mirror(h=false,v=true) extremes=2 saved=true`
|
||||
@@ -580,7 +606,9 @@ extremes **合并成一次 draw**(原来分两次调用,彼此看不见)
|
||||
`min 22.0℃` 在冷区、Pt1/Pt2 带白底标签,互不压字
|
||||
- [x] 分析界面:标点与照片烧录位置对齐,**只有一个 min、一个 max**
|
||||
- [x] 分析面板数值可见且与照片一致(最高 32.9 / 最低 22.0 / 中心 24.4℃)
|
||||
- [x] 图像增强 2 级:画面细节明显增强,帧率不掉
|
||||
- [x] 图像增强:档位生效、修正后不再放大噪声、15.1fps 不掉帧
|
||||
- [x] 设置持久化跨冷启动(`mag160c_settings.xml`:enhanceLevel / imageFlipV)
|
||||
- [x] 屏幕熄灭时无空转(CPU 3.7%)、USB 读取继续、不崩溃
|
||||
|
||||
**教训记录**:
|
||||
- `dumpsys gfxinfo` 不统计 SurfaceView 的 lockCanvas 绘制。判断自绘画面性能
|
||||
|
||||
Reference in New Issue
Block a user