# 排障手记：人脸检测返回的这四个值，都不能按字面理解 # 智绘鸿蒙·如 7 而至#

## 前言

需求很朴素：做个证件照 App，用户拍完立刻告诉他"这张能不能过"。

我调 `faceDetector.detect()`，拿返回值定阈值。第一版上线前自测，四个地方对不上：

1. `probability` 返回 **1.000**——正面照是 1.000，侧头 40° 的废片也是 1.000。拿它当质量分，等于没有质量分。
2. 用户明明侧头约 40°，`pose.yaw` 报 **-25.97**。我按"±10° 算合格"写的阈值，判出来的结果和肉眼对不上。
3. 一张糊掉的六人老合影，`detect()` 返回**空数组**。不是"检测到人但质量差"，是根本没看见脸。
4. `points` 数组长度是 **5**。d.ts 里只写了 `Array<FacePoint>`，没说几个点、什么顺序。

这一篇就是这四条的排查记录。

## 这个能力本来该解决什么

证件照、门禁抓拍、视频会议入会前检查、直播开播前校验——共同点是**在提交前 1 秒给出可/不可**，而不是事后被打回。

`faceDetector`（`@kit.CoreVisionKit`）在端侧完成检测，不联网、不要权限，返回人脸框、姿态角、特征点、置信度。做"拍摄即时反馈"是合适的。

但要注意它的定位是**检测**（有没有脸、脸在哪、姿态如何），不是**质量评估**（曝光、虚焦、背景是否合规）。后者要自己补，见最后一节。

## 接口

```typescript
import { faceDetector } from '@kit.CoreVisionKit';

faceDetector.init(): Promise<boolean>
faceDetector.init(cfg: FaceRecognitionConfiguration): Promise<boolean>   // cfg = { faceBlock: boolean }
faceDetector.detect(vi: VisionInfo): Promise<Array<Face>>                // vi = { pixelMap }
faceDetector.release(): Promise<void>

interface Face {
  readonly probability: number;
  readonly block?: FaceBlock;        // 需 init 时 faceBlock:true 才有值
  readonly pose: FacePose;           // { yaw, pitch, roll }
  readonly rect: FaceRectangle;      // { left, top, width, height }
  readonly points: Array<FacePoint>; // { x, y }[]
}
```

> - **yaw / pitch / roll**：偏航（左右转）、俯仰（抬头低头）、侧倾（歪头）三个旋转维度。
> - **FaceBlock**：枚举 `{ UNINITIALIZED = -1, UNBLOCKED = 0, BLOCKED = 1 }`。
> - **端侧检测**：识别过程在设备本地完成，人脸图像不外传，这对证件照类应用是硬性要求。

版本：phone / tablet 自 5.0.0(12)，2in1 自 5.0.1(13)。**不是 API 26 新增**，鸿蒙 7 上只是继续可用。

还有一点容易漏：`block` 字段要显式 `init({ faceBlock: true })` 才会填充，默认 `init()` 时它是 `undefined`。

## 开发步骤

### 第一步：生命周期

和 OCR 一样，`init()` 全局一次，别每次检测都调：

```typescript
private inited: boolean = false;

async init(): Promise<void> {
  if (this.inited) {
    return;
  }
  try {
    this.inited = await faceDetector.init();
  } catch (err) {
    const e = err as BusinessError;
    throw new Error(`人脸检测初始化失败（${e.code}）：${e.message}`);
  }
}
```

### 第二步：检测并挑最大的一张脸

合影场景下 `detect()` 会返回多张，证件照要取最大那张：

```typescript
const faces: faceDetector.Face[] = await faceDetector.detect({ pixelMap: pm });
let best: faceDetector.Face = faces[0];
for (let i = 1; i < faces.length; i++) {
  if (faces[i].rect.width > best.rect.width) {
    best = faces[i];
  }
}
```

### 第三步：体检规则全部用相对量

绝对像素没有意义，同一张脸在不同分辨率图上框大小不同。所有阈值都换算成比例：

```typescript
const ratio: number = rect.width / imgW;                              // 人脸占比
const off: number = Math.abs(rect.left + rect.width / 2 - imgW / 2) / imgW;  // 水平偏离
const topGap: number = rect.top / imgH;                               // 头顶留白
```

### 第四步：打印日志时避开一个坑

`hilog` **不支持 `%{public}f`**。我第一版这么写：

```typescript
hilog.info(0x0000, TAG, 'yaw=%{public}f pitch=%{public}f pts=%{public}d',
  face.pose.yaw, face.pose.pitch, face.points.length);
```

真机输出：

```
yaw=}f pitch=}f pts=3.702503
```

浮点参数被吞掉，后面的整数参数**串位**到了错误的占位符上。我一度以为 `points.length` 是 370 万。改成先放大再打印才对：

```typescript
// hilog 不支持 %{public}f，浮点必须先放大成整数再打印
hilog.info(0x0000, TAG, 'yaw100=%{public}d pitch100=%{public}d pts=%{public}d',
  Math.round(face.pose.yaw * 100), Math.round(face.pose.pitch * 100), face.points.length);
```

这条和人脸检测无关，但排查那十分钟足够把人逼疯，先记在这。

## 真机数据

测试机：HUAWEI MatePad Pro（MRDI-W00），HarmonyOS 7.0.0.107，API 26。三张样片均为 540×720（合影为 640×794）。

| 样片 | 人脸数 | 耗时 | rect (l,t,w,h) | yaw | pitch | roll | probability | points |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 标准正面 | 1 | 111 ms | 148,172,249,302 | 3.70 | 8.23 | 1.63 | **1.000** | 5 |
| 侧头 40° | 1 | 46 ms | 184,214,226,261 | **-25.97** | 16.76 | 13.68 | **1.000** | 5 |
| 模糊六人合影 | **0** | 45 ms | — | — | — | — | — | — |

派生指标：

| 样片 | 人脸占比 | 水平偏离 | 头顶留白 | 结论 |
| --- | --- | --- | --- | --- |
| 标准正面 | 46.1% | 0.5% | 上 23.9% / 下 34.2% | 全部通过 |
| 侧头 40° | 41.9% | 5.0% | 上 29.7% / 下 34.0% | yaw / pitch / roll 三项不合格 |
| 模糊合影 | — | — | — | 人脸数量不合格（0 张） |

合格报告：

![](assets/01-pass.png)

不合格报告：

![](assets/02-fail.png)

## 四条排查结论

### 结论一：`probability` 不是质量分，是"检出了脸"

两张图都是 1.000，一张是完美正面、一张是明显废片。

所以 **`probability` 只能用来过滤误检**（比如把衣服花纹认成脸），阈值设在 0.6 以上足够，**不要拿它做质量分级**。

质量要自己算：人脸占比、姿态角、头顶留白，再补曝光/虚焦检测（对 `pixelMap` 自己统计亮度直方图与梯度）。

### 结论二：姿态角不是物理角度，阈值必须实测标定

侧头约 40°，`yaw` 报 -25.97。符号也不对应直觉（向右侧头是负值）。

结论是：**`pose` 是模型内部度量，不能当"度"来用**。所以"±10° 算合格"这种凭常识写的阈值是错的。

正确做法是拿一批真实合格/不合格样本跑一遍，看分布再定线。本文三张样片给出的参考锚点：正脸 yaw≈3.7、明显侧头 yaw≈-26；正脸 roll≈1.6、明显歪头 roll≈13.7。

另外注意：**标准正面照的 pitch 是 8.23°，不是 0**。人拿手机拍照时俯仰天然不为零，把 pitch 阈值设成 ±3° 会把大量正常照片判为不合格。

### 结论三：`points` 实测 5 个，但顺序官方未声明

d.ts 只给 `Array<FacePoint>`。实测长度稳定为 5，按通行惯例应是左右眼、鼻尖、左右嘴角一类，但**没有文档保证**。

所以不要把 `points[2]` 硬编码成"鼻尖"来做业务判断。需要精确关键点（比如"眼睛连线是否水平"）时，用 `roll` 角度而不是自己算两点连线；或者先用大量样本验证顺序稳定性再上生产。

### 结论四：检不出脸 ≠ 没有脸，模糊小脸会直接返回空数组

那张六人合影里人脸清晰可辨（人眼），但因为是翻拍老照片、模糊、单脸只有几十像素，`detect()` 返回空。

这不是 bug，是能力边界。产品上的含义是：**"未检测到人脸"这个提示对低质量图是误导**，用户明明看得见脸。建议文案改成"没检测到清晰人脸，请靠近一点或换一张清晰的"。

更实用的做法是**串联超分**：检测返回空时，先用 `imageSuperResolution` 放大 4 倍再检一次。老照片批量标人脸，这条链路是通的（超分把 320px 提到 1280px，小脸尺寸随之进入可检测范围）。

## 实时预览怎么做才不掉帧

证件照场景一定要边拍边提示，也就是相机预览帧持续送进 `detect()`。实测单次 46–111 ms，按 30 fps 出帧必然堆积。三个动作要一起做：

**降频而不是全检。** 每 3–4 帧检测一次，中间帧沿用上一次的 `rect` 做 UI 提示。人脸移动速度远慢于帧率，肉眼看不出差别。

**请求排队，绝不并发。** `detect()` 是异步的，如果每帧都直接 `await`，回调会乱序返回，UI 上的框会来回跳。用一个"进行中"标志把新请求丢掉即可：

```typescript
private running: boolean = false;

async tick(pm: image.PixelMap): Promise<void> {
  if (this.running) {
    return;              // 上一帧还没检完，这一帧直接丢弃
  }
  this.running = true;
  try {
    this.rep = await this.checker.check(pm);
  } finally {
    this.running = false;
  }
}
```

丢帧比排队好——排队会让提示滞后好几秒，用户早就不动了框还在追。

**坐标换算走比例。** `rect` 是像素坐标，预览控件是 vp。换算一律用 `rect.width / imgW * 控件宽`，不要缓存绝对值，否则切换相机分辨率就全错。

## 阈值该留多少余量

基于本文三张样片的锚点，给一组我认为安全的初始阈值。注意这些是**起点不是终点**，上线前必须用自己业务的真实样本重标：

| 项目 | 建议初始阈值 | 依据 |
| --- | --- | --- |
| yaw | ≤ 12 | 正脸实测 3.70，明显侧头 -25.97，中间留一倍余量 |
| pitch | ≤ 14 | 正脸实测 8.23 已经不接近 0，设太严会大量误杀 |
| roll | ≤ 8 | 正脸 1.63，歪头 13.68，8 是干净的分割带 |
| 人脸占比 | 25%–65% | 两张样片分别 46.1% 与 41.9% |
| 水平偏离 | ≤ 8% | 正脸 0.5%，侧头 5.0% |
| 头顶留白 | 8%–35% | 正脸 23.9%，侧头 29.7% |
| probability | ≥ 0.6 | 只用于过滤误检，两张样片都是 1.000 |

一个反直觉但重要的点：**pitch 的余量要给得比 yaw 大**。人举手机拍证件照时，手臂高度几乎不可能与眼睛齐平，俯仰天然存在；而左右偏头是用户主动能控制的。把 pitch 设成 ±5° 的结果是大量正常照片被判不合格，用户只会认为 App 在故意挑刺。

## 上生产前还要补的

- **曝光与虚焦**：`faceDetector` 不管，要自己统计亮度直方图 / 拉普拉斯梯度。
- **背景色合规**：证件照对底色有要求，得自己采样 `pixelMap` 边角像素判断。
- **`init({ faceBlock: true })`**：只有需要 `block` 字段时才开，默认不开。
- **文案兜底**：返回空数组时不要写"未检测到人脸"，见结论四。

## 总结

什么场景值得用它：证件照、门禁抓拍、入会前检查、开播前校验这类"提交前一秒给个可不可"的场景，端侧跑完人脸不出设备。

不适合：指望它判断照片质量好坏。曝光、虚焦、底色合规它一概不管，得自己在 `pixelMap` 上补统计。

> 💡 **一句话**：它给的是几何信息不是质量结论，`probability` 和 `pose` 都别按字面用，阈值一律实测标定。

## 参考文献

1. Core Vision Kit 开发指南 · 人脸检测：https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-face-detector
2. Core Vision Kit API 参考 · faceDetector（人脸检测）：https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-face-detector-api
3. Core Vision Kit 开发指南 · 图像超分（串联升档用）：https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-image-super-resolution
