前言
在游乐场、玻璃天桥、滑雪场等娱乐场所,经常能看到有摄影师在拍照片,令这些经营者发愁的一件事就是照片太多了,客户在成千上万张照片中找到自己可不是件容易的事。在一次游玩等活动或家庭聚会也同理,太多了照片导致挑选十分困难。
还好有 .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
个分组和一个“未找到队友”的分组:
还能有什么问题?
就两个 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);
}
}
旋转后,我的照片如下:
这样竖拍的照片也能识别出来了。
并行速度
前文说过,一个文件夹可能会有成千上万个文件,一个个上传识别,速度可能慢了点,它的代码可能长这个样子:
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));
将人脸框起来
照片太多,如果活动很大,或者合影中有好几十个人,分出来的组,将长这个样子:
完全不知道自己的脸在哪,因此需要将检测到的脸框起来。
注意框起来的过程,也很有技巧,回忆一下,上传时的照片本来就是压缩和旋转过的,因此返回的 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
多张人脸,我将其中我的照片的前三张找出来,结果如下:
......
总的来说,这个效果还挺不错,渣渣分辨率的照片的脸都被它找到了????。
注意,不一定非得用 AzureCognitiveServices
来做人脸识别,国内还有阿里云等厂商也提供了人脸识别等服务,并提供了 .NET
接口,无非就是调用 API
,注意其限制,代码总体差不多。
另外,如有离线人脸识别需求, Luxand
提供了还有离线版人脸识别 SDK
,名叫 LuxandFaceSDK
,同样提供了 .NET
接口。因为无需网络调用,其识别更快,匹配速度更是可达每秒5千万个人脸数据,精度也非常高,亲测好用,目前最新版是 v7.1.0
,授权昂贵(但百度有惊喜)。
微信不能留言,有想法的朋友,欢迎前往我的博客园进行评论、点赞:https://www.cnblogs.com/sdflysha/p/20191122-dotnet-face-detection.html