5.2. 坐标与区域¶
图像处理作用于像素,而要对某个像素进行处理,算法必须通过坐标来定位它。要对一个矩形范围内的像素进行处理也是如此——这个矩形必须以算法和应用代码都认同的方式来描述。image 模块用于描述坐标和矩形的约定非常直观,只有一处细节会让习惯数学惯例而非计算机图形惯例的读者感到意外,值得在一开始就明确说明。
5.2.1. 像素网格¶
像素 (0, 0) 是图像的左上角。x 轴向右延伸,因此 x 越大表示越靠右。y 轴向下延伸,因此 y 越大表示在图像中越靠下。一个宽乘高的图像所包含的像素位于从 (0, 0) 到 (width - 1, height - 1) 的整数坐标处;在 (width, 0) 或 (0, height) 处并没有像素——这些位置是右边缘和下边缘,比每个方向上最后一个实际像素再多出一步。
向下延伸的 y 轴就是上面提到的那处细节。习惯方格纸几何的读者会预期 y 越大意味着越靠上;而在这里,这种直觉恰好被颠倒过来。颠倒的原因在于:数字传感器和数字显示器都从左上角开始工作,按行从左向右、从上到下逐行扫描,而将像素在内存中按相同顺序排布,会使"缓冲区中的位置 i"与"图像的第 r 行、第 c 列"之间的关系成为尽可能简单的算术运算——像素 (x, y) 的位置 i 就是 y * width + x。出于同样的原因,几十年前所有的图像处理库都认同了这种排布方式,其代价不过是在初次处理图像时做一点小小的思维调整。
图像坐标系:原点位于左上角,x 向右延伸,y 向下延伸。图像内部的一个矩形区域由其左上角 (x, y) 及其尺寸 (w, h) 来命名。¶
5.2.2. 矩形¶
对图像的大多数操作关心的与其说是单个像素,不如说是像素组成的矩形——一块要查看的区域、一块要复制出来的区域、一帧之内用于计算统计量的子帧。命名矩形的形式采用了对单像素约定最简单的扩展:给出左上角的坐标,后跟矩形的尺寸,打包成一个四元组 (x, y, w, h)。矩形内部的像素位于第 x 列到第 x + w - 1 列、第 y 行到第 y + h - 1 行。
这里值得明确说明的细节是:w 和 h 是尺寸,而不是右下角的坐标。矩形 (10, 20, 4, 3) 覆盖第 10、11、12、13 列和第 20、21、22 行——总共十二个像素——而不是从 (10, 20) 延伸到 (4, 3) 的区域。这个约定在整个模块中是统一的,因此一旦内化,失误就会停止,但它确实会让人在第一次时栽跟头。
(x, y, w, h) 这种形式出现在三个看似不同、却共享同一约定的地方。第一处是图像描述自身范围的时候:覆盖整张图像的矩形是 (0, 0, width, height)。第二处是检测方法返回带有边界框的结果时——一个 blob、一个 rect、一个 apriltag——边界框会以 (x, y, w, h) 的形式报告回来。第三处是当某个方法需要被告知在图像的子区域而非整帧上工作时;用于限定操作范围的 roi 关键字参数采用的就是这同一个四元组。
从一个方法获取边界框,再将其传入下一个方法的 roi 中,是图像处理中最常见的模式之一。粗略的第一次检测得到的边界框,可以为更精细的第二次检测缩小搜索范围,而检测结果与方法参数之间统一的表述方式,正是让这种模式如此直接的原因——一种元组形式,在交接的两端以相同的方式使用。
5.2.3. 整数地址,分数形心¶
像素地址本身是整数。一个像素要么位于给定的整数列和行,要么不在那里,而询问坐标 (40.5, 30.7) 处有什么并不是一个良好定义的问题——并没有像素恰好坐落在那个位置。不过,image 模块从像素位置推导出的少数几个量是分数形式的,值得理解其中的原因,以免这种区别在之后让应用程序措手不及。
最常见的情形是形心——一个区域的质心。对于一块连通的像素区域,浮点形式的形心是其成员像素位置按密度加权后的平均值。一块像素横跨两列的区域,其形心 x 可能是比如说 41.6——这是一个真实的位置,肉眼会将其描述为"那块区域的中间",尽管并没有实际像素恰好坐落在那个 x 处。检测结果对象以只读属性的形式同时携带两种形式:一个整数对(cx / cy,在将位置反馈给需要整数像素坐标的对象时很有用)和一个浮点对(cxf / cyf,在位置要送入受益于亚像素分辨率的控制回路时很有用)。
另一种情形是在频域中测量的两帧之间的位移。那些分析图像频谱内容而非直接分析其像素的技术,能够分辨出小于一个像素的偏移,并将这些偏移以浮点 (dx, dy) 值的形式报告出来。
经验法则是:像素地址是整数;而从算法中得出的位置和偏移可以是浮点数。绘图方法接受这两种形式,并在结果必须落在网格上时把浮点数向下取整到最接近的整数像素。
5.2.4. 笛卡尔坐标与极坐标¶
目前为止描述的系统是笛卡尔坐标系:每个像素都由其相对于原点的水平和垂直偏移来命名。这就是字节实际存储所采用的系统——缓冲区中的像素 i 对应于第 i % width 列、第 i // width 行的像素,从顶部开始逐行扫描——也是默认情况下每个方法所操作的系统。
还有第二种表示方式值得了解,因为某些算法在其中工作得好得多。极坐标用每个像素与所选中心点的距离,以及它与某个参考方向之间的角度来命名该像素。图像的像素并没有移动——字节仍然位于同一个行优先的缓冲区中——但寻址方案已经从"向右多远、向下多远"切换为"距离中心多远、绕中心的角度是多少"。
同一个点 P,以两种方式命名:从左上角原点出发的笛卡尔坐标 (x, y),从所选中心出发的极坐标 (r, theta)。¶
为什么要费心切换?因为有两个恒等式能把困难的搜索变成容易的搜索。
在极坐标中,将图像绕所选中心旋转,与沿角度轴平移其像素是同一种操作——即重投影图像中的 x 方向。一份旋转后的副本,就是原图在极坐标形式下向左或向右平移的结果。
在对数极坐标变体中——距离轴采用对数刻度,角度轴保持线性——将图像绕所选中心缩放,与沿距离轴平移其像素是同一种操作——即 y 方向。一份缩放后的副本,就是原图在对数极坐标形式下向上或向下平移的结果。
因此,需要在旋转或缩放下识别某个已知图案的算法,可以在极坐标空间中进行搜索,在那里两种变换都变成了普通的平移。搜索平移比搜索旋转和缩放代价要小得多,而极坐标重投影正是让这种替换成为可能的方法。
极坐标并不取代笛卡尔坐标来存储像素;字节始终存在于笛卡尔网格上。该模块提供了一对方法,可按需将图像从笛卡尔形式重投影为极坐标形式,让需要极坐标的算法完成其工作,随后要么将结果重投影回去,要么直接使用极坐标空间中的测量值。这一机制是极坐标之所以出现在该模块各处接口中的唯一原因。
有了用于命名单个像素的笛卡尔坐标、用于命名其矩形的 (x, y, w, h) 四元组,以及在算法受益时可用的极坐标,应用程序就拥有了一套完整的表述方式,用来命名某物在图像中的位置。而这些位置上实际存储的是什么,则是这一基础的下一层内容。