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