图像坐标空间变换:仿射变换(Affine Transformation)

仿射变换(Affine Transformation)简介

一般来说,仿射变换是指将一个向量空间进行一次线性变换(linear transformation)和一次平移(translation),变换到另一个向量空间的操作。
通常仿射变换对点、线、面具有一定的保持性,这种保持性体现在:

  • 经过变换后,点还是点,线还是线,面还是面(如果不是仿射变换而是3D投影的话,在一定的视角下,面有可能变成线,线有可能变成点)
  • 经过变换后,平行线和平行面依然平行
  • 经过变换后,图形间的某些比例关系保持不变,比如两条平行线的长度比不变,点在线段中的位置比例保持不变

注意:仿射变换不能保证角度不变

仿射变换的基础类型

仿射变换的基础类型包括恒等(identity)尺度(scaling)旋转(rotation)剪切(shear)镜像(reflection)平移(translation),除了平移以外,其他都属于线性变换。线性变换通过矩阵乘法实现,平移通过向量加法实现。
以上基础变换类型主要从变换效果上进行分类,从数学上讲并不是很严格,因为存在包含关系,比如恒等变换可以理解为宽高方向放缩倍数均为1的尺度变换。不过也不必对此过分纠结,因为从效果进行分类更符合我们对图像的日常认知。

接下来我们使用公式对各种变换进行描述,公式采用两种形式进行表达:线性方程组矩阵
开始前我们需要进行符号约定(u,v)用来表示原始图像中的坐标,(x,y)用来表示变换后图像的坐标。

在矩阵乘法的表达式中,我们将坐标以行向量的形式放在左侧,将变换矩阵放在右侧。这样做的好处是使用numpy或者tensorflow这种具有向量化的矩阵乘法算子时,不必再对坐标矩阵调整通道并reshape。当然如果你不在乎这类写代码的便利性的话,那么将变换矩阵放在左侧,将坐标以列向量形式放在右侧也没问题,而且确实有一些教科书是这么做的。

恒等

{ x = u y = v [ x y ] = [ u v ] [ 1 0 0 1 ] \begin{cases} x = u \\ y = v \end{cases} \\[4ex] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} 1 & 0 \\ 0 & 1 \end{bmatrix} {x=uy=v[xy]=[uv][1001]

尺度

{ x = α ∗ u y = β ∗ v [ x y ] = [ u v ] [ α 0 0 β ] \begin{cases} x = \alpha *u \\ y = \beta *v \end{cases} \\[4ex] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} \alpha & 0 \\ 0 & \beta \end{bmatrix} {x=αuy=βv[xy]=[uv][α00β]

旋转

{ x = c o s ( θ ) ∗ u − s i n ( θ ) ∗ v y = s i n ( θ ) ∗ u + c o s ( θ ) ∗ v [ x y ] = [ u v ] [ c o s ( θ ) s i n ( θ ) − s i n ( θ ) c o s ( θ ) ] \begin{cases} x = cos(\theta)*u - sin(\theta)*v \\ y = sin(\theta)*u + cos(\theta)*v \end{cases} \\[4ex] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} cos(\theta) & sin(\theta) \\ -sin(\theta) & cos(\theta) \end{bmatrix} {x=cos(θ)usin(θ)vy=sin(θ)u+cos(θ)v[xy]=[uv][cos(θ)sin(θ)sin(θ)cos(θ)]

剪切

分为水平剪切垂直剪切,两种分开来写

水平剪切

{ x = u + s v ∗ v y = v [ x y ] = [ u v ] [ 1 0 s v 1 ] \begin{cases} x = u + s_v*v\\ y = v \end{cases} \\[4ex] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} 1 & 0 \\ s_v & 1 \end{bmatrix} {x=u+svvy=v[xy]=[uv][1sv01]

垂直剪切

{ x = u y = s u ∗ u + v [ x y ] = [ u v ] [ 1 s u 0 1 ] \begin{cases} x = u \\ y = s_u*u + v \end{cases} \\[4ex] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} 1 & s_u \\ 0 & 1 \end{bmatrix} {x=uy=suu+v[xy]=[uv][10su1]

镜像

{ x = − u y = v [ x y ] = [ u v ] [ − 1 0 0 1 ] \begin{cases} x = -u \\ y = v \end{cases} \\[4ex] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} -1 & 0 \\ 0 & 1 \end{bmatrix} {x=uy=v[xy]=[uv][1001]

平移

{ x = u + t x y = v + t y [ x y ] = [ u v ] + [ t x t y ] \begin{cases} x = u + t_x \\ y = v + t_y \end{cases} \\[4ex] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} + \begin{bmatrix} t_x & t_y \end{bmatrix} {x=u+txy=v+ty[xy]=[uv]+[txty]

我们可以乘以多个线性变换矩阵来实现各种基础线性变换类型的叠加,比如如果我们想同时实现旋转和尺度变换,就可以使用下式。实际计算时一般先把后面两个2×2矩阵乘算出来,然后再与左侧的坐标值做计算,这样我们只需要一次坐标变换就可以实现两种变换效果。(公式中我们只写了一个点的坐标,但实际操作中,我们需要对图像中 h e i g h t × w i d t h height \times width height×width个点做坐标变换,所以上述方式可以节省很多计算量)
[ x y ] = [ u v ] [ α 0 0 β ] [ c o s ( θ ) s i n ( θ ) − s i n ( θ ) c o s ( θ ) ] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} \alpha & 0 \\ 0 & \beta \end{bmatrix} \begin{bmatrix} cos(\theta) & sin(\theta) \\ -sin(\theta) & cos(\theta) \end{bmatrix} [xy]=[uv][α00β][cos(θ)sin(θ)sin(θ)cos(θ)]

仿射变换通式

我们可以将各种基础变换融合起来用一个式子表达,也就是仿射变换的通式,其线性方程组形式为:
{ x = t 11 ∗ u + t 21 ∗ v + t 31 y = t 12 ∗ u + t 22 ∗ v + t 32 \begin{cases} x = t_{11}*u + t_{21}*v + t_{31} \\ y = t_{12}*u + t_{22}*v + t_{32} \end{cases} {x=t11u+t21v+t31y=t12u+t22v+t32
可以有三种方法将其写为矩阵形式:

  • 线性变换平移分开来写,这种写法比较符合仿射变换的基础概念,但是通常我们在实际应用时不使用这种方法,因为根据这种写法我们需要做两次矩阵运算,一次乘法和一次加法,也就是说它不够简单。
    [ x y ] = [ u v ] [ t 11 t 12 t 21 t 22 ] + [ t 31 t 32 ] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v \end{bmatrix} \begin{bmatrix} t_{11} & t_{12} \\ t_{21} & t_{22} \end{bmatrix} + \begin{bmatrix} t_{31} & t_{32} \end{bmatrix} [xy]=[uv][t11t21t12t22]+[t31t32]
  • 常用的是直接根据线性方程组的形式获得一种融合式的写法。此时我们使用一个矩阵乘法就完成了线性变换平移两个运算。
    [ x y ] = [ u v 1 ] [ t 11 t 12 t 21 t 22 t 31 t 32 ] \begin{bmatrix} x & y \end{bmatrix} = \begin{bmatrix} u & v & 1 \end{bmatrix} \begin{bmatrix} t_{11} & t_{12} \\ t_{21} & t_{22} \\ t_{31} & t_{32} \end{bmatrix} [xy]=[uv1]t11t21t31t12t22t32
    当我们根据现行方程组的形式将其改写为矩阵形式时,我们很自然地就会把等号右侧两个矩阵的第一个写成 [ u v 1 ] \begin{bmatrix} u & v & 1 \end{bmatrix} [uv1],这种形式的坐标称为齐次坐标,它是大有来头的。在2D的图像仿射变换中,我们对其最直观的感受是使用这种坐标形式可以将仿射变换的通式用一个矩阵乘法完成,也就是将线性变换和平移合并了起来,这已经是很大的便利了,但是给人的感觉还是不够冲击,可能因为在仿射变换中它并非不可或缺。当我们进一步推导透视变换(Projective Transformation)时,就会发现必需要引入齐次坐标才行。另外齐次坐标也是图形学的基石之一。不过我们这里不讨论透视变换和图形学。
  • 第三种写法是第二种写法的延伸,简单来说就是将等号左侧也写为齐次坐标形式,这种写法可能是教科书中最常见的形式了,但是我个人比较推荐第二种,因为在仿射变换中,变换矩阵的第三列固定为 [ 0 0 1 ] T \begin{bmatrix} 0 & 0 & 1 \end{bmatrix} ^{T} [001]T,没有什么实际用处,并且多少浪费了点计算量。
    [ x y 1 ] = [ u v 1 ] [ t 11 t 12 0 t 21 t 22 0 t 31 t 32 1 ] \begin{bmatrix} x & y & 1 \end{bmatrix} = \begin{bmatrix} u & v & 1 \end{bmatrix} \begin{bmatrix} t_{11} & t_{12} & 0 \\ t_{21} & t_{22} & 0 \\ t_{31} & t_{32} & 1 \end{bmatrix} [xy1]=[uv1]t11t21t31t12t22t32001

例子:人脸位置对齐

接下来我们以人脸识别中的人脸位置对齐为例,看看如何求解仿射变换矩阵以及如何应用这个知识点。这一部分对知识背景做简要描述。

人脸识别是目前(2020年)为止深度学习最成功的应用之一。跟很多其他的识别任务比起来,人脸识别任务本身其实并不算难。比如给定相同数量的训练集(1000万)和测试集(100万),要求大家在测试集上的准确率达到95%才算合格,那么相比较行人重识别,或者ImageNet那种通类识别任务,人脸识别可能是最容易合格的,这就是为什么说任务本身不算难。那么其难点在哪呢,难点在于大家对人脸识别的精度要求非常高,绝不是95%的准确率就可以让人满意的,试想如果每100个人脸识别支付的订单中都有5个可能出错,那将是一场大灾难,比如某个跟你长得有点相似的路人甲在超市买了点东西,刷了一下脸,然后被识别成了你,并且从你的账户里扣了钱,那就真的悲剧了。人脸识别的准确率要达到99.999…%,小数点后要n个9才行(2019年看到过一个关于人脸识别准确率的要求:要求在某个特定的拒识率下,误识率不高于千万分之一。这个意思也就是说对于少部分非常难以识别的人脸图片,可以拒绝识别,但是一旦同意识别,就必需得识别对)。

那么在这么高的准确率的要求下,人脸识别在数据预处理上与其他的图像识别任务就有着一些区别。比如人脸识别要求进行人脸位置对齐,这样可以减少由于位置偏差而带来的误识别率。

具体而言就是在做完人脸检测后,还要进行人脸特征点回归,一般五点就够用:两个眼睛中心,鼻尖,两个嘴角。回归出五个特征点后,将其配准到我们预先定义好的五点模板上。回归出的五个特征点坐标以 ( u , v ) (u, v) (u,v)表示,五点模板坐标以 ( x , y ) (x, y) (x,y)表示,这些信息都是已知的,所以在仿射变换通式中,只有变换矩阵是未知的,此时我们可以通过最小二乘法反推出变换矩阵。然后我们再正向运用变换矩阵,将原图中的人脸通过坐标变换变到五点模板上,完成位置对齐的任务。

这里多讲一点。并不是所有的图像识别任务都可以做位置对齐,人脸识别之所以可以做位置对齐,是因为人脸图片大体上存在一种固定的模式:眉毛、眼睛、鼻子和嘴巴的相对位置关系不会发生非常显著的变化(或者可以反过来理解,眼睛不会跑到嘴巴和鼻子中间去)。

一个需要小心的坑:图像索引与坐标的关系

在推导公式的时候,我们通常不需要引入图像索引这个概念,也就是我们常用的(i, j),但是写代码的时候就必然得用到了,这个时候需要特别小心图像索引与坐标的对应关系。图像索引中我们常常使用i来表示行,用j来表示列,此时需注意以下对应关系:

i --> 行 --> y
j --> 列 --> x

所以像素索引为(i, j)的点的坐标为(y, x)。这个对应关系如果搞错了的话,可能会发生莫名其妙的错误,比如明明我们想要顺时针旋转图片,结果却是逆时针旋转。

数据准备

我们利用opencv写一个非常简单的交互程序来获取图像中某些像素的坐标:

import cv2

WIN_NAME = 'pick_points'
IMAGE_FILE = 'test.png'

def pick_points(event, x, y, flags, param):
    if event == cv2.EVENT_LBUTTONDOWN:
        print('x = %d, y = %d' % (x, y))


cv2.namedWindow(WIN_NAME, 0)
cv2.setMouseCallback(WIN_NAME, pick_points)
image = cv2.imread(IMAGE_FILE, cv2.IMREAD_UNCHANGED)
while True:
    cv2.imshow(WIN_NAME, image)
    key = cv2.waitKey(30)
    if key == 27:  # ESC
        break
cv2.destroyAllWindows()

你可能需要修改程序中图片文件的路径,注意路径中不要包含中文字符。运行程序后,左键点击图片可以获得当前鼠标位置的坐标,通过print函数将坐标打印出来。

本来这里应该用一张人脸图片的,不过我怕随意使用别人的图片侵犯别人肖像权,但是又不想用自己的大头照,所以思来想去搞了一张大猩猩的脸。图片在我本地是550 x 333(宽 x 高)的分辨率,不知道上传后会不会发生变化,如果变了的话,可以使用上述代码自己再拾取一下坐标。
在这里插入图片描述

拾取坐标的顺序为:左眼,右眼,鼻尖,左嘴角,右嘴角。注意这里的左右以我们看到的图片为基准,不用去想着图片里的对象的真实左右是什么,因为有些相机在拍摄的时候做过镜像,所以真实的左右很难讲明白。我拾取到的坐标为(还记得我们之前的符号约定,原图用u, v表示坐标):

u1 = 288, v1 = 115
u2 = 334, v2 = 119
u3 = 278, v3 = 160
u4 = 250, v4 = 193
u5 = 318, v5 = 192

做人脸对齐时,必须有一个已知的人脸五点模板。然而现在我们正在做大猩猩脸对齐,假设有一个大猩猩脸的五点模板(模板尺寸是100 x 100)为:

x1 = 31.3, y1 = 45.2
x2 = 67.8, y2 = 45.2
x3 = 49.5, y3 = 68.7
x4 = 35.2, y4 = 84.8
x5 = 62.6, y5 = 84.8

这个模板随便搞的,毕竟是大猩猩脸,没那么数据来算模板。

求解仿射变换矩阵

前向映射

将五组对应的点代入仿射变换通式可得:
{ x 1 = t 11 ∗ u 1 + t 21 ∗ v 1 + t 31 ∗ 1 y 1 = t 12 ∗ u 1 + t 22 ∗ v 1 + t 32 ∗ 1 x 2 = t 11 ∗ u 2 + t 21 ∗ v 2 + t 31 ∗ 1 y 2 = t 12 ∗ u 2 + t 22 ∗ v 2 + t 32 ∗ 1 . . . . . . x 5 = t 11 ∗ u 5 + t 21 ∗ v 5 + t 31 ∗ 1 y 5 = t 12 ∗ u 5 + t 22 ∗ v 5 + t 32 ∗ 1 \begin{cases} x_1 = t_{11}*u_1 + t_{21}*v_1 + t_{31}*1 \\ y_1 = t_{12}*u_1 + t_{22}*v_1 + t_{32}*1 \\ x_2 = t_{11}*u_2 + t_{21}*v_2 + t_{31}*1 \\ y_2 = t_{12}*u_2 + t_{22}*v_2 + t_{32}*1 \\ ...... \\ x_5 = t_{11}*u_5 + t_{21}*v_5 + t_{31}*1 \\ y_5 = t_{12}*u_5 + t_{22}*v_5 + t_{32}*1 \end{cases} x1=t11u1+t21v1+t311y1=t12u1+t22v1+t321x2=t11u2+t21v2+t311y2=t12u2+t22v2+t321......x5=t11u5+t21v5+t311y5=t12u5+t22v5+t321
上式中, u , v , x , y u, v, x, y u,v,x,y都是已知量,待求解的是 t i j t_{ij} tij,另外注意到 t 11 , t 21 , t 31 t_{11}, t_{21}, t_{31} t11,t21,t31只与 x x x相关, t 12 , t 22 , t 32 t_{12}, t_{22}, t_{32} t12,t22,t32只与 y y y相关。所以上述线性方程组可以写成两组:
{ x 1 = t 11 ∗ u 1 + t 21 ∗ v 1 + t 31 ∗ 1 x 2 = t 11 ∗ u 2 + t 21 ∗ v 2 + t 31 ∗ 1 . . . . . . x 5 = t 11 ∗ u 5 + t 21 ∗ v 5 + t 31 ∗ 1 \begin{cases} x_1 = t_{11}*u_1 + t_{21}*v_1 + t_{31}*1 \\ x_2 = t_{11}*u_2 + t_{21}*v_2 + t_{31}*1 \\ ...... \\ x_5 = t_{11}*u_5 + t_{21}*v_5 + t_{31}*1 \\ \end{cases} x1=t11u1+t21v1+t311x2=t11u2+t21v2+t311......x5=t11u5+t21v5+t311
{ y 1 = t 12 ∗ u 1 + t 22 ∗ v 1 + t 32 ∗ 1 y 2 = t 12 ∗ u 2 + t 22 ∗ v 2 + t 32 ∗ 1 . . . . . . y 5 = t 12 ∗ u 5 + t 22 ∗ v 5 + t 32 ∗ 1 \begin{cases} y_1 = t_{12}*u_1 + t_{22}*v_1 + t_{32}*1 \\ y_2 = t_{12}*u_2 + t_{22}*v_2 + t_{32}*1 \\ ...... \\ y_5 = t_{12}*u_5 + t_{22}*v_5 + t_{32}*1 \end{cases} y1=t12u1+t22v1+t321y2=t12u2+t22v2+t321......y5=t12u5+t22v5+t321
记:
T 1 = [ t 11 , t 21 , t 31 ] T T 2 = [ t 12 , t 22 , t 32 ] T T_1 = \begin{bmatrix} t_{11}, t_{21}, t_{31} \end{bmatrix}^T \\ T_2 = \begin{bmatrix} t_{12}, t_{22}, t_{32} \end{bmatrix}^T T1=[t11,t21,t31]TT2=[t12,t22,t32]T
X = [ x 1 , x 2 , . . . , x 5 ] T Y = [ y 1 , y 2 , . . . , y 5 ] T X = \begin{bmatrix}x_1, x_2,..., x_5\end{bmatrix}^T \\ Y = \begin{bmatrix}y_1, y_2,..., y_5\end{bmatrix}^T X=[x1,x2,...,x5]TY=[y1,y2,...,y5]T
A = [ u 1 v 1 1 u 2 v 2 1 . . . . . . u 5 v 5 1 ] A = \begin{bmatrix} u_1 & v_1 & 1 \\ u_2 & v_2 & 1 \\ ...... \\ u_5 & v_5 & 1 \end{bmatrix} A=u1u2......u5v1v2v5111
那么上面两个线性方程组可写为: A T 1 = X AT_1=X AT1=X A T 2 = Y AT_2=Y AT2=Y。现在 A , X , Y A, X, Y A,X,Y是已知的,分别求出 T 1 , T 2 T_1, T_2 T1,T2即可。这两个线性方程组是超定方程组,即方程数目多于未知量数目,所以方程组没有精确解,只有最小二乘解。
下面以 A T 1 = X AT_1=X AT1=X为例推导最小二乘解:
A T 1 = X A T A T 1 = A T X ( A T A ) − 1 A T A T 1 = ( A T A ) − 1 A T X T 1 = ( A T A ) − 1 A T X AT_1 = X \\[2ex] A^TAT_1 = A^TX \\[2ex] (A^TA)^{-1}A^TAT_1 = (A^TA)^{-1}A^TX \\[2ex] T_1 = (A^TA)^{-1}A^TX AT1=XATAT1=ATX(ATA)1ATAT1=(ATA)1ATXT1=(ATA)1ATX
简单解释上面的推导过程:

  • 两边同乘以 A T A^T AT可以将方程组变为正定,即现在的系数矩阵 A T A A^TA ATA是一个方阵
  • 两边再同乘以 A T A A^TA ATA的逆,此时左侧的系数矩阵 ( A T A ) − 1 A T A (A^TA)^{-1}A^TA (ATA)1ATA变为单位阵,等式右侧就是 T 1 T_1 T1的解

同理可得 T 2 = ( A T A ) − 1 A T Y T_2 = (A^TA)^{-1}A^TY T2=(ATA)1ATY

以上求解过程从原始图像的坐标 ( u , v ) (u, v) (u,v)出发,经过矩阵变换,得到变换后的坐标 ( x , y ) (x, y) (x,y),该过程称为前向映射,前向映射中 ( u , v ) (u, v) (u,v)是整数, ( x , y ) (x, y) (x,y)一般是浮点数。前向映射是比较容易理解的求解方法,但是实际上在真正的计算过程中不使用前向映射,而是使用后向映射。原因是坐标变换后的值通常是浮点数,而图像只记录整数坐标位置上的像素值,所以坐标变换后一般要接上一个插值的流程,而前向映射的计算结果对插值不友好,后向映射则顺滑得多。

后向映射

后向映射从变换后图像的坐标 ( x , y ) (x, y) (x,y)出发,经矩阵变换,得到其在原始图像中对应的坐标 ( u , v ) (u, v) (u,v),后向映射中 ( x , y ) (x, y) (x,y)是整数, ( u , v ) (u, v) (u,v)一般是浮点数。

后向映射与前向映射的推导基本雷同,只需将 ( x , y ) (x, y) (x,y) ( u , v ) (u, v) (u,v)的位置调换一下就可以,下面罗列一下推导流程。
将五组对应的点代入通式:
{ u = t 11 ∗ x + t 21 ∗ y + t 31 v = t 12 ∗ x + t 22 ∗ y + t 32 \begin{cases} u = t_{11}*x + t_{21}*y + t_{31} \\ v = t_{12}*x + t_{22}*y + t_{32} \end{cases} {u=t11x+t21y+t31v=t12x+t22y+t32
可得:
{ u 1 = t 11 ∗ x 1 + t 21 ∗ y 1 + t 31 ∗ 1 u 2 = t 11 ∗ x 2 + t 21 ∗ y 2 + t 31 ∗ 1 . . . . . . u 5 = t 11 ∗ x 5 + t 21 ∗ y 5 + t 31 ∗ 1 \begin{cases} u_1 = t_{11}*x_1 + t_{21}*y_1 + t_{31}*1 \\ u_2 = t_{11}*x_2 + t_{21}*y_2 + t_{31}*1 \\ ...... \\ u_5 = t_{11}*x_5 + t_{21}*y_5 + t_{31}*1 \\ \end{cases} u1=t11x1+t21y1+t311u2=t11x2+t21y2+t311......u5=t11x5+t21y5+t311
{ v 1 = t 12 ∗ x 1 + t 22 ∗ y 1 + t 32 ∗ 1 v 2 = t 12 ∗ x 2 + t 22 ∗ y 2 + t 32 ∗ 1 . . . . . . v 5 = t 12 ∗ x 5 + t 22 ∗ y 5 + t 32 ∗ 1 \begin{cases} v_1 = t_{12}*x_1 + t_{22}*y_1 + t_{32}*1 \\ v_2 = t_{12}*x_2 + t_{22}*y_2 + t_{32}*1 \\ ...... \\ v_5 = t_{12}*x_5 + t_{22}*y_5 + t_{32}*1 \end{cases} v1=t12x1+t22y1+t321v2=t12x2+t22y2+t321......v5=t12x5+t22y5+t321
记:
T 1 = [ t 11 , t 21 , t 31 ] T T 2 = [ t 12 , t 22 , t 32 ] T T_1 = \begin{bmatrix} t_{11}, t_{21}, t_{31} \end{bmatrix}^T \\ T_2 = \begin{bmatrix} t_{12}, t_{22}, t_{32} \end{bmatrix}^T T1=[t11,t21,t31]TT2=[t12,t22,t32]T
U = [ u 1 , u 2 , . . . , u 5 ] T V = [ v 1 , v 2 , . . . , v 5 ] T U = \begin{bmatrix}u_1, u_2,..., u_5\end{bmatrix}^T \\ V = \begin{bmatrix}v_1, v_2,..., v_5\end{bmatrix}^T U=[u1,u2,...,u5]TV=[v1,v2,...,v5]T
A = [ x 1 y 1 1 x 2 y 2 1 . . . . . . x 5 y 5 1 ] A = \begin{bmatrix} x_1 & y_1 & 1 \\ x_2 & y_2 & 1 \\ ...... \\ x_5 & y_5 & 1 \end{bmatrix} A=x1x2......x5y1y2y5111
那么方程组的解为:
T 1 = ( A T A ) − 1 A T U T 2 = ( A T A ) − 1 A T V T_1 = (A^TA)^{-1}A^TU \\[2ex] T_2 = (A^TA)^{-1}A^TV T1=(ATA)1ATUT2=(ATA)1ATV
记: A ˉ = ( A T A ) − 1 A T \bar{A}= (A^TA)^{-1}A^T Aˉ=(ATA)1AT,先把 A ˉ \bar{A} Aˉ计算出来可以节省一些计算量,此时有:
T 1 = A ˉ U T 2 = A ˉ V T_1 = \bar{A}U \\[2ex] T_2 = \bar{A}V T1=AˉUT2=AˉV

后向映射代码实现

请注意,本文档的主要内容是介绍仿射变换,但是如果想要看到仿射变换后的图片,坐标变换后还需要接一个插值的步骤,工程中一般用双线性插值会多一些,不过插值不是这里的重点,所以图个简单省事使用了最邻近插值方法,插值函数为nearest_sampler
如果对插值有额外兴趣的话,可以参考下文:
图像插值:最邻近(nearest)与双线性(bilinear)

# -*- coding: utf-8 -*-

import cv2
import numpy as np


def nearest_sampler(image, coords):
    """
    Nearest Neighbour sampler
    
    Parameters
    ----------
    image: ndarray
        source image, whose shape is [height, width, channels]
    coords: ndarray
        coordinates to be interpolated, the length of last axis should be 2,
        meaning 2D coordinate
    
    Returns
    -------
    output: ndarray
        the interpolated image, same shape as coords except the last axis
    """
    height, width, channels = image.shape[0:3]
    output_shape = list(coords.shape)
    coords = np.reshape(coords, (-1, output_shape[-1]))
    output_shape[-1] = channels
    coords = np.round(coords).astype(np.int32)
    idx = (coords[:, 0] >= 0) & (coords[:, 0] < width) & \
          (coords[:, 1] >= 0) & (coords[:, 1] < height)
    output = np.zeros((coords.shape[0], channels), dtype=np.uint8)
    output[idx] = image[coords[idx, 1], coords[idx, 0], :]
    output = np.reshape(output, output_shape)
    return output


if __name__ == '__main__':
    # feature points
    uv = np.array([[288, 115],
                   [334, 119],
                   [278, 160],
                   [250, 193],
                   [318, 192]])
    # model points
    xy = np.array([[31.3, 45.2],
                   [67.8, 45.2],
                   [49.5, 68.7],
                   [35.2, 84.8],
                   [62.6, 84.8]])

    # compute transform matrix
    U = np.reshape(uv[:, 0], [uv.shape[0], 1])
    V = np.reshape(uv[:, 1], [uv.shape[0], 1])
    A = np.zeros((xy.shape[0], 3))
    A[:, 0:2] = xy
    A[:, 2] = 1
    A_bar = np.linalg.inv(A.T @ A) @ A.T
    T1 = A_bar @ U
    T2 = A_bar @ V
    T = np.concatenate((T1, T2), axis=1)

    # coordinates mapping
    height = 100
    width = 100
    coords = np.meshgrid(np.arange(0, width), np.arange(0, height))
    # shape = [height, width, 2] after transpose
    coords = np.array(coords).transpose([1, 2, 0])
    ones = np.ones([height, width, 1])
    # homogeneous  coordinates
    coords = np.concatenate((coords, ones), axis=2)
    # transformed coordinates
    coords = coords @ T

    # sampler
    image = cv2.imread('2.jpg')
    img = nearest_sampler(image, coords)

    cv2.imshow('affine', img)
    cv2.waitKey(0)
    cv2.destroyAllWindows()

大猩猩脸对齐的结果为:
在这里插入图片描述
这个脸看起来有一些变形,追溯回基础变换类型的话有两个因素导致了变形:

  • 宽高方向的缩放尺度不一样;
  • 剪切变换。

在实际的人脸位置对齐中,一般不希望引入这种变形,我们通常希望宽高方向的缩放尺度是一样的,并且不希望有剪切变换存在。所以就需要对仿射变换加一些限制,下面一部分就介绍这种特殊的仿射变换矩阵的求解方法。

一种特殊的仿射变换及变换矩阵求解方法

这一节介绍的放射变换为了解决之前的问题,要求:

  • 宽高方向的缩放尺度一样
  • 不引入剪切变换

请注意,人脸位置对齐时镜像变换也不会被引入,所以事实上就剩下了三种基础变换类型:

  • 旋转
  • 尺度(宽高方向缩放因子相同)
  • 平移

所以变换过程可以表示为(直接推导后向映射过程):
[ u c ] = [ x y ] [ s 0 0 s ] [ c o s ( θ ) s i n ( θ ) − s i n ( θ ) c o s ( θ ) ] + [ t u t v ] \begin{bmatrix} u & c \end{bmatrix} = \begin{bmatrix} x & y \end{bmatrix} \begin{bmatrix} s & 0 \\ 0 &s \end{bmatrix} \begin{bmatrix} cos(\theta) & sin(\theta) \\ -sin(\theta) & cos(\theta) \end{bmatrix} + \begin{bmatrix} t_u & t_v \end{bmatrix} [uc]=[xy][s00s][cos(θ)sin(θ)sin(θ)cos(θ)]+[tutv]
将上述式子合并,变为:
[ u v ] = [ x y 1 ] [ s ∗ c o s ( θ ) s ∗ s i n ( θ ) − s ∗ s i n ( θ ) s ∗ c o s ( θ ) t u t v ] \begin{bmatrix} u & v \end{bmatrix} = \begin{bmatrix} x & y & 1 \end{bmatrix} \begin{bmatrix} s*cos(\theta) & s*sin(\theta) \\ -s*sin(\theta) & s*cos(\theta) \\ t_u & t_v \end{bmatrix} [uv]=[xy1]scos(θ)ssin(θ)tussin(θ)scos(θ)tv
现在记:
a = s ∗ c o s ( θ ) b = s ∗ s i n ( θ ) c = t u d = t v a = s*cos(\theta) \\[2ex] b = s*sin(\theta) \\[2ex] c = t_u \\[2ex] d = t_v a=scos(θ)b=ssin(θ)c=tud=tv
那么仿射变换可以写为:
[ u v ] = [ x y 1 ] [ a b − b a c d ] \begin{bmatrix} u & v \end{bmatrix} = \begin{bmatrix} x & y & 1 \end{bmatrix} \begin{bmatrix} a & b \\ -b & a \\ c & d \end{bmatrix} [uv]=[xy1]abcbad
接下来我们求解这个变换矩阵。类似之前的过程,我们将五组点代入上述线性方程组可得(此处不再推导前向映射过程,直接写后向映射过程):
{ u 1 = a ∗ x 1 − b ∗ y 1 + c ∗ 1 + d ∗ 0 u 2 = a ∗ x 2 − b ∗ y 2 + c ∗ 1 + d ∗ 0 . . . u 5 = a ∗ x 5 − b ∗ y 5 + c ∗ 1 + d ∗ 0 v 1 = a ∗ y 1 + b ∗ x 1 + c ∗ 0 + d ∗ 1 v 2 = a ∗ y 2 + b ∗ x 2 + c ∗ 0 + d ∗ 1 . . . v 5 = a ∗ y 5 + b ∗ x 5 + c ∗ 0 + d ∗ 1 \begin{cases} u_1 = a*x_1 - b*y_1 +c*1 + d*0 \\ u_2 = a*x_2 -b*y_2 + c*1 + d*0 \\ ... \\ u_5 = a*x_5 -b*y_5 + c*1 + d*0 \\[2ex] v_1 = a*y_1 + b*x_1 + c*0 + d*1 \\ v_2 = a*y_2 + b*x_2 + c*0 + d*1 \\ ... \\ v_5 = a*y_5 + b*x_5 + c*0 + d*1 \end{cases} u1=ax1by1+c1+d0u2=ax2by2+c1+d0...u5=ax5by5+c1+d0v1=ay1+bx1+c0+d1v2=ay2+bx2+c0+d1...v5=ay5+bx5+c0+d1
因为变换矩阵的第一列和第二列存在共同变量,也就是变量耦合,所以没法再像之前一样分开两个方程组分别求解第一列和第二列。这里通过一次最小二乘求解得到变换矩阵中的所有4个参数。
记:
A = [ x 1 − y 1 1 0 x 2 − y 2 1 0 . . . x 5 − y 5 1 0 y 1 x 1 0 1 y 2 x 2 0 1 . . . y 5 x 5 0 1 ] A = \begin{bmatrix} x_1 & -y_1 & 1 & 0 \\ x_2 & -y_2 & 1 & 0 \\ ... \\ x_5 & -y_5 & 1 & 0 \\[2ex] y_1 & x_1 & 0 & 1 \\ y_2 & x_2 & 0 & 1 \\ ... \\ y_5 & x_5 & 0 & 1 \\ \end{bmatrix} A=x1x2...x5y1y2...y5y1y2y5x1x2x5111000000111
B = [ u 1 u 2 . . . u 5 v 1 v 2 . . . v 5 ] T B = \begin{bmatrix} u_1 & u_2 & ... & u_5 &v_1 & v_2 & ... & v_5 \end{bmatrix}^T B=[u1u2...u5v1v2...v5]T
T ˉ = [ a b c d ] T \bar{T} = \begin{bmatrix} a & b & c & d \end{bmatrix}^T Tˉ=[abcd]T
那么有:
T ˉ = ( A T A ) − 1 A T B \bar{T} = (A^TA)^{-1}A^TB Tˉ=(ATA)1ATB
有了 T ˉ \bar{T} Tˉ以后我们重新排列一下元素的形状和顺序就可以得到真正的变换矩阵。下面是代码示例,除了compute transform matrix部分之外,其它与上面的代码一样,还是用上面大猩猩脸的数据。

# -*- coding: utf-8 -*-

import cv2
import numpy as np


def nearest_sampler(image, coords):
    """
    Nearest Neighbour sampler
    
    Parameters
    ----------
    image: ndarray
        source image, whose shape is [height, width, channels]
    coords: ndarray
        coordinates to be interpolated, the length of last axis should be 2,
        meaning 2D coordinate
    
    Returns
    -------
    output: ndarray
        the interpolated image, same shape as coords except the last axis
    """
    height, width, channels = image.shape[0:3]
    output_shape = list(coords.shape)
    coords = np.reshape(coords, (-1, output_shape[-1]))
    output_shape[-1] = channels
    coords = np.round(coords).astype(np.int32)
    idx = (coords[:, 0] >= 0) & (coords[:, 0] < width) & \
          (coords[:, 1] >= 0) & (coords[:, 1] < height)
    output = np.zeros((coords.shape[0], channels), dtype=np.uint8)
    output[idx] = image[coords[idx, 1], coords[idx, 0], :]
    output = np.reshape(output, output_shape)
    return output


if __name__ == '__main__':
    # feature points
    uv = np.array([[288, 115],
                   [334, 119],
                   [278, 160],
                   [250, 193],
                   [318, 192]])
    # model points
    xy = np.array([[31.3, 45.2],
                   [67.8, 45.2],
                   [49.5, 68.7],
                   [35.2, 84.8],
                   [62.6, 84.8]])

    # compute transform matrix
    num = xy.shape[0]
    A = np.zeros((2 * num, 4))
    A[0:num, 0] = xy[:, 0]
    A[0:num, 1] = -xy[:, 1]
    A[0:num, 2] = 1
    A[num:2 * num, 0] = xy[:, 1]
    A[num:2 * num, 1] = xy[:, 0]
    A[num:2 * num, 3] = 1
    B = np.reshape(uv.T, [2 * num, 1])
    T_bar = np.linalg.inv(A.T @ A) @ A.T @ B
    T = np.zeros((3, 2))
    T[0, 0] = T_bar[0]
    T[0, 1] = T_bar[1]
    T[1, 0] = -T_bar[1]
    T[1, 1] = T_bar[0]
    T[2, 0] = T_bar[2]
    T[2, 1] = T_bar[3]

    # coordinates mapping
    height = 100
    width = 100
    coords = np.meshgrid(np.arange(0, width), np.arange(0, height))
    # shape = [height, width, 2] after transpose
    coords = np.array(coords).transpose([1, 2, 0])
    ones = np.ones([height, width, 1])
    # homogeneous  coordinates
    coords = np.concatenate((coords, ones), axis=2)
    # transformed coordinates
    coords = coords @ T

    # sampler
    image = cv2.imread('2.jpg')
    img = nearest_sampler(image, coords)

    cv2.imshow('affine', img)
    cv2.waitKey(0)
    cv2.destroyAllWindows()

得到的结果是:
在这里插入图片描述
再把之前得到的带有变形的结果放在这里,各位可以对比一下:
在这里插入图片描述
下面是我直接用软件旋转原图并裁减后得到的结果,因为是随便手动裁减的,所以位置跟程序算出来的肯定不一样,各位主要看一下这两种仿射变换并跟原图比较,观察一下第一种更加通用的仿射变换带来的变形效果:
在这里插入图片描述
从图片拍摄的角度来看,导致这种变形效果的原因是脸比较侧,假如脸比较正的话,即使使用第一种仿射变换也不会导致太大的变形,甚至歪个脖子也不会。

又一个坑:前向映射与后向映射的变换矩阵不互逆

有些同学写程序时图个概念上的方便,因为教科书上通常教的都是前向映射,所以写程序时也按照前向映射进行求解,求出前向映射的变换矩阵后,通过求逆来得到后向映射的变换矩阵,请注意这种做法是错误的,两者一般不互逆。为什么说一般呢,有一种情况互逆关系可以成立:求解的线性方程组为正定,也就是只有三组不线性相关的特征点,这种情况在实践中不是太多见。

下面我们用程序进行验证。请注意,上面我们求解坐标变换时使用的是3 x 2的矩阵,如果要验证互逆性的话,需要拼个 [ 0 0 1 ] T \begin{bmatrix} 0 & 0 & 1 \end{bmatrix}^T [001]T组成一个3 x 3的矩阵。

# -*- coding: utf-8 -*-

import numpy as np

# feature points
uv = np.array([[288, 115],
               [334, 119],
               [278, 160],
               [250, 193],
               [318, 192]])
# model points
xy = np.array([[31.3, 45.2],
               [67.8, 45.2],
               [49.5, 68.7],
               [35.2, 84.8],
               [62.6, 84.8]])

# compute backward transform matrix
U = np.reshape(uv[:, 0], [uv.shape[0], 1])
V = np.reshape(uv[:, 1], [uv.shape[0], 1])
A = np.zeros((xy.shape[0], 3))
A[:, 0:2] = xy
A[:, 2] = 1
A_bar = np.linalg.inv(A.T @ A) @ A.T
T1 = A_bar @ U
T2 = A_bar @ V
T = np.concatenate((T1, T2), axis=1)
T_backward = np.concatenate((T, np.array([[0], [0], [1]])), axis=1)

# compute forward transform matrix
X = np.reshape(xy[:, 0], [xy.shape[0], 1])
Y = np.reshape(xy[:, 1], [xy.shape[0], 1])
A = np.zeros((xy.shape[0], 3))
A[:, 0:2] = uv
A[:, 2] = 1
A_bar = np.linalg.inv(A.T @ A) @ A.T
T1 = A_bar @ X
T2 = A_bar @ Y
T_forward = np.concatenate((T1, T2, np.array([[0], [0], [1]])), axis=1)

# verification
T_forward_inv = np.linalg.inv(T_forward)
T_mul = T_forward @ T_backward

上面求到的几个矩阵为:
T f o r w a r d = [ 0.488518 − 0.0123897 0 0.177793 0.51996 0 − 121.849 − 11.6322 1 ] T b a c k w a r d = [ 1.69603 0.0564699 0 − 0.68687 1.90407 0 255.175 27.8433 1 ] T f o r w a r d _ i n v = [ 2.02941 0.048357 0 − 0.69393 1.90669 0 239.21 28.0712 1 ] T m u l = [ 0.837049 0.00399568 0 − 0.0556026 1.00008 0 56.5053 − 1.18607 1 ] T_{forward} = \begin{bmatrix} 0.488518 & -0.0123897 & 0 \\ 0.177793 & 0.51996 & 0 \\ -121.849 & -11.6322 & 1 \end{bmatrix} \\[2ex] T_{backward} = \begin{bmatrix} 1.69603 & 0.0564699 & 0 \\ -0.68687 & 1.90407 & 0 \\ 255.175 & 27.8433 & 1 \end{bmatrix} \\[2ex] T_{forward\_inv} = \begin{bmatrix} 2.02941 & 0.048357 & 0 \\ -0.69393 & 1.90669 & 0 \\ 239.21 & 28.0712 & 1 \end{bmatrix} \\[2ex] T_{mul} = \begin{bmatrix} 0.837049 & 0.00399568 & 0 \\ -0.0556026 & 1.00008 & 0 \\ 56.5053 & -1.18607 & 1 \end{bmatrix} Tforward=0.4885180.177793121.8490.01238970.5199611.6322001Tbackward=1.696030.68687255.1750.05646991.9040727.8433001Tforward_inv=2.029410.69393239.210.0483571.9066928.0712001Tmul=0.8370490.055602656.50530.003995681.000081.18607001
可以看到, T f o r w a r d _ i n v T_{forward\_inv} Tforward_inv T b a c k w a r d T_{backward} Tbackward虽然有点相近,但是毕竟还是差了点意思,另外 T f o r w a r d T_{forward} Tforward T b a c k w a r d T_{backward} Tbackward的乘积 T m u l T_{mul} Tmul也不是单位阵,所以两者不互逆。

  • 73
    点赞
  • 106
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
基于MATLAB GUI图像空间变换仿射变换),可以使用以下步骤来实现: 步骤1:创建MATLAB GUI窗口,包括图像显示区域和仿射变换参数调整区域。 步骤2:导入待处理的图像文件,并将其显示在图像显示区域。 步骤3:在仿射变换参数调整区域中,提供用户可调整的参数,如平移量、缩放比例和旋转角度等。用户可以通过滑动条或输入框来调整参数。 步骤4:编写MATLAB代码来实现仿射变换。使用imwarp函数可以对图像进行仿射变换。根据用户调整的参数,可以计算仿射变换矩阵,并将其应用于原始图像。通过imwarp函数将变换后的图像显示在图像显示区域中。 步骤5:为了使用户能够实时观察变换效果,需要在参数调整过程中实时更新变换后的图像。可以使用回调函数来响应参数调整事件,根据最新的参数值重新计算仿射变换矩阵,并将其应用于原始图像。 下面是一个简单的MATLAB GUI图像仿射变换代码示例: ```matlab function gui_affine_transformation() % 创建GUI窗口 fig = figure('Name', 'Affine Transformation', 'Position', [100, 100, 500, 400]); % 创建图像显示区域 img_axes = axes('Parent', fig, 'Units', 'normalized', 'Position', [0.1, 0.3, 0.8, 0.6]); % 创建仿射变换参数调整区域 uicontrol('Style', 'text', 'String', 'Translation', 'Position', [50, 250, 100, 20]); translation_slider = uicontrol('Style', 'slider', 'Min', -100, 'Max', 100, 'Value', 0, 'Position', [150, 250, 200, 20]); uicontrol('Style', 'text', 'String', 'Scale', 'Position', [50, 200, 100, 20]); scale_slider = uicontrol('Style', 'slider', 'Min', 0, 'Max', 2, 'Value', 1, 'Position', [150, 200, 200, 20]); uicontrol('Style', 'text', 'String', 'Rotation', 'Position', [50, 150, 100, 20]); rotation_slider = uicontrol('Style', 'slider', 'Min', -180, 'Max', 180, 'Value', 0, 'Position', [150, 150, 200, 20]); % 导入图像文件 img = imread('lena.jpg'); % 显示原始图像 imshow(img, 'Parent', img_axes); % 创建参数调整事件回调函数 set([translation_slider, scale_slider, rotation_slider], 'Callback', {@update_affine_transformation, img, img_axes}); function update_affine_transformation(~, ~, img, img_axes) % 获取最新参数值 translation = get(translation_slider, 'Value'); scale = get(scale_slider, 'Value'); rotation = get(rotation_slider, 'Value'); % 计算仿射变换矩阵 tform = affine2d([scale*cosd(rotation) -scale*sind(rotation) 0; scale*sind(rotation) scale*cosd(rotation) 0; translation translation 1]); % 应用仿射变换 transformed_img = imwarp(img, tform); % 显示变换后的图像 imshow(transformed_img, 'Parent', img_axes); end end ``` 这个代码示例创建了一个名为"Affine Transformation"的GUI窗口,并在图像显示区域显示了一个名为"lena.jpg"的图像。用户可以通过滑动条来调整平移量、缩放比例和旋转角度。在参数调整过程中,实时更新变换后的图像。 以上就是基于MATLAB GUI图像空间变换仿射变换)的300字中文回答,包含了MATLAB的源码示例。希望对您有帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值