cwLeeDev's picture
Release AIFlow Math Ink 0.5 geometric gridding layer
b9c7f6c verified
Raw
History Blame Contribute Delete
12.2 kB
"""์˜จ๋ผ์ธ ํ•„๊ธฐ 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))