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])
|