计算机视觉编程——图像到图像的映射

图像到图像的映射

1 单应性变换

单应性变换是将一个平面内的点映射到另一个平面内的二维投影变换。

单应性变换具有很强的实用性,比如图像配准、图像纠正和纹理扭曲,以及创建全景图像。

本质上,单应性变换H,按照下边的方程映射二维中的点(齐次坐标意义下):
在这里插入图片描述
对于图像平面内的点,齐次坐标是一个非常有用的表示方式。点的齐次坐标是依赖于其尺度定义的,所以x=[x, y, w]=[αx, αy, αw]=[x/w, y/w,1]都表示同一个二维点。因此,单应性矩阵H也仅仅依赖尺度定义,所以单应性矩阵具有9个独立的自由度。

通常使用w=1来归一化,这样点具有唯一的图像坐标x和y,这样可以简单地使用一个矩阵来表示变换。

创建homography.py文件,使用下边的函数实现对点进行归一化和转换齐次坐标的功能:

def normalize(points):
    for row in points:
        row /= points[-1]
    return points

def make_homog(points):
    return vastack((points,ones((1,points,shape[1]))))

行点和变换处理时,我们会按照列优先的原则存储这些点。因此,n个二维点集将会存储为齐次坐标意义下的一个3 *n数组。这种格式使得矩阵乘法和点的变换操作更加容易。

在这些投影的变换中,有一些特别重要的变换。比如,仿射变换可以应用于图像扭曲、相似变换可以应用于图像配准。

1.1 直接线性变换算法

单应性矩阵可以由两幅图像中对应点对计算出来。一个完全射影变换具有8个自由度,根据对应点约束,每个对应点对可以写出两个方程,分别对应于x和y坐标。因此,计算单应性矩阵H需要4个对应点对。

DLT(直接线性变换)是给定4个或者更多对应点对矩阵,来计算单应性矩阵H的算法。将单应性矩阵H作用于对应点对上,重新写出一个齐次方程Ah=0,其中A是一个具有对应点对二倍数量行数的矩阵。将这些对应点对方程的系数堆叠到一个矩阵中,可以使用奇异值分解找到H的最小二乘解。

下面是实现该算法的代码:

def H_from_points(fp,tp):
    if fp.shape != tp.shape:
        raise RuntimeError('number of points do not match')
    
    m = mean(fp[:2],axis=1)
    maxstd = max(std(fp[:2],axis=1)) + 1e-9
    C1 = diag([1/maxstd,1/maxstd,1])
    C1[0][2] = -m[0]/maxstd
    C1[1][2] = m[1]/maxstd
    fp = dot(C1,fp)

    m = mean(tp[:2],axis=1)
    maxstd = max(std(tp[:2],axis=1)) +1e-9
    
    C2 = diag([1/maxstd,1/maxstd,1])
    C2[0][2] = -m[0]/maxstd
    C2[1][2] = -m[1]/maxstd
    
    tp = dot(C2,tp)
    
    nbr_correspondences = fp.shape[1]
    A = zeros((2*nbr_correspondences,9))
    for i in range(nbr_correspondences):
        A[2*i] = [-fp[0][i],-fp[1][i],-1,0,0,0,tp[0][i]*fp[0][i],tp[0][i]*fp[1][i],tp[0][i]]
        A[2*i+1] = [0,0,0,-fp[0][i],-fp[1][i],-1,tp[1][i]*fp[0][i],tp[1][i]*fp[1][i],tp[0][i]]
    U,S,V = linalg.svd(A)
    H = V[8].reshape((3,3))
    
    H = dot(linalg.inv(C2),dot(H,C1))

    return H / H[2,2]

上面函数的第一步操作是检查点对的两个数组中点的数目是否相同。如果不相同,函数抛出异常信息。

对这些点进行归一化操作,使其均值为0,方差为1。然后使用对应点对构造矩阵A。最小二乘解即为矩阵SVD分解后所得矩阵V的最后一行。该行经过变形后得到矩阵H。然后对着矩阵进行处理和归一化,返回输出。

1.2 仿射变换

由于仿射变换具有6个自由度,因此我们需要三个对应点对来估计矩阵H。通过将最后两个元素设置为0,即h7 = h8 = 0。仿射变换可以用上面的DLT算法估计得出。

下面的函数使用对应点对来计算仿射变换矩阵:

def Haffine_from_points(fp,tp):
    if fp.shape != tp.shape:
        raise RuntimeError('number of points do not match')

    m = mean(fp[:2],axis=1)
    maxstd = max(std(fp[:2],axis=1)) + 1e-9
    C1 = diag([1/maxstd,1/maxstd,1])
    C1[0][2] = -m[0]/maxstd
    C1[1][2] = -m[1]/maxstd
    fp_cond = dot(C1,fp)
    
    m = mean(tp[:2],axis=1)
    C2 = C1.copy() 
    C2[0][2] = -m[0]/maxstd
    C2[1][2] = -m[1]/maxstd
    tp_cond = dot(C2,fp)

    A = concatenate((fp_cond[:2],tp_cond[:2]),axis=0)
    U,S,V = linalg.svd(A.T)

    tmp = V[:2].T
    B = tmp[:2]
    C = tmp[2:4]
    
    tmp2 = concatenate((dot(C,linalg.pinv(B)),zeros((2,1))),axis=1)
    H = vstack((tmp2,[0,0,1]))
    
    H = dot(linalg.inv(C2),dot(H,C1))
    return H/H[2,2]

2 图像扭曲

对图像块应用仿射变换,即为图像扭曲。这一操作可以使用Scipy工具包中的ndimage包来简单完成。命令:

transformed_im = ndimage.affine_transform(im,A,b,size)

使用一个线性变换A和一个平移向量b来对图像块应用仿射变换。size用来指定输出图像的大小。默认输出图像设置为和原始图像同样大小。

了研究该函数是如何工作的,我们可以运行下面的命令:

from PIL import Image
from pylab import *
from scipy import ndimage

im = array(Image.open('jimei2.jpg').convert('L'))
H = array([[1.4,0.05,-100],[0.05,1.5,-100],[0,0,1]])
im2 = ndimage.affine_transform(im,H[:2,:2],(H[0,2],H[1,2]))

subplot(121)
gray()
axis('off')
imshow(im)

subplot(122)
gray()
axis('off')
imshow(im2)

show()

得到的结果如下图所示,可见输出图像中丢失的像素用零来填充:
在这里插入图片描述

图1 用仿射变换扭曲图像

2.1 图像中的图像

仿射扭曲的一个简单例子是将图像或者图像的一部分放置在另一幅图像中,使得他们能够和指定的区域或者标记物对齐。

以下函数的输入参数为两幅图像和一个坐标。该坐标为将一副图像放置到第二幅图象中的角点位置:

def image_in_image(im1,im2,tp):

    m,n = im1.shape[:2]
    fp = array([[0,m,m,0],[0,0,n,n],[1,1,1,1]])

    H = Haffine_form_points(tp,fp)
    im1_t = ndimage.affine_transform(im,H[:2,:2],(H[0,2],H[1,2]),im2.shape[:2])
    alpha = (im1_t>0)
    
    return (1-alpha)*im2 + alpha*im1_t

将扭曲的图像和第二幅图像融合,就创建alpha图像。该图像定义了每个像素从各个图像中获取的像素值成分多少。这里基于以下事实:扭曲的图像是在扭曲区域边界之外以0来填充的图像,来创建一个二值的alpha图像。严格意义上,需要在第一幅图象中的潜在0像素上加上一个小的数值,或者合理的处理这些0像素。

下面几行代码会将一幅图像插入另一幅图像:

im4= array(Image.open('jimei.jpg').convert('L'))
im5 = array(Image.open('guanggaopai.jpg').convert('L'))

tp = array([[50,300,300,50],[10,10,600,550],[1,1,1,1]])
im6 = image_in_image(im4,im5,tp)
figure()
gray()
imshow(im6)
axis('equal')
axis('off')
show()

在这里插入图片描述

图2 用仿射变换将一幅图像放置到另一幅图像中

函数Haffine_from points() 会返回给定对应点对的最有仿射变换。在上面的例子中,对应点对为图像和公告牌的角点。如果透视效应比较弱,那么这种方法会返回很好的结果。

2.2 分段仿射扭曲

给定任意图像的标记点,通过将这些点进行三角剖分,然后使用仿射扭曲来扭曲每个三角形,我们可以将图像和另一幅图像的对应标记点扭曲对应。为了三角化这些点,我们经常使用狄洛克三角剖分方法。在matplotlib中有狄洛克三角剖分:

from PIL import Image
from pylab import *
import numpy as np
from scipy.spatial import Delaunay

x,y  = array(np.random.standard_normal((2,100)))
"""centers, edges, tri, neighbors = md.delaunay(x, y"""
tri = Delaunay(np.c_[x,y]).simplices

figure()

for t in tri:
    t_ext = [t[0],t[1],t[2],t[0]]
    plot(x[t_ext], y[t_ext], 'r')

plot(x,y,'*')
axis('off')
show()

在这里插入图片描述

图3 随机二维点集的狄洛克三角

现在让我们将该算法用于一个例子,在该例子中,在5*6的网格上使用30个控制点,将一幅图像扭曲到另一幅图像中的非平坦区域。

首先,我们需要写出一个用于分段仿射图像扭曲通用的扭曲函数。下面的代码实现该功能:

def pw_affine(fromim,toim,fp,tp,tri):
    im = toim_copy()

    is_color = len(fromim.shape) == 3 

    im_t = zeros(im.shape,'unit8')
    
    for t in tri:
        H = Haffine_from_points(tp[:,t],fp[:,t])
        
        if is_color:
            for col in range(fromim,shape[2]):
                im_t[:,:,col] = ndimage.affine_transform(fromim[:,:,col],H[:2,:2],(H[0,2],H[1,2]),im.shape[:2])
        else:
            im_t = ndimage.affine_transform(fromim,H[:2,:2],(H[0,2],H[1,2]),im.shape[:2])

        alpha = alpha_from_triangle(tp[:,t],im.shape[0],im.shape[1])

        im[alpha>0] = im_t[alpha>0]
    
    return im

在该代码中,我们首先见检查该图像是灰度图像还是彩色图像。如果图像为彩色图像,则对每个颜色通道进行扭曲处理。因为对于每个三角形来说,仿射变换是唯一确定的,所以我们这里使用Haffine_from_points()函数来处理。

2.3 图像配准

图像配准是对图像进行变换,使得变换后的图像能够在常见的坐标系中对齐。配准可以是严格配准,也可以是非严格配准。为了能够进行图像对比和更精细的图像分析,图像配准是一步非常重要的操作。

目前,很难找到一种普适的方法能够应对所有的配准情况,任何一种配准算法都必须考虑图像的成像原理、几何变形、噪声影响、配准精度等因素。不过,从原理上将,配准算法可以大致分为以下四个步骤:

(1)特征提取
采用人工或者自动的方法检测图像中的不变特征,如:闭合区域、边缘、轮廓、角点等。特征提取算法需要满足三个条件
(a)显著性,所提取的特征应该是比较明显的,分布广泛的、易于提取的特征;
(b)抗噪性,具有较强的噪声抑制能力且对成像条件的变化不敏感;
(c)一致性,能准确地检测出两幅图像的共有特征;

(2)特征匹配
通过特征描述算作及相似性度量来建立所提取的特征之间的对应关系。特征匹配常用到的区域灰度、特征向量空间分布和特征符号描述等信息。某些算法在进行特征匹配的同时也完成了变换模型参数的估计;

(3)变换模型估计
指根据待配准图像与参考图像之间的几何畸变的情况,选择能最佳拟合两幅图像之间变化的几何变换模型,可以分为全局映射模型和局部映射模型。其中,全局映射模型利用所有控制点信息进行全局参数估计;局部映射模型利用图像局部的特征分别进行局部参数估计。常见的变换模型包括仿射变换、透视变换、多项式变换等,其中最常用的是仿射变换和多项式变换。

(4)坐标变换与插值
将输入图像做对应的参数变换,使它与参考图像处于同一个坐标系下。由于图像变换后的坐标点不一定是整数,因此,需要考虑一定的插值处理操作。常用的插值方法包括:最近邻插值、双线性插值、双三次插值、B样条插值、高斯插值。

3 创建全景图

3.1 RANSAC

RANSAC是用来找到正确模型来拟合带有噪声数据的迭代方法。给定一个模型,例如点集之间的单应性矩阵,该算法的基本思想是数据中包含正确的点和噪声点,合理的模型能够在描述正确点的同时摒弃噪声点。

3.2 稳健的单应性矩阵估计

在使用RANSAC模块时,需要在相应的类中实现fit()和get_error()方法,剩下的就是正确使用ransac.py。

将下面的代码添加到homography.py中:

class RansacModel(object):  
    def __init__(self,debug=False):
        self.debug = debug
        
    def fit(self, data):
        data = data.T

        fp = data[:3,:4]

        tp = data[3:,:4]

        return H_from_points(fp,tp)
    
    def get_error( self, data, H):
        data = data.T

        fp = data[:3]

        tp = data[3:]
        
        fp_transformed = dot(H,fp)

        fp_transformed = normalize(fp_transformed)

        return sqrt( sum((tp-fp_transformed)**2,axis=0) )

可以看到,这个类包含fit() 方法。该方法仅仅接受由ransac.py选择的4个对应点对,然后你和一个单应性矩阵。记住,4个点对是计算单应性矩阵所需的最少数目。由于get_error()方法对于每个对应点对使用该单应性矩阵,然后返回相应的平方距离之和。因此ransac算法能够判定哪些点对是正确的,那些是错误的。在实际中,我们需要在距离上使用一个阙值来决定哪些矩阵是合理的。为了使用方便,将下面的函数添加到homography.py 文件中:

def H_from_ransac(fp,tp,model,maxiter=1000,match_theshold=10):
    import ransac

    data = vstack((fp,tp))
    
    H,ransac_data = ransac.ransac(data.T,model,4,maxiter,match_theshold,10,return_all=True)
    return H,ransac_data['inliers']

该函数同样允许提供阙值和最小期望的点对数目。最重要的参数是最大迭代次数:程序推出太早可能得到一个坏解;迭代次数太多会占用太多空间。函数的返回结果为单应性矩阵和对应该单应性矩阵的正确点对。类似于下面的操作,你可以将RANSAC算法应用于点对上:

def convert_points(j): 
    ndx = matches[j].nonzero()[0]
    fp = homography.make_homog(l[j+1][ndx,:2].T)
    ndx2 = [int(matches[j][i]) for i in ndx]
    tp = homography.make_homog(l[j][ndx2,:2].T)
    return fp,tp

model = homography.RansacModel()

fp,tp = convert_points(1)
H_12 = homography.H_from_ransac(fp,tp,model)[0] 

fp,tp = convert_points(0)
H_01 = homography.H_from_ransac(fp,tp,model)[0] 

tp,fp = convert_points(2) 
H_32 = homography.H_from_ransac(fp,tp,model)[0] 

tp,fp = convert_points(3) 
H_43 = homography.H_from_ransac(fp,tp,model)[0]

在该例子中,图像2是中心图像,也是我们希望将其他图像变成的图像。图像0和图像1应该是从右边扭曲,图像3和图像4应该是从左边扭曲。在每个图像对中,由于匹配是从最右边的图像计算出来的。所以我们将对应的顺序进行了颠倒,使其从左边图像开始扭曲。因为我们不关心该扭曲例子中的正确点对,所以仅需要该函数的第一个输出。

3.3 拼接图像

估计出图像间的单应性矩阵,现在我们需要将所有图像扭曲到一个公共的图像平面上。通常,这里的公共平面为中心图像平面。一种方法是创建一个很大的图像,比如图像中全部填充0,使其和中心图像平行,然后将所有的图像扭曲到上面。由于我们所有的图像是由照相机水平旋转拍摄的,因此我们需要一个比较简单的步骤:将中心图像左边或者右边的区域填充0,以便为扭曲的图像腾出空间。

def panorama(H,fromim,toim,padding=2400,delta=2400):
    is_color = len(fromim.shape) == 3
    
    def transf(p):
        p2 = dot(H,[p[0],p[1],1])
        return (p2[0]/p2[2],p2[1]/p2[2])
    
    if H[1,2]<0: 
        print('warp - right')
        if is_color:
            toim_t = hstack((toim,zeros((toim.shape[0],padding,3))))
            fromim_t = zeros((toim.shape[0],toim.shape[1]+padding,toim.shape[2]))
            for col in range(3):
                fromim_t[:,:,col] = ndimage.geometric_transform(fromim[:,:,col],
                                        transf,(toim.shape[0],toim.shape[1]+padding))
        else:
            toim_t = hstack((toim,zeros((toim.shape[0],padding))))
            fromim_t = ndimage.geometric_transform(fromim,transf,
                                    (toim.shape[0],toim.shape[1]+padding)) 
    else:
        print('warp - left')
        H_delta = array([[1,0,0],[0,1,-delta],[0,0,1]])
        H = dot(H,H_delta)

        if is_color:
            toim_t = hstack((zeros((toim.shape[0],padding,3)),toim))
            fromim_t = zeros((toim.shape[0],toim.shape[1]+padding,toim.shape[2]))
            for col in range(3):
                fromim_t[:,:,col] = ndimage.geometric_transform(fromim[:,:,col],
                                            transf,(toim.shape[0],toim.shape[1]+padding))
        else:
            toim_t = hstack((zeros((toim.shape[0],padding)),toim))
            fromim_t = ndimage.geometric_transform(fromim,
                                    transf,(toim.shape[0],toim.shape[1]+padding))
    
    if is_color:
        alpha = ((fromim_t[:,:,0] * fromim_t[:,:,1] * fromim_t[:,:,2] ) > 0)
        for col in range(3):
            toim_t[:,:,col] = fromim_t[:,:,col]*alpha + toim_t[:,:,col]*(1-alpha)
    else:
        alpha = (fromim_t > 0)
        toim_t = fromim_t*alpha + toim_t*(1-alpha)
    
    return toim_t

对于通用的geometric_transform()函数,我们需要指定能够描述像素到像素间映射的函数。在这个例子中,transf()函数就是该指定函数。干函数通过将像素和H相乘,然后对齐次坐标进行归一化来实现像素间的映射。通过查看H中的平移量,我们可以决定应该将图像填补到左边还是右边。当图像填补到左边时,由于目标图像中点的坐标也变化了,所以在“左边“情况中,需要在单应性矩阵中加入平移。简单起见,我们同样使用0像素的技巧来寻找alpha图。

im1 = np.array(Image.open(imname[1]))
delta = im1.shape[1] 

im2 = np.array(Image.open(imname[2]))
im_12 = warp.panorama(H_12,im1,im2,delta,delta)

im1 = np.array(Image.open(imname[0]))
im_02 = warp.panorama(np.dot(H_12,H_01),im1,im_12,delta,delta)

im1 = np.array(Image.open(imname[3]))
im_32 = warp.panorama(H_32,im1,im_02,delta,delta)

im1 = np.array(Image.open(imname[4]))
im_42 = warp.panorama(np.dot(H_32,H_43),im1,im_32,delta,2*delta)

figure()
imshow(array(im_42))
axis('off')
show()

在这里插入图片描述

图4使用SIFT对应点自动创建水平全景图像

猜你喜欢

转载自blog.csdn.net/weixin_42262128/article/details/107836773