File size: 4,808 Bytes
92a5683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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