Find extreme outer points in image with Python OpenCV

Here's a potential approach:

  • Convert image to grayscale and Gaussian blur
  • Threshold to obtain a binary image
  • Find contours
  • Obtain outer coordinates

After converting to grayscale and blurring image, we threshold to get a binary image

Now we find contours using cv2.findContours(). Since OpenCV uses Numpy arrays to encode images, a contour is simply a Numpy array of (x,y) coordinates. We can slice the Numpy array and use argmin() or argmax() to determine the outer left, right, top, and bottom coordinates like this

left = tuple(c[c[:, :, 0].argmin()][0])
right = tuple(c[c[:, :, 0].argmax()][0])
top = tuple(c[c[:, :, 1].argmin()][0])
bottom = tuple(c[c[:, :, 1].argmax()][0])

Here's the result

left: (162, 527)

right: (463, 467)

top: (250, 8)

bottom: (381, 580)

import cv2
import numpy as np

# Load image, grayscale, Gaussian blur, threshold
image = cv2.imread('1.png')
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (3,3), 0)
thresh = cv2.threshold(blur, 220, 255, cv2.THRESH_BINARY_INV)[1]

# Find contours
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if len(cnts) == 2 else cnts[1]
c = max(cnts, key=cv2.contourArea)

# Obtain outer coordinates
left = tuple(c[c[:, :, 0].argmin()][0])
right = tuple(c[c[:, :, 0].argmax()][0])
top = tuple(c[c[:, :, 1].argmin()][0])
bottom = tuple(c[c[:, :, 1].argmax()][0])

# Draw dots onto image
cv2.drawContours(image, [c], -1, (36, 255, 12), 2)
cv2.circle(image, left, 8, (0, 50, 255), -1)
cv2.circle(image, right, 8, (0, 255, 255), -1)
cv2.circle(image, top, 8, (255, 50, 0), -1)
cv2.circle(image, bottom, 8, (255, 255, 0), -1)

print('left: {}'.format(left))
print('right: {}'.format(right))
print('top: {}'.format(top))
print('bottom: {}'.format(bottom))
cv2.imshow('thresh', thresh)
cv2.imshow('image', image)
cv2.waitKey()

Here's a possible improvement to nathancy's answer, where most of the code comes from, also the main idea of using np.argmax. So, please have a look at that answer before!


Since we already have a binarized image from cv2.threshold, such that the (white) background of the input image is set to zero, we can use the ability of cv2.boundingRect to "calculate the up-right bounding rectangle of a point set or non-zero pixels of gray-scale image". The method returns a tuple (x, y, w, h) with (x, y) the upper left point as well as width w and height h of the bounding rectangle. From there, the mentioned points left, right, etc. can be obtained easily using np.argmax on the corresponding slice of the thresh image.

Here comes the full code:

import cv2
import numpy as np

image = cv2.imread('images/dMXjY.png')

blur = cv2.GaussianBlur(image, (3,3), 0)
gray = cv2.cvtColor(blur, cv2.COLOR_BGR2GRAY)

thresh = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY_INV)[1]

x, y, w, h = cv2.boundingRect(thresh)           #  Replaced code
                                                # 
left = (x, np.argmax(thresh[:, x]))             # 
right = (x+w-1, np.argmax(thresh[:, x+w-1]))    # 
top = (np.argmax(thresh[y, :]), y)              # 
bottom = (np.argmax(thresh[y+h-1, :]), y+h-1)   # 

cv2.circle(image, left, 8, (0, 50, 255), -1)
cv2.circle(image, right, 8, (0, 255, 255), -1)
cv2.circle(image, top, 8, (255, 50, 0), -1)
cv2.circle(image, bottom, 8, (255, 255, 0), -1)

print('left: {}'.format(left))
print('right: {}'.format(right))
print('top: {}'.format(top))
print('bottom: {}'.format(bottom))
cv2.imshow('thresh', thresh)
cv2.imshow('image', image)
cv2.waitKey()

The image outputs look like the ones in nathancy's answer.

Nevertheless, one of the resulting points differs a bit:

left: (162, 527)

right: (463, 461) (instead of (463, 467))

top: (250, 8)

bottom: (381, 580)

If we have a closer look on the thresh image, we'll see that for the 463-th column, all pixels in the range of 461 ... 467 have a value of 255. So, for the right edge, there's no unique extreme value.

The contour c found in nathancy's approach holds the two points (463, 467) and (463, 461) in that order, such that np.argmax will find (463, 467) first. In my approach, the 463-th column is examined from 0 to (height of image), such that np.argmax will find (463, 461) first instead.

From my point of view, both (or even all other points in between) are suitable results, since there's no additional constraint on the handling of multiple extreme points.

Using cv2.boundingRect saves two lines of code, and also performs faster, at least according to some short tests using timeit.


Disclosure: Again, most of the code and the main idea come from nathancy's answer.


Rather than inspecting every single element (and stalling the CPU with an if statement for every pixel) it is probably faster to sum all the elements down every column. They should come to 600*255, or 153,000 if they are all white. So, then find where 153,000 minus the column-total is non-zero. The first and last will be the top and bottom of the statue.

Then repeat across the rows to find left and right extrema.

So, starting with the greyscale image, run down each row totalling up the pixels:

import numpy as np

# Total up all the elements in each column
colsums = np.sum(gray, axis=0)

The sums of each column now look like this:

array([153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 152991, 153000, 152976, 152920,
       152931, 152885, 151600, 148818, 147448, 146802, 146568, 146367,
       146179, 145888, 145685, 145366, 145224, 145066, 144745, 144627,
       144511, 144698, 144410, 144329, 144162, 143970, 143742, 143381,
       141860, 139357, 135358, 133171, 131138, 129246, 128410, 127866,
       127563, 127223, 126475, 125614, 125137, 124848, 122906, 121653,
       119278, 115548, 114473, 113800, 113486, 112655, 112505, 112670,
       111845, 111124, 110378, 110315, 109996, 109693, 109649, 109411,
       110626, 110628, 112247, 112348, 111865, 111571, 110601, 108308,
       107213, 106768, 105546, 103971, 103209, 101866, 100215,  98964,
        98559,  97008,  94981,  94513,  92490,  91555,  91491,  90072,
        88642,  87210,  86960,  86834,  85759,  84496,  83237,  81911,
        80249,  78942,  77715,  76918,  75746,  75826,  75443,  75087,
        75156,  75432,  75730,  75699,  77028,  77825,  76813,  76718,
        75958,  75207,  74216,  73042,  72527,  72043,  71819,  71384,
        70693,  69922,  69537,  69685,  69688,  69876,  69552,  68937,
        68496,  67942,  67820,  67626,  67627,  68113,  68426,  67894,
        67868,  67365,  66191,  65334,  65752,  66438,  66285,  66565,
        67616,  69090,  69386,  69928,  70470,  70318,  70228,  71028,
        71197,  71827,  71712,  71312,  72013,  72878,  73398,  74038,
        75017,  76270,  76087,  75317,  75210,  75497,  75099,  75620,
        75059,  75008,  74146,  73531,  73556,  73927,  75395,  77235,
        77094,  77229,  77463,  77808,  77538,  77104,  76816,  76500,
        76310,  76331,  76889,  76293,  75626,  74966,  74871,  74950,
        74931,  74852,  74885,  75077,  75576,  76104,  76208,  75387,
        74971,  75878,  76311,  76566,  77014,  77205,  77231,  77456,
        77983,  78379,  78793,  78963,  79154,  79710,  80777,  82547,
        85164,  88944,  91269,  92438,  93646,  94836,  96071,  97918,
       100244, 102011, 103553, 104624, 104961, 105354, 105646, 105866,
       106367, 106361, 106461, 106659, 106933, 107055, 106903, 107028,
       107080, 107404, 107631, 108022, 108194, 108261, 108519, 109023,
       109349, 109873, 110373, 110919, 111796, 112587, 113219, 114143,
       115161, 115733, 116531, 117615, 118338, 119414, 120492, 121332,
       122387, 123824, 124938, 126113, 127465, 128857, 130411, 131869,
       133016, 133585, 134442, 135772, 136440, 136828, 137200, 137418,
       137705, 137976, 138167, 138481, 138788, 138937, 139194, 139357,
       139375, 139583, 139924, 140201, 140716, 140971, 141285, 141680,
       141837, 141975, 142260, 142567, 142774, 143154, 143533, 143853,
       144521, 145182, 145832, 147978, 149006, 150026, 151535, 152753,
       152922, 152960, 152990, 152991, 153000, 152995, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000,
       153000, 153000, 153000, 153000, 153000, 153000, 153000, 153000],
      dtype=uint64)

Now find where those columns do not sum up to 153,000:

np.nonzero(153000-colsums)                                                                 

That looks like this:

(array([156, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
        170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182,
        183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195,
        196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208,
        209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221,
        222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234,
        235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247,
        248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260,
        261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273,
        274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286,
        287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299,
        300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312,
        313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325,
        326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338,
        339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351,
        352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364,
        365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377,
        378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 388, 389, 390,
        391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403,
        404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416,
        417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429,
        430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442,
        443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455,
        456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 469]),)

So the top row that doesn't consist entirely of white pixels is row 156 (the first entry) and the bottom row that doesn't entirely consist of white pixels is row 469 (the last entry).

Now sum across the other axis (axis=1) and do the same thing again to get left and right extrema.