65.9K
CodeProject 正在变化。 阅读更多。
Home

3D 人脸查看器和匹配器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (139投票s)

2017年4月24日

CPOL

32分钟阅读

viewsIcon

183177

downloadIcon

17251

一个工具,只需一张面部照片即可生成不同角度和光照下的面部图像和动画 GIF 文件。还包括使用 Microsoft Face API 的面部匹配器。

 

版本 3.0 的最新源代码也可以从 https://github.com/kwyangyh/ThreeDFace 下载。

背景

面部识别是目前最受欢迎的生物识别方法之一。面部捕捉就像给一个人拍照一样简单。然而,要使面部照片对面部识别有用,必须满足某些面部图像规格。

  • 面部必须是正面视图。
  • 图像不得被不均匀地拉伸。
  • 面部应均匀照明。
  • 受试者必须处于中性表情状态,眼睛睁开,嘴巴闭合。

符合这些规格的照片才有资格作为合适的注册照片。对于自动面部识别系统,这些照片将是面部特征文件生成的来源。特征文件大多是特定于源照片的,并存储可用于比较的面部特征摘要。

当两个特征文件匹配时,源照片和捕捉照片的主体是同一个人的可能性很高。

面部识别系统面临的主要挑战之一是难以获得用于匹配的良好正面图像。摄像机应放置得当,以便获得受试者的完整正面视图。然而,人们的身高各不相同,并且他们也可能倾向于稍微偏离正面看摄像头。摄像头可能稍微侧视、俯视、仰视或处于某个角度,导致捕捉到的照片不是理想的正面照。

根据面部识别系统,在生成特征文件之前,系统可以进行内部自动校正,但这会影响匹配分数。

另一个问题是照明。通常,大多数注册照片质量都相当不错,因为它们是在受控环境中拍摄的,例如在专用照相亭中。用于匹配的照片在大多数情况下是在可能出现照明条件变化的环境中拍摄的,例如靠近窗户的面部门禁单元

为了测试面部识别系统的准确性和可靠性,需要一些测试用例,其中受试者在各种角度和光照条件下拍摄面部。这些图像将用于与同一受试者的受控图像进行测试。这是一个耗时且繁琐的过程,需要专用测试对象的积极参与,或者,我们需要从实时系统中捕捉图像。

本文的灵感源于创建可以从单张面部图像重现、改变光照条件和相机角度的测试用例。

技术

即使有先进的商业工具,创建逼真的3D 面部模型也并非易事。

随着Kinect X-Box One 的发布,我们找到了恰当的技术,可以毫不费力地创建逼真的3D 面部模型Kinect 2.0 传感器是一款高度先进的设备。它有一个 1920X1080 的全高清分辨率摄像头,可捕捉质量相当不错的图像。还有一个红外深度传感器,可输出 512X424 分辨率的深度图像。此深度传感器的深度信息可能是目前市面上最好的。基本上,这些只是来自每个摄像头/传感器的原始帧(每秒 30 帧)。然而,还有其他计算好的帧(也以每秒 30 帧的速度)。这些是身体索引身体基本面部高清面部帧。

HDFace 帧尤其令人感兴趣。这些是跟踪到的面部的3D 世界坐标。每个面部有1347 个顶点。 3 个点连接起来就构成了 3D 三角形表面。Kinect 2.0 使用一组标准的三角形索引引用,共2630 个三角形。有了2630 个表面,面部模型就确实逼真了。

HDFace 帧创建的3D 模型可以通过 .NET System.Windows.Media.Media3D 类进行渲染,使用 MeshGeometry3D 进行建模,使用 Viewport3D 在 WPF 窗口中进行查看,使用 PerspectiveCameraAmbientLightDirectionalLight 在各种光照和视角下渲染模型。

AForge .NET 类提供了用于处理源图像、改变亮度和对比度的过滤器。

OpenCV 提供了 HaarClassifier 用于从输入图像中查找面部和眼睛。

System.Drawing .NET 类用于 GDI+ 图像操作,例如拉伸和旋转。

System.Windows.Media 类用于 WPF 窗口中的呈现。

网格文件

GeometryModel3D 类用于 3D 图像处理,包含 2 个基本组件

  • GeometryModel3D.Geometry
  • GeometryModel3D.Material

GeometryModel3D.Geometry 类需要定义以下信息

  • 职位
  • TriangleIndices
  • TextureCoordinates

Position3D 世界坐标中的顶点定义。每个顶点通过其在Position 顶点集合中的顺序进行引用。例如,要定义一个立方体,我们需要 8 个顶点,分别位于立方体的每个角。我们将坐标输入到Position 集合的顺序很重要。

Positions="-0.05,-0.1,0 0.05,-0.1,0 -0.05,0,0 0.05,0,0 -0.05,-0.1,
           -0.1 0.05,-0.1,-0.1 -0.05,0,-0.1 0.05,0,-0.1"

在上面的示例中,-0.05,-0.1,0 是第一个顶点,索引为 0

TriangleIndices 指的是以 3 个为一组的索引列表,这些索引定义了3D 模型中的每个表面。

TriangleIndices="0,1,2 1,3,2 0,2,4 2,6,4 2,3,6 3,7,6 3,1,5 3,5,7 0,5,1 0,4,5 6,5,4 6,7,5"/>

在上面的示例中,我们定义了 16 个三角形,每个三角形有 3 个顶点,总共定义了立方体的所有表面。前 3 个索引 0,1,2 指的是索引为 012Position 顶点。这将构成3D 模型中的一个表面。

为了渲染表面,我们可以使用 GeometryModel3D.Material 类定义一个画笔brush。但是画笔brush需要知道什么纹理/颜色应用到表面上。这就是 TextureCoordinate 的作用。

 TextureCoordinates="1,1  0,0  0,0 1,1 1,1  0,0  0,0  1,1"

上面有 8 个坐标,每个坐标都应用于立方体的一个顶点。第一个坐标是 (1,1)。这如何定义颜色纹理?这些数字只有在我们参考画笔brush来绘制表面时才有意义。

对于这个例子,我们将参考一个LinearGradientBrush。这个brush允许从StartPointEndPoint定义一个渐变。

<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
    <GradientStop Color="AliceBlue" Offset="0" />
    <GradientStop Color="DarkBlue" Offset="1" />
</LinearGradientBrush>

在上例中,想象一个 1x1 单位的区域。两个相对的角将具有坐标(0,0)(1,1)。值为0指的是AliceBlue颜色,值为1指的是DarkBlue颜色。介于两者之间的任何数字都是这两种颜色之间的某种色调。这样定义颜色使我们能够获得连续的渐变图。1x1 区域中的每个点都将被映射到一个定义的颜色。

回到我们立方体顶点索引 0 的TextureCoordinate值 (1,1),根据这个LinearGradientBrush画笔,颜色将是DarkBlue

同样,我们也可以有一个ImageBrushImageBrush将有一个image sourceimage source 的图像将用于定义纹理/颜色。同样,坐标的值范围为(0,0)(1,1)。例如,如果图像大小为 1920X1080,那么(0,0)将指向图像上的点(0,0),而(1,1)将指向图像右下角的点(1920-1, 1080-1)TextureCoordinate (0.5,0.5)将被映射到(0.5X1920-1, 0.5X1080-1)。这样,我们就能为(0,0) -(1,1)范围内的任何TextureCoordinate找到图像点。

面部模型的网格文件包含1347Position顶点,后面是1347TextureCoordinates点。纹理文件是一个 1920X1080 的图像,将用作渲染3D 模型ImageBrush的图像源。

定义面部模型中所有表面的三角形索引对于Kinect 2.0 生成的所有面部模型都相同。我在文件tri_index.txt中包含了这些索引。有 2630 个表面,由 7890 个索引定义。

还有一个文件列出了这些面部点的图像坐标

  • 右眼
  • 左眼
  • 鼻子
  • 嘴巴
  • 下巴

这些文件中的信息均来自使用Kinect 2.0 sensorKinect 2.0 SDK API生成的数据。在本文中,我不会涵盖 Kinect 特定的区域。如果您有兴趣,请参阅Kinect 2.0 SDK 中的HDFace示例。

HDFace 点的文档记录不太好,可能是因为它们太多了。很难为1347个点中的每一个命名。然而,根据我的调查,我们感兴趣的面部点是

  • 328-1105(右眼在这两个点之间)
  • 883-1092(左眼在这两个点之间)
  • 10(上唇基部中心)
  • 14(鼻基)
  • 0(下巴)

面部图像

本文的理念基于Kinect 2.0 SDK HDFace示例。在该示例中,HDFace帧和Color帧同步处理。使用Kinect 2.0 坐标映射器,每个HDFace点都可以映射到Color帧中相应的颜色坐标,并且可以使用Color帧作为ImageBrushimage source实时准确地生成TextureCordinate

我快照了一组同步的HDFaceColor帧,记录了生成的TextureCoordinate和标准的TriangleIndices。这将提供我不需要Kinect 2.0 sensor即可重现3D 面部模型所需的所有信息。

但是,该模型只能用于特定的纹理(Color帧)。Color帧是 1920X1080,但面部图像只占大约 500X500 的区域。如果我们能用另一张脸替换这个区域,我们或许就能将那张脸渲染到3D 面部模型上!

这就像戴上面具。但它必须准确地安装。

为了精确替换面部区域,我们需要知道该面部的方向。它是侧视、上视还是下视?眼睛是否水平且睁开,嘴巴是否闭合?要获得相同方向的替换面孔将非常困难。

我们需要一个面部图像的标准。为每个3D 模型记录的原始Color帧具有标准规格的面部。本质上,它与身份证照片采用的标准相同:完整面部正面、中性表情、眼睛睁开、嘴巴闭合。图像分辨率应保持在 400X400 到 800X800 之间。

对于替换面孔,我们需要遵守相同的标准。

尽管如此,在3.0 版中,我们可以使用自定义网格处理非正面输入(请参阅本文末尾的更新部分)。

面部拟合

拟合不佳

拟合良好

原始纹理是 1920X1080。面部图像可能位于中心某处。要替换该面部,我们需要将新面部固定在一些不变点上。选择的点应该是面部的主要特征区域。大多数面部识别系统将眼睛、鼻子和嘴巴定义为重要特征。有些还包括脸颊和眉毛。

对于本文,我确定了 5 个点:右眼左眼鼻基上唇基部下巴

新面孔与每个面部模型的匹配程度可能不同。我们需要一种方法来计算拟合优度。最佳拟合是指在水平和垂直方向进行均匀拉伸(即放大变换)后,这 5 个点都对齐的情况。如果我们有足够的面部模型,我们可能会找到一个或多个理想的模型。只有 6 个面部模型,我们可能无法获得理想的拟合。

面部拟合算法

  1. 查找参考(原始面部)图像中两眼之间的距离。
  2. 查找新面部图像中两眼之间的距离。
  3. 查找参考图像中鼻基到眼睛中点的距离。
  4. 查找新图像中鼻基到眼睛中点的距离。
  5. 将新图像水平拉伸的因子通过将 1) 除以 2) 得到。
  6. 将新图像垂直拉伸的因子通过将 3) 除以 4) 得到。
  7. 对于新拉伸的图像,查找从鼻基到上唇基部的垂直距离。
  8. 对于参考图像,查找从鼻基到上唇基部的垂直距离。
  9. 对于新图像,通过将 8) 除以 7) 得到的因子,从鼻基向下垂直拉伸(或压缩)。
  10. 现在嘴巴将对齐。对于新重新拉伸的图像,查找从上唇基部到下巴的垂直距离。
  11. 对于参考图像,查找从上唇基部到下巴的垂直距离。
  12. 最后一步:通过将 11) 除以 12) 得到的因子,从上唇基部向下垂直拉伸(或压缩)。

现在所有面部点都将对齐

然而,对于一些面部模型,导致的新图像可能会严重变形,请参见上面标记为拟合不佳的图。

对于拟合优度的测量,我设计了一种基于拉伸因子的方法。涉及 4 个拉伸因子

  1. factor1 =眼间距
  2. factor2 =鼻基到眼睛中点
  3. factor3 =鼻基到上唇基部
  4. factor4 =上唇基部到下巴

对于 1) 和 2),我们希望这些值尽可能相似,我们计算绝对比率(factor1-factor2)/(factor1)。称之为眼鼻误差

对于 3),我们希望将因子保持在尽可能接近 1.00 的值,我们使用绝对比率(factor3-1)。称之为鼻嘴误差

对于 4),我们也希望将因子保持在尽可能接近 1.00 的值,我们使用绝对比率(factor4-1)。称之为嘴下巴误差

由于眼鼻拉伸应用于整个面部,因此赋予更高的权重。同样,鼻嘴拉伸涉及从鼻子向下拉伸,而嘴下巴拉伸只涉及从嘴巴向下拉伸,因此其权重小于眼鼻误差,但大于嘴下巴误差。

当前的权重是眼鼻误差为 4,鼻嘴误差为 2,嘴下巴误差为 1。

相机、灯光、动作

图 1:设置

图 2:为立方体打光

图 3:视图随网格平移变化

图 4:视图随相机旋转变化

用于设置Viewport3DXaml标记代码

    <Viewport3D  HorizontalAlignment="Stretch" VerticalAlignment="Stretch" 
     Width="Auto" Height="Auto" x:Name="viewport3d" RenderTransformOrigin="0.5,0.5" 
     MouseDown="viewport3d_MouseDown" MouseRightButtonDown="viewport3d_MouseRightButtonDown" >
        <Viewport3D.RenderTransform>
            <ScaleTransform ScaleX="1" ScaleY="1"/>
        </Viewport3D.RenderTransform>
        <!-- Defines the camera used to view the 3D object. -->
        <Viewport3D.Camera>
            <!--<PerspectiveCamera Position="0.0, 0.0, 0.45" LookDirection="0,0, -1" 
                 UpDirection="0,1,0" FieldOfView="70" />-->
            <PerspectiveCamera
                Position = "0, -0.08, 0.5"
                LookDirection = "0, 0, -1"
                UpDirection = "0, 1, 0"
                FieldOfView = "70">
                <PerspectiveCamera.Transform>
                    <Transform3DGroup>
                        <RotateTransform3D>
                            <RotateTransform3D.Rotation>
                                <AxisAngleRotation3D
                                    Axis="0 1 0" 
                                    Angle="{Binding Value, ElementName=hscroll}" />
                            </RotateTransform3D.Rotation>
                        </RotateTransform3D>
                        <RotateTransform3D>
                            <RotateTransform3D.Rotation>
                                <AxisAngleRotation3D
                                    Axis="1 0 0" 
                                    Angle="{Binding Value, ElementName=vscroll}" />
                            </RotateTransform3D.Rotation>
                        </RotateTransform3D>
                        <RotateTransform3D>
                            <RotateTransform3D.Rotation>
                                <AxisAngleRotation3D
                                    Axis="0 0 1" 
                                    Angle="{Binding Value, ElementName=vscrollz}" />
                            </RotateTransform3D.Rotation>
                        </RotateTransform3D>

                    </Transform3DGroup>
                </PerspectiveCamera.Transform>

            </PerspectiveCamera>
        </Viewport3D.Camera>

        <!-- The ModelVisual3D children contain the 3D models -->
        <!-- This ModelVisual3D defines the light cast in the scene. Without light, the 3D 
           object cannot be seen. Also, the direction of the lights affect shadowing. 
           If desired, you can create multiple lights with different colors 
           that shine from different directions. -->
        <ModelVisual3D>
            <ModelVisual3D.Content>
                <Model3DGroup>
                    <AmbientLight x:Name="amlight" Color="White"/>
                    <!--<DirectionalLight x:Name="dirlight" Color ="Black" 
                         Direction="1,-2,-3" />-->
                    <DirectionalLight x:Name="dirlight" Color="White" Direction="0,0,-0.5" >
                        <DirectionalLight.Transform>
                            <Transform3DGroup>
                                <TranslateTransform3D OffsetZ="0" OffsetX="0" OffsetY="0"/>
                                <ScaleTransform3D ScaleZ="1" ScaleY="1" ScaleX="1"/>
                                <TranslateTransform3D OffsetZ="0" OffsetX="0" OffsetY="0"/>
                                <TranslateTransform3D OffsetY="-0.042" 
                                 OffsetX="0.469" OffsetZ="-0.103"/>
                            </Transform3DGroup>
                        </DirectionalLight.Transform>
                    </DirectionalLight>
                </Model3DGroup>
            </ModelVisual3D.Content>
        </ModelVisual3D>
        <ModelVisual3D>
            <ModelVisual3D.Content>
                <GeometryModel3D>

                    <!-- The geometry specifies the shape of the 3D plane. 
                         In this sample, a flat sheet is created. -->
                    <GeometryModel3D.Geometry>
                        <MeshGeometry3D x:Name="theGeometry"
                            Positions="-0.05,-0.1,0 0.05,-0.1,0 -0.05,0,0 0.05,
                            0,0 -0.05,-0.1,-0.1 0.05,-0.1,-0.1 -0.05,0,-0.1 0.05,0,-0.1"
                            TextureCoordinates="0,1 1,1 0,0 1,0 0,0 1,0 0,1 1,1"
                            TriangleIndices="0,1,2 1,3,2 0,2,4 2,6,4 2,3,6 3,7,6 3,1,
                                             5 3,5,7 0,5,1 0,4,5 6,5,4 6,7,5"/>
                    </GeometryModel3D.Geometry>

                    <!-- The material specifies the material applied to the 3D object. 
                         In this sample a linear gradient 
                         covers the surface of the 3D object.-->
                    <GeometryModel3D.Material>
                        <MaterialGroup>
                            <DiffuseMaterial x:Name="theMaterial">
                                <DiffuseMaterial.Brush>
                                    <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
                                        <GradientStop Color="AliceBlue" Offset="0" />
                                        <GradientStop Color="DarkBlue" Offset="1" />
                                    </LinearGradientBrush>
                                </DiffuseMaterial.Brush>
                            </DiffuseMaterial>
                        </MaterialGroup>
                    </GeometryModel3D.Material>

                </GeometryModel3D>
            </ModelVisual3D.Content>
        </ModelVisual3D>
    </Viewport3D>

Viewport3D包含以下元素

  • Viewport3D.Camera
  • ModelVisual3D

Viewport3D.Camera包含PerspectiveCamera。相机规格如下

            <PerspectiveCamera
                Position = "0, -0.08, 0.5"
                LookDirection = "0, 0, -1"
                UpDirection = "0, 1, 0"
                FieldOfView = "70">

相机位于世界坐标(0,-0.08,0.5)。参见图 1:设置LookDirection (0,0,-1) 表示相机看向负 Z 方向。

此相机设置为具有绕 X、Y 和 Z 轴的旋转变换功能。

                <PerspectiveCamera.Transform>
                    <Transform3DGroup>
                        <RotateTransform3D>
                            <RotateTransform3D.Rotation>
                                <AxisAngleRotation3D
                                    Axis="0 1 0" 
                                    Angle="{Binding Value, ElementName=hscroll}" />
                            </RotateTransform3D.Rotation>
                        </RotateTransform3D>
                        <RotateTransform3D>
                            <RotateTransform3D.Rotation>
                                <AxisAngleRotation3D
                                    Axis="1 0 0" 
                                    Angle="{Binding Value, ElementName=vscroll}" />
                            </RotateTransform3D.Rotation>
                        </RotateTransform3D>
                        <RotateTransform3D>
                            <RotateTransform3D.Rotation>
                                <AxisAngleRotation3D
                                    Axis="0 0 1" 
                                    Angle="{Binding Value, ElementName=vscrollz}" />
                            </RotateTransform3D.Rotation>
                        </RotateTransform3D>

                    </Transform3DGroup>
                </PerspectiveCamera.Transform>

旋转角度均绑定到滑块的值:绕X 轴="1 0 0"的旋转绑定到vscroll 滑块,绕Y 轴 "0 1 0"的旋转绑定到hscroll 滑块,绕Z 轴 "0 0 1"的旋转绑定到vscrollz 滑块。有效角度范围为 -180 至 180 度。滑动这些滑块会导致相机位置发生变化。由此产生的视图似乎是所观察的对象已被旋转。参见图 4:视图随相机旋转变化

ModelVisual3D包含两个ModelVisual3D.Content

一个包含Model3DGroup,其中包含光源。有两个光源

                    <AmbientLight x:Name="amlight" Color="White"/>
                    <DirectionalLight x:Name="dirlight" Color="White" Direction="0,0,-0.5" >

灯光默认为白色。所有对象都将被白光照亮。

在下面的源代码中,我们根据滑块的值更改这些灯光的颜色

        private void sliderColor_ValueChanged
          (object sender, RoutedPropertyChangedEventArgs<double> e)
        {
        //    if (!bIsXLoaded) return;
            if (sliderRed!= null && sliderGreen!= null && sliderBlue!= null && sliderAmb!=null)
            {
                Color color = Color.FromArgb(255, (byte)sliderRed.Value, 
                              (byte)sliderGreen.Value, (byte)sliderBlue.Value);
                if (labelColor != null)
                {
                    labelColor.Content = color.ToString();
                    labelColor.Background = new SolidColorBrush(color);
                }
                
                if (dirlight != null)
                    dirlight.Color = color;

                Color amcolor = Color.FromArgb(255, (byte)sliderAmb.Value, 
                                (byte)sliderAmb.Value, (byte)sliderAmb.Value);
                if (amlight != null)
                    amlight.Color = amcolor;
            }
        }

我们还可以更改Directional灯光的方向

        void dispatcherTimer2_Tick(object sender, EventArgs e)
        {
            var dir = dirlight.Direction;

            if (dir.Y > 5 || dir.Y < -5) deltaYdir = -1 * deltaYdir;
            dir.Y += deltaYdir;
            dirlight.Direction = new Vector3D(dir.X, dir.Y, dir.Z);
        }

        void dispatcherTimer_Tick(object sender, EventArgs e)
        {
            var dir = dirlight.Direction;

            if (dir.X > 5 || dir.X<-5) deltaXdir = -1 * deltaXdir;
            dir.X += deltaXdir;
            dirlight.Direction = new Vector3D(dir.X, dir.Y, dir.Z);            
        }

改变灯光的颜色方向会导致对象以不同的颜色和阴影进行观察。参见图 2:为立方体打光

另一个ModelVisual3D.Content包含Model3DGroup,其中包含GeometryModel3D.GeometryGeometryModel3D.Material

GeometryModel3D.Geometry指定网格细节:PositionTextureCoordinatesTriangleIndicesGeometryModel3D.Material指定用于渲染对象的Brush。原始对象是一个立方体,画笔是一个简单的渐变图。

    <GeometryModel3D.Geometry>
        <MeshGeometry3D x:Name="theGeometry"
            Positions="-0.05,-0.1,0 0.05,-0.1,0 -0.05,0,0 0.05,0,0 -0.05,-0.1,
                       -0.1 0.05,-0.1,-0.1 -0.05,0,-0.1 0.05,0,-0.1"
            TextureCoordinates="0,1 1,1 0,0 1,0 0,0 1,0 0,1 1,1"
            TriangleIndices="0,1,2 1,3,2 0,2,4 2,6,4 2,3,6 3,7,6 3,1,5 3,5,
                             7 0,5,1 0,4,5 6,5,4 6,7,5"/>
    </GeometryModel3D.Geometry>

    <!-- The material specifies the material applied to the 3D object. 
         In this sample a linear gradient covers the surface of the 3D object.-->
    <GeometryModel3D.Material>
        <MaterialGroup>
            <DiffuseMaterial x:Name="theMaterial">
                <DiffuseMaterial.Brush>
                    <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
                        <GradientStop Color="AliceBlue" Offset="0" />
                        <GradientStop Color="DarkBlue" Offset="1" />
                    </LinearGradientBrush>
                </DiffuseMaterial.Brush>
            </DiffuseMaterial>
        </MaterialGroup>
    </GeometryModel3D.Material>

要更改网格的位置,我们对其顶点进行平移。

        private void UpdateMesh(float offsetx,float offsety,float offsetz)
        {        
            var vertices = orgmeshpos;
    
            for (int i = 0; i < vertices.Count; i++)
            {
                var vert = vertices[i];
                vert.Z += offsetz;
                vert.Y += offsety;
                vert.X += offsetx;
                this.theGeometry.Positions[i] = new Point3D(vert.X, vert.Y, vert.Z);
            }  
        }

我们加载启动位置(存储在orgmeshpos中),然后应用通过 X、Y 和 Z 滑块改变的平移。参见图 3:视图随网格平移变化

用户界面

启动时,UI 如图 5 所示

图 5:启动

这是启动立方体的正面视图。您可以滑动左侧、右侧和底部的相机旋转滑块以获得立方体不同的视图。

右键单击立方体可在面部立方体渐变颜色立方体之间切换。

图 6:面部立方体

图 6 显示了由ImageBrush渲染的面部立方体。您可以使用平移设置滑块更改立方体的位置。更改相机旋转滑块的值 将导致立方体以不同角度进行观察。环境光照阴影和方向颜色设置会影响灯光颜色和强度的变化。这将导致对象出现不同的光照效果。可以通过单击标记为<->^--v的按钮来更改定向灯光的方向。再次单击这些按钮将锁定当时灯光的方向。右键单击这些按钮可重置灯光的方向。

图 7:面部模型

有 6 个面部模型可供选择。单击右侧的 6 张面部图像中的任意一张。所选面部模型将加载到默认位置,但具有当前的相机旋转设置。要转到所有默认位置相机旋转设置,请单击重置按钮,或双击渲染的面部模型

要截取当前视图的模型快照,请单击快照按钮。将记录并存储在内存中一个快照,并显示在左侧的垂直列中。该列显示最后 6 张快照。要滚动查看其他图像,请将鼠标悬停在任何图像上并滚动鼠标滚轮。

单击图像即可查看/保存到文件。

加载面部模型后,模型的文件纹理将显示在左上角。单击纹理图像以选择并加载新的面部文件。

图 8:面部拟合

选择并加载面部图像后,程序将尝试定位眼睛。眼睛检测使用OpenCV HaarClassifier进行。请注意,在活动面部特征定位器(红圈)的中心有一个小方框。这是用于精确精确定位特征点的。在左上方,还会有一个放大镜显示活动面部特征定位器的内容。

要定位的 5 个点是2 只眼睛、鼻基、上唇基部和下巴。要精细移动定位器,请单击以选中它,然后使用箭头键,同时查看放大镜中心的內容,以精确地定位面部点。

如果输入面部图像的眼睛不是完全水平,请勾选对齐眼睛复选框。请注意,大多数人在正面照片中都无法让眼睛绝对水平。

单击最佳拟合按钮以获得最适合新面孔的面部模型。单击更新以使用当前选定的面部模型

图 9:面部拟合评估

选择并更新面部模型以及新面孔后,请注意以下几点

  • 右上角图像:用于纹理的拉伸面孔
  • 右下角:拉伸面孔与面部模型的对齐拟合
  • 拟合误差:显示 4 个数字 <眼-鼻>:<鼻-嘴>:<嘴-下巴>:<总计>

为了良好拟合,拉伸后的面孔不应过于变形。请参见本文前面标记为拟合不佳的图,其中包含一个拟合不佳的面孔图像的示例。

拟合误差将帮助您重新调整面部点。例如,值为-10:15:-10:80。在这种情况下,眼鼻误差=-10鼻嘴误差=15嘴下巴误差=-10,并且总体误差(使用分配的权重:眼鼻为 4,鼻嘴为 2,嘴下巴为 1 计算)为80

为了补偿负数眼鼻误差,将两个眼睛定位器拉近,并/或降低鼻子定位器。同样,为了补偿正数眼鼻误差,将眼睛定位器移开,并/或将鼻子定位器抬高。

为了补偿负数鼻嘴误差,将嘴巴和鼻子定位器拉开。对于正数误差,将这些定位器拉近。

为了补偿负数嘴下巴误差,将嘴巴和下巴定位器拉开。对于正数误差,将这些定位器拉近。

在我们的示例中,值为-10:15:-10:80,我们应将眼睛定位器拉近,将鼻子定位器向下移动以补偿-10眼鼻误差。对于鼻嘴误差校正15,将嘴巴定位器向上移动。对于嘴下巴误差-10,将下巴定位器进一步向下移动。

请注意,重新定位面部点定位器以补偿拟合误差将导致拉伸的面部图像的比例与原始图像更相似,但如果纠正过多,某些面部点可能无法与面部模型上的相应面部点对齐。单击更新按钮,然后查看右下角的对齐拟合图像。

这是一个迭代过程,需要一些练习才能熟练。然而,有些面孔可能无法令人满意地拟合,如果它们的点配置与我们 6 个面部模型中的任何一个相比比例严重失调。我尝试拟合的大多数面孔都可以以最多总体误差100的程度进行拟合。

请注意,如果您无法使鼻基网格鼻基对齐,您可以将两个眼睛的定位器移向同一方向。如果网格鼻基位于图像鼻基的左侧,则将两个眼睛的定位器移到右侧。同样,如果网格鼻基位于图像鼻基的右侧,则将眼睛定位器移到左侧。为了不拉伸图像,您必须以相同的幅度移动定位器。按箭头键控制移动的幅度。每次按下箭头键都会以相同的幅度移动定位器。因此,为了不拉伸图像,如果您将左眼定位器移动 5 次,您需要将右眼定位器移动 5 次。

面部拟合后,您可以执行平移和相机旋转以获得所需的视图。然后单击快照按钮进行快照。

要移除面部上的网格线,请取消勾选右上角的ShowGrid复选框。

有时,面部图像无法完全覆盖面部模型,尤其是在面部边缘附近。

图 10:面部纹理不足

图 10 显示,由于面部纹理不足,我们无法有效渲染面部侧面。面部边缘变形,因为它使用了耳朵和头发的一部分进行渲染。为了处理这种情况,我设计了一种方法,用靠近脸颊和眼侧的面部纹理来修补新面部的侧面。取消勾选左上角的No-Stretching复选框可启用此功能。

图 11:修补的面孔

图 11 显示,面部侧面已通过从面部内部延伸的纹理进行修补。

代码亮点

OpenCV:在 C# 中查找面部和眼睛,无需 Emgucv。最初,我想使用Emgucv,但其占用的空间太大了,不适合在此文章中分发。这里的代码使用了Opencv 2.2 的包装器。包装器的代码在DetectFace.cs中。下面的代码利用了这个包装器中的方法来进行面部和眼睛检测。包装器代码来自 https://gist.github.com/zrxq/1115520/fc3bbdb8589eba5fc243fb42a1964e8697c70319 中的detectface.cs

public static void FindFaceAndEyes(BitmapSource srcimage, 
out System.Drawing.Rectangle facerect, out System.Drawing.Rectangle[] eyesrect)
{
    String faceFileName = AppDomain.CurrentDomain.BaseDirectory + 
                          "haarcascade_frontalface_alt2.xml";
    String eyeFileName = AppDomain.CurrentDomain.BaseDirectory + "haarcascade_eye.xml";

    IntelImage _img = CDetectFace.CreateIntelImageFromBitmapSource(srcimage);

    using (HaarClassifier haarface = new HaarClassifier(faceFileName))
    using (HaarClassifier haareye = new HaarClassifier(eyeFileName))
    {
        var faces = haarface.DetectObjects(_img.IplImage());
        if(faces.Count>0)
        {
                var face = faces.ElementAt(0);
                facerect = new System.Drawing.Rectangle
                           (face.x, face.y, face.width, face.height);
                
                int x=face.x,y=face.y,h0=face.height ,w0=face.width;
                System.Drawing.Rectangle temprect = 
                                         new System.Drawing.Rectangle(x,y,w0,5*h0/8);
                System.Drawing.Bitmap bm_current=
                                      CDetectFace.ToBitmap(_img.IplImageStruc(),false)  ;  
                System.Drawing.Bitmap bm_eyes =   bm_current.cropAtRect(temprect);
                bm_eyes.Save(AppDomain.CurrentDomain.BaseDirectory + "temp\\~eye.bmp", 
                                       System.Drawing.Imaging.ImageFormat.Bmp);
                IntelImage image_eyes = CDetectFace.CreateIntelImageFromBitmap(bm_eyes);
            
                 IntPtr p_eq_img_eyes= CDetectFace.HistEqualize(image_eyes);
        
                 var eyes = haareye.DetectObjects(p_eq_img_eyes);

              //clean up
                 NativeMethods.cvReleaseImage(ref  p_eq_img_eyes);  
                image_eyes.Dispose();
                image_eyes = null;                      
                bm_eyes.Dispose();
              
                if (eyes.Count > 0)
                {
                    eyesrect = new System.Drawing.Rectangle[eyes.Count];

                    for (int i = 0; i < eyesrect.Length; i++)
                    {
                        var eye = eyes.ElementAt(i);
                        eyesrect[i] = new System.Drawing.Rectangle
                                      (eye.x, eye.y, eye.width, eye.height);
                    }
                }
                else
                    eyesrect = null;   
           }
            else
            {
                facerect = System.Drawing.Rectangle.Empty;
                eyesrect = null;
            }
    }

    _img.Dispose();
}

WPF 和 GDI+ 转换WPF System.Windows.Media 类非常适合呈现,但在图像处理方面灵活性不高。在System.Winows.Drawing.Bitmap上绘图比在System.Windows.Media.ImageSource上绘图更容易。因此,对于位图处理,我将 WPF BitmapSource 转换为System.Windows.Drawing.Bitmap,对于 WPF 上的呈现,我再从System.Windows.Drawing.Bitmap转换回BitmapSource

        public static System.Windows.Media.Imaging.BitmapImage Bitmap2BitmapImage
               (System.Drawing.Bitmap bitmap)
        {
                System.Drawing.Image img = new System.Drawing.Bitmap(bitmap);
                ((System.Drawing.Bitmap)img).SetResolution(96, 96);
                MemoryStream ms = new MemoryStream();
           
                img.Save(ms, System.Drawing.Imaging.ImageFormat.Png );
                img.Dispose();
                img=null;
                ms.Seek(0, SeekOrigin.Begin);

                BitmapImage bi = new BitmapImage();

                bi.BeginInit();
                bi.StreamSource = ms;
                bi.EndInit();
                bi.Freeze();
                return bi;      
        }

        public static System.Drawing.Bitmap BitmapImage2Bitmap(BitmapSource bitmapImage)
        {
            using (MemoryStream outStream = new MemoryStream())
            {
                //BitmapEncoder enc = new BmpBitmapEncoder();
                BitmapEncoder enc = new PngBitmapEncoder();
                enc.Frames.Add(BitmapFrame.Create(bitmapImage));
                enc.Save(outStream);
                System.Drawing.Bitmap bitmap = new System.Drawing.Bitmap(outStream);
                bitmap.SetResolution(96, 96);
                System.Drawing.Bitmap bm=new System.Drawing.Bitmap(bitmap);
                tempbm.Dispose();
                tempbm=null;
                return bm;
            }
        }

从视口快照RenderTargetBitmap 类对于从Viewport3D获取图像很有用。但是,它会截取整个视口。尽管如此,我们仍然可以得到对象,因为快照的视口大部分是透明像素。可以找到非透明像素的边界矩形,调整边界以增加一些边距,然后使用CropBitmap类从快照的RenderTargetBitmap中裁剪。然后,我们使用FormatConvertedBitmap类将最终图像转换为RGB24格式,这是我们OpenCV包装器等大多数图像处理软件使用的标准格式。

            var viewport = this.viewport3d;
            var renderTargetBitmap = new RenderTargetBitmap((int)
                                                           (((int)viewport.ActualWidth+3)/4 *4) ,
                                                           (int)viewport.ActualHeight  ,
                                                           96, 96, PixelFormats.Pbgra32);
            renderTargetBitmap.Render(viewport);

            byte[] b=new byte[(int)renderTargetBitmap.Height*(int)renderTargetBitmap.Width*4];
            int stride=((int)renderTargetBitmap.Width )*4;
            renderTargetBitmap.CopyPixels(b, stride, 0);
            
            //get bounding box;
            int x = 0, y = 0,minx=99999,maxx=0,miny=99999,maxy=0;
            //reset all the alpha bits
            for(int i=0;i<b.Length;i=i+4)
            {
                y = i /stride;
                x = (i % stride) / 4;

                if (b[i + 3] == 0) //if transparent we set to white
                {
                    b[i] = 255;
                    b[i + 1] = 255;
                    b[i + 2] = 255;
                }
                else
                {
                    if (x > maxx) maxx = x;
                    if (x < minx) minx = x;
                    if (y > maxy) maxy = y;
                    if (y < miny) miny = y;
                }
            }

            BitmapSource image = BitmapSource.Create(
                (int)renderTargetBitmap.Width ,
                (int)renderTargetBitmap.Height,
                96,
                96,
                PixelFormats.Bgra32,
                null,
                b,
                stride);

            int cropx = minx - 20;
            if (cropx < 0) cropx = 0;
            int cropy = miny - 20;

            if (cropy < 0) cropy = 0;

            int cropwidth = (((maxx - cropx + 20 + 1) + 3) / 4) * 4;
            int cropheight = maxy - cropy + 20 + 1;

            //check oversized cropping
            int excessx = cropwidth + cropx - image.PixelWidth;
            int excessy = cropheight + cropy - image.PixelHeight;
            if (excessx < 0) excessx = 0;
            if (excessy < 0) excessy = 0;
            excessx = ((excessx + 3) / 4) * 4;
           
            CroppedBitmap crop;
            try
            {
                crop = new CroppedBitmap(image, new Int32Rect
                       (cropx, cropy, cropwidth - excessx, cropheight - excessy));
            }
            catch
            {
                return;
            }

            ////Convert to rgb24
            var destbmp = new FormatConvertedBitmap();
            destbmp.BeginInit();
            destbmp.DestinationFormat = PixelFormats.Rgb24;
            destbmp.Source = crop;
            destbmp.EndInit();

保存图像和背景Window2实现了一个通用的窗口来显示和保存图像。它由一个包含Image (Image1)Grid (TopGrid)组成。代码检索TopGrid背景的image source,并将Image1中的image source绘制到它上面。对于这种叠加,背景图像和前景图像都必须支持透明度

    int imagewidth = (int)Image1.Source.Width;
    int imageheight = (int)Image1.Source.Height ;
    System.Drawing.Bitmap bm=null;

    if (SourceBrushImage == null)
        bm = new System.Drawing.Bitmap
        (imagewidth, imageheight, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
    else
    {
      //TopGrid Background store the ImageBrush
      ImageBrush ib=(ImageBrush)(TopGrid.Background) ; 
      BitmapSource ibimgsrc = ib.ImageSource as BitmapSource;
      bm = CCommon.BitmapImage2Bitmap(ibimgsrc);
    }
    System.Drawing.Graphics gbm = System.Drawing.Graphics.FromImage(bm);

    if (SourceBrushImage == null)
       gbm.Clear(System.Drawing.Color.AliceBlue);

    //Image1 store the image
    System.Drawing.Bitmap bm2 = 
     CCommon.BitmapImage2Bitmap(Image1.Source as BitmapSource );// as BitmapImage);
    gbm.DrawImage(bm2, 0, 0);
    gbm.Dispose();

    bm.Save(filename, System.Drawing.Imaging.ImageFormat.Jpeg);

AForge 亮度和对比度。当我们调整亮度和/或对比度时,我们会检索原始的未过滤(即未应用任何图像滤镜)图像,并按顺序将亮度和对比度滤镜应用于原始图像,先应用亮度,然后使用结果图像设置对比度滤镜

            if (((System.Windows.Controls.Slider)sender).Name == "sliderBrightness" ||
                ((System.Windows.Controls.Slider)sender).Name == "sliderContrast")
            {
                    if (colorbitmap == null) return;
                    System.Drawing.Bitmap bm = 
                           CCommon.BitmapImage2Bitmap((BitmapImage)colorbitmap);

                    AForge.Imaging.Filters.BrightnessCorrection filterB = 
                                   new AForge.Imaging.Filters.BrightnessCorrection();
                    AForge.Imaging.Filters.ContrastCorrection filterC = 
                                   new AForge.Imaging.Filters.ContrastCorrection();

                    filterB.AdjustValue = (int)sliderBrightness.Value;
                    filterC.Factor = (int)sliderContrast.Value;
                  
                    bm = filterB.Apply(bm);
                    bm = filterC.Apply(bm);               

                    BitmapImage bitmapimage = CCommon.Bitmap2BitmapImage(bm);
                    theMaterial.Brush = new ImageBrush(bitmapimage)
                    {
                        ViewportUnits = BrushMappingMode.Absolute
                    };                                
            }

获取最佳拟合面部模型面部拟合的算法前面已经介绍过。在这里,我们通过比率进行操作,而不实际进行新面部的图像处理,来找到拟合误差,然后选择误差最小的面部模型

 public string getBestFittingMesh(string filename)
        {          
            FeaturePointType righteyeNew = new FeaturePointType();
            FeaturePointType lefteyeNew = new FeaturePointType();
            FeaturePointType noseNew = new FeaturePointType();
            FeaturePointType mouthNew = new FeaturePointType();
            FeaturePointType chinNew = new FeaturePointType();

            for (int i = 0; i < _imagefacepoints.Count; i++)
            {
                FeaturePointType fp = new FeaturePointType();
                fp.desp = _imagefacepoints[i].desp;
                fp.pt = _imagefacepoints[i].pt;
                switch (fp.desp)
                {
                    case "RightEye1":
                        righteyeNew = fp;
                        break;
                    case "LeftEye1":
                        lefteyeNew = fp;
                        break;
                    case "Nose1":
                        noseNew = fp;
                        break;
                    case "Mouth3":
                        mouthNew = fp;
                        break;
                    case "Chin1":
                        chinNew = fp;
                        break;
                }
            }

            //do prerotation
            if (_degPreRotate != 0)
            {
                //all point are to be altered
                righteyeNew = rotateFeaturePoint(righteyeNew, _degPreRotate);
                lefteyeNew = rotateFeaturePoint(lefteyeNew, _degPreRotate);
                noseNew = rotateFeaturePoint(noseNew, _degPreRotate);
                mouthNew = rotateFeaturePoint(mouthNew, _degPreRotate);
                chinNew = rotateFeaturePoint(chinNew, _degPreRotate);
            }

            int eyedistNew = (int)(lefteyeNew.pt.X - righteyeNew.pt.X);

            FeaturePointType righteyeRef = new FeaturePointType();
            FeaturePointType lefteyeRef = new FeaturePointType();
            FeaturePointType noseRef = new FeaturePointType();
            FeaturePointType mouthRef = new FeaturePointType();
            FeaturePointType chinRef = new FeaturePointType();

            string[] meshinfofiles = Directory.GetFiles
            (AppDomain.CurrentDomain.BaseDirectory + "mesh\\","*.info.txt");

            List<Tuple<string,string, double>> listerr = 
                              new List<Tuple<string,string, double>>();

            foreach(var infofilename in meshinfofiles)
            {
                //string infofilename = AppDomain.CurrentDomain.BaseDirectory + 
                // "\\mesh\\mesh" + this.Title + ".info.txt";
                using (var file = File.OpenText(infofilename))
                {
                    string s = file.ReadToEnd();
                    var lines = s.Split(new string[] { "\r\n", "\n" }, 
                                StringSplitOptions.RemoveEmptyEntries);
                    for (int i = 0; i < lines.Length; i++)
                    {
                        var parts = lines[i].Split('=');
                        FeaturePointType fp = new FeaturePointType();
                        fp.desp = parts[0];
                        fp.pt = ExtractPoint(parts[1]);
                        switch (fp.desp)
                        {
                            case "RightEye1":
                                righteyeRef = fp;
                                break;
                            case "LeftEye1":
                                lefteyeRef = fp;
                                break;
                            case "Nose1":
                                noseRef = fp;
                                break;
                            case "Mouth3":
                                mouthRef = fp;
                                break;
                            case "Chin1":
                                chinRef = fp;
                                break;
                        }
                    }
                }

                double x0Ref = (lefteyeRef.pt.X + righteyeRef.pt.X) / 2;
                double y0Ref = (lefteyeRef.pt.Y + righteyeRef.pt.Y) / 2;
                double x0New = (lefteyeNew.pt.X + righteyeNew.pt.X) / 2;
                double y0New = (lefteyeNew.pt.Y + righteyeNew.pt.Y) / 2;

               int eyedistRef = (int)(lefteyeRef.pt.X - righteyeRef.pt.X);
               double noselengthNew = Math.Sqrt((noseNew.pt.X - x0New) * 
               (noseNew.pt.X - x0New) + (noseNew.pt.Y - y0New) * (noseNew.pt.Y - y0New));
               double noselengthRef = Math.Sqrt((noseRef.pt.X - x0Ref) * 
               (noseRef.pt.X - x0Ref) + (noseRef.pt.Y - y0Ref) * (noseRef.pt.Y - y0Ref));

               double ratiox = (double)eyedistRef / (double)eyedistNew;
               double ratioy = noselengthRef / noselengthNew;
               double errFitting = /*Math.Abs*/(ratiox - ratioy) / ratiox;

               ////Alight the mouth//////////
               Point newptNose = new Point(noseNew.pt.X * ratiox, noseNew.pt.Y * ratioy);
               Point newptMouth = new Point(mouthNew.pt.X * ratiox, mouthNew.pt.Y * ratioy);

               double mouthDistRef = mouthRef.pt.Y - noseRef.pt.Y;

               double mouthDistNew = newptMouth.Y - newptNose.Y;//noseNew.pt.Y * ratioy;

               double ratioy2 = mouthDistRef / mouthDistNew;

               double errFitting1 = /*Math.Abs*/(1 - ratioy2);

               ///Align the chin
               Point newptChin = new Point(chinNew.pt.X * ratiox, chinNew.pt.Y * ratioy);
               double chinDistRef = chinRef.pt.Y - mouthRef.pt.Y;

               double chinDistNew = newptChin.Y - newptMouth.Y;//noseNew.pt.Y * ratioy;

               double ratioy3 = chinDistRef / chinDistNew;

               double errFitting2 = /*Math.Abs*/(1 - ratioy3);

               double score = Math.Abs(errFitting)*4+ Math.Abs(errFitting1)*2+ 
                              Math.Abs(errFitting2);
               string fittingerr = (int)(errFitting*100)+":"+ 
                                   (int)(errFitting1*100) +":"+ (int)(errFitting2*100);
               Tuple<string,string,double> tp=new Tuple<string,string,double> 
                                              (infofilename,fittingerr,score);
               listerr.Add(tp);           
            }
            var sortedlist = listerr.OrderBy(o => o.Item3).ToList();
            string selected=sortedlist[0].Item1;
            var v=selected.Split('\\');
            var v2 = v[v.Length - 1].Split('.');
            string meshname = v2[0].Replace("mesh","");
            return meshname ;
        }

使用 WritableBitmap 实现放大镜。这是一个简单但非常有用的放大镜实现。其理念是 WPF 图像的宽度和高度设置为Auto时,在容器(Grid/Window)大小调整时会拉伸。在 xaml 文件中,我们将Window3的大小设置为 50X50。

<Window x:Class="ThreeDFaces.Window3"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Height="50" 
                 Width="50" WindowStyle="None"  
                 PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown" 
                 PreviewMouseMove="Window_PreviewMouseMove" Loaded="Window_Loaded" 
                 SizeChanged="Window_SizeChanged">
    <Grid Name="MainGrid">
        <Image Name="Image1" HorizontalAlignment="Left" Height="Auto" 
                             VerticalAlignment="Top"  Width="Auto"/>
    </Grid>
</Window>

winMagnifier是对Window3的引用。当我们第一次创建一个新的Window3时,我们将winMagnifierImage1image source初始化为一个WritetableBitmap(大小为 50X50)。

            _wbitmap = new WriteableBitmap(50, 50, 96, 96, PixelFormats.Bgra32, null);
            winMagnifier = new Window3();
            winMagnifier.Image1.Source = _wbitmap;
            UpdateMagnifier(0, 0);
            winMagnifier.Owner = this;
            winMagnifier.Show();

当窗口加载时,我们将其大小调整为 150X150,这样图像看起来就像被放大了 3 倍。我们还必须确保保持纵横比,以免窗口被不成比例地拉伸。我们实现了一个计时器,该计时器在窗口大小调整时检查窗口宽度是否等于窗口高度。

        private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            this.Width = 150;
            this.Height = 150;
            _resizeTimer.Tick += _resizeTimer_Tick;
        }

        void _resizeTimer_Tick(object sender, EventArgs e)
        {
            _resizeTimer.IsEnabled = false;
            if (bHeightChanged)
                this.Width = this.Height;
            else
                this.Height = this.Width;
        }

        private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
        {
            Size oldsize = e.PreviousSize;
            Size newsize = e.NewSize;

            bHeightChanged = ((int)oldsize.Height) == ((int)newsize.Height) ? false : true;

            _resizeTimer.IsEnabled = true;
            _resizeTimer.Stop();
            _resizeTimer.Start();  
        }

在调用过程中,我们有一个方法可以更新WritetableBitmap,它是winMagnifierImage1的源。这样,每次调用UpdateMagnifier时,winMagnifier的内容都会改变。

   private void UpdateMagnifier(int x, int y)
        {
            try
            {
                BitmapImage bmi = Image1.Source as BitmapImage;
                int byteperpixel=(bmi.Format.BitsPerPixel + 7) / 8;
                int stride = bmi.PixelWidth * byteperpixel;
                byte[] _buffer = new byte[50 * stride];

                bmi.CopyPixels(new Int32Rect(x, y, 50, 50), _buffer, stride, 0);
                //Draw the cross bars
                for (int i = 0; i < 50;i++ )
                    for (int k = 0; k < 2;k++ )
                        _buffer[24 * stride + i * byteperpixel+k] = 255;

                for (int j = 0; j < 50;j++ )
                    for (int k = 0; k < 2; k++)
                    {
                        _buffer[j * stride + 24 * byteperpixel + k] = 255;
                    }

                    _wbitmap.WritePixels(new Int32Rect(0, 0, 50, 50), _buffer, stride, 0);
            }
            catch
            {

            }
        }

演示

用于犯罪调查:从以前的正面视图获取侧面轮廓,以匹配同一人的另一个侧面视图。

威廉·莎士比亚面具拟合我们所有的 6 个面部模型

仅为娱乐,使用阴影、颜色和方向

注释

嘴巴张开露出牙齿的面孔在侧视图中效果不佳。牙齿似乎会从嘴巴里突出来。

眼睛明显不水平的面孔效果不佳。

我在\Images目录中包含了一些用于测试的图像。这些图片的来源是

更新

2.0 版开始,您可以处理包含多个面部的图像。

加载包含多个面部的图像时,会弹出一个面部选择窗口,其中所有检测到的面部都用红色矩形标记。通过在矩形内双击来选择一个面部。面部选择窗口将最小化,选定的面部将出现在面部拟合窗口中。如果您在不进行任何选择的情况下最小化面部选择窗口,第一个检测到的面部将被自动选中。如果您关闭面部选择窗口,整个多个面部图像将出现在面部拟合窗口中。

如果您想从多个面部图像中选择另一个面部,只需从任务栏中还原面部选择窗口并进行另一次选择,无需重新加载文件中的多个面部图像

我还包含了一个包含许多面部的测试文件在\GroupImages目录中。

2.2 版中,您可以为模型创建动画 GIF 文件,通过围绕 Y 轴左右旋转相机。

 

动画 GIF 的编码器来自 Code Project 文章:NGif, Animated GIF Encoder for .NET

2.3 版中,您可以进行面部左右映射。右键单击视口中的面部模型,将出现一个上下文菜单,供您选择 1) 映射左侧,2) 映射右侧或 3) 无映射。

如果选择映射左侧,左侧面部(即出现在屏幕右侧的面部侧面)将被右侧面部替换。同样,映射右侧将右侧面部替换为左侧面部。参见下图。

为了正确进行面部映射,拉伸后的面孔应对齐,使眼睛水平且鼻基与叠加在面部网格上的图像上的鼻基标记对齐。参见下图。

为了进行微调,您可以移动面部拟合窗口中的眼睛和鼻子标记,并观察面部网格叠加图像和面部模型中的变化。请注意,如果您一个眼睛定位器比另一个高,您实际上可以旋转面部图像相对于面部网格。

我在/temp目录中包含了所有面部图像(来自/Images目录)的面部点信息文件。您可以测试来自/Images/ronald-reagan.jpg的上述图像。文件加载后,面部点将被标记,然后您可以单击最佳拟合按钮来更新面部模型,然后右键单击它来选择您想要的面部映射

3.0 版中,您可以基于 6 个基础面部模型中的任何一个来添加和编辑面部网格。

要创建自己的面部模型

  1. 从右侧面部模型网格列中加载任何一个 6 个基础面部模型。
  2. 加载面部模型后,右键单击右下角的面部网格。

要编辑或删除任何新创建的面部模型,请滚动到新面部模型并右键单击它。将弹出一个上下文菜单,允许您编辑/删除该面部模型。

要编辑面部模型,请使用面部模型编辑窗口四个边缘的四个滑块中的任何一个来旋转和拉伸面部模型。

我还改进了网格编辑。现在有精细的功能,允许您在 3D 空间中选择和移动面部网格点。移动鼠标进行选择,然后移除面部点进行编辑。按键移动选定的点。请参阅移动鼠标时和单击某个点时的 UI 说明。

4.0 版本

4.0 版中,我添加了基于Microsoft Cognitive Services Face API面部匹配功能。要使用Face API,您需要从此网站获取 API 密钥:https://azure.microsoft.com/en-us/try/cognitive-services/?api=face-api

API 密钥是一个32 个字符的字符串,唯一标识Face API 用户。

获得API 密钥后,您就可以通过Face Matcher Window开始使用Face API

要启动Face Matcher Window,您可以单击Show Matcher按钮,或右键单击任何快照图像弹出上下文菜单,如上图所示,然后选择Show Matcher Window菜单项。

启动窗口后,在Key文本框中键入或粘贴 32 个字符的API 密钥。对于Region文本框,您可以保留默认区域westen-central,除非您从不同区域获取了API 密钥。然后单击Generate Face Service按钮。API 密钥将在网上进行验证,并创建一个Face Service Client 对象

2 个面板,左侧是面板 1,右侧是面板 2。对于每个面板,您可以使用Browse..按钮加载面部图像,从您的计算机中选择图像文件。文件选定后,它将通过 Web 服务调用发送到后端服务器上的Azure 云服务器。该过程使用await-async机制完成,因此您可以在第一个文件处理完成之前在另一个面板上加载另一个文件。

或者,您可以使用主窗口的快照面板将任何快照加载到Face Matcher Window。右键单击任何一个快照以选择它并弹出上下文菜单,然后选择子菜单项Load 1将面部图像加载到左面板 1,或选择Load 2将图像加载到右面板 2。同样,您可以在第一个面部处理完成之前加载第二个面部。

文件处理后,Face Matcher Window 的标题栏将显示检测到的面部数量。如果检测到任何面部,面板图像将显示加载的图像内容,所有面部都用框标出。对于多个面部,将选择一个用红色框标出的默认面部进行匹配,否则将选择唯一检测到的面部。要在多面部图像中选择不同的面部进行匹配,只需单击用绿色框标出的任何一个面部,它将被选中并用红色框标出。

从每个面板中选择用于匹配的面部将并排显示在Match按钮旁边的缩略图上。单击Match按钮将面部发送进行匹配。匹配的结果将通过 2 个返回值反映:Is Identical(布尔值)和Confidence(介于 0 到 1 之间的双精度值)。匹配的面孔将具有:Is Identical设置为true,且Confidence值为0.5或更高。面孔越相似,Confidence分数越高。

请注意,您从https://azure.microsoft.com/en-us/try/cognitive-services/?api=face-api获得的API 密钥将在 30 天后过期。要获取永久密钥,您需要注册Microsoft Azure Services 账户。目前MicrosoftAzure订阅用户提供Face API的免费套餐。

免费试用和免费套餐的限制

每分钟最多 20 次 Web 服务调用,每月 30000 次调用。

面部匹配器的代码亮点

创建 FaceServiceClient 对象

faceServiceClient = 
new FaceClient(
new ApiKeyServiceClientCredentials(Key.Text ),
new System.Net.Http.DelegatingHandler[] { });

使用API 密钥Region参数实例化一个FaceServiceClient对象,并将其分配给faceServiceClient IFaceServiceClient接口,该接口公开了许多面部操作。在本文中,我们将使用其中的两个操作

  • DetectAsync
  • VerifyAsync

异步将图像文件加载到后端服务器以进行面部检测

    private async Task<DetectedFace[]> UploadAndDetectFaces(string imageFilePath)
        {
            // The list of Face attributes to return.
            IList<FaceAttributeType?> faceAttributes =
                new FaceAttributeType?[] { FaceAttributeType.Gender,
                                         FaceAttributeType.Age,
                                         FaceAttributeType.Smile,
                                         FaceAttributeType.Emotion,
                                         FaceAttributeType.Glasses,
                                         FaceAttributeType.Hair,
                                         FaceAttributeType.Blur,
                                         FaceAttributeType.Noise};

            // Call the Face API.
            try
            {
                using (Stream imageFileStream = File.OpenRead(imageFilePath))
                {
                    IList<DetectedFace> faces = 
                    await faceServiceClient.Face.DetectWithStreamAsync(imageFileStream,
                                            returnFaceId: true,
                                            returnFaceLandmarks: false,
                                            returnFaceAttributes: faceAttributes);
                    if (faces.Count > 0)
                    {
                        DetectedFace[] list = new DetectedFace[faces.Count];
                        faces.CopyTo(list, 0);
                        return list;
                    } else
                        return new DetectedFace[0];
                }
            }
            // Catch and display Face API errors.
            catch (APIErrorException f)
            {
                MessageBox.Show(f.Message);
                return new DetectedFace[0];
            }
            // Catch and display all other errors.
            catch (Exception e)
            {
                MessageBox.Show(e.Message, "Error");
                return new DetectedFace[0];
            }
        }

DetectAsync函数接收图像数据(作为 IO Stream)以及一些设置参数,并返回检测到的面部的Face数组。当returnFaceId设置为true时,每个返回的Face对象将拥有一个Guid唯一标识符,用于标识后端服务器上的检测到的面部。returnFaceAttributes用于指定在Face对象中返回哪些FaceAttributes。在上面的代码中,我们指定了AgeSexHair...的FaceAttributeType。如果我们还想获取FaceLandmarks,则将returnFaceLandmarks设置为trueFaceLandmarks是检测到的面部地标的 2D 坐标:左瞳孔、右瞳孔、鼻尖。

异步面部匹配

//Verify UUID of faces

private async Task<VerifyResult> VerifyFaces(Guid faceId1, Guid faceId2)
{
    VerifyResult result = await faceServiceClient.Face.VerifyFaceToFaceAsync(faceId1, faceId2);
    return result;
}

对于面部匹配,我们调用VerifyAsync函数,传入两个先前检测到的面部的Guid,其Guid是已知的。请注意,在我们先前调用DetectAsync时,我们将returnFaceId设置为true,以便获得所有检测到的面部的Guid以用于面部匹配VeriyAsync函数的返回是一个VerifyResult对象,该对象包含IsIdenticalbool)和Confidencedouble)属性。

历史

2022年3月20日:.NET6 版本

  • 改进的 GIF 输出
  • 精细的面部网格编辑

2022年3月10日:.NET6 版本

  • .NET 6 支持。需要 Visual Studio 2022

2022年3月1日:版本 4.0.1.1

  • 更新 Newtonsoft.Json 以支持使用 Microsoft Face AP1 2.0

2021年1月31日:版本 4.0.1

  • 使用 Microsoft Face AP1 2.0 更新面部匹配功能

2017年6月28日:版本 4.0

  • 包含使用 Microsoft Face API 的面部匹配功能

2017年6月28日:版本 3.0

  • 错误修复:使用 CultureInfo.InvariantCulture 处理文本文件中的所有十进制分隔符

2017年6月26日:版本 3.0

  • 新功能:启用其他面部模型的创建
  • 错误修复:默认系统文化为"en-us",以便识别网格文件中的十进制分隔符"."

2017年5月28日:版本 2.3

  • 新功能:面部左右映射。在/temp目录中包含面部点信息文件。

2017年5月7日:版本 2.2

  • 新功能:创建动画 GIF
  • 错误修复:修复 WPF 控件刷新/更新不一致的问题

2017年5月5日:版本 2.1

  • 新功能:缓存多个面孔,提高多面孔图像的眼睛检测精度
  • 错误修复:使用后处理 GDI+ Bitmaps 以释放内存

2017年5月2日:版本 2.0

  • 新功能:处理多个面孔图像文件

2017年4月30日:版本 1.2A

  • 新功能
    • 缓存面部点以供面部文件使用
    • 更准确的眼睛检测
    • 自动裁剪面部以便更好地显示在拟合窗口中

2017年4月28日:版本 1.2

  • 文章部分
    • 校对和修正拼写错误
    • 提供更多关于拟合误差校正的信息

2017年4月27日:版本 1.2

  • Bug 修复
    • 在 OpenCV 包装器中为 cvSaveImage 函数添加了第三个参数,以匹配 OpenCV .h 文件中的原始规范
    • 通过修复直方图均衡化的实现来改进眼睛检测

2017年4月26日:版本 1.1

  • Bug 修复
    • 包含对输入面部点和所选面部文件的验证
    • 允许 8 位图像文件

2017年4月24日:版本 1.0

  • 首次发布
© . All rights reserved.