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:
ZXCLI
2026-09-12 05:04:50 +08:00
parent 4ebdca109a
commit f41668d5d1
8 changed files with 180 additions and 35 deletions
@@ -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,9 +282,11 @@ 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 = 0)
assertArrayEquals("gain 0 must not change a single pixel", before, gray)
}
/**
* 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)
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(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()
}
assertTrue("gain 0 is a no-op", changedAt(0) == 0)
assertTrue("a real gain must change something", changedAt(3000) > 0)
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