| """์จ๋ผ์ธ ํ๊ธฐ stroke๋ฅผ ๋ชจ๋ธ ๋
๋ฆฝ์ ์ธ 2์ฐจ์ ์์ ์
๋ก ๋ฌถ๋๋ค.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from statistics import median |
| from typing import Any, Sequence |
|
|
| from PIL import Image, ImageDraw |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class GridConfig: |
| """ํ์ ๋ณ์: ๊ธฐํ ์๊ณ๊ฐ๊ณผ ์
๋ ฅ ์ํ์ด๋ค. |
| ์๋ ์๋ฆฌ: ๊ณ ์ ์ต์๊ฐ๊ณผ ๋ํ ํ ๋์ด ๋น์จ์ ํจ๊ป ์ฌ์ฉํด ๊ธฐ๊ธฐ ์ขํ๊ณ ๋ณํ์ ๋์ํ๋ค. |
| """ |
|
|
| baseline_tolerance: float = 24.0 |
| expression_gap: float = 72.0 |
| structure_gap: float = 52.0 |
| padding: float = 12.0 |
| max_strokes: int = 2_000 |
| max_points_per_stroke: int = 4_096 |
| max_total_points: int = 200_000 |
| coordinate_limit: float = 20_000.0 |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class StrokeBox: |
| """ํ์ ๋ณ์: ์ขยท์ยท์ฐยทํ ๊ฒฝ๊ณ๋ค. |
| ์๋ ์๋ฆฌ: ํ๊ณผ ์์ ์
์ ์ถ ์ ๋ ฌ ๊ฒฝ๊ณ ๋ฐ ๋ณํฉ ์ฐ์ฐ์ ์ ๊ณตํ๋ค. |
| """ |
|
|
| left: float |
| top: float |
| right: float |
| bottom: float |
|
|
| @property |
| def width(self) -> float: |
| """ํ์ ๋ณ์: ์ข์ฐ ์ขํ. ์๋ ์๋ฆฌ: ์์๊ฐ ์๋ ํญ์ ๋ฐํํ๋ค.""" |
| return max(0.0, self.right - self.left) |
|
|
| @property |
| def height(self) -> float: |
| """ํ์ ๋ณ์: ์ํ ์ขํ. ์๋ ์๋ฆฌ: ์์๊ฐ ์๋ ๋์ด๋ฅผ ๋ฐํํ๋ค.""" |
| return max(0.0, self.bottom - self.top) |
|
|
| @property |
| def center_y(self) -> float: |
| """ํ์ ๋ณ์: ์ํ ์ขํ. ์๋ ์๋ฆฌ: ๊ฐ์ ๊ธฐ์ค์ ํ์ ์ฉ ์ค์ฌ์ ๋ฐํํ๋ค.""" |
| return (self.top + self.bottom) / 2.0 |
|
|
| def union(self, other: "StrokeBox") -> "StrokeBox": |
| """ํ์ ๋ณ์: ๋ค๋ฅธ ๊ฒฝ๊ณ. ์๋ ์๋ฆฌ: ๋ ๊ฒฝ๊ณ๋ฅผ ํฌํจํ๋ ์ต์ ๊ฒฝ๊ณ๋ฅผ ๋ง๋ ๋ค.""" |
| return StrokeBox( |
| min(self.left, other.left), |
| min(self.top, other.top), |
| max(self.right, other.right), |
| max(self.bottom, other.bottom), |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class Stroke: |
| """ํ์ ๋ณ์: ์์ ์ ์ธ ํ IDยท์์ ์๋ ์ ยท์ ๊ตต๊ธฐ๋ค. |
| ์๋ ์๋ฆฌ: ์ ๋ฐ์ง๋ฆ์ ํฌํจํ ๊ฒฝ๊ณ๋ฅผ ๊ณ์ฐํด ํ๊ธฐ ํญ์ ๋ณด์กดํ๋ค. |
| """ |
|
|
| stroke_id: int |
| points: tuple[tuple[float, float], ...] |
| width: float = 3.0 |
|
|
| @property |
| def box(self) -> StrokeBox: |
| """ํ์ ๋ณ์: ํ๊ธฐ์ ยท๊ตต๊ธฐ. ์๋ ์๋ฆฌ: ์ ๋ฐ์ง๋ฆ๊น์ง ํฌํจํ ๊ฒฝ๊ณ๋ฅผ ๊ณ์ฐํ๋ค.""" |
| radius = self.width / 2.0 |
| return StrokeBox( |
| min(point[0] for point in self.points) - radius, |
| min(point[1] for point in self.points) - radius, |
| max(point[0] for point in self.points) + radius, |
| max(point[1] for point in self.points) + radius, |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class FormulaGrid: |
| """ํ์ ๋ณ์: ์
์๋ฒยท๊ฒฝ๊ณยท์์ ํ ID๋ค. |
| ์๋ ์๋ฆฌ: ์ธ์๊ธฐ์ ๋ฌด๊ดํ ๊ฒฐ์ ์ ์์ ์
๊ณ์ฝ์ ํํํ๋ค. |
| """ |
|
|
| index: int |
| box: StrokeBox |
| stroke_ids: tuple[int, ...] |
|
|
|
|
| class _DisjointSet: |
| """ํ์ ๋ณ์: ํ ๊ฐ์. ์๋ ์๋ฆฌ: ๊ฒฝ๋ก ์์ถ๊ณผ ํฌ๊ธฐ ๊ธฐ์ค ๋ณํฉ์ผ๋ก ์ฐ๊ฒฐ ์ฑ๋ถ์ ๊ด๋ฆฌํ๋ค.""" |
|
|
| def __init__(self, size: int) -> None: |
| """ํ์ ๋ณ์: ์์ ์. ์๋ ์๋ฆฌ: ๊ฐ ํ์ ๋
๋ฆฝ ์งํฉ์ผ๋ก ์ด๊ธฐํํ๋ค.""" |
| self.parent = list(range(size)) |
| self.size = [1] * size |
|
|
| def find(self, value: int) -> int: |
| """ํ์ ๋ณ์: ํ ์ธ๋ฑ์ค. ์๋ ์๋ฆฌ: ๋ํ๋ฅผ ์ฐพ๋ ๋์ ๊ฒฝ๋ก๋ฅผ ์์ถํ๋ค.""" |
| while self.parent[value] != value: |
| self.parent[value] = self.parent[self.parent[value]] |
| value = self.parent[value] |
| return value |
|
|
| def union(self, first: int, second: int) -> None: |
| """ํ์ ๋ณ์: ๋ ํ ์ธ๋ฑ์ค. ์๋ ์๋ฆฌ: ์์ ํธ๋ฆฌ๋ฅผ ํฐ ํธ๋ฆฌ ์๋์ ๊ฒฐํฉํ๋ค.""" |
| root_first = self.find(first) |
| root_second = self.find(second) |
| if root_first == root_second: |
| return |
| if self.size[root_first] < self.size[root_second]: |
| root_first, root_second = root_second, root_first |
| self.parent[root_second] = root_first |
| self.size[root_first] += self.size[root_second] |
|
|
|
|
| def parse_writing_events(value: Any, config: GridConfig | None = None) -> list[Stroke]: |
| """ํ์ ๋ณ์: JSON ์ ์ฌ writing events์ ์ ํ ์ค์ ์ด๋ค. |
| ์๋ ์๋ฆฌ: ์ ํํ ์ขํยท์ ยทํ๋ง ์ํ ์์์ ๋ฐ์ ์์ ํ ๋ถ๋ณ Stroke๋ก ์ ๊ทํํ๋ค. |
| """ |
| settings = config or GridConfig() |
| if not isinstance(value, list): |
| return [] |
| total_points = 0 |
| strokes: list[Stroke] = [] |
| for fallback_id, item in enumerate(value[: settings.max_strokes]): |
| if not isinstance(item, dict) or not isinstance(item.get("points"), list): |
| continue |
| points: list[tuple[float, float]] = [] |
| for point in item["points"][: settings.max_points_per_stroke]: |
| if not isinstance(point, dict): |
| continue |
| try: |
| x = float(point["x"]) |
| y = float(point["y"]) |
| except (KeyError, TypeError, ValueError): |
| continue |
| if abs(x) <= settings.coordinate_limit and abs(y) <= settings.coordinate_limit: |
| points.append((x, y)) |
| if len(points) < 2: |
| continue |
| if total_points + len(points) > settings.max_total_points: |
| break |
| try: |
| stroke_id = int(item.get("stroke_id", fallback_id)) |
| width = min(32.0, max(0.5, float(item.get("width", 3.0)))) |
| except (TypeError, ValueError): |
| continue |
| strokes.append(Stroke(stroke_id, tuple(points), width)) |
| total_points += len(points) |
| return strokes |
|
|
|
|
| def build_formula_grids( |
| strokes: Sequence[Stroke], |
| config: GridConfig | None = None, |
| ) -> list[FormulaGrid]: |
| """ํ์ ๋ณ์: ์ ๊ทํ๋ ํ๊ณผ ์ ํ ์ค์ ์ด๋ค. |
| ์๋ ์๋ฆฌ: ๊ธฐ์ค์ ยท๊ทผ์ ๋ถ์ฐฉยท๊ตฌ์กฐ์ ๊ด๊ณ ๊ทธ๋ํ์ ์ฐ๊ฒฐ ์ฑ๋ถ์ 2์ฐจ์ ์์ ์
๋ก ๋ง๋ ๋ค. |
| """ |
| if not strokes: |
| return [] |
| settings = config or GridConfig() |
| boxes = [stroke.box for stroke in strokes] |
| typical_height = median(box.height for box in boxes) |
| baseline = max(settings.baseline_tolerance, typical_height * 0.50) |
| expression_gap = max(settings.expression_gap, typical_height * 1.25) |
| structure_gap = max(settings.structure_gap, typical_height * 0.80) |
| attachment_gap = max(18.0, typical_height * 0.18) |
| groups = _DisjointSet(len(strokes)) |
|
|
| for first in range(len(strokes)): |
| for second in range(first + 1, len(strokes)): |
| horizontal_gap = _axis_gap( |
| boxes[first].left, |
| boxes[first].right, |
| boxes[second].left, |
| boxes[second].right, |
| ) |
| vertical_gap = _axis_gap( |
| boxes[first].top, |
| boxes[first].bottom, |
| boxes[second].top, |
| boxes[second].bottom, |
| ) |
| center_gap = abs(boxes[first].center_y - boxes[second].center_y) |
| same_line = center_gap <= baseline and horizontal_gap <= expression_gap |
| attachment = ( |
| horizontal_gap <= attachment_gap |
| and vertical_gap <= attachment_gap |
| and center_gap |
| <= max(boxes[first].height, boxes[second].height) * 1.35 + attachment_gap |
| ) |
| if same_line or attachment: |
| groups.union(first, second) |
|
|
| _join_structural_bridges(boxes, groups, structure_gap) |
| members: dict[int, list[int]] = {} |
| for index in range(len(strokes)): |
| members.setdefault(groups.find(index), []).append(index) |
|
|
| raw: list[tuple[StrokeBox, tuple[int, ...]]] = [] |
| for indices in members.values(): |
| box = boxes[indices[0]] |
| for index in indices[1:]: |
| box = box.union(boxes[index]) |
| padded = StrokeBox( |
| box.left - settings.padding, |
| box.top - settings.padding, |
| box.right + settings.padding, |
| box.bottom + settings.padding, |
| ) |
| raw.append((padded, tuple(sorted(strokes[index].stroke_id for index in indices)))) |
|
|
| raw.sort(key=lambda item: (round(item[0].top / max(1.0, baseline)), item[0].left)) |
| return [ |
| FormulaGrid(index=index, box=box, stroke_ids=stroke_ids) |
| for index, (box, stroke_ids) in enumerate(raw) |
| ] |
|
|
|
|
| def render_grid_images( |
| image: Image.Image, |
| strokes: Sequence[Stroke], |
| grids: Sequence[FormulaGrid], |
| ) -> list[Image.Image]: |
| """ํ์ ๋ณ์: ๊ธฐ์ค ์บ๋ฒ์คยท์๋ณธ ํยท์์ ์
์ด๋ค. |
| ์๋ ์๋ฆฌ: ๊ฐ ์
์ ์์ ํ๋ง ํฐ ๋ฐฐ๊ฒฝ์ ์ฌ๋ ๋๋งํ๊ณ ์
๊ฒฝ๊ณ๋ก ์๋ผ RGB ์ด๋ฏธ์ง๋ก ๋ฐํํ๋ค. |
| """ |
| stroke_by_id = {stroke.stroke_id: stroke for stroke in strokes} |
| results: list[Image.Image] = [] |
| for grid in grids: |
| canvas = Image.new("RGB", image.size, "white") |
| draw = ImageDraw.Draw(canvas) |
| for stroke_id in grid.stroke_ids: |
| stroke = stroke_by_id.get(stroke_id) |
| if stroke is not None: |
| draw.line( |
| stroke.points, |
| fill="black", |
| width=max(1, round(stroke.width)), |
| joint="curve", |
| ) |
| results.append(_crop(canvas, grid.box)) |
| return results |
|
|
|
|
| def _join_structural_bridges( |
| boxes: Sequence[StrokeBox], |
| groups: _DisjointSet, |
| structure_gap: float, |
| ) -> None: |
| """ํ์ ๋ณ์: ํ ๊ฒฝ๊ณยท์งํฉ ๊ตฌ์กฐยท๊ตฌ์กฐ ๊ฑฐ๋ฆฌ๋ค. |
| ์๋ ์๋ฆฌ: ๊ธด ๊ฐ๋ก์ ์ ๋ถ์์ , ๊ธด ์ธ๋ก์ ์ ๊ดํธยทํ๋ ฌ ๊ฒฝ๊ณ ํ๋ณด๋ก ๋ณด๊ณ ์ธ์ ํ์ ์ฐ๊ฒฐํ๋ค. |
| """ |
| for bridge_index, bridge in enumerate(boxes): |
| horizontal_bridge = bridge.width >= 30 and bridge.width >= bridge.height * 4 |
| vertical_bridge = bridge.height >= 45 and bridge.height >= bridge.width * 2.5 |
| if not horizontal_bridge and not vertical_bridge: |
| continue |
| for other_index, other in enumerate(boxes): |
| if other_index == bridge_index: |
| continue |
| if horizontal_bridge: |
| overlap = _overlap(bridge.left, bridge.right, other.left, other.right) |
| denominator = max(1.0, min(bridge.width, other.width)) |
| if ( |
| overlap / denominator >= 0.20 |
| and _axis_gap(bridge.top, bridge.bottom, other.top, other.bottom) |
| <= structure_gap |
| ): |
| groups.union(bridge_index, other_index) |
| elif ( |
| bridge.top - 18 <= other.center_y <= bridge.bottom + 18 |
| and _axis_gap(bridge.left, bridge.right, other.left, other.right) |
| <= structure_gap |
| ): |
| groups.union(bridge_index, other_index) |
|
|
|
|
| def _crop(image: Image.Image, box: StrokeBox) -> Image.Image: |
| """ํ์ ๋ณ์: ์ด๋ฏธ์ง์ ์
๊ฒฝ๊ณ. ์๋ ์๋ฆฌ: ๊ฒฝ๊ณ๋ฅผ ์ด๋ฏธ์ง ์ ์ ์ ์ขํ๋ก ์ ํํด ์์ ํ๊ฒ ์๋ฅธ๋ค.""" |
| left = max(0, min(image.width - 1, int(box.left))) |
| top = max(0, min(image.height - 1, int(box.top))) |
| right = max(left + 1, min(image.width, int(box.right + 0.999))) |
| bottom = max(top + 1, min(image.height, int(box.bottom + 0.999))) |
| return image.crop((left, top, right, bottom)) |
|
|
|
|
| def _axis_gap( |
| first_start: float, |
| first_end: float, |
| second_start: float, |
| second_end: float, |
| ) -> float: |
| """ํ์ ๋ณ์: ํ ์ถ์ ๋ ๊ตฌ๊ฐ. ์๋ ์๋ฆฌ: ๊ฒน์น๋ฉด 0, ์๋๋ฉด ์ต๊ทผ์ ๋์ ๊ฑฐ๋ฆฌ๋ฅผ ๋ฐํํ๋ค.""" |
| return max(0.0, max(first_start, second_start) - min(first_end, second_end)) |
|
|
|
|
| def _overlap( |
| first_start: float, |
| first_end: float, |
| second_start: float, |
| second_end: float, |
| ) -> float: |
| """ํ์ ๋ณ์: ํ ์ถ์ ๋ ๊ตฌ๊ฐ. ์๋ ์๋ฆฌ: ๋ ๊ตฌ๊ฐ์ ๊ต์งํฉ ๊ธธ์ด๋ฅผ ๋ฐํํ๋ค.""" |
| return max(0.0, min(first_end, second_end) - max(first_start, second_start)) |
|
|