Release AIFlow Math Ink 0.5 geometric gridding layer
Browse filesStandalone Apache-2.0 stroke geometry layer with method documentation, architecture SVG, tests, and no OCR model artifacts.
- .gitignore +8 -0
- LICENSE +193 -0
- NOTICE +9 -0
- README.md +119 -0
- assets/architecture.svg +92 -0
- docs/LICENSE_REVIEW.md +60 -0
- docs/METHOD.md +152 -0
- examples/basic.py +44 -0
- pyproject.toml +22 -0
- src/aiflow_math_ink_05/__init__.py +22 -0
- src/aiflow_math_ink_05/grid.py +314 -0
- tests/test_grid.py +64 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
outputs/
|
| 8 |
+
|
LICENSE
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
Copyright 2026 AIFlow contributors
|
| 181 |
+
|
| 182 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 183 |
+
you may not use this file except in compliance with the License.
|
| 184 |
+
You may obtain a copy of the License at
|
| 185 |
+
|
| 186 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 187 |
+
|
| 188 |
+
Unless required by applicable law or agreed to in writing, software
|
| 189 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 190 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 191 |
+
See the License for the specific language governing permissions and
|
| 192 |
+
limitations under the License.
|
| 193 |
+
|
NOTICE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
AIFlow Math Ink 0.5
|
| 2 |
+
Copyright 2026 AIFlow contributors
|
| 3 |
+
|
| 4 |
+
This distribution contains only the AIFlow geometric gridding implementation.
|
| 5 |
+
It contains no OCR model, weights, tokenizer, dataset, or third-party source.
|
| 6 |
+
|
| 7 |
+
Pillow is an optional install-time runtime dependency and is not redistributed.
|
| 8 |
+
External OCR recognizers are separate works and are governed by their own terms.
|
| 9 |
+
|
README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
- ko
|
| 6 |
+
tags:
|
| 7 |
+
- handwriting
|
| 8 |
+
- mathematical-expression-recognition
|
| 9 |
+
- preprocessing
|
| 10 |
+
- stroke-grouping
|
| 11 |
+
- geometry
|
| 12 |
+
library_name: aiflow-math-ink
|
| 13 |
+
pipeline_tag: image-to-text
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# AIFlow Math Ink 0.5
|
| 17 |
+
|
| 18 |
+
AIFlow Math Ink 0.5 is a **model-agnostic geometric gridding layer** for online
|
| 19 |
+
handwritten mathematics. It converts ordered pen strokes into spatially coherent
|
| 20 |
+
formula cells and renders one clean image per cell.
|
| 21 |
+
|
| 22 |
+
> This repository contains no OCR model, model weight, tokenizer, training data,
|
| 23 |
+
> or generated prediction. It is a deterministic preprocessing layer only.
|
| 24 |
+
|
| 25 |
+
The output images can be passed to any image-to-LaTeX recognizer. They are
|
| 26 |
+
interface-compatible with TexTeller-style image batches, but TexTeller is not
|
| 27 |
+
included, redistributed, modified, or required by this repository.
|
| 28 |
+
|
| 29 |
+

|
| 30 |
+
|
| 31 |
+
## Why this layer exists
|
| 32 |
+
|
| 33 |
+
An image-to-LaTeX model normally expects one coherent expression image. A pen
|
| 34 |
+
canvas may instead contain multiple rows, detached superscripts, fractions, or
|
| 35 |
+
matrix delimiters. AIFlow Math Ink 0.5 uses the original stroke geometry to
|
| 36 |
+
partition that canvas before recognition.
|
| 37 |
+
|
| 38 |
+
The layer:
|
| 39 |
+
|
| 40 |
+
1. validates bounded UTF-8 JSON-like stroke events;
|
| 41 |
+
2. computes width-aware stroke bounding boxes;
|
| 42 |
+
3. estimates a scale from the median stroke height;
|
| 43 |
+
4. creates same-baseline and local-attachment edges;
|
| 44 |
+
5. adds structural bridge edges for fraction bars and tall delimiters;
|
| 45 |
+
6. extracts connected components with a disjoint-set structure;
|
| 46 |
+
7. sorts cells in stable two-dimensional reading order; and
|
| 47 |
+
8. re-renders only the member strokes of each cell.
|
| 48 |
+
|
| 49 |
+
This is a transparent heuristic algorithm, not a trained neural-network layer.
|
| 50 |
+
|
| 51 |
+
## Installation
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
pip install Pillow
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Clone this repository and add `src` to your Python path, or install it locally:
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
pip install -e .
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Minimal example
|
| 64 |
+
|
| 65 |
+
```python
|
| 66 |
+
from PIL import Image
|
| 67 |
+
|
| 68 |
+
from aiflow_math_ink_05 import GridConfig, Stroke, build_formula_grids, render_grid_images
|
| 69 |
+
|
| 70 |
+
strokes = [
|
| 71 |
+
Stroke(0, ((24, 20), (36, 20)), 3.0),
|
| 72 |
+
Stroke(1, ((10, 42), (60, 42)), 3.0),
|
| 73 |
+
Stroke(2, ((24, 64), (36, 64)), 3.0),
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
grids = build_formula_grids(strokes, GridConfig())
|
| 77 |
+
images = render_grid_images(Image.new("RGB", (80, 90), "white"), strokes, grids)
|
| 78 |
+
|
| 79 |
+
for grid, image in zip(grids, images, strict=True):
|
| 80 |
+
print(grid.stroke_ids)
|
| 81 |
+
image.save(f"cell-{grid.index}.png")
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
For an existing API payload, use `parse_writing_events()` before
|
| 85 |
+
`build_formula_grids()`. See [`examples/basic.py`](examples/basic.py).
|
| 86 |
+
|
| 87 |
+
## Recognizer compatibility
|
| 88 |
+
|
| 89 |
+
`render_grid_images()` returns ordinary RGB `PIL.Image.Image` objects. A
|
| 90 |
+
recognizer can consume the list as a batch without importing this package into
|
| 91 |
+
its model implementation. TexTeller compatibility means only this image-level
|
| 92 |
+
interface; no TexTeller component is included.
|
| 93 |
+
|
| 94 |
+
## Method and complexity
|
| 95 |
+
|
| 96 |
+
The complete method, equations, assumptions, and limitations are documented in
|
| 97 |
+
[`docs/METHOD.md`](docs/METHOD.md).
|
| 98 |
+
|
| 99 |
+
For \(n\) accepted strokes, pair construction is \(O(n^2)\), disjoint-set
|
| 100 |
+
operations are effectively near-linear, and rendering is proportional to the
|
| 101 |
+
number of retained points plus output pixels. Input caps are mandatory in
|
| 102 |
+
server environments; the parser defaults to 2,000 strokes and 200,000 total
|
| 103 |
+
points.
|
| 104 |
+
|
| 105 |
+
## Scope and limitations
|
| 106 |
+
|
| 107 |
+
- This release performs geometry-based grouping, not symbol recognition.
|
| 108 |
+
- It does not infer LaTeX, semantics, or correctness.
|
| 109 |
+
- Thresholds are scale-adaptive but remain heuristic.
|
| 110 |
+
- Dense overlapping notes and unusual layouts can over-merge.
|
| 111 |
+
- The method has not been presented here as a peer-reviewed contribution.
|
| 112 |
+
- The repository ships no dataset and makes no benchmark claim.
|
| 113 |
+
|
| 114 |
+
## License
|
| 115 |
+
|
| 116 |
+
The original code in this repository is licensed under Apache License 2.0.
|
| 117 |
+
Pillow is an install-time dependency and is not vendored. External recognizers
|
| 118 |
+
are separate works governed by their own licenses.
|
| 119 |
+
|
assets/architecture.svg
ADDED
|
|
docs/LICENSE_REVIEW.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# License review
|
| 2 |
+
|
| 3 |
+
Review date: 2026-07-25
|
| 4 |
+
|
| 5 |
+
## Distribution boundary
|
| 6 |
+
|
| 7 |
+
This repository distributes:
|
| 8 |
+
|
| 9 |
+
- original AIFlow geometric grouping source;
|
| 10 |
+
- original documentation and SVG artwork;
|
| 11 |
+
- tests and a small synthetic example.
|
| 12 |
+
|
| 13 |
+
It does not distribute:
|
| 14 |
+
|
| 15 |
+
- an OCR model or model architecture implementation;
|
| 16 |
+
- model weights, tokenizer files, or inference configuration;
|
| 17 |
+
- a dataset or user handwriting;
|
| 18 |
+
- source copied from an external recognizer;
|
| 19 |
+
- Pillow source or binaries.
|
| 20 |
+
|
| 21 |
+
## Project license
|
| 22 |
+
|
| 23 |
+
The distributed work is offered under Apache License 2.0. The repository
|
| 24 |
+
includes the full `LICENSE` text and a `NOTICE` file. Apache-2.0 permits
|
| 25 |
+
commercial use, modification, and redistribution subject to its notice,
|
| 26 |
+
license-copy, and modification-marking conditions. It also includes an express
|
| 27 |
+
patent grant and warranty disclaimer.
|
| 28 |
+
|
| 29 |
+
Primary source:
|
| 30 |
+
https://www.apache.org/licenses/LICENSE-2.0
|
| 31 |
+
|
| 32 |
+
## Runtime dependency
|
| 33 |
+
|
| 34 |
+
Pillow is declared as an install-time dependency and is not vendored. Pillow is
|
| 35 |
+
licensed under the open-source MIT-CMU license. Its license permits use, copy,
|
| 36 |
+
modification, and distribution subject to preservation of its copyright and
|
| 37 |
+
permission notice.
|
| 38 |
+
|
| 39 |
+
Primary source:
|
| 40 |
+
https://github.com/python-pillow/Pillow/blob/main/LICENSE
|
| 41 |
+
|
| 42 |
+
## Optional recognizer interoperability
|
| 43 |
+
|
| 44 |
+
The package returns ordinary RGB Pillow images. Compatibility with an external
|
| 45 |
+
recognizer is an interface statement only and does not create a bundled or
|
| 46 |
+
derived distribution of that recognizer. Users must obtain and operate any
|
| 47 |
+
external recognizer separately under its own license.
|
| 48 |
+
|
| 49 |
+
TexTeller's official repository currently declares Apache-2.0, but no TexTeller
|
| 50 |
+
artifact is included here.
|
| 51 |
+
|
| 52 |
+
Primary source:
|
| 53 |
+
https://github.com/OleehyO/TexTeller
|
| 54 |
+
|
| 55 |
+
## Conclusion
|
| 56 |
+
|
| 57 |
+
The repository is suitable for publication as a standalone Apache-2.0 source
|
| 58 |
+
release, assuming the uploader is authorized to license the AIFlow source. This
|
| 59 |
+
is a technical license review, not jurisdiction-specific legal advice.
|
| 60 |
+
|
docs/METHOD.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AIFlow Math Ink 0.5: geometric gridding method
|
| 2 |
+
|
| 3 |
+
## Abstract
|
| 4 |
+
|
| 5 |
+
AIFlow Math Ink 0.5 is a deterministic preprocessing method for partitioning
|
| 6 |
+
online handwritten mathematics into image cells suitable for downstream
|
| 7 |
+
image-to-LaTeX recognition. It uses stroke trajectories and pen widths rather
|
| 8 |
+
than predicted symbols. The method constructs a sparse semantic approximation
|
| 9 |
+
through pairwise geometric predicates, augments it with fraction-bar and tall
|
| 10 |
+
delimiter bridges, and extracts connected components with disjoint-set union.
|
| 11 |
+
It is not a learned model and makes no claim of semantic parsing.
|
| 12 |
+
|
| 13 |
+
## 1. Input representation
|
| 14 |
+
|
| 15 |
+
Let a canvas contain ordered strokes
|
| 16 |
+
|
| 17 |
+
\[
|
| 18 |
+
\mathcal{S} = \{s_i\}_{i=1}^{n},
|
| 19 |
+
\qquad
|
| 20 |
+
s_i = (\mathrm{id}_i, \{(x_{ik},y_{ik})\}_{k=1}^{m_i}, w_i).
|
| 21 |
+
\]
|
| 22 |
+
|
| 23 |
+
Each stroke has a stable identifier, an ordered point sequence, and pen width
|
| 24 |
+
\(w_i\). Its axis-aligned bounding box expands the point extrema by
|
| 25 |
+
\(w_i/2\). This preserves the visible support of thick strokes.
|
| 26 |
+
|
| 27 |
+
The parser applies finite caps to stroke count, points per stroke, total points,
|
| 28 |
+
coordinate magnitude, and pen width. These caps are part of the runtime safety
|
| 29 |
+
contract rather than the mathematical grouping rule.
|
| 30 |
+
|
| 31 |
+
## 2. Scale estimation
|
| 32 |
+
|
| 33 |
+
Let \(h_i\) be the height of stroke box \(B_i\). The characteristic scale is
|
| 34 |
+
|
| 35 |
+
\[
|
| 36 |
+
\tilde h = \operatorname{median}_{i}(h_i).
|
| 37 |
+
\]
|
| 38 |
+
|
| 39 |
+
Median height is resistant to a small number of long fraction bars or tall
|
| 40 |
+
delimiters. Four distances are then defined:
|
| 41 |
+
|
| 42 |
+
\[
|
| 43 |
+
\tau_b = \max(24, 0.50\tilde h), \quad
|
| 44 |
+
\tau_x = \max(72, 1.25\tilde h),
|
| 45 |
+
\]
|
| 46 |
+
|
| 47 |
+
\[
|
| 48 |
+
\tau_s = \max(52, 0.80\tilde h), \quad
|
| 49 |
+
\tau_a = \max(18, 0.18\tilde h).
|
| 50 |
+
\]
|
| 51 |
+
|
| 52 |
+
The constants are defaults exposed by `GridConfig`; they are not learned
|
| 53 |
+
parameters.
|
| 54 |
+
|
| 55 |
+
## 3. Geometric relation graph
|
| 56 |
+
|
| 57 |
+
For boxes \(B_i,B_j\), let \(g_x(i,j)\) and \(g_y(i,j)\) be non-negative
|
| 58 |
+
axis gaps, and let \(c_y(i)\) be vertical center.
|
| 59 |
+
|
| 60 |
+
A same-baseline edge is created when
|
| 61 |
+
|
| 62 |
+
\[
|
| 63 |
+
|c_y(i)-c_y(j)| \le \tau_b
|
| 64 |
+
\quad\land\quad
|
| 65 |
+
g_x(i,j) \le \tau_x.
|
| 66 |
+
\]
|
| 67 |
+
|
| 68 |
+
A local-attachment edge handles detached pieces and scripts:
|
| 69 |
+
|
| 70 |
+
\[
|
| 71 |
+
g_x(i,j)\le\tau_a
|
| 72 |
+
\quad\land\quad
|
| 73 |
+
g_y(i,j)\le\tau_a
|
| 74 |
+
\]
|
| 75 |
+
|
| 76 |
+
\[
|
| 77 |
+
\quad\land\quad
|
| 78 |
+
|c_y(i)-c_y(j)|
|
| 79 |
+
\le 1.35\max(h_i,h_j)+\tau_a.
|
| 80 |
+
\]
|
| 81 |
+
|
| 82 |
+
These predicates intentionally favor recall of one coherent expression. They
|
| 83 |
+
can over-merge dense neighboring work, which is a documented limitation.
|
| 84 |
+
|
| 85 |
+
## 4. Structural bridges
|
| 86 |
+
|
| 87 |
+
A box is treated as a horizontal bridge candidate when
|
| 88 |
+
|
| 89 |
+
\[
|
| 90 |
+
\operatorname{width}(B_i)\ge30
|
| 91 |
+
\quad\land\quad
|
| 92 |
+
\operatorname{width}(B_i)\ge4\operatorname{height}(B_i).
|
| 93 |
+
\]
|
| 94 |
+
|
| 95 |
+
It connects to a nearby box when horizontal overlap is at least 20% of the
|
| 96 |
+
smaller width and vertical axis gap is at most \(\tau_s\). This approximates the
|
| 97 |
+
role of a fraction bar without recognizing its symbol.
|
| 98 |
+
|
| 99 |
+
A box is treated as a vertical bridge candidate when
|
| 100 |
+
|
| 101 |
+
\[
|
| 102 |
+
\operatorname{height}(B_i)\ge45
|
| 103 |
+
\quad\land\quad
|
| 104 |
+
\operatorname{height}(B_i)\ge2.5\operatorname{width}(B_i).
|
| 105 |
+
\]
|
| 106 |
+
|
| 107 |
+
It connects nearby vertically aligned strokes, approximating tall parentheses,
|
| 108 |
+
brackets, absolute-value marks, and matrix delimiters.
|
| 109 |
+
|
| 110 |
+
## 5. Connected components
|
| 111 |
+
|
| 112 |
+
All accepted relations are union operations over a disjoint-set forest with
|
| 113 |
+
path compression and union by size. Each connected component becomes one
|
| 114 |
+
formula cell. The cell boundary is the union of member boxes plus configurable
|
| 115 |
+
padding.
|
| 116 |
+
|
| 117 |
+
Cells are deterministically ordered by a quantized vertical band followed by
|
| 118 |
+
their left coordinate. This is a stable geometric order, not a full
|
| 119 |
+
two-dimensional mathematical parse.
|
| 120 |
+
|
| 121 |
+
## 6. Rendering contract
|
| 122 |
+
|
| 123 |
+
For every cell, member strokes are redrawn on a white RGB canvas and cropped to
|
| 124 |
+
the padded component boundary. Redrawing suppresses unrelated marks that happen
|
| 125 |
+
to occupy the rectangular crop. The returned object is a list of ordinary
|
| 126 |
+
`PIL.Image.Image` values and contains no recognizer-specific tensor transform.
|
| 127 |
+
|
| 128 |
+
This image-level contract is compatible with downstream recognizers that accept
|
| 129 |
+
formula image batches, including TexTeller-style pipelines. No recognizer is
|
| 130 |
+
included.
|
| 131 |
+
|
| 132 |
+
## 7. Complexity
|
| 133 |
+
|
| 134 |
+
The pairwise relation phase is \(O(n^2)\). Disjoint-set operations take
|
| 135 |
+
\(O(\alpha(n))\) amortized time, where \(\alpha\) is the inverse Ackermann
|
| 136 |
+
function. Rendering cost is proportional to retained stroke points and output
|
| 137 |
+
pixels. Bounded input size is therefore required for untrusted server traffic.
|
| 138 |
+
|
| 139 |
+
## 8. Scientific status and limitations
|
| 140 |
+
|
| 141 |
+
AIFlow Math Ink 0.5 is an engineering heuristic with explicit assumptions:
|
| 142 |
+
|
| 143 |
+
- axis-aligned boxes are sufficient local geometry;
|
| 144 |
+
- median stroke height approximates device scale;
|
| 145 |
+
- long horizontal and vertical strokes are useful structural bridges;
|
| 146 |
+
- connected components are adequate formula-cell hypotheses.
|
| 147 |
+
|
| 148 |
+
The release does not include a learned grouping head, probabilistic confidence,
|
| 149 |
+
ground-truth dataset, or benchmark claim. Appropriate evaluation would report
|
| 150 |
+
partition exact match, over-merge and under-merge rates, writer/device-disjoint
|
| 151 |
+
slices, and end-to-end expression accuracy separately.
|
| 152 |
+
|
examples/basic.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AIFlow Math Ink 0.5μ JSON-like stroke μ
λ ₯ μμ."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from aiflow_math_ink_05 import (
|
| 8 |
+
build_formula_grids,
|
| 9 |
+
parse_writing_events,
|
| 10 |
+
render_grid_images,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main() -> None:
|
| 15 |
+
"""νμ λ³μ: μμ stroke payload. μλ μ리: μμ μ
μ λ§λ€κ³ UTF-8 κ²½λ‘ μλ PNGλ‘ μ μ₯νλ€."""
|
| 16 |
+
events = [
|
| 17 |
+
{
|
| 18 |
+
"stroke_id": 0,
|
| 19 |
+
"width": 3,
|
| 20 |
+
"points": [{"x": 24, "y": 20}, {"x": 36, "y": 20}],
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"stroke_id": 1,
|
| 24 |
+
"width": 3,
|
| 25 |
+
"points": [{"x": 10, "y": 42}, {"x": 60, "y": 42}],
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"stroke_id": 2,
|
| 29 |
+
"width": 3,
|
| 30 |
+
"points": [{"x": 24, "y": 64}, {"x": 36, "y": 64}],
|
| 31 |
+
},
|
| 32 |
+
]
|
| 33 |
+
strokes = parse_writing_events(events)
|
| 34 |
+
grids = build_formula_grids(strokes)
|
| 35 |
+
images = render_grid_images(Image.new("RGB", (80, 90), "white"), strokes, grids)
|
| 36 |
+
output_directory = Path("outputs")
|
| 37 |
+
output_directory.mkdir(exist_ok=True)
|
| 38 |
+
for grid, image in zip(grids, images, strict=True):
|
| 39 |
+
image.save(output_directory / f"cell-{grid.index}.png")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
if __name__ == "__main__":
|
| 43 |
+
main()
|
| 44 |
+
|
pyproject.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "aiflow-math-ink"
|
| 7 |
+
version = "0.5.0"
|
| 8 |
+
description = "Model-agnostic geometric gridding for online handwritten mathematics"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = {file = "LICENSE"}
|
| 11 |
+
requires-python = ">=3.11"
|
| 12 |
+
dependencies = ["Pillow>=10,<12"]
|
| 13 |
+
|
| 14 |
+
[project.optional-dependencies]
|
| 15 |
+
test = ["pytest>=8,<9"]
|
| 16 |
+
|
| 17 |
+
[tool.setuptools.packages.find]
|
| 18 |
+
where = ["src"]
|
| 19 |
+
|
| 20 |
+
[tool.pytest.ini_options]
|
| 21 |
+
testpaths = ["tests"]
|
| 22 |
+
|
src/aiflow_math_ink_05/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AIFlow Math Ink 0.5 κ³΅κ° μΈν°νμ΄μ€."""
|
| 2 |
+
|
| 3 |
+
from .grid import (
|
| 4 |
+
FormulaGrid,
|
| 5 |
+
GridConfig,
|
| 6 |
+
Stroke,
|
| 7 |
+
StrokeBox,
|
| 8 |
+
build_formula_grids,
|
| 9 |
+
parse_writing_events,
|
| 10 |
+
render_grid_images,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"FormulaGrid",
|
| 15 |
+
"GridConfig",
|
| 16 |
+
"Stroke",
|
| 17 |
+
"StrokeBox",
|
| 18 |
+
"build_formula_grids",
|
| 19 |
+
"parse_writing_events",
|
| 20 |
+
"render_grid_images",
|
| 21 |
+
]
|
| 22 |
+
|
src/aiflow_math_ink_05/grid.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""μ¨λΌμΈ νκΈ° strokeλ₯Ό λͺ¨λΈ λ
립μ μΈ 2μ°¨μ μμ μ
λ‘ λ¬Άλλ€."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from statistics import median
|
| 7 |
+
from typing import Any, Sequence
|
| 8 |
+
|
| 9 |
+
from PIL import Image, ImageDraw
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True, slots=True)
|
| 13 |
+
class GridConfig:
|
| 14 |
+
"""νμ λ³μ: κΈ°ν μκ³κ°κ³Ό μ
λ ₯ μνμ΄λ€.
|
| 15 |
+
μλ μ리: κ³ μ μ΅μκ°κ³Ό λν ν λμ΄ λΉμ¨μ ν¨κ» μ¬μ©ν΄ κΈ°κΈ° μ’νκ³ λ³νμ λμνλ€.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
baseline_tolerance: float = 24.0
|
| 19 |
+
expression_gap: float = 72.0
|
| 20 |
+
structure_gap: float = 52.0
|
| 21 |
+
padding: float = 12.0
|
| 22 |
+
max_strokes: int = 2_000
|
| 23 |
+
max_points_per_stroke: int = 4_096
|
| 24 |
+
max_total_points: int = 200_000
|
| 25 |
+
coordinate_limit: float = 20_000.0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True, slots=True)
|
| 29 |
+
class StrokeBox:
|
| 30 |
+
"""νμ λ³μ: μ’Β·μΒ·μ°Β·ν κ²½κ³λ€.
|
| 31 |
+
μλ μ리: νκ³Ό μμ μ
μ μΆ μ λ ¬ κ²½κ³ λ° λ³ν© μ°μ°μ μ 곡νλ€.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
left: float
|
| 35 |
+
top: float
|
| 36 |
+
right: float
|
| 37 |
+
bottom: float
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def width(self) -> float:
|
| 41 |
+
"""νμ λ³μ: μ’μ° μ’ν. μλ μ리: μμκ° μλ νμ λ°ννλ€."""
|
| 42 |
+
return max(0.0, self.right - self.left)
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def height(self) -> float:
|
| 46 |
+
"""νμ λ³μ: μν μ’ν. μλ μ리: μμκ° μλ λμ΄λ₯Ό λ°ννλ€."""
|
| 47 |
+
return max(0.0, self.bottom - self.top)
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def center_y(self) -> float:
|
| 51 |
+
"""νμ λ³μ: μν μ’ν. μλ μ리: κ°μ κΈ°μ€μ νμ μ© μ€μ¬μ λ°ννλ€."""
|
| 52 |
+
return (self.top + self.bottom) / 2.0
|
| 53 |
+
|
| 54 |
+
def union(self, other: "StrokeBox") -> "StrokeBox":
|
| 55 |
+
"""νμ λ³μ: λ€λ₯Έ κ²½κ³. μλ μ리: λ κ²½κ³λ₯Ό ν¬ν¨νλ μ΅μ κ²½κ³λ₯Ό λ§λ λ€."""
|
| 56 |
+
return StrokeBox(
|
| 57 |
+
min(self.left, other.left),
|
| 58 |
+
min(self.top, other.top),
|
| 59 |
+
max(self.right, other.right),
|
| 60 |
+
max(self.bottom, other.bottom),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass(frozen=True, slots=True)
|
| 65 |
+
class Stroke:
|
| 66 |
+
"""νμ λ³μ: μμ μ μΈ ν IDΒ·μμ μλ μ Β·μ κ΅΅κΈ°λ€.
|
| 67 |
+
μλ μ리: μ λ°μ§λ¦μ ν¬ν¨ν κ²½κ³λ₯Ό κ³μ°ν΄ νκΈ° νμ 보쑴νλ€.
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
stroke_id: int
|
| 71 |
+
points: tuple[tuple[float, float], ...]
|
| 72 |
+
width: float = 3.0
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def box(self) -> StrokeBox:
|
| 76 |
+
"""νμ λ³μ: νκΈ°μ Β·κ΅΅κΈ°. μλ μ리: μ λ°μ§λ¦κΉμ§ ν¬ν¨ν κ²½κ³λ₯Ό κ³μ°νλ€."""
|
| 77 |
+
radius = self.width / 2.0
|
| 78 |
+
return StrokeBox(
|
| 79 |
+
min(point[0] for point in self.points) - radius,
|
| 80 |
+
min(point[1] for point in self.points) - radius,
|
| 81 |
+
max(point[0] for point in self.points) + radius,
|
| 82 |
+
max(point[1] for point in self.points) + radius,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass(frozen=True, slots=True)
|
| 87 |
+
class FormulaGrid:
|
| 88 |
+
"""νμ λ³μ: μ
μλ²Β·κ²½κ³Β·μμ ν IDλ€.
|
| 89 |
+
μλ μ리: μΈμκΈ°μ 무κ΄ν κ²°μ μ μμ μ
κ³μ½μ νννλ€.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
index: int
|
| 93 |
+
box: StrokeBox
|
| 94 |
+
stroke_ids: tuple[int, ...]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class _DisjointSet:
|
| 98 |
+
"""νμ λ³μ: ν κ°μ. μλ μ리: κ²½λ‘ μμΆκ³Ό ν¬κΈ° κΈ°μ€ λ³ν©μΌλ‘ μ°κ²° μ±λΆμ κ΄λ¦¬νλ€."""
|
| 99 |
+
|
| 100 |
+
def __init__(self, size: int) -> None:
|
| 101 |
+
"""νμ λ³μ: μμ μ. μλ μ리: κ° νμ λ
립 μ§ν©μΌλ‘ μ΄κΈ°ννλ€."""
|
| 102 |
+
self.parent = list(range(size))
|
| 103 |
+
self.size = [1] * size
|
| 104 |
+
|
| 105 |
+
def find(self, value: int) -> int:
|
| 106 |
+
"""νμ λ³μ: ν μΈλ±μ€. μλ μ리: λνλ₯Ό μ°Ύλ λμ κ²½λ‘λ₯Ό μμΆνλ€."""
|
| 107 |
+
while self.parent[value] != value:
|
| 108 |
+
self.parent[value] = self.parent[self.parent[value]]
|
| 109 |
+
value = self.parent[value]
|
| 110 |
+
return value
|
| 111 |
+
|
| 112 |
+
def union(self, first: int, second: int) -> None:
|
| 113 |
+
"""νμ λ³μ: λ ν μΈλ±μ€. μλ μ리: μμ νΈλ¦¬λ₯Ό ν° νΈλ¦¬ μλμ κ²°ν©νλ€."""
|
| 114 |
+
root_first = self.find(first)
|
| 115 |
+
root_second = self.find(second)
|
| 116 |
+
if root_first == root_second:
|
| 117 |
+
return
|
| 118 |
+
if self.size[root_first] < self.size[root_second]:
|
| 119 |
+
root_first, root_second = root_second, root_first
|
| 120 |
+
self.parent[root_second] = root_first
|
| 121 |
+
self.size[root_first] += self.size[root_second]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def parse_writing_events(value: Any, config: GridConfig | None = None) -> list[Stroke]:
|
| 125 |
+
"""νμ λ³μ: JSON μ μ¬ writing eventsμ μ ν μ€μ μ΄λ€.
|
| 126 |
+
μλ μ리: μ νν μ’νΒ·μ Β·νλ§ μν μμμ λ°μ μμ ν λΆλ³ Strokeλ‘ μ κ·ννλ€.
|
| 127 |
+
"""
|
| 128 |
+
settings = config or GridConfig()
|
| 129 |
+
if not isinstance(value, list):
|
| 130 |
+
return []
|
| 131 |
+
total_points = 0
|
| 132 |
+
strokes: list[Stroke] = []
|
| 133 |
+
for fallback_id, item in enumerate(value[: settings.max_strokes]):
|
| 134 |
+
if not isinstance(item, dict) or not isinstance(item.get("points"), list):
|
| 135 |
+
continue
|
| 136 |
+
points: list[tuple[float, float]] = []
|
| 137 |
+
for point in item["points"][: settings.max_points_per_stroke]:
|
| 138 |
+
if not isinstance(point, dict):
|
| 139 |
+
continue
|
| 140 |
+
try:
|
| 141 |
+
x = float(point["x"])
|
| 142 |
+
y = float(point["y"])
|
| 143 |
+
except (KeyError, TypeError, ValueError):
|
| 144 |
+
continue
|
| 145 |
+
if abs(x) <= settings.coordinate_limit and abs(y) <= settings.coordinate_limit:
|
| 146 |
+
points.append((x, y))
|
| 147 |
+
if len(points) < 2:
|
| 148 |
+
continue
|
| 149 |
+
if total_points + len(points) > settings.max_total_points:
|
| 150 |
+
break
|
| 151 |
+
try:
|
| 152 |
+
stroke_id = int(item.get("stroke_id", fallback_id))
|
| 153 |
+
width = min(32.0, max(0.5, float(item.get("width", 3.0))))
|
| 154 |
+
except (TypeError, ValueError):
|
| 155 |
+
continue
|
| 156 |
+
strokes.append(Stroke(stroke_id, tuple(points), width))
|
| 157 |
+
total_points += len(points)
|
| 158 |
+
return strokes
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def build_formula_grids(
|
| 162 |
+
strokes: Sequence[Stroke],
|
| 163 |
+
config: GridConfig | None = None,
|
| 164 |
+
) -> list[FormulaGrid]:
|
| 165 |
+
"""νμ λ³μ: μ κ·νλ νκ³Ό μ ν μ€μ μ΄λ€.
|
| 166 |
+
μλ μ리: κΈ°μ€μ Β·κ·Όμ λΆμ°©Β·κ΅¬μ‘°μ κ΄κ³ κ·Έλνμ μ°κ²° μ±λΆμ 2μ°¨μ μμ μ
λ‘ λ§λ λ€.
|
| 167 |
+
"""
|
| 168 |
+
if not strokes:
|
| 169 |
+
return []
|
| 170 |
+
settings = config or GridConfig()
|
| 171 |
+
boxes = [stroke.box for stroke in strokes]
|
| 172 |
+
typical_height = median(box.height for box in boxes)
|
| 173 |
+
baseline = max(settings.baseline_tolerance, typical_height * 0.50)
|
| 174 |
+
expression_gap = max(settings.expression_gap, typical_height * 1.25)
|
| 175 |
+
structure_gap = max(settings.structure_gap, typical_height * 0.80)
|
| 176 |
+
attachment_gap = max(18.0, typical_height * 0.18)
|
| 177 |
+
groups = _DisjointSet(len(strokes))
|
| 178 |
+
|
| 179 |
+
for first in range(len(strokes)):
|
| 180 |
+
for second in range(first + 1, len(strokes)):
|
| 181 |
+
horizontal_gap = _axis_gap(
|
| 182 |
+
boxes[first].left,
|
| 183 |
+
boxes[first].right,
|
| 184 |
+
boxes[second].left,
|
| 185 |
+
boxes[second].right,
|
| 186 |
+
)
|
| 187 |
+
vertical_gap = _axis_gap(
|
| 188 |
+
boxes[first].top,
|
| 189 |
+
boxes[first].bottom,
|
| 190 |
+
boxes[second].top,
|
| 191 |
+
boxes[second].bottom,
|
| 192 |
+
)
|
| 193 |
+
center_gap = abs(boxes[first].center_y - boxes[second].center_y)
|
| 194 |
+
same_line = center_gap <= baseline and horizontal_gap <= expression_gap
|
| 195 |
+
attachment = (
|
| 196 |
+
horizontal_gap <= attachment_gap
|
| 197 |
+
and vertical_gap <= attachment_gap
|
| 198 |
+
and center_gap
|
| 199 |
+
<= max(boxes[first].height, boxes[second].height) * 1.35 + attachment_gap
|
| 200 |
+
)
|
| 201 |
+
if same_line or attachment:
|
| 202 |
+
groups.union(first, second)
|
| 203 |
+
|
| 204 |
+
_join_structural_bridges(boxes, groups, structure_gap)
|
| 205 |
+
members: dict[int, list[int]] = {}
|
| 206 |
+
for index in range(len(strokes)):
|
| 207 |
+
members.setdefault(groups.find(index), []).append(index)
|
| 208 |
+
|
| 209 |
+
raw: list[tuple[StrokeBox, tuple[int, ...]]] = []
|
| 210 |
+
for indices in members.values():
|
| 211 |
+
box = boxes[indices[0]]
|
| 212 |
+
for index in indices[1:]:
|
| 213 |
+
box = box.union(boxes[index])
|
| 214 |
+
padded = StrokeBox(
|
| 215 |
+
box.left - settings.padding,
|
| 216 |
+
box.top - settings.padding,
|
| 217 |
+
box.right + settings.padding,
|
| 218 |
+
box.bottom + settings.padding,
|
| 219 |
+
)
|
| 220 |
+
raw.append((padded, tuple(sorted(strokes[index].stroke_id for index in indices))))
|
| 221 |
+
|
| 222 |
+
raw.sort(key=lambda item: (round(item[0].top / max(1.0, baseline)), item[0].left))
|
| 223 |
+
return [
|
| 224 |
+
FormulaGrid(index=index, box=box, stroke_ids=stroke_ids)
|
| 225 |
+
for index, (box, stroke_ids) in enumerate(raw)
|
| 226 |
+
]
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def render_grid_images(
|
| 230 |
+
image: Image.Image,
|
| 231 |
+
strokes: Sequence[Stroke],
|
| 232 |
+
grids: Sequence[FormulaGrid],
|
| 233 |
+
) -> list[Image.Image]:
|
| 234 |
+
"""νμ λ³μ: κΈ°μ€ μΊλ²μ€Β·μλ³Έ νΒ·μμ μ
μ΄λ€.
|
| 235 |
+
μλ μ리: κ° μ
μ μμ νλ§ ν° λ°°κ²½μ μ¬λ λλ§νκ³ μ
κ²½κ³λ‘ μλΌ RGB μ΄λ―Έμ§λ‘ λ°ννλ€.
|
| 236 |
+
"""
|
| 237 |
+
stroke_by_id = {stroke.stroke_id: stroke for stroke in strokes}
|
| 238 |
+
results: list[Image.Image] = []
|
| 239 |
+
for grid in grids:
|
| 240 |
+
canvas = Image.new("RGB", image.size, "white")
|
| 241 |
+
draw = ImageDraw.Draw(canvas)
|
| 242 |
+
for stroke_id in grid.stroke_ids:
|
| 243 |
+
stroke = stroke_by_id.get(stroke_id)
|
| 244 |
+
if stroke is not None:
|
| 245 |
+
draw.line(
|
| 246 |
+
stroke.points,
|
| 247 |
+
fill="black",
|
| 248 |
+
width=max(1, round(stroke.width)),
|
| 249 |
+
joint="curve",
|
| 250 |
+
)
|
| 251 |
+
results.append(_crop(canvas, grid.box))
|
| 252 |
+
return results
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _join_structural_bridges(
|
| 256 |
+
boxes: Sequence[StrokeBox],
|
| 257 |
+
groups: _DisjointSet,
|
| 258 |
+
structure_gap: float,
|
| 259 |
+
) -> None:
|
| 260 |
+
"""νμ λ³μ: ν κ²½κ³Β·μ§ν© ꡬ쑰·ꡬ쑰 거리λ€.
|
| 261 |
+
μλ μ리: κΈ΄ κ°λ‘μ μ λΆμμ , κΈ΄ μΈλ‘μ μ κ΄νΈΒ·νλ ¬ κ²½κ³ νλ³΄λ‘ λ³΄κ³ μΈμ νμ μ°κ²°νλ€.
|
| 262 |
+
"""
|
| 263 |
+
for bridge_index, bridge in enumerate(boxes):
|
| 264 |
+
horizontal_bridge = bridge.width >= 30 and bridge.width >= bridge.height * 4
|
| 265 |
+
vertical_bridge = bridge.height >= 45 and bridge.height >= bridge.width * 2.5
|
| 266 |
+
if not horizontal_bridge and not vertical_bridge:
|
| 267 |
+
continue
|
| 268 |
+
for other_index, other in enumerate(boxes):
|
| 269 |
+
if other_index == bridge_index:
|
| 270 |
+
continue
|
| 271 |
+
if horizontal_bridge:
|
| 272 |
+
overlap = _overlap(bridge.left, bridge.right, other.left, other.right)
|
| 273 |
+
denominator = max(1.0, min(bridge.width, other.width))
|
| 274 |
+
if (
|
| 275 |
+
overlap / denominator >= 0.20
|
| 276 |
+
and _axis_gap(bridge.top, bridge.bottom, other.top, other.bottom)
|
| 277 |
+
<= structure_gap
|
| 278 |
+
):
|
| 279 |
+
groups.union(bridge_index, other_index)
|
| 280 |
+
elif (
|
| 281 |
+
bridge.top - 18 <= other.center_y <= bridge.bottom + 18
|
| 282 |
+
and _axis_gap(bridge.left, bridge.right, other.left, other.right)
|
| 283 |
+
<= structure_gap
|
| 284 |
+
):
|
| 285 |
+
groups.union(bridge_index, other_index)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _crop(image: Image.Image, box: StrokeBox) -> Image.Image:
|
| 289 |
+
"""νμ λ³μ: μ΄λ―Έμ§μ μ
κ²½κ³. μλ μ리: κ²½κ³λ₯Ό μ΄λ―Έμ§ μ μ μ μ’νλ‘ μ νν΄ μμ νκ² μλ₯Έλ€."""
|
| 290 |
+
left = max(0, min(image.width - 1, int(box.left)))
|
| 291 |
+
top = max(0, min(image.height - 1, int(box.top)))
|
| 292 |
+
right = max(left + 1, min(image.width, int(box.right + 0.999)))
|
| 293 |
+
bottom = max(top + 1, min(image.height, int(box.bottom + 0.999)))
|
| 294 |
+
return image.crop((left, top, right, bottom))
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _axis_gap(
|
| 298 |
+
first_start: float,
|
| 299 |
+
first_end: float,
|
| 300 |
+
second_start: float,
|
| 301 |
+
second_end: float,
|
| 302 |
+
) -> float:
|
| 303 |
+
"""νμ λ³μ: ν μΆμ λ ꡬκ°. μλ μ리: κ²ΉμΉλ©΄ 0, μλλ©΄ μ΅κ·Όμ λμ 거리λ₯Ό λ°ννλ€."""
|
| 304 |
+
return max(0.0, max(first_start, second_start) - min(first_end, second_end))
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _overlap(
|
| 308 |
+
first_start: float,
|
| 309 |
+
first_end: float,
|
| 310 |
+
second_start: float,
|
| 311 |
+
second_end: float,
|
| 312 |
+
) -> float:
|
| 313 |
+
"""νμ λ³μ: ν μΆμ λ ꡬκ°. μλ μ리: λ ꡬκ°μ κ΅μ§ν© κΈΈμ΄λ₯Ό λ°ννλ€."""
|
| 314 |
+
return max(0.0, min(first_end, second_end) - max(first_start, second_start))
|
tests/test_grid.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AIFlow Math Ink 0.5μ λͺ¨λΈ λ
립μ κ·Έλ¦¬λ© κ³μ½μ κ²μ¦νλ€."""
|
| 2 |
+
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
from aiflow_math_ink_05 import (
|
| 6 |
+
GridConfig,
|
| 7 |
+
Stroke,
|
| 8 |
+
build_formula_grids,
|
| 9 |
+
parse_writing_events,
|
| 10 |
+
render_grid_images,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_fraction_bar_joins_numerator_and_denominator() -> None:
|
| 15 |
+
"""νμ λ³μ: λΆμΒ·λΆμμ Β·λΆλͺ¨ ν. μλ μ리: ꡬ쑰μ μ΄ μΈ νμ ν μ
λ‘ λ¬Άλμ§ κ²μ¦νλ€."""
|
| 16 |
+
strokes = [
|
| 17 |
+
Stroke(0, ((24, 20), (36, 20))),
|
| 18 |
+
Stroke(1, ((10, 42), (60, 42))),
|
| 19 |
+
Stroke(2, ((24, 64), (36, 64))),
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
grids = build_formula_grids(strokes)
|
| 23 |
+
|
| 24 |
+
assert len(grids) == 1
|
| 25 |
+
assert grids[0].stroke_ids == (0, 1, 2)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_distant_rows_remain_separate() -> None:
|
| 29 |
+
"""νμ λ³μ: λ©λ¦¬ λ¨μ΄μ§ λ ν. μλ μ리: ν μ¬μ΄ μ°κ²° μ‘°κ±΄μ΄ μμΌλ©΄ λ μ
μ μ μ§νλ€."""
|
| 30 |
+
strokes = [
|
| 31 |
+
Stroke(0, ((10, 20), (40, 20))),
|
| 32 |
+
Stroke(1, ((10, 180), (40, 180))),
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
grids = build_formula_grids(strokes)
|
| 36 |
+
|
| 37 |
+
assert len(grids) == 2
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_parser_enforces_total_point_cap() -> None:
|
| 41 |
+
"""νμ λ³μ: μμ μ μνκ³Ό λ ν payload. μλ μ리: μνμ λλ νμ λ°μ§ μλμ§ κ²μ¦νλ€."""
|
| 42 |
+
payload = [
|
| 43 |
+
{"stroke_id": 0, "points": [{"x": 0, "y": 0}, {"x": 1, "y": 1}]},
|
| 44 |
+
{"stroke_id": 1, "points": [{"x": 2, "y": 2}, {"x": 3, "y": 3}]},
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
strokes = parse_writing_events(payload, GridConfig(max_total_points=2))
|
| 48 |
+
|
| 49 |
+
assert [stroke.stroke_id for stroke in strokes] == [0]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_renderer_returns_one_rgb_image_per_grid() -> None:
|
| 53 |
+
"""νμ λ³μ: μΊλ²μ€Β·λ ν. μλ μ리: κ° μ
μ λμνλ RGB μ΄λ―Έμ§κ° μμ±λλμ§ κ²μ¦νλ€."""
|
| 54 |
+
strokes = [
|
| 55 |
+
Stroke(0, ((10, 20), (40, 20))),
|
| 56 |
+
Stroke(1, ((10, 180), (40, 180))),
|
| 57 |
+
]
|
| 58 |
+
grids = build_formula_grids(strokes)
|
| 59 |
+
|
| 60 |
+
images = render_grid_images(Image.new("RGB", (100, 220), "white"), strokes, grids)
|
| 61 |
+
|
| 62 |
+
assert len(images) == len(grids) == 2
|
| 63 |
+
assert all(image.mode == "RGB" for image in images)
|
| 64 |
+
|