ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DTW、Fréchet与Hausdorff手势轨迹匹配及Matlab GUI实现

DTW、Fréchet与Hausdorff手势轨迹匹配及Matlab GUI实现

1. 三种"像不像"的答案:手势轨迹匹配在比什么

去年帮一个做教学演示的朋友搭手势控制的小程序,需求很朴素:用户在画布上用鼠标画一笔,系统判断这是"圆圈""横线""勾""闪电"中的哪一种,然后触发对应动作。他一开始想上深度学习,我看了一眼他手头的数据量——八类手势,每类他自己画了五遍,总共四十条轨迹——这个规模上神经网络纯属自找麻烦。传统模板匹配在这个场景下反而更合适:样本极少、要求可解释、还要在界面上把"为什么认为是这个手势"画出来给人看,这三条正好是距离度量类算法的主场。

这篇文章要拆的,就是围绕DTW(动态时间规整)、Fréchet 距离(弗雷歇距离)和Hausdorff 距离(豪斯多夫距离)这三种轨迹相似度度量,配一套 Matlab 的图形界面,把"画一笔、比一比、给结论"这条链路走通的完整过程。关键词我摊开说:手势识别、轨迹相似度、DTW、离散Fréchet、Hausdorff、Matlab GUI、最近邻分类、轨迹重采样归一化。适合谁看?手上有几十条到几百条轨迹样本、想快速做出一个能演示能解释的原型的人;以及正在做课程设计、需要把算法原理和界面都讲清楚的人。有 Matlab 基础就能跟着跑,没做过轨迹匹配也能看懂思路,因为我会把每一种距离"到底在比什么"用生活化的例子说透。

1.1 同一个手势画三遍,三条曲线差在哪

先把问题本身看清楚了,才知道为什么需要三种不同的尺子。假设你让同一个人连着画三个"圆圈",把鼠标采样点连起来,会看到三种典型差异。

第一种是速度差异。第一遍画得快,采样点稀疏,六十多个点;第三遍手慢,还中间顿了一下,一百二十个点。两条曲线的形状几乎一样,但点数差一倍,逐点对齐算欧氏距离会产生巨大误差——这就是 DTW 要解决的问题,它允许时间轴上的非线性拉伸。

第二种是起笔和收笔的位置偏差。同一个人画圆,起点有时在正上方,有时在左上角,终点也因此偏移。两条曲线整体形状高度一致,但端点对不上。Hausdorff 距离完全不在乎这个,因为它只问"这条曲线上的每个点,到另一条曲线上最近点的距离最大值是多少",点的先后顺序它根本不看。

第三种是整体走向的细微差别,比如一个画得偏扁、一个画得偏圆,中间还带点抖动。这种"走着走着偏了"的差异,正是 Fréchet 距离擅长捕捉的——它考虑的是沿着曲线前进时,最坏情况下的偏离程度。

我在调试初期最大的教训是:不要指望一种尺子解决所有问题。当时我拿 DTW 去区分"横线"和"横线加回勾",准确率怎么调都上不去,后来换成 Fréchet 一眼就分开了。原因后面细说。

1.2 DTW、离散 Fréchet、Hausdorff 的度量哲学

这三者看着都是算两条点序列的距离,底层假设完全不同,我用一张表把关键差异摆出来,这张表我在项目里反复回看过很多次:

维度DTW离散 FréchetHausdorff
是否关心点的先后顺序关心,且允许重复对应关心,且必须单调前进完全不关心,当作点集
对绘制速度差异的容忍度极强,可任意拉伸中等,单调但不跳跃无,速度快慢不影响结果
对离群点(飞点)中等,会被摊到路径上较敏感,最大值直接放大极敏感,一个飞点就毁掉
单次计算复杂度O(NM),可加窗口优化O(NM),不可加窗口O(NM),可高度向量化
是否满足三角不等式不满足(加窗口后更不满足)连续版满足,离散耦合版近似满足,是真度量
典型翻车场景停顿、笔画粗细导致的对齐病态采样过稀时距离虚高起笔抖动的孤立点

表里有一行值得单独说:Hausdorff 把轨迹当点集,完全丢弃顺序信息。你把一个"横着的 C"和一个"反着写的 Ɔ"放在一起,Hausdorff 距离会告诉你它们一模一样——因为两组点的空间分布几乎重合。这在很多场景下是优点(比如只关心图形覆盖范围),但在手势识别里是硬伤,因为"从左往右画横"和"从右往左画横"在交互语义上可能是两个完全不同的指令。

DTW 不满足三角不等式这一点也经常被忽略。这意味着你不能拿它做度量空间上的加速索引(比如 BK 树),也别指望"距离小就一定相似、距离大就一定不相似"的严格保证。做几十条模板的暴力最近邻完全没问题,想上十万条就得掂量了。

1.3 什么样的项目适合用这套传统方案

我把这个话题单拎出来讲,是因为见过太多人在四十条样本的项目上硬套神经网络,最后花两周调参得到一个还不如模板匹配的结果。

小样本、强可解释、需要可视化中间过程,这三条只要占了两条,传统距离度量就是更优解。手势识别只是其中一个例子,同样的思路可以迁移到工业上的装配轨迹比对、体育动作的规范度打分、医疗康复里的关节运动轨迹评估——这些场景共同点是:样本少、每个样本维度低(二维或三维点序列)、结果要能指出"哪里不像"。

反过来,什么情况别用?手势类别超过五十种、每类样本上千条、需要识别不同人的风格泛化,这时候模板匹配的准确率上限会很明显,该上学习模型就上。我个人的经验阈值大致是:每类样本少于三十条、类别数少于二十,模板匹配的性价比远高于训练模型。

还有一点:这套算法的中间结果(DTW 的对齐路径、Fréchet 的自由空间图、Hausdorff 的最大偏离点)都能直接画在界面上。给非技术人员演示的时候,"你看,系统认为你画的第三段和模板的第二段对上了"这种解释,比一个 0.87 的置信度分数有说服力得多。

2. 从鼠标轨迹到归一化矩阵:预处理决定了后面所有的上限

这一章我放在算法之前,是因为踩过的最深的坑几乎全在预处理。有句话我特别认同:轨迹匹配的上限由预处理决定,距离度量只是把这个上限兑现出来。我最初偷懒,直接把鼠标回调拿到的原始点丢给 DTW,结果同一手势画两遍的类内距离比不同手势的类间距离还大,调了三天参数毫无进展,最后发现是重采样没做。

2.1 采集环节:事件回调拿到的点在抖什么

Matlab 里用鼠标在坐标轴上画线,最常见的写法是在 figure 上挂WindowButtonDownFcn、WindowButtonMotionFcn、WindowButtonUpFcn三个回调,用get(ax,'CurrentPoint')取当前坐标:

function onMouseDown(src, ~) cp = get(ax, 'CurrentPoint'); % 返回 2x3 矩阵 p = cp(1, 1:2); % 取第一行的 x y if ~inAxes(ax, p), return; end app.pts = p; app.drawing = true; set(app.hLine, 'XData', p(1), 'YData', p(2)); end function onMouseMove(src, ~) if ~app.drawing, return; end cp = get(ax, 'CurrentPoint'); app.pts(end+1, :) = cp(1, 1:2); set(app.hLine, 'XData', app.pts(:,1), 'YData', app.pts(:,2)); drawnow limitrate; end function onMouseUp(src, ~) app.drawing = false; runRecognize(app); end function ok = inAxes(ax, p) xl = get(ax, 'XLim'); yl = get(ax, 'YLim'); ok = p(1) >= xl(1) && p(1) <= xl(2) && ... p(2) >= yl(1) && p(2) <= yl(2); end

这里有几个实际的坑。第一个是CurrentPoint返回的是2x3矩阵而不是 2 元素向量,直接索引cp(1)cp(2)会取到错误的值,必须cp(1,1:2)。第二个是WindowButtonMotionFcn的触发频率取决于系统事件队列,不同机器上每秒采到的点数可能差好几倍,这意味着原始点序列的"时间采样"是不可靠的。第三个是鼠标移动事件在快速划动时会被合并,相邻两点之间可能隔着几十个像素,曲线看起来是直的一段。

还有一个隐蔽问题:用户在同一个位置轻微抖动时,会连续产生一批几乎重合的点。这些点如果不去掉,后面的等距重采样用的累计弧长会出现零长度段,interp1会报警告甚至返回 NaN。

2.2 去重、等距重采样与累计弧长插值

我的做法是两步走:先按最小距离去重,再按累计弧长做等距重采样。为什么要按弧长而不是按下标?因为下标反映的是"采到了第几个点",受采样频率影响;弧长反映的是"沿着曲线走了多远",是曲线的内在属性。等距重采样之后,两条用不同速度画出来的曲线,在几何上就有了可比性。

function P = resampleGesture(pts, N) % 输入 pts: M x 2 原始轨迹点 % 输出 P : N x 2 等弧长重采样后的轨迹 % 1) 去重:相邻点距离过小则丢弃 if size(pts, 1) < 2 P = repmat(pts(1,:), N, 1); return; end d = sqrt(sum(diff(pts).^2, 2)); keep = [true; d > 1e-9]; pts = pts(keep, :); if size(pts, 1) < 2 P = repmat(pts(1,:), N, 1); return; end % 2) 累计弧长 seg = sqrt(sum(diff(pts).^2, 2)); cum = [0; cumsum(seg)]; total = cum(end); if total < 1e-9 P = repmat(pts(1,:), N, 1); return; end % 3) 等弧长插值 qi = linspace(0, total, N)'; P = [interp1(cum, pts(:,1), qi, 'linear'), ... interp1(cum, pts(:,2), qi, 'linear')]; end

N取多少合适?我用的是64。理由有三:一是在我的小样本上,32 点对"回勾""闪电"这类带细节的手势明显不够,拐角会被抹平;128 点对分类准确率的提升不到一个百分点,但 DTW 的 O(N²) 计算量翻四倍,实时绘制的界面会开始卡;64 是拐点。如果你只做简单几何图形,48 就够;如果做手写数字类的复杂轨迹,96 更稳妥。

去重阈值我用的是相对值而不是绝对像素,代码里写1e-9是因为坐标已经归一化过了。如果还没归一化,用画布宽度的千分之一左右,比如 400 像素宽的画布用 0.4。

2.3 平移、缩放、旋转:三个开关各自的代价

归一化这三件事,每一件都是"用了方便、用错了丢信息",得一个一个想清楚。

平移几乎无脑要做:把质心挪到原点。这样用户在画布任何位置画同一个手势,结果都一致。代码就一行P = P - mean(P,1)。

缩放要谨慎选择范数。常见两种:按包围盒的最大边长缩放到统一大小(L∞ 范数),或者按离质心最远点的距离缩放(L2 范数)。

function P = scaleGesture(P, mode) switch mode case 'bbox' % 按包围盒 s = max(max(P) - min(P)); case 'radius' % 按半径 s = max(sqrt(sum(P.^2, 2))); otherwise s = 1; end if s > 1e-9, P = P / s; end end

两种的差别在细长形状上很明显:一条横线用包围盒归一化会被拉到满宽,用半径归一化则会被压得很短。我最后选了bbox,因为对"圆""方""三角"这类闭合图形,包围盒归一化后的尺寸更一致。

但这里有个所有人都该知道的反噬:缩放归一化之后,"画一个大圆"和"画一个小圆"就变成了同一类。如果你的手势语义里大小是有意义的(比如"画大圈=音量+10,画小圈=音量+1"),那缩放就得关掉,或者按面积分档。

旋转归一化是最有争议的一个。经典做法是用首笔点到质心的方向作为"指示角",把整个轨迹旋转到这个角朝上。这个技巧对"箭头""勾"这类有明确指向的手势有效,但对圆形、方形、Z 形就没意义——一个圆你转多少度都是同一个圆,反而会因为起笔点位置不同引入额外方差。我的处理是把它做成界面上的一个复选框,默认关闭,只在特定手势集合上打开:

function P = rotateToIndicative(P) c = mean(P, 1); P = P - c; theta = atan2(P(1,2), P(1,1)); % 首点相对质心的方向角 R = [cos(-theta) -sin(-theta); sin(-theta) cos(-theta)]; P = P * R'; % 注意:右乘转置等于左乘 R end

这段代码里P * R'这个写法值得盯一下,方向不能反。我见过有人在这一点上debug半天,画出来的轨迹越转越歪。

2.4 预处理顺序错了就白干

顺序我固定成:去重 → 平移 → 等距重采样 → 缩放 → 旋转。每一处的相对位置都有理由,不能随便换。

去重必须在重采样之前,因为累计弧长依赖严格递增的cum向量,有重复点会导致interp1报错。平移放在重采样之前还是之后,理论上可以互换(平移不改变弧长),但放在前面代码更好读。缩放必须在重采样之后,因为重采样是在原始坐标系下按弧长分布取点,先缩放会改变数值精度。旋转放最后,因为它依赖质心,而质心在平移后就固定在原点了。

有个我实际踩到的坑:平移和缩放如果在重采样之前做,容易遇到坐标数值过小导致浮点精度问题。原始画布坐标通常是几百像素量级,归一化后变成 0.001 量级,此时再去算两点间的平方距离,有效位数会掉。解决办法是先按画布尺寸整体缩放到 [-1,1] 附近,再做后续处理。这一条在论文里基本不会写,但真实调试时会遇到。

3. 三种距离的 Matlab 实现与参数取舍

这一段是全文的技术核心。我把三种度量都写成了独立函数,输入输出形状一致(输入两个 N x 2 矩阵,输出一个标量距离),这样在分类器里可以直接换着调用,方便对比。下面每一段都会强调一个容易被忽略的实现细节。

3.1 DTW:递推式、窗口带与路径归一化

DTW 的递推式不复杂:构造一个 N x M 的代价矩阵D,D(i,j)表示 A 的前 i 个点和 B 的前 j 个点匹配的最小累计代价。转移只有三个方向——竖着走(A 前进一格,B 停住)、横着走(A 停住,B 前进)、斜着走(两者同时前进)。

function [dist, D] = dtwDistance(A, B, bandRatio) % A, B : N x 2 归一化后的轨迹 % bandRatio : Sakoe-Chiba 带宽比例,0 表示不限制 % dist : 归一化后的 DTW 距离 N = size(A,1); M = size(B,1); d = pairDist(A, B); % N x M 局部代价矩阵 D = inf(N, M); if bandRatio <= 0 band = max(N, M); else band = max(1, round(bandRatio * max(N, M))); end for i = 1:N jlo = max(1, i - band); jhi = min(M, i + band); for j = jlo:jhi if i == 1 && j == 1 D(i,j) = d(i,j); continue; end c = inf; if i > 1, c = min(c, D(i-1, j )); end if j > 1, c = min(c, D(i, j-1)); end if i > 1 && j > 1, c = min(c, D(i-1, j-1)); end D(i,j) = d(i,j) + c; end end dist = D(N, M) / N; % 两序列长度相同,按步数归一化 end function D = pairDist(A, B) % 不依赖 Statistics 工具箱的两两欧氏距离 D2 = sum(A.^2,2) + sum(B.^2,2)' - 2 * (A * B'); D = sqrt(max(D2, 0)); end

第一个细节:pairDist这个手写函数。很多人第一反应是pdist2,但那需要 Statistics and Machine Learning Toolbox。给人演示的代码,依赖越少越好,用矩阵乘法展开(a-b)² = a² + b² - 2ab完全等价,而且更快。max(D2, 0)那一步不能省——浮点误差会让理论上为 0 的值变成 -1e-17,开方得到复数。

第二个细节:末尾除以 N。这一步太多人漏掉。DTW 的累计代价是沿着路径一路加过来的,路径越长累加值越大。两条各自 64 点的轨迹,路径长度大致就是 64,不归一化的话,形状类似但细节更多的模板天然吃亏。有人会用"路径长度"做精确归一化(回溯统计实际步数),但两条等长序列的路径长度差异很小,除以 N 更省事且效果相当。如果你处理的序列长度不等(比如没做等距重采样),就必须回溯算真实路径长度了。

第三个细节:Sakoe-Chiba 带宽。不加限制时,DTW 会把形状上完全不相干的部分强行对齐,产生所谓的"病态对齐"——比如把"横线"的整段都压到一个点上。加带宽后,只允许对角线附近的匹配,计算量从 N² 降到 2·N·band。我实测bandRatio = 0.15时,64x64 的匹配耗时从约 1.8ms 降到 0.6ms,准确率只掉了 2 个百分点。如果你的界面要实时给反馈,这个折中是值得的。

3.2 离散 Fréchet:为什么多数 Demo 算的其实不是几何 Fréchet

Fréchet 距离有个流传最广的解释:"一个人牵着狗,人沿曲线 A 走,狗沿曲线 B 走,两者都用任意速度但都不能后退,问最短需要多长的狗绳"。这个比喻很好,但离散版实现和连续版定义完全是两回事,这里必须讲清楚,否则你会在参数调优时一头雾水。

连续的 Fréchet 距离定义中,人和狗可以停在曲线上的任意一点,所以计算时需要把点投影到线段上,用点到线段的距离,并用自由空间图(free space diagram)判定可达性。而绝大多数代码实现(包括很多教学资料里给的)用的是离散耦合 Fréchet:只在采样点上做单调匹配,用点到点的距离:

function [dist, C] = frechetDistance(A, B) % 离散耦合 Fréchet 距离(点对点,非点到线段) N = size(A,1); M = size(B,1); d = pairDist(A, B); C = inf(N, M); C(1,1) = d(1,1); for i = 2:N, C(i,1) = max(C(i-1,1), d(i,1)); end for j = 2:M, C(1,j) = max(C(1,j-1), d(1,j)); end for i = 2:N for j = 2:M m = min([C(i-1,j), C(i-1,j-1), C(i,j-1)]); C(i,j) = max(m, d(i,j)); end end dist = C(N,M); end

三个转移方向取min(选择最"温和"的前进方式),再和当前局部代价取max(因为要保证最坏情况)。这个 max-min 结构和 DTW 的纯求和完全不同,也是为什么 Fréchet 更容易被单个坏点拉高。

后果是什么?离散耦合 Fréchet 的值依赖采样密度。同一条曲线,采 32 个点和采 128 个点,算出来的距离不一样——采样越稀,落到最近点上的概率越低,距离越大。这在跨模板比较时不致命(因为所有模板都用同样的 N 重采样过),但如果你拿它跟论文里的数值对比,就会发现对不上。

想算真正的几何 Fréchet,得把局部代价矩阵换成点到线段的距离,判断可达性还得用自由空间图,代码量翻三倍。我的建议是:做手势识别,离散耦合版够用,因为所有输入都经过了统一的等距重采样,这个偏差是系统性的、一致的。追求严格数学定义的话,另写一个函数。

还有一个实操细节:Fréchet 不支持 Sakoe-Chiba 那样的窗口裁剪。它的递推依赖前一行整行的值,强行截断会得到错误结果。好在 64x64 规模下全矩阵计算也就 1.5ms 左右,不用优化。

3.3 Hausdorff:一行代码算完,然后被一个飞点毁掉

Hausdorff 距离的实现最短,但解释起来最需要小心。双向 Hausdorff 的定义是:A 上每个点到 B 的最近距离中的最大值,与 B 上每个点到 A 的最近距离中的最大值,取较大者。

function [dist, dhAB, dhBA] = hausdorffDistance(A, B) d = pairDist(A, B); dhAB = max(min(d, [], 2)); % A 每点到 B 的最小距离,取最大 dhBA = max(min(d, [], 1)); % B 每点到 A 的最小距离,取最大 dist = max(dhAB, dhBA); end

注意min(d,[],2)是沿行取最小(对 A 中每个点求到 B 所有点的最近距离),min(d,[],1)是沿列取最小。这两个方向搞反了,代码还能跑出数值,但结果就不再有意义——这是我见过最常见的实现错误。

Hausdorff 的致命弱点在max上。因为取的是最大值,只要有一个点偏离极远,整个距离就被它主导。手势绘制的起笔瞬间最容易产生这种点:鼠标按下的位置和第一个移动事件的位置之间可能有跳动,或者用户画完提笔时手抖了一下。这个点可能离模板的最近点有 0.3 的距离(在归一化坐标系里算很远了),直接把整条轨迹的距离拉到 0.3,而正常匹配应该在 0.05 以下。

两个修补办法我都试过。第一个是用分位数替代最大值,比如取 95 分位:

function dist = hausdorffRobust(A, B, q) d = pairDist(A, B); dhAB = prctile(min(d, [], 2), q); dhBA = prctile(min(d, [], 1), q); dist = max(dhAB, dhBA); end

第二个是在预处理阶段做离群点剔除:计算每个点到轨迹中位点的距离,超过 3 倍中位绝对偏差的点直接丢掉。我最后两个都用上了,q = 0.95,配合预处理剔除,Hausdorff 的准确率才从 60% 出头提到 70% 左右。

但即使这样,Hausdorff 在我的测试里仍然是三者中准确率最低的。这不是实现问题,是原理决定的:它丢掉了顺序信息,也就丢掉了手势最有辨识度的部分。我保留它的理由只有一个——它是最快的,0.25ms 量级,适合做粗筛:先用 Hausdorff 快速排除明显不相关的模板,再用 DTW 精算前三个候选。

3.4 我调出来的参数组合

参数不是拍脑袋定的,我是按"固定其他、只动一个"的方式扫出来的。这张表记录的是最终值,以及为什么这么定:

参数取值定位理由
重采样点数 N6432 拐角丢失,128 收益低且耗时翻倍
去重阈值画布宽度的 1/1000太低留下抖动点,太高吃掉短笔画
缩放方式bounding box闭合图形尺寸一致性更好
旋转归一化关闭(默认)对圆/方无意义,反而增大类内方差
DTW 带宽比例0(全窗口)/ 0.15(实时模式)离线准确率优先,实时响应优先
DTW 归一化除以 N长度相同,路径长度近似 N
Fréchet 局部代价点对点(离散耦合)与重采样统一的系统性偏差可接受
Hausdorff 分位数0.95抑制起笔飞点,降噪效果明显
分类器 k 值3k=1 太敏感,k=5 明显欠拟合

表格里"分类器 k 值"那一行属于下一章的内容,先放在这里备查。要提醒的是,这些值是在我自己采集的小样本上得到的,换数据源必须重扫,特别是 N 和带宽这两个对数据密度敏感的。

4. GUI 骨架:把算法装进一个能演示的界面

算法跑通是一回事,做成能给别人演示的界面是另一回事。我在这一章花了大约和算法本身一样多的时间,主要卡在两个地方:鼠标回调的坐标转换,以及实时重绘造成的卡顿。这两件事在任何一篇讲 DTW 的文章里都不会提,但它是实际项目的必经之路。

4.1 界面布局与控制件分工

我选的是经典 figure + uicontrol,没用 App Designer。理由很直接:经典方案的代码全是纯文本.m文件,可以直接贴进文章、复制到任何版本的 Matlab 里跑;App Designer 的.mlapp是二进制包,分享和 diff 都麻烦。而且这个界面的交互复杂度(十来个控件)远没到需要可视化设计器的程度。

布局上分三块。左侧是主画布axDraw,负责绘制输入轨迹,占 55% 宽度。右上是一组控制件:一个popupmenu选择距离度量(DTW / Fréchet / Hausdorff),一个checkbox控制旋转归一化开关,一个pushbutton触发识别,一个listbox显示模板库和计算出的距离排名。右下是一个小画布axCompare,用来把输入轨迹和最佳匹配模板叠在一起画出来,让用户直观看到"哪里对上了、哪里偏了"。

function buildUI fig = figure('Name', '手势轨迹模板匹配演示', 'NumberTitle', 'off', ... 'MenuBar', 'none', 'ToolBar', 'none', ... 'Color', [0.95 0.95 0.95], ... 'Position', [280 180 960 580]); axDraw = axes('Parent', fig, 'Units', 'normalized', ... 'Position', [0.04 0.10 0.55 0.84], ... 'Box', 'on', 'XLim', [0 1], 'YLim', [0 1], ... 'XTick', [], 'YTick', []); axCmp = axes('Parent', fig, 'Units', 'normalized', ... 'Position', [0.65 0.10 0.32 0.34], ... 'Box', 'on', 'XLim', [-1.2 1.2], 'YLim', [-1.2 1.2], ... 'XTick', [], 'YTick', [], 'DataAspectRatio', [1 1 1]); uicontrol('Parent', fig, 'Style', 'popupmenu', ... 'String', {'DTW', 'Frechet', 'Hausdorff'}, ... 'Units', 'normalized', 'Position', [0.65 0.86 0.32 0.06], ... 'Callback', @onMetricChanged); uicontrol('Parent', fig, 'Style', 'checkbox', ... 'String', '启用旋转归一化', 'Value', 0, ... 'Units', 'normalized', 'Position', [0.65 0.79 0.32 0.05]); uicontrol('Parent', fig, 'Style', 'pushbutton', 'String', '识别', ... 'Units', 'normalized', 'Position', [0.65 0.71 0.15 0.06], ... 'Callback', @onRecognize); uicontrol('Parent', fig, 'Style', 'listbox', 'String', {}, ... 'Units', 'normalized', 'Position', [0.65 0.49 0.32 0.19]); end

axCmp上那个DataAspectRatio [1 1 1]值得单独说。轨迹是二维等比例数据,如果不锁定纵横比,画出来的圆会变成椭圆,用户一看就懵。用axis equal也行,但会在某些布局重排时被覆盖。

4.2 画布回调的两种写法与坐标陷阱

回调挂载有两种思路:挂在axes上的ButtonDownFcn,或者挂在figure上的WindowButtonDownFcn。区别在于,axes的ButtonDownFcn只在点击到 axes 的空白背景时触发,如果 axes 里已经有 Line 对象,点在线上就触发不了。而WindowButtonDownFcn挂在 figure 上,任何位置的按下都能收到。

我做拖拽绘制用的是后者,然后在回调里自己判断坐标落点是否在画布范围内:

function onRecognize(app, ~) pts = app.rawPts; if size(pts, 1) < 3 set(app.hStatus, 'String', '轨迹太短,请重新绘制'); return; end P = resampleGesture(pts, app.N); P = P - mean(P, 1); P = scaleGesture(P, 'bbox'); if app.useRotate P = rotateToIndicative(P); end names = fieldnames(app.templates); D = zeros(numel(names), 1); for k = 1:numel(names) T = app.templates.(names{k}); % 已经是 N x 2 归一化轨迹 switch app.metric case 1, D(k) = dtwDistance(P, T, 0); case 2, D(k) = frechetDistance(P, T); case 3, D(k) = hausdorffDistance(P, T); end end [ds, idx] = sort(D, 'ascend'); set(app.hList, 'String', ... arrayfun(@(i) sprintf('%s %.4f', names{idx(i)}, ds(i)), ... 1:numel(idx), 'UniformOutput', false)); set(app.hList, 'Value', 1); plotCompare(app.axCmp, P, app.templates.(names{idx(1)}), names{idx(1)}); end

坐标陷阱在CurrentPoint上:如果 axes 的XLim、YLim被用户缩放(比如误触了工具栏的放大按钮),那么CurrentPoint返回的是数据坐标,落点会跑到 [0,1] 之外,后面所有归一化都基于错误的数据。我的做法是直接把工具栏关掉('ToolBar','none'),并显式锁定XLim、YLim。这样鼠标位置和数据坐标之间是简单的比例关系,中间不需要任何换算。

4.3 重绘卡顿的定位过程与修复

最初版本的绘制手感很差,画两笔就开始拖影。排查过程大致是这样:先确认是绘制环节还是计算环节——我在onMouseMove的开头结尾加了tic/toc,发现单次回调耗时不到 1ms,说明计算没问题。然后确认帧率,用简单的计数器统计每秒触发的 motion 事件数,结果只有十几帧,而系统事件队列本身能提供 60+。问题定位到重绘策略上。

原来的写法是每次移动都调用plot(axDraw, x, y)。plot每次都会清空 axes 并重建所有图形对象,即使只有一个 Line 这也是相对重的操作。改法有两条:

第一条是不复建对象,只改数据。一开始就创建一个Line句柄,之后每次只更新XData/YData:

% 初始化时 hLine = animatedline(axDraw, 'Color', [0.1 0.4 0.8], 'LineWidth', 2); % 移动时 addpoints(hLine, p(1), p(2)); drawnow limitrate;

animatedline就是为了这类增量绘制设计的,配合drawnow limitrate(限制到大约每秒 20 帧)效果立竿见影。注意必须给animatedline设定MaximumNumPoints,否则长轨迹会积累上万个点,性能反而崩掉。我设的是 2048。

第二条是把识别计算从绘制回调里剥离出来。识别只在MouseUp时触发一次,不要放在MouseMove里跑。早期版本我图省事在移动时就实时算距离显示在界面上,结果每帧 6 个模板 × 1.8ms ≈ 11ms,直接锁死主线程。改成松手才计算之后,拖拽立刻顺滑。

修复后我重新测了一轮:64 点轨迹、8 个模板、满窗口 DTW,一次完整识别耗时约 15ms,用户完全感知不到延迟。

5. 最近邻分类器与模板库的工程化处理

算法和界面都有了,剩下的就是把它们组织成一个能给出答案的分类器。这一层看起来最简单——算距离、取最小——但里面的决策细节直接决定最终准确率,也是我在项目后期反复调整的地方。

5.1 模板库怎么选:单模板、多模板与类别不平衡

最朴素的方案是每类手势存一个模板(通常是用第一遍画的),待测轨迹和每个模板比一次,取最近的。这套方案的可解释性最好——界面上能明确显示"最像圆圈模板",用户容易接受。

但它的问题在类内方差。同一个"圆圈",有人起笔在顶、有人起笔在左,单模板只能覆盖其中一种风格。我的改进方案是每类存三个模板,用 k-近邻的思路投票:分别算出待测轨迹到三个模板的距离,取最小值作为类别距离,或者对前 k 个最近邻(跨类别)做加权投票。

function [label, score] = classify(P, templates, metricFn, k) % templates : struct,每个字段是 N x M 的轨迹矩阵(M 个模板堆叠) names = fieldnames(templates); allD = []; allL = {}; for i = 1:numel(names) T = templates.(names{i}); for m = 1:size(T, 1) / 64 seg = T((m-1)*64+1 : m*64, :); allD(end+1, 1) = metricFn(P, seg); allL{end+1, 1} = names{i}; end end [ds, idx] = sort(allD, 'ascend'); k = min(k, numel(ds)); win = allL(idx(1:k)); label = mode(win); score = ds(1); end

模板的采集要专门做一轮:每种手势让同一个人画 5 遍,从中挑 3 个两两距离最分散的作为模板,而不是随便取前三个。选择方法是先算 5 个候选之间的距离矩阵,贪心地选第一个、离它最远的第二个、离前两个距离和最大的第三个。这个技巧把留一法准确率提高了大约 4 个百分点,成本只是多写二十行代码。

类不平衡的问题也不能忽略。"圆圈"可能收集了 20 条,"闪电"只有 5 条,那么圆形因为模板多,天然更容易被选中。简单处理是限定每个类别参与投票的模板数相同,或者对类别距离做归一化。

5.2 距离归一化与"拒识"逻辑

三种度量的原始数值范围差得很远。在归一化坐标系下,DTW 除以 N 后的典型值在 0.02~0.5 之间,离散 Fréchet 在 0.05~0.8 之间,Hausdorff 在 0.01~0.4 之间。直接拿绝对值做阈值没有可比性,我在界面上显示的置信度是相对值:最小距离与次小距离的比值。

if numel(ds) >= 2 ratio = ds(2) / max(ds(1), 1e-9); % 越大越可信 conf = 1 - exp(-(ratio - 1)); % 映射到 (0,1) else conf = 0.5; end

这个比值的解释非常直观:如果第一名和第二名差距很大,说明这个判断很确定;如果两个类别距离接近,说明系统自己也在犹豫。

拒识是另一个必须有的机制。用户随手乱画一条线,如果系统硬要给出一个答案,体验会很糟。我的做法是设两道闸:绝对距离超过阈值(用模板库自匹配的 95 分位做参考值),或者置信度低于 0.3,都判为"未识别"。阈值的确定不能凭感觉,要拿交叉验证的距离分布来定——把所有留一法的正确匹配距离收集起来,取 95 分位作为上限。

5.3 自建小样本上的实测结果与失败案例分析

我在自己的小数据集上跑了一轮留一法:8 类手势、每类 6 条,共 48 条,每次留一条做测试,其余做模板。结果如下,必须说明样本量很小,这些数字只能看相对趋势,不能当基准:

度量方式留一法准确率单次匹配耗时(64 点)主要错误类型
DTW(全窗口)91.7%约 1.8 ms"回勾"被判成"横线"
DTW(带宽 0.15)89.6%约 0.6 ms同上,误差更多
离散 Fréchet85.4%约 1.5 ms采样稀疏的手势距离虚高
Hausdorff(双向)68.8%约 0.25 ms起笔抖动点导致整条误判
Hausdorff(95 分位)70.8%约 0.30 ms同上,略有改善

DTW 输掉的那一例很有意思:一个"横线加右侧回勾"被认成了纯"横线"。查了对齐矩阵才发现,纯横线模板在末尾有一段点密集区(因为那次画的时候手慢,采到的点多),DTW 就把回勾的那段点都对到了这段密集区上,累计代价并没有明显升高。后来改用 Fréchet 做这个二分类的判别,因为 Fréchet 取最大值,回勾那一段的最大偏离一下子暴露出来了,距离从 0.04 跳到 0.21,干净利落地分开。

这个案例让我形成了一个实用经验:DTW 擅长判断"整体形状是否一致",Fréchet 擅长判断"是否存在局部严重偏离"。如果两类手势的区别在于某个局部细节(有没有钩、拐了几个弯),Fréchet 更靠谱;如果区别在整体轮廓(圆 vs 三角),DTW 更稳。

Hausdorff 的错误集中在起笔位置。有一条"闪电"手势被认成了"勾",因为两者都是锯齿状的折线,点集分布高度相似,Hausdorff 根本区分不出来——这印证了前面说的"丢失顺序信息"是原理性的缺陷,不是实现能补的。

我最终的方案是两级串联:先用 Hausdorff 快速筛出距离最小的 4 个候选(这一步几乎不耗时),再在这 4 个候选上用 DTW 和 Fréchet 的距离加权(权重 0.6 / 0.4)做最终判定。这个组合在留一法上跑到 93.8%,比任何一个单一度量都好。加权系数的来源是简单网格搜索,0.0 到 1.0 步长 0.1 扫了一遍,最优点很平缓,说明结果对这个参数不敏感,可以放心用。

6. 换个数据源之后失效的地方:几个已验证的边界

代码写完能跑,和"这个方案能用",中间还隔着一堆边界条件。我在往其他场景迁移这套东西的时候,撞上了几个明确的失效区,写出来省得别人再走一遍。

6.1 从鼠标点到骨架点:采样噪声的量级完全变了

鼠标轨迹的噪声主要来自事件合并和手抖,量级在几个像素。换成摄像头采集的骨架关键点,噪声会突然放大到十几个像素,而且带帧间抖动——同一个静止的姿势,关键点坐标在相邻帧之间会跳。这时候原来的 95 分位 Hausdorff 就不够用了,因为噪声点是遍布整条轨迹的,不是集中在起笔那一两个孤立点。

我在这个场景下的处理是加一层时域平滑:对每个坐标维度做 5 点滑动平均,或者用简单的一阶低通滤波:

function P = smoothTrajectory(P, alpha) % alpha 越小越平滑,0.3 左右是个不错的起点 for i = 2:size(P,1) P(i,:) = alpha * P(i,:) + (1 - alpha) * P(i-1,:); end end

平滑必须放在重采样之前,否则重采样已经把点拉到等间距,再平滑会改变采样点的弧长分布,引入新的偏差。这个顺序反了,曲线会被"吃"掉拐角,看起来钝钝的。

另外,骨架点的采样率是固定的(比如 30fps),这意味着原始序列的"时间等距"是可信的,这时候等距重采样反而会破坏时间信息——如果动作的节奏本身有语义(快挥和慢挥是不同指令),就该保留时间采样,改用按时间加权的 DTW 代价。

6.2 多笔手势与分段匹配

现实里的手势经常不是一笔画完的,比如画一个"+"需要两笔,"米"需要四笔。直接把这些点连成一条序列交给 DTW,会在笔画之间的跳跃处引入巨大的虚假位移——笔尖离开画布的瞬间,上一个笔画终点和下一个笔画起点之间的连线,被当成了一段真实的轨迹。

解决办法是按笔画分段,段内匹配、段间求和。每一段独立做重采样和归一化(缩放要按整组笔画统一的包围盒,不能每段各自归一化,否则相对大小信息丢失),然后总距离是各段距离的加权和:

function D = multiStrokeDistance(A, B, metricFn) % A, B : cell 数组,每个元素是一笔的 N x 2 轨迹 if numel(A) ~= numel(B) D = inf; return; % 笔画数不同,直接判不匹配 end D = 0; for k = 1:numel(A) D = D + metricFn(A{k}, B{k}); end D = D / numel(A); end

笔画数不同直接判不匹配,这个策略简单但有效,因为"几笔"本身就是很强的语义特征。代价是容忍不了用户多画或少画一笔,所以我在界面上给了一个"允许分笔对其"的备选模式,用动态规划找最优的分段对应关系——不过这条路径我只在实验里跑通,最终没放进主流程,因为它引入了太多需要调的参数,收益并不明显。

还有一点值得记下来:同一笔画的反向绘制。DTW 和 Fréchet 都要求单调前进,用户如果习惯了从右往左画横线,遇到从左往右画的模板,距离会非常大。我的处理是给每个模板同时存一份反转版本,匹配时取两者的较小值。成本是模板库翻倍,匹配耗时翻倍,但对用户友好度的提升很明显——尤其在没有明确指示"该往哪个方向画"的界面上,这个改动几乎必须做。

7. 我个人在调这套东西时沉淀的几条判断

写到这里,我想收在几个不属于任何具体章节的经验上,都是调试过程中反复验证过的。

先看数据,再看算法。我见过太多人一上来就调 DTW 的窗口、换距离函数,实际上问题出在输入轨迹本身——点数不一致、坐标没归一化、有 NaN。加一段fprintf把每步预处理后的点数、取值范围打出来,五分钟就能定位一半的"算法不准"问题。

距离的绝对值没有意义,相对排名才有意义。阈值和置信度不要拍脑袋定,用留一法的距离分布推。这一点在换数据源时尤其重要,我那套阈值在鼠标数据上工作良好,换到骨架点数据上全线失效,就是因为分布整体平移了。

三种度量别当成互斥选项。它们度量的是不同侧面:DTW 看整体节奏对齐,Fréchet 看局部最坏偏离,Hausdorff 看空间覆盖。把它们当特征组合起来用,比纠结"哪个最好"收益大得多。我最后那套两级串联的结构,代码量只多了三十来行,准确率提了将近 7 个百分点。

界面上一定要画出对齐结果。不是为了好看,是为了调试。DTW 的对齐路径画出来,能一眼看出是"某一段被压缩了"还是"整体偏移",比盯着一个数字调参高效太多。我那个axCompare小画布,后期基本成了主力调试工具,比在命令行里打印距离矩阵直观十倍。

能跑通的最小版本要尽早跑通。这套东西我最初写成三个独立脚本加一个命令行驱动,能跑出准确率之后才动的界面。如果反过来先做界面,调试算法时每次都要点鼠标画一笔才能看结果,效率会低到让人放弃。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进