Chupo_cro
Chupo_cro

Reputation: 718

The fastest way to exclude surrounding zeros from an array representing an image?

I have a 2D array containing grayscale image created from .png as follows:

import cv2

img = cv2.imread("./images/test.png")
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

What I would like to do is to extract a subarray containing only the rectangle containing the data - ignoring all zeros surrounding the picture.

For example, if input is:

  0   0   0   0   0   0   0   0
  0   0   0   0   0   0   0   0
  0   0 175   0   0   0  71   0
  0   0   0  12   8  54   0   0
  0   0   0   0 255   0   0   0
  0   0   0   2   0   0   0   0
  0   0   0   0   0   0   0   0
  0   0   0   0   0   0   0   0

then output should be:

175   0   0   0  71
  0  12   8  54   0
  0   0 255   0   0
  0   2   0   0   0

I could iterate over the rows in forward direction to find the first nonzero row and then iterate over the rows backwards to find the last nonzero row remembering indices - and then repeat the same for the columns and then extract a subarray using that data but I am sure there are more appropriate ways of doing the same or there even might be a NumPy function designed for such a purpose.

If I were to choose between shortest code vs fastest execution I'd be more interested in fastest code execution.

EDIT:
I did not include the best example because there could be zero rows/columns in the middle as here:

Input:

  0   0   0   0   0   0   0   0
  0   0   0   0   0   0   0   0
  0   0 175   0   0   0  71   0
  0   0   0  12   8  54   0   0
  0   0   0   0 255   0   0   0
  0   0   0   0   0   0   0   0
  0   0   0   2   0   0   0   0
  0   0   0   0   0   0   0   0

Output:

175   0   0   0  71
  0  12   8  54   0
  0   0 255   0   0
  0   0   0   0   0
  0   2   0   0   0

Upvotes: 9

Views: 3461

Answers (3)

fireant
fireant

Reputation: 14530

Update This simpler method using opencv functions actually faster, and probably faster than the other methods presented in the other answers here.

def crop_fastest(arr):
    return cv2.boundingRect(cv2.findNonZero(arr))

This return the x, y, width, and the height of bounding box. On my desktop pc for my old code 1000 loops, best of 3: 562 µs per loop while for this new code 10000 loops, best of 3: 179 µs per loop.

Yet Another Update

As Chupo_cro pointed out simply calling cv2.boundingRect(arr) returns the same result, and that seems to be due to the code in this function that does the conversion internally.

Previous answer

Probably there are faster methods for this. This simpler function is slightly faster.

from scipy import ndimage
def crop_fast(arr):
    slice_x, slice_y = ndimage.find_objects(arr>0)[0]
    return arr[slice_x, slice_y]

To compare the speeds of droooze's code and this one,

arr = np.zeros(shape=(50000,6), dtype=np.uint8)
arr[2] = [9,8,0,0,1,1]
arr[1] = [0,3,0,0,1,1]

Then %timeit crop(arr) returns 1000 loops, best of 3: 1.62 ms per loop and %timeit crop_fast(arr) returns 1000 loops, best of 3: 979 µs per loop on my laptop. That is, crop_fast() takes about 60% of time by crop().

Upvotes: 5

dROOOze
dROOOze

Reputation: 3532

Note, not an OpenCV solution - this will work for n-dimensional NumPy or SciPy arrays in general.

(Based on Divakar's answer, extended to n dimensions)

def crop_new(arr):

    mask = arr != 0
    n = mask.ndim
    dims = range(n)
    slices = [None]*n

    for i in dims:
        mask_i = mask.any(tuple(dims[:i] + dims[i+1:]))
        slices[i] = (mask_i.argmax(), len(mask_i) - mask_i[::-1].argmax())

    return arr[[slice(*s) for s in slices]]

Speed tests:

In [42]: np.random.seed(0)

In [43]: a = np.zeros((30, 30, 30, 20),dtype=np.uint8)

In [44]: a[2:-2, 2:-2, 2:-2, 2:-2] = np.random.randint(0,255,(26,26,26,16),dtype
=np.uint8)

In [45]: timeit crop(a) # Old solution
1 loop, best of 3: 181 ms per loop

In [46]: timeit crop_fast(a) # modified fireant's solution for n-dimensions
100 loops, best of 3: 5 ms per loop

In [48]: timeit crop_new(a) # modified Divakar's solution for n-dimensions
100 loops, best of 3: 1.91 ms per loop

Old solution

You can use np.nonzero to get the indices of the array. The bounding box of this array are then contained entirely in the maximum and minimum values of the indices.

def _get_slice_bbox(arr):
    nonzero = np.nonzero(arr)
    return [(min(a), max(a)+1) for a in nonzero]

def crop(arr):
    slice_bbox = _get_slice_bbox(arr)
    return arr[[slice(*a) for a in slice_bbox]]

E.g.

>>> img = np.array([[  0,   0,   0,   0,   0,   0,   0,   0],
                    [  0,   0,   0,   0,   0,   0,   0,   0],
                    [  0,   0, 175,   0,   0,   0,  71,   0],
                    [  0,   0,   0,  12,   8,  54,   0,   0],
                    [  0,   0,   0,   0, 255,   0,   0,   0],
                    [  0,   0,   0,   2,   0,   0,   0,   0],
                    [  0,   0,   0,   0,   0,   0,   0,   0],
                    [  0,   0,   0,   0,   0,   0,   0,   0]],  dtype='uint8')
>>> print crop(img)
[[175   0   0   0  71]
 [  0  12   8  54   0]
 [  0   0 255   0   0]
 [  0   2   0   0   0]]

Upvotes: 11

Divakar
Divakar

Reputation: 221574

We could use argmax to get the start, stop row and column indices, as discussed in some detail in this post. We also intend to work with boolean arrays/masks for efficient processing. Thus, using these tools/ideas, we would have one vectorized solution, like so -

def remove_black_border(a): 
    # Mask of non-zeros
    mask = a!=0 # Use a >tolerance for a tolerance defining black border

    # Mask of non-zero rows and columns
    mask_row = mask.any(1)
    mask_col = mask.any(0)

    # First, last indices among the non-zero rows
    sr0,sr1 = mask_row.argmax(), len(mask_row) - mask_row[::-1].argmax()

    # First, last indices among the non-zero columns
    sc0,sc1 = mask_col.argmax(), len(mask_col) - mask_col[::-1].argmax()

    # Finally slice along the rows & cols with the start and stop indices to get 
    # cropped image. Slicing helps for an efficient operation.
    return a[sr0:sr1, sc0:sc1]

Sample run -

In [56]: a # Input image array
Out[56]: 
array([[  0,   0,   0,   0,   0,   0,   0,   0],
       [  0,   0,   0,   0,   0,   0,   0,   0],
       [  0,   0,   0,   0,   0,   0,   0,   0],
       [  0,   0,   0, 175,   0,   0,   0,  71],
       [  0,   0,   0,   0,  12,   8,  54,   0],
       [  0,   0,   0,   0,   0, 255,   0,   0],
       [  0,   0,   0,   0,   0,   0,   0,   0],
       [  0,   0,   0,   0,   2,   0,   0,   0],
       [  0,   0,   0,   0,   0,   0,   0,   0],
       [  0,   0,   0,   0,   0,   0,   0,   0]])

In [57]: out = remove_black_border(a)

In [58]: out
Out[58]: 
array([[175,   0,   0,   0,  71],
       [  0,  12,   8,  54,   0],
       [  0,   0, 255,   0,   0],
       [  0,   0,   0,   0,   0],
       [  0,   2,   0,   0,   0]])

Memory efficiency :

The output is a view into the input array, so no extra memory or copying is needed, which is helping on memory efficiency. Let's verify the view part -

In [59]: np.shares_memory(a, out)
Out[59]: True

Timings with all proposed approaches on bigger image

In [105]: # Setup for 1000x1000 2D image and 100 offsetted boundaries all across
     ...: np.random.seed(0)
     ...: a = np.zeros((1000,1000),dtype=np.uint8)
     ...: a[100:-100,100:-100] = np.random.randint(0,255,(800,800),dtype=np.uint8)

In [106]: %timeit crop_fast(a) # @fireant's soln
     ...: %timeit crop(a)      # @droooze's soln
     ...: %timeit remove_black_border(a) # from this post
100 loops, best of 3: 4.58 ms per loop
10 loops, best of 3: 127 ms per loop
10000 loops, best of 3: 155 µs per loop

Upvotes: 8

Related Questions