24

I have the following image which has text and a lot of white space underneath the text. I would like to crop the white space such that it looks like the second image.

enter image description here

Cropped Image

enter image description here

Here is what I've done

>>> img = cv2.imread("pg13_gau.jpg.png")
>>> gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
>>> edged = cv2.Canny(gray, 30,300)
>>> (img,cnts, _) = cv2.findContours(edged.copy(), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
>>> cnts = sorted(cnts, key = cv2.contourArea, reverse = True)[:10]
Anthony
  • 33,838
  • 42
  • 169
  • 278
  • 2
    just crop on the `boundingRect` of black pixels – Miki Apr 18 '18 at 19:16
  • `boundingRect` takes contours. Should I find the contours using `findContour`? How do I get the top left black pixel and bottom right black pixel? – Anthony Apr 18 '18 at 19:20
  • 1
    boundingRect takes a vector of points... No need to find contours. C++ code will be just: `std::vector pts; cv::findNonZero(~gray); cv::Rect roi = cv::boundingRect(pts); cv::Mat1b crop = img(roi);` Python won't be very different – Miki Apr 18 '18 at 19:22
  • Check out section 7 `cv2.boundingRect()` from [HERE](https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_imgproc/py_contours/py_contour_features/py_contour_features.html#contour-features) – Jeru Luke Apr 18 '18 at 19:26
  • I did the following but the resulting image is not the cropped image: `img = cv2.imread("ws.png"); gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY); coords = cv2.findNonZero(gray); rekt = cv2.boundingRect(coords) cv2.imwrite("rekt.png",rekt)` – Anthony Apr 18 '18 at 19:35
  • `cv2.boundingRect` returns the **bounding box coordinates**, not the cropped image. – rayryeng Apr 18 '18 at 19:37

3 Answers3

28

As many have alluded in the comments, the best way is to invert the image so the black text becomes white, find all the non-zero points in the image then determine what the minimum spanning bounding box would be. You can use this bounding box to finally crop your image. Finding the contours is very expensive and it isn't needed here - especially since your text is axis-aligned. You can use a combination of cv2.findNonZero and cv2.boundingRect to do what you need.

Therefore, something like this would work:

import numpy as np
import cv2

img = cv2.imread('ws.png') # Read in the image and convert to grayscale
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = 255*(gray < 128).astype(np.uint8) # To invert the text to white
coords = cv2.findNonZero(gray) # Find all non-zero points (text)
x, y, w, h = cv2.boundingRect(coords) # Find minimum spanning bounding box
rect = img[y:y+h, x:x+w] # Crop the image - note we do this on the original image
cv2.imshow("Cropped", rect) # Show it
cv2.waitKey(0)
cv2.destroyAllWindows()
cv2.imwrite("rect.png", rect) # Save the image

The code above exactly lays out what I talked about in the beginning. We read in the image, but we also convert to grayscale as your image is in colour for some reason. The tricky part is the third line of code where I threshold below the intensity of 128 so that the dark text becomes white. This however produces a binary image, so I convert to uint8, then scale by 255. This essentially inverts the text.

Next, given this image we find all of the non-zero coordinates with cv2.findNonZero and we finally put this into cv2.boundingRect which will give you the top-left corner of the bounding box as well as the width and height. We can finally use this to crop the image. Note we do this on the original image and not the inverted one. We use simply NumPy array indexing to do the cropping for us.

Finally, we show the image to show that it works and we save it to disk.


I now get this image:

enter image description here


For the second image, a good thing to do is to remove some of the right border and bottom border. We can do that by cropping the image down to that first. Next, this image contains some very small noisy pixels. I would recommend doing a morphological opening with a very small kernel, then redo the logic we talked about above.

Therefore:

import numpy as np
import cv2

img = cv2.imread('pg13_gau_preview.png') # Read in the image and convert to grayscale
img = img[:-20,:-20] # Perform pre-cropping
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = 255*(gray < 128).astype(np.uint8) # To invert the text to white
gray = cv2.morphologyEx(gray, cv2.MORPH_OPEN, np.ones((2, 2), dtype=np.uint8)) # Perform noise filtering
coords = cv2.findNonZero(gray) # Find all non-zero points (text)
x, y, w, h = cv2.boundingRect(coords) # Find minimum spanning bounding box
rect = img[y:y+h, x:x+w] # Crop the image - note we do this on the original image
cv2.imshow("Cropped", rect) # Show it
cv2.waitKey(0)
cv2.destroyAllWindows()
cv2.imwrite("rect.png", rect) # Save the image

Note: Output image removed due to privacy

rayryeng
  • 102,964
  • 22
  • 184
  • 193
  • @Anthony You'd have to remove some of the right border first as well as some pixels along the buttom, then this will work. The code doesn't work if there are stray blobs like what you see on the right of the image. Try doing this to the image first prior to doing the bounding box selection: `img = img[:-20,:-20]`. This removes the last 20 pixels of both the rows and columns. – rayryeng Apr 18 '18 at 19:50
  • ah I see because then there would be two sections of black in image. Is there any way to remove that programmatically so that the cropping works as desired? – Anthony Apr 18 '18 at 19:53
  • @Anthony Yes, you can definitely remove the border pixels. In MATLAB, there is a function called `imclearborder` that removes any pixels that are connected to the border. OpenCV does not have this function, but I wrote one myself in a previous answer I provided: https://stackoverflow.com/questions/24731810/segmenting-license-plate-characters. Take note that this can get computationally intensive, because the process is to find all contours in the image, then check to see if any contours are touching any of the border pixels in the image. I then paint the area of these contours to black. – rayryeng Apr 18 '18 at 19:55
  • Ok thanks, I am going to try it out. Doing `img = img[:-20,:-20]` didn't help ..it seems to have just removed the whitespace on top but not at bottom – Anthony Apr 18 '18 at 19:57
  • @Anthony I've edited my post to process your second image. In summary, there was a small noisy black pixel towards the bottom of the image which I removed with morphology. – rayryeng Apr 18 '18 at 20:08
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/169277/discussion-between-anthony-and-rayryeng). – Anthony Apr 18 '18 at 20:08
  • Not sure what's so sensitive on that second image, but keep in mind the link is still in the history and the image available on imgur... – Dan Mašek Apr 18 '18 at 21:06
  • 1
    @DanMašek I know. There's nothing I can do about that now, but the second image had a very tiny black dot towards the bottom of the image. If we found all of the non-zero pixels after inverting, this black dot would be included and thus the bounding box that encompasses this set of points would stretch to encompass this noisy black dot. I was only able to see it once I downloaded it and viewed it on GIMP. I removed that with morphology prior to doing the cropping. – rayryeng Apr 18 '18 at 21:16
  • 2
    You could flag it for a moderator to remove it, or send a [removal request](https://imgur.com/removalrequest) to Imgur directly. – Dan Mašek Apr 18 '18 at 21:20
  • @DanMašek Thanks. I will do that. – rayryeng Apr 18 '18 at 21:21
3

Opencv reads the image as a numpy array and it's much simpler to use numpy directly (scikit-image does the same). One possible way of doing it is to read the image as grayscale or convert to it and do the row-wise and column-wise operations as shown in the code snippet below. This will remove the columns and rows when all pixels are of pixel_value (white in this case).

def crop_image(filename, pixel_value=255):
    gray = cv2.imread(filename, cv2.IMREAD_GRAYSCALE)
    crop_rows = gray[~np.all(gray == pixel_value, axis=1), :]
    cropped_image = crop_rows[:, ~np.all(crop_rows == pixel_value, axis=0)]
    return cropped_image

and the output:

enter image description here

mobiuscreek
  • 421
  • 6
  • 6
-1

This would also work:

from PIL import Image, ImageChops

img = Image.open("pUq4x.png")
pixels = img.load()

print (f"original: {img.size[0]} x {img.size[1]}")
xlist = []
ylist = []
for y in range(0, img.size[1]):
    for x in range(0, img.size[0]):
        if pixels[x, y] != (255, 255, 255, 255):
            xlist.append(x)
            ylist.append(y)
left = min(xlist)
right = max(xlist)
top = min(ylist)
bottom = max(ylist)

img = img.crop((left-10, top-10, right+10, bottom+10))
img.show()


enter image description here

Alireza
  • 5,444
  • 9
  • 38
  • 50
  • 1
    Not optimum, You can save a record of max and min inside of the for loop. – Amir Fo Jun 01 '21 at 05:34
  • Could you convert the array to NumPy and use `np.min` and `np.max`? Double `for` loops for image processing in Python are not preferred. – rayryeng Jun 01 '21 at 13:21