简介:基于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++ 标准建议设成 C++17,cv::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 函数 | 常用参数 | 调整方向 |
|---|---|---|---|
| 色彩空间转换 | cvtColor | COLOR_BGR2YCrCb | 比 HSV 对高光更稳 |
| 肤色阈值 | inRange | Cr 133-173,Cb 77-127 | 偏黄环境上调 Cr 下限 |
| 去毛刺 | MORPH_OPEN | 5x5 椭圆核 | 核太大会吃掉小拇指边缘 |
| 填补空洞 | MORPH_CLOSE | 9x9 椭圆核 | 核太大会合并相邻手指 |
2.3 提取最大轮廓并用 fillPoly 生成干净的测量 mask
二值 mask 里可能有桌面上的肤色误检,或者手掌之外的小块噪声。测量只需要最大的一块连通区域,所以先findContours,再按面积排序取最大轮廓。
这里有一个 C++ 里很容易踩的坑:OpenCV 4 的findContours会修改输入图像,所以传入的必须是二值图的克隆,而不是morph本身。
std::vector<std::vector<cv::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::vector<std::vector<cv::Point>> poly = { contours[idx] }; cv::fillPoly(handMask, poly, cv::Scalar(255)); cv::Rect roi = cv::boundingRect(contours[idx]);fillPoly和drawContours(..., cv::FILLED)效果等价,但fillPoly接受vector<vector<Point>>时更直接。生成的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::vector<cv::Point> contour = contours[idx]; cv::Moments m = cv::moments(contour); cv::Point2f center(m.m10 / m.m00, m.m01 / m.m00); std::vector<int> hullIdx; cv::convexHull(contour, hullIdx, false, false);第四个参数returnPoints=false表示输出的是contour上的索引,而不是坐标点。这个索引是convexityDefects的硬性要求,传std::vector<cv::Point>进去会直接断言失败。
凸性缺陷描述的是“凸包与轮廓之间的凹陷区域”,每一组Vec4i包含四个值:凹陷起始轮廓点索引、结束轮廓点索引、最深点索引和深度。深度是定点数,需要除以 256 才是像素值:
std::vector<cv::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::vector<cv::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::vector<cv::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::vector<cv::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::vector<Finger> 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.at<uchar>(q) == 255) { seg++; } else if (seg > 0) { break; } } best = std::max(best, (double)seg); } return best; }这里有两个容易出错的地方。第一,unit是归一化后的方向向量,unit * (t * len)的结果才是真正的坐标偏移;直接拿tip - root去乘会导致扫描点越过图像边界。第二,统计时碰到第一个白色段后的 0 就break,是为了防止扫描线另一端扫到相邻手指,只保留当前手指的连续截面。
采样范围从t=0.2到t=0.8,避开指尖圆弧和指根过渡区。cv::LineIterator的count是扫描线上的像素总数,不是图像宽高,循环里不能直接用mask.cols。
这个宽度值是“像素宽度”,后面还有一步标定。
4. 像素标定与 OpenCV 参数调优:毫米级测量误差从哪来
4.1 用参照物建立毫米-像素比例
手指长度和宽度最终要输出成毫米。最直接的方法是拍照时在手掌旁边放一个已知尺寸的参照物,比如直径 25 毫米的硬币,或者宽度 30 毫米的标签纸。检测参照物的方式不用写死,常见做法是另外跑一次二值化,拿到参照物的外接圆或者外接矩形。
double mmPerPx = 1.0; std::vector<std::vector<cv::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才是vector<int>。如果把convexHull返回的坐标点直接传给convexityDefects,OpenCV 会报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::vector<std::vector<cv::Point>> drawContours = { fingers[0].tip }; cv::drawContours(canvas, std::vector<std::vector<cv::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接收的是vector<vector<Point>>,不是单个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,按手指编号横向展开,后续做误差分析时也能直接看到哪根手指最不稳定。
本文还有配套的精品资源,点击获取