Here's an algorithm to fit quadrangles to arbitrary masks using the technique from View Frustum Optimization
To Maximize Object’s Image Area.
Here's the output -
import cv2
import numpy as np
import sympy
def appx_best_fit_ngon(mask_cv2, n: int = 4) -> list[(int, int)]:
# convex hull of the input mask
mask_cv2_gray = cv2.cvtColor(mask_cv2, cv2.COLOR_RGB2GRAY)
contours, _ = cv2.findContours(
mask_cv2_gray, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
)
hull = cv2.convexHull(contours[0])
hull = np.array(hull).reshape((len(hull), 2))
# to sympy land
hull = [sympy.Point(*pt) for pt in hull]
# run until we cut down to n vertices
while len(hull) > n:
best_candidate = None
# for all edges in hull ( <edge_idx_1>, <edge_idx_2> ) ->
for edge_idx_1 in range(len(hull)):
edge_idx_2 = (edge_idx_1 + 1) % len(hull)
adj_idx_1 = (edge_idx_1 - 1) % len(hull)
adj_idx_2 = (edge_idx_1 + 2) % len(hull)
edge_pt_1 = sympy.Point(*hull[edge_idx_1])
edge_pt_2 = sympy.Point(*hull[edge_idx_2])
adj_pt_1 = sympy.Point(*hull[adj_idx_1])
adj_pt_2 = sympy.Point(*hull[adj_idx_2])
subpoly = sympy.Polygon(adj_pt_1, edge_pt_1, edge_pt_2, adj_pt_2)
angle1 = subpoly.angles[edge_pt_1]
angle2 = subpoly.angles[edge_pt_2]
# we need to first make sure that the sum of the interior angles the edge
# makes with the two adjacent edges is more than 180°
if sympy.N(angle1 + angle2) <= sympy.pi:
continue
# find the new vertex if we delete this edge
adj_edge_1 = sympy.Line(adj_pt_1, edge_pt_1)
adj_edge_2 = sympy.Line(edge_pt_2, adj_pt_2)
intersect = adj_edge_1.intersection(adj_edge_2)[0]
# the area of the triangle we'll be adding
area = sympy.N(sympy.Triangle(edge_pt_1, intersect, edge_pt_2).area)
# should be the lowest
if best_candidate and best_candidate[1] < area:
continue
# delete the edge and add the intersection of adjacent edges to the hull
better_hull = list(hull)
better_hull[edge_idx_1] = intersect
del better_hull[edge_idx_2]
best_candidate = (better_hull, area)
if not best_candidate:
raise ValueError("Could not find the best fit n-gon!")
hull = best_candidate[0]
# back to python land
hull = [(int(x), int(y)) for x, y in hull]
return hull
Here's the code I used to generate this image -
hull = appx_best_fit_ngon(mask_cv2)
for idx in range(len(hull)):
next_idx = (idx + 1) % len(hull)
cv2.line(mask_cv2, hull[idx], hull[next_idx], (0, 255, 0), 1)
for pt in hull:
cv2.circle(mask_cv2, pt, 2, (255, 0, 0), 2)