How to crop square inscribed in partial circle?

Question:

I have frames of a video taken from a microscope. I need to crop them to a square inscribed to the circle but the issue is that the circle isn’t whole (like in the following image). How can I do it?
input image from microscope

My idea was to use contour finding to get the center of the circle and then find the distance from each point over the whole array of coordinates to the center, take the maximum distance as the radius and find the corners of the square analytically but there must be a better way to do it (also I don’t really have a formula to find the corners).

Answers:

This may not be adequate in terms of centered at center of circle, but using my iterative processing, one can crop to an approximation of the largest rectangle inside your circle area.

Input:

enter image description here

import cv2
import numpy as np

# read image
img = cv2.imread('img.jpg')
h, w = img.shape[:2]

# threshold so border is black and rest is white (invert as needed). 
# Here I needed to specify the upper threshold at 20 as your black is not pure black.

lower = (0,0,0)
upper = (20,20,20)
mask = cv2.inRange(img, lower, upper)
mask = 255 - mask

# define top and left starting coordinates and starting width and height
top = 0
left = 0
bottom = h
right = w

# compute the mean of each side of the image and its stop test
mean_top = np.mean( mask[top:top+1, left:right] )
mean_left = np.mean( mask[top:bottom, left:left+1] )
mean_bottom = np.mean( mask[bottom-1:bottom, left:right] )
mean_right = np.mean( mask[top:bottom, right-1:right] )

mean_minimum = min(mean_top, mean_left, mean_bottom, mean_right)

top_test = "stop" if (mean_top == 255) else "go"
left_test = "stop" if (mean_left == 255) else "go"
bottom_test = "stop" if (mean_bottom == 255) else "go"
right_test = "stop" if (mean_right == 255) else "go"

# iterate to compute new side coordinates if mean of given side is not 255 (all white) and it is the current darkest side
while top_test == "go" or left_test == "go" or right_test == "go" or bottom_test == "go":

    # top processing
    if top_test == "go":
        if mean_top != 255:
            if mean_top == mean_minimum:
                top += 1
                mean_top = np.mean( mask[top:top+1, left:right] )
                mean_left = np.mean( mask[top:bottom, left:left+1] )
                mean_bottom = np.mean( mask[bottom-1:bottom, left:right] )
                mean_right = np.mean( mask[top:bottom, right-1:right] )
                mean_minimum = min(mean_top, mean_left, mean_right, mean_bottom)
                #print("top",mean_top)
                continue
        else:
            top_test = "stop"   

    # left processing
    if left_test == "go":
        if mean_left != 255:
            if mean_left == mean_minimum:
                left += 1
                mean_top = np.mean( mask[top:top+1, left:right] )
                mean_left = np.mean( mask[top:bottom, left:left+1] )
                mean_bottom = np.mean( mask[bottom-1:bottom, left:right] )
                mean_right = np.mean( mask[top:bottom, right-1:right] )
                mean_minimum = min(mean_top, mean_left, mean_right, mean_bottom)
                #print("left",mean_left)
                continue
        else:
            left_test = "stop"  

    # bottom processing
    if bottom_test == "go":
        if mean_bottom != 255:
            if mean_bottom == mean_minimum:
                bottom -= 1
                mean_top = np.mean( mask[top:top+1, left:right] )
                mean_left = np.mean( mask[top:bottom, left:left+1] )
                mean_bottom = np.mean( mask[bottom-1:bottom, left:right] )
                mean_right = np.mean( mask[top:bottom, right-1:right] )
                mean_minimum = min(mean_top, mean_left, mean_right, mean_bottom)
                #print("bottom",mean_bottom)
                continue
        else:
            bottom_test = "stop"    

    # right processing
    if right_test == "go":
        if mean_right != 255:
            if mean_right == mean_minimum:
                right -= 1
                mean_top = np.mean( mask[top:top+1, left:right] )
                mean_left = np.mean( mask[top:bottom, left:left+1] )
                mean_bottom = np.mean( mask[bottom-1:bottom, left:right] )
                mean_right = np.mean( mask[top:bottom, right-1:right] )
                mean_minimum = min(mean_top, mean_left, mean_right, mean_bottom)
                #print("right",mean_right)
                continue
        else:
            right_test = "stop" 


# crop input
result = img[top:bottom, left:right]

# print crop values 
print("top: ",top)
print("bottom: ",bottom)
print("left: ",left)
print("right: ",right)
print("height:",result.shape[0])
print("width:",result.shape[1])

# save cropped image
#cv2.imwrite('border_image1_cropped.png',result)
cv2.imwrite('img_cropped.png',result)
cv2.imwrite('img_mask.png',mask)

# show the images
cv2.imshow("mask", mask)
cv2.imshow("cropped", result)
cv2.waitKey(0)
cv2.destroyAllWindows()

Result:

enter image description here

Answered By: fmw42

Let’s start with an illustration of the problem to help with the explanation.


Of course, we have to begin with loading the image. Let’s also grab its width and height, since they will be useful later on.

img = cv2.imread('TUP74.jpg', cv2.IMREAD_COLOR)
height, width = img.shape[:2]

First, let’s convert the image to grayscale and then apply threshold to make the circle all white, and the background black. I arbitrarily picked a threshold value of 31, which seems to give reasonable results.

img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(img_gray, 31, 255, cv2.THRESH_BINARY)

The result of those operations looks like this:


Now, we can determine the "top" and "bottom" of the circle (first_yd and last_yd), by finding the first and last row that contains at least one white pixel. I chose to use cv2.reduce to find the maximum of each row (since the thresholded image only contains 0’s and 255’s, a non-zero result means there is at least 1 white pixel), followed by cv2.findNonZero to get the row numbers.

reduced = cv2.reduce(thresh, 1, cv2.REDUCE_MAX)
row_info = cv2.findNonZero(reduced)
first_yd, last_yd = row_info[0][0][1], row_info[-1][0][1]

This information allows us to determine the diameter of the circle d, its radius r (r = d/2), as well as the Y coordinate of the center of the circle center_y.

diameter = last_yd - first_yd
radius = int(diameter / 2)
center_y = first_yd + radius

Next, we need to determine the X coordinate of the center of the circle center_x.

Let’s take advantage of the fact that the circle is cropped on the left-hand side. The white pixels in the first column of the threshold image represent a chord c of the circle (red in the diagram).

Again, we begin with finding the "top" and "bottom" of the chord (first_yc and last_yc), but since we’re working with a single column, we only need cv2.findNonZero.

row_info = cv2.findNonZero(thresh[:,0])
first_yc, last_yc = row_info[0][0][1], row_info[-1][0][1]

c = last_yc - first_yc

Now we have a nice right-angled triangle with one side adjacent to the right angle being half of the chord c (red in the diagram), the other adjacent side being the unknown offset o, and the hypotenuse (green in the diagram) being the radius of the circle r. Let’s apply Pythagoras’ theorem:

r2 = (c/2)2 + o2
o2 = r2 - (c/2)2
o = sqrt(r2 - (c/2)2)

And in Python:

center_x = int(math.sqrt(radius**2 - (c/2)**2))

Now we’re ready to determine the parameters of the inscribed square. Let’s keep in mind that the center of the circle and center of its inscribed square are co-located. Here is another illustration:

We will again use Pythagoras’ theorem. The hypotenuse of the right triangle is again the radius r. Both of the sides adjacent to the right angle are of equal length, which is half the length of the side of inscribed square s.

r2 = (s/2)2 + (s/2)2
r2 = 2 × (s/2)2
r2 = 2 × s2/22
r2 = s2/2
s2 = 2 × r2
s = sqrt(2) × r

And in Python:

s = int(math.sqrt(2) * radius)

Finally, we can determine the top-left and bottom-right corners of the inscribed square. Both of those points are offset by s/2 from the common center.

half_s = int(s/2)
tl = (center_x - half_s, center_y - half_s)
br = (center_x + half_s, center_y + half_s)

We have determined all the parameters we need. Let’s print them out…

Circle diameter = 1167 pixels
Circle radius = 583 pixels
Circle center = (404,1089)
Inscribed square side = 824 pixels
Inscribed square top-left = (-8,677)
Inscribed square bottom-right = (816,1501)

and visualize the center (green), the detected circle (red) and the inscribed square (blue) on a copy of the input image:


Now we can do the cropping, but first we have to make sure we don’t go out of bounds of the source image.

crop_left = max(tl[0], 0)
crop_top = max(tl[1], 0) # Kinda redundant, but why not
crop_right = min(br[0], width)
crop_bottom = min(br[1], height) # ditto

cropped = img[crop_top:crop_bottom, crop_left:crop_right]

And that’s it. Here’s the cropped image (it’s rectangular, since small part of the inscribed square falls outside the source image, and scaled down for embedding — click to get the full-sized image):


Complete Script

import cv2
import numpy as np
import math

img = cv2.imread('TUP74.jpg', cv2.IMREAD_COLOR)
height, width = img.shape[:2]

img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(img_gray, 31, 255, cv2.THRESH_BINARY)

# Find top/bottom of the circle, to determine radius and center
reduced = cv2.reduce(thresh, 1, cv2.REDUCE_MAX)
row_info = cv2.findNonZero(reduced)
first_yd, last_yd = row_info[0][0][1], row_info[-1][0][1]

diameter = last_yd - first_yd
radius = int(diameter / 2)
center_y = first_yd + radius

# Repeat again, just on first column, to find length of a chord of the circle
row_info = cv2.findNonZero(thresh[:,0])
first_yc, last_yc = row_info[0][0][1], row_info[-1][0][1]

c = last_yc - first_yc

# Apply Pythagoras theorem to find the X offset of the center from the chord
# Since the chord is in row 0, this is also the X coordinate
center_x = int(math.sqrt(radius**2 - (c/2)**2))

# Find length of the side of the inscribed square (Pythagoras again)
s = int(math.sqrt(2) * radius)

# Now find the top-left and bottom-right corners of the square
half_s = int(s/2)
tl = (center_x - half_s, center_y - half_s)
br = (center_x + half_s, center_y + half_s)

# Let's print out what we found
print("Circle diameter = %d pixels" % diameter)
print("Circle radius = %d pixels" % radius)
print("Circle center = (%d,%d)" % (center_x, center_y))
print("Inscribed square side = %d pixels" % s)
print("Inscribed square top-left = (%d,%d)" % tl)
print("Inscribed square bottom-right = (%d,%d)" % br)

# And visualize it...
vis = img.copy()
cv2.line(vis, (center_x-5,center_y), (center_x+5,center_y), (0,255,0), 3)
cv2.line(vis, (center_x,center_y-5), (center_x,center_y+5), (0,255,0), 3)
cv2.circle(vis, (center_x,center_y), radius, (0,0,255), 3)
cv2.rectangle(vis, tl, br, (255,0,0), 3)

# Write some illustration images
cv2.imwrite('circ_thresh.png', thresh)
cv2.imwrite('circ_vis.png', vis)

# Time to do some cropping, but we need to make sure the coordinates are inside the bounds of the image
crop_left = max(tl[0], 0)
crop_top = max(tl[1], 0) # Kinda redundant, but why not
crop_right = min(br[0], width)
crop_bottom = min(br[1], height) # ditto

cropped = img[crop_top:crop_bottom, crop_left:crop_right]
cv2.imwrite('circ_cropped.png', cropped)

NB: The main focus of this was the explanation of the algorithm. I’ve been kinda blunt on rounding the values, and there may be some off-by-one errors. For the sake of brevity, error checking is minimal. It’s left as an excercise to the reader to address those issues as necessary.

Furthermore, the assumption is that the left-hand side of the circle is cropped as in the sample image. It should be fairly trivial to extend this to handle other possible scenarios, using the techniques I’ve demonstrated.

Answered By: Dan Mašek

Find the edge points of the image circle, and then fit a circle to the edge.
Or, you may be able to use minEnclosingCircle() instead of circle fitting.

(I omit the explanation of the subsequent steps for obtaining a square.)

Answered By: fana

Building on Dan Mašek’s answer, here is an alternate method of computing the center and radius in Python/OpenCV/Numpy, in particular, the x-coordinate of the center.

The idea is simply find the coordinate of column that has the largest non-zero count in the thresholded image.

Input:

enter image description here

import cv2
import numpy as np
import math

img = cv2.imread('img_circle.jpg')
height, width = img.shape[:2]

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
thresh = cv2.threshold(gray, 31, 255, cv2.THRESH_BINARY)[1]

# Find top/bottom of the circle, to determine radius and y coordinate center
reduced = cv2.reduce(thresh, 1, cv2.REDUCE_MAX)
row_info = cv2.findNonZero(reduced)
first_yd, last_yd = row_info[0][0][1], row_info[-1][0][1]

diameter = last_yd - first_yd
radius = int(diameter / 2)
center_y = first_yd + radius

# count non-zero pixels in columns to find the column with the largest count
# that will give us the x coordinate center
col_counts = np.count_nonzero(thresh, axis=0)
max_counts = np.amax(col_counts)

# find index (x-coordinate) where col_counts=max_counts
max_coords = np.argwhere(col_counts==max_counts)

# get number of max values in case more than one
num_max = len(max_coords)

# compute center_y
center_x = max_coords[0][0] + num_max//2

print("radius:", radius, "center_x:", center_x, "center_y:", center_y)
print('')

Result:

radius: 583 center_x: 388 center_y: 1089

The rest is the same as in Dan Mašek’s answer.

Answered By: fmw42
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.