引言
在AIoT时代,手势交互正在重塑人机交互方式。从智能家居的隔空操控到AR/VR的沉浸式体验,再到无障碍交互系统,高精度手掌检测是构建下一代自然交互的基础能力。
传统图像处理方法在复杂场景下捉襟见肘,而基于深度学习的方法在精度和鲁棒性上实现了质的飞跃。本文将手把手带你实现工业级手掌检测系统。
准备阶段
- opencv 4.x
- MediaPipe 数据模型
- vs2022
处理效果
处理流程
和常规图像处理方式一样,分为预处理,预测,后处理。
- 预处理
- 预测
- 后处理
一、模型输入要求:让AI"看懂"手掌的秘诀
1.1 输入规范
- 尺寸:192×192像素
- 颜色空间:RGB格式
- 数值范围:[0,1]浮点数
- 通道顺序:NHWC(批次数×高度×宽度×通道)
1.2 预处理代码
/**
* @brief 图像预处理流程
* @param image 原始输入图像
* @return 预处理后的blob和填充偏移量
*/
std::pair<cv::Mat, cv::Point2i> PalmDetector::preprocess(const cv::Mat& image) {
cv::Point2i pad_bias(0, 0); // 记录填充偏移量
// 计算保持长宽比的缩放比例
float ratio = std::min(static_cast<float>(input_size.width) / image.cols,
static_cast<float>(input_size.height) / image.rows);
cv::Mat processed_image;
if (image.rows != input_size.height || image.cols != input_size.width) {
// 按比例缩放图像
cv::Size ratio_size(static_cast<int>(image.cols * ratio),
static_cast<int>(image.rows * ratio));
cv::resize(image, processed_image, ratio_size);
// 计算需要填充的像素数
int pad_h = input_size.height - ratio_size.height;
int pad_w = input_size.width - ratio_size.width;
pad_bias.x = pad_w / 2; // 水平方向填充偏移
pad_bias.y = pad_h / 2; // 垂直方向填充偏移
// 执行对称填充
cv::copyMakeBorder(processed_image, processed_image,
pad_bias.y, pad_h - pad_bias.y,
pad_bias.x, pad_w - pad_bias.x,
cv::BORDER_CONSTANT, cv::Scalar(0, 0, 0));
}
else {
processed_image = image.clone();
}
// 将图像转换为模型输入的blob格式
cv::Mat blob;
cv::dnn::Image2BlobParams params;
params.datalayout = cv::dnn::DNN_LAYOUT_NHWC; // 通道最后排列
params.ddepth = CV_32F; // 浮点型数据
params.mean = cv::Scalar::all(0); // 不进行均值减法
params.scalefactor = cv::Scalar::all(1.0 / 255.0);// 归一化到[0,1]
params.size = input_size; // 输入尺寸
params.swapRB = true; // BGR转RGB
params.paddingmode = cv::dnn::DNN_PMODE_NULL; // 无特殊填充
blob = cv::dnn::blobFromImageWithParams(processed_image, params);
// 将填充偏移量转换回原始图像坐标系
pad_bias.x = static_cast<int>(pad_bias.x / ratio);
pad_bias.y = static_cast<int>(pad_bias.y / ratio);
return { blob, pad_bias };
}
1.3 预处理关键技术
二.模型预测
通过opencv dnn模块调用模型推理。
2.1 预测代码
/**
* @brief 执行手掌检测推理
* @param image 输入图像(BGR格式)
* @return 检测结果向量,每个元素包含[x1,y1,x2,y2,landmarks...,score]
*/
std::vector<std::vector<float>> PalmDetector::infer(const cv::Mat& image) {
// 图像预处理:缩放、填充、转换为blob
std::pair<cv::Mat, cv::Point2i> preprocess_result = preprocess(image);
cv::Mat preprocessed_image = preprocess_result.first;
cv::Point2i pad_bias = preprocess_result.second;
// 设置模型输入并执行前向推理
model.setInput(preprocessed_image);
std::vector<cv::Mat> outputs;
model.forward(outputs, model.getUnconnectedOutLayersNames());
// 后处理:解码输出,应用NMS,生成最终检测结果
return postprocess(outputs, image.size(), pad_bias);
}
三、模型输出解析:从数字到物理世界的映射
3.1 模型输出数据格式
boxes_output [2016, 18] //手掌边框4 + 7个手掌关键点
scores_output [2016, 1] 2016 个 置信度
3.2 后处理模型代码
/**
* @brief 后处理模型输出
* @param output_blobs 模型输出层数据
* @param original_size 原始图像尺寸
* @param pad_bias 预处理时的填充偏移量
* @return 格式化后的检测结果
*/
std::vector<std::vector<float>> PalmDetector::postprocess(
const std::vector<cv::Mat>& output_blobs,
const cv::Size& original_size,
const cv::Point2i& pad_bias) {
// 解析模型输出:得分矩阵和边界框/关键点矩阵
cv::Mat scores = output_blobs[1].reshape(1, output_blobs[1].total() / 1);
cv::Mat boxes = output_blobs[0].reshape(1, output_blobs[0].total() / 18);
std::vector<float> score_vec; // 有效检测得分
std::vector<cv::Rect2f> boxes_vec; // 检测框坐标
std::vector<std::vector<cv::Point2f>> landmarks_vec; // 手掌关键点
// 计算原始图像的缩放比例(预处理时保持长宽比)
float scale = std::max(original_size.height, original_size.width);
// 遍历所有候选检测结果
for (int i = 0; i < scores.rows; i++) {
// 计算sigmoid得分
float score = 1.0f / (1.0f + std::exp(-scores.at<float>(i, 0)));
// 提取当前检测的框偏移量和关键点偏移量
cv::Mat box_delta = boxes.row(i).colRange(0, 4);
cv::Mat landmark_delta = boxes.row(i).colRange(4, 18);
cv::Point2f anchor = anchors[i]; // 当前锚点坐标
// 解码边界框参数
cv::Point2f cxy_delta(box_delta.at<float>(0) / input_size.width,
box_delta.at<float>(1) / input_size.height);
cv::Point2f wh_delta(box_delta.at<float>(2) / input_size.width,
box_delta.at<float>(3) / input_size.height);
// 计算实际图像坐标(考虑填充偏移)
cv::Point2f xy1(
(cxy_delta.x - wh_delta.x / 2 + anchor.x) * scale - pad_bias.x,
(cxy_delta.y - wh_delta.y / 2 + anchor.y) * scale - pad_bias.y
);
cv::Point2f xy2(
(cxy_delta.x + wh_delta.x / 2 + anchor.x) * scale - pad_bias.x,
(cxy_delta.y + wh_delta.y / 2 + anchor.y) * scale - pad_bias.y
);
// 过滤低置信度检测
if (score > score_threshold) {
score_vec.push_back(score);
boxes_vec.emplace_back(xy1.x, xy1.y, xy2.x - xy1.x, xy2.y - xy1.y);
// 解码关键点坐标
std::vector<cv::Point2f> landmarks;
for (int j = 0; j < 7; j++) { // 7个关键点
float dx = landmark_delta.at<float>(j * 2) / input_size.width;
float dy = landmark_delta.at<float>(j * 2 + 1) / input_size.height;
dx += anchor.x;
dy += anchor.y;
// 转换到原始图像坐标系
dx = dx * scale - pad_bias.x;
dy = dy * scale - pad_bias.y;
landmarks.emplace_back(dx, dy);
}
landmarks_vec.push_back(landmarks);
}
}
// 应用非极大值抑制(NMS)
std::vector<int> indices;
std::vector<cv::Rect> boxes_int; // NMSBoxes需要整数坐标
for (const auto& box : boxes_vec) {
boxes_int.emplace_back(static_cast<int>(box.x),
static_cast<int>(box.y),
static_cast<int>(box.width),
static_cast<int>(box.height));
}
cv::dnn::NMSBoxes(boxes_int, score_vec, score_threshold,
nms_threshold, indices);
// 格式化输出结果:框坐标+关键点+得分
std::vector<std::vector<float>> results;
for (int idx : indices) {
std::vector<float> result;
const auto& box = boxes_vec[idx];
// 添加框坐标(x1,y1,x2,y2)
result.push_back(box.x);
result.push_back(box.y);
result.push_back(box.x + box.width);
result.push_back(box.y + box.height);
// 添加关键点坐标(x,y)*7
for (const auto& point : landmarks_vec[idx]) {
result.push_back(point.x);
result.push_back(point.y);
}
// 添加置信度得分
result.push_back(score_vec[idx]);
results.push_back(result);
}
return results;
}
3.3 后处理输出格式
- std::vector<std::vector>
可拆分为std::vector<18个浮点数>
其中18个浮点数含义:
手掌示意图:
3.4 关键点解码流程:
- 遍历7个关键点(每个点x,y坐标)
- 应用与边界框相同的坐标转换逻辑
- 使用Sigmoid函数处理置信度得分