目录
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 图像扭曲
使用一个线性变换A和一个平移向量b来对图像块应用仿射变换。size用来指定输出图像的大小。默认输出图像设置为和原始图像同样大小。
了研究该函数是如何工作的,我们可以运行下面的命令:
im = array(Image.open('1.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()
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_from_points(tp,fp)
im1_t=ndimage.affine_transform(im1,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像素。
下面几行代码会将一幅图像插入另一幅图像:
im2 = array(Image.open('1.jpg').convert('L'))
im1 = array(Image.open('11.jpg').convert('L'))
tp=array([[264,538,540,264],[40,36,605,605],[1,1,1,1]])
im3=homograhpy.image_in_image(im1,im2,tp)
subplot(221)
gray()
axis('off')
imshow(im1)
subplot(222)
gray()
axis('off')
imshow(im2)
subplot(223)
gray()
axis('off')
imshow(im3)
函数Haffine_from points() 会返回给定对应点对的最有仿射变换。在上面的例子中,对应点对为图像和公告牌的角点。如果透视效应比较弱,那么这种方法会返回很好的结果。
2.2 分段仿射扭曲
给定任意图像的标记点,通过将这些点进行三角剖分,然后使用仿射扭曲来扭曲每个三角形,我们可以将图像和另一幅图像的对应标记点扭曲对应。为了三角化这些点,我们经常使用狄洛克三角剖分方法。在matplotlib中有狄洛克三角剖分:
x,y = array(random.standard_normal((2,100)))
centers,edges,tri ,neighbors=md.delaunay(x,y)
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()
输出三角形三个点的切片
#首先检查该图像时灰度还是彩色图像,若是彩色图像,则要对每个颜色通道进行扭曲处理。
# 分段仿射图像函数
def pw_affine(fromim,toim,fp,tp,tri):
# fromim = 将要扭曲的图像 toim = 目标图 fp = 扭曲前的点 tp = 扭曲后的点 tri = 三角剖分
im = toim.copy()
# check if image is grayscale or color
is_color = len(fromim.shape) == 3
# create image to warp to (needed if iterate colors)
im_t = zeros(im.shape, 'uint8')
for t in tri:
# compute affine transformation
H = homography.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 = alpha_for_triangle(tp[:,t],im.shape[0],im.shape[1])
# 将三角形加入到图像中
im[alpha>0] = im_t[alpha>0]
return im
使用狄洛克三角剖分标记点进行分段仿射扭曲
import homography
import warp
import os
from numpy import *
fromin = array(Image.open('pic/sunset_tree.jpg'))
# 创造网格
x,y = meshgrid(range(5),range(6))
x = (fromin.shape[1]/4)*x.flatten()
y = (fromin.shape[0]/5)*y.flatten()
#三角剖分
tri = Delaunay(np.c_[x,y]).simplices
#打开图像和目标点
im = array(Image.open('pic/turningtorso1.jpg'))
tp = loadtxt('turninggtorso_points.txt')
figure()
subplot(1, 4, 1)
axis('off')
imshow(im)
# 将点转换成齐次坐标
fp = array(vstack((y, x, ones((1, len(x))))), 'int')
tp = array(vstack((tp[:, 1], tp[:, 0], ones((1, len(tp))))), 'int')
# 扭曲三角形
im = warp.pw_affine(fromim, im, fp, tp, tri)
# 绘制图像
subplot(1, 4, 2)
axis('off')
imshow(fromim)
warp.plot_mesh(fp[1], fp[0], tri)
subplot(1, 4, 3)
axis('off')
imshow(im)
subplot(1, 4, 4)
axis('off')
imshow(im)
warp.plot_mesh(tp[1], tp[0], tri)
show()
第四张图显示带有三角剖分的图像被扭曲,并使用仿射变换,与第一张图片融合。
2.3 图像配准
图像配准是对图像进行变换,使变换后的图像能够在常见的坐标系中对齐,让我们一起看一个对多个人脸图像进行严格配准的例子,使得计算的平均人脸和人脸表现的变化具有意义,这类配准中,实际上是寻找一个相似变换,在对应点之间建立映射。
使用xml.dom模块中的minidom来读取XML文件。
def read_points_from_xml(xmlFileName):
""" Reads control points for face alignment. """
xmldoc = minidom.parse(xmlFileName)
facelist = xmldoc.getElementsByTagName('face')
faces = {}
for xmlFace in facelist:
fileName = xmlFace.attributes['file'].value
xf = int(xmlFace.attributes['xf'].value)
yf = int(xmlFace.attributes['yf'].value)
xs = int(xmlFace.attributes['xs'].value)
ys = int(xmlFace.attributes['ys'].value)
xm = int(xmlFace.attributes['xm'].value)
ym = int(xmlFace.attributes['ym'].value)
faces[fileName] = array([xf, yf, xs, ys, xm, ym])
return faces
文件中的标记点会以字典的形式返回,字典的键值为图像的文件名,值为:xf 、yf(左眼);xs、ys(右眼);xm、ym(嘴),参数使用最小二乘解,这些点被映射到目标位置
使用linalg.listsq()函数来计算最小二乘解
def write_points_to_xml(faces, xmlFileName):
xmldoc = minidom.Document()
xmlFaces = xmldoc.createElement("faces")
keys = faces.keys()
for k in keys:
xmlFace = xmldoc.createElement("face")
xmlFace.setAttribute("file", k)
xmlFace.setAttribute("xf", "%d" % faces[k][0])
xmlFace.setAttribute("yf", "%d" % faces[k][1])
xmlFace.setAttribute("xs", "%d" % faces[k][2])
xmlFace.setAttribute("ys", "%d" % faces[k][3])
xmlFace.setAttribute("xm", "%d" % faces[k][4])
xmlFace.setAttribute("ym", "%d" % faces[k][5])
xmlFaces.appendChild(xmlFace)
xmldoc.appendChild(xmlFaces)
fp = open(xmlFileName, "w")
fp.write(xmldoc.toprettyxml(encoding='utf-8'))
fp.close()
def compute_rigid_transform(refpoints,points):
""" Computes rotation, scale and translation for
aligning points to refpoints. """
A = array([ [points[0], -points[1], 1, 0],
[points[1], points[0], 0, 1],
[points[2], -points[3], 1, 0],
[points[3], points[2], 0, 1],
[points[4], -points[5], 1, 0],
[points[5], points[4], 0, 1]])
y = array([ refpoints[0],
refpoints[1],
refpoints[2],
refpoints[3],
refpoints[4],
refpoints[5]])
# least sq solution to mimimize ||Ax - y||
a,b,tx,ty = linalg.lstsq(A,y)[0]
R = array([[a, -b], [b, a]]) # rotation matrix incl scale
return R,tx,ty
返回具有尺度地旋转矩阵,以及x和y方向上的平移量
对每个颜色通道进行仿射变换,直接使用第一幅图像中的标记位置作为参考坐标系,来进行配准操作
def rigid_alignment(faces,path,plotflag=False):
""" Align images rigidly and save as new images.
path determines where the aligned images are saved
set plotflag=True to plot the images. """
# take the points in the first image as reference points
refpoints = faces.values()[0]
# warp each image using affine transform
for face in faces:
points = faces[face]
R,tx,ty = compute_rigid_transform(refpoints, points)
T = array([[R[1][1], R[1][0]], [R[0][1], R[0][0]]])
im = array(Image.open(os.path.join(path,face)))
im2 = zeros(im.shape, 'uint8')
# warp each color channel
for i in range(len(im.shape)):
im2[:,:,i] = ndimage.affine_transform(im[:,:,i],linalg.inv(T),offset=[-ty,-tx])
if plotflag:
imshow(im2)
show()
# crop away border and save aligned images
h,w = im2.shape[:2]
border = (w+h)/20
# crop away border
# 组合路径后返回并裁剪边界
imsave(os.path.join(path, 'aligned/'+face),im2[border:h-border,border:w-border,:])
经过配准操作的平均图像比没有对齐操作的平均清晰地多,由于在网上找不到JKace.xml的资源,暂时无法做这个实验。
3 创建全景图
3.1 RANSAC
算法简介:RANSAC算法的基本假设是样本中包含正确数据(inliers,可以被模型描述的数据),也包含异常数据(outliers,偏离正常范围很远、无法适应数学模型的数据),即数据集中含有噪声。这些异常数据可能是由于错误的测量、错误的假设、错误的计算等产生的。同时RANSAC也假设,给定一组正确的数据,存在可以计算出符合这些数据的模型参数的方法。
RANSAC是通过反复选择数据集去估计出模型,一直迭代到估计出认为比较好的模型。
具体的实现步骤可以分为以下几步:
选择出可以估计出模型的最小数据集;(对于直线拟合来说就是两个点,对于计算Homography矩阵就是4个点)
使用这个数据集来计算出数据模型;
将所有数据带入这个模型,计算出“内点”的数目;(累加在一定误差范围内的适合当前迭代推出模型的数据)
比较当前模型和之前推出的最好的模型的“内点“的数量,记录最大“内点”数的模型参数和“内点”数;
重复1-4步,直到迭代结束或者当前模型已经足够好了(“内点数目大于一定数量”)。
3.2 稳健的单应性矩阵估计
在简单了解了RANSAC算法后,我们来使用它进行单应性矩阵估计:
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
fp_transformed = dot(H,fp)
# 归一化齐次坐标
for i in range(3)
fp_transformed[i]/= fp_transformed[2]
# 返回误差
return sqrt( sum((tp-fp_transformed)**2,axis=0) )
#设定阈值、点对数目
def H_from_ransac(fp,tp,model,maxiter=1000,match_theshold=10):
from PCV.tools import ransac
#对应点组
data = vstack((fp,tp)
# 计算H
H,ransac_data = ransac.ransac(data.T,model,4,maxiter,match_theshold,10,return_all=True)
return H,ransac_data['inliers']
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)
fp = vstack([fp[1],fp[0],fp[2]])
tp = vstack([fp[1],fp[0],fp[2]])
return fp,tp
#单应性矩阵估计
model = homography.RansacModel()
fp,tp = convert_points(1)
H_12 = homography.H_from_ransac(fp,tp,model)[0] #im 1 to 2
fp,tp = convert_points(0)
H_01 = homography.H_from_ransac(fp,tp,model)[0] #im 0 to 1
tp,fp = convert_points(2) #NB: reverse order
H_32 = homography.H_from_ransac(fp,tp,model)[0] #im 3 to 2
tp,fp = convert_points(3) #NB: reverse order
H_43 = homography.H_from_ransac(fp,tp,model)[0] #im 4 to 3
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()