File size: 13,772 Bytes
2948983
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
"""ํš grouping์„ ์กฐ๊ธฐ ํ™•์ •ํ•˜์ง€ ์•Š๊ณ  ๋ณต์ˆ˜ symbol ํ›„๋ณด๋กœ ๋ณด์กดํ•˜๋Š” ์—ฐ๊ตฌ์šฉ lattice๋‹ค."""

from __future__ import annotations

from collections import defaultdict
from typing import Any, Sequence
import math

import numpy as np


def _stroke_box(stroke: dict[str, Any]) -> tuple[float, float, float, float]:
    """ํ•„์š” ๋ณ€์ˆ˜: point๊ฐ€ ์žˆ๋Š” stroke. ์ž‘๋™ ์›๋ฆฌ: ํ›„๋ณด ๊ฑฐ๋ฆฌยท์ •๋ ฌ์— ์‚ฌ์šฉํ•  bbox๋ฅผ ๊ณ„์‚ฐํ•œ๋‹ค."""

    points = stroke.get("points") or []
    xs = [float(point.get("x", 0.0) if isinstance(point, dict) else point[0]) for point in points]
    ys = [float(point.get("y", 0.0) if isinstance(point, dict) else point[1]) for point in points]
    if not xs:
        raise ValueError("๋นˆ stroke๋Š” segmentation lattice์— ๋„ฃ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
    return min(xs), min(ys), max(xs), max(ys)


def _group_box(indices: frozenset[int], boxes: Sequence[tuple[float, float, float, float]]) -> dict[str, float]:
    """ํ•„์š” ๋ณ€์ˆ˜: stroke index ์ง‘ํ•ฉยท๊ฐœ๋ณ„ bbox. ์ž‘๋™ ์›๋ฆฌ: group ์ „์ฒด bbox๋ฅผ ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•œ ๊ฐ’์œผ๋กœ ํ•ฉ์นœ๋‹ค."""

    return {
        "left": min(boxes[index][0] for index in indices),
        "top": min(boxes[index][1] for index in indices),
        "right": max(boxes[index][2] for index in indices),
        "bottom": max(boxes[index][3] for index in indices),
    }


def _box_gap(first: tuple[float, float, float, float], second: tuple[float, float, float, float]) -> float:
    """ํ•„์š” ๋ณ€์ˆ˜: ๋‘ bbox. ์ž‘๋™ ์›๋ฆฌ: ๊ฒน์น˜๋ฉด 0, ์•„๋‹ˆ๋ฉด ์œ ํด๋ฆฌ๋“œ ์™ธ๊ณฝ ๊ฐ„๊ฒฉ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค."""

    dx = max(first[0] - second[2], second[0] - first[2], 0.0)
    dy = max(first[1] - second[3], second[1] - first[3], 0.0)
    return math.hypot(dx, dy)


def build_group_candidate_lattice(
    strokes: Sequence[dict[str, Any]],
    seed_partitions: Sequence[Sequence[dict[str, Any]]],
    *,
    temporal_window: int = 4,
    spatial_neighbors: int = 3,
) -> list[dict[str, Any]]:
    """ํ•„์š” ๋ณ€์ˆ˜: ์› strokeยท์—ฌ๋Ÿฌ geometry partition. ์ž‘๋™ ์›๋ฆฌ: singleton/์‹œ๊ฐ„์ฐฝ/๊ณต๊ฐ„์ด์›ƒ/๊ธฐ์กด group์„ ์ค‘๋ณต ์ œ๊ฑฐํ•ด ๋ณด์กดํ•œ๋‹ค."""

    usable = [(index, stroke) for index, stroke in enumerate(strokes) if stroke.get("points")]
    if not usable:
        return []
    source_indices = [index for index, _stroke in usable]
    stroke_by_index = {index: stroke for index, stroke in usable}
    boxes_by_source = {index: _stroke_box(stroke) for index, stroke in usable}
    dense_boxes = [boxes_by_source[index] for index in source_indices]
    evidence: dict[frozenset[int], set[str]] = defaultdict(set)

    def add(indices: frozenset[int], reason: str) -> None:
        if indices and indices.issubset(stroke_by_index):
            evidence[indices].add(reason)

    for index in source_indices:
        add(frozenset({index}), "singleton")
    for partition_index, partition in enumerate(seed_partitions):
        for segment in partition:
            add(frozenset(int(index) for index in segment["source_indices"]), f"geometry:{partition_index}")

    # ํ•„๊ธฐ ์ˆœ์„œ๊ฐ€ ์ธ์ ‘ํ•œ ๋‹คํš ๊ธฐํ˜ธ๋ฅผ ๋ณต๊ตฌํ•˜๋˜ ์ง€์ˆ˜ ํญ๋ฐœ์„ ๋ง‰๊ธฐ ์œ„ํ•ด ์งง์€ ์—ฐ์† ์ฐฝ๋งŒ ๋งŒ๋“ ๋‹ค.
    for start in range(len(source_indices)):
        for length in range(2, min(temporal_window, len(source_indices) - start) + 1):
            add(frozenset(source_indices[start:start + length]), f"temporal:{length}")

    # ์‹ค์ œ ๊ธฐํ˜ธ ํš์ด ์‹œ๊ฐ„์ƒ ๋ผ์–ด ์“ฐ์˜€์„ ๋•Œ๋ฅผ ์œ„ํ•ด ๊ฐ ํš์˜ ๊ฐ€๊นŒ์šด ๊ณต๊ฐ„ ์ด์›ƒ pair๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค.
    for dense_index, source_index in enumerate(source_indices):
        neighbor_order = sorted(
            (other for other in range(len(source_indices)) if other != dense_index),
            key=lambda other: (_box_gap(dense_boxes[dense_index], dense_boxes[other]), other),
        )[:spatial_neighbors]
        for other in neighbor_order:
            add(frozenset({source_index, source_indices[other]}), "spatial_pair")

    candidates = []
    for indices, reasons in evidence.items():
        box = _group_box(indices, boxes_by_source)
        candidates.append({
            "source_indices": sorted(indices),
            "strokes": [stroke_by_index[index] for index in sorted(indices)],
            "box": box,
            "evidence": sorted(reasons),
            "candidate_id": "g:" + ",".join(str(index) for index in sorted(indices)),
        })
    candidates.sort(key=lambda row: (row["box"]["left"], len(row["source_indices"]), row["source_indices"]))
    return candidates


def lattice_partition_oracle(
    candidates: Sequence[dict[str, Any]], truth_groups: Sequence[frozenset[int]],
) -> dict[str, Any]:
    """ํ•„์š” ๋ณ€์ˆ˜: ํ›„๋ณด latticeยท์ •๋‹ต partition. ์ž‘๋™ ์›๋ฆฌ: ์ ์ˆ˜์™€ ๋ฌด๊ด€ํ•œ ํ›„๋ณด coverage ์ƒํ•œ์„ ๊ณ„์‚ฐํ•œ๋‹ค."""

    candidate_groups = {frozenset(int(index) for index in row["source_indices"]) for row in candidates}
    matched = [group for group in truth_groups if group in candidate_groups]
    missing = [sorted(group) for group in truth_groups if group not in candidate_groups]
    return {
        "truth_groups": len(truth_groups),
        "matched_groups": len(matched),
        "group_recall": len(matched) / max(len(truth_groups), 1),
        "exact_partition_recoverable": not missing,
        "missing_groups": missing,
    }


LATTICE_FEATURE_NAMES = (
    "stroke_count", "point_count_log", "width_ref", "height_ref", "aspect_log",
    "temporal_span", "temporal_contiguous", "singleton", "geometry_votes",
    "has_temporal", "has_spatial_pair", "pair_gap_mean", "pair_gap_max",
    "stroke_width_mean", "stroke_width_std", "stroke_height_mean", "stroke_height_std",
)

LATTICE_OCR_FEATURE_NAMES = (
    "ocr_top1", "ocr_margin", "ocr_top5_mass", "ocr_entropy", "family_top1",
    "family_margin", "merge_top1_gain", "merge_entropy_gain", "label_alnum",
    "label_operator", "label_container", "label_delimiter", "label_dot_like",
)


def lattice_candidate_features(
    candidates: Sequence[dict[str, Any]], strokes: Sequence[dict[str, Any]],
) -> np.ndarray:
    """ํ•„์š” ๋ณ€์ˆ˜: lattice ํ›„๋ณดยท์› stroke. ์ž‘๋™ ์›๋ฆฌ: label ์—†์ด ๊ณต๋™ selector๊ฐ€ ์‚ฌ์šฉํ•  scale-normalized geometry feature๋ฅผ ๋งŒ๋“ ๋‹ค."""

    boxes = [_stroke_box(stroke) for stroke in strokes]
    nonzero_heights = [box[3] - box[1] for box in boxes if box[3] - box[1] > 1e-6]
    reference_height = float(np.median(nonzero_heights)) if nonzero_heights else 1.0
    rows = []
    for candidate in candidates:
        indices = [int(index) for index in candidate["source_indices"]]
        selected = [boxes[index] for index in indices]
        box = candidate["box"]
        width = float(box["right"] - box["left"])
        height = float(box["bottom"] - box["top"])
        gaps = [_box_gap(selected[first], selected[second]) / reference_height
                for first in range(len(selected)) for second in range(first + 1, len(selected))]
        widths = [(value[2] - value[0]) / reference_height for value in selected]
        heights = [(value[3] - value[1]) / reference_height for value in selected]
        evidence = set(candidate.get("evidence") or [])
        rows.append([
            len(indices), math.log1p(sum(len(strokes[index].get("points") or []) for index in indices)),
            width / reference_height, height / reference_height,
            math.log(max(width, 1e-6) / max(height, 1e-6)),
            max(indices) - min(indices) + 1, float(max(indices) - min(indices) + 1 == len(indices)),
            float(len(indices) == 1), sum(value.startswith("geometry:") for value in evidence),
            float(any(value.startswith("temporal:") for value in evidence)),
            float("spatial_pair" in evidence), float(np.mean(gaps)) if gaps else 0.0,
            max(gaps, default=0.0), float(np.mean(widths)), float(np.std(widths)),
            float(np.mean(heights)), float(np.std(heights)),
        ])
    return np.asarray(rows, dtype=np.float32)


def lattice_ocr_features(candidates: Sequence[dict[str, Any]]) -> np.ndarray:
    """ํ•„์š” ๋ณ€์ˆ˜: OCR summary๊ฐ€ ๋ถ€์ฐฉ๋œ lattice. ์ž‘๋™ ์›๋ฆฌ: confidence์™€ singleton ๋Œ€๋น„ ๊ฒฐํ•ฉ ์ด๋“์„ label-coarse feature๋กœ ๋ฐ”๊พผ๋‹ค."""

    singleton = {
        int(candidate["source_indices"][0]): candidate["ocr_summary"]
        for candidate in candidates if len(candidate["source_indices"]) == 1
    }
    operators = {"+", "-", "=", "/", r"\times", r"\div", r"\pm", r"\neq", "<", ">"}
    containers = {r"\sqrt{}", r"\sqrt", r"\sum", r"\int", r"\prod"}
    delimiters = {"(", ")", "[", "]", r"\{", r"\}", r"\langle", r"\rangle", "|"}
    dot_like = {".", ",", r"\dots", r"\dotsc", r"\cdot"}
    rows = []
    for candidate in candidates:
        summary = candidate.get("ocr_summary")
        if not isinstance(summary, dict):
            raise ValueError("lattice OCR feature์—๋Š” ocr_summary๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
        indices = [int(index) for index in candidate["source_indices"]]
        components = [singleton[index] for index in indices if index in singleton]
        component_top1 = float(np.mean([row["top1"] for row in components])) if components else 0.0
        component_entropy = float(np.mean([row["entropy"] for row in components])) if components else 0.0
        label = str(summary["top_label"])
        rows.append([
            float(summary["top1"]), float(summary["top1"] - summary["top2"]),
            float(summary["top5_mass"]), float(summary["entropy"]), float(summary["family_top1"]),
            float(summary["family_top1"] - summary["family_top2"]),
            float(summary["top1"] - component_top1) if len(indices) > 1 else 0.0,
            float(component_entropy - summary["entropy"]) if len(indices) > 1 else 0.0,
            float(label.isalnum()), float(label in operators), float(label in containers),
            float(label in delimiters), float(label in dot_like),
        ])
    return np.asarray(rows, dtype=np.float32)


def select_lattice_partition(
    candidates: Sequence[dict[str, Any]], scores: Sequence[float], stroke_count: int,
    *, beam_width: int = 128, group_bias: float = 0.0, options_per_stroke: int = 32,
    transition_scores: np.ndarray | None = None,
) -> list[frozenset[int]]:
    """ํ•„์š” ๋ณ€์ˆ˜: ํ›„๋ณดยทlogit scoreยทํš ์ˆ˜. ์ž‘๋™ ์›๋ฆฌ: ๋ชจ๋“  ํš์„ ์ •ํ™•ํžˆ ํ•œ ๋ฒˆ ๋ฎ๋Š” ์ตœ๊ณ  ์ ์ˆ˜ partition์„ beam exact-cover๋กœ ์ฐพ๋Š”๋‹ค."""

    if len(candidates) != len(scores):
        raise ValueError("lattice ํ›„๋ณด์™€ score ์ˆ˜๊ฐ€ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.")
    if stroke_count < 1:
        return []
    if transition_scores is not None and transition_scores.shape != (len(candidates), len(candidates)):
        raise ValueError("candidate transition score ํ–‰๋ ฌ ํฌ๊ธฐ๊ฐ€ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.")
    full_mask = (1 << stroke_count) - 1
    prepared = []
    by_stroke: dict[int, list[int]] = defaultdict(list)
    for candidate_index, (candidate, score) in enumerate(zip(candidates, scores, strict=True)):
        indices = tuple(int(index) for index in candidate["source_indices"])
        if not indices or any(index < 0 or index >= stroke_count for index in indices):
            continue
        mask = sum(1 << index for index in indices)
        prepared.append((candidate_index, mask, frozenset(indices), float(score) + group_bias))
        for index in indices:
            by_stroke[index].append(len(prepared) - 1)
    for index in by_stroke:
        by_stroke[index].sort(key=lambda row: prepared[row][3], reverse=True)
        by_stroke[index] = by_stroke[index][:options_per_stroke]
    if transition_scores is None:
        beams: dict[Any, tuple[float, tuple[frozenset[int], ...]]] = {0: (0.0, ())}
    else:
        beams = {(0, -1): (0.0, ())}
    for _step in range(stroke_count):
        expanded: dict[Any, tuple[float, tuple[frozenset[int], ...]]] = {}
        for state_key, (total_score, groups) in beams.items():
            used_mask, previous_candidate = (state_key, -1) if transition_scores is None else state_key
            if used_mask == full_mask:
                expanded[state_key] = max(expanded.get(state_key, (-math.inf, ())), (total_score, groups), key=lambda row: row[0])
                continue
            first_uncovered = next(index for index in range(stroke_count) if not used_mask & (1 << index))
            for prepared_index in by_stroke.get(first_uncovered, []):
                candidate_index, mask, group, score = prepared[prepared_index]
                if used_mask & mask:
                    continue
                new_mask = used_mask | mask
                transition = 0.0 if transition_scores is None or previous_candidate < 0 else float(
                    transition_scores[previous_candidate, candidate_index]
                )
                proposal = (total_score + score + transition, groups + (group,))
                new_key: Any = new_mask if transition_scores is None else (new_mask, candidate_index)
                if new_key not in expanded or proposal[0] > expanded[new_key][0]:
                    expanded[new_key] = proposal
        if not expanded:
            raise ValueError("๋ชจ๋“  stroke๋ฅผ ๋ฎ๋Š” lattice partition์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
        beams = dict(sorted(expanded.items(), key=lambda row: row[1][0], reverse=True)[:beam_width])
        complete_keys = [key for key in beams if (key if transition_scores is None else key[0]) == full_mask]
        if complete_keys and all((key if transition_scores is None else key[0]) == full_mask for key in beams):
            break
    complete = [value for key, value in beams.items() if (key if transition_scores is None else key[0]) == full_mask]
    if not complete:
        raise ValueError("beam width ์•ˆ์—์„œ ์™„์ „ partition์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
    return list(max(complete, key=lambda row: row[0])[1])