dlib人脸关键点检测的模型分析与压缩

人脸关键点检测的技术在很多领域上都有应用,首先是人脸识别,常见的人脸算法其实都会有一步,就是把人脸的图像进行对齐,而这个对齐就是通过关键点实现的,因此关于人脸关键点检测的论文也常叫face alignment,也就是人脸对齐。另一方面,对于美颜,2D/3D建模等等也需要一来人脸的关键点技术,而且通常也要求有尽可能多的人脸关键点。

Dlib is a modern C++ toolkit containing machine learning algorithms and tools for creating complex software in C++ to solve real world problems. It is used in both industry and academia in a wide range of domains including robotics, embedded devices, mobile phones, and large high performance computing environments. Dlib's open source licensing allows you to use it in any application, free of charge.

Dlib是一个包含了大量的机器学习和复杂软件开发工具的现代C++工具箱,被广泛的用于软件开发等领域。

本篇博客主要研究的就是Dlib中的人脸关键点检测的工具。该工具的方法依据是 One Millisecond Face Alignment with an Ensemble of Regression Trees by Vahid Kazemi and Josephine Sullivan, CVPR 2014 这篇论文,在速度和精度上均达到了极好的效果。 本文的侧重点在于人脸关键点模型的存储结构的分析和模型的压缩策略分析,最终在性能几乎不变的情况下,得到模型的至少10倍的压缩比。项目最终的github地址为:https://github.com/miaoerduo/dlib-face-landmark-compression 欢迎fork、star和pr。

注意:

  1. 本文假定了读者对该论文有一定的了解,可以使用Dlib完成人脸关键点的训练和部署,因此不做论文的相关方法的解释。
  2. 本文中分析的数据都是Dlib的shape_predictor类的私有成员,这里不得不把他们的修饰符从private改成了public,但文中并没有专门指出。
  3. 本文中所有的代码均在本地的64位操作系统上运行,在变量数据存储的大小描述的时候也均以64位来说明,即使是不同的编译器也会对数据大小造成影响,但这不是本文的重点。
  4. 本文中的数据类型如果不在C++中见到的数据类型,则为下面的typedef的数据类型:
typedef char        int8;
typedef short       int16;
typedef int         int32;
typedef long long   int64;
typedef float       float32;
typedef double      float64;
typedef unsigned char       uint8;
typedef unsigned short      uint16;
typedef unsigned int        uint32;
typedef unsigned long long  uint64;

Dlib中人脸关键点实现的类是dlib::shape_predictor,源码为:https://github.com/davisking/dlib/blob/master/dlib/image_processing/shape_predictor.h

这里简单的抽取了数据相关的接口定义:

namespace dlib
{
    namespace impl
    {
        struct split_feature
        {
            unsigned long idx1;
            unsigned long idx2;
            float thresh;
        };

        struct regression_tree
        {
            std::vector<split_feature> splits;
            std::vector<matrix<float,0,1> > leaf_values;
        };
    }

    class shape_predictor
    {
    private:
        matrix<float,0,1> initial_shape;
        std::vector<std::vector<impl::regression_tree> > forests;
        std::vector<std::vector<unsigned long> > anchor_idx;
        std::vector<std::vector<dlib::vector<float,2> > > deltas;
    };
}

下面,我们逐一对每个部分的参数进行分析。Dlib内置了很多的数据类型,像vectormetrix等等,每种数据类型又可以单独序列化成二进制的数据。对于shape_predictor的序列化,本质上就是不断的调用成员变量数据的序列化方法,由此极大地简化代码,提高了代码的复用率。

inline void serialize (const shape_predictor& item, std::ostream& out)
{
    int version = 1;
    dlib::serialize(version, out);
    dlib::serialize(item.initial_shape, out);
    dlib::serialize(item.forests, out);
    dlib::serialize(item.anchor_idx, out);
    dlib::serialize(item.deltas, out);
}

但,对于移动端等应用场景,需要模型占用尽可能少的存储空间,这样一来,这些标准的存储方式就会造成数据的很大程度的冗余。我们的任务就是一点点的减少这些冗余,只存有用的数据。

一、常量部分

首先,我们需要知道一些常量的数据。这些数据完成了对模型的描述。 [table id=6 /]

变量名 数据类型 作用
version uint64 记录模型版本号
cascade_depth uint64 回归树的级数
num_trees_per_cascade_level uint64 每一级中的树的个数
tree_depth uint64 树的深度
feature_pool_size uint64 特征池的大小
landmark_num uint64 特征点的数目
quantization_num uint64 量化的级数
prune_thresh float32 剪枝的阈值

二、初始形状 initial_shape

matrix<float,0,1> initial_shape; 表示的是初始化人脸关键点的坐标,存储类型是float型,个数为landmark_num * 2(不要忘了一个点是两个数组成 :P)。

三、锚点 anchor_idx

std::vector<std::vector<unsigned long> > anchor_idx; 是一个二维的数组,存放的是landmark点的下标。在常见的68点和192点的任务中,使用一个uint8就可以存放下标,而这里使用的是unsigned long,显然过于冗余,这里可以简化成uint8存储。这个二维数组的大小为 cascade_depth * feature_pool_size。每一级回归树使用一套锚点。

四、deltas

std::vector<std::vector<dlib::vector<float,2> > > deltas;和anchor_idx类似,是一个二维数组,不同的是,数组的每个值都是dlib::vector<float,2>的结构。这个数组的大小为cascade_depth * feature_pool_size * 2,存放的内容是float数值。考虑到这里的参数量很少,没有压缩的必要,这里我们直接存储原数据。

五、森林 forests

这部分是模型参数量最大的部分,一个模型大概2/3的存储都耗在了这个地方。这里才是我们压缩的重点! std::vector<std::vector<impl::regression_tree> > forests;一个shape_predictor中,有cascade_depth级,每一级有num_trees_per_cascade_level棵树。对于每棵树,它主要存放了两个部分的数据:分割的阈值splits和叶子的值leaf_values。为了便于阅读,再把数据结构的定义附上。

namespace dlib
{
    namespace impl
    {
        struct split_feature
        {
            unsigned long idx1;
            unsigned long idx2;
            float thresh;
        };

        struct regression_tree
        {
            std::vector<split_feature> splits;
            std::vector<matrix<float,0,1> > leaf_values;
        };
    } // end namespace impl
}

5.1 splits

splits存放的数据是阈值和特征像素值的下标,这个下标的范围是[0, feature_pool_size),在通常情况下,feature_pool_size不会太大,论文中最大也就设到了2000。这里我们可以使用一个uint16来存储。thresh就直接存储。对于一棵树,树的深度为tree_depth,则有 2^tree_depth - 1个split_node。(这里认为只有根节点的树深度为0)。

5.2 leaf_values

std::vector<matrix<float,0,1> > leaf_values;对于深度为tree_depth的树,有2^tree_depth个叶子节点。对于每个叶子节点,需要存储整个关键点的偏移量,也就是说每个节点存放了landmark_num * 2float的数值。那么这部分的参数量到底有多大呢?

举个例子,在cascade_num为10,num_trees_per_cascade_level为500,tree_depth为5,landmark_num为68的时候。leaf_values的值有cascade_num * num_trees_per_cascade_level * (2 ^ tree_depth) * landmark_num * 2 = 21760000 = 20.8M的参数量,由于使用float存储,通常一个float是4个字节,因此总的存储量达到了逆天的80MB!远大于其他的参数的总和。 那么如何才能有效的降低这部分的存储量呢? 这就要要用到传说中的模型压缩三件套:剪枝,量化编码

5.2.1 参数分布分析

首先笔者统计了参数的分布,大致的情况是这样的,(具体的结果找不到了)。 叶子节点里的参数的范围在[-0.11, 0.11]之间,其中[-0.0001, 0.0001]的参数占了50%以上。说明模型中有大量的十分接近0的数字。

5.2.2 剪枝

剪枝的策略十分粗暴,选择一个剪枝的阈值prune_thresh,将模小于阈值的数全部置0。

5.2.3 量化

量化的过程,首先获取数据中的最小值和最大值,记为:leaf_min_valueleaf_max_value。之后根据量化的级数quantization_num,计算出每一级的步长:quantization_precision = (leaf_max_value - leaf_min_value) / quantization_num。之后对于任意数值x,那么它最终为x / quantization_precision进行四舍五入的结果。这样就可以把float的数字转换成整形来表示。量化级数越高,则量化之后的值损失就越小。

5.3.3 编码

如果我们不做任何的编码操作,直接存储量化之后的结果,也是可以一定程度上进行模型的压缩的。比如使用256级量化,则量化的结果使用一个uint8就可以存储,从而把存储量降为原来的1/4。但是这样有两个问题:1,依赖量化的级数;2,存储量减少不大。 在信息论中有个信息熵的概念。为了验证存储上的可以再优化,这里选择了一个68点的模型,经过256级量化之后,计算出信息熵(信息熵的计算请查阅其他的资料),其数值为1.53313,也就是说,理想情况下,一个数值只需要不到2 bits就可以存储了。如果不编码则需要8 bits。压缩比为1.53313 / 8 = 19.2%,前者仅为后者的1/5不到!

这里,我采用的是经典的huffman编码,使用了github上的https://github.com/ningke/huffman-codes项目中的代码,感谢作者的贡献!原项目中只能对char类型的数据进行编码,因此这里也做了相应的修改,以适应于int类型的编码,同时删除了一些用不到的函数。使用huffman对上述的256级的数值进行编码,最终的每个数字的平均长度为1.75313,已经很接近理想情况。使用huffman编码时,同时需要将码表进行储存,这部分细节较为繁琐,读者可以自行阅读源码。

至此,Dlib的模型的分析和压缩就全部介绍完了。对代码感兴趣的同学可以在:https://github.com/miaoerduo/dlib-face-landmark-compression ,也就是我的github上clone到最新的代码,代码我目前也在不断的测试,如果有问题,也会及时更新的。

在本地的实验中,原模型的大小为127M,压缩之后只有5.9M,且性能几乎不变(这里prune_thresh设为0.0001,quantization_num设为256,quantization_num设置越大,则精度越接近原模型,同时prune_thresh的大小很多时候是没有用的)。

马上就要毕业了,希望写博客的习惯能够一直保持下去。

最后,再一次,希望小喵能和大家一起学习和进步~~