Benjamin Kolber
Benjamin Kolber

Reputation: 176

Removing blank space around a circle shaped mask

I have an image of a circular-shaped mask, which is essentially a colored circle within a black image. mask

I want to remove all the blank space around the mask, such that the boundaries of the image align with the circle as such: original vs cropped

I've written up a script to do this by searching through every column and row until a pixel with a value greater than 0 appears. searching from left to right, right to left, top to bottom, and bottom to the top gets me the mask boundaries, allowing me to crop the original image. Here is the code:

ROWS, COLS, _ = img.shape

BORDER_RIGHT = (0,0)
BORDER_LEFT = (0,0)

right_found = False
left_found = False 

# find borders of blank space for removal.
# left and right border
print('Searching for Right and Left corners')
for col in tqdm(range(COLS), position=0, leave=True):
    for row in range(ROWS):
        if left_found and right_found:
            break
        
        # searching from left to right 
        if not left_found and N.sum(img[row][col]) > 0:
            BORDER_LEFT = (row, col)
            left_found = True
        
        # searching from right to left 
        if not right_found and N.sum(img[row][-col]) > 0:
            BORDER_RIGHT = (row, img.shape[1] + (-col))
            right_found = True

BORDER_TOP = (0,0)
BORDER_BOTTOM = (0,0)

top_found = False
bottom_found = False             

# top and bottom borders 
print('Searching for Top and Bottom corners')
for row in tqdm(range(ROWS), position=0, leave=True):
    for col in range(COLS):
        if top_found and bottom_found:
            break
        
        # searching top to bottom 
        if not top_found and N.sum(img[row][col]) > 0:
            BORDER_TOP = (row, col)
            top_found = True
        
        # searching bottom to top
        if not bottom_found and N.sum(img[-row][col]) > 0:
            BORDER_BOTTOM = (img.shape[0] + (-row), col)
            bottom_found = True

# crop left and right borders 
new_img = img[:,BORDER_LEFT[1]: BORDER_RIGHT[1] ,:]

# crop top and bottom borders 
new_img = new_img[BORDER_TOP[0] : BORDER_BOTTOM[0],:,:]

I was wondering whether there was a more efficient way to do this. With larger images, this can be quite time-consuming especially if the mask is relatively small with respect to the original image shape. thanks!

Upvotes: 2

Views: 900

Answers (1)

rayryeng
rayryeng

Reputation: 104503

Assuming you have only this object inside the image, there are two ways to do this:

  1. You can threshold the image, then use numpy.where to find all locations that are non-zero, then use numpy.min and numpy.max on the appropriate row and column locations that come out of numpy.where to give you the bounding rectangle.
  2. You can first find the contour points of the object after you threshold with cv2.findContours. This should result in a single contour, so once you have these points you put this through cv2.boundingRect to return the top-left corner of the rectangle followed by the width and height of its extent.

The first method will work if there is a single object and efficiently at that. The second one will work if there is more than one object, but you have to know which contour the object of interest is in, then you simply index into the output of cv2.findContours and pipe this through cv2.boundingRect to get the rectangular dimensions of the object of interest.

However, the takeaway is that either of these methods is much more efficient than the approach you have proposed where you are manually looping over each row and column and calculating sums.


Pre-processing

These sets of steps are going to be common to both methods. In summary, we read in the image, then convert it to grayscale then threshold. I didn't have access to your original image so I read it in from Stack Overflow and cropped it so that the axes are not showing. This will apply to the second method as well.

Here's a reconstruction of your image where I've taken a snapshot.

Original

First I'll read in the image directly from the Internet as well as import the relevant packages I need to get the job done:

import skimage.io as io
import numpy as np
import cv2

img = io.imread('https://i.sstatic.net/dj1a8.png')

Thankfully, Scikit image has a method that reads in images directly from the Internet: skimage.io.imread.

After, I'm going to convert the image to grayscale, then threshold it:

img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
im = img_gray > 40

I use OpenCV's cv2.cvtColor to convert the image from colour to grayscale. After, I threshold the image so that any intensity above 40 is set to True and everything else is set to False. The threshold of 40 I chose by trial and error until I get a mask that appeared to be circular. Taking a look at this image we get:

Threhsold


Method #1

As I illustrated above, use numpy.where on the thresholded image, then use numpy.min and numpy.max find the appropriate top-left and bottom-right corners and crop the image:

(r, c) = np.where(im == 1)
min_row, min_col = np.min(r), np.min(c)
max_row, max_col = np.max(r), np.max(c)
im_crop = img[min_row:max_row+1, min_col:max_col+1]

numpy.where for a 2D array will return a tuple of row and column locations that are non-zero. If we find the minimum row and column location, that corresponds to the top-left corner of the bounding rectangle. Similarly, the maximum row and column location corresponds to the bottom-right corner of the bounding rectangle. What's nice is that numpy.min and numpy.max work in a vectorised fashion, meaning that it operates on entire NumPy arrays in a single sweep. This logic is used above, then we index into the original colour image to crop out the range of rows and columns that contain the object of interest. im_crop contains that result. Note that the maximum row and column needs to be added with 1 when we're indexing as slicing with the end indices are exclusive so adding with 1 ensures we include the pixel locations at the bottom right corner of the rectangle.

We therefore get:

Crop 1

Method #2

We will use cv2.findContours to find all contour points of all objects in the image. Because there's a single object, only one contour should result, so we use this contour to pipe into cv2.boundingRect to find the top-left corner of the bounding rectangle of the object, combined with its width and height to crop out the image.

cnt, _ = cv2.findContours(im.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
x, y, w, h = cv2.boundingRect(cnt[0])
im_crop = img[y:y+h, x:x+w]

Take note that we have to convert the thresholded image into unsigned 8-bit integer, as that is the type that the function is expecting. Furthermore, we use cv2.RETR_EXTERNAL as we only want to retrieve the coordinates of the outer perimeter of any objects we see in the image. We also use cv2.CHAIN_APPROX_NONE to return every possible contour point on the object. The cnt is a list of contours that was found in the image. The size of this list should only be 1, so we index into this directly and pipe this into cv2.boundingRect. We then use the top-left corner of the rectangle, combined with its width and height to crop out the object.

We therefore get:

Crop 2


Full Code

Here's the full code listing from start to finish. I've left comments below to delineate what methods #1 and #2 are. For now, method #2 has been commented out, but you can decide whichever one you want to use by simply commenting and uncommenting the relevant code.

import skimage.io as io
import cv2
import numpy as np

img = io.imread('https://i.sstatic.net/dj1a8.png')

img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
im = img_gray > 40

# Method #1
(r, c) = np.where(im == 1)
min_row, min_col = np.min(r), np.min(c)
max_row, max_col = np.max(r), np.max(c)
im_crop = img[min_row:max_row+1, min_col:max_col+1]

# Method #2
#cnt, _ = cv2.findContours(im.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
#x, y, w, h = cv2.boundingRect(cnt[0])
#im_crop = img[y:y+h, x:x+w]

Upvotes: 5

Related Questions