38

Can anyone help me figure out what's happening in my image auto-cropping script? I have a png image with a large transparent area/space. I would like to be able to automatically crop that space out and leave the essentials. Original image has a squared canvas, optimally it would be rectangular, encapsulating just the molecule.

here's the original image: Original Image

Doing some googling i came across PIL/python code that was reported to work, however in my hands, running the code below over-crops the image.

import Image
import sys

image=Image.open('L_2d.png')
image.load()

imageSize = image.size
imageBox = image.getbbox()

imageComponents = image.split()

rgbImage = Image.new("RGB", imageSize, (0,0,0))
rgbImage.paste(image, mask=imageComponents[3])
croppedBox = rgbImage.getbbox()
print imageBox
print croppedBox
if imageBox != croppedBox:
    cropped=image.crop(croppedBox)
    print 'L_2d.png:', "Size:", imageSize, "New Size:",croppedBox
    cropped.save('L_2d_cropped.png')

the output is this:script's output

Can anyone more familiar with image-processing/PLI can help me figure out the issue?

dimka
  • 4,301
  • 11
  • 31
  • 36

8 Answers8

52

Install Pillow

pip install Pillow

and use as

from PIL import Image
    
image=Image.open('L_2d.png')

imageBox = image.getbbox()
cropped = image.crop(imageBox)
cropped.save('L_2d_cropped.png')

When you search for boundaries by mask=imageComponents[3], you search only by blue channel.

Nico Schlömer
  • 53,797
  • 27
  • 201
  • 249
sneawo
  • 3,543
  • 1
  • 26
  • 31
37

You can use numpy, convert the image to array, find all non-empty columns and rows and then create an image from these:

import Image
import numpy as np

image=Image.open('L_2d.png')
image.load()

image_data = np.asarray(image)
image_data_bw = image_data.max(axis=2)
non_empty_columns = np.where(image_data_bw.max(axis=0)>0)[0]
non_empty_rows = np.where(image_data_bw.max(axis=1)>0)[0]
cropBox = (min(non_empty_rows), max(non_empty_rows), min(non_empty_columns), max(non_empty_columns))

image_data_new = image_data[cropBox[0]:cropBox[1]+1, cropBox[2]:cropBox[3]+1 , :]

new_image = Image.fromarray(image_data_new)
new_image.save('L_2d_cropped.png')

The result looks like cropped image

If anything is unclear, just ask.

ryanjdillon
  • 17,658
  • 9
  • 85
  • 110
Thorsten Kranz
  • 12,492
  • 2
  • 39
  • 56
  • 7
    `(...)cropBox[2]:cropBox[3]+1 , :]` <-- +1 for this smile :) I'm new to Python... :P – cubuspl42 May 25 '13 at 17:50
  • 2
    This method works with Python3 if importing `Image` as `from PIL import Image` (having installed [`PILLOW`](https://python-pillow.org/) for Python3). – ryanjdillon Sep 13 '17 at 12:11
  • This works like a charm for RGB and RGBA images but doesn't work with P Mode images.. can you please advise? – user12345 Dec 16 '17 at 18:36
  • @user12345, I'm not sure what you mean by p Mode images. Please explain. Do you have any examples? – Thorsten Kranz Dec 17 '17 at 19:11
  • Slight correction that fixed this for me in edge cases: Change `image_data_bw = image_data.max(axis=2)` to `image_data_bw = image_data.take(3, axis=2)` So it actually looks at the transparency value – tryashtar Jul 13 '19 at 23:29
  • This wasn't even the problem/solution I was looking for, but I have to upvote because of the brilliant and simple solution. – rmooney Mar 27 '20 at 13:54
  • in most images the background is white; the provided code assumes that the background is black; replace the line with `image_data_bw = 255 - image_data.max(axis=2)` to invert the bw image – am70 Jun 05 '21 at 19:46
  • this said, the provided code snippet is quite elegant and effective, I am using to extract paragraphs from a PDF file – am70 Jun 05 '21 at 19:47
23

I tested most of the answers replied in this post, however, I was ended up my own answer. I used anaconda python3.

from PIL import Image, ImageChops

def trim(im):
    bg = Image.new(im.mode, im.size, im.getpixel((0,0)))
    diff = ImageChops.difference(im, bg)
    diff = ImageChops.add(diff, diff, 2.0, -100)
    #Bounding box given as a 4-tuple defining the left, upper, right, and lower pixel coordinates.
    #If the image is completely empty, this method returns None.
    bbox = diff.getbbox()
    if bbox:
        return im.crop(bbox)

if __name__ == "__main__":
    bg = Image.open("test.jpg") # The image to be cropped
    new_im = trim(bg)
    new_im.show()
Richard
  • 56,349
  • 34
  • 180
  • 251
neouyghur
  • 1,577
  • 16
  • 31
  • 3
    This code has the great advantage to work for any color and alpha. – FabienRohrer Dec 05 '18 at 14:37
  • 1
    I'm having an issue with this particular bit of code while working with low-color images, specifically ones with a single pen and a single canvas color. If the top left pixel happens to be the pen color, it does not generate the mask (you call it diff) correctly. I solved the issue by replacing `im.getpixel((0,0))` with a tuple representing the paper color. – Austin Burk Nov 27 '19 at 19:28
  • 1
    I used this method, but found that when my background color was white, and the content of the image was near-white, it would get cropped off. I fixed it by changing the `-100` constant to `0`. – jdhildeb Apr 16 '20 at 16:10
  • 1
    This method gave me the exact same result as `convert image.png -format "%@" info:` for the bounding box values – Ultraspider Aug 29 '20 at 09:38
6

Here's another version using pyvips.

import sys
import pyvips

image = pyvips.Image.new_from_file(sys.argv[1])
left, top, width, height = image.find_trim(threshold=2, background=[255, 255, 255])
image = image.crop(left, top, width, height)
image.write_to_file(sys.argv[2])

The pyvips trimmer is useful for photographic images. It does a median filter, subtracts the background, finds pixels over the threshold, and removes up to the first and last row and column outside this set. The median and threshold mean it is not thrown off by things like JPEG compression, where noise or invisible compression artefacts can confuse other trimmers.

If you don't supply the background argument, it uses the pixel at (0, 0). threshold defaults to 10, which is about right for JPEG.

Here it is running on an 8k x 8k pixel NASA earth image:

$ time ./trim.py /data/john/pics/city_lights_asia_night_8k.jpg x.jpg
real    0m1.868s
user    0m13.204s
sys     0m0.280s
peak memory: 100mb

Before:

Earth at night before crop

After:

Earth after crop

There's a blog post with some more discussion here.

jcupitt
  • 10,213
  • 2
  • 23
  • 39
4

This is an improvement over snew's reply, which works for transparent background. With mathematical morphology we can make it work on white background (instead of transparent), with the following code:

from PIL import Image
from skimage.io import imread
from skimage.morphology import convex_hull_image
from skimage.color import rgb2gray
im = imread('L_2d.jpg')
plt.imshow(im)
plt.title('input image')
plt.show()
# create a binary image
im1 = 1 - rgb2gray(im)
threshold = 0.5
im1[im1 <= threshold] = 0
im1[im1 > threshold] = 1
chull = convex_hull_image(im1)
plt.imshow(chull)
plt.title('convex hull in the binary image')
plt.show()
imageBox = Image.fromarray((chull*255).astype(np.uint8)).getbbox()
cropped = Image.fromarray(im).crop(imageBox)
cropped.save('L_2d_cropped.jpg')
plt.imshow(cropped)
plt.show()

enter image description here enter image description here enter image description here

mustafa candan
  • 567
  • 5
  • 16
Sandipan Dey
  • 21,482
  • 2
  • 51
  • 63
2

Came across this post recently and noticed the PIL library has changed. I re-implemented this with openCV:

import cv2

def crop_im(im, padding=0.1):
    """
    Takes cv2 image, im, and padding % as a float, padding,
    and returns cropped image.
    """
    bw = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    rows, cols = bw.shape
    non_empty_columns = np.where(bw.min(axis=0)<255)[0]
    non_empty_rows = np.where(bw.min(axis=1)<255)[0]
    cropBox = (int(min(non_empty_rows) * (1 - padding)),
                int(min(max(non_empty_rows) * (1 + padding), rows)),
                int(min(non_empty_columns) * (1 - padding)),
                int(min(max(non_empty_columns) * (1 + padding), cols)))
    cropped = im[cropBox[0]:cropBox[1]+1, cropBox[2]:cropBox[3]+1 , :]

    return cropped

im = cv2.imread('testimage.png')
cropped = crop_im(im)
cv2.imshow('', cropped)
cv2.waitKey(0)
wordsforthewise
  • 13,746
  • 5
  • 87
  • 117
2

pilkit already contains processor for automatic cropping TrimBorderColor. SOmething like this should work:

from pilkit.lib import Image
from pilkit.processors import TrimBorderColor

img = Image.open('/path/to/my/image.png')
processor = TrimBorderColor()
new_img = processor.process(img)

https://github.com/matthewwithanm/pilkit/blob/b24990167aacbaab3db6d8ec9a02f9ad42856898/pilkit/processors/crop.py#L33

igo
  • 6,359
  • 6
  • 42
  • 51
0

I know that this post is old but, for some reason, none of the suggested answers worked for me. So I hacked my own version from existing answers:

import Image
import numpy as np
import glob
import shutil
import os

grey_tolerance = 0.7 # (0,1) = crop (more,less)

f = 'test_image.png'
file,ext = os.path.splitext(f)

def get_cropped_line(non_empty_elms,tolerance,S):
    if (sum(non_empty_elms) == 0):
        cropBox = ()
    else:
        non_empty_min = non_empty_elms.argmax()
        non_empty_max = S - non_empty_elms[::-1].argmax()+1
        cropBox = (non_empty_min,non_empty_max)
    return cropBox

def get_cropped_area(image_bw,tol):
    max_val = image_bw.max()
    tolerance = max_val*tol
    non_empty_elms = (image_bw<=tolerance).astype(int)
    S = non_empty_elms.shape
    # Traverse rows
    cropBox = [get_cropped_line(non_empty_elms[k,:],tolerance,S[1]) for k in range(0,S[0])]
    cropBox = filter(None, cropBox)
    xmin = [k[0] for k in cropBox]
    xmax = [k[1] for k in cropBox]
    # Traverse cols
    cropBox = [get_cropped_line(non_empty_elms[:,k],tolerance,S[0]) for k in range(0,S[1])]
    cropBox = filter(None, cropBox)
    ymin = [k[0] for k in cropBox]
    ymax = [k[1] for k in cropBox]
    xmin = min(xmin)
    xmax = max(xmax)
    ymin = min(ymin)
    ymax = max(ymax)
    ymax = ymax-1 # Not sure why this is necessary, but it seems to be.
    cropBox = (ymin, ymax-ymin, xmin, xmax-xmin)
    return cropBox

def auto_crop(f,ext):
    image=Image.open(f)
    image.load()
    image_data = np.asarray(image)
    image_data_bw = image_data[:,:,0]+image_data[:,:,1]+image_data[:,:,2]
    cropBox = get_cropped_area(image_data_bw,grey_tolerance)
    image_data_new = image_data[cropBox[0]:cropBox[1]+1, cropBox[2]:cropBox[3]+1 , :]
    new_image = Image.fromarray(image_data_new)
    f_new = f.replace(ext,'')+'_cropped'+ext
    new_image.save(f_new)
Charles
  • 947
  • 1
  • 15
  • 39