├── lena.jpg ├── setup.py ├── warpcythondemo.py ├── README.md ├── COPYING ├── warp.py └── warpcython.pyx /lena.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimSC/image-piecewise-affine/HEAD/lena.jpg -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from distutils.extension import Extension 3 | from Cython.Distutils import build_ext 4 | 5 | ext_modules = [Extension("warpcython", ["warpcython.pyx"])] 6 | 7 | setup( 8 | name = 'Piecewise-Affine-Transform', 9 | cmdclass = {'build_ext': build_ext}, 10 | ext_modules = ext_modules 11 | ) 12 | -------------------------------------------------------------------------------- /warpcythondemo.py: -------------------------------------------------------------------------------- 1 | import warpcython 2 | from PIL import Image 3 | 4 | if __name__ == "__main__": 5 | #Load source image 6 | srcIm = Image.open("lena.jpg") 7 | 8 | #Create destination image 9 | dstIm = Image.new(srcIm.mode,(500,500)) 10 | 11 | #Define control points for warp 12 | srcCloud = [(100,100),(400,100),(400,400),(100,400)] 13 | dstCloud = [(150,120),(374,105),(410,267),(105,390)] 14 | 15 | #Perform transform 16 | warpcython.PiecewiseAffineTransform(srcIm, srcCloud, dstIm, dstCloud) 17 | 18 | #Visualise result 19 | dstIm.show() 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | image-piecewise-affine 2 | ====================== 3 | 4 | A piecewise affine image warper library for Python 2 and 3. Both pure python and cython implementations are included. The transform is defined by a set of control points in the source and destination images. Demo programs are included in warp.py and warpcythondemo.py. This library operates on both greyscale and colour images. 5 | 6 | The method depends on scipy.spatial to perform Delaunay triangularisation and PIL to open images. On my desktop PC, the native python version runs in 6.992 sec, the cython version in 4.861 seconds. 7 | 8 | The cython version can be built using the command: python setup.py build_ext --inplace 9 | 10 | This software is available under the Simplified BSD License as specified in the COPYING file. 11 | 12 | NOTE: This functionality has been integrated into scikit-image, available here http://scikit-image.org/ 13 | 14 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Tim Sheerman-Chase 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | The views and conclusions contained in the software and documentation are those 25 | of the authors and should not be interpreted as representing official policies, 26 | either expressed or implied, of the FreeBSD Project. 27 | -------------------------------------------------------------------------------- /warp.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | import numpy as np 3 | import math 4 | import scipy.spatial as spatial 5 | 6 | def GetBilinearPixel(imArr, posX, posY, out): 7 | 8 | #Get integer and fractional parts of numbers 9 | modXi = int(posX) 10 | modYi = int(posY) 11 | modXf = posX - modXi 12 | modYf = posY - modYi 13 | 14 | #Get pixels in four corners 15 | for chan in range(imArr.shape[2]): 16 | bl = imArr[modYi, modXi, chan] 17 | br = imArr[modYi, modXi+1, chan] 18 | tl = imArr[modYi+1, modXi, chan] 19 | tr = imArr[modYi+1, modXi+1, chan] 20 | 21 | #Calculate interpolation 22 | b = modXf * br + (1. - modXf) * bl 23 | t = modXf * tr + (1. - modXf) * tl 24 | pxf = modYf * t + (1. - modYf) * b 25 | out[chan] = int(pxf+0.5) #Do fast rounding to integer 26 | 27 | return None #Helps with profiling view 28 | 29 | def WarpProcessing(inIm, inArr, 30 | outArr, 31 | inTriangle, 32 | triAffines, shape): 33 | 34 | #Ensure images are 3D arrays 35 | px = np.empty((inArr.shape[2],), dtype=np.int32) 36 | homogCoord = np.ones((3,), dtype=np.float32) 37 | 38 | #Calculate ROI in target image 39 | xmin = shape[:,0].min() 40 | xmax = shape[:,0].max() 41 | ymin = shape[:,1].min() 42 | ymax = shape[:,1].max() 43 | xmini = int(xmin) 44 | xmaxi = int(xmax+1.) 45 | ymini = int(ymin) 46 | ymaxi = int(ymax+1.) 47 | #print xmin, xmax, ymin, ymax 48 | 49 | #Synthesis shape norm image 50 | for i in range(xmini, xmaxi): 51 | for j in range(ymini, ymaxi): 52 | homogCoord[0] = i 53 | homogCoord[1] = j 54 | 55 | #Determine which tesselation triangle contains each pixel in the shape norm image 56 | if i < 0 or i >= outArr.shape[1]: continue 57 | if j < 0 or j >= outArr.shape[0]: continue 58 | 59 | #Determine which triangle the destination pixel occupies 60 | tri = inTriangle[i,j] 61 | if tri == -1: 62 | continue 63 | 64 | #Calculate position in the input image 65 | affine = triAffines[tri] 66 | outImgCoord = np.dot(affine, homogCoord) 67 | 68 | #Check destination pixel is within the image 69 | if outImgCoord[0] < 0 or outImgCoord[0] >= inArr.shape[1]: 70 | for chan in range(px.shape[0]): outArr[j,i,chan] = 0 71 | continue 72 | if outImgCoord[1] < 0 or outImgCoord[1] >= inArr.shape[0]: 73 | for chan in range(px.shape[0]): outArr[j,i,chan] = 0 74 | continue 75 | 76 | #Nearest neighbour 77 | #outImgL[i,j] = inImgL[int(round(inImgCoord[0])),int(round(inImgCoord[1]))] 78 | 79 | #Copy pixel from source to destination by bilinear sampling 80 | #print i,j,outImgCoord[0:2],im.size 81 | GetBilinearPixel(inArr, outImgCoord[0], outImgCoord[1], px) 82 | for chan in range(px.shape[0]): 83 | outArr[j,i,chan] = px[chan] 84 | #print outImgL[i,j] 85 | 86 | return None 87 | 88 | def PiecewiseAffineTransform(srcIm, srcPoints, dstIm, dstPoints): 89 | 90 | #Convert input to correct types 91 | srcArr = np.asarray(srcIm, dtype=np.float32) 92 | dstPoints = np.array(dstPoints) 93 | srcPoints = np.array(srcPoints) 94 | 95 | #Split input shape into mesh 96 | tess = spatial.Delaunay(dstPoints) 97 | 98 | #Calculate ROI in target image 99 | xmin, xmax = dstPoints[:,0].min(), dstPoints[:,0].max() 100 | ymin, ymax = dstPoints[:,1].min(), dstPoints[:,1].max() 101 | #print xmin, xmax, ymin, ymax 102 | 103 | #Determine which tesselation triangle contains each pixel in the shape norm image 104 | inTessTriangle = np.ones(dstIm.size, dtype=np.int) * -1 105 | for i in range(int(xmin), int(xmax+1.)): 106 | for j in range(int(ymin), int(ymax+1.)): 107 | if i < 0 or i >= inTessTriangle.shape[0]: continue 108 | if j < 0 or j >= inTessTriangle.shape[1]: continue 109 | normSpaceCoord = (float(i),float(j)) 110 | simp = tess.find_simplex([normSpaceCoord]) 111 | inTessTriangle[i,j] = simp 112 | 113 | #Find affine mapping from input positions to mean shape 114 | triAffines = [] 115 | for i, tri in enumerate(tess.vertices): 116 | meanVertPos = np.hstack((srcPoints[tri], np.ones((3,1)))).transpose() 117 | shapeVertPos = np.hstack((dstPoints[tri,:], np.ones((3,1)))).transpose() 118 | 119 | affine = np.dot(meanVertPos, np.linalg.inv(shapeVertPos)) 120 | triAffines.append(affine) 121 | 122 | #Prepare arrays, check they are 3D 123 | targetArr = np.copy(np.asarray(dstIm, dtype=np.uint8)) 124 | srcArr = srcArr.reshape(srcArr.shape[0], srcArr.shape[1], len(srcIm.mode)) 125 | targetArr = targetArr.reshape(targetArr.shape[0], targetArr.shape[1], len(dstIm.mode)) 126 | 127 | #Calculate pixel colours 128 | WarpProcessing(srcIm, srcArr, targetArr, inTessTriangle, triAffines, dstPoints) 129 | 130 | #Convert single channel images to 2D 131 | if targetArr.shape[2] == 1: 132 | targetArr = targetArr.reshape((targetArr.shape[0],targetArr.shape[1])) 133 | dstIm.paste(Image.fromarray(targetArr)) 134 | 135 | if __name__ == "__main__": 136 | #Load source image 137 | srcIm = Image.open("lena.jpg") 138 | 139 | #Create destination image 140 | dstIm = Image.new(srcIm.mode,(500,500)) 141 | 142 | #Define control points for warp 143 | srcCloud = [(100,100),(400,100),(400,400),(100,400)] 144 | dstCloud = [(150,120),(374,105),(410,267),(105,390)] 145 | 146 | #Perform transform 147 | PiecewiseAffineTransform(srcIm, srcCloud, dstIm, dstCloud) 148 | 149 | #Visualise result 150 | dstIm.show() 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /warpcython.pyx: -------------------------------------------------------------------------------- 1 | ### cython: profile=False 2 | # cython: cdivision=True 3 | # cython: boundscheck=False 4 | # cython: wraparound=False 5 | 6 | from PIL import Image 7 | import numpy as np 8 | cimport numpy as np 9 | import math 10 | import scipy.spatial as spatial 11 | 12 | cdef GetBilinearPixel(np.ndarray[np.float32_t,ndim=3] imArr, float posX, float posY, np.ndarray[np.int32_t,ndim=1] out): 13 | cdef float modXf, modYf 14 | cdef int modXi, modYi, chan 15 | cdef float bl, br, tl, tr, b, t, pxf 16 | 17 | #Get integer and fractional parts of numbers 18 | modXi = int(posX) 19 | modYi = int(posY) 20 | modXf = posX - modXi 21 | modYf = posY - modYi 22 | 23 | #Get pixels in four corners 24 | for chan in range(imArr.shape[2]): 25 | bl = imArr[modYi, modXi, chan] 26 | br = imArr[modYi, modXi+1, chan] 27 | tl = imArr[modYi+1, modXi, chan] 28 | tr = imArr[modYi+1, modXi+1, chan] 29 | 30 | #Calculate interpolation 31 | b = modXf * br + (1. - modXf) * bl 32 | t = modXf * tr + (1. - modXf) * tl 33 | pxf = modYf * t + (1. - modYf) * b 34 | out[chan] = int(pxf+0.5) #Do fast rounding to integer 35 | 36 | return None #Helps with profiling view 37 | 38 | cdef WarpProcessing(inImg, np.ndarray[np.float32_t, ndim=3] inArr, 39 | np.ndarray[np.uint8_t, ndim=3] outArr, 40 | np.ndarray[np.int_t, ndim=2] inTriangle, 41 | triAffines, shape): 42 | 43 | cdef int i, j, tri, chan 44 | cdef float xmin, xmax, ymin, ymax 45 | cdef int xmini, xmaxi, ymini, ymaxi 46 | cdef float normSpaceCoordX, normSpaceCoordY 47 | 48 | #cdef np.ndarray[np.float32_t, ndim=3] inArr = np.asarray(inImg, dtype=np.float32) 49 | cdef np.ndarray[np.int32_t, ndim=1] px = np.empty((inArr.shape[2],), dtype=np.int32) 50 | cdef np.ndarray[np.float32_t, ndim=1] homogCoord = np.ones((3,), dtype=np.float32) 51 | cdef np.ndarray[double, ndim=1] outImgCoord 52 | cdef np.ndarray[double, ndim=2] affine 53 | 54 | #Calculate ROI in target image 55 | xmin = shape[:,0].min() 56 | xmax = shape[:,0].max() 57 | ymin = shape[:,1].min() 58 | ymax = shape[:,1].max() 59 | xmini = int(xmin) 60 | xmaxi = int(xmax+1.) 61 | ymini = int(ymin) 62 | ymaxi = int(ymax+1.) 63 | #print xmin, xmax, ymin, ymax 64 | 65 | #Synthesis shape norm image 66 | for i in range(xmini, xmaxi): 67 | for j in range(ymini, ymaxi): 68 | homogCoord[0] = i 69 | homogCoord[1] = j 70 | 71 | #Determine which tesselation triangle contains each pixel in the shape norm image 72 | if i < 0 or i >= outArr.shape[1]: continue 73 | if j < 0 or j >= outArr.shape[0]: continue 74 | 75 | #Determine which triangle the destination pixel occupies 76 | tri = inTriangle[i,j] 77 | if tri == -1: 78 | continue 79 | 80 | #Calculate position in the input image 81 | affine = triAffines[tri] 82 | outImgCoord = np.dot(affine, homogCoord) 83 | 84 | #Check destination pixel is within the image 85 | if outImgCoord[0] < 0 or outImgCoord[0] >= inArr.shape[1]: 86 | for chan in range(px.shape[0]): outArr[j,i,chan] = 0 87 | continue 88 | if outImgCoord[1] < 0 or outImgCoord[1] >= inArr.shape[0]: 89 | for chan in range(px.shape[0]): outArr[j,i,chan] = 0 90 | continue 91 | 92 | #Nearest neighbour 93 | #outImgL[i,j] = inImgL[int(round(inImgCoord[0])),int(round(inImgCoord[1]))] 94 | 95 | #Copy pixel from source to destination by bilinear sampling 96 | #print i,j,outImgCoord[0:2],im.size 97 | GetBilinearPixel(inArr, outImgCoord[0], outImgCoord[1], px) 98 | for chan in range(px.shape[0]): 99 | outArr[j,i,chan] = px[chan] 100 | #print outImgL[i,j] 101 | 102 | return None 103 | 104 | def PiecewiseAffineTransform(srcIm, srcPoints, dstIm, dstPoints): 105 | 106 | #Convert input to correct types 107 | srcArr = np.asarray(srcIm, dtype=np.float32) 108 | dstPoints = np.array(dstPoints) 109 | srcPoints = np.array(srcPoints) 110 | 111 | #Split input shape into mesh 112 | tess = spatial.Delaunay(dstPoints) 113 | 114 | #Calculate ROI in target image 115 | xmin, xmax = dstPoints[:,0].min(), dstPoints[:,0].max() 116 | ymin, ymax = dstPoints[:,1].min(), dstPoints[:,1].max() 117 | #print xmin, xmax, ymin, ymax 118 | 119 | #Determine which tesselation triangle contains each pixel in the shape norm image 120 | inTessTriangle = np.ones(dstIm.size, dtype=np.int) * -1 121 | for i in range(int(xmin), int(xmax+1.)): 122 | for j in range(int(ymin), int(ymax+1.)): 123 | if i < 0 or i >= inTessTriangle.shape[0]: continue 124 | if j < 0 or j >= inTessTriangle.shape[1]: continue 125 | normSpaceCoord = (float(i),float(j)) 126 | simp = tess.find_simplex([normSpaceCoord]) 127 | inTessTriangle[i,j] = simp 128 | 129 | #Find affine mapping from input positions to mean shape 130 | triAffines = [] 131 | for i, tri in enumerate(tess.vertices): 132 | meanVertPos = np.hstack((srcPoints[tri], np.ones((3,1)))).transpose() 133 | shapeVertPos = np.hstack((dstPoints[tri,:], np.ones((3,1)))).transpose() 134 | 135 | affine = np.dot(meanVertPos, np.linalg.inv(shapeVertPos)) 136 | triAffines.append(affine) 137 | 138 | #Prepare arrays, check they are 3D 139 | targetArr = np.copy(np.asarray(dstIm, dtype=np.uint8)) 140 | srcArr = srcArr.reshape(srcArr.shape[0], srcArr.shape[1], len(srcIm.mode)) 141 | targetArr = targetArr.reshape(targetArr.shape[0], targetArr.shape[1], len(dstIm.mode)) 142 | 143 | #Calculate pixel colours 144 | WarpProcessing(srcIm, srcArr, targetArr, inTessTriangle, triAffines, dstPoints) 145 | 146 | #Convert single channel images to 2D 147 | if targetArr.shape[2] == 1: 148 | targetArr = targetArr.reshape((targetArr.shape[0],targetArr.shape[1])) 149 | dstIm.paste(Image.fromarray(targetArr)) 150 | 151 | --------------------------------------------------------------------------------