专栏首页DotNet程序园.NET做人脸识别并分类

.NET做人脸识别并分类

前言

在游乐场、玻璃天桥、滑雪场等娱乐场所,经常能看到有摄影师在拍照片,令这些经营者发愁的一件事就是照片太多了,客户在成千上万张照片中找到自己可不是件容易的事。在一次游玩等活动或家庭聚会也同理,太多了照片导致挑选十分困难。

还好有 .NET,只需少量代码,即可轻松找到人脸并完成分类。

本文将使用 MicrosoftAzure云提供的 认知服务CognitiveServicesAPI来识别并进行人脸分类,可以免费使用,注册地址是:https://portal.azure.com。注册完成后,会得到两个 密钥,通过这个 密钥即可完成本文中的所有代码,这个 密钥长这个样子(非真实密钥):

fa3a7bfd807ccd6b17cf559ad584cbaa

使用方法

首先安装 NuGetMicrosoft.Azure.CognitiveServices.Vision.Face,目前最新版是 2.5.0-preview.1,然后创建一个 FaceClient

string key = "fa3a7bfd807ccd6b17cf559ad584cbaa"; // 替换为你的keyusing var fc = new FaceClient(new ApiKeyServiceClientCredentials(key)){    Endpoint = "https://southeastasia.api.cognitive.microsoft.com",};

然后识别一张照片:

using var file = File.OpenRead(@"C:\Photos\DSC_996ICU.JPG");IList<DetectedFace> faces = await fc.Face.DetectWithStreamAsync(file);

其中返回的 faces是一个 IList结构,很显然一次可以识别出多个人脸,其中一个示例返回结果如下(已转换为 JSON):

[    {      "FaceId": "9997b64e-6e62-4424-88b5-f4780d3767c6",      "RecognitionModel": null,      "FaceRectangle": {        "Width": 174,        "Height": 174,        "Left": 62,        "Top": 559      },      "FaceLandmarks": null,      "FaceAttributes": null    },    {      "FaceId": "8793b251-8cc8-45c5-ab68-e7c9064c4cfd",      "RecognitionModel": null,      "FaceRectangle": {        "Width": 152,        "Height": 152,        "Left": 775,        "Top": 580      },      "FaceLandmarks": null,      "FaceAttributes": null    }  ]

可见,该照片返回了两个 DetectedFace对象,它用 FaceId保存了其 Id,用于后续的识别,用 FaceRectangle保存了其人脸的位置信息,可供对其做进一步操作。 RecognitionModelFaceLandmarksFaceAttributes是一些额外属性,包括识别 性别年龄表情等信息,默认不识别,如下图 API所示,可以通过各种参数配置,非常好玩,有兴趣的可以试试:

最后,通过 .GroupAsync来将之前识别出的多个 faceId进行分类:

var faceIds = faces.Select(x => x.FaceId.Value).ToList();GroupResult reslut = await fc.Face.GroupAsync(faceIds);

返回了一个 GroupResult,其对象定义如下:

public class GroupResult{    public IList<IList<Guid>> Groups    {        get;        set;    }
    public IList<Guid> MessyGroup    {        get;        set;    }
    // ...}

包含了一个 Groups对象和一个 MessyGroup对象,其中 Groups是一个数据的数据,用于存放人脸的分组, MessyGroup用于保存未能找到分组的 FaceId

有了这个,就可以通过一小段简短的代码,将不同的人脸组,分别复制对应的文件夹中:

void CopyGroup(string outputPath, GroupResult result, Dictionary<Guid, (string file, DetectedFace face)> faces){    foreach (var item in result.Groups        .SelectMany((group, index) => group.Select(v => (faceId: v, index)))        .Select(x => (info: faces[x.faceId], i: x.index + 1)).Dump())    {        string dir = Path.Combine(outputPath, item.i.ToString());        Directory.CreateDirectory(dir);        File.Copy(item.info.file, Path.Combine(dir, Path.GetFileName(item.info.file)), overwrite: true);    }
    string messyFolder = Path.Combine(outputPath, "messy");    Directory.CreateDirectory(messyFolder);    foreach (var file in result.MessyGroup.Select(x => faces[x].file).Distinct())    {        File.Copy(file, Path.Combine(messyFolder, Path.GetFileName(file)), overwrite: true);    }}

然后就能得到运行结果,如图,我传入了 102张照片,输出了 15个分组和一个“未找到队友”的分组:

还能有什么问题?

就两个 API调用而已,代码一把梭,感觉太简单了?其实不然,还会有很多问题。

图片太大,需要压缩

毕竟要把图片上传到云服务中,如果上传网速不佳,流量会挺大,而且现在的手机、单反、微单都能轻松达到好几千万像素, jpg大小轻松上 10MB,如果不压缩就上传,一来流量和速度遭不住。

二来……其实 Azure也不支持,文档(https://docs.microsoft.com/en-us/rest/api/cognitiveservices/face/face/detectwithstream)显示,最大仅支持 6MB的图片,且图片大小应不大于 1920x1080的分辨率:

  • JPEG, PNG, GIF (the first frame), and BMP format are supported. The allowed image file size is from 1KB to 6MB.
  • The minimum detectable face size is 36x36 pixels in an image no larger than 1920x1080 pixels. Images with dimensions higher than 1920x1080 pixels will need a proportionally larger minimum face size.

因此,如果图片太大,必须进行一定的压缩(当然如果图片太小,显然也没必要进行压缩了),使用 .NETBitmap,并结合 C# 8.0switchexpression,这个判断逻辑以及压缩代码可以一气呵成:

byte[] CompressImage(string image, int edgeLimit = 1920){    using var bmp = Bitmap.FromFile(image);
    using var resized = (1.0 * Math.Max(bmp.Width, bmp.Height) / edgeLimit) switch    {        var x when x > 1 => new Bitmap(bmp, new Size((int)(bmp.Size.Width / x), (int)(bmp.Size.Height / x))),         _ => bmp,     };
    using var ms = new MemoryStream();    resized.Save(ms, ImageFormat.Jpeg);    return ms.ToArray();}

竖立的照片

相机一般都是 3:2的传感器,拍出来的照片一般都是横向的。但偶尔寻求一些构图的时候,我们也会选择纵向构图。虽然现在许多 API都支持正负 30度的侧脸,但竖着的脸 API基本都是不支持的,如下图(实在找不到可以授权使用照片的模特了?):

还好照片在拍摄后,都会保留 exif信息,只需读取 exif信息并对照片做相应的旋转即可:

void HandleOrientation(Image image, PropertyItem[] propertyItems){    const int exifOrientationId = 0x112;    PropertyItem orientationProp = propertyItems.FirstOrDefault(i => i.Id == exifOrientationId);
    if (orientationProp == null) return;
    int val = BitConverter.ToUInt16(orientationProp.Value, 0);    RotateFlipType rotateFlipType = val switch    {        2 => RotateFlipType.RotateNoneFlipX,         3 => RotateFlipType.Rotate180FlipNone,         4 => RotateFlipType.Rotate180FlipX,         5 => RotateFlipType.Rotate90FlipX,         6 => RotateFlipType.Rotate90FlipNone,         7 => RotateFlipType.Rotate270FlipX,         8 => RotateFlipType.Rotate270FlipNone,         _ => RotateFlipType.RotateNoneFlipNone,     };
    if (rotateFlipType != RotateFlipType.RotateNoneFlipNone)    {        image.RotateFlip(rotateFlipType);    }}

旋转后,我的照片如下:

这样竖拍的照片也能识别出来了。

并行速度

前文说过,一个文件夹可能会有成千上万个文件,一个个上传识别,速度可能慢了点,它的代码可能长这个样子:

Dictionary<Guid, (string file, DetectedFace face)> faces = GetFiles(inFolder)  .Select(file =>   {    byte[] bytes = CompressImage(file);    var result = (file, faces: fc.Face.DetectWithStreamAsync(new MemoryStream(bytes)).GetAwaiter().GetResult());    (result.faces.Count == 0 ? $"{file} not detect any face!!!" : $"{file} detected {result.faces.Count}.").Dump();    return (file, faces: result.faces.ToList());  })  .SelectMany(x => x.faces.Select(face => (x.file, face)))  .ToDictionary(x => x.face.FaceId.Value, x => (file: x.file, face: x.face));

要想把速度变化,可以启用并行上传,有了 C#/ .NETLINQ支持,只需加一行 .AsParallel()即可完成:

Dictionary<Guid, (string file, DetectedFace face)> faces = GetFiles(inFolder)  .AsParallel() // 加的就是这行代码  .Select(file =>   {    byte[] bytes = CompressImage(file);    var result = (file, faces: fc.Face.DetectWithStreamAsync(new MemoryStream(bytes)).GetAwaiter().GetResult());    (result.faces.Count == 0 ? $"{file} not detect any face!!!" : $"{file} detected {result.faces.Count}.").Dump();    return (file, faces: result.faces.ToList());  })  .SelectMany(x => x.faces.Select(face => (x.file, face)))  .ToDictionary(x => x.face.FaceId.Value, x => (file: x.file, face: x.face));

断点续传

也如上文所说,有成千上万张照片,如果一旦网络传输异常,或者打翻了桌子上的咖啡(谁知道呢?)……或者完全一切正常,只是想再做一些其它的分析,所有东西又要重新开始。我们可以加入下载中常说的“断点续传”机制。

其实就是一个缓存,记录每个文件读取的结果,然后下次运行时先从缓存中读取即可,缓存到一个 json文件中:

class Cache<T>{    static string cacheFile = outFolder + @$"\cache-{typeof(T).Name}.json";    Dictionary<string, T> cachingData;
    public Cache()    {        cachingData = File.Exists(cacheFile) switch        {            true => JsonSerializer.Deserialize<Dictionary<string, T>>(File.ReadAllBytes(cacheFile)),            _ => new Dictionary<string, T>()        };    }
    public T GetOrCreate(string key, Func<T> fetchMethod)    {        if (cachingData.TryGetValue(key, out T cachedValue))        {            return cachedValue;        }
        var realValue = fetchMethod();
        lock(this)        {            cachingData[key] = realValue;            File.WriteAllBytes(cacheFile, JsonSerializer.SerializeToUtf8Bytes(cachingData, new JsonSerializerOptions            {                WriteIndented = true,             }));            return realValue;        }    }}

注意代码下方有一个 lock关键字,是为了保证多线程下载时的线程安全。

使用时,只需只需在 Select中添加一行代码即可:

var cache = new Cache<List<DetectedFace>>(); // 重点Dictionary<Guid, (string file, DetectedFace face)> faces = GetFiles(inFolder)  .AsParallel()  .Select(file => (file: file, faces: cache.GetOrCreate(file, () => // 重点  {    byte[] bytes = CompressImage(file);    var result = (file, faces: fc.Face.DetectWithStreamAsync(new MemoryStream(bytes)).GetAwaiter().GetResult());    (result.faces.Count == 0 ? $"{file} not detect any face!!!" : $"{file} detected {result.faces.Count}.").Dump();    return result.faces.ToList();  })))  .SelectMany(x => x.faces.Select(face => (x.file, face)))  .ToDictionary(x => x.face.FaceId.Value, x => (file: x.file, face: x.face));

将人脸框起来

照片太多,如果活动很大,或者合影中有好几十个人,分出来的组,将长这个样子:

完全不知道自己的脸在哪,因此需要将检测到的脸框起来。

注意框起来的过程,也很有技巧,回忆一下,上传时的照片本来就是压缩和旋转过的,因此返回的 DetectedFace对象值,它也是压缩和旋转过的,如果不进行压缩和旋转,找到的脸的位置会完全不正确,因此需要将之前的计算过程重新演算一次:

using var bmp = Bitmap.FromFile(item.info.file);HandleOrientation(bmp, bmp.PropertyItems);using (var g = Graphics.FromImage(bmp)){  using var brush = new SolidBrush(Color.Red);  using var pen = new Pen(brush, 5.0f);  var rect = item.info.face.FaceRectangle;  float scale = Math.Max(1.0f, (float)(1.0 * Math.Max(bmp.Width, bmp.Height) / 1920.0));  g.ScaleTransform(scale, scale);  g.DrawRectangle(pen, new Rectangle(rect.Left, rect.Top, rect.Width, rect.Height));}bmp.Save(Path.Combine(dir, Path.GetFileName(item.info.file)));

使用我上面的那张照片,检测结果如下(有点像相机对焦时人脸识别的感觉):

1000个脸的限制

.GroupAsync方法一次只能检测 1000FaceId,而上次活动 800多张照片中有超过 2000FaceId,因此需要做一些必要的分组。

分组最简单的方法,就是使用 System.Interactive包,它提供了 Rx.NET那样方便快捷的 API(这些 APILINQ中未提供),但又不需要引入 Observable<T>那样重量级的东西,因此使用起来很方便。

这里我使用的是 .Buffer(int)函数,它可以将 IEnumerable<T>按指定的数量(如 1000)进行分组,代码如下:

foreach (var buffer in faces  .Buffer(1000)  .Select((list, groupId) => (list, groupId)){  GroupResult group = await fc.Face.GroupAsync(buffer.list.Select(x => x.Key).ToList());  var folder = outFolder + @"\gid-" + buffer.groupId;  CopyGroup(folder, group, faces);}

总结

文中用到的完整代码,全部上传了到我的博客数据 Github,只要输入图片和 key,即可直接使用和运行: https://github.com/sdcb/blog-data/tree/master/2019/20191122-dotnet-face-detection

本文分享自微信公众号 - DotNet程序园(dotnetblog)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-11-27

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • ef+Npoi导出百万行excel之踩坑记

    最近在做一个需求是导出较大的excel,本文是记录我在做需求过程中遇到的几个问题和解题方法,给大家分享一下,一来可以帮助同样遇到问题的朋友,二呢,各位大神也许有...

    梁规晓
  • Asp.NetCore轻松学-部署到 Linux 进行托管

    上一篇文章介绍了如何将开发好的 Asp.Net Core 应用程序部署到 IIS,且学习了进程内托管和进程外托管的区别;接下来就要说说应用 Asp.Net Co...

    梁规晓
  • Asp.NetCore轻松学-部署到 Linux 进行托管

    上一篇文章介绍了如何将开发好的 Asp.Net Core 应用程序部署到 IIS,且学习了进程内托管和进程外托管的区别;接下来就要说说应用 Asp.Net Co...

    梁规晓
  • 杂篇-从整理文件发起的杂谈[-File-]

    张风捷特烈
  • 过气的00截断

    截断的核心,就是 chr(0)这个字符 先说一下这个字符,这个字符不为空 (Null),也不是空字符 (""),更不是空格。 当程序在输出含有 chr(0)变量...

    安恒网络空间安全讲武堂
  • Java8:当 Lambda 遇上受检异常

    我今天高高兴兴,想写个简单的统计一个项目下有多少行代码的小程序,于是咔咔的写下:

    黄泽杰
  • Java8:当 Lambda 遇上受检异常

    题外话: Files.walk(Path) 在 JDK1.8 时添加,深度优先遍历一个 Path (目录),返回这个目录下所有的Path(目录和文件),通过 S...

    猿天地
  • 企业微信上传 带中文名称的 临时素材资源 报错 44001:empty media data

    错误原因:urllib3的老版本bug,卸载掉 requests,urllib3,从新安装最新版的requests(此包内部依赖urllib3);

    用户1558882
  • Linux shell ${}简单用法

    Linux shell ${}简单用法 [转]http://linux.chinaunix.net/techdoc/develop/2007/05/05/9...

    汤高
  • Cocos Creator开发hello World

    若本号内容有做得不到位的地方(比如:涉及版权或其他问题),请及时联系我们进行整改即可,会在第一时间进行处理。

    达达前端

扫码关注云+社区

领取腾讯云代金券