做图像处理的人应该都遇到过这种问题:拿到一张二值图,想用最简单的方式把里面的目标“描述”出来,让它变成一组能算、能比、能存的数据。边界跟踪(Boundary Tracking)和链代码(Chain Code)就是干这个的——把目标轮廓转成有序点集,再把点集转成方向序列;在此基础上叠加一个主成分描述(PCA),你还能快速提取主轴方向、长宽比这类全局形状特征。这篇文章把这三块的原理、步骤和一套完整可跑的MATLAB代码一次性讲清楚。无论你是正在学数字图像处理课程,还是需要在项目里做形状识别和特征提取,这条链路都值得亲手过一遍。
1. 图像表示这件事,到底在图什么
1.1 从像素矩阵到结构化描述
最直观的图像“表示”就是像素矩阵本身,但实战里它非常不好用。你可以想象一个20×20的“L”形目标,只要整体平移几个像素,整个矩阵里的数值分布就全变了,像素级去比对会让匹配彻底失效。但目标的几何本质——边界的相对位置、轮廓的粗细长短、拐角的形状——其实一点都没变。
所以图像表示的核心目标,是把“像素长什么样”转化成“目标的结构是什么样”。在经典的数字图像处理框架里,这个转化通常分三个层次:
- 第一层是区域/像素集合,适合做填充、腐蚀、膨胀这类形态学操作,但不适合直接量化比较。
- 第二层是边界表示,用一组有序点列把区域轮廓表达成闭合曲线,数据量大幅压缩,同时保留了形状的拓扑顺序。
- 第三层是描述子,比如链代码、傅里叶描述子、各类矩,它们把边界点列提炼成更紧凑的数值特征,这才适合做识别和分类。
边界跟踪解决的就是从第一层到第二层的第一步,链代码属于第三层里最经典的方向描述子,而主成分描述则站在第三层更全局的角度,把整条边界当成一个二维点云来提取骨架。把这三件事串起来,才算构成一条完整的“图像表示链路”。
1.2 这条链路能用在哪儿
这套组合拳的应用范围很广。字符识别里靠链代码提取笔画的局部方向变化;细胞形态分析用PCA计算细胞长轴角度和边界紧致度;零件缺陷检测里通过主轴比例快速筛掉明显变形的目标;手势轮廓匹配则既用链码做局部形状对比,又用PCA做整体姿态估计。本文讲的每一步都可以单独使用,但放在一起时,你能得到一个非常实用的形状特征提取流程:先跟踪边界,再算链码,最后用PCA兜底拿全局参数。
2. 边界跟踪:从像素海里牵出轮廓线
2.1 先定两个约定:邻域和连通性
边界跟踪的第一步不是写代码,而是明确“什么样的像素算相邻”。4邻域只看上下左右,8邻域把四个对角也算进去。绝大多数形状描述场景都会选用8邻域,因为斜对角关系在很多细小结构里是真实相邻的,只用4邻域会把轮廓拆得支离破碎。
但8邻域有个著名的小坑:如果背景和前景都按8连通处理,有些细小的三角形空隙会被错误地“桥接”起来,导致内外边界判断模糊。我的习惯是:前景目标用8连通追踪边界,与此同时在预处理阶段先用imfill填充孔洞,避免内边界也被追出来。后面的代码里你也会看到这一步。
2.2 Moore边界跟踪:贴着墙走一圈
边界跟踪算法里,Moore算法是最经典也最直观的一种。你可以把它想象成“摸着墙走”:把前景目标当成一栋房子,边界上的像素是墙,你站在这面墙上,左手始终贴着墙,沿着墙走一圈回到起点,这一路踩过的位置就是边界点列。它的核心逻辑分几步:
- 找起点。按行优先顺序扫描二值图,遇到第一个前景像素就作为起点。这个起点左上方的位置必然是背景,因此可以确定初始“进入方向”。
- 从当前点出发,按固定方向顺序(比如顺时针)扫描8邻域,找到第一个前景像素,它就是下一个边界点。
- 关键细节:记录刚刚是从哪个方向找到这个点的,下一次从这个方向的下一个位置开始搜索,而不是每次都从头扫。否则算法会在相邻像素之间来回抖动,甚至绕进死循环。
- 终止判断:当搜索到的下一个点回到起点时,把起点补到点列末尾形成闭合边界,结束。
这段逻辑可以用很短的MATLAB代码还原,我见过不少课程作业就是这么写的:
function pts = mooreTraceSimple(bw) % 简化版Moore边界跟踪,适合单个连通前景目标的二值图 % bw: 前景1,背景0 % 输出 pts: Mx2,格式[行,列],首尾重复(闭合) [rows, cols] = size(bw); startPt = []; for i = 1:rows for j = 1:cols if bw(i,j) == 1 startPt = [i, j]; break; end end if ~isempty(startPt), break; end end if isempty(startPt) error('二值图中没有前景像素'); end % 8邻域搜索方向,按顺时针排列 dirs = [0 1; 1 1; 1 0; 1 -1; 0 -1; -1 -1; -1 0; -1 1]; % 索引对应:1=东,2=东南,3=南,4=西南,5=西,6=西北,7=北,8=东北 pts = startPt; cur = startPt; entryIdx = 5; % 起点的左侧是背景,认为从“西”方向进入起点 while true startIdx = mod(entryIdx, 8) + 1; foundIdx = -1; for k = 0:7 idx = mod(startIdx + k - 1, 8) + 1; nr = cur(1) + dirs(idx, 1); nc = cur(2) + dirs(idx, 2); if nr >= 1 && nr <= rows && nc >= 1 && nc <= cols && bw(nr, nc) == 1 foundIdx = idx; break; end end if foundIdx < 0 error('边界跟踪中断:目标可能不是单连通区域'); end nxt = [cur(1) + dirs(foundIdx,1), cur(2) + dirs(foundIdx,2)]; if isequal(nxt, startPt) && size(pts,1) > 1 pts = [pts; startPt]; % 闭合 break; end pts = [pts; nxt]; cur = nxt; entryIdx = foundIdx; if size(pts,1) > rows*cols error('边界跟踪超时,检查图像是否干净'); end end end这段代码我建议只用来理解原理,真正做项目时不要手写。因为它对噪声和复杂连通域的处理能力有限,一旦目标带孔洞或边界有断口,很容易提前闭合或死循环。
2.3 工程实现直接用bwboundaries
MATLAB自带一个叫bwboundaries的函数,专门干这件事。它的好处是内部实现经过优化,能同时处理多个连通域,并且区分内外边界。基本用法是:
boundaries = bwboundaries(bw, 8);参数8表示按8连通追踪,返回值是一个cell数组,每个元素是一个N×2的矩阵,保存了一条边界的坐标点。这里的坐标格式是[行, 列],即第一列是row,第二列是col。很多人第一次画边界时直接plot(B),结果图是转置的——因为你把row当成了x轴。正确画法是:
imshow(bw); hold on; plot(B(:,2), B(:,1), 'r-', 'LineWidth', 1.5);这个坑特别常见,后面还会强调。
3. 链代码:把坐标序列换成方向序列
3.1 方向编码表和计算例子
边界点列虽然比像素矩阵紧凑,但直接拿坐标去做比较还是太笨。链代码的思路是:不记录“我在哪个点”,而是记录“我从上一个点到这个点是朝哪个方向走的”。对于8邻域边界,方向只有8个,每个方向用一个0到7的数字表示。
以我的代码为例,方向码定义如下:
| 方向码 | 方向含义 | 行差dr | 列差dc |
|---|---|---|---|
| 0 | 东 | 0 | +1 |
| 1 | 东北 | -1 | +1 |
| 2 | 北 | -1 | 0 |
| 3 | 西北 | -1 | -1 |
| 4 | 西 | 0 | -1 |
| 5 | 西南 | +1 | -1 |
| 6 | 南 | +1 | 0 |
| 7 | 东南 | +1 | +1 |
这个表最需要注意的是坐标约定。在MATLAB图像矩阵里,行方向是向下的,所以“北”是行差-1,“南”是行差+1。如果你把行列理解成y/x,会把整个链码的方向全部搞反。
举个简单例子,一个直角三角形轮廓的三个点按顺序为A(row=1,col=2)、B(row=1,col=3)、C(row=2,col=2)。从A出发:
- A→B:行差0,列差+1,方向码0(东)
- B→C:行差+1,列差-1,方向码5(西南)
- C→A:行差-1,列差0,方向码2(北)
所以链码是[0 5 2]。只看这3个数字,你就能还原出这个三角形的走向,这就是方向序列比坐标序列强大的地方。
3.2 归一化之后才能拿去比较
链码有一个天然优点:它只记录相对方向,因此平移不会影响它。但还有两个棘手的问题。
第一个问题是起点不同。同一个轮廓,从不同的边界点开始记录,得到的链码序列完全不同。解决办法是“起点归一化”:把链码看成循环序列,枚举所有可能的循环移位,取字典序最小的那个作为标准形式。实现技巧是把链码首尾相接成双倍长度,再扫描n个长度为n的子序列。
第二个问题是旋转。一个矩形旋转30度之后,边界上的每个方向码都会整体加上同一个常数(模8),直接比对肯定失败。解决办法是“一阶差分链码”:计算相邻方向码的变化量,也就是方向码从一步到下一步转了多少。因为整体旋转相当于给每个方向码加常数,而差分会把常数抵消掉。
一阶差分的MATLAB实现很简洁,有一个小技巧:
function fd = chainFirstDifference(chain) if isempty(chain) fd = []; return; end % 把链码尾部和首部连起来,保证闭环差分 fd = mod(diff([chain, chain(1)]), 8); end注意这里的“闭环”细节很重要。如果链码已经由闭合边界点列生成,最后一个方向码本来就是“最后一点返回到第一点”的方向,那么必须把这个尾巴接回头部再差分,否则会丢掉首尾之间的旋转信息。
3.3 毛刺与噪声是链码的天敌
链码对噪声非常敏感。边界上只要有一个锯齿状毛刺,链码就会多出两个甚至三个方向的抖动,表现为局部方向码剧烈跳变,序列长度也会变长。更麻烦的是,这种毛刺会扰乱一阶差分,让原本干净的特征变得很不稳定。
我的处理顺序是:先对二值图做形态学开运算,去掉孤立小点和细长凸起;再做闭运算,填平细小凹坑;最后才做边界跟踪。如果只想保留轮廓的大致走向,还可以在拿到边界点列之后做一次平滑,比如用三点的移动平均,但要注意别把真正的角点磨圆。对于追求更高精度的场景,可以用边缘检测先得到亚像素轮廓,再做链码,效果会好很多,但那是另一个话题了。
4. 主成分描述:给边界算一个“骨架”
4.1 PCA在这里到底干了什么
链代码擅长描述边界的局部走向,但如果你想快速知道“这个目标整体朝哪个方向伸展开、长宽比大概是多少”,链代码就绕远了。这时候用主成分分析(PCA)最合适。
思路很简单:把边界点集看成一个二维点云。PCA去计算这个点云沿哪个方向方差最大,那个方向就是主轴;第二个方向与主轴正交,就是次轴。方差的大小对应了目标在主轴/次轴方向上的展开程度。得到的特征向量给出方向,特征值给出延展量,合在一起就是形状的“骨架”。
数学上,需要先把边界点坐标减去质心,得到去均值点云,然后构造2×2的协方差矩阵,再对协方差矩阵做特征值分解。最大特征值对应的特征向量就是主轴方向。
4.2 和中心矩的等价关系
这里有个很漂亮的等价关系:对于二维边界点云,协方差矩阵的四个元素本质上就是二阶中心矩。设协方差矩阵为
C = [σxx σxy; σxy σyy]
那么主轴方向角度θ有一个闭式解:
θ = 0.5 * atan2d(2*σxy, σxx - σyy)
同时,对去均值点云做SVD得到的右奇异向量,和协方差矩阵的特征向量完全一致。所以在MATLAB里,我通常直接对去均值点云做SVD,而不是先算协方差再调用eig。原因有两个:SVD数值上更稳定;奇异值直接描述了主轴/次轴方向的离散程度,比特征值更直观。
一个需要特别处理的细节是:SVD得到的特征向量符号不唯一,主轴角度可能偶尔相差180度。解决办法是把角度用mod归一化到[0,180)区间。这个角度归一化不影响主轴本质,因为一维主轴方向的正反端是等价的。
4.3 描述子的构成与实用建议
一个完整的PCA形状描述子可以包含这些量:
- 质心坐标:告诉目标在图像中的位置
- 主轴角度:目标整体朝向
- 主轴长度:目标在主轴方向上的跨度
- 次轴长度:目标在次轴方向上的跨度
- 长短轴比:目标的长宽比例,这是一个缩放不变量
- 奇异值比:相当于用点云标准差来定义的长短轴比,对离群点更稳健
实际做识别的时候,可以直接把这些量拼成一个特征向量,比如[角度, 长短轴比, 奇异值比],然后喂给分类器。要注意的是,当目标接近圆形时,长短轴比接近1,主轴角度的意义就不大了,这时应该侧重边界描述子或纹理特征,别硬用PCA角度。
5. MATLAB完整流程与代码拆解
5.1 可运行主程序
下面是完整脚本,请新建一个脚本文件连续粘贴运行。
%% 图像表示:边界跟踪 + 链代码 + 主成分描述 % 演示:二值形状的紧凑表示和特征提取 % 环境:MATLAB R2020b及以上 clc; clear; close all; %% 0. 构造测试图(也可以用真实图片替换) img = zeros(256, 256); img(60:190, 50:220) = 1; img = imrotate(img, 30, 'crop'); % 旋转30度,考察旋转不变性 bw = img > 0.5; bw = imfill(bw, 'holes'); % 填充孔洞 %% 1. 边界跟踪 boundaries = bwboundaries(bw, 8); if isempty(boundaries) error('二值图中没有前景目标'); end lenList = cellfun(@(x) size(x,1), boundaries); [~, idx] = max(lenList); % 取最长边界作为主目标 B = boundaries{idx}; fprintf('边界点数:%d\n', size(B,1)); %% 2. 链代码 B_closed = [B; B(1,:)]; % 闭合补点 chain = boundaryToChainCode(B_closed); fprintf('原始链码长度:%d\n', numel(chain)); normChain = normalizeChainStart(chain); % 起点归一化 fd = chainFirstDifference(normChain); % 旋转归一化(一阶差分) %% 3. 主成分描述 desc = pcaShapeDescriptor(B); fprintf('主轴角度:%.2f°\n', desc.axisAngle); fprintf('主轴长度:%.2f, 次轴长度:%.2f\n', desc.axisLengths(1), desc.axisLengths(2)); fprintf('长短轴比(投影跨度):%.3f\n', desc.axisRatio); fprintf('长短轴比(奇异值):%.3f\n', desc.singularRatio); %% 4. 可视化 figure('Name','边界与主轴'); imshow(bw); hold on; plot(B(:,2), B(:,1), 'r-', 'LineWidth', 1.6); plot(B(1,2), B(1,1), 'go', 'MarkerSize', 10, 'MarkerFaceColor', 'g'); drawShapeAxis(desc, 120); % 画主轴和次轴 %% ================= 子函数 ================= function chain = boundaryToChainCode(B) % 输入:有序边界点B,格式[行,列],首尾闭合 % 输出:8方向链码 chain: 1x(N-1) lut = [3 2 1; 4 -1 0; 5 6 7]; % lut(dr+2, dc+2) d = diff(B); if any(abs(d(:)) > 1) warning('边界存在非8邻域跳变,链码可能不准'); end n = size(d, 1); chain = zeros(1, n); for k = 1:n chain(k) = lut(d(k,1)+2, d(k,2)+2); end end function normChain = normalizeChainStart(chain) % 链码的循环字典序最小移位(起点归一化) n = numel(chain); if n <= 1 normChain = chain; return; end dchain = [chain, chain]; % 环形展开 bestIdx = 1; for s = 2:n seg = dchain(s:s+n-1); segBest = dchain(bestIdx:bestIdx+n-1); cmpPos = find(seg ~= segBest, 1); if ~isempty(cmpPos) && seg(cmpPos) < segBest(cmpPos) bestIdx = s; end end normChain = dchain(bestIdx:bestIdx+n-1); end function fd = chainFirstDifference(chain) % 一阶差分链码,旋转不变 if isempty(chain) fd = []; return; end fd = mod(diff([chain, chain(1)]), 8); end function desc = pcaShapeDescriptor(B) % 基于SVD/PCA的形状描述 X = double(B(:, [2 1])); % 转为(x, y):x=列, y=行 mu = mean(X); Xc = X - mu; [~, S, V] = svd(Xc, 'econ'); P = Xc * V; % 投影到主轴坐标系 lenMajor = max(P(:,1)) - min(P(:,1)); lenMinor = max(P(:,2)) - min(P(:,2)); desc.mu = mu; desc.axisAngle = mod(atan2d(V(2,1), V(1,1)), 180); % 主轴与x轴夹角 desc.axisLengths = [lenMajor, lenMinor]; desc.axisRatio = lenMajor / (lenMinor + eps); desc.singularRatio = S(1,1) / (S(2,2) + eps); desc.projected = P; desc.singularValues = diag(S); end function drawShapeAxis(desc, halfLen) % 以质心为中心画主轴和次轴 angle1 = desc.axisAngle * pi / 180; angle2 = angle1 + pi/2; cx = desc.mu(1); cy = desc.mu(2); dx1 = halfLen * cos(angle1); dy1 = halfLen * sin(angle1); dx2 = halfLen * cos(angle2); dy2 = halfLen * sin(angle2); plot([cx-dx1 cx+dx1], [cy-dy1 cy+dy1], 'y-', 'LineWidth', 2); plot([cx-dx2 cx+dx2], [cy-dy2 cy+dy2], 'c-', 'LineWidth', 2); end这段代码跑完,你会看到三样东西:边界轮廓线、绿色起点、黄色主轴线与青色次轴线。控制台会输出主轴角度和长短轴比。因为测试图本身旋转了30度,你会发现主轴角度大致也在30度附近——这就是PCA描述子在作用。
5.2 每一步的“为什么”
预处理里有个细节:imrotate之后,边界的插值会让二值图产生很多边缘过渡像素,用img > 0.5之后轮廓会带一点锯齿。这是故意的,因为真实图像几乎不可能给你完美的二进制边界,提前暴露链码对锯齿敏感的特性反而有助于理解。
链码部分,B_closed = [B; B(1,:)]这一步很多人会忽略。bwboundaries返回的边界不重复起点,如果不补上,diff计算会少掉最后一点回到起点的那个方向,链码长度少1,闭环信息丢失。我们做一阶差分时又依赖首尾相连,所以这个补点操作是必须的。
PCA部分,pcaShapeDescriptor里我把B的列顺序从[行,列]换成了[x, y]。这样主轴角度表示的是相对水平x轴的夹角,更符合人的直觉。如果你不改这一句,角度会变成相对行方向的角度,画图时和视觉理解容易出现偏差。
5.3 结果解读与参数调优
把代码跑完,你会得到类似下面的输出(不同MATLAB版本略有差异):
边界点数:817 原始链码长度:817 主轴角度:29.82° 主轴长度:218.34, 次轴长度:155.61 长短轴比(投影跨度):1.403 长短轴比(奇异值):1.355注意这里的长短轴比不是旋转前矩形的宽高比,而是旋转和二值化后边界外接主轴的跨度比,属于离散点云层面的几何描述。如果你的输入目标本身是椭圆形或矩形,轴比接近真实长宽比;如果形状复杂,轴比只是整体粗度的一个指标,别指望它精确还原尺寸。
实际操作中,有几个参数值得调。一是二值化阈值,imbinarize里的阈值直接影响边界长度和链码质量;二是连通参数,bwboundaries的第二个参数选4还是8,对细长目标影响很大;三是形态学处理的结构元尺寸,在去毛刺和保留真实角点之间需要权衡。
6. 常见错误排查与实践中的坑
6.1 问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 边界画出图是横向翻转 | 混淆了行列与xy坐标 | 画图用plot(B(:,2), B(:,1)) |
| 链码长度明显比边界点数多 | 边界存在非8邻域跳变或重复点 | 检查bwboundaries连通参数,或对边界做平滑 |
| 链码长度比边界点数少1 | 没有补闭合点 | 边界点列末尾追加起点 |
| PCA角度每次结果差180度 | SVD特征向量符号不唯一 | 对角度做mod 180归一化 |
| 一阶差分链码仍然比对不上 | 存在镜像翻转或缩放差异 | 链码本身不处理镜像和缩放,需额外归一化 |
| 边界追出了一圈内轮廓 | 目标有孔洞 | 追踪前先imfill('holes') |
| 手动Moore跟踪提前闭合 | 目标带孔洞或噪声毛刺 | 换用bwboundaries,先做形态学滤波 |
6.2 几个容易被忽略的细节
我自己实际踩过的坑里,最值得说的是“链码起点归一化和差分链码的顺序问题”。经验做法是先做起点归一化,再做一阶差分。因为差分之后的序列同样是循环的,如果先差分再去找字典序最小的循环移位,理论上也行,但差分会放大毛刺噪声,导致起点归一化更不稳定。先起点归一化,再差分,结果更干净。
还有一个关于PCA投影的建议:如果单个目标的边界点特别多,直接用全部点算PCA会包含大量冗余点。可以先在边界上均匀重采样几百个点,再做PCA和链码。重采样不仅能降低噪声影响,还能让不同目标之间的边界点数保持一致,这对后续做特征拼接和分类非常有帮助。
另外,当你打算把链码和PCA特征结合起来做识别时,注意尺度匹配。链码长度和边界点数强相关,会受到图像分辨率影响;而PCA的长短轴比本身就是缩放不变量。所以组合特征里,用链码的差分直方图或循环移位后的链码做局部描述,用PCA的轴比和归一化角度做全局描述,效果会比硬拼原始链码更好。
最后再分享一个小经验:我之前在一个轮廓识别练手项目里,直接用链码做模板匹配,结果因为旋转问题折腾了很久。后来把链码换成归一化差分链码,再用PCA角度做预筛选,速度和稳定性都明显上去了。边界跟踪和链代码这套组合拳,真正决定成败的往往不是后面的算法有多高级,而是前面预处理能不能给出一条干净、闭合、有序的边界——边界不干净,后面一切描述子都在垃圾数据上做文章。先跑通这条基础流程,再往傅里叶描述子、Hu矩这些方向扩展,你会觉得顺很多。