Matlab手势识别:DTW/Frechet/Hausdorff模板匹配GUI
1. 手势识别这个题目为什么值得认真做一遍第一次拿到这个题的时候我脑子里冒出来的念头其实很朴素手势识别无非就是把手部轨迹抓出来跟模板比一比谁近就判成谁。真动手之后才发现难点压根不在比这一步而在怎么比才公平。同一套动作你录一条样本用两秒别人做一遍用四秒你画一个圆圈起笔在正上方他起笔在左下角你手抖带来的高频抖动会让两个本来属于同类的手势在距离值上被拉得比异类还远。手势识别gesture recognition这个领域里模板匹配是最轻量、最容易落地的技术路线尤其适合资源有限的嵌入式场景和教学演示场景而 DTW、Frechet、Hausdorff 这三种距离度量恰好代表了三种完全不同的比较哲学。这篇内容适合三类人看。第一类是正在做课程设计或者毕业设计、需要一个能跑起来、有界面、能演示的系统的人第二类是想搞明白轨迹相似度度量到底怎么选、参数怎么定的人第三类是做交互原型想用手势替代鼠标键盘做快速验证的人。我下面给的东西不是纯理论推导而是围绕一个完整的 Matlab GUI 项目来展开数据怎么从原始坐标变成可比序列三种算法各自的实现要点和坑位在哪里界面上那些控件背后的回调怎么写以及实测下来哪种场景该选哪种方法。需要提前说一句三种算法没有绝对的高下只有适配的场景。DTW 擅长处理节奏不同但动作一致的情况Frechet 对点的先后顺序极其敏感Hausdorff 只看形状不看顺序。你选错了识别率能差出二十个百分点这不是夸张。2. 三种距离度量到底在算什么2.1 DTW把快慢不一的时间轴掰弯动态时间规整Dynamic Time WarpingDTW解决的问题非常具体两条长度不同的序列怎么找到一种最优的对齐方式让它们之间的累计距离最小。举个生活化的例子两个人唱同一首歌一个唱得快一个唱得慢你逐帧对比波形永远对不上但如果允许把时间轴做局部的拉伸和压缩就能对齐到同一个旋律上。DTW 干的就是这件事。算法核心是一个累加代价矩阵 D。设模板序列为 P长度 N待测序列为 Q长度 M先算一个 N×M 的局部距离矩阵元素 d(i,j) 通常是 P 第 i 个点和 Q 第 j 个点的欧氏距离。然后递推% 累加距离矩阵递推 D zeros(N, M); D(1,1) d(1,1); for i 2:N D(i,1) D(i-1,1) d(i,1); end for j 2:M D(1,j) D(1,j-1) d(1,j); end for i 2:N for j 2:M D(i,j) d(i,j) min([D(i-1,j), D(i-1,j-1), D(i,j-1)]); end end dist D(N,M);这里面有个非常关键的工程细节朴素 DTW 的计算量是 O(N·M)。假设模板有 50 条每条重采样到 128 个点单次识别就是 50×128×128 约 82 万次距离计算在 Matlab 里循环实现会明显感觉到卡顿。我的处理方式是加Sakoe-Chiba 窗口约束也就是只在一个带状区域内搜索最优路径带宽 r 一般取序列长度的 10% 到 20%。这一改计算量直接降到 O(N·r)实测识别耗时从一百多毫秒压到十几毫秒而且识别率几乎不掉——因为真实的手势对齐不会出现极端的路径偏移。还有一个容易被忽略的点路径约束。有些实现只允许 (i-1,j)、(i-1,j-1)、(i,j-1) 三个方向的转移这保证了单调性和连续性不会出现时间倒流的荒谬对齐。但如果你对端点对齐有要求还得额外约束起点必须是 (1,1)、终点必须是 (N,M)。早期我图省事没做强约束结果一个画横线的手势被对齐到了画竖线的模板上距离值还挺小排查了半天才反应过来是路径跑飞了。2.2 Frechet比 DTW 更讲究顺序感弗雷歇距离Frechet distance有个特别形象的类比——人遛狗。人和狗沿着各自的路径走中间用一根绳连着绳子的最短长度就是这两条曲线的 Frechet 距离而且两者都必须沿着各自路径向前走不能倒退。这个不能倒退的约束就是它和 Hausdorff 最大的区别。离散 Frechet 距离的递推式和 DTW 长得很像但语义完全不同。DTW 求的是累计最小Frechet 求的是最大中的最小% 离散 Frechet 距离 dF zeros(N, M); dF(1,1) d(1,1); for i 2:N dF(i,1) max(dF(i-1,1), d(i,1)); end for j 2:M dF(1,j) max(dF(1,j-1), d(1,j)); end for i 2:N for j 2:M dF(i,j) max(d(i,j), min([dF(i-1,j), dF(i-1,j-1), dF(i,j-1)])); end end dist dF(N,M);注意那个 max 套 min 的结构。它的意义是每一步必须承受当前点的距离同时选择前驱中代价最小的那条路。最终结果反映的是整条对齐路径上最大的那个局部偏差。这个特性带来一个很实际的好处——它对局部噪声不敏感但对整体形状的偏离非常敏感。一条曲线中间抖一下DTW 的累计值会涨一点点Frechet 可能根本不变但如果曲线的整体走向偏了Frechet 会立刻炸出一个大值。在我做的对比实验里Frechet 在处理手势闭合性这一类问题上表现特别好。比如画圆和画圆弧两者的采样点分布可能很接近Hausdorff 容易混淆DTW 因为累计效应也容易糊在一起但 Frechet 能靠端点与路径的顺序关系把它们分开。代价是计算量比加了窗口的 DTW 还大因为 Frechet 目前没有特别有效的线性近似只能老老实实做 O(N·M) 的动态规划。2.3 Hausdorff只看形状不管先后豪斯多夫距离Hausdorff distance的定义很直白点集 A 中的每个点到点集 B 的最近距离取其中最大的那个再反过来算一遍 B 到 A两个结果取最大值。公式写出来就是 h(A,B) max_{a∈A} min_{b∈B} d(a,b)H(A,B) max(h(A,B), h(B,A))。它的优点是不需要对齐也不需要两条序列长度相同直接把点云丢进去就能算。这在某些场景下极其方便——比如你不关心用户是从左往右画还是从右往左画只关心他画的形状是不是那个样子。缺点同样明显它对离群点极其敏感。用户手上一个抖动产生的孤立点可能让整个距离值翻倍。我的应对策略是换用平均 Hausdorff 距离也就是把 max 换成均值% 平均 Hausdorff对离群点更鲁棒 distMat pdist2(A, B); hAB mean(min(distMat, [], 2)); hBA mean(min(distMat, [], 1)); dist max(hAB, hBA);这一改抗噪能力上来了但同时也削弱了它对形状是否严格一致的判别力。实测下来平均 Hausdorff 在粗分类阶段很有用能快速排除掉明显不像的模板把候选集从五十条缩到五条再交给 DTW 或者 Frechet 做精细匹配。这个两级级联的思路是我在这个项目里最有价值的一个改动后面会详细说。3. 数据预处理与特征构造识别率的第一道分水岭3.1 手部轨迹的采集与坐标归一化不管后面用什么算法前面这一步做不好全是白搭。我在项目里用的轨迹来源有两类一类是预先录好的坐标序列文件另一类是通过摄像头逐帧提取手部关键点。无论哪种拿到的原始数据都是 (x, y) 或者 (x, y, t) 的序列直接送进算法基本没法用。第一个必须做的是平移归一化。不同人做手势的位置不一样有人在画面左上角画圈有人在右下角如果不处理两个同类手势的欧氏距离会大得离谱。做法是算序列的质心把所有点减去质心坐标pts pts - mean(pts, 1);第二个是尺度归一化。手大手小、离镜头远近都会影响轨迹的绝对尺寸。我的做法是算序列的外接包围盒取长边作为尺度因子把整个序列缩放到单位长度span max(max(pts) - min(pts)); if span eps pts pts / span; end这里有个坑我得提醒包围盒缩放对细长型手势和方正型手势的缩放比例是不一样的。比如画一条横线长边是横向跨度缩放后纵向几乎为零画一个正方形横纵比例保持一致。这本身没问题但如果你的手势库里同时有横线和竖线缩放之后它们会变成同一条线段的不同朝向Hausdorff 距离会认为它们几乎一样。解决办法是保留朝向信息或者在特征里额外加一个主轴方向角。我在 GUI 里加了一个是否启用方向约束的复选框就是这个原因。第三个是重采样。原始轨迹的采样率取决于帧率不同设备不一样同一设备在不同负载下也不一样。统一重采样到固定点数我用的是 64 点有两个好处一是让不同长度的序列可比二是把计算量钉死。重采样我按弧长等距来做而不是按时间等距因为按时间等距会让快速划过的部分点稀疏、慢速部分点密集形状信息分布不均。3.2 噪声过滤别小看那几个毛刺真实采集的轨迹一定有抖动。手指在触屏上划一下坐标序列的相邻点差值经常出现几个像素的来回跳。这些高频噪声对不同算法的影响程度完全不同必须区别对待。对 DTW 来说噪声的影响是累计型的。每个抖动点都会往累加矩阵里贡献一点距离最后总量会明显偏高。我的处理是先做一次滑动平均滤波窗口取 3 到 5win 5; kernel ones(1, win) / win; xSmooth conv(pts(:,1), kernel, same); ySmooth conv(pts(:,2), kernel, same);需要注意conv 的 same 模式会在两端产生边界效应首尾几个点会被拉向零。更稳妥的写法是用 movmean 或者手写一个只对有效区间求均值的循环。我早期直接用 conv 没注意导致手势起点总是被吸到原点附近识别的时候端点类的模板全部失配查了两天才定位到。对 Hausdorff 来说噪声是极值型的。因为它取的是最大最小距离一个离群点就能主导结果。所以这条路线上我不仅做平滑还会额外做一个离群点剔除算每个点到序列中位点的距离超过中位数三倍标准差的点直接丢掉。这一步做完Hausdorff 的稳定性提升非常明显。对 Frechet 来说噪声影响介于两者之间。它不累计但对路径形状敏感所以平滑是必要的剔除倒不是必须。3.3 模板库的构建录几条才算够这个问题我被问过很多次。我的答案是每个手势至少 3 条理想情况 5 条且必须来自不同的录制轮次。不要一次性连续录五条那样五条样本之间的相似度极高等于只有一条。模板库的构建逻辑是对每个手势类别收集若干条样本识别时计算待测样本到该类所有模板的距离取最小值作为该类得分最后取全局最小值对应的类别作为识别结果。这个取最小的策略比取平均更合理因为真实手势的类内差异往往是多峰的——不同的起手姿势、不同的手速会形成几个明显的子簇取平均反而会把簇中心拉到一个谁都不像的位置。我在项目里还做了一个小设计GUI 上有个追加模板按钮识别错了可以当场把当前轨迹补录进对应类别。这个功能在实际调试阶段特别好用边测边补模板库会越来越贴合真实使用习惯。当然要注意控制模板总量超过一百条之后单次识别的耗时会变得明显这时候就该考虑做模板约简了——比如对同一类别内的模板先做一次层次聚类每簇只保留距簇心最近的那条。4. Matlab GUI 实操从界面布局到算法回调4.1 界面框架与控件划分这个项目的界面我按左中右三栏来组织。左侧是画板区域一个 axes 控件用来实时绘制用户当前轨迹中间是模板展示区一个小 axes 加一个列表框显示当前选中类别的模板形状右侧是控制面板包含算法选择下拉框、参数输入框、识别按钮、清空按钮、录制模板按钮以及一个结果显示文本框。用 GUIDE 还是 App Designer我的建议是如果你手上的 Matlab 版本在 R2016a 之后优先用 App Designer控件的布局约束更稳跨分辨率的表现更好。但我这个项目为了兼容性用了 GUIDE也就是 .fig 加 .m 的经典组合好处是代码结构一目了然回调函数和控件一一对应教学演示的时候容易讲清楚。画板区域的轨迹采集核心是三个回调WindowButtonDownFcn、WindowButtonMotionFcn、WindowButtonUpFcn。按下时清空当前轨迹并开始记录移动时不断把当前点追加进序列并重绘抬起时结束记录并触发预处理。这里有个细节必须处理移动回调触发频率很高如果每次都重绘全图界面会明显卡顿。我的做法是隔几次采样才重绘一次用一个计数器控制persistent drawCnt if isempty(drawCnt), drawCnt 0; end drawCnt drawCnt 1; if mod(drawCnt, 4) 0 set(hLine, XData, trajX, YData, trajY); drawnow limitrate; enddrawnow limitrate比裸drawnow好很多它会把多次重绘请求合并帧率能稳定在 30 以上。4.2 核心计算函数的组织方式我把三种算法各自封装成一个独立的函数文件签名统一成dist algoName(seqA, seqB, params)这样在识别主流程里可以直接用函数句柄切换switch algoPopup.Value case DTW fHandle calcDTW; case Frechet fHandle calcFrechet; case Hausdorff fHandle calcHausdorff; end scores zeros(1, numClasses); for c 1:numClasses dmin inf; for t 1:numTemplates{c} d fHandle(testSeq, templates{c}{t}, params); dmin min(dmin, d); end scores(c) dmin; end [~, predIdx] min(scores);这种写法有个很大的好处新增第四种算法的时候只要写一个同签名的函数在 switch 里加一行界面和流程一行都不用改。我后来加了一个轮廓距离的对比实验就是这么接进去的十分钟搞定。参数传递上我用一个结构体 params 统一管理窗口宽度、重采样点数、是否启用平均 Hausdorff 等开关。GUI 上对应的输入框在识别前读一次值填进结构体避免在深层函数里反复访问界面句柄——那样写出来的代码几乎没法单独测试。4.3 结果反馈与可视化设计识别结果只给一个类别名字体验其实很差。我在结果区加了三块信息最匹配的类别、对应的距离值、各类得分的柱状图。柱状图这块特别值得做因为用户能一眼看出是干脆利落地赢了还是险胜后者往往意味着模板库需要补样本。另外我加了一个叠加对比的选项勾上之后待测轨迹和最匹配的模板会以不同颜色画在同一个坐标系里并且用虚线把关键对应点连起来。这个可视化的价值远超预期——很多识别错误一眼就能看出原因比如用户画的方向反了、起笔位置差太多、或者后半段手滑跑偏了。调试阶段我几乎全靠这个视图定位问题。性能上还有一个点柱状图的绘制要放在识别计算之后、但不要每次都重建 axes。用bar每次重建会有一两百毫秒的延迟改成初始化时建好 bar 对象之后只更新YData响应速度立刻不一样。5. 三种算法的实测对比与选型建议5.1 实验怎么设计才算公平我搭了一个小规模的测试集五类手势分别是画横线、画竖线、画圆、画 Z 字形、画三角形。每类由三名不同的操作者各做十次共 150 条测试样本。模板库每类五条来自另外两名操作者。这个设计的用意是模拟跨用户场景因为同一个人录同一个人测识别率虚高没有参考价值。评价指标除了整体准确率我还记录了两项单次识别的平均耗时以及不同操作者之间的准确率波动。后者很重要如果一个算法在操作者 A 身上有 95%在操作者 B 身上只有 70%那它在真实场景里基本没法用。预处理统一按前面说的流程走质心平移、包围盒缩放、弧长重采样到 64 点、五点滑动平均。5.2 实测结果与场景适配分析算法整体准确率平均耗时ms跨操作者波动主要失效模式DTW窗口 15%93.3%11.2±4.1%圆形与三角形尾部混淆离散 Frechet91.3%46.8±3.2%起笔方向相反时失配原始 Hausdorff82.7%3.5±9.6%对抖动点敏感横竖线混淆平均 Hausdorff86.0%3.8±5.8%形状相近的类别分不开平均 Hausdorff 粗筛 DTW 精排95.3%8.7±2.4%粗筛漏掉正确类别这张表里的数字比任何理论分析都有说服力。几个结论值得展开说。第一DTW 的性价比确实最高。加了窗口之后它的耗时和 Hausdorff 在同一量级但准确率高出七个点。对于用户动作节奏差异大但形状一致性尚可的手势场景它是默认首选。第二Frechet 的耗时是个硬伤。将近五十毫秒在模板数量翻倍之后会线性增长。但它的跨操作者波动最小说明它对个体差异的容忍度更好。如果你的场景对实时性要求不高比如离线的手势日志分析它值得优先考虑。第三Hausdorff 单独用确实不够看但它快得离谱。三点几毫秒意味着你可以用它先做一次全库粗筛。级联方案就是这么来的整体准确率反而超过了单独的 DTW因为粗筛阶段把一些会被 DTW 误判的干扰项提前排除了。这个结果我一开始没想到属于实验过程中的意外收获。第四方向一致性是个隐藏的大坑。测试里画圆这个手势有人顺时针有人逆时针Hausdorff 完全分不出来DTW 勉强能分但得分很接近只有 Frechet 表现稳定。如果你的手势集里存在这种镜像或者反向也算同类的情况要么在预处理里做方向归一化要么直接用 Frechet。6. 常见问题与排查实录6.1 识别结果忽好忽坏同一手势两次结果不同这是最高频的问题。排查顺序我建议按这个来先看原始轨迹有没有丢点再看预处理后的序列是否稳定最后才怀疑算法。丢点的典型表现是轨迹中间出现长直线段。原因通常是移动回调被阻塞比如界面上某个耗时操作占住了主线程。Matlab 是单线程的回调里做重计算一定会掉帧。我的解决办法是把重计算挪到按钮回调里采集阶段只做轻量的坐标追加和重绘。预处理不稳定的表现是两次录同一个手势重采样后的点数虽然一样但分布明显不同。检查一下重采样是不是用的累计弧长插值如果用的是简单等间隔索引长度差异大的轨迹会被扭曲得很厉害。如果前两步都正常才轮到怀疑算法。DTW 在这种情况下大概率是窗口太窄正确路径被约束带切掉了把窗口从 10% 放宽到 20% 试试。6.2 GUI 点击识别后界面卡住两三秒这个问题我在模板数量上到八十条之后遇到了。原因是循环里每条模板都跑一次完整算法中间没有任何让出控制权的机会。三种改法我都试过效果依次递增。最简单的改法是在循环里插drawnow但这样会让总耗时更长不推荐。第二种是把最内层循环向量化Matlab 的矩阵运算比 for 循环快一个量级把 pdist2 用起来局部距离矩阵一次算完。第三种是加进度提示用一个 waitbar 让用户知道程序没死同时把粗筛级联打开把候选模板从八十条压到八条。我最后用的是第二加第三种。实测八十三条模板的识别耗时从 2.8 秒降到 0.4 秒体感完全不一样。6.3 换个人来用识别率断崖式下跌这几乎是所有模板匹配方法的通病。根因是模板库只覆盖了录制者的动作习惯。解决办法有三条按成本从低到高排扩模板库每个类别补录不同操作者的样本至少保证三类体型和两种惯用手都在里面在预处理里加强归一化特别是手部尺度这一块用掌宽做参考长度比用包围盒更稳引入拒绝判定也就是当最小距离超过某个阈值时不强行给出类别而是返回未识别。这一条特别重要很多演示系统之所以显得不靠谱就是因为它永远都给一个答案哪怕答案错得离谱。阈值的定法我建议用统计的方式拿一批已知类别的样本统计正确匹配的距离分布取 95 分位数作为阈值上限再拿一批异类样本算距离取 5 分位数作为下限两者之间就是灰色地带。这个灰色地带里我选择拒绝宁可让用户重做一次。6.4 常见问题速查现象可能原因处理方式轨迹画出来是断的移动回调掉帧、坐标追加被跳过采集阶段减负重绘降频所有手势都判成同一类归一化失效或模板库单一检查质心与缩放补多来源模板距离值全部接近零序列长度为零或重复点太多加长度与去重校验逆时针和顺时针分不开度量本身不区分方向换 Frechet 或做方向归一化偶尔报索引越界重采样后点数不足算法最小要求设下限不足时拒绝计算界面缩放后控件错位用了绝对像素定位换归一化 Units 或 App Designer7. 参数调优与后续可扩展的方向7.1 DTW 窗口和权重的实际取值窗口宽度我一般取序列长度的 15%但这不是拍脑袋。手势轨迹的特点是局部形变集中在转折处直线段的对齐偏差很小。如果你的手势里弧线占比高窗口可以放宽到 20%如果全是折线10% 就够。判断依据很简单把窗口从 5% 逐步加到 30%画一条窗口—准确率曲线找拐点。权重方面朴素 DTW 每步代价相同但实际场景里起点和终点的对齐质量比中间段重要得多。我试过给首尾各 10% 的区间乘一个 1.5 的权重系数端点类手势比如 Z 字形的准确率涨了三个点圆形手势则几乎无变化。这个改动很小但针对性强值得一试。7.2 阈值设定的量化方法不要凭感觉定阈值。我的做法是先录一批正样本也就是同一手势的多次重复算它们两两之间的距离得到类内距离分布再算不同手势之间的距离得到类间距离分布。两类分布的重叠区域就是最难的样本区。阈值定在重叠区的下沿偏上位置能保证高精确率定在上沿召回率更高。如果两个分布几乎完全重叠说明这两类手势在你的特征空间里根本不可分再怎么调阈值都没用。这时候要做的是换特征或者换算法而不是死磕参数。我在圆和椭圆上就踩过这个坑最后是加了长短轴比这个显式特征才解决。7.3 还能往哪些方向继续做第一是多特征融合。目前只用了几何轨迹其实还可以加轨迹的速度剖面、曲率序列、起止点相对位置。DTW 完全可以作用在一维的速度序列上把一个二维问题降成一维计算量小很多而且对画得快和画得慢的区分更直接。第二是算法级联的自动化。现在粗筛用的是平均 Hausdorff阈值是手工设的。可以改成根据模板库规模自适应调整候选数量比如固定返回 Top-8 进入精排这样不管库多大精排开销都是常数。第三是在线学习。识别错了就把正确样本补进去这个功能我已经做了雏形但补进去之后没有做模板约简库会无限膨胀。后面可以加一个基于类内聚散度的自动裁剪把那些与其他模板高度冗余的条目定期清掉保证库的精简。第四是与实时采集打通。现在还是点一下识别一下的交互如果把手部关键点检测接进来做成连续手势分割加重识别就能支持更自然的交互比如隔空翻页、隔空调音量。这条路的难点在于分割点的判定什么算一个手势的开始和结束得靠速度谷值和停顿检测来做。我个人在这个项目里最大的体会是算法选型的重要性远远低于预处理和模板质量的重要性。我见过太多人一上来就纠结用 DTW 还是用别的结果归一化没做、模板只录了一条准确率卡在六七十上不去就开始怀疑算法本身有问题。实际上把质心平移和弧长重采样老老实实做对同一个 DTW 实现准确率能往上跳十几个点。另外那个叠加对比的可视化我强烈建议每个做轨迹匹配的人都加上它省下的调试时间抵得上你多写半天的算法代码。