detangutify / sequence_segmentation.py
loohhoo's picture
Experimental image segmentation
92a5683 verified
Raw History Blame Contribute Delete
4.81 kB
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from features import _ensure_ink_true, binarize
@dataclass(frozen=True)
class GlyphSegment:
bbox: tuple[int, int, int, int]
image: np.ndarray
def _true_runs(values: np.ndarray) -> list[tuple[int, int]]:
padded = np.pad(np.asarray(values, dtype=np.int8), (1, 1))
changes = np.flatnonzero(np.diff(padded))
return [(int(start), int(end)) for start, end in changes.reshape(-1, 2)]
def _tight_bbox(mask: np.ndarray) -> tuple[int, int, int, int] | None:
ys, xs = np.where(mask)
if not len(xs):
return None
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
def _projection_boundaries(
projection: np.ndarray,
orthogonal_extent: int,
max_characters: int,
) -> list[int]:
extent = int(len(projection))
if extent < 2:
return []
# A real gap between glyph cells is normally much wider than an internal
# one- or two-pixel opening. Permit a tiny amount of antialiasing/noise in
# an otherwise blank column or row.
blank_threshold = max(0, int(round(orthogonal_extent * 0.015)))
minimum_gap = max(2, int(round(orthogonal_extent * 0.06)))
boundaries = [
(start + end) // 2
for start, end in _true_runs(projection <= blank_threshold)
if start > 0 and end < extent and end - start >= minimum_gap
]
if boundaries:
return boundaries[: max(0, max_characters - 1)]
# Touching glyphs may have no completely blank divider. If the ink block
# is clearly wider/taller than one square Tangut cell, look near expected
# cell boundaries for the lowest-ink projection instead.
estimated = min(max_characters, max(1, int(round(extent / max(orthogonal_extent, 1)))))
if estimated < 2 or extent < orthogonal_extent * 1.35:
return []
cell = extent / estimated
minimum_cell = max(2, int(round(cell * 0.55)))
chosen: list[int] = []
for index in range(1, estimated):
target = cell * index
radius = max(2, int(round(cell * 0.22)))
low = max(minimum_cell, int(round(target)) - radius)
high = min(extent - minimum_cell, int(round(target)) + radius)
if high <= low:
continue
candidates = np.arange(low, high + 1)
best = min(
candidates,
key=lambda value: (float(projection[value]), abs(float(value) - target)),
)
if not chosen or best - chosen[-1] >= minimum_cell:
chosen.append(int(best))
return chosen
def segment_glyphs(
gray: np.ndarray,
direction: str = "auto",
max_characters: int = 32,
) -> tuple[str, list[GlyphSegment]]:
image = np.asarray(gray)
if image.ndim != 2:
raise ValueError("Sequence segmentation expects a two-dimensional grayscale image.")
if image.size == 0 or int(image.max()) == int(image.min()):
return "horizontal", []
mask = _ensure_ink_true(binarize(image, min_size=2))
overall = _tight_bbox(mask)
if overall is None:
return "horizontal", []
left, top, right, bottom = overall
width = right - left
height = bottom - top
if direction == "auto":
resolved_direction = "horizontal" if width >= height else "vertical"
elif direction in {"horizontal", "vertical"}:
resolved_direction = direction
else:
raise ValueError("direction must be 'auto', 'horizontal', or 'vertical'.")
tight_mask = mask[top:bottom, left:right]
if resolved_direction == "horizontal":
projection = tight_mask.sum(axis=0)
relative_boundaries = _projection_boundaries(projection, height, max_characters)
boundaries = [left, *(left + value for value in relative_boundaries), right]
regions = [(boundaries[i], top, boundaries[i + 1], bottom) for i in range(len(boundaries) - 1)]
else:
projection = tight_mask.sum(axis=1)
relative_boundaries = _projection_boundaries(projection, width, max_characters)
boundaries = [top, *(top + value for value in relative_boundaries), bottom]
regions = [(left, boundaries[i], right, boundaries[i + 1]) for i in range(len(boundaries) - 1)]
minimum_ink = max(4, int(round(mask.sum() * 0.005)))
segments: list[GlyphSegment] = []
for x0, y0, x1, y1 in regions:
region_mask = mask[y0:y1, x0:x1]
if int(region_mask.sum()) < minimum_ink:
continue
local = _tight_bbox(region_mask)
if local is None:
continue
lx0, ly0, lx1, ly1 = local
box = (x0 + lx0, y0 + ly0, x0 + lx1, y0 + ly1)
bx0, by0, bx1, by1 = box
segments.append(GlyphSegment(box, image[by0:by1, bx0:bx1].copy()))
return resolved_direction, segments