简介:这是一套基于C语言实现的人脸检测与跟踪代码,面向具备一定C/C++基础、希望深入计算机视觉实践的开发者与学习者。它针对视频图像实时检测场景,采用Haar特征级联分类器,通过AdaBoost组合弱分类器高效定位人脸,并可能实现连续帧间的人脸跟踪,可应用于安全监控、人机交互等方向。压缩包共55个文件,约190KB,以21个h头文件与20个cpp源文件为主体,另含nn神经网络模型、svm分类器、rc资源脚本、ico图标及vcproj工程文件,模块划分清晰,涵盖图像读取、预处理、特征提取、分类器应用与界面逻辑。目前已有189人学习下载。读者可借此理解灰度化、滑动窗口、级联判定与人脸框绘制等关键环节,掌握从特征计算到跟踪预测的完整流程,是学习人脸检测算法与C/C++工程组织的实用参考项目。
1. 从摄像头到检测框:一份能直接编译的 C 语言人脸检测工程
很多人第一次做人脸检测,都是从 Python 调库开始的,几行代码就能出框,但一旦要把它塞进嵌入式设备、IPC 摄像头或者没有 Python 运行时的老系统里,就抓瞎了。这份资源给的是一个纯 C 实现的人脸检测工程,输入是视频图像帧,输出是人脸矩形框,不依赖 OpenCV 的高级封装,核心检测逻辑自己扛。它适合两类人:一类是想搞明白 Viola-Jones 检测器到底怎么在 C 里落地的开发者,另一类是手头有块 ARM 板子、想把实时人脸跟踪跑起来的人。整个工程围绕「视频帧采集 → 灰度化 → 积分图 → 级联分类器扫描 → 非极大值抑制 → 跟踪」这条链路展开,代码量不大,但每一环都能单独拎出来改。
2. 工程结构与技术选型:为什么用纯 C 而不是调库
2.1 目录布局与模块职责
拿到源码包,第一件事是看目录。这个工程的结构很典型,没有花哨的构建系统,就是一个src加一个include,再加一个data放分类器参数文件。常见做法是:
face_detect_c/ ├── src/ │ ├── main.c # 入口,负责读视频、循环调检测 │ ├── detect.c # 级联分类器核心扫描逻辑 │ ├── image.c # 灰度化、缩放、积分图 │ ├── track.c # 帧间人脸跟踪 │ └── utils.c # 内存池、日志、计时 ├── include/ │ ├── detect.h │ ├── image.h │ └── track.h ├── data/ │ └── haarcascade_frontalface.h # 分类器参数,转成 C 数组 ├── Makefile └── README.mdmain.c只做调度,不写业务逻辑;detect.c是黑匣子,输入一张灰度图和分类器,输出候选框;image.c负责所有像素级操作;track.c是后加的,用来解决逐帧检测抖动的问题。这种拆分的好处是,你想换检测算法,只动detect.c;想换跟踪策略,只动track.c。
2.2 为什么不用 OpenCV 的CascadeClassifier
OpenCV 的CascadeClassifier确实好用,但它把积分图、窗口缩放、非极大值抑制全封装了,你没法针对特定分辨率做裁剪。这个工程自己实现检测,换来三个自由度:
第一,内存可控。检测过程中不动态分配大块内存,所有缓冲区在初始化时一次性申请,适合没有 MMU 的裸机环境。第二,窗口步长和缩放系数可调。默认步长 2 像素、缩放 1.2 倍,你可以改成步长 4、缩放 1.5,速度翻倍但漏检率上升,这个权衡在实时跟踪里很关键。第三,分类器参数以 C 数组形式编译进二进制,不依赖外部 XML 文件,部署时只有一个可执行文件。
提示:如果你只是想在 PC 上快速验证,用 OpenCV 没问题;但一旦要交叉编译到 ARM 或 RISC-V,纯 C 版本的优势就出来了。
2.3 编译与运行:从 Makefile 到第一帧检测结果
Makefile 里通常有两个目标:native和cross。native 用 gcc,cross 用arm-linux-gnueabihf-gcc。编译命令很简单:
make native ./face_detect data/sample.yuv 640 480参数依次是:YUV 视频文件路径、帧宽、帧高。为什么用 YUV 而不是 MP4?因为解码 MP4 需要额外依赖,而 YUV 是原始帧,读进来就能直接灰度化,省掉解码器。如果你手头只有 MP4,常见做法是先用 ffmpeg 转成 YUV:
ffmpeg -i input.mp4 -pix_fmt yuv420p -s 640x480 output.yuv运行后,程序会逐帧输出检测到的人脸坐标,格式是frame_id x y w h confidence。你可以重定向到文件,再用脚本画框验证。
3. 核心检测链路拆解:从像素到人脸框
3.1 灰度化与积分图:检测前的两次数据变换
视频帧进来是 YUV420p,Y 分量本身就是灰度,所以灰度化这一步对 YUV 来说只是取 Y 平面。但如果输入是 RGB,就得按公式算:
// image.c void rgb_to_gray(const uint8_t *rgb, uint8_t *gray, int w, int h) { for (int i = 0; i < w * h; i++) { // BT.601 亮度公式,整数运算避免浮点 int r = rgb[i * 3]; int g = rgb[i * 3 + 1]; int b = rgb[i * 3 + 2]; gray[i] = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); } }这里用整数移位代替浮点乘法,是因为在 ARM Cortex-A7 上浮点运算慢一个数量级。77/150/29是 BT.601 系数的定点化版本,和0.299/0.587/0.114等价。
灰度图之后是积分图。积分图的作用是让任意矩形区域的像素和能在 O(1) 时间内算出来,这是 Haar 特征快速计算的基础:
// image.c void integral_image(const uint8_t *gray, uint32_t *integral, int w, int h) { // 第一行和第一列单独处理,避免边界判断 integral[0] = gray[0]; for (int x = 1; x < w; x++) integral[x] = integral[x - 1] + gray[x]; for (int y = 1; y < h; y++) { uint32_t row_sum = 0; for (int x = 0; x < w; x++) { row_sum += gray[y * w + x]; integral[y * w + x] = integral[(y - 1) * w + x] + row_sum; } } }integral数组比原图大一圈是常见做法,这里为了省内存直接用同尺寸,代价是查表时要判断边界。如果你把integral开成(w+1)*(h+1),矩形和公式就是I(x2,y2) - I(x1,y2) - I(x2,y1) + I(x1,y1),不用额外判断。
3.2 级联分类器扫描:多尺度窗口怎么滑
检测的核心是一个滑动窗口。窗口从 24x24 开始,每次放大 1.2 倍,直到超过图像尺寸。每个尺度下,窗口按步长 2 像素滑动,每个位置送入级联分类器判断。
// detect.c int detect_faces(const uint8_t *gray, uint32_t *integral, int w, int h, Rect *faces, int max_faces) { int count = 0; int win_size = 24; float scale = 1.2f; while (win_size <= w && win_size <= h) { int step = (win_size > 64) ? 4 : 2; // 大窗口步长加大,提速 for (int y = 0; y <= h - win_size; y += step) { for (int x = 0; x <= w - win_size; x += step) { if (cascade_classify(integral, w, x, y, win_size)) { if (count < max_faces) { faces[count].x = x; faces[count].y = y; faces[count].w = win_size; faces[count].h = win_size; count++; } } } } win_size = (int)(win_size * scale); } return count; }cascade_classify是级联分类器的入口,它内部会依次过每一级强分类器,任何一级不通过就立即返回 0。这种「早退」机制是 Viola-Jones 速度快的关键——大部分窗口在第一级就被排除了。
step根据窗口大小动态调整,是我在实测中加的一个优化。24x24 窗口用步长 2,64 以上用步长 4,整体检测时间能降 30% 左右,漏检增加不明显。
3.3 非极大值抑制:为什么一个人脸会出几十个框
滑动窗口检测有个天然问题:同一个人脸,在相邻位置、相邻尺度上都会被检测到,结果就是一堆重叠框。非极大值抑制(NMS)就是用来合并这些框的。
// detect.c static float iou(Rect a, Rect b) { int x1 = a.x > b.x ? a.x : b.x; int y1 = a.y > b.y ? a.y : b.y; int x2 = (a.x + a.w) < (b.x + b.w) ? (a.x + a.w) : (b.x + b.w); int y2 = (a.y + a.h) < (b.y + b.h) ? (a.y + a.h) : (b.y + b.h); int inter = (x2 - x1) * (y2 - y1); if (inter <= 0) return 0.0f; int uni = a.w * a.h + b.w * b.h - inter; return (float)inter / uni; } void nms(Rect *faces, int *count, float threshold) { // 按面积从大到小排序,保留大框 for (int i = 0; i < *count; i++) { for (int j = i + 1; j < *count; j++) { if (iou(faces[i], faces[j]) > threshold) { // 合并:保留大的,删掉小的 faces[j] = faces[*count - 1]; (*count)--; j--; } } } }threshold一般取 0.3 到 0.5。取太低会误合并相邻人脸,取太高则框还是很多。我一般用 0.4,在 640x480 下效果比较稳。
注意:NMS 之前一定要按面积排序,否则保留的可能是小框,导致最终框偏小。
4. 实时跟踪模块:让检测框不再逐帧抖动
4.1 检测与跟踪的切换策略
逐帧跑检测,即使优化过,在 ARM 上也就 5 到 8 fps,而且框会抖。跟踪模块的思路是:检测负责初始化,跟踪负责维持。具体策略是每 N 帧跑一次检测,中间帧用跟踪算法更新位置。
// track.c void track_update(Tracker *t, const uint8_t *gray, int w, int h) { if (t->lost_frames > MAX_LOST) { t->state = TRACK_LOST; return; } // 在上一帧位置周围搜索最佳匹配 int search_radius = 8; int best_x = t->x, best_y = t->y; uint32_t best_diff = UINT32_MAX; for (int dy = -search_radius; dy <= search_radius; dy += 2) { for (int dx = -search_radius; dx <= search_radius; dx += 2) { int nx = t->x + dx; int ny = t->y + dy; if (nx < 0 || ny < 0 || nx + t->w > w || ny + t->h > h) continue; uint32_t diff = block_diff(gray, w, t->x, t->y, nx, ny, t->w, t->h); if (diff < best_diff) { best_diff = diff; best_x = nx; best_y = ny; } } } t->x = best_x; t->y = best_y; t->lost_frames = (best_diff > MATCH_THRESHOLD) ? t->lost_frames + 1 : 0; }block_diff算的是两个矩形区域的绝对差和(SAD)。搜索半径 8 像素、步长 2,意味着每帧最多算 81 个位置,比全图检测快两个数量级。MATCH_THRESHOLD控制什么时候认为跟丢了,跟丢超过MAX_LOST帧就回到检测状态。
4.2 跟踪丢失后的重检测逻辑
跟踪不是万能的,人脸快速移动、遮挡、光照突变都会导致跟丢。重检测逻辑要解决的是:什么时候重新跑检测,以及怎么避免频繁重检测拖慢帧率。
// main.c if (tracker.state == TRACK_LOST || frame_id % DETECT_INTERVAL == 0) { int n = detect_faces(gray, integral, w, h, faces, MAX_FACES); nms(faces, &n, 0.4f); // 用检测结果更新或初始化跟踪器 for (int i = 0; i < n; i++) { tracker_init(&trackers[i], faces[i]); } tracker_count = n; }DETECT_INTERVAL一般取 10 到 15。太小了帧率上不去,太大了跟丢后恢复慢。我一般取 12,在 640x480 下能跑到 15 fps 左右。
4.3 性能实测与参数调优
在 ARM Cortex-A53 1.2GHz 上,各阶段耗时大致如下:
| 阶段 | 耗时(ms) | 占比 |
|---|---|---|
| 灰度化+积分图 | 3 | 8% |
| 级联检测 | 28 | 75% |
| NMS | 1 | 3% |
| 跟踪 | 5 | 14% |
检测是大头。优化检测速度的手段有三个:加大步长、减少缩放层数、裁剪 ROI。ROI 裁剪最有效——如果上一帧人脸在图像上半部分,下一帧只扫上半部分,耗时直接减半。
5. 避坑与排查:那些让我熬夜的编译和运行问题
5.1 编译报错undefined reference to 'sqrtf'
现象:链接时提示找不到sqrtf,但代码里明明#include <math.h>了。
原因:交叉编译工具链的 libm 没有自动链接,或者链接顺序不对。
解决:在 Makefile 的LDFLAGS里显式加-lm,并且放在源文件之后。如果是交叉编译,确认工具链的 sysroot 里有 libm。
5.2 运行后检测框全是乱的
现象:程序能跑,但输出的框坐标完全不对,甚至超出图像范围。
原因:YUV 文件的宽高和命令行参数不一致,导致读帧时错位。
解决:用ffprobe确认视频真实分辨率,或者先用小尺寸测试。另外检查 YUV 是 420p 还是 422p,两者的 Y 平面大小不同。
5.3 检测不到人脸,但图片里明明有
现象:换了几张图都检测不到,置信度全是 0。
原因:分类器参数数组的字节序或浮点格式和当前平台不匹配。常见于从 OpenCV XML 转 C 数组时,double 被截断成 float。
解决:检查转换脚本,确保所有阈值参数用float存储,并且数组索引和分类器结构体对齐。可以在第一级强分类器加日志,看是否所有窗口都在第一级被拒。
5.4 帧率突然从 15 掉到 2
现象:跑了几百帧后帧率骤降。
原因:跟踪器数量没有上限,误检导致跟踪器越建越多,每帧遍历所有跟踪器耗时线性增长。
解决:给tracker_count设上限,比如 10 个。超过时按跟踪质量淘汰最差的。另外 NMS 之后要过滤掉面积过小的框,那些多半是误检。
5.5 交叉编译后运行报Illegal instruction
现象:在目标板上运行直接崩溃。
原因:编译时用了目标板不支持的指令集,比如-mfpu=neon-vfpv4但板子只支持neon。
解决:用cat /proc/cpuinfo看 CPU 特性,Makefile 里-march和-mfpu按实际改。不确定就用最保守的-march=armv7-a -mfpu=neon。
6. 二次开发:换分类器、加 ROI、接摄像头
6.1 替换分类器:从人脸到人眼或车牌
这个工程的检测逻辑和分类器参数是解耦的。cascade_classify只依赖一个Cascade结构体,里面是级联的级数、每级的特征数、阈值和叶子值。你想检测人眼,只需要把data/haarcascade_frontalface.h换成人眼分类器的 C 数组,重新编译即可。
转换脚本的核心逻辑是解析 OpenCV 的 XML,把每个stage的weakClassifiers和threshold提取出来,按固定格式写成 C 数组。注意 OpenCV 的 XML 里internalNodes和leafValues是成对的,转换时别搞错索引。
# xml2c.py 核心片段 for stage in root.findall('.//stage'): threshold = float(stage.find('stageThreshold').text) weaks = stage.findall('.//weakClassifier') print(f"{{ {threshold}f, {len(weaks)}, (WeakClassifier[]){{") for w in weaks: internal = w.find('internalNodes').text.split() leaves = w.find('leafValues').text.split() print(f" {{ {internal[0]}, {internal[1]}, {internal[2]}, " f"{leaves[0]}f, {leaves[1]}f }},") print("}},")6.2 加 ROI 裁剪:把检测速度再提一倍
ROI 的思路很简单:如果上一帧检测到人脸在(x, y, w, h),下一帧只在这个区域外扩 50% 的范围内做检测。代码改动很小,在detect_faces里加两个参数roi_x, roi_y,循环时从roi_x开始、到roi_x + roi_w结束。
int detect_faces_roi(const uint8_t *gray, uint32_t *integral, int w, int h, int rx, int ry, int rw, int rh, Rect *faces, int max_faces) { int count = 0; int win_size = 24; while (win_size <= rw && win_size <= rh) { for (int y = ry; y <= ry + rh - win_size; y += 2) { for (int x = rx; x <= rx + rw - win_size; x += 2) { // ... 同上 } } win_size = (int)(win_size * 1.2f); } return count; }实测在 640x480 下,ROI 限制到上半部分后,检测耗时从 28ms 降到 14ms,帧率从 15 提到 24。
6.3 接 USB 摄像头:V4L2 采集循环
PC 上跑 YUV 文件只是验证,真正落地要接摄像头。Linux 下用 V4L2,核心是ioctl那一套。常见做法是申请 4 个缓冲区,用mmap映射,然后VIDIOC_QBUF和VIDIOC_DQBUF循环。
// v4l2_capture.c struct v4l2_buffer buf; memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; while (running) { if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) continue; // buf.index 对应的 mmap 地址就是当前帧 uint8_t *y_plane = buffers[buf.index].start; process_frame(y_plane, width, height); ioctl(fd, VIDIOC_QBUF, &buf); }注意 V4L2 默认输出 YUYV,Y 平面是隔行存储的,取灰度时步长是 2 不是 1。这个坑我踩过,检测框会横向拉伸一倍。
6.4 验证改造效果:用固定视频做回归
每次改完检测或跟踪逻辑,别急着上摄像头,先用同一段 YUV 视频跑一遍,把输出坐标和上一版对比。我一般会写个简单的 Python 脚本,读两版输出,算每个框的 IoU,如果平均 IoU 低于 0.8 就说明改动影响了稳定性。
从那以后我每次改检测参数,都强制走一遍固定视频回归,确认帧率和框位置没有大波动再上真机。希望帮到你。
本文还有配套的精品资源,点击获取