
简介面向OpenCV初学者与图像处理入门者是一份可直接运行的C/C基础源代码包覆盖图像读写、颜色空间转换、边缘检测、运动跟踪、相机标定、三角剖分等典型实验场景便于对照学习OpenCV核心模块的调用逻辑。压缩包共229个文件其中包含27个C源文件、21个C源文件及必要头文件并配有makefile、dsw/dsp工程文件方便在Linux或Windows下快速编译同时附带jpg/png图片和avi视频素材一份空间即可完成多种算法测试。包体仅3.35MB轻量但内容完整。已有279人学习/下载适合OpenCV课程作业、毕业设计或自学实践时用作参考模板。文件按示例功能分目录组织结构清晰可独立运行某一示例也方便对照注释修改参数压缩包内还保留了编译好的exe及调试信息能减少环境配置耗时让读者更专注于算法理解与代码改写。1. 一套C接口示例看懂OpenCV的“底层叙事”拿到这份压缩包时里面是九个.c文件和一段video.avi。文件名对老OpenCV用户来说一眼就能认出来camshift.c、motempl.c、delaunay.c、distrans.c这是OpenCV 1.x时代官方教程的配套示例全部用纯C接口写成没有C封装更没有Python。很多人第一反应是这个太旧了没必要看。但我的看法相反恰恰因为去掉了现代API的糖衣矩阵存储、反向投影、运动历史这些概念的底层逻辑才暴露得最清楚。你如果只想跑个cv2.CamShift当然不需要读它可一旦你需要在嵌入式环境里移植算法、或者调试一个“为什么跟踪框越跑越大”的问题这套源码里藏着答案。本文会顺着几个代表性文件拆开讲并给出可直接编译的对照代码和迁移到OpenCV 4的落地技巧。2. CvMat矩阵操作cvmat.c里的内存模型与GEMM计算2.1 CvMat是C接口的“地基”在C接口里图像和矩阵没有本质区别它们都是一块连续内存加上描述头部。IplImage是带图像语义宽、高、通道数、ROI的矩阵CvMat则更纯粹只关心rows、cols、type、data、step这几个字段。step是容易被忽略的字段它表示一行数据占用的字节数由于OpenCV对行有内存对齐step不一定等于width * channels * elemSize。cvmat.c里的示例正是在演示这种数据布局用cvMat把头指针指向现有数据块再以矩阵视角去读写从而省掉一次拷贝——这在实时处理里是实打实的性能收益。这里有个关键的选型边界要分清楚cvCreateMat在堆上分配头部和数据适合生命周期长、需要跨函数传出的矩阵。cvMat是栈上结构体只初始化头部数据可以指向栈、堆或IplImage的数据区适合临时包装已有内存。2.2 cvmat.c中两个高频操作的现代解读2.2.1 矩阵创建与遍历cvmat.c里最典型的模式是用cvGet2D/cvSet2D按行列访问元素。注意这两个宏在1.x里是CV_BUG_FIX之后才稳定的访问的是CvScalar而非裸浮点数所以性能不高。下面的代码复现了它的核心逻辑创建两个矩阵赋值后做乘法。#include cv.h #include stdio.h int main() { // 2x3 与 3x2 矩阵乘法结果存放在 2x2 的C CvMat* A cvCreateMat(2, 3, CV_32FC1); CvMat* B cvCreateMat(3, 2, CV_32FC1); CvMat* C cvCreateMat(2, 2, CV_32FC1); for (int i 0; i 2; i) for (int j 0; j 3; j) cvSet2D(A, i, j, cvRealScalar(i j 1)); for (int i 0; i 3; i) for (int j 0; j 2; j) cvmSet(B, i, j, i * 2 j 1); // alpha*A*B beta*C不存在第三个矩阵时传NULL cvGEMM(A, B, 1.0, NULL, 0.0, C, 0); for (int i 0; i 2; i) { for (int j 0; j 2; j) printf(%6.1f , cvmGet(C, i, j)); printf(\n); } cvReleaseMat(A); cvReleaseMat(B); cvReleaseMat(C); return 0; }cvGEMM的前两个参数是参与乘法的矩阵alpha和beta是缩放系数src3是可选的加数矩阵最后一位tABC是由CV_GEMM_A_T、CV_GEMM_B_T、CV_GEMM_C_T按位或组合的标志置位表示对应矩阵在计算前先转置。我在实际项目里常用它替代先转置再乘的两步操作省一次中间缓冲。2.2.2 cvmSet/cvmGet为什么不叫cvSet早期的C接口没有统一的标量访问方式后来补了一套cvmGet/cvmSet专门针对CV_32FC1类型做优化不做CvScalar打包拆包。在cvmat.c里这两种风格都出现过从效率上讲cvmSet明显更快适合在双层循环里赋值。反之如果矩阵是CV_8UC3这类多通道类型cvmSet就不能用了得回到cvSet2D或直接用data指针按字节偏移计算地址。2.3 一个容易翻车的细节cvGet2D的坐标顺序cvGet2D(mat, row, col)的第一个坐标是行y方向第二个是列x方向和图像坐标(x, y)正好相反。我见过不止一个人在这上面栽跟头遍历图像像素时把(col, row)传进去结果矩阵横竖颠倒输出图像跟着转置。cvmat.c里没有踩这个坑但它定义了一个CvMat包装函数读取IplImage的像素数据后以矩阵形式输出这正好提示了正确姿势——先确认数据在内存里是行优先还是列优先再决定循环怎么写。C接口函数作用注意点cvCreateMat分配堆内存矩阵记得配对cvReleaseMatcvMat栈上包装已有内存不持有数据所有权释放数据区会悬垂cvGet2D/cvSet2D通用标量访问适合多通道性能偏低cvmGet/cvmSet单通道float快速访问只支持CV_32FC1cvGEMM广义矩阵乘法注意转置标志位提示cvmat.c里演示的cvMat包装IplImage的做法在OpenCV 2.0之后的cv::Mat构造里演变成了cv::Mat(IplImage*)这样的隐式转换思想完全一致只是编译器帮你做了类型转发。排错时如果遇到“矩阵维度对数值全乱”优先怀疑step行对齐问题而不是乘法算错。3. CamShift目标跟踪从颜色直方图到反向投影3.1 整个跟踪流程的四步拆解camshift.c是这套源码里含金量最高的文件。CamShift全称Continuously Adaptive Meanshift和普通Meanshift的区别在于Meanshift的搜索窗口尺寸固定CamShift会在每帧迭代后根据零阶矩调整窗口大小所以它能适应目标在画面中变大变小的情况。整个跟踪链路分四步缺一不可。3.1.1 选择色彩空间并计算Hue直方图第一步是把BGR图像转到HSV只取Hue色调通道做统计丢弃饱和度和明度。原因是Hue对光照强度变化相对稳定目标从阴影走进阳光里色相值变化小RGB三个通道却会一起漂移。直方图的bin数量在camshift.c里是hdims 30这是一个经验值bin太少区分度不够不同物体容易落在同一个bin里bin太多又会对噪点敏感反向投影出来的图会碎成散点。Hue的取值范围在8位表示下是0到180不是255这决定了直方图范围必须设置为{0, 180}。CvHistogram* hist; int hdims 30; float hranges_arr[] {0, 180}; float* hranges hranges_arr; // 创建单维直方图bin数为30范围[0,180] hist cvCreateHist(1, hdims, CV_HIST_ARRAY, hranges, 1); // 输入是单通道Hue平面 cvCalcHist(hue_plane, hist, 0, 0); cvNormalizeHist(hist, 1.0);cvCreateHist的第四个参数hranges是一个float*数组的数组因为直方图可以是多维的每一维都需要自己的范围数组。最后一行的cvNormalizeHist(hist, 1.0)把直方图归一化到总和为1这是为了让反向投影结果不受目标大小影响——目标离镜头近了像素更多但归一化后每个像素的颜色概率不变。3.1.2 反向投影生成概率图反向投影做的事情可以理解成“查表”遍历输入图像的每个像素读它的Hue值在直方图里找到对应bin的统计值把这个值写回输出图像的对应位置。结果是单通道浮点图亮的地方表示该像素颜色与目标直方图匹配度高。camshift.c里对这个结果还会做一步彩色化显示但那只影响可视化不影响跟踪逻辑。反向投影质量直接决定后续跟踪成败这里最常见的错误是直方图没有归一化就投影导致输出值域不可控。3.1.3 CamShift迭代与窗口自适应拿到反向投影图后进入核心函数cvCamShift。它的输入是反向投影图、初始搜索窗口、迭代终止条件。该函数内部会先对窗口内的像素计算零阶矩和一阶矩得到质心把窗口中心移到质心重复这个过程直到满足终止条件然后计算二阶矩来估计目标的方向角和尺度最终返回一个带旋转角度的椭圆窗口。CvConnectedComp comp; CvBox2D box; CvTermCriteria criteria; // 最大迭代10次窗口移动距离小于1像素即停止 criteria cvTermCriteria(CV_TERMCRIT_EPS | CV_TERMCRIT_ITER, 10, 1); cvCamShift(backproject, window, criteria, comp, box);window在初始化时是用户框选的目标区域类型是CvRect。comp是连通分量它的rect字段会用本次迭代的结果更新搜索窗口这个更新后的窗口要传给下一帧形成时间上的连续跟踪。box是CvBox2D包含中心点、宽高和角度它才是真正画在画面上的跟踪框。终止条件里EPS1表示窗口位移小于1像素就认为收敛ITER10防止无限循环这两个值在目标快速运动时可以适当放宽但要控制在上限以内否则一帧的耗时可能吃掉实时性。3.2 在video.avi上复现时的调参要点video.avi这段视频在教程里通常用来演示人脸或手部跟踪。直接跑camshift.c时需要关注几个参数直方图范围必须是{0, 180}如果错填成{0, 255}Hue值超过180的部分会全部落入最后一个bin跟踪区域会出现奇怪的块状漂移。反向投影后的概率图建议先做一次cvSmooth高斯模糊再送入cvCamShift可以滤掉单像素噪声但这会增加开销。camshift.c原版没有做这步在低分辨率视频里问题不大换成高清视频就明显容易抖动原因是目标内部的纹理导致反向投影图出现“空洞”。每帧更新直方图时camshift.c里用了一个衰减因子让旧的颜色样本逐渐失效。这样做的好处是目标光照缓慢变化时直方图能跟上代价是如果目标短暂被遮挡直方图会被背景颜色污染恢复跟踪后窗口容易跳到背景上。实际应用中遇到遮挡场景我一般会把衰减因子调小甚至暂时冻结直方图更新。4. 几何与形态学算法Delaunay、距离变换与运动模板4.1 delaunay.c用CvSubdiv2D做点集三角剖分Delaunay三角剖分在二维点集处理里是绕不开的基础工具OpenCV 1.x的C接口用一套叫quad-edge的结构来实现对外暴露的类型是CvSubdiv2D。delaunay.c演示的流程是先在图像上随机撒点然后用cvSubdivDelaunay2DInsert逐个插入点最后遍历所有边把剖分结果画出来。CvMemStorage* storage cvCreateMemStorage(0); CvRect rect {0, 0, width, height}; // 创建覆盖整个图像范围的Delaunay剖分 CvSubdiv2D* subdiv cvCreateSubdivDelaunay2D(rect, storage); // 插入一个点坐标是浮点型 CvPoint2D32f p cvPoint2D32f(100.5f, 200.3f); cvSubdivDelaunay2DInsert(subdiv, p); // 定位一个点落在哪条边附近 CvSubdiv2DEdge edge; CvSubdiv2DPoint* closest cvSubdiv2DLocate(subdiv, p, edge, NULL);cvCreateSubdivDelaunay2D的rect参数划定了剖分范围范围之外的点不会被处理。cvSubdiv2DLocate返回的是包含该点的剖分顶点如果点不在剖分上它会返回最近的一条边。遍历方面cvSubdiv2DGetEdge配合CV_NEXT_AROUND_LEFT标志可以顺时针访问一个顶点的所有相邻边这是在做Voronoi图或者邻域分析时的常用手法。注意剖分结果存储在CvMemStorage里用完后调cvReleaseMemStorage一次性释放单个顶点和边没有独立的释放函数。用这个接口最大的坑是大规模点集数千个点下反复调用cvSubdiv2DLocate会明显变慢原因是定位操作是O(√n)级别的不是常数时间。如果只需要建剖分而不用定位插入点本身很快如果需要频繁查询某个点属于哪个三角形建议把剖分结果一次性转存到自己的索引结构里而不是每帧都去调这个接口。4.2 distrans.c距离变换是骨架化的前置distrans.c演示的是cvDistTransform的用法。距离变换输入一张8位单通道图像非零像素视为前景输出一张32位浮点图每个前景像素的灰度值等于它到最近背景像素的距离。这有什么用最典型的两个场景一是图像骨架化前的预处理取距离图的局部极大值就能得到粗略骨架二是路径规划里做障碍物膨胀把障碍物的影响范围按距离衰减画出来。IplImage* src cvLoadImage(shape.png, CV_LOAD_IMAGE_GRAYSCALE); IplImage* dst cvCreateImage(cvGetSize(src), IPL_DEPTH_32F, 1); // CV_DIST_L2表示欧几里得距离3是掩码尺寸 cvDistTransform(src, dst, CV_DIST_L2, 3, NULL, 0);第三个参数distance_type支持CV_DIST_L1曼哈顿距离、CV_DIST_L2欧氏距离、CV_DIST_C切比雪夫距离。在需要精确欧氏距离的场合只用CV_DIST_L2配合掩码尺寸3或5。掩码尺寸越大计算越慢但精度越高掩码5在实践里和真实欧氏距离的误差已经很小。最后一个参数0表示把距离值按默认方式归一化如果你需要绝对像素距离可以传入NULL掩码并在后续自行缩放。距离变换的输出值域不是固定的直接做阈值处理前最好先统计一下最大值按比例取阈值否则容易被不同尺寸的目标干扰。4.3 motempl.c与motiondetect.c两种运动分析路线这两个文件的名字看起来相近但思路完全不同。motiondetect.c用的是最朴素的帧差法当前帧与上一帧做cvAbsDiff差值超过阈值的像素视为运动区域再用cvFindContours把区域串成轮廓。它的优点是计算量极低缺点是只能检测“变化”静止的物体会立刻从结果里消失目标被遮挡后再出现会分裂成多块。motempl.c引入的“运动历史图”Motion History ImageMHI是对帧差法的升级。它维护一张浮点图每个像素记录该位置最近一次运动发生的时刻越新的运动值越高旧的运动随时间衰减为0。这相当于给运动区域施加了时间上的“记忆”。// silhouette是当前帧的前景掩码mhi是累积的运动历史图 cvUpdateMotionHistory(silhouette, mhi, timestamp, duration); CvMat* mask NULL; CvMat* orient NULL; double max_gap 100; double min_gap 10; // 从运动历史图计算梯度幅值和方向 cvCalcMotionGradient(mhi, mask, orient, max_gap, min_gap, 0); // 返回全局运动方向角度 double angle cvCalcGlobalOrientation(orient, mask, mhi, timestamp, duration);cvUpdateMotionHistory的timestamp是当前时间戳需要由外部递增传入duration决定一个运动事件保留多久超过这个时间的旧运动会被清零。cvCalcMotionGradient从MHI里提取梯度输出orient是每个像素的运动方向角度。cvCalcGlobalOrientation返回的是整幅图像能量的主方向可以用来判断人体的大致走向。这两者的取舍要点如果你只需要“哪儿动了”帧差法足够如果你需要“往哪个方向动”就必须上MHI。MHI还有个副作用是参数敏感duration设太大时慢速运动会被当成静止丢弃设太小又会把同一个目标的连续运动切断成碎片。实际调试时先用cvShowImage把MHI可视化确认运动轨迹的长度和连续性复合预期再调后续的梯度阈值。5. 把这些示例改造成现代C或Python的迁移技巧5.1 视频源和头文件的替换video.avi是这套示例自带的测试视频在OpenCV 4里读它只需要两行cv::VideoCapture cap(video.avi); if (!cap.isOpened()) { /* 检查文件路径或编码 */ } cv::Mat frame; while (cap.read(frame)) { /* 处理 */ }如果是用摄像头采集把路径换成设备索引号0即可。真正麻烦的是头文件与库的迁移OpenCV 2.0之后C接口被标记为废弃cv.h不再随新版本分发。在老代码里#include cv.h的位置要改成#include opencv2/core/core.hpp、#include opencv2/imgproc/imgproc.hpp和#include opencv2/highgui/highgui.hpp。5.2 C接口到C API映射表老C接口函数新版C对应备注cvCreateMatcv::Mat::zeros或cv::Mat(rows, cols, type)无需手动释放RAII管理cvGEMM(A, B, alpha, NULL, beta, C, 0)cv::gemm(A, B, alpha, cv::Mat(), beta, C, 0)也可直接用A * B但丢失转置控制cvCalcHistcv::calcHist参数从指针改为容器引用cvCalcBackProjectcv::calcBackProject新旧API签名差异较大cvCamShiftcv::CamShift输入输出从CvConnectedComp改为cv::RotatedRectcvUpdateMotionHistorycv::updateMotionHistory函数名完全相同cvDistTransformcv::distanceTransform注意掩码参数类型变化5.3 编译与运行时的排错清单编译阶段最常遇见的报错是cv.h: No such file or directory这基本可以断定系统中装的是OpenCV 3.x或4.x不再分发老C头文件。两个选择一是改用C API重写二是安装opencv_legacy兼容模块仅OpenCV 2.x时代提供3.0之后彻底移除。相比之下还是迁移到C值得迁移过程本身能帮你理清老代码里每个函数到底修改了哪些数据。运行阶段的典型错误集中在数据类型不匹配。C接口里的IplImage*到C的cv::Mat本质是同一个对象的不同视角但cv::CamShift要求输入是8位单通道图像如果直接用彩色图会触发assertion failed。反向投影的结果默认是CV_8UC1这没问题但如果你对这层结果做了归一化变成浮点图就需要cv::Mat::convertTo回到8位才能送进cv::CamShift。另外video.avi如果读出来是空的先不要怀疑代码用cap.get(cv::CAP_PROP_FRAME_COUNT)确认帧数是否大于0很多开源avi文件实际使用的是MJPEG编码缺解码器时isOpened()返回true但read()一直是false。本文还有配套的精品资源点击获取