ASP.NET Core + FaceRecognitionDotNet 人脸识别实战:DI 单例、特征向量持久化与 1:N 比对

ASP.NET Core + FaceRecognitionDotNet 人脸识别实战:DI 单例、特征向量持久化与 1:N 比对

关键词:.NET 8 / FaceRecognitionDotNet / dlib / 128 维特征 / 反射 / 依赖注入

适用场景:中小型系统里的人脸录入与 1:N 身份识别(考勤、门禁、签到、人员核验)


一、需求背景

业务上需要一个很朴素的能力:

  1. 录入(Enroll):上传一张人脸照片 + 姓名,把人脸"存下来";
  2. 识别(Recognize):再传一张照片,告诉我是"库里的谁",顺便给个置信度。

听起来简单,但真做起来会遇到三个坑:

  • 模型加载极慢,绝不能每次请求都 Create 一次;
  • dlib 的人脸特征对象 FaceEncoding 没有公开构造函数,存进数据库之后不知道怎么还原;
  • 缓存 / 数据库里存什么格式、阈值定多少、并发怎么控。

下面这套实现全部踩过一遍,直接给你能跑的版本。


二、技术选型与原理

FaceRecognitionDotNet 是 dlib 的 .NET 封装,识别流程和 Python 的 face_recognition 完全一致:
#mermaid-svg-39TTwy9FO8CCvkIh{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-39TTwy9FO8CCvkIh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-39TTwy9FO8CCvkIh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-39TTwy9FO8CCvkIh .error-icon{fill:#552222;}#mermaid-svg-39TTwy9FO8CCvkIh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-39TTwy9FO8CCvkIh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-39TTwy9FO8CCvkIh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-39TTwy9FO8CCvkIh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-39TTwy9FO8CCvkIh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-39TTwy9FO8CCvkIh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-39TTwy9FO8CCvkIh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-39TTwy9FO8CCvkIh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-39TTwy9FO8CCvkIh .marker.cross{stroke:#333333;}#mermaid-svg-39TTwy9FO8CCvkIh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-39TTwy9FO8CCvkIh p{margin:0;}#mermaid-svg-39TTwy9FO8CCvkIh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-39TTwy9FO8CCvkIh .cluster-label text{fill:#333;}#mermaid-svg-39TTwy9FO8CCvkIh .cluster-label span{color:#333;}#mermaid-svg-39TTwy9FO8CCvkIh .cluster-label span p{background-color:transparent;}#mermaid-svg-39TTwy9FO8CCvkIh .label text,#mermaid-svg-39TTwy9FO8CCvkIh span{fill:#333;color:#333;}#mermaid-svg-39TTwy9FO8CCvkIh .node rect,#mermaid-svg-39TTwy9FO8CCvkIh .node circle,#mermaid-svg-39TTwy9FO8CCvkIh .node ellipse,#mermaid-svg-39TTwy9FO8CCvkIh .node polygon,#mermaid-svg-39TTwy9FO8CCvkIh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-39TTwy9FO8CCvkIh .rough-node .label text,#mermaid-svg-39TTwy9FO8CCvkIh .node .label text,#mermaid-svg-39TTwy9FO8CCvkIh .image-shape .label,#mermaid-svg-39TTwy9FO8CCvkIh .icon-shape .label{text-anchor:middle;}#mermaid-svg-39TTwy9FO8CCvkIh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-39TTwy9FO8CCvkIh .rough-node .label,#mermaid-svg-39TTwy9FO8CCvkIh .node .label,#mermaid-svg-39TTwy9FO8CCvkIh .image-shape .label,#mermaid-svg-39TTwy9FO8CCvkIh .icon-shape .label{text-align:center;}#mermaid-svg-39TTwy9FO8CCvkIh .node.clickable{cursor:pointer;}#mermaid-svg-39TTwy9FO8CCvkIh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-39TTwy9FO8CCvkIh .arrowheadPath{fill:#333333;}#mermaid-svg-39TTwy9FO8CCvkIh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-39TTwy9FO8CCvkIh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-39TTwy9FO8CCvkIh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-39TTwy9FO8CCvkIh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-39TTwy9FO8CCvkIh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-39TTwy9FO8CCvkIh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-39TTwy9FO8CCvkIh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-39TTwy9FO8CCvkIh .cluster text{fill:#333;}#mermaid-svg-39TTwy9FO8CCvkIh .cluster span{color:#333;}#mermaid-svg-39TTwy9FO8CCvkIh 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-39TTwy9FO8CCvkIh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-39TTwy9FO8CCvkIh rect.text{fill:none;stroke-width:0;}#mermaid-svg-39TTwy9FO8CCvkIh .icon-shape,#mermaid-svg-39TTwy9FO8CCvkIh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-39TTwy9FO8CCvkIh .icon-shape p,#mermaid-svg-39TTwy9FO8CCvkIh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-39TTwy9FO8CCvkIh .icon-shape .label rect,#mermaid-svg-39TTwy9FO8CCvkIh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-39TTwy9FO8CCvkIh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-39TTwy9FO8CCvkIh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-39TTwy9FO8CCvkIh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
图片 Bitmap
FaceLocations

HOG/CNN 人脸检测
FaceEncodings

68 关键点对齐 + ResNet
128 维 double 向量
欧氏距离比对
距离 < 阈值?
命中该人员
未知人员

核心结论:一张人脸 = 一个 128 维向量 。识别不是"图像相似度",而是"向量空间里的欧氏距离"。距离越小越像,dlib 官方建议阈值 0.6。

所以持久化时我们根本不需要存图片,只存这 128 个 double 就够了------这也是本方案能做到毫秒级比对的原因。


三、环境准备

3.1 NuGet 包

xml 复制代码
<PackageReference Include="FaceRecognitionDotNet" Version="1.0.7.7" />
<PackageReference Include="DlibDotNet" Version="19.21.0.2" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />

DlibDotNet 的 native 运行库(DlibDotNetNative.dll 等)会随包一起还原,Windows x64 开箱可用。

3.2 下载 dlib 模型文件

去 http://dlib.net/files/ 下载并放到项目的 models 目录(4 个文件一个都不能少):

文件名 用途
shape_predictor_5_face_landmarks.dat 5 点关键点(快速对齐)
shape_predictor_68_face_landmarks.dat 68 点关键点(主用)
mmod_human_face_detector.dat CNN 人脸检测器
dlib_face_recognition_resnet_model_v1.dat ResNet 特征提取(输出 128 维)

在 .csproj 里配置"复制到输出目录",否则运行时上班目录(bin/Debug/net8.0/models)是空的:

xml 复制代码
<ItemGroup>
  <None Update="models\**\*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

四、第一步:注册为单例(关键)

csharp 复制代码
// 注意:FaceRecognition 创建时很消耗资源,并且需要加载模型,必须注册为单例 (Singleton)
builder.Services.AddSingleton<FaceRecognition>(sp =>
{
    // 确保 "models" 文件夹存在于程序的运行目录(bin/Debug/net8.0)下
    // 如果路径不对,Create 方法会抛出异常,导致 DI 失败
    var modelPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "models");
    return FaceRecognition.Create(modelPath);
});

为什么必须是 Singleton?

FaceRecognition.Create() 内部会做两件事:

  1. 把 .dat 模型文件反序列化成 dlib 的 C++ 对象(ShapePredictor、Anet、ResNet 权重);
  2. 建立 native 内存句柄。

这个过程是 秒级 的,而且占用几十到上百 MB 内存。如果注册成 Scoped(ASP.NET Core 里最容易犯的错),每个 HTTP 请求都会重新加载一遍模型------轻则接口 3 秒起步,重则内存直接打满、OutOfMemoryException。

顺手再强调两个点:

  • FaceRecognition 是线程安全的(内部无可变共享状态),单例可以并发调用;
  • 但它 CPU 密集 ,高并发下建议用 SemaphoreSlim 限流(见第九节),别让线程池被拖死。

五、第二步:定义人员实体与存储格式

csharp 复制代码
// 人员实体
public class Person
{
    public string Id { get; set; }
    public string Name { get; set; }
    // 存储 128 维特征向量的 JSON 序列化字符串
    public string FaceEncodingJson { get; set; }
}

FaceEncodingJson 存的内容长这样,就是一段纯数组:

json 复制代码
[0.123,-0.0456,0.0789, ... ,0.0012]

别存图片、别存 Base64、别存 byte\[\]。128 × 8 字节 = 1KB,一个人的特征占 1KB 左右,10000 人也才 10MB,随便放哪儿都行。


六、第三步:录入(Enroll)------把 FaceEncoding 变成可存储的 JSON

csharp 复制代码
public async Task<APIResult> Enroll(RecognizeRequest request)
{
    // 1. 处理图片(去掉 data:image/jpg;base64, 前缀后转 Bitmap)
    var base64Data = ImgHelper.GetBase64String(request.ImageBase64);
    byte[] imageBytes = Convert.FromBase64String(base64Data);

    using var ms = new MemoryStream(imageBytes);
    using var bitmap = new Bitmap(ms);

    // 包一层 dlib 的 Image,注意 using(native 资源)
    using var image = FaceRecognition.LoadImage(bitmap);

    // 2. 找脸
    var locations = _faceRecognition.FaceLocations(image).ToArray();
    if (locations.Length == 0) return APIResult.Error("没找到脸");

    // 3. 提取特征
    var encodings = _faceRecognition.FaceEncodings(image, locations).ToArray();
    using var targetEncoding = encodings[0];

    // 4. ★★★ 关键所在:生成 FaceEncodingJson ★★★
    // 先拿到 128 维数组
    double[] rawData = targetEncoding.GetRawEncoding();
    // 把数组序列化成 JSON 字符串,例如变成 "[0.123, 0.456, 0.789, ...]"
    string jsonString = JsonSerializer.Serialize(rawData);

    // 5. 存入缓存 / 数据库(此时 FaceEncodingJson 字段就有值了)
    var person = new Person
    {
        Id = Guid.NewGuid().ToString("N"), // ← 注意:一定要生成 Id,否则识别结果里 personId 永远是 null
        Name = request.Name,               // 比如 "张三"
        FaceEncodingJson = jsonString      // 这里存的就是上面生成的字符串
    };

    var list = await App.Cache.GetAsync<List<Person>>("rlsb") ?? new List<Person>();
    list.Add(person);
    await App.Cache.SetAsync("rlsb", list, TimeSpan.FromDays(1));

    // _dbContext.Persons.Add(person);
    // _dbContext.SaveChanges();

    return APIResult.Success("录入成功");
}

这里唯一的"知识点"就是 GetRawEncoding():它是 FaceEncoding 暴露出来的唯一一个导出接口 ,把内部 Matrix<double> 拉平成 double[128]。

如果你的 FaceRecognitionDotNet 版本比较老、没有这个方法,可以反射读私有字段(字段名在不同版本里可能是 _Matrix 或 Matrix):

csharp 复制代码
var field = typeof(FaceEncoding).GetField("_Matrix", BindingFlags.NonPublic | BindingFlags.Instance)
         ?? typeof(FaceEncoding).GetField("Matrix",  BindingFlags.NonPublic | BindingFlags.Instance);
var matrix = (Matrix<double>)field.GetValue(targetEncoding);
double[] rawData = matrix.ToArray();

七、第四步:识别(Recognize)------用反射重建 FaceEncoding

这才是整篇文章最"脏"也最有价值的部分。

7.1 为什么必须反射?

FaceEncoding 这个类型的设计是只出不进:

  • 公开 API 里只有 FaceEncodings(image, locations) 能从图片造出它;
  • 没有任何 new FaceEncoding(double[]) 或 FromArray() 之类的入口;
  • 它的构造函数是 internal 的:internal FaceEncoding(Matrix<double> matrix)。

也就是说,你想把落库的 128 维数组还原成 FaceEncoding 对象,再用官方的 FaceDistance() 比对,反射是唯一的路。

7.2 反射重建代码

csharp 复制代码
/// <summary>
/// 通过反射,从 DlibDotNet 的 Matrix 重建 FaceEncoding
/// </summary>
public FaceEncoding CreateFaceEncodingByReflection(Matrix<double> matrix)
{
    // 1. 获取 FaceEncoding 的类型
    var faceEncodingType = typeof(FaceEncoding);

    // 2. 查找那个接受 Matrix<double> 的私有构造函数
    var constructor = faceEncodingType.GetConstructor(
        BindingFlags.NonPublic | BindingFlags.Instance,
        null,
        new[] { typeof(Matrix<double>) },
        null);

    if (constructor == null)
    {
        throw new MissingMethodException("未找到 FaceEncoding 的私有构造函数。");
    }

    // 3. 调用构造函数,创建实例
    return (FaceEncoding)constructor.Invoke(new object[] { matrix });
}

7.3 1:N 比对主流程

csharp 复制代码
public async Task<APIResult> Recognize(RecognizeRequest request)
{
    // 1. 图片预处理
    var base64Data = ImgHelper.GetBase64String(request.ImageBase64);
    byte[] imageBytes = Convert.FromBase64String(base64Data);

    using var ms = new MemoryStream(imageBytes);
    using var bitmap = new Bitmap(ms);
    using var image = FaceRecognition.LoadImage(bitmap);

    // 2. 检测人脸并获取特征编码(128 维向量)
    var faceLocations = _faceRecognition.FaceLocations(image).ToArray();
    if (faceLocations.Length == 0)
        return APIResult.Error("照片中未检测到人脸");
    if (faceLocations.Length > 1)
        return APIResult.Error("照片中检测到多个人脸,请确保只传一人");

    // 3. 提取传进来的人脸特征
    var targetEncodings = _faceRecognition.FaceEncodings(image, faceLocations).ToArray();
    var targetEncoding = targetEncodings.FirstOrDefault();
    if (targetEncoding == null)
        return APIResult.Error("无法提取人脸特征");

    // 4. 从数据库 / 缓存中获取系统中所有人脸特征
    var allPersons = await App.Cache.GetAsync<List<Person>>("rlsb") ?? new List<Person>();

    // 5. 遍历比对,计算相似度
    double bestDistance = double.MaxValue;
    Person bestMatchPerson = null;

    // 常用阈值:0.6(dlib 官方);0.45 更严格,误识率更低
    const double Tolerance = 0.45;

    foreach (var person in allPersons)
    {
        if (string.IsNullOrWhiteSpace(person.FaceEncodingJson)) continue;

        // 反序列化并比对
        double[] rawData = JsonSerializer.Deserialize<double[]>(person.FaceEncodingJson);
        if (rawData is null || rawData.Length != 128) continue;

        // 包装成 Matrix,注意:不要把 matrix 用 using 包起来(所有权已转移给 FaceEncoding)
        var matrix = new Matrix<double>(rawData, rawData.Length, 1);
        using var knownEncoding = CreateFaceEncodingByReflection(matrix);

        // 计算两张人脸的欧氏距离
        var distance = FaceRecognition.FaceDistance(targetEncoding, knownEncoding);

        if (distance < Tolerance && distance < bestDistance)
        {
            bestDistance = distance;
            bestMatchPerson = person;
        }
    }

    // 6. 返回结果
    if (bestMatchPerson != null)
    {
        // 距离越小,置信度越高(简单线性映射,仅用于展示)
        double confidence = Math.Round(1.0 - (bestDistance / Tolerance), 2);

        return new APIResult
        {
            data = new
            {
                isSuccess = true,
                personId = bestMatchPerson.Id,
                personName = bestMatchPerson.Name,
                confidence,
                message = "识别成功"
            }
        };
    }

    return APIResult.Error("未匹配到系统中的人员");
}

八、踩坑清单(这一节最值钱)

坑 1:Matrix<double> 不能用 using

csharp 复制代码
// ❌ 错误:matrix 会被提前释放,FaceEncoding 内部持有的是野指针
using var matrix = new Matrix<double>(rawData, rawData.Length, 1);
using var encoding = CreateFaceEncodingByReflection(matrix);

// ✅ 正确:所有权转移给 FaceEncoding,由它统一释放
var matrix = new Matrix<double>(rawData, rawData.Length, 1);
using var encoding = CreateFaceEncodingByReflection(matrix);

FaceEncoding 的构造函数会接管 Matrix 的所有权,FaceEncoding.Dispose() 内部会连带释放 Matrix。如果你再 using 一次 Matrix,就是 double free ,表现为随机崩溃或 AccessViolationException------这种 bug 极难排查。

坑 2:Enroll 忘了给 Id 赋值

原版代码里 Person 只赋了 Name 和 FaceEncodingJson,Id 是 null。结果就是识别明明成功了,前端拿到的 personId 却是 null,业务层直接炸。录入时必须 Id = Guid.NewGuid().ToString("N")。

坑 3:阈值 0.45 和 0.6 的取舍

阈值 误识率 FAR 拒识率 FRR 适用场景
0.6 偏高 低 相册聚类、宽松查找
0.5 中 中 大多数业务(推荐起点)
0.45 低 偏高 门禁、考勤、身份核验(宁拒不错)

不要迷信单一阈值。正确做法是拿你自己业务的照片(不同光照、角度、眼镜、口罩)跑一批样本,画 FAR/FRR 曲线再定。1:N 场景下 N 越大,越要往严了调。

坑 4:System.Drawing.Bitmap 的跨平台限制

System.Drawing.Common 在 .NET 6+ 已 仅支持 Windows 。部署到 Linux 容器会抛 PlatformNotSupportedException。Linux 上的替代方案:

csharp 复制代码
// 用 ImageSharp 解码,再交给 dlib
using var image = SixLabors.ImageSharp.Image.Load<Rgb24>(imageBytes);
using var bmp = new Bitmap(image.Width, image.Height, PixelFormat.Format24bppRgb);
// ... 逐像素拷贝(或用 FaceRecognition.LoadImage(byte[]) 重载)

坑 5:缓存版"数据库"的三个隐患

示例里用 App.Cache 存人员列表,上线前请务必替换:

隐患 后果 解法
TimeSpan.FromDays(1) 过期 第二天所有人脸数据凭空消失 落库(SQLite / MySQL / PostgreSQL)
读-改-写无锁 并发录入互相覆盖,丢数据 分布式锁 / 数据库事务
多实例部署 各节点缓存不一致 集中存储 + 本地只读缓存

坑 6:FaceLocations 的第二个参数

csharp 复制代码
_faceRecognition.FaceLocations(image)              // 默认 upsample = 1
_faceRecognition.FaceLocations(image, 2)           // 远处的小脸能检测到,但慢 3~4 倍
_faceRecognition.FaceLocations(image, 0)           // 大脸场景,最快

默认 HOG 检测器对 小于 80×80 像素 的人脸基本无能为力。如果业务是"手机自拍/证件照",默认值够用;如果是"监控画面远端人脸",就得加大 upsample 或改用 FaceDetectorModels.Cnn。


九、生产化改造建议

9.1 更优雅的方案:根本不需要反射

说到底,FaceDistance 干的事情就是两向量欧氏距离:

csharp 复制代码
private static double EuclideanDistance(double[] a, double[] b)
{
    if (a.Length != b.Length) throw new ArgumentException("特征维度不一致");
    double sum = 0;
    for (int i = 0; i < a.Length; i++)
    {
        double d = a[i] - b[i];
        sum += d * d;
    }
    return Math.Sqrt(sum);
}

那么整个识别流程可以简化成:只保留 query 一张图的 FaceEncoding,库里所有人都直接拿 double[] 算距离。

收益非常明显:

  • 彻底摆脱反射(不再受内部构造函数改名、字段改名影响);
  • 省掉每次循环 new 一个 Matrix + FaceEncoding 的 native 分配与释放,1:N 场景下性能提升 5~10 倍;
  • 代码可测试、可移植,甚至能把比对丢到 SIMD / GPU 或向量数据库里。
csharp 复制代码
var queryRaw = targetEncoding.GetRawEncoding();   // 只算一次

foreach (var person in allPersons)
{
    var raw = JsonSerializer.Deserialize<double[]>(person.FaceEncodingJson);
    var distance = EuclideanDistance(queryRaw, raw);
    // ...
}

结论:反射方案适合"我想用官方 API 保证行为一致",纯数组方案适合"我要性能和稳定"。两者结果数值上完全一致(同一套浮点运算),可以放心替换。

9.2 并发限流

dlib 是纯 CPU 计算,10 个并发请求就能把 8 核机器打满,然后整站接口一起变慢。用一个全局信号量兜住:

csharp 复制代码
private static readonly SemaphoreSlim Gate = new(Environment.ProcessorCount);

public async Task<APIResult> Recognize(RecognizeRequest request)
{
    await Gate.WaitAsync();
    try
    {
        // 上面的识别逻辑
    }
    finally
    {
        Gate.Release();
    }
}

9.3 特征缓存到内存

如果人员库不大(< 5000)且变更不频繁,可以把 double[] 在启动时一次性加载进 ConcurrentDictionary<string, double[]>,比对时零反序列化开销。

9.4 数据量上来之后

线性扫全库是 O(N)。N 超过 1 万以后,单次识别要几百毫秒。此时应该上 向量索引:

  • pgvector(PostgreSQL 扩展,最省事)
  • Milvus / Qdrant(专业向量库)
  • Faiss(本地索引,适合单机)

9.5 别忘了安全和合规

  • 人脸是生物识别信息 ,属于《个人信息保护法》里的敏感个人信息,必须单独告知 + 单独同意;
  • 建议只存特征向量、不存原图;特征不可逆,泄露风险远低于照片;
  • 线上一定要加 活体检测(眨眼/张嘴/动作指令),否则一张打印照片就能骗过整套系统。

十、完整接口定义

csharp 复制代码
public interface IAgentAIService
{
    Task<APIResult> Enroll(RecognizeRequest request);
    Task<APIResult> Recognize(RecognizeRequest request);
}

public class RecognizeRequest
{
    /// <summary>带前缀的 Base64,如 data:image/jpeg;base64,/9j/4AAQ...</summary>
    public string ImageBase64 { get; set; }
    /// <summary>录入时使用</summary>
    public string Name { get; set; }
}

/// <summary>
/// 获取真正的图片 base64 数据
/// 即去掉 data:image/jpg;base64, 这样的格式
/// </summary>
public static class ImgHelper
{
    public static string GetBase64String(string base64UrlStr)
    {
        const string pattern = "^(data:image/.*?;base64,).*?$";
        var match = Regex.Match(base64UrlStr, pattern);
        if (match.Groups.Count > 1)
            base64UrlStr = base64UrlStr.Replace(match.Groups[1].ToString(), "");
        return base64UrlStr;
    }
}

关于 GetBase64String 有个小建议:match.Groups.Count > 1 这个判断在没有匹配 时也是 1(只有 Group 0),所以逻辑是对的;但更稳的写法是判断 match.Success:

csharp 复制代码
if (match.Success) base64UrlStr = base64UrlStr[match.Groups[1].Length..];

Substring 比 Replace 安全------万一 Base64 正文里恰好出现同样的前缀字符串,Replace 会把正文也削掉。


十一、总结

要点 一句话
DI 注册 必须 Singleton,模型加载是秒级 + 上百 MB
持久化 只存 128 维 double[] 的 JSON,1KB/人
还原 FaceEncoding 构造函数是 internal,只能反射
更优解 自己写欧氏距离,绕过反射,性能提升数倍
阈值 门禁类业务 0.45,检索类 0.6,别拍脑袋定
释放 Matrix 交给 FaceEncoding 管,千万别重复 using
合规 人脸是敏感个人信息,存特征不存图,上线配活体

整套逻辑不到 200 行,真正难的不是"调用库",而是上面这七个细节。希望对正在做同类需求的同学少踩两个坑。

有问题欢迎评论区交流,尤其是阈值调参 和跨平台部署这两块,踩过的坑最多。

相关推荐
captain3764 小时前
MyBatis操作数据库(入门2)
java·数据库·spring boot·后端·spring·mybatis
IT_陈寒5 小时前
Java序列化踩坑记:对象循环引用让我加班两小时
前端·人工智能·后端
泡海椒5 小时前
05-雷达图多维画像-信用评级多维画像
后端
王中阳Go5 小时前
5.27 分钟干完后端和全部测试:一场三小时笔试的五个失分点,四个不在代码上
大数据·数据库·人工智能·后端·大语言模型
大哥43095 小时前
Modbus 的"字序":一个配错就变成天文数字,或者干脆变成 0
后端
岩钧5 小时前
New API 源码解析:一个生产级 AI 网关的架构与实现
后端
学到老活到老5 小时前
第 24 集:配置文件写了 80,Spring Boot 为什么读到 90?
java·后端·spring
用户360055579005 小时前
Kestrel_VLLM - 25 · 全注意力层设备化、整层对拍与访存粒度
后端
yliucherish5 小时前
从零实现一个 SQL 注入检测引擎:报错注入与布尔盲注的误报控制
后端