.NET做人脸识别并分类
发布于 15 天前 作者 yan 42 次浏览

前言

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

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

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

fa3a7bfd807ccd6b17cf559ad584cbaa


# 使用方法

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

string key = "fa3a7bfd807ccd6b17cf559ad584cbaa"; // 替换为你的key 

using 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 保存了其人脸的位置信息,可供对其做进一步操作。  RecognitionModel 、  FaceLandmarks 、  FaceAttributes 是一些额外属性,包括识别  性别 、  年龄 、  表情 等信息,默认不识别,如下图  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 ``` 个分组和一个“未找到队友”的分组: 

![](https://mmbiz.qpic.cn/mmbiz_png/EWbhww8DznzGvkGbQ64TRvxsWiahKAmZVXiaJlRqZtswjKtAQQv75eyhWfshfwg3F2Xtscn3Pw78pic3nZAEx1v2A/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)


# 还能有什么问题?


就两个  ``` 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.




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

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); 

} 

}



旋转后,我的照片如下: 

![](https://mmbiz.qpic.cn/mmbiz_jpg/EWbhww8DznzGvkGbQ64TRvxsWiahKAmZVQ3IraXCunh5rz8AhvjPflfsEcXQATdNxu5AHKcr8HsFfVZ6GFTSpXw/640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)



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

## 并行速度

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

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#.NET 的  LINQ 支持,只需加一行  .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));


## 将人脸框起来

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

![](https://mmbiz.qpic.cn/mmbiz_png/EWbhww8DznzGvkGbQ64TRvxsWiahKAmZVHejjCODWhFzTfB1xCjowReib9VDtlROMV01zFlSsDiaox8miaL6cY4dpg/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)



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


注意框起来的过程,也 **很有技巧** ,回忆一下,上传时的照片本来就是压缩和旋转过的,因此返回的  ``` 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 方法一次只能检测  1000 个  FaceId ,而上次活动  800 多张照片中有超过  2000 个  FaceId ,因此需要做一些必要的分组。

分组最简单的方法,就是使用  System.Interactive 包,它提供了  Rx.NET 那样方便快捷的  API (这些  API 在  LINQ 中未提供),但又不需要引入  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

这个月我参加了上海的  ``` .NETConf ``` ,我上述代码对  ``` .NETConf ``` 的  ``` 800 ``` 多张照片做了分组,识别出了  ``` 2000 ``` 多张人脸,我将其中我的照片的前三张找出来,结果如下: 

![](https://mmbiz.qpic.cn/mmbiz_png/EWbhww8DznzGvkGbQ64TRvxsWiahKAmZV2CCsUFr095mAvicvdxj1Vewm8aWb7VliaPIjoxqWucRJdMIP8icDJricAw/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)



![](https://mmbiz.qpic.cn/mmbiz_png/EWbhww8DznzGvkGbQ64TRvxsWiahKAmZVRQKfJLJXVGBM8J6kc6EOFdSCPJUwRRFB4icqStwp8sl9vlLapD8D69w/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)



![](https://mmbiz.qpic.cn/mmbiz_png/EWbhww8DznzGvkGbQ64TRvxsWiahKAmZVtOrzAj2XctCEy9yvWwiaArd5w4iavrTnY8vXncpJ6Zy7Vo6Tibyd7ibu7w/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)



 ......


总的来说,这个效果还挺不错,渣渣分辨率的照片的脸都被它找到了😂。

注意,不一定非得用  ``` AzureCognitiveServices ``` 来做人脸识别,国内还有阿里云等厂商也提供了人脸识别等服务,并提供了  ``` .NET ``` 接口,无非就是调用  ``` API ``` ,注意其限制,代码总体差不多。

另外,如有离线人脸识别需求,  ``` Luxand ``` 提供了还有离线版人脸识别  ``` SDK ``` ,名叫  ``` LuxandFaceSDK ``` ,同样提供了  ``` .NET ``` 接口。因为无需网络调用,其识别更快,匹配速度更是可达每秒 **5千万** 个人脸数据,精度也非常高,亲测好用,目前最新版是  ``` v7.1.0 ``` ,授权昂贵(但百度有惊喜)。

微信不能留言,有想法的朋友,欢迎前往我的博客园进行评论、点赞:https://www.cnblogs.com/sdflysha/p/20191122-dotnet-face-detection.html

![](https://mmbiz.qpic.cn/mmbiz_jpg/gak2lhVxV6Ll3Rjypick8DKRBSUpPIgFyCxeb5deosVPTBP2DJO7FENibZQVoweibm12hN3icfjxz4TVrPoZCpUVWA/640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
回到顶部