
简介基于C与OpenCV视觉库实现的手掌图像测量项目配套设计报告与完整源码适合计算机视觉、图像处理课程设计以及生物特征测量应用入门。项目通过摄像头获取完整手掌图像综合运用滤波、边缘检测、角点检测、霍夫变换等图像分析与处理方法精确计算出五根手指的长度与宽度、虎口角度、手掌宽度及手腕宽度可有效辅助手势分析、健康评估等场景。压缩包共47个文件包含10个H头文件、9个CPP源文件、设计报告DOCX、PNG/JPG测试图像以及Visual Studio工程配置文件体积仅1.18MB。已有654人学习下载。除源码和报告外还附带中文说明、README与解决方案文件目录结构清晰标注了各成员模块便于对照复现和继续扩展适合作为课程报告撰写和图像处理实践参考。1. 用 C 与 OpenCV 分析手掌图像时先别急着量长度拍一张手掌照片调用cv::resize再量轮廓距离看起来五分钟就能出结果。真正动手后你会发现OpenCV 返回的是点、轮廓和凸包它并不知道哪个点是食指指尖哪个凹陷是虎口长度测量出错往往不是mmPerPx标定错了而是指尖和指根配错了一位。这个项目解决的是“手掌图像里把每根手指拆出来再输出长度和宽度”的完整链路肤色分割拿到二值 mask轮廓与凸包分析定位指尖和指根最后沿手指轴线做法线采样测宽度。它适合正在交计算机视觉大作业、做手部康复评估或交互设备原型的开发者也适合想弄清楚convexHull、convexityDefects这些 API 边界的人。下文直接用 C 实现命令和代码都可以在本地编译跑通。2. 基于 C 的 OpenCV 手掌图像预处理环境、肤色分割与最大轮廓提取2.1 OpenCV 安装教程与 vscode 配置 C/C 环境的编译命令做手掌图像分析的第一步不是写算法而是让#include opencv2/opencv.hpp在你自己的工程里编译通过。我一般建议用 CMake 管理而不是手动往 g 命令里拼一串-lopencv_core因为 OpenCV 4 的组件拆分比 3.x 更细手写库名很容易漏。Linux 下最省事的是系统包管理器sudo apt update sudo apt install libopencv-devWindows 下用 vcpkg 或者从 OpenCV 官网下载 release 包都行。下载版记得在 CMake 里显式指定位置cmake_minimum_required(VERSION 3.16) project(HandMeasure) find_package(OpenCV REQUIRED) add_executable(hand_measure main.cpp) target_link_libraries(hand_measure ${OpenCV_LIBS}) if(WIN32) target_compile_definitions(hand_measure PRIVATE _USE_MATH_DEFINES) endif()在 vscode 配置 C/C 环境时c_cpp_properties.json里的includePath至少要有 OpenCV 的include目录Windows 下通常是C:/opencv/build/include这样代码提示和跳转才正常。C 标准建议设成 C17cv::Point做加减乘除时不用自己写类型转换。编译时用 CMake 生成工程比直接敲 g 更稳mkdir build cd build cmake .. -DOpenCV_DIR/usr/lib/x86_64-linux-gnu/cmake/opencv4 make -j4之后所有章节的代码都会放在同一个main.cpp里只需要替换measureHand的核心函数。2.2 用 YCrCb 肤色分割得到手掌二值 mask手掌图像最常见的干扰是桌面、衣袖和阴影。直接在 RGB 空间做阈值很脆弱因为肤色在 RGB 里受亮度影响太大。常见做法是转到 YCrCb 空间把亮度分量 Y 丢掉只用 Cr 和 Cb 两个色度通道做范围判断cv::Mat bgr cv::imread(argv[1], cv::IMREAD_COLOR); if (bgr.empty()) { std::cerr image not found std::endl; return -1; } cv::Mat ycc; cv::cvtColor(bgr, ycc, cv::COLOR_BGR2YCrCb); cv::Mat skin; cv::inRange(ycc, cv::Scalar(0, 133, 77), cv::Scalar(255, 173, 127), skin);cv::inRange的上下界分别是 Y、Cr、Cb 三个通道的最小值和最大值。Y 通道范围放宽到 0-255是因为手掌的亮部与暗部都要保留Cr 133-173、Cb 77-127 是室内光照下比较通用的肤色范围。如果拍照环境偏黄或偏蓝Cr 和 Cb 的上下界要整体平移而不是把范围无限放宽。分割出来的二值图通常会有两类噪声毛孔和边缘毛刺造成的小黑点背景里肤色相近的像素造成的小白块。处理方式很简单cv::Mat morph; cv::morphologyEx(skin, morph, cv::MORPH_OPEN, cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5, 5))); cv::morphologyEx(morph, morph, cv::MORPH_CLOSE, cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(9, 9)));先开运算去掉小噪点再闭运算把手指边缘的断裂处接上。闭运算核不能太大否则食指和中指之间贴得近时会被焊成一根。下面是这组预处理参数的作用与调整方向处理步骤OpenCV 函数常用参数调整方向色彩空间转换cvtColorCOLOR_BGR2YCrCb比 HSV 对高光更稳肤色阈值inRangeCr 133-173Cb 77-127偏黄环境上调 Cr 下限去毛刺MORPH_OPEN5x5 椭圆核核太大会吃掉小拇指边缘填补空洞MORPH_CLOSE9x9 椭圆核核太大会合并相邻手指2.3 提取最大轮廓并用 fillPoly 生成干净的测量 mask二值 mask 里可能有桌面上的肤色误检或者手掌之外的小块噪声。测量只需要最大的一块连通区域所以先findContours再按面积排序取最大轮廓。这里有一个 C 里很容易踩的坑OpenCV 4 的findContours会修改输入图像所以传入的必须是二值图的克隆而不是morph本身。std::vectorstd::vectorcv::Point contours; cv::findContours(morph.clone(), contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); int idx -1; double maxArea 0.0; for (int i 0; i (int)contours.size(); i) { double area cv::contourArea(contours[i]); if (area maxArea) { maxArea area; idx i; } } cv::Mat handMask cv::Mat::zeros(morph.size(), CV_8UC1); std::vectorstd::vectorcv::Point poly { contours[idx] }; cv::fillPoly(handMask, poly, cv::Scalar(255)); cv::Rect roi cv::boundingRect(contours[idx]);fillPoly和drawContours(..., cv::FILLED)效果等价但fillPoly接受vectorvectorPoint时更直接。生成的handMask才是后面做宽度采样的标准 mask因为它的边缘是完整的单像素闭合区域没有findContours分层带来的内部空洞。boundingRect返回的Rect只有x, y, width, height四个成员。OpenCV 里看不到cols、rows这样的成员新手把rect.cols写成rect.width是高频报错点。morph.cols是 Mat 的列数Rect必须用width。如果照片里手腕也进了画面最大轮廓会把手臂一起包进来导致凸包分析时手腕两个角点被误判成指尖。常见做法是先按roi高度裁掉底部 15% 的区域再重新找轮廓也可以暂时用椭圆约束只看离质心最远的 5 个凸包点。这一步直接影响后面所有测量结果ROI 不干净时不要继续往下走。3. 手指长度与宽度测量凸包、凸性缺陷和法线采样3.1 用 convexHull 和 convexityDefects 找指尖与指根拿到干净的handMask后下一步是找轮廓上的关键点。OpenCV 里有两条路直接找goodFeaturesToTrack角点或者做凸包分析。对张开的手掌我更推荐凸包分析因为指尖的几何特征就是从轮廓向外突出的凸起点而指根恰好是轮廓凹进去的地方。先计算轮廓质心和凸包索引std::vectorcv::Point contour contours[idx]; cv::Moments m cv::moments(contour); cv::Point2f center(m.m10 / m.m00, m.m01 / m.m00); std::vectorint hullIdx; cv::convexHull(contour, hullIdx, false, false);第四个参数returnPointsfalse表示输出的是contour上的索引而不是坐标点。这个索引是convexityDefects的硬性要求传std::vectorcv::Point进去会直接断言失败。凸性缺陷描述的是“凸包与轮廓之间的凹陷区域”每一组Vec4i包含四个值凹陷起始轮廓点索引、结束轮廓点索引、最深点索引和深度。深度是定点数需要除以 256 才是像素值std::vectorcv::Vec4i defects; cv::convexityDefects(contour, hullIdx, defects); double palmRadius 0.0; for (int idx : hullIdx) { palmRadius std::max(palmRadius, cv::norm(contour[idx] - center)); } std::vectorcv::Point valleys; for (const auto d : defects) { int depthPix d[3] / 256; if (depthPix palmRadius * 0.03) { valleys.push_back(contour[d[2]]); } }palmRadius * 0.03是最小凹陷深度阈值。太小的缺陷通常来自手指边缘不光滑而不是真正的指根阈值调太大会漏掉小拇指根部的凹陷。3.2 指尖候选筛选尖角角度加质心距离双重约束凸包上除了五个指尖通常还有手腕两侧的角点。单靠“距离质心最远”这个条件手腕角点如果离得远也会混进来。更稳妥的做法是加一个尖角判断取轮廓上当前点前后各 8 个点计算两条边的夹角指尖角度明显小于 90 度手腕角点则钝很多。double angleAt(const std::vectorcv::Point c, int idx) { int n (int)c.size(); cv::Point a c[(idx - 8 n) % n]; cv::Point b c[idx]; cv::Point d c[(idx 8) % n]; cv::Point v1 a - b; cv::Point v2 d - b; double cosA v1.dot(v2) / (cv::norm(v1) * cv::norm(v2) 1e-6); return std::acos(std::max(-1.0, std::min(1.0, cosA))) * 180.0 / CV_PI; }这个函数的输出是角度不是相似度。角度小于 90 度且距离超过palmRadius * 0.75的点才认为是候选指尖std::vectorcv::Point tips; for (int idx : hullIdx) { double dist cv::norm(contour[idx] - center); if (dist palmRadius * 0.75 angleAt(contour, idx) 90.0) { tips.push_back(contour[idx]); } }0.75倍半径这个阈值在五指张开时很稳但如果手掌倾斜导致某根手指在图像里缩短阈值要降到0.7以下。角度阈值 90 度同样需要微调目标是让每个指尖只保留一个候选点。指根配对采用最近邻原则每个指尖左右两侧各有一个凹陷点取距离最近的两个valleys用它们的中点作为测量起点。struct Finger { cv::Point tip; cv::Point root; double lengthPx; }; std::vectorFinger fingers; for (const auto tip : tips) { cv::Point r1, r2; double d1 1e9, d2 1e9; for (const auto v : valleys) { double dist cv::norm(tip - v); if (dist d1) { d2 d1; r2 r1; d1 dist; r1 v; } else if (dist d2) { d2 dist; r2 v; } } if (d1 palmRadius * 0.8 d2 palmRadius * 0.8) { cv::Point root (r1 r2) * 0.5; fingers.push_back({ tip, root, cv::norm(tip - root) }); } }距离上限palmRadius * 0.8是为了防止某个指尖错误匹配到远处的虎口凹陷。大拇指的虎口凹陷距离较远如果测量对象是大拇指可以单独放宽到1.0倍。3.3 沿法线做轮廓内连续段统计计算手指宽度长度是两点间距离宽度则不能直接用“轮廓上某个点到另一个点”的欧氏距离。正确做法是先在指尖到指根之间取若干等分点在每个点上沿着手指中轴线的垂直方向画一条扫描线统计扫描线与二值 mask 相交的白色线段长度。double fingerWidthPx(const cv::Mat mask, const cv::Point tip, const cv::Point root) { cv::Point axis tip - root; double len cv::norm(axis); if (len 1e-6) return 0.0; cv::Point unit(axis.x / len, axis.y / len); cv::Point perp(-unit.y, unit.x); double best 0.0; cv::Rect imgRect(0, 0, mask.cols, mask.rows); for (double t 0.2; t 0.8; t 0.1) { cv::Point p root unit * (t * len); double radius len * 0.3; cv::Point p1(p.x - perp.x * radius, p.y - perp.y * radius); cv::Point p2(p.x perp.x * radius, p.y perp.y * radius); cv::LineIterator it(mask, p1, p2, 8); int seg 0; for (int i 0; i it.count; i, it) { cv::Point q it.pos(); if (!imgRect.contains(q)) continue; if (mask.atuchar(q) 255) { seg; } else if (seg 0) { break; } } best std::max(best, (double)seg); } return best; }这里有两个容易出错的地方。第一unit是归一化后的方向向量unit * (t * len)的结果才是真正的坐标偏移直接拿tip - root去乘会导致扫描点越过图像边界。第二统计时碰到第一个白色段后的 0 就break是为了防止扫描线另一端扫到相邻手指只保留当前手指的连续截面。采样范围从t0.2到t0.8避开指尖圆弧和指根过渡区。cv::LineIterator的count是扫描线上的像素总数不是图像宽高循环里不能直接用mask.cols。这个宽度值是“像素宽度”后面还有一步标定。4. 像素标定与 OpenCV 参数调优毫米级测量误差从哪来4.1 用参照物建立毫米-像素比例手指长度和宽度最终要输出成毫米。最直接的方法是拍照时在手掌旁边放一个已知尺寸的参照物比如直径 25 毫米的硬币或者宽度 30 毫米的标签纸。检测参照物的方式不用写死常见做法是另外跑一次二值化拿到参照物的外接圆或者外接矩形。double mmPerPx 1.0; std::vectorstd::vectorcv::Point objContours; cv::findContours(brightMask.clone(), objContours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); float radius 0.0f; cv::Point2f circleCenter; for (const auto c : objContours) { float r; cv::Point2f cc; cv::minEnclosingCircle(c, cc, r); if (r radius) { radius r; circleCenter cc; } } double coinDiameterPx radius * 2.0; mmPerPx 25.0 / coinDiameterPx;标定必须放在measureHand之前执行得到mmPerPx后再乘到lengthPx和widthPx上。要注意这个比例只对手掌与参照物处于同一平面、且镜头光轴基本垂直该平面时准确。手掌抬起倾斜哪怕只有 10 度测量误差也会超过 2%。4.2 threshold、缺陷深度阈值和宽度采样区间怎么调下面的参数表是撑起整个测量精度的关键任何一个取值偏差都会在最终输出里放大成毫米级误差。参数作用经验值调大后果调小后果inRangeCr 下限肤色分割范围133肤色暗区丢失指尖断裂桌面、阴影误检暴增闭运算核大小填补手指边缘空洞9x9相邻手指粘连边缘锯齿宽度偏大angleAt邻域点指尖夹角计算8 点角度更平滑但小拇指丢失噪声点被当成指尖指尖距离阈值质心距离比例0.75漏检拇指手腕角点混入缺陷深度阈值过滤无效凹陷0.03 * palmRadius指根缺失长度偏长边缘毛刺当指根宽度采样t区间采样范围0.2 - 0.8误入指根或指尖圆弧采样点太少不稳定调试时最有效的顺序是先看handMask是否干净再看tips和valleys标点是否落在正确位置。点和点没对上后面所有参数都没有调的意义。4.3 convexHull、convexityDefects 和 findContours 的三个常见失败先检查一个非常隐蔽的 API 误用cv::convexHull(contour, hullIdx, false, false)的第四个参数是returnPoints传false后hullIdx才是vectorint。如果把convexHull返回的坐标点直接传给convexityDefectsOpenCV 会报hull must be array of int之类的断言错误。第二个高频问题是findContours修改了输入图。上面代码里写的是morph.clone()如果你图省事传了morph第二次调用时轮廓会全乱。这个错误在单张图调试时不一定暴露切换到批处理时才会突然出现。第三个问题是手腕进入 ROI。手腕带来的两个凸包点角度不够尖通常能被angleAt过滤掉但如果手掌在画面里偏小手腕点与质心距离可能超过0.75 * palmRadius。这时候先裁掉roi底部 15% 再重新找轮廓比继续调阈值更省时间。cv::Rect handRoi cv::boundingRect(contours[idx]); handRoi.height cvRound(handRoi.height * 0.85); cv::Mat cropped handMask(handRoi);裁剪后所有坐标都要加上handRoi.x和handRoi.y才能映射回原图测量结果标注时容易忽略这一步。5. 把测量封装成单文件命令行工具并在图上验证5.1 用 drawContours 和 putText 输出标注图与 CSV把所有步骤收拢成一个measureHand函数后主程序只需要处理输入输出。命令行设计成接受图片路径和可选的mmPerPx没有标定参数时就输出像素值int main(int argc, char** argv) { if (argc 3) { std::cerr usage: hand_measure image mm_per_px std::endl; return 1; } cv::Mat bgr cv::imread(argv[1], cv::IMREAD_COLOR); double scale atof(argv[2]); auto fingers measureHand(bgr, scale); std::ofstream csv(hand_measure.csv); csv index,length_mm,width_mm\n; for (size_t i 0; i fingers.size(); i) { csv i , fingers[i].lengthPx * scale , fingers[i].widthPx * scale \n; } cv::Mat canvas bgr.clone(); std::vectorstd::vectorcv::Point drawContours { fingers[0].tip }; cv::drawContours(canvas, std::vectorstd::vectorcv::Point{ contour }, -1, cv::Scalar(0, 255, 0), 2); for (size_t i 0; i fingers.size(); i) { cv::circle(canvas, fingers[i].tip, 6, cv::Scalar(0, 0, 255), -1); cv::line(canvas, fingers[i].tip, fingers[i].root, cv::Scalar(255, 0, 0), 2); cv::putText(canvas, cv::format(%.1f mm, fingers[i].lengthPx * scale), fingers[i].tip cv::Point(-20, -10), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 0, 255), 1); } cv::imwrite(annotated.png, canvas); return 0; }drawContours接收的是vectorvectorPoint不是单个Point向量。用轮廓可视化时把最大轮廓包一层再传进去是最不容易错的写法。putText的坐标加一个cv::Point(-20, -10)偏移避免文字压住指尖圆点。5.2 旋转自检用多角度重测结果判断算法稳定性单张图像测量只能说明“这次跑了出结果”不能说明算法稳定。最容易做、且信息量最大的验证方式是旋转输入图重新跑一遍完整测量流程。cv::Mat rotated; cv::Point2f center(bgr.cols / 2.0f, bgr.rows / 2.0f); cv::Mat rot cv::getRotationMatrix2D(center, 4.0, 1.0); cv::warpAffine(bgr, rotated, rot, bgr.size());对同一张图分别旋转 -4、-2、0、2、4 度记录每根手指长度与宽度的均值和标准差。如果同一手指长度在不同角度下差异超过 2%说明指尖或指根匹配不稳定优先检查angleAt邻域点数量和凹陷深度阈值如果宽度差异大问题多半出在二值 mask 边缘需要调整闭运算核。旋转自检还能用来选“代表值”把 5 个角度下的测量结果按手指编号取中位数比直接采用 0 度单次结果更稳定。把多角度结果写进同一张 CSV按手指编号横向展开后续做误差分析时也能直接看到哪根手指最不稳定。本文还有配套的精品资源点击获取