Getting a perfect solution is challenging.
We may find an approximated solution using the following stages:
Start by closing and opening morphological operations.
Apply closing with 5x5 kernel, followed by opening with 3x3 kernel - connecting neighbors without too much dilating:
mask = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
Result:

Removing small contours:
contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
for i, c in enumerate(contours):
area_tresh = 1000
area = cv2.contourArea(c)
if area < area_tresh: # Fill small contours with black color
cv2.drawContours(mask, contours, i, 0, cv2.FILLED)
Result:

Apply opening in the vertical direction and then in the horizontal direction:
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((51, 1), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((1, 51), np.uint8))
Result:

Find largest "black" contour, and approximate it to a rectangle using simplify_contour:
contours, hierarchy = cv2.findContours(255 - mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
c = max(contours, key=cv2.contourArea)
# Simplify contour to rectangle
approx = simplify_contour(c, 4)
out_img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) # Convert to BGR before drawing colored contour.
cv2.drawContours(out_img, [approx], 0, (0, 255, 0), 3)
Result:

For finding the best inner rectangle we my use one of the solutions from the following post.
Code sample:
import cv2
import numpy as np
# https://stackoverflow.com/a/55339684/4926757
def simplify_contour(contour, n_corners=4):
"""
Binary searches best `epsilon` value to force contour
approximation contain exactly `n_corners` points.
:param contour: OpenCV2 contour.
:param n_corners: Number of corners (points) the contour must contain.
:returns: Simplified contour in successful case. Otherwise returns initial contour.
"""
n_iter, max_iter = 0, 100
lb, ub = 0.0, 1.0
while True:
n_iter += 1
if n_iter > max_iter:
return contour
k = (lb + ub)/2.
eps = k*cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, eps, True)
if len(approx) > n_corners:
lb = (lb + ub)/2.0
elif len(approx) < n_corners:
ub = (lb + ub)/2.0
else:
return approx
img = cv2.imread('white_spots.png', cv2.IMREAD_GRAYSCALE) # Read input image as grayscale (assume binary image).
#thresh = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV)[1] # Convert to binary image - use automatic thresholding and invert polarity
thresh = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY)[1] # Convert to binary image (not really required).
# Apply closing with 5x5 kernel, followed by opening with 3x3 kernel - connecting neighbors without too much dilating.
mask = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
cv2.imwrite('mask1.png', mask) # Save for testing
# Find contours, (use cv2.RETR_EXTERNAL)
contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
# Remove small contours
for i, c in enumerate(contours):
area_tresh = 1000
area = cv2.contourArea(c)
if area < area_tresh: # Fill small contours with black color
cv2.drawContours(mask, contours, i, 0, cv2.FILLED)
cv2.imwrite('mask2.png', mask) # Save for testing
# Apply opening in the vertical direction and then in the horizontal direction
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((51, 1), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((1, 51), np.uint8))
cv2.imwrite('mask3.png', mask) # Save for testing
# Find largest "black" contour.
contours, hierarchy = cv2.findContours(255 - mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
c = max(contours, key=cv2.contourArea)
# Simplify contour to rectangle
approx = simplify_contour(c, 4)
out_img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) # Convert to BGR before drawing colored contour.
cv2.drawContours(out_img, [approx], 0, (0, 255, 0), 3)
cv2.imwrite('out_img.png', out_img)