万字长文 | Java 人脸识别学习笔记与踩坑实录:从 SFace 到 AdaFace,15 个生产坑全记录

35 阅读26分钟

这是一篇生产级的 Java 人脸识别落地实战。从「虹软 SDK 停服」到「OpenCV DNN 替代」,再到「发现 SFace 精度崩溃、升级 AdaFace」——历时 1.5 天,干翻 15 个坑,覆盖系统环境、内存溢出、EXIF 旋转、多人脸误选、模型精度不足等问题。没有机器学习背景的 Java 工程师即可读懂。11.9章节有完整示例,拿去就可以用。


1. 本文能帮你解决什么

读完本文,你将掌握:

  1. Java 人脸识别开源方案的全景认知(YuNet/SFace/AdaFace/InsightFace 各自定位与选型参考)
  2. 如何用纯 Java 加载 ONNX 模型做人脸 1:1 比对(含完整可跑示例)
  3. 生产环境部署 OpenCV + ONNX Runtime 的完整踩坑清单(15 个坑)
  4. 为什么 SFace 在低质量图下崩溃,以及如何升级到 AdaFace
  5. K8s 容器化部署人脸识别服务的内存调优经验

适用场景:

  • 内网刷脸登录 / 员工自助登录
  • 养老机构长者签到(本项目场景)
  • 任何弱安全场景下的人脸 1:1 比对
  • Java 工程师快速落地人脸识别能力

不适用场景:

  • 支付认证 / 实名核验 / 高安全门禁
  • 防照片攻击、强监管身份认证

2. 背景:为什么要做这件事

我们的康养 SaaS 系统某私有化客户有一个核心场景:长者刷脸签到。护理员用平板拍一张照片,系统和档案底图比对,确认是本人后完成签到。

原本用的是虹软(ArcSoft)免费版 Java SDK,稳定跑了两年。但 2025 年初虹软停止了免费版授权支持,我们不得不找替代方案。

需求很明确:迁移代价最小、商用免费、能本地离线跑

调研后的候选项:

方案License活体精度(LFW)Java 接入结论
SeetaFace6BSD-30.991JNI 封装体感最接近虹软
InsightFace ONNXMIT0.998onnxruntime-java精度最高,需自己写胶水
OpenCV DNN (YuNet+SFace)Apache 2.00.976纯 Java本文方案
CompreFaceApache 2.0-REST 服务要单独部署

为什么最终选 OpenCV DNN:

  • 我们的签到场景是弱安全场景:长者在机构内、由护理员协助签到,不是金融级核身,对活体没有强诉求。
  • 我们已经在业务层做了机构经纬度校验(签到距离 > 1000 米直接拒绝),相当于有了一层风控兜底。
  • 比对是1:1(拿当前照片和长者档案底图比),不是 1:N 大库检索,精度 0.976 完全够用。
  • 纯 Java、Apache 2.0、CPU 可跑、生产资源充足——迁移成本最低。

⚠️ 安全边界先说清楚:本方案只做人脸检测 + 特征比对,不含活体检测。照片、手机屏幕翻拍、打印照都可能绕过。仅适用于内网弱安全场景,绝不能用于支付认证、实名核验、强监管门禁。


3. 整体链路

Java 业务系统(senior third-service)
  ↓
JavaCV / OpenCV Java API
  ↓
OpenCV DNN / objdetect 模块
  ↓
YuNet ONNX 模型做人脸检测(找到脸 + 5 个关键点)
  ↓
SFace ONNX 模型做人脸特征提取(输出 128 维向量)
  ↓
比对两个人脸特征,余弦相似度超过阈值则认为是同一人

一句话概括三者关系:

JavaCV 负责让 Java 调 OpenCV,
opencv_zoo 负责提供模型文件,
OpenCV 的 DNN 模块负责运行模型。

它们不是竞争关系,而是配合关系。


4. 三个核心概念(Java 工程师视角)

4.1 ONNX 模型 ≈ 一个已经训练好的算法文件

ONNX(Open Neural Network Exchange)是「AI 模型的通用文件格式」。

用 Java 概念类比:

ONNX 模型 ≈ 一个已经训练好、可以直接调用的「算法黑盒」
它不是 jar 包,而是保存了神经网络结构 + 参数的二进制文件

我们用到两个:

  • face_detection_yunet_2023mar.onnx(约 227KB)——输入图片,输出人脸框 + 关键点 + 置信度
  • face_recognition_sface_2021dec.onnx(约 37MB)——输入对齐后的人脸,输出 128 维特征向量

业务系统不需要理解向量里每个数字的含义,只需要知道一条规律:

同一个人的两张脸,特征向量距离更近
不同人的两张脸,特征向量距离更远

💡 跨平台要点:ONNX 是纯权重数据,跨操作系统通用。Windows 开发、Linux 部署用同一份模型文件即可,不需要像虹软那样区分 WIN64 / LINUX64 目录。

4.2 DNN 模块 = 运行模型的执行引擎

DNN(Deep Neural Network)是 OpenCV 里专门加载并运行深度学习模型的模块,它负责:

读取 ONNX 文件 → 解析模型结构 → 把图片转成模型输入格式 → 执行推理 → 返回结果

Java 后端类比:

ONNX 文件 = 模型文件(数据)
DNN 模块  = 运行模型的执行引擎(JVM 之于 class)
JavaCV    = Java 调用执行引擎的桥

实际编码时不需要直接操作底层 DNN API,OpenCV 已封装了两个高级类:FaceDetectorYNFaceRecognizerSF,它们底层会自动用 DNN 能力跑 ONNX。

4.3 JavaCV = Java 调 OpenCV 的桥

OpenCV 主体是 C++ 写的。JavaCV(bytedeco 出品)把 OpenCV、FFmpeg 等 C/C++ 库封装成 Java 可调用的类,并通过 JNI 桥接 native 库。

Maven 引入(开发快速验证版):

<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacv-platform</artifactId>
    <version>1.5.10</version>
</dependency>

⚠️ 体积提醒javacv-platform 会打包所有平台的 native(windows/linux/macos/arm),依赖体积近 1GB。好处是部署无脑、跨平台自动适配;缺点是体积大,且在非标准打包(非 Spring Boot Fat Jar)的 CI/CD 流水线中可能丢失 classifier JAR(详见第 10.1 节)。

生产推荐写法(显式 classifier + 排除无用模块):

<!-- Java API 层(431KB),排除不需要的重型传递依赖 -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacv</artifactId>
    <version>1.5.10</version>
    <exclusions>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>ffmpeg</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>flycapture</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>libdc1394</artifactId></exclusion>
        <!-- ... 其他不需要的模块 -->
    </exclusions>
</dependency>
<!-- JavaCPP 加载框架 -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacpp</artifactId>
    <version>1.5.10</version>
    <classifier>linux-x86_64</classifier>
</dependency>
<!-- OpenCV 原生库(含 jniopencv_*.so) -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>opencv</artifactId>
    <version>4.9.0-1.5.10</version>
    <classifier>linux-x86_64</classifier>
</dependency>
<!-- OpenBLAS 原生库(矩阵运算依赖) -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>openblas</artifactId>
    <version>0.3.26-1.5.10</version>
    <classifier>linux-x86_64</classifier>
</dependency>

💡 为什么推荐显式声明:bytedeco 的架构是 javacv(纯 Java API)+ 各组件的 platform JAR(含 native .so/.dll)。javacv-platform 是一个 POM 类型依赖,会自动拉入所有平台的 native;但在自定义打包流水线(tar.gz 结构而非 Fat Jar)中,Maven 的 classifier JAR 经常不会被正确收集到 lib/ 目录。显式声明 + 指定 classifier 才是最稳妥的生产方案。体积从 ~1GB 降到 ~60MB(仅保留目标平台)。


5. 两个模型分别负责什么

整个识别过程是「检测 → 对齐 → 提特征 → 比对」四步,前两步靠 YuNet,后两步靠 SFace。

5.1 YuNet:人脸检测(找到脸在哪)

模型目录:github.com/opencv/open…

它回答三个问题:这张图里有没有脸?脸在哪?眼睛鼻子嘴角在哪?

FaceDetectorYN.detect() 返回一个二维矩阵,每一行代表一张脸,形状 [num_faces, 15]

0-1 : 人脸框左上角 x, y
2-3 : 人脸框 width, height
4-5 : 右眼坐标
6-7 : 左眼坐标
8-9 : 鼻尖坐标
10-11: 右嘴角坐标
12-13: 左嘴角坐标
14  : 人脸置信度(0~1,越大越确定是脸)

第 14 列的置信度,正是后面要重点讲的 score-threshold 过滤的依据。输出已按置信度从高到低排序,所以 faces.row(0) 就是最可信的那张脸。

5.2 SFace:人脸识别(判断是不是同一人)

模型目录:github.com/opencv/open…

它回答:这两张脸是不是同一个人?注意 SFace 不会告诉你「这是张三」,它只提取特征向量,「是谁」需要业务侧自己拿向量去比对。

FaceRecognizerSF 三个核心方法:

alignCrop : 根据眼睛/鼻子/嘴角关键点,把人脸摆正并裁剪到标准位置
feature   : 对对齐后的人脸提取 128 维特征向量
match     : 计算两个特征向量的相似度(余弦)或距离(L2)

💡 为什么必须 alignCrop:原图里人脸可能歪头、侧脸、远近不一。alignCrop 用关键点做仿射变换,把脸对齐到模型训练时的标准姿态,识别准确率会明显提升。跳过对齐,精度会大幅下降。


6. Senior 项目的业务落地

讲完原理,看我们项目里实际怎么用。

6.1 我们的两个比对场景

senior 项目里,人脸比对就是1:1 比对(不维护人脸特征库,每次实时下载两张图提特征比对):

场景一 faceCompareArcSoft:直接比对两张图 URL
  - url    : 当前刷脸照片
  - sysUrl : 系统里长者的档案底图

场景二 compareWithUrl:上传图 vs 长者底图(带业务校验)
  - 先校验机构经纬度、签到距离 < 1000 米
  - 再根据 seniorId 取长者档案底图 URL
  - 最后走场景一的双图比对

6.2 接口与实现分离的设计

迁移的核心原则是不动老代码、用接口隔离、配置可回退。我们的做法:

老的(保留不动):
  FaceFeatureService / FaceFeatureServiceImpl  ← 虹软实现

新增(统一接口,多引擎实现):
  OpenCvFaceCompareService              ← 接口,定义 2 个方法
  OpenCvFaceCompareServiceImpl          ← SFace 实现(face.engine=opencv)
  AdaFaceFaceCompareServiceImpl         ← AdaFace 实现(face.engine=adaface)

入口路由(按配置切换):
  ArcSoftFeign.faceCompareArcSoft
  ArcSoftController.compareWithUrl
    → 读 face.engine 配置:非 arcsoft 走新实现,arcsoft 走老实现
    → 具体加载哪个实现由 @ConditionalOnProperty 决定

这样切换只靠一个 Nacos 配置项,出问题随时回退,无需改代码、不影响老逻辑。后期新增的 AdaFace 实现也是同一个接口,配置切换即可(详见第 11 章)。

6.3 引擎的条件装配与生命周期

新实现只在 face.engine 不为 arcsoft 时装配,具体加载 SFace 还是 AdaFace 由 havingValue 决定:

@Slf4j
@Service
@ConditionalOnProperty(name = "face.engine", havingValue = "opencv")
public class OpenCvFaceCompareServiceImpl
        implements OpenCvFaceCompareService, InitializingBean, DisposableBean {

    private static final String YUNET_RES = "opencv/face_detection_yunet_2023mar.onnx";
    private static final String SFACE_RES = "opencv/face_recognition_sface_2021dec.onnx";

    @Value("${face.opencv.threshold:0.363}")
    private double threshold;          // 同人判定阈值
    @Value("${face.opencv.detect-size:320}")
    private int detectSize;            // 检测器输入尺寸
    @Value("${face.opencv.score-threshold:0.6}")
    private float scoreThreshold;      // 检测置信度门槛
    @Value("${face.opencv.nms-threshold:0.3}")
    private float nmsThreshold;        // 重叠框去重
    @Value("${face.opencv.top-k:5000}")
    private int topK;                  // 单帧最多保留人脸数

    // FaceDetectorYN / FaceRecognizerSF 都不是线程安全的,统一加锁
    private FaceDetectorYN detector;
    private FaceRecognizerSF recognizer;
    private final Object engineLock = new Object();

    @Override
    public void afterPropertiesSet() throws Exception {
        // ONNX 模型在 jar 里,OpenCV DNN 不支持 InputStream,必须先解压到临时文件
        File yunetFile = extractToTempFile(YUNET_RES, "clife-yunet-", ".onnx");
        File sfaceFile = extractToTempFile(SFACE_RES, "clife-sface-", ".onnx");

        this.detector = FaceDetectorYN.create(
                yunetFile.getAbsolutePath(), "",
                new Size(detectSize, detectSize),
                scoreThreshold, nmsThreshold, topK,
                0,   // backend_id: DNN_BACKEND_DEFAULT
                0    // target_id:  DNN_TARGET_CPU
        );
        this.recognizer = FaceRecognizerSF.create(sfaceFile.getAbsolutePath(), "");
    }

    @Override
    public void destroy() {
        synchronized (engineLock) {
            if (detector != null) detector.close();
            if (recognizer != null) recognizer.close();
        }
    }
    // ... 比对方法见下
}

AdaFace 实现类也是同一个接口,只是 havingValue 不同:

@Slf4j
@Service
@ConditionalOnProperty(name = "face.engine", havingValue = "adaface")
public class AdaFaceFaceCompareServiceImpl
        implements OpenCvFaceCompareService, InitializingBean, DisposableBean {
    // 复用 YuNet 检测器 + ONNX Runtime 加载 AdaFace 模型
    // 特征提取用 512 维向量,不用 FaceRecognizerSF
    // OrtSession 是线程安全的,无需加锁
    // ... 详见第 11 章
}

💡 模型从 jar 解压的坑:OpenCV DNN 的 create() 只接受文件路径,不支持 InputStream。模型打在 jar 里时无法直接传 classpath 路径,必须先用 ClassPathResource 读出来写到 java.io.tmpdir,再传绝对路径。

/** 把 classpath 下的 ONNX 模型解压到临时目录 */
private static File extractToTempFile(String resourcePath, String prefix, String suffix) throws IOException {
    ClassPathResource res = new ClassPathResource(resourcePath);
    if (!res.exists()) {
        throw new IOException("Model resource not found in classpath: " + resourcePath);
    }
    Path tmp = Files.createTempFile(prefix, suffix);
    try (InputStream in = res.getInputStream()) {
        Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
    }
    File f = tmp.toFile();
    f.deleteOnExit();
    return f;
}

6.4 核心比对逻辑(含 native 内存管理)

/** 检测最优人脸 + 对齐 + 特征提取,返回独立内存的特征 Mat(调用方负责 close) */
private Mat detectAndExtract(Mat img, int urlType, ResultVo resultVo) {
    Mat faces = new Mat();
    Mat aligned = new Mat();
    Mat feature = new Mat();
    try {
        synchronized (engineLock) {          // 引擎非线程安全,加锁
            detector.setInputSize(img.size()); // 必须设为图片实际尺寸,否则坐标错位
            detector.detect(img, faces);
            if (faces.rows() <= 0) {
                resultVo.setState(2);
                resultVo.setMsg(urlType == 1
                        ? FaceResultEnum.HUMAN_FACE_ERROR.getMsg()
                        : FaceResultEnum.HUMAN_SYS_FACE_ERROR.getMsg());
                return null;
            }
            // 输出按 score 已排序,第 0 行即最优脸
            try (Mat firstFace = faces.row(0)) {
                recognizer.alignCrop(img, firstFace, aligned);
                recognizer.feature(aligned, feature);
            }
        }
        return feature.clone();              // 锁外使用必须 clone 到独立内存
    } finally {
        faces.close();
        aligned.close();
        feature.close();
    }
}

/** cosine 相似度,范围 0~1,越大越像 */
private double matchFeatures(Mat f1, Mat f2) {
    synchronized (engineLock) {
        return recognizer.match(f1, f2, FaceRecognizerSF.FR_COSINE);
    }
}

⚠️ native 内存必须手动释放Mat 是 OpenCV 的 native 对象,不归 JVM GC 管。每个 Mat 用完必须 close(),否则堆外内存泄漏,长跑必崩。这里用 try-finally 确保释放,并在锁外返回前 clone() 出独立内存(避免锁内 Mat 被释放后悬空)。

6.5 字节流读图(处理中文路径/网络图)

我们的图都是从 COS/OSS 下载的字节流,用 imdecode 解码而非 imread

byte[] bytes = downloadWithHttpClient(url);  // HttpClient 下载
try (BytePointer ptr = new BytePointer(bytes);
     Mat raw = new Mat(1, bytes.length, CV_8UC1, ptr);
     Mat img = imdecode(raw, IMREAD_COLOR)) {
    if (img.empty()) {
        // 解码失败处理
    }
    return detectAndExtract(img, urlType, resultVo);
}

7. 关键参数详细解读(重点)

这一节是本文核心。这些参数我们都做成了配置项(Nacos 可调,无需重新部署),下面结合实际踩坑逐个讲透。

7.1 detect-size:检测器输入尺寸(最容易被忽视的关键参数)

face.opencv.detect-size=320

它是干嘛的: YuNet 在检测前,会先把输入图片缩放到这个正方形尺寸再送进神经网络。它是「性能 vs 召回率」的核心权衡旋钮。

取值CPU 推理耗时检测召回适用场景
160~2ms大脸 OK,小脸漏检证件照、极致性能
320(默认)~5ms大部分场景够用通用 / 本项目
480~12ms远距离/小脸明显改善群体合影、远景
640~25ms接近最佳召回监控大场景、远距离打卡
1024~80ms边际提升很小一般不用

关键理解: 假设原图是 4000×3000 的全身照,脸只占 5%。缩放到 320 后,脸只剩约 16 像素,模型根本看不清——于是「明明有脸却检测不到」。这时把 detect-size 调大到 640,脸就有约 32 像素,能检出了。

经验法则:

  • 「确认有脸但检测不到」→ 第一反应调大 detect-size
  • 「推理耗时太高」→ 调小 detect-size
  • 我们的签到照是近距离单人正脸,320 足够。

它和 score-threshold 的区别(重要):

detect-size     决定「模型能不能看见脸」(输入分辨率)
score-threshold 决定「看见后要不要承认这是脸」(置信度门槛)

7.2 score-threshold:检测置信度阈值(我们踩的第一个坑)

face.opencv.score-threshold=0.6

它是干嘛的: YuNet 输出的每张脸都带一个置信度(0~1),只有 ≥ 此阈值才被当作人脸返回。

踩坑实录: 一开始我们照搬 OpenCV 官方示例用了 0.9,结果本地测试一张正常的 960×1280 单人照,检测到人脸数: 0,直接抛「未检测到人脸」。把阈值降到 0.6 后立刻正常检出。

取值效果风险
0.9只认非常确定的脸漏检多(我们踩的坑)
0.6(默认)平衡推荐
0.3几乎都认误检多(把不是脸的当脸)

结论: 0.9 对真实业务照片偏严。我们把默认值定为 0.6,并做成配置项,生产遇到漏检可在 Nacos 直接下调。

💡 调试技巧:检测不到时,临时把阈值降到 0.1 重测,读 YuNet 输出第 14 列的实际置信度,就知道该把阈值设到多少。

7.3 threshold:余弦相似度阈值(判定是否同一人)

face.opencv.threshold=0.363

它是干嘛的: recognizer.match(f1, f2, FR_COSINE) 返回两张脸的余弦相似度(0~1,越大越像)。相似度 >= threshold 即判定为同一人。

0.363 是 SFace 官方在 LFW 数据集上调出的平衡点。但这个值不能盲信,必须用业务数据实测:

同一人样本:同一个长者,不同光线/角度/表情,拍几十组
不同人样本:不同长者之间互相比对几十组
观察两组的相似度分布,找到能最好区分的分界线

我们本地实测(同一人不同角度)的 cosine 约 0.70,远高于 0.363,判定「同一人」稳定。经验分布参考:

同一人正脸不同角度/光线 : 0.5 ~ 0.9
同一人 vs 戴口罩         : 0.3 ~ 0.5(易误判)
不同人                  : < 0.2 ~ 0.3

业务取舍: 想「宁可拒绝也别认错」(降低误识别),把阈值提到 0.45~0.5;想「尽量让长者一次过」(降低误拒),保持 0.363 或更低。

7.4 nms-threshold:重叠框去重

face.opencv.nms-threshold=0.3

NMS(非极大值抑制)用于去掉同一张脸上重叠的多个检测框。0.3 是标准值,一般不用动

7.5 top-k:单帧最多保留人脸数

face.opencv.top-k=5000

检测阶段最多保留的候选框数量。签到照里人脸很少,5000 远超实际,一般不用动


8. 配置与引擎切换

8.1 application.properties

# 引擎类型: opencv(SFace 128维) | adaface(AdaFace 512维,推荐) | arcsoft(已停用)
face.engine=adaface
arcsoft.enable=0

# --- YuNet 人脸检测器(opencv/adaface 共用) ---
face.opencv.detect-size=640
face.opencv.score-threshold=0.4
face.opencv.nms-threshold=0.3
face.opencv.top-k=5000

# --- OpenCV SFace: face.engine=opencv 时生效 ---
face.opencv.threshold=0.363

# --- AdaFace IR18: face.engine=adaface 时生效 ---
face.adaface.threshold=0.35

⚠️ properties 中文注释乱码坑:Spring Boot 默认按 ISO-8859-1 解码 .properties 文件,中文注释运行时会乱码。新增配置的注释统一用英文

8.2 一键切换与回退

切到 AdaFace(推荐): face.engine=adaface + arcsoft.enable=0
切到 SFace:       face.engine=opencv  + arcsoft.enable=0
回退到虹软:     face.engine=arcsoft + arcsoft.enable=1
改完重启服务即可,无需改代码。

9. 独立验证 Demo(不依赖 Spring)

落地前先用两张本地图验证。这个 main 方法不启动 Spring,不依赖 Nacos/Redis/Feign,改图片路径直接跑。

public class OpenCvFaceCompareLocalTest {

    private static final String IMG_A = "D:\\test\\face\\a.jpg";
    private static final String IMG_B = "D:\\test\\face\\b.jpg";
    private static final double THRESHOLD = 0.363;      // 同人判定阈值
    private static final int DETECT_SIZE = 320;
    private static final float SCORE_THRESHOLD = 0.6f;  // 注意:别用 0.9,会漏检
    private static final float NMS_THRESHOLD = 0.3f;
    private static final int TOP_K = 5000;

    public static void main(String[] args) throws Exception {
        File yunetFile = resolveModelFile("opencv/face_detection_yunet_2023mar.onnx");
        File sfaceFile = resolveModelFile("opencv/face_recognition_sface_2021dec.onnx");

        FaceDetectorYN detector = FaceDetectorYN.create(
                yunetFile.getAbsolutePath(), "",
                new Size(DETECT_SIZE, DETECT_SIZE),
                SCORE_THRESHOLD, NMS_THRESHOLD, TOP_K, 0, 0);
        FaceRecognizerSF recognizer = FaceRecognizerSF.create(sfaceFile.getAbsolutePath(), "");

        try (Mat featA = extractFeature(detector, recognizer, IMG_A);
             Mat featB = extractFeature(detector, recognizer, IMG_B)) {
            double cosine = recognizer.match(featA, featB, FaceRecognizerSF.FR_COSINE);
            double l2 = recognizer.match(featA, featB, FaceRecognizerSF.FR_NORM_L2);
            System.out.printf("Cosine: %.4f (threshold=%.3f, larger=more similar)%n", cosine, THRESHOLD);
            System.out.printf("L2    : %.4f (smaller=more similar, ref<1.128)%n", l2);
            System.out.println("判定  : " + (cosine >= THRESHOLD ? "同一人" : "不同人"));
        } finally {
            detector.close();
            recognizer.close();
        }
    }

    private static Mat extractFeature(FaceDetectorYN detector, FaceRecognizerSF recognizer, String path) {
        Mat img = imread(path, IMREAD_COLOR);
        if (img.empty()) throw new RuntimeException("无法解码图片: " + path);
        Mat faces = new Mat();
        Mat aligned = new Mat();
        Mat feature = new Mat();
        try {
            detector.setInputSize(img.size());
            detector.detect(img, faces);
            if (faces.rows() <= 0) throw new RuntimeException("未检测到人脸: " + path);
            try (Mat first = faces.row(0)) {
                recognizer.alignCrop(img, first, aligned);
                recognizer.feature(aligned, feature);
            }
            return feature.clone();
        } finally {
            img.close(); faces.close(); aligned.close(); feature.close();
        }
    }
    // resolveModelFile(...) 省略:从 classpath 解压或回退到 resources/opencv/
}

实测输出:

[D:\test\face\a.jpg] 图片尺寸: 960x1280
[D:\test\face\a.jpg] score>=0.6 检测到人脸数: 1
[D:\test\face\b.jpg] 图片尺寸: 960x1280
[D:\test\face\b.jpg] score>=0.6 检测到人脸数: 1
Cosine: 0.7055 (threshold=0.363, larger=more similar)
L2    : 0.7675 (smaller=more similar, ref<1.128)
判定  : 同一人

💡 以上是 SFace(128维)的验证方法。如果你要用 AdaFace(512维,低质量图更鲁棒),完整可跑示例见 11.9 节


10. 踩坑清单汇总

把本次迁移踩过的坑集中列出,供后人避雷:

#现象解法
1score-threshold=0.9 太严正常照片「检测到人脸数: 0」默认值改 0.6,做成配置项
2模型在 jar 里无法直接加载DNN create 报找不到文件ClassPathResource 解压到 tmpdir 再传路径
3Mat native 内存泄漏长跑堆外内存涨、崩溃每个 Mat try-finally close,返回前 clone
4引擎非线程安全并发下结果错乱/崩溃detect/extract/match 统一 synchronized
5detect-size 与图片尺寸不一致关键点坐标错位、对齐失败detector.setInputSize(img.size()) 设为实际尺寸
6properties 中文注释乱码运行时配置注释乱码新增配置注释用英文
7new Mat(byte[]) 构造不存在编译报方法找不到BytePointer + imdecode
8FaceDetectorYN.create 6 参版本不存在编译报方法签名不匹配用 8 参版本,补 backend_id=0, target_id=0
9bytedeco native JAR 未进 classpathUnsatisfiedLinkError: no jniopencv_highguipom 显式声明 opencv:classifier 而非 javacv:classifier(详见 10.1)
10Docker slim 缺系统库libgtk-x11-2.0.so.0: cannot open shared object fileDockerfile 加 apt-get install libgtk2.0-0 libgomp1
11本地能跑 CI/CD 挂本地测试通过,容器启动崩溃Maven 本地仓库有缓存 ≠ 显式声明了依赖(详见 10.2)
12K8s OOM Killed(exit code 137)服务启动成功,首次请求到来时容器被杀OpenCV native mmap .so 占额外内存,Pod limit 需额外留 1.5GB+(详见 10.5)
13手机照片 EXIF 旋转未处理同一人 cosine 只有 0.07~0.17,远低于阈值imdecode 不读 EXIF,竖拍照片实际是侧倒的,需手动解析 Orientation 并旋转(详见 10.6)
14多人脸场景取错了脸cosine 极低且不稳定,背景有海报/屏幕时尤其明显原代码取置信度最高而非面积最大,签到场景应取最大脸(详见 11.5)
15SFace 对低质量图崩溃性失效同一测试机拍的两张照片 cosine=0.09,连自己都不认识SFace 128维向量信息量不足,换 AdaFace 512维后同场景 0.38+(详见第 11 章)

10.1 深坑详解:bytedeco native JAR 未进 classpath(坑 #9)

这是我们部署到 K8s 测试环境后遇到的第一个致命错误,也是最难排查的——因为本地跑得好好的。

报错信息:

Caused by: java.lang.UnsatisfiedLinkError: no jniopencv_highgui in java.library.path
    at org.bytedeco.javacpp.Loader.load(Loader.java:1234)
    at org.bytedeco.opencv.opencv_objdetect.FaceDetectorYN.<clinit>(FaceDetectorYN.java:42)

第一直觉的误区: 看到 java.library.path 就以为要在启动脚本加 -Djava.library.path=/xxx。但这是错的!bytedeco 的 Loader 根本不用 java.library.path,它有自己的加载机制。

bytedeco Loader 的真实加载链路:

① classpath 上有 opencv-4.9.0-1.5.10.jar(Java API 层,1.8MB)
    ↓
② Loader 通过 @Platform 注解推断当前 OS + arch = linux-x86_64
    ↓
③ 在 classpath 中搜索 opencv-4.9.0-1.5.10-linux-x86_64.jar(native 层,29MB)
    ↓
④ 找到 → 解压 .so 到 /root/.javacpp/cache/ → System.load() 加载
   找不到 → 抛 UnsatisfiedLinkError

根因分析: 我们最初的 pom.xml 是这样写的(错误写法):

<!-- ❌ 错误!javacv:1.5.10 这个 artifact 没有 classifier 变体 -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacv</artifactId>
    <version>1.5.10</version>
    <classifier>linux-x86_64</classifier>
</dependency>

去 Maven Central 看就明白了:

https://repo1.maven.org/maven2/org/bytedeco/javacv/1.5.10/
  → 只有 javacv-1.5.10.jar(431KB),没有 javacv-1.5.10-linux-x86_64.jar

https://repo1.maven.org/maven2/org/bytedeco/opencv/4.9.0-1.5.10/
  → 有 opencv-4.9.0-1.5.10-linux-x86_64.jar(29MB)✅

bytedeco 的组件层次(必须理解):

┌─────────────────────────────────────────────────────────────┐
│ javacv:1.5.10           ← Java API 层(纯 Java,431KB)    │
│   传递依赖 →                                                │
│     opencv:4.9.0-1.5.10 ← OpenCV Java 绑定(无 classifier)│
│     openblas:0.3.26-1.5.10                                  │
│     javacpp:1.5.10                                          │
│     ffmpeg / flycapture / ... (你不需要的)                │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 必须额外显式声明(带 classifier):                          │
│   javacpp:1.5.10:linux-x86_64        ← 加载框架 native     │
│   opencv:4.9.0-1.5.10:linux-x86_64   ← OpenCV .so(29MB)  │
│   openblas:0.3.26-1.5.10:linux-x86_64 ← 矩阵运算 native   │
└─────────────────────────────────────────────────────────────┘

正确的 pom.xml:

<!-- ✅ Java API 层 + 排除无用传递依赖 -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacv</artifactId>
    <version>1.5.10</version>
    <exclusions>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>ffmpeg</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>flycapture</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>libdc1394</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>libfreenect</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>libfreenect2</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>librealsense</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>librealsense2</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>videoinput</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>artoolkitplus</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>leptonica</artifactId></exclusion>
        <exclusion><groupId>org.bytedeco</groupId><artifactId>tesseract</artifactId></exclusion>
    </exclusions>
</dependency>
<!-- ✅ 原生库:注意 artifactId 是 javacpp / opencv / openblas,不是 javacv! -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacpp</artifactId>
    <version>1.5.10</version>
    <classifier>linux-x86_64</classifier>
</dependency>
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>opencv</artifactId>
    <version>4.9.0-1.5.10</version>
    <classifier>linux-x86_64</classifier>
</dependency>
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>openblas</artifactId>
    <version>0.3.26-1.5.10</version>
    <classifier>linux-x86_64</classifier>
</dependency>

💡 版本对应关系:javacv 1.5.10 → javacpp 1.5.10 → opencv 4.9.0-1.5.10 → openblas 0.3.26-1.5.10。前缀数字是上游库原始版本,后缀是 bytedeco 的构建版本。在 javacv-1.5.10.pom<dependencies> 里可以查到精确对应关系。

10.2 本地能跑但 CI/CD 崩的经典原因(坑 #11)

这是最让人抓狂的:本地 IntelliJ 跑测试类完美通过,推到 CI/CD 容器就崩

根因:Maven 本地仓库有缓存。

当你之前用过 javacv-platform(或任何全量依赖)做过一次编译,.m2/repository/org/bytedeco/opencv/4.9.0-1.5.10/ 目录下就已经存在:

.m2/repository/org/bytedeco/opencv/4.9.0-1.5.10/
├── opencv-4.9.0-1.5.10.jar              ← Java API(1.8MB)
├── opencv-4.9.0-1.5.10-linux-x86_64.jar ← native(29MB)✅ 缓存在这
├── opencv-4.9.0-1.5.10-windows-x86_64.jar ← native(31MB)✅ 缓存在这
└── ...

IntelliJ 运行时,bytedeco Loader 会扫描整个 .m2 缓存目录树,即使你的 pom 没显式声明这些 classifier JAR,Loader 也能从缓存中「侥幸」找到并加载。

但 CI/CD 是干净环境——Docker 镜像构建时 Maven 从零下载,只下载 pom.xml 中显式声明的依赖。你声明了一个不存在的 javacv:1.5.10:linux-x86_64(Maven 解析为空),真正需要的 opencv:4.9.0-1.5.10:linux-x86_64 从未被声明,自然不会下载。

本地 IntelliJ:
  classpath 包含 .m2 全部 JAR → Loader 在缓存中找到 native JAR → ✅ 成功

CI/CD Docker:
  lib/ 只有 pom 显式声明的 JAR → native JAR 不存在 → ❌ UnsatisfiedLinkError

诊断方法: 如果你怀疑是依赖问题,删掉本地 .m2/repository/org/bytedeco/ 目录重新编译跑,立刻就能在本地复现 CI/CD 的报错。

10.3 Docker slim 镜像缺系统级共享库(坑 #10)

修完依赖问题后,第二次部署又崩了,但报错信息变了(说明 native JAR 已经到位了):

Caused by: java.lang.UnsatisfiedLinkError:
  /root/.javacpp/cache/opencv-4.9.0-1.5.10-linux-x86_64.jar/org/bytedeco/opencv/linux-x86_64/libjniopencv_highgui.so:
  libgtk-x11-2.0.so.0: cannot open shared object file: No such file or directory

解读:

  • JAR 下载 ✅
  • .so 解压到 cache ✅
  • System.load() 加载 .so 时,操作系统的动态链接器发现它依赖 libgtk-x11-2.0.so.0,但 slim 镜像里没装

原因: jdk:8u291-slim 基于 Debian slim,为了减小镜像体积裁掉了几乎所有非必要包。OpenCV 的 highgui 模块(图形界面相关)链接了 GTK2。虽然服务端不需要 GUI,但 bytedeco 默认打包了完整的 OpenCV 模块,加载时会触发依赖检查。

修复方案(Dockerfile 加系统依赖):

FROM clife-devops-docker.pkg.coding.net/public-repository/sl/jdk:8u291-slim
MAINTAINER {{orgName}}
# OpenCV native (bytedeco) 运行时所需的系统级共享库
RUN apt-get update && apt-get install -y --no-install-recommends \
    libgomp1 \
    libgtk2.0-0 \
    libgl1-mesa-glx \
    && rm -rf /var/lib/apt/lists/*
ADD target/service-packs/{{serviceName}}.tar.gz /{{serviceName}}/
CMD bash -c "/{{serviceName}}/bin/appStartInDocker.sh && tail -f /www/logs/{{serviceName}}/application.out"

三个包的作用:

系统库作用不装的报错
libgomp1GCC OpenMP 并行计算库libgomp.so.1: cannot open
libgtk2.0-0GTK2 图形库libgtk-x11-2.0.so.0: cannot open
libgl1-mesa-glxOpenGL 渲染库libGL.so.1: cannot open

💡 rm -rf /var/lib/apt/lists/* 解释:这只是删除 APT 的包索引元数据(相当于"商品目录册"),不影响已安装的包,是 Docker 官方最佳实践,能节省 20-50MB 镜像体积。

💡 替代方案思考:如果不想装 GTK(确实是 headless 服务不需要的),理论上可以自己编译一份不含 highgui 的 OpenCV native。但 bytedeco 的预编译包是全模块的,没有 headless 版本发布,所以现阶段最务实的方案就是装系统库。

10.4 部署排查流程图(总结)

容器启动报 UnsatisfiedLinkError
  │
  ├─ 消息含 "no jnixxx in java.library.path"
  │     → native JAR 没进 classpath
  │     → 检查 pom.xml:是否显式声明了 opencv:version:classifier
  │     → 注意:artifactId 是 opencv 不是 javacv!
  │
  └─ 消息含 "libxxx.so: cannot open shared object file"
        → native JAR 有了,但 .so 的系统依赖缺失
        → ldd 查看缺哪些 → Dockerfile 里 apt-get install

10.5 K8s Exit Code 137:OpenCV 推理触发 OOM Killed(坑 #12)

服务启动成功、健康检查通过,但第一个人脸比对请求到来时容器直接被 K8s 杀掉,exit code 137。

Exit code 137 = SIGKILL,在 K8s 中几乎必然是 Pod 内存使用超过了 resources.limits.memory

为什么启动不 OOM,请求才 OOM?

启动阶段:
  JVM heap(-Xmx2048M) + Metaspace + 线程栈 ≈ 2.3GB → 刚好不超 2Gi limit

首次请求阶段:
  bytedeco Loader 首次使用时 mmap 加载 .so 到内存:
    libopencv_*.so     ≈ 120MB
    libopenblas*.so    ≈ 80MB
    libjniopencv_*.so  ≈ 150MB
    ---
  合计额外 ~350MB,瞬间把总内存推到 2.6GB+ → 超过 2Gi → SIGKILL

关键认知:JVM 堆之外还有大量 native 内存。 容器 limit 的计算公式:

Pod memory limit ≥ JVM heap + Metaspace(256~512M) + native mmap(~400M) + 线程栈 + 缓冲区

修复方案:

  1. 调大 Pod memory limit:2Gi → 4Gi(给 native 留足余量)
  2. 用百分比代替固定堆-XX:MaxRAMPercentage=60.0,JVM 自动感知容器 limit 计算堆大小
  3. 提高 requests.memory:避免被 K8s eviction 优先驱逐
# deploy-http-app.yaml
resources:
  requests:
    cpu: 1000m
    memory: 2Gi
  limits:
    cpu: 2
    memory: 4Gi
JAVA_OPTS="-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=512m \
           -XX:MaxRAMPercentage=60.0 -XX:InitialRAMPercentage=60.0 \
           -Djava.io.tmpdir=/tmp -Dspring.profiles.active=${PROFILES_ACTIVE}"

💡 MaxRAMPercentage=60.0 的好处:以后调整 Pod limit 时 heap 自动跟着变,不用 -Xms/-Xmx 两处同步改。60% 留给堆,40% 留给 native + Metaspace + 栈,对 OpenCV 场景刚好。

10.6 手机竖拍照片 EXIF 旋转未处理:同一人 cosine 极低(坑 #13)

这是最隐蔽的精度杀手——服务一切正常,人脸能检测到,特征能提取,cosine 有值(不是 0),但同一个人的分数只有 0.07~0.17,远低于阈值 0.363。

现象:

compareTwoImageUrls cosine=0.10988, threshold=0.363 → 判定为不同人

你确认底图确实是本人,照片没问题,模型也没坏。但分数就是低得离谱。

根因:JPEG EXIF Orientation。

手机竖着拍照时,CMOS 传感器实际是横向采集的(如 4032×3024)。手机通过在 JPEG 文件的 EXIF 元数据中写入 Orientation=6("顺时针旋转 90°")来告诉显示端正确展示。

imdecode() 只解码像素数据,完全忽略 EXIF 元数据。结果:

手机竖拍 → 传感器横向采集 4032×3024
  → EXIF 标记 Orientation=6
  → 用户看到的:正常竖屏人脸
  → imdecode 解码后:人脸侧倒 90°!

侧倒的人脸 → YuNet 勉强检测到(低置信度)
  → 5 个关键点位置全错
  → alignCrop 对齐完全失效
  → SFace 提取的特征向量是垃圾
  → cosine 极低(0.07~0.17)

为什么很难发现?

  • 不报错,不崩溃,有正常返回值
  • cosine 不是 0(因为确实检测到了「某种」面部区域)
  • 系统底图如果是通过网页上传的(浏览器会自动修正 EXIF),它是正的
  • 只有手机实时拍照的那张是侧倒的
  • 你把阈值调低也没用——0.11 连 0.2 都过不了

修复方案:在 imdecode 之后、人脸检测之前,解析 EXIF Orientation 并应用旋转。

// 核心修复代码
private Mat applyExifRotation(Mat img, byte[] rawBytes) {
    int orientation = getExifOrientation(rawBytes);
    if (orientation <= 1 || orientation > 8) {
        return img; // 无需旋转(正常方向或非 JPEG)
    }
    Mat dst = new Mat();
    switch (orientation) {
        case 6: // 顺时针 90°(最常见:手机竖拍)
            rotate(img, dst, ROTATE_90_CLOCKWISE);
            break;
        case 3: // 旋转 180°
            rotate(img, dst, ROTATE_180);
            break;
        case 8: // 逆时针 90°
            rotate(img, dst, ROTATE_90_COUNTERCLOCKWISE);
            break;
        // ... 其他翻转情况
    }
    return dst;
}

getExifOrientation() 直接从 JPEG 字节流解析 APP1 段的 TIFF IFD0 Orientation Tag(0x0112),无需引入第三方库,~60 行纯 Java 实现。

EXIF Orientation 常见值:

含义场景
1正常(无旋转)电脑截图、网页上传后的图
3旋转 180°手机倒持拍照
6顺时针 90°手机正常竖拍(最常见)
8逆时针 90°手机反向横拍

修复后效果: 同一人 cosine 从 0.07~0.17 恢复到 0.5~0.7+,阈值 0.363 轻松通过。

💡 经验法则:任何从移动端直传(不经过 Web 前端处理)的 JPEG 图片,在做 CV 处理前都必须先处理 EXIF 旋转。这不是 OpenCV 的 bug——imdecode 的设计哲学就是只管像素解码,不管元数据语义。


11. 从 SFace 升级到 AdaFace:解决低质量图片的最终方案

11.1 为什么需要换模型

在解决了上述所有部署坑(native JAR、系统库、OOM、EXIF)后,我们终于得到了一个能稳定运行的服务。但测试时发现了新问题:

同一人、同一手机拍的照片:cosine = 0.65 → 轻松通过 ✅
同一人、低端测试机拍的照片:cosine = 0.11 → 完全不过 ✗
同一人、同一测试机拍的两张照片:cosine = 0.09 → 连自己都不认识自己 ✗

关键发现:SFace 对低质量图片(模糊、低分辨率、差镜头)的容忍度极低。当图片质量低于某个门槛时,提取出的 128 维特征向量基本是噪声,调任何参数都救不回来。

这不是可以通过调阈值解决的——当 cosine = 0.11 时,你把阈值降到 0.1 确实能「通过」,但两个陌生人也能通过,等于人脸识别形同虚设。

11.2 为什么选 AdaFace

维度SFace (2021)AdaFace (2022, CVPR Oral)
核心思路固定 margin,对所有样本一视同仁质量自适应 margin:低质量样本自动降低惩罚
特征维度128 维512 维(信息量更大)
LFW 精度99.60%99.82%
IJB-C (FAR=1e-4)~94%97.39%
低质量图表现崩溃性下降优雅降级,仍保持可用
Java 接入OpenCV FaceRecognizerSF(内置)ONNX Runtime(onnxruntime-java
模型体积37MB91MB (IR18)

AdaFace 的核心创新:用特征范数(feature norm)近似图像质量,训练时动态调整 loss 的惩罚强度:

  • 高质量图 → 大 margin、强约束(精确区分)
  • 低质量图 → 小 margin、弱约束(先保证不判错,宁可放过)

结果:低质量图的特征向量虽然不够精确,但至少「方向对了」——同一人的两张低质量照片仍能得到 0.3~0.5 的 cosine,而不是 SFace 的 0.09。

11.3 Java 接入方案:ONNX Runtime

AdaFace 没有原生 Java SDK,但 ONNX 模型是跨语言的。我们用微软官方的 onnxruntime-java 加载它:

<dependency>
    <groupId>com.microsoft.onnxruntime</groupId>
    <artifactId>onnxruntime</artifactId>
    <version>1.17.0</version>
</dependency>

与 SFace 的架构对比:

SFace 链路:
  YuNet检测 → alignCrop对齐 → FaceRecognizerSF.feature() → 128维 → match()
                     ↑ OpenCV 内置,一行代码

AdaFace 链路:
  YuNet检测 → 裁剪+resize(112x112) → OrtSession.run() → 512维 → 手写cosine
                     ↑ 需要自己做预处理,但逻辑很简单

关键代码片段:

// 1. 图片预处理:BGR 112x112 → float[1][3][112][112],归一化到 [-1, 1]
for (int ch = 0; ch < 3; ch++) {
    float pixel = (float) (pixels[idx + ch] & 0xFF);
    inputData[0][ch][row][col] = (pixel / 127.5f) - 1.0f;  // AdaFace 用 BGR,无需转 RGB
}

// 2. ONNX 推理
OnnxTensor inputTensor = OnnxTensor.createTensor(env, inputData);
OrtSession.Result result = session.run(Collections.singletonMap("input", inputTensor));
float[] embedding = ((float[][]) result.get(0).getValue())[0];

// 3. L2 归一化 + 余弦相似度
float[] normalized = l2Normalize(embedding);  // 512维
double cosine = dot(normalizedA, normalizedB);  // 同一人典型 0.4~0.7

11.4 ONNX 模型导出

AdaFace 官方只提供 PyTorch checkpoint(.ckpt),需要自己导出 ONNX。以下是完整的一键导出流程(需要 Python 3.8+ 和梯子/VPN访问 Google Drive):

Step 1:安装 Python 依赖

pip install torch torchvision gdown onnx onnxscript
# torch 推荐 2.0+,但导出时必须用 dynamo=False

Step 2:下载官方 checkpoint

# IR18 + WebFace4M 训练集(597MB)
gdown "https://drive.google.com/uc?id=1BmDRrhPsHSbXcWZoYFPJg2KJn1sd3QpN" -O adaface_ir18_webface4m.ckpt

💡 无法访问 Google Drive 的,可以在 AdaFace 官方 GitHub 找镜像链接,或联系笔者获取已导出的 ONNX 文件。

Step 3:执行导出脚本

将以下内存为 export_adaface.py,然后运行 python export_adaface.py

import torch
import torch.nn as nn
from torch.nn import Linear, Conv2d, BatchNorm1d, BatchNorm2d, PReLU, Sequential, Module
import torch.nn.functional as F

# ===== 模型定义(简化版 IResNet18) =====
class Flatten(Module):
    def forward(self, x): return x.flatten(1)

def conv3x3(in_ch, out_ch, stride=1):
    return Conv2d(in_ch, out_ch, 3, stride, 1, bias=False)

class IBasicBlock(Module):
    def __init__(self, in_ch, out_ch, stride=1, downsample=None):
        super().__init__()
        self.bn1 = BatchNorm2d(in_ch)
        self.conv1 = conv3x3(in_ch, out_ch)
        self.bn2 = BatchNorm2d(out_ch)
        self.prelu = PReLU(out_ch)
        self.conv2 = conv3x3(out_ch, out_ch, stride)
        self.bn3 = BatchNorm2d(out_ch)
        self.downsample = downsample

    def forward(self, x):
        identity = x
        out = self.bn1(x)
        out = self.conv1(out)
        out = self.bn2(out)
        out = self.prelu(out)
        out = self.conv2(out)
        out = self.bn3(out)
        if self.downsample is not None:
            identity = self.downsample(x)
        return out + identity

class IResNet(Module):
    def __init__(self, layers, num_features=512, dropout=0.4):
        super().__init__()
        self.conv1 = Conv2d(3, 64, 3, 1, 1, bias=False)
        self.bn1 = BatchNorm2d(64)
        self.prelu = PReLU(64)
        self.layer1 = self._make_layer(64, 64, layers[0], stride=2)
        self.layer2 = self._make_layer(64, 128, layers[1], stride=2)
        self.layer3 = self._make_layer(128, 256, layers[2], stride=2)
        self.layer4 = self._make_layer(256, 512, layers[3], stride=2)
        self.bn2 = BatchNorm2d(512)
        self.dropout = nn.Dropout(dropout)
        self.flatten = Flatten()
        self.fc = Linear(512 * 7 * 7, num_features)
        self.features = BatchNorm1d(num_features)

    def _make_layer(self, in_ch, out_ch, blocks, stride):
        downsample = Sequential(Conv2d(in_ch, out_ch, 1, stride, bias=False), BatchNorm2d(out_ch))
        layers = [IBasicBlock(in_ch, out_ch, stride, downsample)]
        for _ in range(1, blocks):
            layers.append(IBasicBlock(out_ch, out_ch))
        return Sequential(*layers)

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.prelu(x)
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        x = self.bn2(x)
        x = self.dropout(x)
        x = self.flatten(x)
        x = self.fc(x)
        x = self.features(x)
        return x

def iresnet18(**kwargs):
    return IResNet([2, 2, 2, 2], **kwargs)

# ===== 导出 =====
model = iresnet18(num_features=512, dropout=0.4)
ckpt = torch.load('adaface_ir18_webface4m.ckpt', map_location='cpu')
state = {k.replace('model.', ''): v for k, v in ckpt['state_dict'].items()}
model.load_state_dict(state)
model.eval()

dummy = torch.randn(1, 3, 112, 112)
torch.onnx.export(
    model, dummy, 'adaface_ir18_webface4m.onnx',
    input_names=['input'], output_names=['embedding'],
    opset_version=12,
    dynamo=False,  # ← 关键!新版 PyTorch 默认 True 会导出空壳
    dynamic_axes={'input': {0: 'batch'}, 'embedding': {0: 'batch'}}
)

import os
size_mb = os.path.getsize('adaface_ir18_webface4m.onnx') / 1024 / 1024
print(f'\n✅ 导出成功: adaface_ir18_webface4m.onnx ({size_mb:.1f} MB)')
if size_mb < 10:
    print('❌ 警告:文件过小,可能是空壳!请确认使用了 dynamo=False')

预期输出:

✅ 导出成功: adaface_ir18_webface4m.onnx (91.6 MB)

⚠️ 坑:PyTorch 2.9+ 默认用 dynamo=True 导出,会触发 opset 版本转换失败,生成 0.1MB 的空壳文件。必须显式传 dynamo=False 走 legacy 导出路径。如果导出后文件小于 10MB,说明导出失败了。

11.5 选脸策略升级:面积最大代替置信度最高

在调试过程中还发现了另一个影响精度的因素:多人脸场景下取错了脸

上传图检测到 2 张脸:
  - 脸 A:用户本人(占图最大,但略有运动模糊,置信度 0.72)
  - 脸 B:背景海报上的人像(清晰小脸,置信度 0.95)

原代码 faces.row(0) 取置信度最高 → 取到了海报脸 → cosine 极低

修复:签到场景下,用户一定是画面中最大的脸。

// 选面积最大的脸(width × height)
int bestIdx = 0;
float bestArea = 0;
for (int i = 0; i < faces.rows(); i++) {
    float w = faces.row(i).get(2);
    float h = faces.row(i).get(3);
    if (w * h > bestArea) {
        bestArea = w * h;
        bestIdx = i;
    }
}

11.6 实战结果对比

用同一组测试图片(包含低端测试机拍的照片):

图片对SFace (128维)AdaFace (512维)判定
手机 A vs 手机 B(同人)0.650.72都通过
手机 A vs 测试机 C(同人)0.110.42SFace失败,AdaFace通过
测试机 C vs 测试机 D(同人)0.090.38SFace失败,AdaFace通过
手机 A vs 陌生人(不同人)-0.050.08都拒绝

结论:AdaFace 在低质量场景下 cosine 提升 3~4 倍,从“完全不可用”变为“可稳定通过”。

11.7 配置与切换

我们设计为双引擎可切换,通过一个配置项即可在 SFace 和 AdaFace 间切换,无需改代码:

# application.properties / Nacos

# 引擎类型: opencv(SFace) | adaface(AdaFace)
face.engine=adaface

# AdaFace 阈值(512维,同一人典型 0.4~0.7)
face.adaface.threshold=0.35

架构上用 @ConditionalOnProperty 实现引擎加载隔离,两套实现共用同一个 OpenCvFaceCompareService 接口,Controller 层完全无感知:

@Service
@ConditionalOnProperty(name = "face.engine", havingValue = "adaface")
public class AdaFaceFaceCompareServiceImpl implements OpenCvFaceCompareService { ... }

@Service
@ConditionalOnProperty(name = "face.engine", havingValue = "opencv")
public class OpenCvFaceCompareServiceImpl implements OpenCvFaceCompareService { ... }

11.8 部署注意事项

  • 模型体积:AdaFace IR18 ONNX 为 91.6MB,打入 JAR 后 CI/CD 构建时间会增加
  • 内存占用:ONNX Runtime 比 bytedeco OpenCV 轻量,不需要 mmap 大量 .so,4Gi Pod limit 足够
  • 不需要额外系统库:ONNX Runtime 是纯 Java + JNI,不依赖 GTK/OpenGL,Dockerfile 无需改动
  • 线程安全:OrtSession 本身是线程安全的,不需要像 SFace 那样加 synchronized 锁
  • YuNet 检测器仍复用:人脸检测用 YuNet,只有特征提取层换了 AdaFace

11.9 完整可跑示例:5 分钟跑通 AdaFace 人脸比对

下面是一个完整的、可直接跑的 AdaFace 本地测试类。无需启动 Spring,复制即用。

前置准备清单

#事项完成标志
1pom.xml 引入 5 个依赖mvn compile 无报错
2下载 YuNet 模型 (233KB)resources/opencv/face_detection_yunet_2023mar.onnx 存在
3导出/下载 AdaFace 模型 (91.6MB)resources/opencv/adaface_ir18_webface4m.onnx 存在
4准备 2 张测试照片D:\test\face\a.jpgb.jpg

Step 1:pom.xml 依赖

<!-- YuNet 人脸检测(OpenCV DNN) -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacv</artifactId>
    <version>1.5.10</version>
</dependency>
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>opencv</artifactId>
    <version>4.9.0-1.5.10</version>
    <classifier>windows-x86_64</classifier>  <!-- Linux 环境换 linux-x86_64 -->
</dependency>
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>openblas</artifactId>
    <version>0.3.26-1.5.10</version>
    <classifier>windows-x86_64</classifier>
</dependency>
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacpp</artifactId>
    <version>1.5.10</version>
    <classifier>windows-x86_64</classifier>
</dependency>

<!-- AdaFace 特征提取(ONNX Runtime) -->
<dependency>
    <groupId>com.microsoft.onnxruntime</groupId>
    <artifactId>onnxruntime</artifactId>
    <version>1.17.0</version>
</dependency>

💡 生产部署时,所有 windows-x86_64 换成 linux-x86_64,或两个都加上(本地开发 + CI/CD 两个平台)。

Step 2:下载模型文件

YuNet(人脸检测):

# 直接从 OpenCV Zoo 下载(无需梯子)
wget https://raw.githubusercontent.com/opencv/opencv_zoo/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
# 放入 src/main/resources/opencv/

AdaFace(特征提取): 用 11.4 节的 export_adaface.py 脚本自己导出。整个流程:

# 1. 安装 Python 依赖
pip install torch gdown onnx onnxscript

# 2. 下载官方 checkpoint(需要梯子访问 Google Drive,597MB)
gdown "https://drive.google.com/uc?id=1BmDRrhPsHSbXcWZoYFPJg2KJn1sd3QpN" -O adaface_ir18_webface4m.ckpt

# 3. 执行导出脚本(完整代码见 11.4 节)
python export_adaface.py
# 输出: adaface_ir18_webface4m.onnx (91.6 MB)

# 4. 放入项目
mv adaface_ir18_webface4m.onnx src/main/resources/opencv/

⚠️ 导出后文件必须是 91.6MB。如果只有 0.1MB,说明 PyTorch 用了 dynamo 导出,请检查 dynamo=False 参数。

Step 3:准备测试图片

D:\test\face\(或你喜欢的任何目录)下放 2 张同一人的照片。用手机自拍就行,不需要证件照。

Step 4:复制代码,右键 Run

import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtSession;
import org.bytedeco.javacpp.BytePointer;
import org.bytedeco.opencv.opencv_core.Mat;
import org.bytedeco.opencv.opencv_core.Rect;
import org.bytedeco.opencv.opencv_core.Size;
import org.bytedeco.opencv.opencv_objdetect.FaceDetectorYN;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.FloatBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Collections;

import static org.bytedeco.opencv.global.opencv_core.CV_8UC1;
import static org.bytedeco.opencv.global.opencv_imgcodecs.IMREAD_COLOR;
import static org.bytedeco.opencv.global.opencv_imgcodecs.imdecode;
import static org.bytedeco.opencv.global.opencv_imgcodecs.imread;
import static org.bytedeco.opencv.global.opencv_imgproc.INTER_LINEAR;
import static org.bytedeco.opencv.global.opencv_imgproc.resize;

/**
 * AdaFace 人脸比对完整示例。
 * 无需 Spring,右键 main 直接跑。
 *
 * 流程:
 *   1. 加载 YuNet 检测模型
 *   2. 加载 AdaFace ONNX 模型
 *   3. 两张图分别检测人脸 → 裁剪 112x112 → 提取 512 维特征
 *   4. 计算余弦相似度 → 判定是否同一人
 */
public class AdaFaceDemo {

    // ════ 修改这两行为你的本地图片 ════
    private static final String IMG_A = "D:\\test\\face\\a.jpg";
    private static final String IMG_B = "D:\\test\\face\\b.jpg";

    // 阈值(512维,同一人典型 0.4~0.7,推荐 0.35)
    private static final double THRESHOLD = 0.35;

    // 模型路径(放在 resources/opencv/ 下)
    private static final String YUNET_MODEL = "opencv/face_detection_yunet_2023mar.onnx";
    private static final String ADAFACE_MODEL = "opencv/adaface_ir18_webface4m.onnx";

    public static void main(String[] args) throws Exception {
        // Step 1: 加载 YuNet 人脸检测器
        File yunetFile = extractModel(YUNET_MODEL);
        FaceDetectorYN detector = FaceDetectorYN.create(
                yunetFile.getAbsolutePath(), "",
                new Size(320, 320),
                0.6f,   // score threshold
                0.3f,   // NMS threshold
                5000,   // top-k
                0, 0
        );

        // Step 2: 加载 AdaFace ONNX 模型
        File adafaceFile = extractModel(ADAFACE_MODEL);
        OrtEnvironment env = OrtEnvironment.getEnvironment();
        OrtSession.SessionOptions opts = new OrtSession.SessionOptions();
        opts.setIntraOpNumThreads(2);
        OrtSession session = env.createSession(adafaceFile.getAbsolutePath(), opts);

        // Step 3: 提取两张图的特征向量
        float[] featA = extractFeature(detector, session, env, IMG_A);
        float[] featB = extractFeature(detector, session, env, IMG_B);

        // Step 4: 计算余弦相似度
        double cosine = cosineSimilarity(featA, featB);
        System.out.printf("%nCosine = %.4f (threshold=%.2f) → %s%n",
                cosine, THRESHOLD, cosine >= THRESHOLD ? "同一人 ✓" : "不同人 ✗");

        session.close();
        detector.close();
    }

    /**
     * 从图片提取 512 维 AdaFace 特征向量。
     * 流程:读图 → YuNet检测 → 选最大脸 → 裁剪+resize(112x112) → 归一化 → ONNX推理
     */
    private static float[] extractFeature(FaceDetectorYN detector, OrtSession session,
                                          OrtEnvironment env, String imgPath) throws Exception {
        // 读取图片
        Mat img = imread(imgPath, IMREAD_COLOR);
        if (img.empty()) {
            // 处理中文路径
            byte[] bytes = Files.readAllBytes(new File(imgPath).toPath());
            Mat raw = new Mat(1, bytes.length, CV_8UC1, new BytePointer(bytes));
            img = imdecode(raw, IMREAD_COLOR);
            raw.close();
        }
        System.out.printf("[%s] size=%dx%d%n", new File(imgPath).getName(), img.cols(), img.rows());

        // YuNet 检测
        detector.setInputSize(img.size());
        Mat faces = new Mat();
        detector.detect(img, faces);
        if (faces.rows() <= 0) throw new RuntimeException("未检测到人脸: " + imgPath);

        // 选面积最大的脸(关键!不是置信度最高)
        int bestIdx = 0;
        float bestArea = 0;
        for (int i = 0; i < faces.rows(); i++) {
            FloatBuffer fb = faces.row(i).createBuffer();
            float area = fb.get(2) * fb.get(3);  // width * height
            if (area > bestArea) { bestArea = area; bestIdx = i; }
        }

        // 裁剪人脸区域
        FloatBuffer fb = faces.row(bestIdx).createBuffer();
        int x = Math.max(0, (int) fb.get(0));
        int y = Math.max(0, (int) fb.get(1));
        int w = Math.min((int) fb.get(2), img.cols() - x);
        int h = Math.min((int) fb.get(3), img.rows() - y);

        // Resize 到 112x112
        Mat faceRoi = img.apply(new Rect(x, y, w, h));
        Mat resized = new Mat();
        resize(faceRoi, resized, new Size(112, 112), 0, 0, INTER_LINEAR);

        // ONNX 推理
        float[] embedding = runAdaFace(session, env, resized);

        resized.close(); faceRoi.close(); faces.close(); img.close();
        return embedding;
    }

    /**
     * AdaFace 推理核心:112x112 BGR Mat → 512 维归一化向量。
     *
     * 输入格式:float[1][3][112][112],NHWC→NCHW,归一化到[-1, 1]
     * 输出格式:float[512],L2归一化后的单位向量
     */
    private static float[] runAdaFace(OrtSession session, OrtEnvironment env, Mat bgr112) throws Exception {
        int h = 112, w = 112, c = 3;
        float[][][][] input = new float[1][c][h][w];

        byte[] pixels = new byte[h * w * c];
        bgr112.data().get(pixels);

        for (int row = 0; row < h; row++) {
            for (int col = 0; col < w; col++) {
                int idx = (row * w + col) * c;
                for (int ch = 0; ch < c; ch++) {
                    float px = (float) (pixels[idx + ch] & 0xFF);
                    input[0][ch][row][col] = (px / 127.5f) - 1.0f;  // 归一化到 [-1, 1]
                }
            }
        }

        OnnxTensor tensor = OnnxTensor.createTensor(env, input);
        try (OrtSession.Result result = session.run(
                Collections.singletonMap(session.getInputNames().iterator().next(), tensor))) {
            float[] embedding = ((float[][]) result.get(0).getValue())[0];
            return l2Normalize(embedding);
        } finally {
            tensor.close();
        }
    }

    /** L2 归一化:特征向量缩放到单位球面上,这样 dot product 就等于 cosine */
    private static float[] l2Normalize(float[] vec) {
        float sum = 0;
        for (float v : vec) sum += v * v;
        float norm = (float) Math.sqrt(sum);
        if (norm < 1e-10f) return vec;
        float[] out = new float[vec.length];
        for (int i = 0; i < vec.length; i++) out[i] = vec[i] / norm;
        return out;
    }

    /** 余弦相似度(向量已 L2 归一化,所以直接点积即可) */
    private static double cosineSimilarity(float[] a, float[] b) {
        double dot = 0;
        for (int i = 0; i < a.length; i++) dot += (double) a[i] * b[i];
        return dot;
    }

    /** 从 classpath 提取模型文件到临时目录 */
    private static File extractModel(String resourcePath) throws IOException {
        InputStream in = AdaFaceDemo.class.getClassLoader().getResourceAsStream(resourcePath);
        if (in == null) throw new IOException("模型文件未找到: " + resourcePath);
        Path tmp = Files.createTempFile("adaface-", ".onnx");
        Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
        in.close();
        File f = tmp.toFile();
        f.deleteOnExit();
        return f;
    }
}

运行结果示例:

[a.jpg] size=3672x4692
[b.jpg] size=960x1280

Cosine = 0.4238 (threshold=0.35) → 同一人 ✓

💡 小贴士:如果你运行后 cosine 异常低(<0.15),请检查:

  1. 图片是否是手机竖拍?→ 加 EXIF 旋转处理(见 10.6 节)
  2. 是否检测到多张脸但取错了?→ 确认用的是“面积最大”而非“置信度最高”
  3. 模型文件是否完整?→ adaface_ir18_webface4m.onnx 应为 91.6MB,不是 0.1MB

12. 安全边界(务必明确)

本方案适合: 内网刷脸登录、员工自助登录、养老机构内部弱安全识别、非金融/非支付/非门禁核心场景。

不适合: 支付认证、实名核验、高安全门禁、防照片攻击、强监管身份认证。

根本原因:没有活体检测。别人拿照片、手机屏幕翻拍、打印照可能骗过系统。

若要增强安全性,需引入(已超出 YuNet+SFace 范畴):

眨眼/张嘴/摇头等动作活体
随机动作挑战
红外/深度摄像头
第三方活体检测 SDK

我们项目能用它的前提是:签到非唯一安全凭证,且业务侧已有机构经纬度 + 距离校验做风控兜底。


13. Java 工程师学习路径

建议按此顺序,别一上来钻神经网络原理:

  1. 先不要研究神经网络原理
  2. 理解 ONNX = 已训练好的模型文件
  3. 理解 DNN = 运行模型的执行引擎
  4. 跑通「两张图是否同一人」的 demo(本文第 9 节)
  5. 封装成 Service,管理引擎生命周期与 native 内存
  6. 接入业务(下载图、校验、比对、返回)
  7. 最后做阈值调优、并发、配置化、安全加固

14. 常见问题

Q1:为什么不直接用账号查出人脸再比对? 可以,这正是我们的做法。「先有 seniorId,再比对档案底图」就是 1:1。只有「不输账号直接刷脸」才需要 1:N 检索(用户多时要上向量库)。

Q2:为什么必须 alignCrop? 人脸可能歪头/侧脸/远近不一,alignCrop 用关键点把脸对齐到标准姿态,识别准确率显著提升。跳过对齐精度大幅下降。

Q3:检测到脸就能签到吗? 不能。检测只回答「有没有脸」,识别才回答「是不是这个人」。签到至少两步:检测人脸 → 提特征比对。

Q4:能用在生产吗? 弱安全场景可以,但必须明确边界。作为便利性手段(非唯一凭证)可落地;强身份认证必须加活体、风控、二次认证。

Q5:要不要区分 Windows / Linux 版本? ONNX 模型跨平台通用一份即可。OpenCV native 库需要区分平台,但不需要你在代码里处理——只要 pom.xml 声明了对应 classifier(如 linux-x86_64windows-x86_64),bytedeco Loader 会自动按当前 OS 加载对应平台的 native JAR。生产只部署 Linux,可只声明 linux-x86_64。本地开发要同时跑,加上 windows-x86_64

Q6:jar 太大怎么办? javacv-platform 含全平台 native(增大约 600~700MB)。生产推荐用显式 classifier 方式(见第 4.3 节),仅保留目标平台 + 排除无用模块,体积可从 ~1GB 降到 ~60MB。

Q7:本地能跑,Docker/CI 环境崩了怎么排查? 这是最典型的坑。排查思路:

  1. 看报错是 no jnixxx in java.library.path(native JAR 缺失)还是 libxxx.so: cannot open(系统库缺失)——两者解法完全不同
  2. 本地删掉 .m2/repository/org/bytedeco/ 重新跑,能复现说明是缓存侥幸
  3. 详见第 10.1~10.4 节的完整排查流程

Q8:服务正常但同一人 cosine 极低(<0.2),是模型精度不行吗? 大概率不是模型问题,而是EXIF 旋转没处理。手机竖拍的 JPEG 带有 Orientation=6 标记,imdecode 忽略它导致图片侧倒 90°,人脸特征提取全废。修复后同一人分数会从 0.07 跳到 0.5+。详见 10.6 节。

Q9:Pod 启动正常但第一个请求就 OOM(exit code 137)? OpenCV native 库是延迟加载的——首次推理时才 mmap .so 文件到内存(~350MB)。如果 Pod limit 只留了够 JVM heap 的空间,首次请求就会触发 OOM Kill。解法:limit 要比 heap 多留至少 1.5GB。详见 10.5 节。

Q10:为什么换了 AdaFace 低质量图就能过了? SFace 训练时用固定 margin,对所有样本一视同仁——低质量图的梯度污染了模型,导致它对差图片的特征提取能力崩溃。AdaFace 用特征范数近似图质,动态调整 margin:低质量图受轻罚、先保方向正确,所以即使图很糊,同一人仍能得到 0.35+ 的 cosine(而不是 SFace 的 0.09)。详见 11.2 节。

Q11:AdaFace 需要额外装什么吗? 只需加一个 Maven 依赖 onnxruntime:1.17.0。不需要安装系统库(不像 OpenCV 要 GTK/OpenGL),不需要 Python 环境(只有导出 ONNX 时要用一次),不需要改 Dockerfile。纯 Java + JNI,开箱即用。


15. 参考来源