N / segmentation.py
q6's picture
Preserve segmented text characters with two-pixel padding
a6fb9f6
Raw History Blame Contribute Delete
3.2 kB
import cv2
import numpy as np
MASK_VALUE = 255
ADAPTIVE_BLOCK_SIZE = 35
ADAPTIVE_OFFSET = 8
BACKGROUND_RING_WIDTH = 3
MIN_COMPONENT_AREA = 2
DEFAULT_MARGIN = 2
MAX_MARGIN = 10
POLARITY_AUTO = "Auto"
POLARITY_DARK = "Dark text"
POLARITY_LIGHT = "Light text"
POLARITY_CHOICES = [POLARITY_AUTO, POLARITY_DARK, POLARITY_LIGHT]
def character_mask(rgb_image, polygons, margin=DEFAULT_MARGIN, polarity=POLARITY_AUTO):
margin = int(margin)
if not 0 <= margin <= MAX_MARGIN:
raise ValueError(f"Margin must be between 0 and {MAX_MARGIN}")
if polarity not in POLARITY_CHOICES:
raise ValueError("Invalid text polarity")
gray = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
height, width = gray.shape
mask = np.zeros_like(gray)
padding = max(ADAPTIVE_BLOCK_SIZE // 2, BACKGROUND_RING_WIDTH)
ring_size = BACKGROUND_RING_WIDTH * 2 + 1
ring_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ring_size, ring_size))
for polygon in polygons:
points = np.asarray(polygon, dtype=np.float32).reshape(-1, 2)
if len(points) < 3 or not np.isfinite(points).all():
continue
points = np.rint(points).astype(np.int32)
x, y, box_width, box_height = cv2.boundingRect(points)
left, top = max(0, x - padding), max(0, y - padding)
right = min(width, x + box_width + padding)
bottom = min(height, y + box_height + padding)
if left >= right or top >= bottom:
continue
crop = gray[top:bottom, left:right]
region = np.zeros_like(crop)
cv2.fillPoly(region, [points - (left, top)], MASK_VALUE)
inside = region != 0
if not np.any(inside):
continue
selected_polarity = polarity
if selected_polarity == POLARITY_AUTO:
ring = (cv2.dilate(region, ring_kernel) != 0) & ~inside
background = np.median(crop[ring] if np.any(ring) else crop[inside])
split, _ = cv2.threshold(
crop[inside], 0, MASK_VALUE, cv2.THRESH_BINARY | cv2.THRESH_OTSU
)
selected_polarity = POLARITY_DARK if background > split else POLARITY_LIGHT
dark_text = selected_polarity == POLARITY_DARK
threshold_type = cv2.THRESH_BINARY_INV if dark_text else cv2.THRESH_BINARY
offset = ADAPTIVE_OFFSET if dark_text else -ADAPTIVE_OFFSET
foreground = cv2.adaptiveThreshold(
crop,
MASK_VALUE,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
threshold_type,
ADAPTIVE_BLOCK_SIZE,
offset,
)
foreground = cv2.bitwise_and(foreground, region)
count, labels, stats, _ = cv2.connectedComponentsWithStats(foreground, connectivity=8)
keep = np.zeros(count, dtype=np.uint8)
keep[1:] = (stats[1:, cv2.CC_STAT_AREA] >= MIN_COMPONENT_AREA) * MASK_VALUE
cleaned = keep[labels]
target = mask[top:bottom, left:right]
np.maximum(target, cleaned, out=target)
if margin:
kernel_size = margin * 2 + 1
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
mask = cv2.dilate(mask, kernel)
return mask