概述
交友项目的头像上传要做一道校验:上传的图片里必须有一张人脸,风景图、多人合照都不算合规头像。这件事可以自己训模型,但更快的是直接调百度 AI 开放平台的人脸检测接口。本文不走封装,就用一个测试类把官方 Java SDK 跑通,把 AppID/API Key/Secret Key、image_type、face_field、error_code、face_num 这几个概念在一次真实调用里全部落地。
纲要
- 能力定位:人脸检测在头像上传链路里的位置
- 接入前准备:注册开放平台 → 创建应用 → 三把钥匙 → 开通人脸检测 → 额度与计费量级
- 引入依赖:工程里真实的 Maven 坐标,以及它被声明在哪个模块
- 初始化客户端:
new AipFace(AppID, APIKey, SecretKey)、超时设置、access_token 由谁维护 - 发起检测:
client.detect(image, imageType, options)三个入参逐个说明image+image_type:URL与BASE64两种方式的代码对比与取舍options:face_field/max_face_num/face_type/liveness_control的作用
- 解析返回:
error_code、error_msg、face_num、face_list的读取方式 - 业务判定:为什么是
error_code == 0 且 face_num == 1 - 完整可运行示例:本地图片 → Base64 → detect → 打印
- 三个对照实验:自拍 / 风景图 / 多人合照
- 常见返回码与网络环境排查
- 安全与合规提醒
人脸检测在业务链路里的位置
头像上传不是一个"上传即保存"的动作,中间夹了一次第三方校验。用户在客户端选图 → app-server 把图片传到 OSS 拿到公网 URL → 拿这个 URL 去问百度"这图里有几张脸" → 判定通过才落库。
百度人脸检测 阿里云 OSS 项目 app-server 百度人脸检测 阿里云 OSS 项目 app-server #mermaid-svg-dtI0kj2YWcJSvzh1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dtI0kj2YWcJSvzh1 .error-icon{fill:#552222;}#mermaid-svg-dtI0kj2YWcJSvzh1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dtI0kj2YWcJSvzh1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dtI0kj2YWcJSvzh1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dtI0kj2YWcJSvzh1 .marker.cross{stroke:#333333;}#mermaid-svg-dtI0kj2YWcJSvzh1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dtI0kj2YWcJSvzh1 p{margin:0;}#mermaid-svg-dtI0kj2YWcJSvzh1 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dtI0kj2YWcJSvzh1 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-dtI0kj2YWcJSvzh1 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dtI0kj2YWcJSvzh1 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-dtI0kj2YWcJSvzh1 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-dtI0kj2YWcJSvzh1 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-dtI0kj2YWcJSvzh1 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-dtI0kj2YWcJSvzh1 .sequenceNumber{fill:white;}#mermaid-svg-dtI0kj2YWcJSvzh1 #sequencenumber{fill:#333;}#mermaid-svg-dtI0kj2YWcJSvzh1 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-dtI0kj2YWcJSvzh1 .messageText{fill:#333;stroke:none;}#mermaid-svg-dtI0kj2YWcJSvzh1 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dtI0kj2YWcJSvzh1 .labelText,#mermaid-svg-dtI0kj2YWcJSvzh1 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-dtI0kj2YWcJSvzh1 .loopText,#mermaid-svg-dtI0kj2YWcJSvzh1 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-dtI0kj2YWcJSvzh1 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dtI0kj2YWcJSvzh1 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-dtI0kj2YWcJSvzh1 .noteText,#mermaid-svg-dtI0kj2YWcJSvzh1 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-dtI0kj2YWcJSvzh1 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dtI0kj2YWcJSvzh1 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dtI0kj2YWcJSvzh1 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dtI0kj2YWcJSvzh1 .actorPopupMenu{position:absolute;}#mermaid-svg-dtI0kj2YWcJSvzh1 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-dtI0kj2YWcJSvzh1 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dtI0kj2YWcJSvzh1 .actor-man circle,#mermaid-svg-dtI0kj2YWcJSvzh1 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-dtI0kj2YWcJSvzh1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} putObject 上传头像 返回公网 URL detect(image=url, image_type=URL, options) 按 URL 下载图片 图片字节流 JSON(error_code, error_msg, face_num, face_list) error_code==0 且 face_num==1 → 允许保存
这里有个顺序上的依赖:走 URL 方式时,百度是从它自己的机房去下载这张图的,所以图片必须先落到一个公网可访问的地址上。私有读的 Bucket、内网地址、带鉴权签名的临时链接,百度都拉不到------这会在返回码里体现出来,后面排查表里会提。
接入前准备
百度人脸识别属于百度 AI 开放平台的能力,走的是"应用 + 密钥"模式,和阿里云短信、OSS 那套是一个套路。区别在于百度的人脸接口是 SDK 直连(SDK 内部自己换 token),不需要你在控制台配模板或签名。
| 步骤 | 做什么 | 产出 / 说明 |
|---|---|---|
| 注册登录 | 打开 https://ai.baidu.com/tech/face,支持百度账号或百度云账号登录 | 一个可用的开发者账号 |
| 创建应用 | 控制台 → 人工智能 → 人脸识别 → 创建应用,填应用名、归属行业 | 应用列表里的三条字符串 |
| 拿三把钥匙 | 应用详情页复制 AppID、API Key、Secret Key | 后续代码里的 APP_ID / API_KEY / SECRET_KEY |
| 开通能力 | 确认「人脸检测」已在应用默认能力里(人脸识别类应用一般默认开通) | 调用失败时会返回权限相关错误码 |
| 了解额度 | 新应用通常有一批免费调用量,同时受 QPS 与每日总量两个维度限制 | 具体数值与计费单价随政策调整,以控制台「概览 / 计费」页为准 |
有三点容易踩:
- AppID 是纯数字,API Key / Secret Key 是字母数字混合串,三者都区分大小写,复制时别把首尾空格带进去。
- 免费额度是双重限制:日调用量用完,当天就直接报限额错误;单机并发高一点会触发 QPS 限额,这两个错误码不一样,排查方向也不同。
- 百度云老账号的 Key 体系叫法不同:
API_KEY对应控制台的 Access Key ID,SECRET_KEY对应 Access Key Secret。讲义里专门提醒过这一点。
引入依赖
工程里真实的坐标是 com.baidu.aip:java-sdk:4.8.0。
xml
<!-- 百度人脸识别API -->
<dependency>
<groupId>com.baidu.aip</groupId>
<artifactId>java-sdk</artifactId>
<version>4.8.0</version>
</dependency>
这段依赖声明位置值得说清楚 。视频里说"咱们项目不需要额外引依赖,第一天都引好了",实读代码可以看到它其实写在 mf-autoconfig/pom.xml 里:
xml
<project>
<artifactId>mf-autoconfig</artifactId>
<dependencies>
<!--阿里云核心API-->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.5.3</version>
</dependency>
<!--阿里云oss存储API-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.10.2</version>
</dependency>
<!--百度人脸识别API-->
<dependency>
<groupId>com.baidu.aip</groupId>
<artifactId>java-sdk</artifactId>
<version>4.8.0</version>
</dependency>
</dependencies>
</project>
而 mf-app-server/pom.xml 依赖了 mf-autoconfig,百度 SDK 是顺着这条依赖传递进测试类的 。父 pom(mf)里并没有这个坐标。所以严格讲不是"第一天在父 pom 引好",而是"引在 autoconfig 模块里、app-server 传递拿到了"。如果你把这段代码单独放到一个干净工程里跑,自己补上这个依赖即可。
工程结构大致是这样,测试类放在 mf-app-server 的 test 目录下:
tree
mf-test/
├── pom.xml # 父 pom:Spring Boot 2.3.9.RELEASE,JDK 8,依赖管理
├── mf-autoconfig/
│ └── pom.xml # 百度 SDK 4.8.0 声明在这里
├── mf-model/
├── mf-commons/
├── mf-dubbo/
└── mf-app-server/
├── pom.xml # 依赖 mf-autoconfig,SDK 由它传递进来
└── src/test/java/com/wb/test/
└── FaceTest.java # 本文的入门案例
SDK 自身带了 org.json,所以代码里的 JSONObject 直接可用,不用另外引 json 包。
初始化客户端
AipFace 就是与百度人脸接口对话的入口,构造三个参数即可。
java
package com.wb.test;
import com.baidu.aip.face.AipFace;
public class AipFaceClientDemo {
// 控制台应用详情页复制过来的三项,换成你自己的
public static final String APP_ID = "你的 AppID";
public static final String API_KEY = "你的 API Key";
public static final String SECRET_KEY = "你的 Secret Key";
public static void main(String[] args) {
// 初始化一个 AipFace,建议做成单例复用
AipFace client = new AipFace(APP_ID, API_KEY, SECRET_KEY);
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000); // 建立连接超时:2s
client.setSocketTimeoutInMillis(60000); // 数据传输超时:60s
// 可选:走公司代理时才需要,二选一
// client.setHttpProxy("proxy_host", 8080);
// client.setSocketProxy("proxy_host", 8080);
}
}
鉴权不用自己管。 百度的调用链是"用 API Key / Secret Key 换 access_token,再拿 token 调业务接口",access_token 默认 30 天有效。这套逻辑 SDK 已经在内部做掉了:它在需要时自动去换 token、自己缓存、过期了自己刷新,AipFace 实例内部维护这层状态。
由此推出一条实践建议:AipFace 不要在每次调用时 new 一个。频繁 new 会让 SDK 反复去换 token,既慢又容易在并发下触发鉴权类限流。工程最终的做法是把它做成 Spring 单例 Bean(下一篇会讲),入门阶段在一个测试类里用一个静态实例或成员变量就够。
两个超时的取值也不是随手写的:连接超时 2s 是"连不上就快速失败",传输超时给到 60s 是因为百度服务端要去下载图片(走 URL 方式时)或上传 Base64 数据,图片大一点耗时会长。两者反着配(连接给 60s、传输给 2s)会导致小图也频繁超时。
发起人脸检测
核心只有一个方法:client.detect(image, imageType, options)。
java
JSONObject res = client.detect(image, imageType, options);
三个入参分别是"图片内容""图片怎么传进来""要检测出什么"。前两个是本文最关键的知识点。
image 与 image_type:URL 还是 BASE64
image_type 决定 image 这个字符串到底是什么。人脸检测支持三种取值,日常只用前两种:
| image_type | image 里放什么 | 前提条件 | 适用场景 |
|---|---|---|---|
URL |
图片的公网可访问地址 | 百度服务器能直接下载到这张图 | 图片已经在 OSS/CDN 上,有稳定公网地址 |
BASE64 |
图片 base64 编码后的字符串 | 本地有文件或内存里有字节流 | 图片不便暴露到公网、内网服务、临时文件 |
FACE_TOKEN |
人脸图片的唯一标识 | 这张图之前被检测过,拿到了 token | 复用此前检测结果,避免重复传图 |
URL 方式,就是讲义和工程里的写法:
java
package com.wb.test;
import com.baidu.aip.face.AipFace;
import org.json.JSONObject;
import java.util.HashMap;
public class FaceTestByUrl {
public static final String APP_ID = "你的 AppID";
public static final String API_KEY = "你的 API Key";
public static final String SECRET_KEY = "你的 Secret Key";
public static void main(String[] args) {
AipFace client = new AipFace(APP_ID, API_KEY, SECRET_KEY);
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
// image 是图片的公网 URL
String image = "https://your-bucket.oss-cn-beijing.aliyuncs.com/2021/04/19/xxxx.jpg";
String imageType = "URL";
HashMap<String, String> options = new HashMap<String, String>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
JSONObject res = client.detect(image, imageType, options);
System.out.println(res.toString(2));
}
}
注意这里的 URL 必须是匿名可读的。OSS 默认私有读的 Bucket,直接拼出来的地址用在浏览器里能打开(因为控制台带的签名)但在百度那边会 403;这种情况要么给 Bucket 开放公共读,要么改用 BASE64。这条是"代码看着没问题、结果一直报错"的高频原因。
BASE64 方式,把图片读成字节数组再编码:
java
package com.wb.test;
import com.baidu.aip.face.AipFace;
import org.json.JSONObject;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.HashMap;
public class FaceTestByBase64 {
public static final String APP_ID = "你的 AppID";
public static final String API_KEY = "你的 API Key";
public static final String SECRET_KEY = "你的 Secret Key";
public static void main(String[] args) throws Exception {
AipFace client = new AipFace(APP_ID, API_KEY, SECRET_KEY);
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
// 1、读本地图片,编码成 base64 字符串
String path = "C:/Users/yourname/Desktop/avatar.jpg";
byte[] bytes = Files.readAllBytes(Paths.get(path));
String image = Base64.getEncoder().encodeToString(bytes);
// 2、imageType 改成 BASE64
String imageType = "BASE64";
HashMap<String, String> options = new HashMap<String, String>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
JSONObject res = client.detect(image, imageType, options);
System.out.println(res.toString(2));
}
}
两种方式怎么选,看的是"图片现在在哪":
- 图片已经躺在 OSS 上、地址公网可达 → 用
URL,业务代码最清爽,一行字符串。 - 图片还在内存里、或者桶是私有读、或者是用户刚上传的临时文件 → 用
BASE64,多一步读文件 + 编码,但不受公网可达性约束。 - 代价上,Base64 会把体积放大约三分之一(4/3 的编码膨胀)。官方给的限制是图片总数据大小小于 10M、base64 编码后的数据不超过 2M,所以头像这种小图随便用,扫描件级别的大图走 Base64 就容易顶到上限,这时候老老实实先传 OSS 再用 URL。
options:控制返回什么、检测几张
options 是一个 HashMap<String, String>,传 null 表示全用默认值。
java
HashMap<String, String> options = new HashMap<String, String>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
| 参数 | 取值 | 作用 |
|---|---|---|
face_field |
age、expression、face_shape、gender、glasses、landmark、landmark150、quality、eye_status、emotion、face_type、mask、spoofing,逗号分隔 |
指定额外返回哪些属性。不传时只返回 face_token、人脸框位置、置信度和旋转角 |
max_face_num |
默认 1,最大 120 | 最多处理几张人脸;超出的脸直接不返回 |
face_type |
LIVE(生活照,默认)、IDCARD(身份证芯片照)、WATERMARK(带水印证件照)、CERT(证件照) |
告诉算法这是什么类型的图,影响检测策略 |
liveness_control |
NONE(默认)、LOW、NORMAL、HIGH |
活体检测严格度。等级越高越能挡照片翻拍,但真人也可能被误拒 |
face_sort_type |
0 按人脸面积从大到小,1 按离图片中心从近到远,默认 0 |
决定 face_list 的排序,也就决定了 face_list[0] 是谁 |
头像校验场景的实际取值建议:face_field 只取你要用的(比如 age,beauty),取多了白白拉长响应;max_face_num 保持小值------反正 face_num > 1 就要判不合规,多检测没有意义;liveness_control 开 LOW 足够,因为这里只关心"是不是人脸",不做身份核验,开高了反而把正常自拍挡掉。
有个编译期的坑:官方文档的示例把 options 写成 HashMap<String, Object>,而 detect 的签名收的是 HashMap<String, String>,照着文档抄会编译不过------值统一写成字符串字面量。
解析返回结果
detect 返回 org.json.JSONObject,错误信息和数据在同一个对象里:
| 字段 | 类型 | 含义 |
|---|---|---|
error_code |
int | 0 表示正常返回,非 0 见官方错误码表 |
error_msg |
string | 成功时是 SUCCESS,失败时是错误描述 |
log_id |
long | 本次请求的日志号,报工单时提供它 |
face_num |
int | 检测到的人脸数量 |
face_list |
array | 人脸信息列表,长度与 face_num 一致 |
face_list 里每一张脸包含这些字段:
| 字段 | 含义 |
|---|---|
face_token |
这张人脸的唯一标识,同一张图多次检测得到同一个值 |
location |
人脸框位置:left / top / width / height / rotation |
face_probability |
是人脸的置信度,范围 0~1 |
angle |
三维旋转角:yaw(左右)/ pitch(俯仰)/ roll(平面内旋转) |
age、beauty、expression、gender 等 |
由 face_field 决定是否返回 |
一次真实检测成功的返回长这样(字段名取自百度官方返回结构,数值是示例值):
json
{
"error_code": 0,
"error_msg": "SUCCESS",
"log_id": 2120123456789,
"face_num": 1,
"face_list": [
{
"face_token": "35235asfas21421fakghktyfdgh68bio",
"location": {
"left": 117,
"top": 131,
"width": 172,
"height": 170,
"rotation": 4
},
"face_probability": 1,
"angle": {
"yaw": -0.35,
"pitch": 1.91,
"roll": 2.30
},
"age": 29.29,
"beauty": 61.34,
"expression": {
"type": "smile",
"probability": 0.55
},
"gender": {
"type": "male",
"probability": 0.99
}
}
]
}
face_field 的可选列表会随文档版本调整(beauty、race 在历史文档里出现过),实际以控制台当前的文档为准;不写进 face_field 的字段不会出现在 face_list 里,拿的时候要么先判断存在,要么把它加进 face_field。
判定逻辑:error_code 为 0 且 face_num 为 1
只看一个 error_code == 0 是不够的,那只能说明"这次调用成功了"。真正决定头像是否合规的是 face_num:
#mermaid-svg-NmIl8yytiIPOYlMQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NmIl8yytiIPOYlMQ .error-icon{fill:#552222;}#mermaid-svg-NmIl8yytiIPOYlMQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NmIl8yytiIPOYlMQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NmIl8yytiIPOYlMQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NmIl8yytiIPOYlMQ .marker.cross{stroke:#333333;}#mermaid-svg-NmIl8yytiIPOYlMQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NmIl8yytiIPOYlMQ p{margin:0;}#mermaid-svg-NmIl8yytiIPOYlMQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NmIl8yytiIPOYlMQ .cluster-label text{fill:#333;}#mermaid-svg-NmIl8yytiIPOYlMQ .cluster-label span{color:#333;}#mermaid-svg-NmIl8yytiIPOYlMQ .cluster-label span p{background-color:transparent;}#mermaid-svg-NmIl8yytiIPOYlMQ .label text,#mermaid-svg-NmIl8yytiIPOYlMQ span{fill:#333;color:#333;}#mermaid-svg-NmIl8yytiIPOYlMQ .node rect,#mermaid-svg-NmIl8yytiIPOYlMQ .node circle,#mermaid-svg-NmIl8yytiIPOYlMQ .node ellipse,#mermaid-svg-NmIl8yytiIPOYlMQ .node polygon,#mermaid-svg-NmIl8yytiIPOYlMQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NmIl8yytiIPOYlMQ .rough-node .label text,#mermaid-svg-NmIl8yytiIPOYlMQ .node .label text,#mermaid-svg-NmIl8yytiIPOYlMQ .image-shape .label,#mermaid-svg-NmIl8yytiIPOYlMQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-NmIl8yytiIPOYlMQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NmIl8yytiIPOYlMQ .rough-node .label,#mermaid-svg-NmIl8yytiIPOYlMQ .node .label,#mermaid-svg-NmIl8yytiIPOYlMQ .image-shape .label,#mermaid-svg-NmIl8yytiIPOYlMQ .icon-shape .label{text-align:center;}#mermaid-svg-NmIl8yytiIPOYlMQ .node.clickable{cursor:pointer;}#mermaid-svg-NmIl8yytiIPOYlMQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NmIl8yytiIPOYlMQ .arrowheadPath{fill:#333333;}#mermaid-svg-NmIl8yytiIPOYlMQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NmIl8yytiIPOYlMQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NmIl8yytiIPOYlMQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NmIl8yytiIPOYlMQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NmIl8yytiIPOYlMQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NmIl8yytiIPOYlMQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NmIl8yytiIPOYlMQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NmIl8yytiIPOYlMQ .cluster text{fill:#333;}#mermaid-svg-NmIl8yytiIPOYlMQ .cluster span{color:#333;}#mermaid-svg-NmIl8yytiIPOYlMQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NmIl8yytiIPOYlMQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NmIl8yytiIPOYlMQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-NmIl8yytiIPOYlMQ .icon-shape,#mermaid-svg-NmIl8yytiIPOYlMQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NmIl8yytiIPOYlMQ .icon-shape p,#mermaid-svg-NmIl8yytiIPOYlMQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NmIl8yytiIPOYlMQ .icon-shape .label rect,#mermaid-svg-NmIl8yytiIPOYlMQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NmIl8yytiIPOYlMQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NmIl8yytiIPOYlMQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NmIl8yytiIPOYlMQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
读取图片(URL 或本地文件转 Base64)
client.detect(image, imageType, options)
error_code 等于 0 ?
调用失败:鉴权 / 网络 / 图片 / 额度问题
face_num 等于 1 ?
不合规:face_num=0 无人脸,face_num>1 多人合照
合规头像,允许保存到用户资料
为什么必须等于 1。 face_num = 0 是风景图、背影、纯色图,没有脸可存;face_num > 1 是多人合照,用户拿一张合影当头像,平台既无法确定展示的是哪一位,也不利于后续的比对类功能。只有"恰好一张脸"才放行。工程里的完整判定就两行:
java
Integer errorCode = (Integer) res.get("error_code");
Integer faceNum = (Integer) res.get("face_num");
// 调用成功 且 恰好检测到一张人脸
boolean legalAvatar = (errorCode == 0) && (faceNum == 1);
顺带区分一个很容易混淆的情况:error_code = 0 但 face_num = 0 不是调用失败,而是"调用成功,但这张图里没人脸",这种在业务上属于"图片不合格",要给用户提示"头像需包含清晰人脸",而不是报"服务异常"。反过来,非 0 的 error_code 才是真的链路出问题了------这两类错误在接口返回给前端时应该分开,混在一起会让用户看到莫名其妙的错误信息。
完整可运行示例
下面这个测试类把"读本地文件 → Base64 编码 → detect → 解析 → 判定"串成一条完整链路,可以直接跑。工程里 JUnit 4.12 和 spring-boot-starter-test 已经在父 pom 里,写 @Test 即可。
java
package com.wb.test;
import com.baidu.aip.face.AipFace;
import org.json.JSONObject;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.HashMap;
public class FaceCheckTest {
// 换成控制台应用详情页里的三项
private static final String APP_ID = "你的 AppID";
private static final String API_KEY = "你的 API Key";
private static final String SECRET_KEY = "你的 Secret Key";
/**
* 检测本地图片中是否只有一张人脸
* true:合规头像;false:无人脸或多张人脸
*/
public boolean detectSingleFace(String imagePath) throws Exception {
// 1、构造客户端(实际项目中应复用同一个实例)
AipFace client = new AipFace(APP_ID, API_KEY, SECRET_KEY);
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
// 2、读文件并做 base64 编码
byte[] bytes = Files.readAllBytes(Paths.get(imagePath));
String image = Base64.getEncoder().encodeToString(bytes);
// 3、检测参数
HashMap<String, String> options = new HashMap<String, String>();
options.put("face_field", "age,beauty,expression");
options.put("max_face_num", "1");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
// 4、调用人脸检测
JSONObject res = client.detect(image, "BASE64", options);
System.out.println(res.toString(2));
// 5、解析结果
int errorCode = res.getInt("error_code");
if (errorCode != 0) {
System.out.println("调用失败,error_code=" + errorCode
+ ",error_msg=" + res.getString("error_msg"));
return false;
}
int faceNum = res.getInt("face_num");
System.out.println("检测到人脸数:" + faceNum);
return faceNum == 1;
}
@Test
public void testLocalImage() throws Exception {
// 本地图片:写成绝对路径最稳,Windows 用正斜杠或双反斜杠
String imagePath = "C:/Users/yourname/Desktop/avatar.jpg";
boolean legal = detectSingleFace(imagePath);
System.out.println("是否合规头像:" + legal);
}
}
路径写法提醒。 Paths.get 拿到的是相对 JVM 工作目录的路径,而 Maven 跑测试时的工作目录是模块根目录 (这里是 mf-app-server),不是 classpath,也不是你 IDE 里打开的那个文件夹。两个常见结果:
- 写成相对路径
src/test/resources/avatar.jpg,在 IDE 里点绿箭头能跑通,命令行mvn test却报java.nio.file.NoSuchFileException------因为两者的工作目录不一样。想跨环境稳定,用绝对路径,或者用getClass().getClassLoader().getResource("avatar.jpg").getFile()从 classpath 读。 - Windows 路径里的
\在 Java 字符串中要转义成\\,"C:\\Users\\...\\avatar.jpg";漏一个反斜杠后面的字符就被当成转义序列,编译期就报错。
三个对照实验
判断这套代码是否真的在干活,用同一段代码换三张图跑一遍就够了。这三个结果能把"检测"这个能力讲明白。
| 实验 | 准备的图片 | 预期 error_code |
预期 face_num |
业务结论 |
|---|---|---|---|---|
| 自拍 | 一张本人清晰正脸照 | 0 | 1 | 合规,允许保存 |
| 风景 | 纯风景 / 无人的图片 | 222202(图片中没有人脸) | 无该字段或 0 | 不合规,提示"请上传含人脸的图片" |
| 合照 | 两人及以上的合影 | 0 | > 1 | 不合规,提示"头像只能包含一张人脸" |
第一组和第二组是最直观的对照:代码一字未改,只换了图片内容,返回的 error_code 就从 0 变成了 222202。这说明"有没有人脸"这件事是服务端算法给出的结论,不是你本地判断出来的------很多人第一次调不通时会怀疑代码写错了,其实是图片本身没有脸。
第三组是头像校验里最容易被忽略的一种:调用成功 (error_code = 0),但 face_num = 2。如果判定条件只写了 error_code == 0,这张合影就会被当成合规头像存进库里。这也是为什么判定必须带上 face_num == 1。
用本地图片跑三个实验时,只需要换 imagePath 的值;用 OSS 上的图跑,则把 image 换成对应的 URL、imageType 改成 "URL"。讲义和工程里就是这么做的:先把图传到 OSS,贴 URL,第一次贴的是无人的图,返回 222202,第二次贴的是人脸图,返回 error_code=0、face_num=1。
常见返回码与排查
error_code 非 0 时别急着改代码,先按这个表定位。具体错误码数值以百度官方文档的「错误信息」章节为准,下面列的只是高频项的语义归类。
| error_code 类别 | 含义 | 常见原因 | 处理方向 |
|---|---|---|---|
鉴权类(110 / 111 等) |
access_token 无效或已过期 | 密钥写错、应用被停用、本机时间偏差过大导致签名失效 | 核对三把钥匙;校准系统时间;SDK 会自动重取 token,仍失败就查应用状态 |
222202 |
pic not has face,图片中没有人脸 | 传了风景图、背影图、纯色图 | 换成含人脸的图再试,属于正常业务返回 |
222203 |
image check fail,无法解析人脸 | 图片损坏、格式异常、人脸过小或严重遮挡 | 换图;确认图片能被正常打开 |
222001 / 222013 / 222015 等 |
参数缺失或格式错误 | image 为空、image_type 拼错、max_face_num 传了非数字 |
逐项核对入参拼写与类型 |
| 图片下载失败 | 百度拉不到 URL 指向的图 | URL 非公网可达、Bucket 私有读、内网地址、链接带过期签名 | 改用公共读地址,或换成 BASE64 方式 |
| 图片尺寸 / 大小超限 | 总数据超过 10M,或 base64 编码后超过 2M | 传了扫描件级别的大图 | 压缩后再传,或先传 OSS 用 URL 方式 |
SDK 本地错误(SDK100~SDK109) |
图片大小超限、边长不合要求、读文件失败、连接超时、格式不支持 | 请求还没发出去就被 SDK 拦下 | 看具体是哪一个,读文件失败多半是路径写错 |
额度类(4 / 17 / 18) |
集群超限额、日请求量超限额、QPS 超限额 | 当天免费量用尽,或并发压高 | 等额度重置、降并发、或按官方计费文档开通付费 |
网络与环境上还有几个和 OSS 那篇同源的坑:
- 企业网络 / 代理 :公司内网直连不到百度接口时,
detect会抛连接超时。SDK 提供了setHttpProxy/setSocketProxy,二选一,都配上反而容易出问题。 - 本机时间偏差 :签名校验依赖时间戳,机器时间比真实时间差太多会直接鉴权失败。这和 OSS 的
RequestTimeTooSkewed是同一类问题------现象都是"代码没动过,昨天还能跑"。开一下系统自动校时即可。 - SDK 与 JDK 版本 :本文用的
4.8.0在 JDK 8 上是稳的,SDK 官方支持 1.7+。工程本身是 JDK 8 + Spring Boot 2.3.9。 - 密钥硬编码:上面示例里的三把钥匙写在常量里,只适合本地验证。真要提交代码,请外置到配置文件或环境变量,别把密钥提交进仓库。
安全与合规
人脸属于敏感个人信息,这类接口的使用有几条底线:
- 三把钥匙按"最小权限"放置,只给需要调用人脸能力的服务,测试和生产用不同的应用。
- 测试时只用自己或已获授权的人脸图片,别拿同事、朋友、网上随便找的人脸照片测------上传到第三方接口就是一次数据出境。
- 测试图用完就删,不要长期留在 OSS 或本地磁盘里。
API 速览
| 用法 | 签名 / 参数 | 说明 |
|---|---|---|
| 构造客户端 | new AipFace(APP_ID, API_KEY, SECRET_KEY) |
建议单例复用,内部维护 access_token |
| 连接超时 | client.setConnectionTimeoutInMillis(2000) |
建立连接超时,单位毫秒 |
| 传输超时 | client.setSocketTimeoutInMillis(60000) |
数据传输超时,单位毫秒 |
| 代理 | client.setHttpProxy(host, port) / client.setSocketProxy(host, port) |
二选一 |
| 人脸检测 | client.detect(image, imageType, options) |
返回 org.json.JSONObject |
| 图片类型 | "URL" / "BASE64" / "FACE_TOKEN" |
决定 image 放什么 |
| 结果字段 | error_code、error_msg、face_num、face_list[].face_token 等 |
error_code == 0 表示调用成功 |
官方文档
- 百度 AI 开放平台 - 人脸识别
- 人脸识别 Java-SDK(含人脸检测样例与返回结构)
- 计费与免费额度:登录百度智能云控制台,在「人脸识别 → 概览 / 计费」页查看当前政策,数值会调整,不在这里写死
- 完整错误码表:见 Java-SDK 文档页的「错误信息」章节
总结
本文只做了一件事:把百度人脸检测 SDK 在一段可运行的代码里跑通。几个要点值得记住:
- 依赖坐标是
com.baidu.aip:java-sdk:4.8.0,工程里声明在mf-autoconfig模块,app-server 通过传递依赖拿到。 - 初始化只需三把钥匙,
access_token由 SDK 内部自动换取和刷新,业务代码不用管鉴权,但要复用AipFace实例。 image_type的两种常用取值分清场景:URL要求图片公网可达,简单但对桶的读权限有要求;BASE64无公网要求,代价是体积膨胀三分之一且有 2M 上限。- 业务判定是两个条件的与:
error_code == 0说明调用成功,face_num == 1才说明是合规的单张人脸。 error_code != 0和error_code == 0 但 face_num == 0是两回事,前者是链路故障,后者是图片不合格,接口返回给前端时要分开。
现在这段代码还把 AipFace 的创建、参数拼装、结果判定全写在一个测试类里,任何要校验头像的服务都得抄一遍。下一篇《百度人脸识别:抽取自动装配组件》把它抽成 mf-autoconfig 里的 AipFaceProperties + AipFaceTemplate,顺带澄清一处被讲错的注入问题------那里会用一个最小复现说明,为什么"在 @Bean 方法里 new 对象"并不等于"注入不生效"。