From f41668d5d1f2dade11ae6dae1b764960474492bc Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sat, 12 Sep 2026 05:04:50 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E4=BF=AE=E6=AD=A3=207x7=20=E7=BB=86?= =?UTF-8?q?=E8=8A=82=E5=A2=9E=E5=BC=BA=E7=9A=84=E6=B7=B7=E5=8F=A0=E7=B3=BB?= =?UTF-8?q?=E6=95=B0=EF=BC=88=E5=8E=9F=E5=AE=9E=E7=8E=B0=E6=8A=8A=E5=A2=9E?= =?UTF-8?q?=E7=9B=8A=E4=B9=98=E4=BA=86=E4=B8=A4=E9=81=8D=EF=BC=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E6=BB=A1=E5=99=AA=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方 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%)。 --- .../com/mag160c/thermal/core/DetailEnhance.kt | 32 ++++++-- .../mag160c/thermal/core/RenderPipeline.kt | 9 +- .../mag160c/thermal/ui/live/LiveViewModel.kt | 5 +- .../thermal/ui/settings/AppSettings.kt | 11 ++- .../thermal/ui/settings/SettingsScreen.kt | 28 ++++++- .../mag160c/thermal/core/DetailEnhanceTest.kt | 82 +++++++++++++++++-- build-artifacts/mag160c-app-debug.apk | 2 +- docs/android_app/session_state.md | 46 +++++++++-- 8 files changed, 180 insertions(+), 35 deletions(-) diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/DetailEnhance.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/DetailEnhance.kt index 76978ea..293a65e 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/DetailEnhance.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/DetailEnhance.kt @@ -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 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 dc0fa42..6377ab0 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 @@ -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 } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index ad2ed6e..1deae86 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -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) } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt index c51e558..f089b6a 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt @@ -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, diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt index a3a02b9..bbc6246 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt @@ -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 = {}, diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/DetailEnhanceTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/DetailEnhanceTest.kt index ca364f5..f90e6ad 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/core/DetailEnhanceTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/DetailEnhanceTest.kt @@ -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 diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 9d45c8e..85fd340 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43308e6a7bfa9fac854d5b74c74c335a41f94d81fbc85c38942d2a72ab021afc +oid sha256:16ed9057c541c6146a0afcef0c081a9c8e8733d93d6d6ed15c14f219f7ba9029 size 12663188 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 4fa96d6..aef47bd 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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 绘制。判断自绘画面性能