简介:面向计算机视觉入门与C/C++工程实践的实时人脸检测代码包,围绕视频图像中的人脸定位与跟踪展开,可用于安全监控、人机交互等场景的原理学习与二次开发。压缩包共55个文件、约190KB,以21个.h头文件和20个.cpp源文件为主体,另含8个.nn与1个.svm等模型数据,以及.vcproj工程、.rc资源、.ico图标和ReadMe.txt说明,src目录按图像采集、预处理、特征计算、分类器与跟踪等模块组织。已有189人学习,说明其作为轻量级实践样本具有一定参考价值。代码采用Haar特征与级联分类思路,配合滑动窗口完成多尺度检测,并绘制人脸框;视频流部分还涉及帧间相似性以预测目标位置。读者可据此梳理从图像读取到检测输出的完整链路,理解C/C++数据结构、图像处理与机器学习基础在工程中的落地方式,适合作为课程设计或算法复现的参考底稿。
1. 拿到一份 C 代码做人脸检测,先别急着调参
前段时间帮人看一个小活:一台普通 USB 摄像头,要在本地机器上实时框出画面里的人脸,最好还能给每个人一个稳定的编号。对方一开始想上深度学习,我劝住了——这种场景瓶颈根本不在模型精度,而在“每帧都要跑、跑完还要稳”。这份 C/C++ 人脸检测代码走的就是这条路:Haar 级联做检测,跟踪模块补帧间连续性,整套东西不依赖 GPU,编译出来一个可执行文件就能跑。它适合两类人:一类是想搞懂实时人脸检测完整链路的学生和刚转岗的开发者,另一类是要在嵌入式或低成本工控机上做二次开发的工程师。下面按“能干什么 → 怎么跑起来 → 每一段代码在干嘛 → 哪里会翻车 → 怎么改成自己的东西”这个顺序拆。
2. 项目骨架与技术选型:C++ 实时人脸检测的目录和依赖怎么组织
一套能在实机上跑起来的人脸检测程序,代码量往往不大,但分层一定要清楚。分不清层的典型后果是:main.cpp里塞了几百行,采集、检测、画框、跟踪搅在一起,等到要换检测器或者加业务逻辑时,只能推倒重写。这份代码的分层思路很朴素——采集归采集,检测归检测,跟踪归跟踪,主函数只做编排。
2.1 目录结构:把采集、检测、跟踪切成三层
工程目录大致是这个样子:
face_detect/ ├── CMakeLists.txt ├── include/ │ ├── detector.h # 检测器封装:初始化、detect、NMS │ ├── tracker.h # 跟踪器:Track 结构、update、tracks() │ └── frame_source.h # 采集抽象:摄像头 / 视频文件 / 图片序列 ├── src/ │ ├── main.cpp # 主循环,只做编排,不写算法 │ ├── detector.cpp │ ├── tracker.cpp │ └── frame_source.cpp ├── models/ │ └── haarcascade_frontalface_default.xml # 级联分类器参数文件 └── build/ # 编译产物,不入版本库frame_source这一层看着有点“过度设计”,但它是后面离线调参的关键。摄像头是实时流,调参时你没法回放,同一秒的画面看第二遍就没了;把采集抽象出来之后,喂一个视频文件进去,检测参数改一次跑一遍,几十秒就能对比出minNeighbors从 4 调到 6 到底少了多少个误检。我一般会保留这个抽象,哪怕只写三十行。
detector对外只暴露一个detect(const cv::Mat& gray, std::vector<cv::Rect>& out),内部包掉灰度图输入、多尺度扫描、重叠框合并。tracker对外只暴露update(dets)和tracks(),内部维护轨迹的生命周期。主函数拿到这两样东西,剩下的就是画框和按键退出。
2.2 依赖与构建:CMake 里那几个必须开的开关
技术栈是 C++17 + OpenCV 的 core / imgproc / videoio / highgui / objdetect 五个模块 + CMake,可选 OpenMP 做多尺度并行。别看模块多,真正干活的只有objdetect里的CascadeClassifier,其余都是读图、转灰度、画框、开窗口这些基础设施。
cmake_minimum_required(VERSION 3.10) project(face_detect CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) # 检测是纯 CPU 密集,Debug 下能慢好几倍 endif() find_package(OpenCV REQUIRED COMPONENTS core imgproc videoio highgui objdetect) find_package(OpenMP) add_executable(face_detect src/main.cpp src/detector.cpp src/tracker.cpp src/frame_source.cpp) target_include_directories(face_detect PRIVATE include) target_link_libraries(face_detect PRIVATE ${OpenCV_LIBS}) if(OpenMP_CXX_FOUND) target_link_libraries(face_detect PRIVATE OpenMP::OpenMP_CXX) endif() # 把模型目录拷到可执行文件旁边,避免运行时因为工作目录不同找不到 xml add_custom_command(TARGET face_detect POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/models $<TARGET_FILE_DIR:face_detect>/models)COMPONENTS那行不要省。省了之后find_package仍然成功,头文件也能找到,但链接阶段会报一堆undefined reference to cv::VideoCapture::...,第一次踩这个坑能愣半天。POST_BUILD的拷贝命令看着不起眼,它解决的是“在 IDE 里点运行和在终端里cd build && ./face_detect工作目录不一样”这个玄学问题——相对路径models/xxx.xml在其中一种情况下必然读不到。
编译和首次运行:
# 以 apt 系为例,用系统包管理器装 OpenCV 开发包 sudo apt install build-essential cmake libopencv-dev cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j$(nproc) ./build/face_detect # 默认打开 0 号摄像头 ./build/face_detect test.mp4 # 也可以喂视频文件,方便离线调参-DCMAKE_BUILD_TYPE=Release别偷懒。Haar 检测的耗时几乎全在浮点运算和内存访问上,Debug 版本没有内联没有向量化,同一台机器上帧率能从 25fps 掉到 5fps,你会误以为代码写得很烂。
2.3 主循环最小闭环:一帧图像从进来到画完框
主函数是整个工程的“装配线”,它不关心检测怎么实现,只负责按顺序调用:
// src/main.cpp #include <opencv2/opencv.hpp> #include "detector.h" #include "tracker.h" int main(int argc, char** argv) { const std::string model = argc > 1 && std::string(argv[1]).size() > 4 ? "models/haarcascade_frontalface_default.xml" : "models/haarcascade_frontalface_default.xml"; cv::VideoCapture cap; if (argc > 1) cap.open(argv[1]); // 传了参数就当视频文件处理 else cap.open(0); // 否则打开默认摄像头 if (!cap.isOpened()) return -1; cap.set(cv::CAP_PROP_FRAME_WIDTH, 640); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480); FaceDetector detector(model, cv::Size(60, 60)); // 小于 60x60 的脸直接不检 FaceTracker tracker(0.3f, 8); // IoU 阈值 0.3,丢失 8 帧后回收 cv::Mat frame, gray; // 循环外声明,避免每帧重新分配 for (;;) { cap >> frame; if (frame.empty()) break; // 视频读完或摄像头掉线 cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY); cv::equalizeHist(gray, gray); // 光照不均时很有用 std::vector<cv::Rect> faces; detector.detect(gray, faces); // 内部已做 NMS tracker.update(faces); // 关联、分配 ID、外推 for (const auto& t : tracker.tracks()) { cv::rectangle(frame, t.box, cv::Scalar(0, 255, 0), 2); cv::putText(frame, "ID " + std::to_string(t.id), t.box.tl() + cv::Point(0, -6), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 255, 0), 1); } cv::imshow("face", frame); if (cv::waitKey(1) == 27) break; // ESC 退出 } return 0; }几个参数值得单独说。cv::Size(60, 60)是检测窗口的下限,它直接决定了远处的人脸能不能被检到,也直接决定了耗时——窗口下限减半,候选框数量大概翻四倍。tracker的两个构造参数后面第 4 章细讲。waitKey(1)不能省,它不只是等按键,还负责驱动 highgui 的消息循环,少了这一行窗口会白屏,而很多人会误判成“摄像头没出图”。
frame和gray放在循环外声明是有意为之。cv::Mat赋值时如果尺寸没变,会复用已分配的内存;写在循环里每次构造析构,跑十分钟内存曲线就会呈现锯齿状上升,虽然是正常的分配回收,但在监控上看很吓人。
3. 核心检测链路:一帧 BGR 图像怎么变成若干个矩形框
检测这一段是整个项目的核心,也是最容易被“调个参数就完事”糊弄过去的地方。实际调起来会发现,同样一段代码,换一个光照环境、换一个摄像头,表现能差出一倍。搞明白每一步在做什么,比记住参数值有用得多。
3.1 采集与预处理:为什么灰度化之后还要做一次直方图均衡
Haar 级联分类器的输入必须是单通道灰度图,这一点没有商量余地。彩色信息对人脸检测毫无帮助,反而让每帧的计算量翻三倍,所以第一步永远是cvtColor。
但只做灰度化是不够的。Haar 特征本质上是矩形区域之间的像素和之差,它对绝对亮度不敏感、对对比度却很敏感。逆光场景下,人脸区域整体偏暗、灰度值集中在 30~70 这个窄区间里,矩形特征之差小得可怜,分类器自然判不出来。equalizeHist把直方图拉伸到 0~255 全区间,等于给检测器“提了对比度”,在室内灯光不均、窗户逆光这些常见环境下能明显提升检出率。
// 预处理:灰度 + 直方图均衡,两步缺一不可 cv::Mat gray; cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY); cv::equalizeHist(gray, gray);注意均衡化不是万能的。如果画面里有大面积过曝(比如正对着灯),均衡化会把噪声一起放大,误检反而变多。我一般的做法是留一个开关,在配置文件里能关掉它,实机部署时两种都试一遍看哪个稳。
还有一步很多人会漏掉:降采样。如果摄像头输出 1280×720,直接送进检测器,耗时是 640×360 的四倍,而人脸在画面里通常只占很小一块。把长边resize到 640 再做检测,检测框按同样比例放大回去,帧率能提升三倍以上,精度损失几乎看不出来。
// 按长边缩放到 640,检测完再把框映射回原图坐标 const float kScale = 640.0f / frame.cols; cv::Mat small; cv::resize(gray, small, cv::Size(), kScale, kScale, cv::INTER_LINEAR); // ...检测得到 smallFaces... std::vector<cv::Rect> faces; for (auto r : smallFaces) { faces.emplace_back(cv::Rect(cvRound(r.x / kScale), cvRound(r.y / kScale), cvRound(r.width / kScale), cvRound(r.height / kScale))); }缩放系数记得存成浮点,不要用整数除法,否则框会整体往左上偏。
3.2 detectMultiScale 四个参数:scaleFactor / minNeighbors / minSize / maxSize
检测的调用只有一个函数,但参数含义值得掰开:
// src/detector.cpp void FaceDetector::detect(const cv::Mat& gray, std::vector<cv::Rect>& out) { std::vector<cv::Rect> raw; // scaleFactor=1.1:金字塔每层放大 10%;minNeighbors=5:压掉孤立误检 cascade_.detectMultiScale(gray, raw, 1.1, 5, 0, min_size_, max_size_); nms(raw, 0.35f, out); // 合并重叠框 }| 参数 | 常见取值 | 作用 | 调大之后 |
|---|---|---|---|
scaleFactor | 1.05 ~ 1.3 | 图像金字塔每层的缩放比 | 速度变快,小脸容易漏检 |
minNeighbors | 3 ~ 6 | 一个候选框周围至少要有几个邻居才保留 | 误检变少,漏检变多 |
minSize | 30×30 ~ 80×80 | 最小检测窗口尺寸 | 速度明显变快,远处的人检不到 |
maxSize | 视场景 | 最大检测窗口尺寸 | 可砍掉“整屏都是一张脸”这类误检 |
flags | 0 | 兼容旧接口 | 一般不动 |
scaleFactor是速度和召回的直接权衡。1.1 意味着金字塔大约有 log(640/60)/log(1.1) ≈ 24 层,1.3 只有 9 层左右,耗时能差两倍。如果画面里的人脸尺寸变化不大(比如固定机位的门禁),用 1.2~1.3 完全够。
minNeighbors是最需要按场景调的。默认值 3 在人脸清晰、正面、光线好的情况下很好用;一旦出现侧脸、戴帽子、画面里有大量类人脸纹理(窗帘褶皱、书架、瓷砖缝),就必须提到 5 甚至 6。代价是检出率下降几个百分点,但换来的是误检从“满屏都是”变成“偶尔一个”。做业务系统时,误检比漏检更让人难受,因为每个误检框都要走一遍后续流程。
minSize是最容易被忽略的性能开关。设成cv::Size(60, 60)意味着 640×480 的画面上,超过一定距离的人脸直接放弃,候选窗口数量能砍掉一大半。
3.3 后处理:重叠框合并与 NMS
Haar 检测的天然特性是“一张脸出好几个框”。多尺度扫描时,同一个人脸会在相邻的几个尺度上都被检出,位置略有偏移,大小略有差异。不去重的话,画面上会出现一堆叠在一起的绿框,跟踪模块也会把它们当成多个人。
// 计算两个矩形的交并比 static float iou(const cv::Rect& a, const cv::Rect& b) { const int x1 = std::max(a.x, b.x), y1 = std::max(a.y, b.y); const int x2 = std::min(a.x + a.width, b.x + b.width); const int y2 = std::min(a.y + a.height, b.y + b.height); const int w = std::max(0, x2 - x1), h = std::max(0, y2 - y1); const int inter = w * h; const int uni = a.area() + b.area() - inter; return uni > 0 ? static_cast<float>(inter) / uni : 0.f; } void FaceDetector::nms(std::vector<cv::Rect>& boxes, float thr, std::vector<cv::Rect>& out) { // Haar 没有置信度分数,这里用面积近似“可信度”:大框通常比小框可靠 std::sort(boxes.begin(), boxes.end(), [](const cv::Rect& a, const cv::Rect& b) { return a.area() > b.area(); }); std::vector<char> removed(boxes.size(), 0); for (size_t i = 0; i < boxes.size(); ++i) { if (removed[i]) continue; out.push_back(boxes[i]); // 保留当前最大框 for (size_t j = i + 1; j < boxes.size(); ++j) { if (!removed[j] && iou(boxes[i], boxes[j]) > thr) removed[j] = 1; } } }阈值0.35是个经验值。调太低(0.2)会把“并排站着的两个人”合并成一个框,因为两个框的 IoU 在肩膀重叠时可能接近 0.2;调太高(0.6)合不掉重复框,画面上还是叠着一堆。0.3~0.4 之间是比较安全的区间。
这里有个取舍要说明:严格的 NMS 会保留大框、吃掉小框,好处是画面干净,坏处是“远小近大”时,远处的人可能被近处人的框吞掉。如果业务上需要检所有人,可以不排序、只按原始顺序合并,或者改成加权平均(把所有重叠框的坐标按面积加权求均值),框会稍微“糊”一点但更稳。
4. 人脸跟踪:检测框在帧间不抖不断的三个关键动作
检测模块只能回答“这一帧里有没有脸”,回答不了“这个脸是不是上一帧那个脸”。业务上要的是后者——客流统计要按人计数,不能因为检测框抖一下就把同一个人算两次。跟踪模块就是补这块的,它不需要多复杂,但三个动作必须做对。
4.1 检测与跟踪的调度:不是每帧都要跑检测
最朴素的做法是每帧都跑检测,然后直接用检测结果画框。问题有两个:一是慢,检测一帧要十几毫秒,剩下的时间全花在等;二是抖,检测框本身就在跳,画出来就是“抽搐”的视觉效果。
正确做法是检测降频 + 跟踪补帧。检测每 N 帧跑一次(N 取 3~5),中间帧只做跟踪更新:
// 检测与跟踪的调度:检测隔帧跑,跟踪每帧跑 int frameIdx = 0; for (;;) { cap >> frame; if (frame.empty()) break; std::vector<cv::Rect> faces; if (frameIdx % kDetectInterval == 0) { // kDetectInterval 一般取 3~5 cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY); detector.detect(gray, faces); // 只在检测帧产生新观测 } tracker.update(faces); // 空 vector 也能调用:只做预测和衰减 // ...画框... ++frameIdx; }update收到空 vector 时不能直接返回,它得继续用上一次的速度外推框的位置、把miss计数加一。这个设计让跟踪器对“检测帧”和“非检测帧”一视同仁,主循环不用写两套逻辑。
kDetectInterval怎么定?摄像头 30fps、检测耗时 15ms 的话,N 取 2 就能跑满帧率;如果检测耗时 40ms,N 必须 ≥ 3,否则队列会越积越长,画面延迟肉眼可见地涨。判断方法很简单:在循环里打印每帧从cap >>到imshow的耗时,稳定在 33ms 以内就是健康的。
4.2 IoU 匹配:把当前帧的框和上一帧的轨迹对上
跟踪的核心是数据关联——新来的检测框该配给哪条已有轨迹。最轻量的做法是 IoU 贪心匹配:算出所有(轨迹,检测框)组合的 IoU,从大到小排序,依次配对,已经配过的跳过。
struct Track { int id = 0; cv::Rect2f box; // 用浮点存,避免平滑更新时被截断误差拖偏 float vx = 0.f; // 上一帧到这一帧的速度,用于外推 float vy = 0.f; int miss = 0; // 连续未匹配帧数 int hits = 0; // 累计匹配次数,用于确认轨迹 }; void FaceTracker::update(const std::vector<cv::Rect>& dets) { // 1) 预测:所有轨迹先按上一帧速度往前走一步 for (auto& t : tracks_) { t.box.x += t.vx; t.box.y += t.vy; } // 2) 关联:穷举 IoU,收集所有超过阈值的候选对 struct Pair { float v; int ti; int di; }; std::vector<Pair> pairs; for (int i = 0; i < (int)tracks_.size(); ++i) for (int j = 0; j < (int)dets.size(); ++j) { float v = iou(tracks_[i].box, cv::Rect2f(dets[j])); if (v >= iou_thr_) pairs.push_back({v, i, j}); } std::sort(pairs.begin(), pairs.end(), [](const Pair& a, const Pair& b) { return a.v > b.v; }); // 贪心:先配最像的 std::vector<char> tUsed(tracks_.size(), 0), dUsed(dets.size(), 0); for (const auto& p : pairs) { if (tUsed[p.ti] || dUsed[p.di]) continue; // 一方已被占用就跳过 tUsed[p.ti] = dUsed[p.di] = 1; Track& t = tracks_[p.ti]; // 速度用一阶低通平滑,别直接用差值,否则抖动会被放大 t.vx = kAlpha * (dets[p.di].x - t.box.x) + (1 - kAlpha) * t.vx; t.vy = kAlpha * (dets[p.di].y - t.box.y) + (1 - kAlpha) * t.vy; blend(t.box, dets[p.di], kAlpha); // 框也做平滑,kAlpha 取 0.5 左右 t.miss = 0; ++t.hits; } // 3) 4) 见下一节 }blend就是按比例把轨迹框往检测框上拉:
// 用检测框平滑地修正轨迹框,a 越大越信任检测,越小越平滑 static void blend(cv::Rect2f& dst, const cv::Rect& src, float a) { dst.x = a * src.x + (1 - a) * dst.x; dst.y = a * src.y + (1 - a) * dst.y; dst.width = a * src.width + (1 - a) * dst.width; dst.height = a * src.height + (1 - a) * dst.height; }kAlpha取 0.5 是个平衡点:取 1.0 就是完全信任检测,等于没做平滑;取 0.2 太平滑,人快速走动时框会明显滞后,甚至追不上。这个值跟帧率和人的移动速度强相关,30fps 下 0.4~0.6 都还行。
IoU 阈值iou_thr_和 NMS 用的阈值不是一回事。NMS 是“同一帧内去重”,可以设高一点;关联是“跨帧匹配”,人走得快的时候前后两帧的框可能只重叠 20%,阈值设到 0.5 就全断了。0.2~0.3 是比较稳的起点。
4.3 轨迹生命周期:新生、确认、丢失、回收
匹配上的轨迹好办,匹配不上的才是麻烦。这里有两个方向:没被匹配的检测框,和没被匹配的轨迹。
新检测框如果直接建成轨迹,那些偶发的误检(窗帘、反光、海报上的人脸)就会变成一个个真的 ID,在画面上闪一下又消失。做法是先挂成“候选”,连续命中 3 帧才正式输出:
// 3) 未匹配的检测框 -> 新建候选轨迹,连续命中 kMinHits 帧才算数 for (int j = 0; j < (int)dets.size(); ++j) { if (dUsed[j]) continue; Track t; t.id = ++next_id_; t.box = cv::Rect2f(dets[j]); t.hits = 1; tracks_.push_back(t); } // 4) 未匹配的轨迹 -> miss++,超过上限就删除,避免内存和 ID 无限增长 for (auto it = tracks_.begin(); it != tracks_.end(); ) { if (it->miss > 0 && it->miss > max_miss_) it = tracks_.erase(it); else ++it; } // 另外把 miss 超过 0 但没到上限的轨迹,统一把 miss 加一(上面预测阶段已加过则跳过)实际代码里miss的递增要放在预测之后统一处理,写法上略有出入,逻辑是:匹配上就清零,没匹配上加一,超过max_miss_(第 2 章构造参数里的 8)就删。8 帧在 30fps 下大约是 260 毫秒,对应“人转头、被遮挡、短暂走出画面”这几种情况。设太小,人一转身 ID 就变,客流统计直接算错;设太大,人走了之后框还挂在那儿半秒,看着像灵异事件。
对外输出时再过滤一次hits >= kMinHits的轨迹,kMinHits取 2~3。这一步能干掉绝大多数单帧误检,是成本最低的“降噪”手段。
还有一点:tracks()返回的应该是std::vector<Track>的副本或者带过滤的视图,别把内部容器直接暴露出去。不然后面加业务逻辑时,某个函数顺手删了一条轨迹,追踪起来非常难受。
5. 避坑与排查:编译通过只是开始,这几类问题在实机上才暴露
代码在开发机上跑通,只是走完了三成。剩下七成问题全都冒在实机上:换一台机器、换一个摄像头、跑上半小时,各种怪现象就出来了。下面这几条是我在这个项目类型上反复遇到的。
5.1 编译期:头文件找得到,链接却报 undefined reference
现象:CMake 配置阶段输出一切正常,#include <opencv2/videoio.hpp>也不报错,一到链接阶段满屏undefined reference to 'cv::VideoCapture::VideoCapture(int)'。
原因:find_package(OpenCV REQUIRED)不带COMPONENTS时,某些版本的配置文件只会把core、imgproc这些基础库加到OpenCV_LIBS里,videoio、highgui、objdetect没有链进来。头文件能编过是因为 include 路径是全局的,链接失败是因为库列表不全。另一种情况是系统里同时装了 3.x 和 4.x 两套 OpenCV,CMake 找到了旧的那套,但代码用的是新 API。
解决:显式列组件,再用 pkg-config 交叉验证版本。
# 先确认系统里到底是哪个版本被 pkg-config 认到 pkg-config --modversion opencv4 || pkg-config --modversion opencv # 实在找不到 OpenCVConfig.cmake 时,直接命令行兜底链接 g++ -O2 -std=c++17 src/*.cpp -Iinclude $(pkg-config --cflags --libs opencv4) -o face_detect如果两条命令给出的版本不一样,说明环境里确实有冲突,最省事的办法是在 CMakeLists 里写死set(OpenCV_DIR "/path/to/opencv4"),用绝对路径切断歧义。
5.2 启动即崩:模型 XML 读不到,分类器空跑
现象:程序不崩溃,但一个框都不出;或者在某些构建下直接 assert 失败退出。CascadeClassifier对象构造成功了,看不出任何异常。
原因:load()返回bool,加载失败不抛异常、不打日志。而模型路径是相对路径,在 IDE 里运行时工作目录是项目根,在终端里cd build && ./face_detect时工作目录是build/,后者必然找不到models/xxx.xml。
解决:一定要检查返回值,并把当前工作目录打出来,一次就能定位。
if (!cascade_.load(path)) { // load 失败只返回 false,不会抛异常 std::cerr << "模型加载失败: " << path << "\n" << "当前工作目录: " << std::filesystem::current_path() << std::endl; std::abort(); }配合第 2 章 CMake 里的POST_BUILD拷贝命令,把models/复制到可执行文件旁边,两种运行方式就都不会踩坑了。另一个隐形问题是模型文件被版本库的.gitignore里的*.xml规则误伤,clone 下来根本没有这个文件——这种事发生一次能查半小时。
5.3 检测框疯狂闪烁:每帧独立检测,没有时序约束
现象:人坐着不动,框的大小每一帧都在跳,偶尔整框消失一两帧再回来,画面上看起来像在抽搐。
原因:Haar 检测是逐帧独立的多尺度滑窗,完全不考虑上一帧的结果,同一张脸在不同尺度上的响应本来就有细微差别,导致最终框的位置和大小天然抖动。光照明暗变化、传感器的自动增益,都会放大这种抖动。
解决:三个动作一起上。检测降频到每 3 帧一次,中间帧靠跟踪补;匹配上的框用blend平滑更新,不要直接用检测框覆盖;minNeighbors从 3 提到 5,让检测本身更稳定。
还可以加一条:如果连续几帧检测到的框和轨迹框 IoU 都很低,先不要立刻替换轨迹框,而是给轨迹一个“容忍期”,让速度外推多跑一两帧。人走路时前后帧的框本来就会移动,强行贴合反而更抖。
5.4 内存持续上涨:跑几分钟就被系统杀掉
现象:top里 RES 从 200MB 一路涨到 2GB,然后进程被 OOM killer 干掉,日志里什么都没有。
原因:几个高频点。一是cv::Mat在循环内声明,每帧都新分配一块内存,虽然会释放但碎片化严重;二是std::vector<cv::Rect> faces写在循环里,每帧构造析构;三是跟踪器的轨迹只加不删,误检持续产生新轨迹,一分钟能攒出几千条;四是imshow之后忘了waitKey,highgui 的事件队列无限堆积。
解决:frame、gray、faces全部提到循环外,每帧开头faces.clear();轨迹必须有过期删除;waitKey(1)一帧都不能少。排查时用一个笨办法就够了——每 300 帧打印一次tracks_.size()和进程 RSS,哪条曲线在涨,问题就在哪儿。
5.5 摄像头能打开但读出来全是空帧
现象:cap.isOpened()返回true,cap >> frame之后frame.empty()却一直成立,imshow一片黑。
原因:分辨率或帧率设置不被后端支持。有些 USB 摄像头在set(CAP_PROP_FRAME_WIDTH, 640)之后协商失败,驱动进入一个异常状态,isOpened仍然是 true 但再也读不出数据。另一种情况是后端选错,Linux 下默认后端和实际驱动不匹配。
解决:先不 set 任何参数,读一帧确认能出图,再读回实际分辨率决定要不要 resize。
cap >> frame; if (frame.empty()) return -1; std::cout << "实际分辨率: " << cap.get(cv::CAP_PROP_FRAME_WIDTH) << " x " << cap.get(cv::CAP_PROP_FRAME_HEIGHT) << std::endl; // 先看清楚再说如果确实需要固定分辨率,set 之后要再读回来验证,不匹配就退回去用原始分辨率 +resize。换后端也是一种办法,cap.open(0, cv::CAP_V4L2)或者换成 GStreamer 管道,能绕开不少驱动层面的坑。
6. 二次开发:把检测框接成能做业务的管道
这份代码拿来直接用只能画框,真正值钱的是它留出的扩展点。改造的第一刀应该切在FaceDetector上——在include/下加一个纯虚接口IDetector,把detect()抽出来,Haar 版本作为它的一个实现。这样以后想换成人脸检测网络,只需要新写一个DnnDetector : public IDetector,加载 ONNX 模型,输入输出格式对齐,主循环一行都不用改。
// include/detector.h 里的抽象接口,换检测器只需要新增一个实现 class IDetector { public: virtual ~IDetector() = default; virtual void detect(const cv::Mat& gray, std::vector<cv::Rect>& out) = 0; };第二刀切在跟踪器的输出上。Track结构里加一个cv::Point2f center和traj环形缓冲,业务侧就能算“这个人往哪个方向走”“有没有跨过画面中间那条线”。客流统计就是靠这个做单方向计数:人从线的上半侧走到下半侧算一次进入,反向算离开,同一个 ID 只计一次。这块逻辑放主循环里就行,别塞进跟踪器。
第三刀是回调。给FaceTracker::update后面挂一个std::function<void(const std::vector<Track>&)> onFrame,业务代码注册进去,主函数保持干净。后面要加录像、加告警、加推流,都从这里接。
| 改造点 | 改哪个文件 | 需要注意 |
|---|---|---|
| 换检测器 | 新增dnn_detector.cpp | 输入尺寸归一化、输出框要映射回原图坐标 |
| 加越线计数 | main.cpp+Track结构 | ID 复用前要清计数状态 |
| 输出到 RTSP / 文件 | 新增sink.cpp | imshow要保留,否则看不到实时效果 |
| 加多线程 | 采集线程 + 处理线程 | 用有界队列,否则延迟会越积越大 |
验证改造有没有跑偏,我习惯用固定素材做回归:录一段 30 秒的视频,里面包含走近、走远、侧脸、两人交叉这四种情况,每改一次代码就用同一个文件跑一遍,对比 ID 切换次数和漏检帧数。这个习惯救过我好几次——有一次优化 NMS 之后总帧率提升了 20%,但两人交叉时 ID 乱跳,肉眼在实时画面上根本看不出来,靠视频回放才发现。
从那以后我每次改检测或跟踪参数,都强制先跑一遍这段三十秒的回归视频,再上实机。实机只用来验证最后一公里的光照和摄像头差异,不用来发现逻辑问题。希望这份代码在你手里也能跑出稳定的框。
本文还有配套的精品资源,点击获取