10  几何变换

产线上的工件不会每次都以同样的位置、同样的角度出现在视野里:振动盘送料有姿态散布,托盘定位有机械间隙,贴片机的吸嘴还会带来旋转偏差。定位补正要把图像”转正”再测量,多相机拼接要把几幅图像对齐到同一坐标系,模板匹配前常常要把图像缩放到合适的分辨率——这些任务的共同内核都是几何变换(geometric transformation):把图像从一个坐标系搬到另一个坐标系。这件事看似只是”挪一挪、转一转”,实则隐藏着两个必须分开回答的问题:坐标怎么映射——这是矩阵的事;新像素值从哪来——这是插值的事。答错任何一个,图像就会出现空洞、锯齿或不知不觉的模糊,而模糊正是测量精度的天敌。

本章的实验使用 图 10.1 所示的 480×360 合成场景。它的每一种结构都是故意”难为”几何变换的:左上角的类文本小块考验细节保持,三根 1 px 亮横线与三根 1 px 暗竖线考验细线存活,一条 1 px 对角线考验斜向结构,中央 80×80 的 2 px 棋盘格是对重采样最敏感的高频图案,右上的圆环则用来观察边缘的光滑程度。

图 10.1: 本章测试场景(480×360):类文本块、1 px 细线三联(横/竖)、1 px 对角线、中央 2 px 棋盘格与圆环,全部是对插值与重采样敏感的细结构。

10.1 变换矩阵

二维点 \((x, y)\)齐次坐标(homogeneous coordinates)下写成 \((x, y, 1)^\mathsf{T}\)(见 章节 2 的线性代数一节),于是平移、旋转、缩放都统一成一个 \(3\times 3\) 矩阵乘法:

\[ T(t_x,t_y)=\begin{bmatrix}1&0&t_x\\0&1&t_y\\0&0&1\end{bmatrix},\qquad R(\theta)=\begin{bmatrix}\cos\theta&-\sin\theta&0\\ \sin\theta&\cos\theta&0\\0&0&1\end{bmatrix},\qquad S(s_x,s_y)=\begin{bmatrix}s_x&0&0\\0&s_y&0\\0&0&1\end{bmatrix}. \]

把它们任意复合(矩阵相乘),得到的最一般形式是仿射变换(affine transformation)

\[ A=\begin{bmatrix}a_{11}&a_{12}&t_x\\ a_{21}&a_{22}&t_y\\ 0&0&1\end{bmatrix}, \]

共 6 个自由度,保持直线仍是直线、平行线仍然平行。定位补正中”先把工件平移到中心、再绕中心转正”的整个动作链,在数学上就是若干个这样的矩阵连乘成一个矩阵。

齐次坐标的真正好处在这里显现:平移本来是加法,升一维后也变成了乘法,于是任意长的变换链都能预先乘成一个矩阵。这个看似平凡的代数事实,到 小节 10.3 会变成一条关乎图像质量的工程纪律。

有了矩阵,下一个问题是怎么用它生成新图像。直觉的做法是正向映射(forward mapping):遍历源图像每个像素,算出它在目标图像中的落点,把灰度搬过去。这个做法有个致命缺陷——落点坐标一般不是整数,取整之后有的目标像素被多个源像素争抢,有的目标像素却一次也没被命中,留下成片的空洞(holes),旋转和放大时尤其明显。正确的做法恰好相反,称为逆映射(inverse mapping):遍历目标图像的每个像素 \(\mathbf p'\),用逆矩阵求出它在源图像中的来源坐标

\[ \mathbf p = A^{-1}\,\mathbf p', \]

然后到源图像中把这个(一般非整数的)位置的灰度”取”出来。每个输出像素都恰好被赋值一次,空洞从根上消失。本章配套代码中的手写旋转就是按这个原则实现的。至于”非整数位置的灰度怎么取”,正是下一节的主题。

10.2 插值

逆映射算出的源坐标 \((s_x, s_y)\) 落在像素网格之间,必须由邻近像素估出一个灰度值,这就是插值(interpolation)。两种基础方法已在 章节 2 介绍过,这里只作回顾。最近邻插值(nearest-neighbor interpolation)直接取四舍五入后最近的像素,不产生任何新灰度值,但代价是 0.5 px 量级的位置量化误差。双线性插值(bilinear interpolation)取周围 4 个像素按距离加权平均:记 \(x_0=\lfloor s_x\rfloor\)\(y_0=\lfloor s_y\rfloor\),小数部分 \(a=s_x-x_0\)\(b=s_y-y_0\),则

\[ f(s_x,s_y)\approx(1-a)(1-b)\,f_{00}+a(1-b)\,f_{10}+(1-a)\,b\,f_{01}+ab\,f_{11}, \]

其中 \(f_{00},f_{10},f_{01},f_{11}\) 是左上、右上、左下、右下四个邻居。它输出连续过渡的灰度,本质上是一次轻微的局部平滑。

两者差多少?我们把测试场景分别用手写逆映射的最近邻与双线性各旋转 17°(两套代码除采样方式外完全相同),结果见 图 10.2;再把中央棋盘格区域裁出并放大 4 倍逐像素观看,见 图 10.3

(a) 最近邻
(b) 双线性
图 10.2: 手写逆映射实现的 17° 旋转:最近邻 vs 双线性。整体看两者相似,差别集中在细结构上——放大对比见 图 10.3
(a) 最近邻 ×4
(b) 双线性 ×4
图 10.3: 中央棋盘格区域裁剪后放大 4 倍。最近邻结果锯齿明显,2 px 棋盘格的黑白格被位置量化打得错乱不堪;双线性结果边缘平滑,棋盘格过渡成规则的灰调。

差别一目了然:最近邻的旋转结果布满锯齿,2 px 棋盘格因为每个输出像素”跳”到最近的源像素,黑白格错乱成不规则的碎块;双线性的结果边缘平滑、几何形状规整,代价是引入了原图中不存在的中间灰度,细结构略微变软。

插值方式的选择法则:测量任务(边缘定位、卡尺、配准)用双线性或更高阶插值,避免最近邻的位置量化伤害亚像素精度;二值掩膜、标签图必须用最近邻——双线性会在 0 与 255 之间插出大量中间灰度值,掩膜就不再是掩膜了。

10.3 重采样的代价

双线性插值”轻微平滑”的副作用单次几乎看不出来,但每执行一次几何变换,图像就被重采样(resampling)一次,平滑就叠加一层。为了把这个代价量化,我们做一个能”回到原点”的实验:把测试场景用双线性旋转 10°,再旋转 10°……共 36 次,总角度恰好 360°,图像应该一丝不差地回到原位。结果见 图 10.4

(a) 36 次 ×10° 累积旋转
(b) 与原图的绝对差
图 10.4: 累积重采样实验。(a) 36 次 10° 双线性旋转后”回到原位”的图像:2 px 棋盘格糊成一块均匀的灰斑,1 px 细线几乎消失,文本块只剩圆润的影子,四角则被旋转裁剪成黑色;(b) 差值图:中央结构处差异清晰可见,四角的大片亮区来自画幅裁剪。

位置确实回去了,图像却面目全非:2 px 棋盘格彻底糊成一块均匀的灰色,1 px 细线只剩若有若无的痕迹,文本块的棱角全部圆掉。定量地看,在中央半径 170 px 的圆盘内(避开角部裁剪区),36 次旋转后的图像与原图的平均绝对差 MAD 高达 10.78 个灰度级;作为对照,单步旋转 360°(数学上的恒等变换)的 MAD 是 0.0000——圆盘内一个字节都没变。用 SDK 的旋转接口链式调用 36 次得到 10.77,与手写实现吻合,说明这不是某个实现的缺陷,而是重采样本身的性质:每一次重采样都是一次不可逆的信息损失

全幅 MAD 是 78.57,远大于圆盘内的 10.78——它被角部裁剪主导:isChangeSize=false 时画幅不变,每次旋转都把四角转出画幅、用填充色补上,36 次之后中央只剩一个内切圆盘。统计误差时务必把这部分剔除,否则量到的是裁剪而不是插值。

由此得到本章最重要的一条工程纪律:变换矩阵在数学上随便结合,图像上绝不要分步执行。需要”先平移、再旋转、再缩放”时,把三个矩阵在代码里乘成一个,再做唯一一次逆映射重采样——结果与分三步在数学上等价,在图像质量上却天差地别。

角部裁剪本身也是个要注意的语义问题:旋转接口的 isChangeSize 参数决定输出画幅是否扩大以容纳旋转后的整个外接矩形。取 false 时画幅与原图相同,四角内容被截掉、空出的区域用指定颜色填充;取 true 时内容无损但画幅变大、后续 ROI 坐标全部失效。补正类应用通常取 false 并确保被测区域落在安全圆盘内。

10.4 缩放与混叠

缩放也是几何变换,但降采样(downsampling)有一个旋转没有的陷阱。把图像缩小 4 倍意味着采样率降为 1/4,按 章节 1 讨论过的采样定理,原图中周期小于 8 px 的结构在新采样率下根本无法表示,若不处理就会以混叠(aliasing)的面目卷土重来(频域解释见 章节 11)。正确做法是先预滤波再抽取:先把高于新奈奎斯特频率的成分滤掉,再降低分辨率。

我们用 SDK 的 Sampling 接口做对比:NEAREST 模式相当于直接隔 4 抽 1(无预滤波),AREA 模式相当于对每个 4×4 源区域取平均(盒式预滤波)后再抽取。结果见 图 10.5(为便于观看,两幅缩小图都用最近邻放大回了原尺寸,不引入新信息)。

(a) NEAREST 直接抽取
(b) AREA 区域平均后抽取
图 10.5: 降采样 ×4 对比。(a) 直接抽取:2 px 棋盘格混叠成一块纯暗色块,三根 1 px 亮线只随机”活”下来一根,圆环与对角线断成虚线;(b) 盒式预滤波后抽取:棋盘格正确地平均为中灰,三根细线全部保住——只是变淡了,这正是细线能量摊薄后的真实样子。

直接抽取的结果触目惊心:2 px 棋盘格混叠成一块纯暗色块——抽样恰好每次都落在暗格上,高频图案伪装成了一个根本不存在的低频结构;三根一模一样的 1 px 亮线只”活”下来一根,另外两根恰好被抽样跳过,存亡完全取决于它们与抽样网格的相对位置;圆环和对角线断成虚线。预滤波后的结果则把所有结构都保了下来:棋盘格平均成中灰(这是它在低分辨率下唯一诚实的表示),三根细线全部可见,只是因能量摊薄而变淡。降采样必须预滤波——细线状缺陷的漏检,很多时候就源于缩略图流程里一次”省事”的直接抽取。

Sampling 提供 6 种插值模式,适用场景如下表。

模式 含义 适用场景
LINEAR 双线性 通用默认:放大、轻度缩小
NEAREST 最近邻(直接抽取/复制) 二值掩膜与标签图;降采样时会混叠
CUBIC 双三次(4×4 邻域) 高质量放大,边缘比双线性锐利
AREA 区域平均(自带盒式预滤波) 降采样首选
LANCZOS4 8×8 Lanczos 窗 sinc 质量最高的缩放;强边缘附近可能振铃
WEIGHT SDK 自定义加权模式 行为接近 LINEAR,使用前建议实测验证

10.5 镜像、裁剪与拼接

还有几个常用的”整数级”几何操作,不涉及插值,按字节搬运像素,无信息损失。镜像(mirror)做水平(左右)、垂直(上下)翻转,常用于统一来自对称工位或经棱镜反射的图像方向。裁剪(cropping)把 ROI 区域抠成独立小图,用于减小后续处理的数据量。这里有一个必须记住的工程事实:SciVision 的 GenRect1 矩形 ROI 的右下角是排他端点——传入 (305,35)–(434,164) 得到的是 129×129 而非 130×130 的输出(图 10.6)。按”包含两端”的直觉写代码,会系统性地丢掉最后一行一列。拼接(stitching)把多相机或多视场图像合成大图,SDK 用户手册 5.12 节提供了相应接口,本书不展开。

(a) 水平镜像
(b) 裁剪结果(129×129)
图 10.6: 整数级几何操作。(a) 水平镜像:左右翻转,逐字节搬运、无插值;(b) 用排他端点 (305,35)–(434,164) 裁出的圆环区域,实际输出 129×129。

10.6 SciVision 实现

旋转与平移由 SciSvRotationShift 提供,一个接口同时完成两件事:

SCIMV::SciSvRotationShift rot;
SciColor fill(0, 0, 0);          // 转出画幅区域的填充色
SciImage dst;
// 绕图像中心旋转 17°,画幅不变,平移量 (0,0)
long rc = rot.RotateShiftImage(src, fill, 17.0f,
                               /*isChangeSize=*/false, 0, 0, &dst);

参数依次为:输入图像、填充色、旋转角(度)、isChangeSize(是否扩大画幅容纳整幅旋转结果,语义见 小节 10.3)、x/y 方向平移量、输出图像。有两个实测事实必须知道。其一,该接口不暴露插值方式参数。我们把 SDK 传 +17° 的旋转结果与手写实现的 −17° 旋转对比(SDK 的正角方向与教科书约定相反,见其二;取负角才是同一几何变换;统计范围为中央半径 170 圆盘内):与手写双线性 −17° 的 MAD 为 1.05,与手写最近邻 −17° 的 MAD 为 3.90——证明其内部是双线性一类的平滑插值,可放心用于测量链路;也因此,本章的最近邻/双线性对比实验是用手写逆映射实现的(SDK 不暴露最近邻模式)。其二,正角的旋转方向与常见的逆时针约定相反:同一个 +17° 传给 SDK 与传给按教科书约定实现的代码,转出来的方向相反。从 OpenCV 等库迁移代码时务必先用一幅非对称图像核对方向,再确定角度的符号。

缩放、镜像与裁剪的调用如下:

SCIMV::SciSvSampling smp;
SciImage down;
smp.Sampling(src, src.Width() / 4, src.Height() / 4,
             SCI_SV_RESAMPLE_AREA, &down);   // 降采样用 AREA(自带预滤波)

SCIMV::SciSvMirror mir;
SciImage flipped;
mir.Mirror(src, SCI_MIRROR_HOR, &flipped);   // 水平镜像(左右翻转)

SCIMV::SciSvImageCropping cropper;
SciROI roi;
SciPoint tl(305, 35), br(434, 164);          // 右下角为排他端点
roi.GenRect1(tl, br);
SciImage patch;
cropper.ImageCrop(src, roi, 0, 0, &patch);   // 输出 129×129

Sampling 的参数为输入图像、目标宽、目标高、插值模式(6 种模式的选择见 小节 10.4 的表)、输出图像;Mirror 的第二个参数是镜像类型;ImageCrop 的第三、四个参数为裁剪类型与越界填充灰度。所有返回码都应检查。生成本章全部实验图像的完整工程位于 code/geometric_transforms/

工业案例:位置补正链路里的隐形模糊

某贴装检测项目的流水线是”模板定位 → 旋转补正 → 卡尺测量”。调试阶段,工程师为了便于单独核对平移量和角度,把补正拆成两步执行:先平移一次、再旋转一次——两次重采样。产线运行后发现卡尺边缘的过渡带从约 2 px 糊到了 3 px,边缘定位的重复性退化了约三成,排查光源、镜头、振动均无果。最终在图像链路里找到原因:两次双线性重采样叠加了两层平滑。把平移与旋转矩阵在代码里乘成一个仿射矩阵、只做一次重采样后,边缘宽度与重复性即恢复。更进一步的经验是:补正能免则免——若后续工具支持带姿态的 ROI,让 ROI 跟着工件转(见 章节 19),图像一次都不必重采样。

10.7 小结

  • 几何变换 = 矩阵 + 插值:齐次坐标把平移、旋转、缩放、仿射统一成 \(3\times 3\) 矩阵;生成图像必须用逆映射——每个输出像素回源图采样,正向映射会留下空洞。
  • 插值方式按任务选:测量任务用双线性或更高阶,二值掩膜与标签图必须用最近邻(不引入新灰度值)。
  • 每次重采样都是一次信息损失:36 次 10° 旋转回到原位后 MAD 达 10.78,而单步等价变换为 0.0000。变换链要先乘成一个矩阵、只重采样一次;能用带姿态的 ROI 就不要动图像。
  • 降采样必须预滤波:直接抽取会让高频结构混叠(棋盘格变色块、细线随机消失),应使用 AREA 等自带预滤波的模式。
  • 核对工程语义RotateShiftImage 不暴露插值参数(实测为双线性类)且正角方向与常见约定相反;GenRect1 的右下角是排他端点,(305,35)–(434,164) 裁出的是 129×129。

几何变换与重采样插值的标准论述见 Gonzalez 与 Woods 的教材 (Gonzalez 和 Woods 2018);介于双线性与样条之间、被广泛采用的三次卷积插值,其原始构造出自 Keys 的论文 (Keys 1981)。关于几何变换与插值在测量系统中更系统的论述(包括投影变换与亚像素精度的关系),可进一步阅读 Steger 等人的著作 (Steger, Ulrich, 和 Wiedemann 2018)