cwLeeDev commited on
Commit
b9c7f6c
Β·
verified Β·
1 Parent(s): 9b1e3db

Release AIFlow Math Ink 0.5 geometric gridding layer

Browse files

Standalone Apache-2.0 stroke geometry layer with method documentation, architecture SVG, tests, and no OCR model artifacts.

.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
+ ![AIFlow Math Ink 0.5 pipeline](assets/architecture.svg)
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
+