Working with PIL and EXIF

PIL has a way to look at a simple set of EXIF tags but cannot read the myriad of file types out there. I needed something to get everything I could out of a NIKON *.jpg file.

The solution I found was running exiftool.exe with the subprocess module.

Exiftool.exe supports many file types. Download at http://www.sno.phy.queensu.ca/~phil/exiftool/

Loading EXIF from a file into Python
To load EXIF data from an image file, use the check_output method in the subprocess module. This method will return the exiftool.exe output as a string. Then splitlines and add each line/tag to a dictionary. I prefer using OrderedDict from the collections module, but I use a regular dictionary in the code below.

import subprocess

# filename is the path to your target image file.
exifdata = subprocess.check_output(['exiftool.exe',
                                    filename], shell=True)
exifdata = exifdata.splitlines()
exif = dict()
for i, each in enumerate(exifdata):
     # tags and values are separated by a colon
     tag,val = each.split(': ', 1) # '1' only allows one split
     exif[tag.strip()] = val.strip()

You can review all the available tags from a file with exif.keys().

Getting a preview image or thumbnail from EXIF into Python
You can get a preview image from a NEF or JPG file also. Not all files will have a preview so you’ll need to handle the error. You can also retrieve the full-size jpeg that is embedded in the file.

import subprocess
import StringIO
from PIL import Image

def get_preview( filename ):
    try:
        im_binary = subprocess.check_output(['exiftool.exe',
                                             filename,
                                             '-previewimage',
                                             '-b'], shell=True)
        image = Image.open( StringIO.StringIO(im_binary) )
        return image
    except:
        return None

def get_thumbnail( filename ):
    try:
        im_binary = subprocess.check_output(['exiftool.exe',
                                              filename,
                                              '-thumbnailimage',
                                              '-b'], shell=True)
        image = Image.open( StringIO.StringIO(im_binary) )
        return image
    except:
        return None

def get_jpeg( filename ):
    try:
        im_binary = subprocess.check_output(['exiftool.exe',
                                              filename,
                                              '-JpgFromRaw',
                                              '-b'], shell=True)
        image = Image.open( StringIO.StringIO(im_binary) )
        return image
    except:
        return None

Preserving the EXIF after PIL manipulation and save
PIL does not automatically save EXIF and PIL’s exif methods do not grab everything, particularly NIKON MakerNote data.
My solution is to save a manipulated image separate from original (do not overwrite original JPG) and copy the EXIF from old to new with exiftool.

# After editing/manipulating image with PIL
savename = filename[:-4]+'_new.jpg'
image.save( savename, quality=95 ) # Default quality is 75
subprocess.call(['exiftool.exe',
                 savename,
                 '-tagsFromFile',
                 filename], shell=True)

After copying the EXIF data, delete the old image or change its name to be a backup. Use os.rename or os.remove.

PIL Image to CV2 image

Bands need to be reversed for CV2. PIL is RGB. CV2 is BGR.
Also, if you are getting errors with CV2, it may be because it doesn’t like transformed ‘views’ of arrays. Use the copy method.

# PIL RGB 'im' to CV2 BGR 'imcv'
imcv = np.asarray(im)[:,:,::-1].copy()
# Or
imcv = cv2.cvtColor(np.asarray(im), cv2.COLOR_RGB2BGR)
# To gray image
imcv = np.asarray(im.convert('L'))
# Or
imcv = cv2.cvtColor(np.asarray(im), cv2.COLOR_RGB2GRAY)
view raw gistfile1.py hosted with ❤ by GitHub

PIL and CV2 use the same percentages of R,G, and B to make a greyscale image but the results may have a difference of 1 in many places due to rounding off. PIL uses integer division and CV2 uses floating point percentages.
PIL: GRAY = R * 299/1000 + G * 587/1000 + B * 114/1000
CV2: GRAY = R * 0.299 + G * 0.587 + B * 0.114

If the PIL image has four bands, only reverse the first three.

PIL projective transform

im.transform(size, PERSPECTIVE, data) image ⇒ image

size = output image size
PERSPECTIVE = Image.PERSPECTIVE (int)
data = 8-tuple (a, b, c, d, e, f, g, h) of coefficients

Questions you may have:
1) How do you know the size of the image after the transform to set the size before doing the transform?
2) How is the transformed image placed within that pre-sized output window?
3) Where does the data 8-tuple come from?
4) How can I use the 8-tuple coefficients to project a point back and forth between original and transformed image.

Answers:
1&2) It depends on how much of the image you want to include. The destination points determine the placement within the new image. You can first calculate the coefficients (forward form, see Ans. 4), then calculate where the image corners will end up, then shift the destination points (or corners) to the desired placement and adjust the image size. Finally, calculate the coefficients a second time (backward form) for the Image.transform function.

3) The coefficients are calculated with some matrix math. You can find an explanation elsewhere. I’ll just show some code:

def get_transform_data(pts8, backward=True ):
    '''This method returns a perspective transform 8-tuple (a,b,c,d,e,f,g,h).

    Use to transform an image:
    X = (a x + b y + c)/(g x + h y + 1)
    Y = (d x + e y + f)/(g x + h y + 1)
    
    Image.transform: Use 4 source coordinates, followed by 4 corresponding 
        destination coordinates. Use backward=True (the default)
        
    To calculate the destination coordinate of a single pixel, either reverse
        the pts (4 dest, followed by 4 source, backward=True) or use the same
        pts but set backward to False.
    
    @arg pts8: four source and four corresponding destination coordinates
    @kwarg backward: True to return coefficients for calculating an originating
        position. False to return coefficients for calculating a destination
        coordinate. (Image.transform calculates originating position.)
    '''
    assert len(pts8) == 8, 'Requires a tuple of eight coordinate tuples (x,y)'
    
    b0,b1,b2,b3,a0,a1,a2,a3 = pts8 if backward else pts8[::-1]
    
    # CALCULATE THE COEFFICIENTS
    A = array([[a0[0], a0[1], 1,     0,     0, 0, -a0[0]*b0[0], -a0[1]*b0[0]],
               [    0,     0, 0, a0[0], a0[1], 1, -a0[0]*b0[1], -a0[1]*b0[1]],
               [a1[0], a1[1], 1,     0,     0, 0, -a1[0]*b1[0], -a1[1]*b1[0]],
               [    0,     0, 0, a1[0], a1[1], 1, -a1[0]*b1[1], -a1[1]*b1[1]],
               [a2[0], a2[1], 1,     0,     0, 0, -a2[0]*b2[0], -a2[1]*b2[0]],
               [    0,     0, 0, a2[0], a2[1], 1, -a2[0]*b2[1], -a2[1]*b2[1]],
               [a3[0], a3[1], 1,     0,     0, 0, -a3[0]*b3[0], -a3[1]*b3[0]],
               [    0,     0, 0, a3[0], a3[1], 1, -a3[0]*b3[1], -a3[1]*b3[1]]] )
    B = array([b0[0], b0[1], b1[0], b1[1], b2[0], b2[1], b3[0], b3[1]])
    
    return linalg.solve(A,B)

If your image has black borders that you want to make transparent, this code could help:

    transparency = 150
    I = asarray(transimage)  # (y, x, band)
    maskmat = (I[:,:,0] | I[:,:,1] | I[:,:,2] == 0)
    mask = ones(size, int).transpose() * transparency
    mask[maskmat] = 0
    mask = Image.fromarray(uint8(mask))
    transimage.putalpha(mask)

This code applies an alpha mask to a transformed image (transimage). Transparency is how transparent the image part will be and the maskmat region is for all the black (non-image) border area. This area of the mask is set to completely transparent (mask[maskmat] = 0). This works with Tkinter where an alpha of zero is transparent and 255 is opaque.

4) When I first saw the equation for calculating the point transform I believed I must have read it wrong. “Why would the equations move a point from the transformed image back to the original? I must be reading this wrong,” I thought. But the data set passed to the transform method must be working in the following way: It first creates a blank image of the specified size and then uses the ‘backwards’ equation to calculate from which pixel in the original image to copy color values into the destination location. Well, it seems counter-intuitive to me so I still call it ‘backwards’ (where ‘backward’ means mapping from destination to the origin, and ‘forward’ is origin to destination). Here is the code to transform a point:

def transform_pt(pt , coeffs ):
    T = coeffs
    x = (T[0]*pt[0] + T[1]*pt[1] + T[2])/(T[6]*pt[0] + T[7]*pt[1] + 1)
    y = (T[3]*pt[0] + T[4]*pt[1] + T[5])/(T[6]*pt[0] + T[7]*pt[1] + 1)
    return x,y

Two ways to use this. Pass a destination coord with the default (backward) transformation coefficients, or pass an origin coord with the forward (backward=False) coefficients.

PIL to OpenCV image

An example suggested this code:

        cv_img1 = cv.CreateImageHeader(img1.size, cv.IPL_DEPTH_8U, 1)
        cv.SetData(cv_img1, img1.tostring(), img1.width*3)

But the image kept getting stretch along the width. Playing with the numbers didn’t help. The above code is suppose to convert an RGB image to single band, but I finally decided to convert the PIL image to a single band first and then it worked. Remove the width multiplier.

        img1 = img1.convert('L')
        cv_img1 = cv.CreateImageHeader(img1.size, cv.IPL_DEPTH_8U, 1)
        cv.SetData(cv_img1, img1.tostring(), img1.width  )

Saving a canvas animation

The only built-in image save function for Tkinter canvas is a postscript save. This didn’t work for me and instead of finding out why, I thought of another way which works great.
Create a frameless canvas window using ‘overrideredirect(1)’, place the canvas in the top corner and use PIL’s ImageGrab and save the images. Finally, ‘destroy’ the window when finished, since there is no kill button in the top corner. Here is an example that animates a polygon moving on the canvas.

from Tkinter import *
from PIL import ImageGrab
from numpy import array
import os


class App(Tk):
    def __init__(self, parent):
        Tk.__init__(self, parent)
        self.overrideredirect(1)
        
        self.width = 900
        self.height = 640
        self.initialize()
        
    def initialize(self):
        self.c = Canvas(self, width=self.width, height=self.height, background='white')
        self.c.pack(side=RIGHT, expand=YES, fill=BOTH)
        self.update()
        
        self.run_anim()
        self.destroy()
        
        
    def run_anim(self):
        print os.getcwd()
        c = self.c
        polyo = array([34,60,226,15,419,60,359,151,91,151])
        polyd = array([205,253,296,187,388,253,353,360,239,360])
        trantime = 20
        for i in xrange(trantime):
            c.delete('pol')
            ptrans = (float(i)/trantime)*polyd+(trantime-float(i))/trantime*polyo
            c.create_polygon(list(ptrans), width=4, outline='black', fill='red', tags='pol')


            self.update()
            savename = 'im_{0:0>6}'.format(i)
            ImageGrab.grab((0,0,900,640)).save(savename + '.jpg')
            

app = App(None)
app.mainloop()

Tkinter notes

I keep getting tripped up by the same problems so this note on Tkinter is long overdue.

Most importantly, remember to keep a global reference to all images that you want to have persist on the canvas or tk window. Also, make sure that the ‘global’ reference is called at the beginning of any method using those images. I keep forgetting one or the other and getting stuck over why my images are not appearing. Any images created within a method will not appear unless referenced by a more permanent variable. I need to remember that you cannot just create images on the fly and throw them onto the canvas (although this seems to work for lines and ovals). So:
1) Make sure there is a permanent reference, and
2) Make sure to use ‘global’ or pass the image as an argument for editing.

Another problem I keep encountering is editing PIL images and getting the ImageTk.PhotoImage method to accept an image.
Getting subwindow of image:

subwin = imjpg.copy().crop( (box coordinates) )

You can chain the copy with crop.
im.load():
The load method seems to only give you pixel access to an image for editing, so I think it is not much use since you can’t do slicing. ImageDraw is better for drawing figures.

Masking:

# MAKE COPY IF YOU WANT TO KEEP ORIGINAL.
imcopy = im.copy()
# COLOR: 0 = TRANSPARENT, 255 = OPAQUE.
mask = Image.new('L', imjpg.size, color=100)
# GET A VIEW OF IMAGE FOR DRAWING. EDITING 'DRAW' ALSO CHANGES 'MASK'.
draw = ImageDraw.Draw(mask)
...
# OVERWRITE OR ADD ALPHA LAYER TO IMAGE ('RGB' -> 'RGBA')
imcopy.putalpha(mask)
# SAVE TO GLOBALLY REFERENCED VARIABLE AS A Tk COMPATIBLE IMAGE.
immasked = ImageTk.PhotoImage(imcopy)