DEV Community

Lank_M
Lank_M

Posted on

Cropping a photo and copying its EXIF back kept the uncropped thumbnail

The standard advice for keeping metadata through an image pipeline is to read the EXIF block from the source and hand it to the encoder on save. In Pillow that is one keyword argument, exif=im.info["exif"], and it feels like the conservative option: you changed the pixels and left everything else alone. The problem is that a good share of EXIF isn't about the scene. It describes the pixel data it was written next to, and once you crop or rotate, those fields are wrong. One of them is a second picture.

Fields that describe the bytes, not the photo

Orientation (tag 274) tells a viewer how to rotate the stored pixels for display, so if your code already rotated them, a leftover value of 6 makes every viewer that honours the tag rotate them again. PixelXDimension and PixelYDimension in the Exif IFD record the size the camera produced, and Pillow doesn't touch them when you pass the old block back in. GPS isn't tied to the pixels at all, but it is the field people least expect to ride along with an edited copy. Then there is IFD1. The Exif specification says the 1st IFD "may be used to record a thumbnail image" (I checked that wording in version 2.32; the 3.x text sits behind a download agreement and I haven't read it), so not every JPEG has one, but the iPhone photo below does. It is a small JPEG of the whole frame stored inside the APP1 segment, where an operation on the main image never looks.

Crop, then copy the EXIF back

I wanted ground truth first, so I generated a 4000×3000 JPEG with a script. The left half carries the text "门牌 18-3" (a house-number plate, in Chinese), the right half a sun on blue. GPS is a made-up point on the sea, and IFD1 holds a 160×120 thumbnail of the full frame. Cropping the right half with Pillow and saving with the original EXIF gives a 2000×3000 file that shows only the sun. Its embedded thumbnail is still 160×120 and still the whole frame, house number included. PixelXDimension still says 4000, and the GPS is still there.

A synthetic file proves the mechanism but says nothing about real phones, so I repeated it on "Avions à l'aéroport de Madrid - 2015" from Wikimedia Commons, released under CC0, shot on an iPhone 6 on 2015-07-14 overlooking the airport apron. The original is 3264×2448 with 64 EXIF fields, a 160×120 thumbnail and GPS at 40°29′27.76″N. Cropping the left half and writing the EXIF back produces a 1632×2448 image whose thumbnail is the uncropped view, including the Emirates aircraft the crop had removed. Same 64 fields, same coordinates.

Left: the saved crop. Right: the thumbnail embedded in the same file's EXIF, enlarged. Top: my synthetic sample; bottom: CC0 iPhone 6 photo from Wikimedia Commons

For contrast, Pillow's default save() without exif= writes none of it: zero EXIF fields, no GPS, no thumbnail. macOS sips went a third way on my sample, cropping away the thumbnail but keeping the GPS. So neither "keep" nor "drop" is a default you can rely on across tools, and the only thing I trust is reading the output file back.

Fixing it field by field

The script keeps the EXIF but rewrites everything that describes the pixels. It rotates the pixels with ImageOps.exif_transpose, crops, sets Orientation to 1, writes the new dimensions, rebuilds the IFD1 thumbnail from the cropped pixels when the source had one, and drops the GPS IFD unless asked to keep it. The ICC profile goes through as its own argument, because exif= doesn't carry it; an earlier run on the sideways sample lost its profile exactly that way. The part where order matters is the top of the function (excerpt):

    im = ImageOps.exif_transpose(im)           # pixels upright
    im = im.crop(box)
    exif["0th"][piexif.ImageIFD.Orientation] = 1
Enter fullscreen mode Exit fullscreen mode

The rest is short. The two PixelDimension tags in the Exif IFD get the cropped image's width and height, read from im after the crop, so they describe the output rather than the source. Dropping GPS means emptying the GPS dict and popping the GPSTag pointer from IFD0. Rebuilding the thumbnail means copying the cropped image, shrinking it to fit 160 pixels, saving it as a quality-75 JPEG into a buffer and putting those bytes back in exif["thumbnail"], but only when the source had a thumbnail in the first place. Then piexif.dump produces the new block for save.

I ran it (Python 3.9, Pillow 11.3, piexif 1.1.3) on three inputs: the house-number sample, the Madrid photo, and a second synthetic photo stored sideways with Orientation 6, 4032×3024 and no thumbnail, and read every output back. The house sample came out at 2000×3000 with matching PixelDimension, a 107×160 thumbnail and no GPS. The Madrid crop came out at 1632×2448, again with matching dimensions, a 107×160 thumbnail and no GPS. The rotated photo came out at 3024×2016 with Orientation 1, PixelDimension 3024×2016, still no thumbnail, and no GPS. For reference, the naive crop-and-copy versions of the first two files kept PixelDimension at 4000×3000 and 3264×2448, the 160×120 thumbnail and the GPS.

The rotated case is the one worth staring at. The crop box (0, 0, 3024, 2016) is expressed in upright coordinates, which only works because the transpose happens before the crop. I ran it the other way round as a check: the same box applied to the stored, sideways pixels and then rotated gives 2016×3024, a portrait strip from a different part of the frame, while transpose-then-crop gives the 3024×2016 you'd expect. I rebuild the thumbnail rather than deleting it because some viewers may use it for quick previews; deleting it is just as valid and simpler. The 160-pixel bound only copies what both originals had. The Madrid file also went through piexif.dump with its MakerNote without an error, which I had half expected to fail, since MakerNote is vendor-private and can contain offsets that break when the block moves. I haven't checked that the rewritten MakerNote still parses in Apple's own software, so count that part as unverified.

What the script doesn't touch

XMP is a separate packet, and the script never looks at it. That matters: when I converted the synthetic sample to PNG with sips, it wrote the GPS into XMP as well as EXIF, so clearing the GPS IFD would have left a second copy behind. Pillow on my machine also can't open HEIC at all, so none of this covers HEIC sources without another decoder. The rule I've settled on is to split EXIF fields into "about the scene" (camera, exposure, capture time), which are safe to copy, and "about these bytes" (orientation, dimensions, thumbnail), which get recomputed or dropped every time the pixels change. GPS fits neither group. It should be a decision someone made on purpose, and the naive copies above show what happens when nobody does.

Top comments (0)