genshiai-daichi commited on
Commit
a49f452
·
verified ·
1 Parent(s): 666958c

Baberu OCR public release (Apache-2.0): 115M multilingual manga OCR + ONNX

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/hero-en.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/hero-ja.png filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Derivative
95
+ 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
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Baberu OCR authors
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - ja
5
+ - en
6
+ - zh
7
+ tags:
8
+ - ocr
9
+ - manga
10
+ - japanese
11
+ - image-to-text
12
+ - vision-language-model
13
+ - dinov2
14
+ - onnx
15
+ pipeline_tag: image-to-text
16
+ ---
17
+
18
+ # Baberu OCR
19
+
20
+ 🇬🇧 English · 🇯🇵 [日本語](./README_ja.md)
21
+
22
+ **A 115M-parameter manga speech-bubble OCR that beats its own 0.9B teachers on independent ground truth — in Japanese, English, and Chinese — and ships as small as 121 MB (ONNX).**
23
+
24
+ ![Baberu OCR — 115M beats its teachers across ja/en/zh](./assets/hero-en.png)
25
+
26
+ ---
27
+
28
+ ## Highlights
29
+
30
+ - 🏆 **Beats its own teachers** on independent (non-circular) ground truth: `manga-ocr` on Japanese, and the **0.9B `PaddleOCR-VL`** on English & Chinese.
31
+ - 🌏 **One model, three languages** (ja / en / zh). Lowest lenient character-error-rate (lCER) of every model compared, in **all three**.
32
+ - 🪶 **115M parameters — ~1/8 the size** of the 0.9B VLMs it beats. Char-level (14,630 vocab), robust to SFX, symbols and mixed full/half-width.
33
+ - ⚡ **Runs fully local, no cloud.** Ships as ONNX from **121 MB** (int4+int8) or **242 MB** lossless — plus a self-contained runner that needs only `onnxruntime`, `numpy`, `pillow`.
34
+ - ✅ **Apache-2.0.**
35
+
36
+ > **Honest scope:** this is SOTA for **modern manga/doujinshi speech bubbles at ≤115M**. On far-out-of-domain print (1940s Western comics) a general 0.9B VLM still wins — see [Generalization](#generalization-honest-limit).
37
+
38
+ ---
39
+
40
+ ## The model
41
+
42
+ ```
43
+ bubble crop
44
+ → DINOv2-ViT-B/14 (86M) # vision encoder
45
+ → 256 vision tokens → MLP projector (768→512)
46
+ → [ vision | BOS … ] → 6-layer GQA decoder (~26M) # custom char-level LM
47
+ → character logits (vocab 14,630)
48
+ ```
49
+
50
+ - **Vision = DINOv2** (Meta, Apache-2.0). **Language side = a small custom char-level decoder** (6 layers, GQA 8h/2kv, SwiGLU, RMSNorm-sandwich, 1D RoPE, tied embeddings) trained from scratch.
51
+ - **Training recipe that made it strong:** first train with the **vision encoder frozen**, then **unfreeze the vision encoder and continue** — this second stage gives a large accuracy jump, and saturates after only ~30k steps (≈10% of an epoch). char-level means no tokenizer OOV problems, which helps SFX and mixed scripts.
52
+ - **115M total.** PyTorch (CUDA / Apple-MPS / CPU) and ONNX (CPU / CUDA / DirectML / web).
53
+
54
+ ---
55
+
56
+ ## Benchmarks (independent ground truth)
57
+
58
+ Teacher-generated labels would be circular, so we score against **independent** GT: Japanese = the human-labeled **Manga109-v2026**; English/Chinese = **re-annotated by hand** (teacher labels not used). Primary metric = **lCER** (lenient char-error-rate: NFKC + whitespace-strip + drop decorative symbols + unify dot-runs), lower is better. Baberu was **not** trained on any of these sets.
59
+
60
+ **Japanese — Manga109-v2026 (n=2000)**
61
+
62
+ | model | size | lCER ↓ | nCER ↓ |
63
+ |---|---|---|---|
64
+ | **Baberu OCR** | **115M** | **0.0345** | 0.0871 |
65
+ | PaddleOCR-VL | 0.9B | 0.0368 | **0.0808** |
66
+ | manga-ocr *(its teacher)* | 110M | 0.0422 | 0.0954 |
67
+ | PP-OCRv5 | ~10M | 0.6317 | 0.6491 |
68
+
69
+ Baberu has the **best lCER**, and **beats its teacher manga-ocr on every metric**. *(Honest note: the 0.9B PaddleOCR-VL edges Baberu on strict nCER 0.0808 vs 0.0871 and on exact-match. `PaddleOCR-VL-For-Manga` posts a lower number here but is **excluded — it was trained on Manga109, so that score is a data leak**.)*
70
+
71
+ **English — in-domain doujin, hand-annotated (n=80)**
72
+
73
+ | model | size | lCER ↓ | exact ↑ |
74
+ |---|---|---|---|
75
+ | **Baberu OCR** | **115M** | **0.0177** | **0.89** |
76
+ | PaddleOCR-VL *(its teacher)* | 0.9B | 0.0268 | 0.88 |
77
+ | PP-OCRv5 | ~10M | 0.0975 | 0.63 |
78
+ | PaddleOCR-VL-For-Manga | 0.9B | 0.1140 | 0.41 |
79
+ | manga-ocr | 110M | 0.2987 | 0.24 |
80
+
81
+ **Chinese — in-domain doujin, hand-annotated (n=77)**
82
+
83
+ | model | size | lCER ↓ | nCER ↓ |
84
+ |---|---|---|---|
85
+ | **Baberu OCR** | **115M** | **0.0868** | **0.1244** |
86
+ | PaddleOCR-VL *(its teacher)* | 0.9B | 0.1013 | 0.1808 |
87
+ | PaddleOCR-VL-For-Manga | 0.9B | 0.1168 | 0.1605 |
88
+ | manga-ocr | 110M | 0.2506 | 0.2518 |
89
+ | PP-OCRv5 | ~10M | 0.5639 | 0.5778 |
90
+
91
+ → **Baberu beats the 0.9B teacher in both English and Chinese**, at 1/8 the size.
92
+
93
+ ### Generalization (honest limit)
94
+
95
+ Public, never-seen gold GT — **COMICS Text+** (1940s Western comics, n=480):
96
+
97
+ | model | size | lCER ↓ |
98
+ |---|---|---|
99
+ | PaddleOCR-VL | 0.9B | **0.0256** |
100
+ | PP-OCRv5 | ~10M | 0.1332 |
101
+ | **Baberu OCR** | **115M** | 0.1488 |
102
+
103
+ Baberu still reads this far-out-of-domain set zero-shot, but on clean vintage print the general 0.9B model wins. Baberu's strength is **modern manga/doujinshi bubbles**, by design.
104
+
105
+ ---
106
+
107
+ ## Quick start
108
+
109
+ Input is **one speech-bubble crop** (use a text detector upstream), not a whole page.
110
+
111
+ ### ONNX — easiest & smallest (no PyTorch)
112
+
113
+ ```bash
114
+ pip install onnxruntime numpy pillow
115
+ huggingface-cli download genshiai-daichi/baberu-ocr --local-dir baberu-ocr
116
+ cd baberu-ocr
117
+
118
+ python onnx_infer.py --image bubble.jpg # 242 MB, lossless
119
+ python onnx_infer.py --vision vision_int4.onnx --image bubble.jpg # 121 MB, smallest
120
+ ```
121
+
122
+ The runner is fully self-contained (`onnx_infer.py`): preprocessing, KV-cache decode loop, and the published decode settings (greedy + `repetition_penalty=1.2` + symbol-aware content-run cap) are all included, so you get the same text as the PyTorch model.
123
+
124
+ ### PyTorch — for fine-tuning / `transformers`
125
+
126
+ ```bash
127
+ pip install torch transformers safetensors pillow
128
+ huggingface-cli download genshiai-daichi/baberu-ocr --local-dir baberu-ocr
129
+ cd baberu-ocr
130
+ python inference.py --model . --image bubble.jpg
131
+ ```
132
+
133
+ ```python
134
+ from PIL import Image
135
+ from inference import BaberuOCR
136
+ ocr = BaberuOCR(".") # local download dir
137
+ print(ocr(Image.open("bubble.jpg")))
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Sizes & quantization (ONNX)
143
+
144
+ | component | fp16 | int8 | int4 |
145
+ |---|---|---|---|
146
+ | vision (86M) | 173 MB | 92 MB | **52 MB** |
147
+ | decoder (prefill+step) | — | **69 MB** | (89 MB) |
148
+
149
+ Two recommended tiers (both keep the decoder at **int8 — which is accuracy-free here**):
150
+
151
+ | tier | files | size | nCER (ja) |
152
+ |---|---|---|---|
153
+ | **lossless** | `vision_fp16.onnx` + `decoder_*_int8.onnx` | **242 MB** | 0.0867 |
154
+ | **smallest** | `vision_int4.onnx` + `decoder_*_int8.onnx` | **121 MB** | 0.0893 |
155
+
156
+ Notes from the measured sweep (see `quantization-results.md`): **decoder int8 is free** (nCER 0.0867 ≈ fp32 0.0871 — greedy decoding is robust to weight-quantization noise); the only real cost is vision quantization, and even there **int4 weight-only beats int8-dynamic** (smaller *and* more accurate). Both tiers still beat the teacher manga-ocr (0.0954). The strictly-dominated variants (vision int8-dynamic, decoder int4) are intentionally not shipped.
157
+
158
+ ---
159
+
160
+ ## Limitations (honest)
161
+
162
+ - **Single bubble crop in, text out** — you need a text-detection stage upstream. Not a page-level OCR.
163
+ - Weak on **dakuten/handakuten** confusions (ポ↔ボ), some **rare kanji**, and dropped small kana.
164
+ - **Far-out-of-domain** (clean vintage/Western print) → a general large VLM is better.
165
+ - Adult content appears in the training distribution; the model itself only transcribes text.
166
+
167
+ ## Training data & license
168
+
169
+ - Trained on Japanese **manga / doujinshi** speech-bubble crops. Text labels were distilled from `manga-ocr` (ja) and `PaddleOCR-VL` (zh/en). **Training images are not redistributed** — only the model weights and code are released.
170
+ - **License: Apache-2.0** (weights + code). Compatible with the upstream components used (DINOv2, manga-ocr, PaddleOCR all Apache-2.0).
171
+
172
+ ## Acknowledgements
173
+
174
+ - [DINOv2](https://github.com/facebookresearch/dinov2) (Meta) — vision encoder
175
+ - [manga-ocr](https://github.com/kha-white/manga-ocr) — the de-facto Japanese manga OCR baseline / ja teacher
176
+ - [PaddleOCR-VL](https://github.com/PaddlePaddle/PaddleOCR) — zh/en teacher
README_ja.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - ja
5
+ - en
6
+ - zh
7
+ tags:
8
+ - ocr
9
+ - manga
10
+ - japanese
11
+ - image-to-text
12
+ - vision-language-model
13
+ - dinov2
14
+ - onnx
15
+ pipeline_tag: image-to-text
16
+ ---
17
+
18
+ # Baberu OCR
19
+
20
+ 🇯🇵 日本語 · 🇬🇧 [English](./README.md)
21
+
22
+ **115M パラメータのマンガ吹き出し OCR。自分の教師(0.9B級)を独立GTで上回る — 日本語・英語・中国語すべてで。ONNX なら最小 121MB。**
23
+
24
+ ![Baberu OCR — 115Mで教師超え、ja/en/zh 全制覇](./assets/hero-ja.png)
25
+
26
+ ---
27
+
28
+ ## ハイライト
29
+
30
+ - 🏆 **自分の教師に勝つ**(身内採点でない独立GTで): 日本語は `manga-ocr`、英語・中国語は **0.9B の `PaddleOCR-VL`**。
31
+ - 🌏 **1モデルで3言語**(ja / en / zh)。比較した全モデル中、**3言語すべてで最小の lCER**(緩和文字誤り率)。
32
+ - 🪶 **115M = 勝った相手(0.9B)の約1/8**。char-level(語彙14,630)で擬音・記号・全半角混在に強い。
33
+ - ⚡ **完全ローカル動作**。ONNX は **121MB**(int4+int8)/ **242MB**(無劣化)。`onnxruntime`・`numpy`・`pillow` だけで動く自己完結ランナー同梱。
34
+ - ✅ **Apache-2.0**。
35
+
36
+ > **正直なスコープ:** これは「**モダンなマンガ/同人の吹き出し × ≤115M**」での SOTA。遠いドメイン(1940年代の欧米コミック等)の clean な印字では汎用 0.9B が依然上 → [汎化の上限](#汎化の上限正直な開示)。
37
+
38
+ ---
39
+
40
+ ## モデル概要
41
+
42
+ ```
43
+ 吹き出し crop
44
+ → DINOv2-ViT-B/14 (86M) # 視覚エンコーダ
45
+ → 256 vision tokens → MLP projector (768→512)
46
+ → [ vision | BOS … ] → 6層 GQA decoder (~26M) # 独自の char-level LM
47
+ → 文字ロジット(語彙 14,630)
48
+ ```
49
+
50
+ - **視覚 = DINOv2**(Meta, Apache-2.0)。**言語側 = 独自の小型 char-level decoder**(6層, GQA 8h/2kv, SwiGLU, RMSNorm sandwich, 1D RoPE, tied embedding)をフルスクラッチ学習。
51
+ - **強さの肝=学習レシピ:** まず**視覚エンコーダを凍結**して学習 → その後**視覚を解凍して継続学習**すると、この2段目で精度が大きく跳ね上がる(しかも解凍後 ~30k step = 1エポックの約10% で頭打ち)。char-level なので tokenizer 由来の未知語が無く、擬音・多言語混在に強い。
52
+ - **合計 115M**。PyTorch(CUDA / Apple-MPS / CPU)と ONNX(CPU / CUDA / DirectML / web)対応。
53
+
54
+ ---
55
+
56
+ ## ベンチマーク(独立GT)
57
+
58
+ 教師が作ったラベルで測ると「身内採点」になるため、**独立GT**で採点: 日本語は人手ラベルの **Manga109-v2026**、英語/中国語は**人手で再注釈**(教師ラベル不使用)。主指標 = **lCER**(緩和文字誤り率: NFKC+空白除去+装飾記号除去+`…/・・・`統一、低いほど良い)。Baberu はいずれも**未学習**。
59
+
60
+ **日本語 — Manga109-v2026 (n=2000)**
61
+
62
+ | model | size | lCER ↓ | nCER ↓ |
63
+ |---|---|---|---|
64
+ | **Baberu OCR** | **115M** | **0.0345** | 0.0871 |
65
+ | PaddleOCR-VL | 0.9B | 0.0368 | **0.0808** |
66
+ | manga-ocr *(教師)* | 110M | 0.0422 | 0.0954 |
67
+ | PP-OCRv5 | ~10M | 0.6317 | 0.6491 |
68
+
69
+ **lCER 最良**、そして**教師 manga-ocr には全指標で勝利**。*(正直な注記: strict nCER と完全一致では 0.9B の PaddleOCR-VL が僅かに上 0.0808 vs 0.0871。`PaddleOCR-VL-For-Manga` はさらに低い値を出すが **Manga109 で学習済=データリークのため除外**。)*
70
+
71
+ **英語 — in-domain doujin・人手GT (n=80)**
72
+
73
+ | model | size | lCER ↓ | 完全一致 ↑ |
74
+ |---|---|---|---|
75
+ | **Baberu OCR** | **115M** | **0.0177** | **0.89** |
76
+ | PaddleOCR-VL *(教師)* | 0.9B | 0.0268 | 0.88 |
77
+ | PP-OCRv5 | ~10M | 0.0975 | 0.63 |
78
+ | PaddleOCR-VL-For-Manga | 0.9B | 0.1140 | 0.41 |
79
+ | manga-ocr | 110M | 0.2987 | 0.24 |
80
+
81
+ **中国語 — in-domain doujin・人手GT (n=77)**
82
+
83
+ | model | size | lCER ↓ | nCER ↓ |
84
+ |---|---|---|---|
85
+ | **Baberu OCR** | **115M** | **0.0868** | **0.1244** |
86
+ | PaddleOCR-VL *(教師)* | 0.9B | 0.1013 | 0.1808 |
87
+ | PaddleOCR-VL-For-Manga | 0.9B | 0.1168 | 0.1605 |
88
+ | manga-ocr | 110M | 0.2506 | 0.2518 |
89
+ | PP-OCRv5 | ~10M | 0.5639 | 0.5778 |
90
+
91
+ → **英語・中国語とも、教師 0.9B を 1/8 サイズで上回る**。
92
+
93
+ ### 汎化の上限(正直な開示)
94
+
95
+ 公開・未学習の gold GT **COMICS Text+**(1940年代の欧米コミック, n=480):
96
+
97
+ | model | size | lCER ↓ |
98
+ |---|---|---|
99
+ | PaddleOCR-VL | 0.9B | **0.0256** |
100
+ | PP-OCRv5 | ~10M | 0.1332 |
101
+ | **Baberu OCR** | **115M** | 0.1488 |
102
+
103
+ 遠いドメインでも zero-shot で読めるが、clean な古印字では汎用 0.9B が勝つ。Baberu の本領は**モダンなマンガ/同人吹き出し**(設計どおり)。
104
+
105
+ ---
106
+
107
+ ## クイックスタート
108
+
109
+ 入力は**吹き出し1個の crop**(上流にテキスト検出器が必要)。ページ全体ではない。
110
+
111
+ ### ONNX — 最も簡単&最小(PyTorch不要)
112
+
113
+ ```bash
114
+ pip install onnxruntime numpy pillow
115
+ huggingface-cli download genshiai-daichi/baberu-ocr --local-dir baberu-ocr
116
+ cd baberu-ocr
117
+
118
+ python onnx_infer.py --image bubble.jpg # 242MB, 無劣化
119
+ python onnx_infer.py --vision vision_int4.onnx --image bubble.jpg # 121MB, 最小
120
+ ```
121
+
122
+ `onnx_infer.py` は自己完結(前処理・KVキャッシュのデコードループ・公開済みデコード設定 = greedy + `repetition_penalty=1.2` + 記号自由のcontent-run cap を全部内蔵)なので、PyTorch版と同じテキストが得られる。
123
+
124
+ ### PyTorch — fine-tuning / `transformers` 向け
125
+
126
+ ```bash
127
+ pip install torch transformers safetensors pillow
128
+ huggingface-cli download genshiai-daichi/baberu-ocr --local-dir baberu-ocr
129
+ cd baberu-ocr
130
+ python inference.py --model . --image bubble.jpg
131
+ ```
132
+
133
+ ```python
134
+ from PIL import Image
135
+ from inference import BaberuOCR
136
+ ocr = BaberuOCR(".") # ローカルのDLディレクトリ
137
+ print(ocr(Image.open("bubble.jpg")))
138
+ ```
139
+
140
+ ---
141
+
142
+ ## サイズと量子化(ONNX)
143
+
144
+ | 部品 | fp16 | int8 | int4 |
145
+ |---|---|---|---|
146
+ | vision (86M) | 173MB | 92MB | **52MB** |
147
+ | decoder (prefill+step) | — | **69MB** | (89MB) |
148
+
149
+ 推奨2ティア(どちらも **decoder は int8 = ここでは精度タダ** に固定):
150
+
151
+ | ティア | ファイル | サイズ | nCER (ja) |
152
+ |---|---|---|---|
153
+ | **無劣化** | `vision_fp16.onnx` + `decoder_*_int8.onnx` | **242MB** | 0.0867 |
154
+ | **最小** | `vision_int4.onnx` + `decoder_*_int8.onnx` | **121MB** | 0.0893 |
155
+
156
+ 実測スイープの要点(`quantization-results.md`): **decoder int8 は劣化ゼロ**(nCER 0.0867 ≈ fp32 0.0871 = greedy デコードは重み量子化ノイズに頑健)。コストが出るのは vision 量子化だけで、そこでも **int4(weight-only) が int8(dynamic) より小さく かつ 正確**。どちらのティアも教師 manga-ocr (0.0954) に勝つ。劣勢な構成(vision int8-dynamic / decoder int4)は同梱しない。
157
+
158
+ ---
159
+
160
+ ## 制限(正直な開示)
161
+
162
+ - **入力は吹き出し1 crop**(上流にテキスト検出が必要)。ページOCRではない。
163
+ - **濁点/半濁点**の取り違え(ポ↔ボ)、一部の**稀少漢字**、小書きカナ脱落に弱い。
164
+ - **遠いドメイン**(clean な欧米/古印字)では汎用大型VLMが上。
165
+ - 学習分布に成人向けコンテンツを含むが、モデルはテキストの転写のみを行う。
166
+
167
+ ## 学習データ・ライセンス
168
+
169
+ - 日本語の**マンガ/同人**吹き出し crop で学習。テキストラベルは `manga-ocr`(ja)と `PaddleOCR-VL`(zh/en)から蒸留。**学習画像は再配布しない** — 公開するのはモデル重みとコードのみ。
170
+ - **ライセンス: Apache-2.0**(重み+コード)。上流(DINOv2 / manga-ocr / PaddleOCR、いずれも Apache-2.0)と整合。
171
+
172
+ ## 謝辞
173
+
174
+ - [DINOv2](https://github.com/facebookresearch/dinov2)(Meta)— 視覚エンコーダ
175
+ - [manga-ocr](https://github.com/kha-white/manga-ocr) — 日本語マンガOCRの事実上の標準 / ja教師
176
+ - [PaddleOCR-VL](https://github.com/PaddlePaddle/PaddleOCR) — zh/en 教師
assets/hero-en.png ADDED

Git LFS Details

  • SHA256: 6ca42b02e50a283341dd07ee17b365684d9841ab7227b1e282fad2ccc0e1aa98
  • Pointer size: 132 Bytes
  • Size of remote file: 1.89 MB
assets/hero-ja.png ADDED

Git LFS Details

  • SHA256: a929f33470734fdff396cdf12222edae7af837d339613972a81ab546c2aa0ffa
  • Pointer size: 132 Bytes
  • Size of remote file: 1.83 MB
config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "BaberuOCRModel"
4
+ ],
5
+ "attention_dropout": 0.0,
6
+ "attn_logit_softcap": null,
7
+ "bos_token_id": 1,
8
+ "dtype": "float32",
9
+ "eos_token_id": 2,
10
+ "final_logit_softcap": 30.0,
11
+ "freeze_vision_encoder": false,
12
+ "head_dim": 64,
13
+ "hidden_act": "silu",
14
+ "hidden_size": 512,
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 1536,
17
+ "max_position_embeddings": 2048,
18
+ "model_type": "baberu_ocr",
19
+ "num_attention_heads": 8,
20
+ "num_hidden_layers": 6,
21
+ "num_key_value_heads": 2,
22
+ "pad_token_id": 0,
23
+ "projector_act": "gelu",
24
+ "rms_norm_eps": 1e-06,
25
+ "rope_theta": 10000.0,
26
+ "sandwich_norm": true,
27
+ "tie_word_embeddings": true,
28
+ "transformers_version": "5.5.3",
29
+ "unk_token_id": 3,
30
+ "use_cache": true,
31
+ "vision_hidden_size": 768,
32
+ "vision_image_size": 224,
33
+ "vision_model_name": "facebook/dinov2-base",
34
+ "vision_num_tokens": 256,
35
+ "vision_patch_size": 14,
36
+ "vision_rope_2d": false,
37
+ "vocab_size": 14630,
38
+ "z_loss_weight": 0.0001
39
+ }
configuration_baberu.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for baberu-ocr v2.
2
+
3
+ Single config that drives both the text-only decoder (BaberuCausalLM, used
4
+ for Step 1 pretraining) and the full vision-grounded OCR model
5
+ (BaberuOCRModel = DINOv2 + MLP projector + decoder).
6
+
7
+ See docs/design/ocr-v2-design.md for the full architecture rationale.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from transformers import PretrainedConfig
13
+
14
+
15
+ class BaberuOCRConfig(PretrainedConfig):
16
+ """Config for baberu-ocr v2 (~115M params, multilingual JA+ZH+EN).
17
+
18
+ Defaults match the May 2026 SOTA design:
19
+ - Vision: DINOv2-ViT-B/14 (86M, Apache 2.0)
20
+ - Projector: MLP 768 -> hidden_size
21
+ - Decoder: 6-layer GQA causal LM, hidden=512, FFN=1536
22
+ - RoPE 1D (text), 2D-RoPE for vision optional
23
+ - RMSNorm sandwich-norm, SwiGLU, logit soft-cap, z-loss, tied embeddings
24
+
25
+ Use ``vision_model_name=None`` to disable the vision branch
26
+ (text-only mode for Step 1 pretraining).
27
+ """
28
+
29
+ model_type = "baberu_ocr"
30
+ keys_to_ignore_at_inference = ["past_key_values"]
31
+
32
+ def __init__(
33
+ self,
34
+ # ---- Vision encoder ----
35
+ vision_model_name: str | None = "facebook/dinov2-base",
36
+ vision_hidden_size: int = 768,
37
+ vision_image_size: int = 224,
38
+ vision_patch_size: int = 14,
39
+ vision_num_tokens: int = 256, # (224/14)^2
40
+ freeze_vision_encoder: bool = True,
41
+ # ---- Projector ----
42
+ projector_act: str = "gelu",
43
+ # ---- Decoder ----
44
+ vocab_size: int = 14630,
45
+ hidden_size: int = 512,
46
+ intermediate_size: int = 1536,
47
+ num_hidden_layers: int = 6,
48
+ num_attention_heads: int = 8,
49
+ num_key_value_heads: int = 2,
50
+ head_dim: int | None = None, # default = hidden_size // num_attention_heads
51
+ max_position_embeddings: int = 2048,
52
+ hidden_act: str = "silu",
53
+ # ---- Norm ----
54
+ rms_norm_eps: float = 1e-6,
55
+ sandwich_norm: bool = True,
56
+ # ---- RoPE ----
57
+ rope_theta: float = 10000.0,
58
+ vision_rope_2d: bool = False, # 2D RoPE for vision prefix (planned)
59
+ # ---- Stability ----
60
+ final_logit_softcap: float = 30.0,
61
+ attn_logit_softcap: float | None = None,
62
+ z_loss_weight: float = 1e-4,
63
+ # ---- Embeddings ----
64
+ tie_word_embeddings: bool = True,
65
+ # ---- Init ----
66
+ initializer_range: float = 0.02,
67
+ # ---- Special tokens ----
68
+ pad_token_id: int = 0,
69
+ bos_token_id: int = 1,
70
+ eos_token_id: int = 2,
71
+ unk_token_id: int = 3,
72
+ # ---- Cache / dtype ----
73
+ use_cache: bool = True,
74
+ attention_dropout: float = 0.0,
75
+ **kwargs,
76
+ ):
77
+ self.vision_model_name = vision_model_name
78
+ self.vision_hidden_size = vision_hidden_size
79
+ self.vision_image_size = vision_image_size
80
+ self.vision_patch_size = vision_patch_size
81
+ self.vision_num_tokens = vision_num_tokens
82
+ self.freeze_vision_encoder = freeze_vision_encoder
83
+
84
+ self.projector_act = projector_act
85
+
86
+ self.vocab_size = vocab_size
87
+ self.hidden_size = hidden_size
88
+ self.intermediate_size = intermediate_size
89
+ self.num_hidden_layers = num_hidden_layers
90
+ self.num_attention_heads = num_attention_heads
91
+ self.num_key_value_heads = num_key_value_heads
92
+ self.head_dim = head_dim or (hidden_size // num_attention_heads)
93
+ self.max_position_embeddings = max_position_embeddings
94
+ self.hidden_act = hidden_act
95
+
96
+ self.rms_norm_eps = rms_norm_eps
97
+ self.sandwich_norm = sandwich_norm
98
+
99
+ self.rope_theta = rope_theta
100
+ self.vision_rope_2d = vision_rope_2d
101
+
102
+ self.final_logit_softcap = final_logit_softcap
103
+ self.attn_logit_softcap = attn_logit_softcap
104
+ self.z_loss_weight = z_loss_weight
105
+
106
+ self.initializer_range = initializer_range
107
+ self.use_cache = use_cache
108
+ self.attention_dropout = attention_dropout
109
+
110
+ super().__init__(
111
+ pad_token_id=pad_token_id,
112
+ bos_token_id=bos_token_id,
113
+ eos_token_id=eos_token_id,
114
+ tie_word_embeddings=tie_word_embeddings,
115
+ **kwargs,
116
+ )
117
+ # Stored separately because PretrainedConfig doesn't claim unk.
118
+ self.unk_token_id = unk_token_id
119
+
120
+ # Convenience for sanity checks
121
+ def validate(self) -> None:
122
+ assert self.num_attention_heads % self.num_key_value_heads == 0, (
123
+ f"num_attention_heads ({self.num_attention_heads}) must be divisible "
124
+ f"by num_key_value_heads ({self.num_key_value_heads}) for GQA"
125
+ )
126
+ assert self.head_dim * self.num_attention_heads == self.hidden_size, (
127
+ f"head_dim ({self.head_dim}) * num_attention_heads "
128
+ f"({self.num_attention_heads}) must equal hidden_size ({self.hidden_size})"
129
+ )
generation_config.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "bos_token_id": 1,
4
+ "eos_token_id": 2,
5
+ "max_new_tokens": 128,
6
+ "output_attentions": false,
7
+ "output_hidden_states": false,
8
+ "pad_token_id": 0,
9
+ "repetition_penalty": 1.2,
10
+ "transformers_version": "5.5.3",
11
+ "use_cache": true
12
+ }
inference.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal, canonical inference for the custom Baberu OCR VLM (DINOv2 + projector
2
+ + 6-layer GQA char decoder, ~115M). This is the source-of-truth example shipped
3
+ with the model card.
4
+
5
+ DECODING — IMPORTANT (see docs/design/ocr-indomain-adjudication-2026-06-19.md):
6
+ Two complementary anti-loop guards, both ON by default — they reproduce the
7
+ published accuracy numbers:
8
+ 1. ``repetition_penalty=1.2`` (NOT ``no_repeat_ngram_size``: the latter hard-caps
9
+ identical runs at N, TRUNCATING legit manga punctuation like ・・・・・・ / ーー —
10
+ it cost ~5pt of exact-match agreement on real crops).
11
+ 2. a SYMBOL-AWARE content-run cap: caps runs of identical CONTENT chars
12
+ (kanji/kana/latin/digit) at 12, while symbols/・・・/ーー repeat freely. This
13
+ kills pathological single-char loops (啊×63, む×61) that repetition_penalty
14
+ alone misses — without it, zh lCER regresses from 0.087 to ~0.147.
15
+
16
+ Usage:
17
+ python -m baberu.ocr_v2.inference --model <ckpt_dir> --image page_crop.jpg
18
+ # or, programmatically:
19
+ ocr = BaberuOCR("<ckpt_dir>"); print(ocr(Image.open("crop.jpg")))
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import unicodedata
25
+ from pathlib import Path
26
+ from typing import Union
27
+
28
+ import torch
29
+ from PIL import Image
30
+ from safetensors.torch import load_file as load_safetensors
31
+ from transformers import LogitsProcessor, LogitsProcessorList
32
+
33
+ from configuration_baberu import BaberuOCRConfig
34
+ from modeling_baberu import BaberuOCRModel
35
+ from tokenization_baberu import BaberuTokenizer
36
+
37
+
38
+ def build_ocr_image_processor(vision_model: str = "facebook/dinov2-base"):
39
+ """DINOv2 processor with do_center_crop=False — resize the WHOLE crop to 224x224
40
+ (tall 縦書き crops lose their top/bottom under the default 256→center-crop-224).
41
+ Matches training/eval preprocessing exactly."""
42
+ from transformers import AutoImageProcessor
43
+
44
+ return AutoImageProcessor.from_pretrained(
45
+ vision_model, do_center_crop=False, size={"height": 224, "width": 224}, crop_size=None
46
+ )
47
+
48
+
49
+ class CapContentRun(LogitsProcessor):
50
+ """Cap runs of identical CONTENT chars (Unicode L/N, excl. long-vowel ー~) at
51
+ ``max_run``; symbols/punctuation/・・・/ーー repeat freely. Kills single-char
52
+ loops the repetition_penalty misses, without clipping legit symbol runs."""
53
+
54
+ def __init__(self, content_ids: set[int], max_run: int = 12):
55
+ self.content_ids = content_ids
56
+ self.max_run = max_run
57
+
58
+ def __call__(self, input_ids, scores):
59
+ for b in range(input_ids.shape[0]):
60
+ t = int(input_ids[b, -1])
61
+ if t in self.content_ids:
62
+ run, j = 0, input_ids.shape[1] - 1
63
+ while j >= 0 and int(input_ids[b, j]) == t:
64
+ run += 1
65
+ j -= 1
66
+ if run >= self.max_run:
67
+ break
68
+ if run >= self.max_run:
69
+ scores[b, t] = float("-inf")
70
+ return scores
71
+
72
+
73
+ def _build_content_ids(tok) -> set[int]:
74
+ def is_content(ch: str) -> bool:
75
+ if not ch or len(ch) != 1 or ch in "ーー〜~":
76
+ return False
77
+ return unicodedata.category(ch)[0] in "LN"
78
+
79
+ n = getattr(tok, "vocab_size", None) or len(tok)
80
+ return {i for i in range(n) if is_content(tok.decode([i], skip_special_tokens=False))}
81
+
82
+
83
+ class BaberuOCR:
84
+ """Load a Step-3 checkpoint and OCR a single bubble crop."""
85
+
86
+ def __init__(self, model_dir: Union[str, Path], device: str | None = None):
87
+ model_dir = Path(model_dir)
88
+ self.device = torch.device(
89
+ device or ("cuda" if torch.cuda.is_available() else
90
+ "mps" if torch.backends.mps.is_available() else "cpu")
91
+ )
92
+ dtype = torch.bfloat16 if self.device.type == "cuda" else torch.float32
93
+
94
+ config = BaberuOCRConfig.from_pretrained(model_dir)
95
+ self.model = BaberuOCRModel(config)
96
+ # strict=False: a non-persistent buffer (causal mask / rope) may be missing;
97
+ # all trained weights (incl. the vision encoder) load from the checkpoint.
98
+ self.model.load_state_dict(
99
+ load_safetensors(model_dir / "model.safetensors"), strict=False
100
+ )
101
+ self.model.to(device=self.device, dtype=dtype).eval()
102
+
103
+ tok_dir = model_dir / "tokenizer"
104
+ self.tok = BaberuTokenizer.from_pretrained(
105
+ tok_dir if tok_dir.exists() else model_dir
106
+ )
107
+ # SAME preprocessing as training: no center-crop, whole crop -> 224x224.
108
+ self.image_processor = build_ocr_image_processor(config.vision_model_name)
109
+ self._content_ids = _build_content_ids(self.tok)
110
+
111
+ @torch.inference_mode()
112
+ def __call__(
113
+ self,
114
+ image: Image.Image,
115
+ max_new_tokens: int = 128,
116
+ repetition_penalty: float = 1.2,
117
+ max_content_run: int = 12,
118
+ ) -> str:
119
+ image = image.convert("RGB")
120
+ pixel_values = self.image_processor(image, return_tensors="pt")["pixel_values"].to(self.device)
121
+ input_ids = torch.tensor([[self.tok.bos_token_id]], device=self.device)
122
+ logits_processor = LogitsProcessorList()
123
+ if max_content_run:
124
+ logits_processor.append(CapContentRun(self._content_ids, max_content_run))
125
+ output_ids = self.model.generate(
126
+ input_ids=input_ids,
127
+ pixel_values=pixel_values,
128
+ max_new_tokens=max_new_tokens,
129
+ do_sample=False,
130
+ repetition_penalty=repetition_penalty, # NOT no_repeat_ngram_size — see module docstring
131
+ logits_processor=logits_processor, # symbol-aware content-run cap
132
+ eos_token_id=self.tok.eos_token_id,
133
+ pad_token_id=self.tok.pad_token_id,
134
+ bos_token_id=self.tok.bos_token_id,
135
+ use_cache=True,
136
+ )
137
+ pred_ids = output_ids[0, input_ids.shape[1]:].detach().cpu().tolist()
138
+ return self.tok.decode(pred_ids, skip_special_tokens=True)
139
+
140
+
141
+ def main():
142
+ ap = argparse.ArgumentParser(description="OCR a manga bubble crop with the custom Baberu model.")
143
+ ap.add_argument("--model", required=True, help="Step-3 checkpoint dir")
144
+ ap.add_argument("--image", required=True, help="crop image to OCR")
145
+ ap.add_argument("--device", default=None)
146
+ ap.add_argument("--max-new-tokens", type=int, default=128)
147
+ ap.add_argument("--repetition-penalty", type=float, default=1.2)
148
+ ap.add_argument("--max-content-run", type=int, default=12,
149
+ help="cap consecutive identical content chars (0=off)")
150
+ args = ap.parse_args()
151
+
152
+ ocr = BaberuOCR(args.model, device=args.device)
153
+ text = ocr(Image.open(args.image),
154
+ max_new_tokens=args.max_new_tokens,
155
+ repetition_penalty=args.repetition_penalty,
156
+ max_content_run=args.max_content_run)
157
+ print(text)
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4b389c67e3ae84db05270920b55404c41085324d098c431a2f79ca92273288df
3
+ size 451347624
modeling_baberu.py ADDED
@@ -0,0 +1,825 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """baberu-ocr v2: prefix-token VLM for multilingual manga OCR.
2
+
3
+ Architecture (May 2026 SOTA):
4
+ Vision (DINOv2) -> MLP projector -> prefix tokens -> [BOS] text [EOS]
5
+ \\
6
+ -> GQA causal LM (6 layers)
7
+
8
+ Decoder details:
9
+ - GQA self-attention (Llama-style) with 1D RoPE (text positions)
10
+ - SwiGLU FFN
11
+ - RMSNorm sandwich-norm (Gemma2-style: norm before AND after each sub-block)
12
+ - Tied input/output embeddings
13
+ - Logit soft-cap (Gemma2-style: tanh(logit/cap)*cap)
14
+ - Z-loss aux for logit-drift stability
15
+ - KV cache for fast decode
16
+
17
+ Two entry points:
18
+ - ``BaberuCausalLM`` : text-only mode (Step 1 pretraining on FineWeb2)
19
+ - ``BaberuOCRModel`` : vision + decoder for OCR (Steps 3-1..3-3)
20
+
21
+ 2D RoPE for vision prefix is planned (see config.vision_rope_2d) but currently
22
+ falls back to 1D positions over the combined [vision || text] sequence.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Optional, Tuple, Union
29
+
30
+ import torch
31
+ import torch.nn as nn
32
+ import torch.nn.functional as F
33
+ from transformers import AutoConfig, AutoModel, PreTrainedModel
34
+ from transformers.generation import GenerationMixin
35
+ from transformers.modeling_outputs import (
36
+ BaseModelOutputWithPast,
37
+ CausalLMOutputWithPast,
38
+ )
39
+
40
+ from configuration_baberu import BaberuOCRConfig
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Small building blocks
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ class BaberuRMSNorm(nn.Module):
49
+ def __init__(self, hidden_size: int, eps: float = 1e-6):
50
+ super().__init__()
51
+ self.weight = nn.Parameter(torch.ones(hidden_size))
52
+ self.eps = eps
53
+
54
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
55
+ input_dtype = x.dtype
56
+ x_f32 = x.to(torch.float32)
57
+ var = x_f32.pow(2).mean(-1, keepdim=True)
58
+ x_f32 = x_f32 * torch.rsqrt(var + self.eps)
59
+ return (self.weight * x_f32).to(input_dtype)
60
+
61
+
62
+ class BaberuMLP(nn.Module):
63
+ """SwiGLU FFN: down(silu(gate) * up)."""
64
+
65
+ def __init__(self, config: BaberuOCRConfig):
66
+ super().__init__()
67
+ self.gate_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
68
+ self.up_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
69
+ self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
70
+
71
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
72
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Rotary position embedding (1D)
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class BaberuRotaryEmbedding(nn.Module):
81
+ """Precomputed 1D RoPE table. Llama-style."""
82
+
83
+ def __init__(self, dim: int, max_seq_len: int, theta: float = 10000.0):
84
+ super().__init__()
85
+ self.dim = dim
86
+ self.theta = theta
87
+ inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
88
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
89
+ self.max_seq_len_cached = 0
90
+ self._cos_cached: torch.Tensor | None = None
91
+ self._sin_cached: torch.Tensor | None = None
92
+ self._build_cache(max_seq_len, device=torch.device("cpu"))
93
+
94
+ def _build_cache(self, seq_len: int, device: torch.device, dtype: torch.dtype = torch.float32):
95
+ self.max_seq_len_cached = seq_len
96
+ t = torch.arange(seq_len, device=device, dtype=torch.float32)
97
+ # Recompute inv_freq from theta/dim rather than reading the buffer. It is
98
+ # registered non-persistent (not in the checkpoint), so under
99
+ # `from_pretrained` it is created on the meta device then materialised by
100
+ # `to_empty()` to *uninitialised* memory — no longer `is_meta`, but filled
101
+ # with garbage that silently breaks RoPE (positions collapse, output turns
102
+ # to junk). Recomputing here is cheap and always correct.
103
+ inv_freq = 1.0 / (
104
+ self.theta ** (torch.arange(0, self.dim, 2, device=device, dtype=torch.float32) / self.dim)
105
+ )
106
+ freqs = torch.einsum("i,j->ij", t, inv_freq)
107
+ emb = torch.cat([freqs, freqs], dim=-1)
108
+ self._cos_cached = emb.cos().to(dtype)
109
+ self._sin_cached = emb.sin().to(dtype)
110
+
111
+ def forward(self, x: torch.Tensor, position_ids: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
112
+ # position_ids: [B, T] long
113
+ seq_max = int(position_ids.max().item()) + 1
114
+ if (
115
+ self._cos_cached is None
116
+ or seq_max > self.max_seq_len_cached
117
+ or self._cos_cached.device != x.device
118
+ or self._cos_cached.dtype != x.dtype
119
+ ):
120
+ self._build_cache(max(seq_max, self.max_seq_len_cached, 64), device=x.device, dtype=x.dtype)
121
+ cos = self._cos_cached[position_ids] # [B, T, dim]
122
+ sin = self._sin_cached[position_ids]
123
+ return cos, sin
124
+
125
+
126
+ def _rotate_half(x: torch.Tensor) -> torch.Tensor:
127
+ x1 = x[..., : x.shape[-1] // 2]
128
+ x2 = x[..., x.shape[-1] // 2 :]
129
+ return torch.cat([-x2, x1], dim=-1)
130
+
131
+
132
+ def apply_rotary_pos_emb(
133
+ q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
134
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
135
+ # q, k: [B, n_heads, T, head_dim]; cos, sin: [B, T, head_dim]
136
+ cos = cos.unsqueeze(1)
137
+ sin = sin.unsqueeze(1)
138
+ q_rot = (q * cos) + (_rotate_half(q) * sin)
139
+ k_rot = (k * cos) + (_rotate_half(k) * sin)
140
+ return q_rot, k_rot
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # GQA self-attention with KV cache
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def _repeat_kv(hidden_states: torch.Tensor, n_rep: int) -> torch.Tensor:
149
+ """Expand KV heads to match Q heads for GQA. [B, kv_heads, T, D] -> [B, q_heads, T, D]."""
150
+ if n_rep == 1:
151
+ return hidden_states
152
+ bsz, kv_heads, slen, head_dim = hidden_states.shape
153
+ hidden_states = hidden_states[:, :, None, :, :].expand(bsz, kv_heads, n_rep, slen, head_dim)
154
+ return hidden_states.reshape(bsz, kv_heads * n_rep, slen, head_dim)
155
+
156
+
157
+ def _cache_length(past_key_values) -> int:
158
+ """Return number of tokens already in the cache, handling DynamicCache and legacy tuples."""
159
+ if past_key_values is None:
160
+ return 0
161
+ if hasattr(past_key_values, "layers"):
162
+ layers = past_key_values.layers
163
+ if not layers or not layers[0].is_initialized:
164
+ return 0
165
+ return layers[0].keys.shape[-2]
166
+ if isinstance(past_key_values, (list, tuple)) and len(past_key_values) > 0:
167
+ return past_key_values[0][0].shape[-2]
168
+ return 0
169
+
170
+
171
+ class BaberuAttention(nn.Module):
172
+ def __init__(self, config: BaberuOCRConfig, layer_idx: int):
173
+ super().__init__()
174
+ self.config = config
175
+ self.layer_idx = layer_idx
176
+ self.hidden_size = config.hidden_size
177
+ self.num_heads = config.num_attention_heads
178
+ self.head_dim = config.head_dim
179
+ self.num_kv_heads = config.num_key_value_heads
180
+ self.num_kv_groups = self.num_heads // self.num_kv_heads
181
+ self.attn_softcap = config.attn_logit_softcap
182
+ self.attention_dropout = config.attention_dropout
183
+
184
+ self.q_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)
185
+ self.k_proj = nn.Linear(self.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
186
+ self.v_proj = nn.Linear(self.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
187
+ self.o_proj = nn.Linear(self.num_heads * self.head_dim, self.hidden_size, bias=False)
188
+
189
+ def forward(
190
+ self,
191
+ hidden_states: torch.Tensor, # [B, T, H]
192
+ cos: torch.Tensor,
193
+ sin: torch.Tensor,
194
+ attention_mask: Optional[torch.Tensor] = None, # [B, 1, T, S] additive (-inf for masked)
195
+ past_key_value: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
196
+ use_cache: bool = False,
197
+ ) -> Tuple[torch.Tensor, Optional[Tuple[torch.Tensor, torch.Tensor]]]:
198
+ bsz, q_len, _ = hidden_states.shape
199
+
200
+ q = self.q_proj(hidden_states).view(bsz, q_len, self.num_heads, self.head_dim).transpose(1, 2)
201
+ k = self.k_proj(hidden_states).view(bsz, q_len, self.num_kv_heads, self.head_dim).transpose(1, 2)
202
+ v = self.v_proj(hidden_states).view(bsz, q_len, self.num_kv_heads, self.head_dim).transpose(1, 2)
203
+
204
+ q, k = apply_rotary_pos_emb(q, k, cos, sin)
205
+
206
+ if past_key_value is not None:
207
+ past_k, past_v = past_key_value
208
+ k = torch.cat([past_k, k], dim=2)
209
+ v = torch.cat([past_v, v], dim=2)
210
+
211
+ present = (k, v) if use_cache else None
212
+
213
+ k_expanded = _repeat_kv(k, self.num_kv_groups)
214
+ v_expanded = _repeat_kv(v, self.num_kv_groups)
215
+
216
+ # Scaled dot-product attention. Q: [B, H, Tq, D], K/V: [B, H, Tk, D]
217
+ if self.attn_softcap is not None:
218
+ # SDPA/flash kernels cannot apply Gemma-style attention logit
219
+ # soft-capping, so keep the explicit (memory-heavy) path only when
220
+ # soft-capping is enabled.
221
+ attn_scores = torch.matmul(q, k_expanded.transpose(-1, -2)) / (self.head_dim ** 0.5)
222
+ attn_scores = torch.tanh(attn_scores / self.attn_softcap) * self.attn_softcap
223
+ if attention_mask is not None:
224
+ attn_scores = attn_scores + attention_mask[:, :, :, : k.shape[-2]]
225
+ attn_weights = F.softmax(attn_scores.to(torch.float32), dim=-1).to(q.dtype)
226
+ if self.attention_dropout > 0 and self.training:
227
+ attn_weights = F.dropout(attn_weights, p=self.attention_dropout)
228
+ attn_output = torch.matmul(attn_weights, v_expanded) # [B, H, Tq, D]
229
+ else:
230
+ # FlashAttention / mem-efficient SDPA: never materialises the
231
+ # [B, H, Tq, Tk] score matrix, which is the bulk of the memory for
232
+ # long sequences. Mathematically identical to the explicit path.
233
+ attn_mask = attention_mask[:, :, :, : k.shape[-2]] if attention_mask is not None else None
234
+ attn_output = F.scaled_dot_product_attention(
235
+ q, k_expanded, v_expanded,
236
+ attn_mask=attn_mask,
237
+ is_causal=attn_mask is None,
238
+ dropout_p=self.attention_dropout if self.training else 0.0,
239
+ scale=self.head_dim ** -0.5,
240
+ )
241
+ attn_output = attn_output.transpose(1, 2).contiguous().view(bsz, q_len, -1)
242
+ attn_output = self.o_proj(attn_output)
243
+ return attn_output, present
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Decoder layer (sandwich norm)
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ class BaberuDecoderLayer(nn.Module):
252
+ """One layer of the causal decoder.
253
+
254
+ With ``sandwich_norm=True``, both pre- and post-RMSNorm wrap each
255
+ sub-block (attention and FFN), in the style of Gemma 2.
256
+ """
257
+
258
+ def __init__(self, config: BaberuOCRConfig, layer_idx: int):
259
+ super().__init__()
260
+ self.config = config
261
+ self.sandwich = config.sandwich_norm
262
+ self.input_norm = BaberuRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
263
+ self.attn = BaberuAttention(config, layer_idx)
264
+ self.post_attn_norm = BaberuRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
265
+ self.pre_ffn_norm = BaberuRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
266
+ self.mlp = BaberuMLP(config)
267
+ self.post_ffn_norm = BaberuRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
268
+
269
+ def forward(
270
+ self,
271
+ hidden_states: torch.Tensor,
272
+ cos: torch.Tensor,
273
+ sin: torch.Tensor,
274
+ attention_mask: Optional[torch.Tensor] = None,
275
+ past_key_value: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
276
+ use_cache: bool = False,
277
+ ) -> Tuple[torch.Tensor, Optional[Tuple[torch.Tensor, torch.Tensor]]]:
278
+ residual = hidden_states
279
+ x = self.input_norm(hidden_states)
280
+ x, present = self.attn(
281
+ hidden_states=x,
282
+ cos=cos,
283
+ sin=sin,
284
+ attention_mask=attention_mask,
285
+ past_key_value=past_key_value,
286
+ use_cache=use_cache,
287
+ )
288
+ if self.sandwich:
289
+ x = self.post_attn_norm(x)
290
+ hidden_states = residual + x
291
+
292
+ residual = hidden_states
293
+ x = self.pre_ffn_norm(hidden_states)
294
+ x = self.mlp(x)
295
+ if self.sandwich:
296
+ x = self.post_ffn_norm(x)
297
+ hidden_states = residual + x
298
+ return hidden_states, present
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Base preTrained model
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ class BaberuPreTrainedModel(PreTrainedModel):
307
+ config_class = BaberuOCRConfig
308
+ base_model_prefix = "model"
309
+ supports_gradient_checkpointing = True
310
+ _no_split_modules = ["BaberuDecoderLayer"]
311
+ _skip_keys_device_placement = "past_key_values"
312
+
313
+ def _init_weights(self, module):
314
+ std = self.config.initializer_range
315
+ if isinstance(module, nn.Linear):
316
+ module.weight.data.normal_(mean=0.0, std=std)
317
+ if module.bias is not None:
318
+ module.bias.data.zero_()
319
+ elif isinstance(module, nn.Embedding):
320
+ module.weight.data.normal_(mean=0.0, std=std)
321
+ if module.padding_idx is not None:
322
+ module.weight.data[module.padding_idx].zero_()
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Backbone (embeddings + decoder layers + final norm)
327
+ # ---------------------------------------------------------------------------
328
+
329
+
330
+ class BaberuModel(BaberuPreTrainedModel):
331
+ """Decoder backbone, language-only (no LM head, no vision).
332
+
333
+ Accepts either ``input_ids`` (token IDs that go through the embedding
334
+ table) or ``inputs_embeds`` (pre-embedded, used when prepending vision).
335
+ """
336
+
337
+ def __init__(self, config: BaberuOCRConfig):
338
+ super().__init__(config)
339
+ config.validate()
340
+ self.embed_tokens = nn.Embedding(
341
+ config.vocab_size, config.hidden_size, padding_idx=config.pad_token_id
342
+ )
343
+ self.layers = nn.ModuleList(
344
+ [BaberuDecoderLayer(config, i) for i in range(config.num_hidden_layers)]
345
+ )
346
+ self.norm = BaberuRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
347
+ self.rotary_emb = BaberuRotaryEmbedding(
348
+ config.head_dim, config.max_position_embeddings, theta=config.rope_theta
349
+ )
350
+ self.gradient_checkpointing = False
351
+ self.post_init()
352
+
353
+ def get_input_embeddings(self) -> nn.Embedding:
354
+ return self.embed_tokens
355
+
356
+ def set_input_embeddings(self, value: nn.Embedding) -> None:
357
+ self.embed_tokens = value
358
+
359
+ @staticmethod
360
+ def _build_causal_mask(
361
+ q_len: int, kv_len: int, device: torch.device, dtype: torch.dtype
362
+ ) -> torch.Tensor:
363
+ """Lower-triangular additive mask: -inf above the diagonal aligned to kv_len."""
364
+ # Q has length q_len, K has length kv_len. The last q_len queries see the
365
+ # last q_len keys causally; earlier keys (from KV cache) are all visible.
366
+ mask = torch.full((q_len, kv_len), float("-inf"), device=device, dtype=dtype)
367
+ # Compute offset so that diagonal at (q, q + (kv_len - q_len)) is allowed.
368
+ offset = kv_len - q_len
369
+ for i in range(q_len):
370
+ mask[i, : i + 1 + offset] = 0.0
371
+ return mask # [Tq, Tk]
372
+
373
+ def forward(
374
+ self,
375
+ input_ids: Optional[torch.LongTensor] = None,
376
+ attention_mask: Optional[torch.Tensor] = None, # [B, T] of 1/0
377
+ position_ids: Optional[torch.LongTensor] = None,
378
+ past_key_values: Optional[Tuple[Tuple[torch.Tensor, torch.Tensor], ...]] = None,
379
+ inputs_embeds: Optional[torch.FloatTensor] = None,
380
+ use_cache: Optional[bool] = None,
381
+ output_hidden_states: Optional[bool] = None,
382
+ return_dict: Optional[bool] = None,
383
+ ) -> Union[Tuple, BaseModelOutputWithPast]:
384
+ use_cache = use_cache if use_cache is not None else self.config.use_cache
385
+ return_dict = return_dict if return_dict is not None else True
386
+
387
+ if (input_ids is None) == (inputs_embeds is None):
388
+ raise ValueError("Pass exactly one of input_ids or inputs_embeds.")
389
+
390
+ if inputs_embeds is None:
391
+ inputs_embeds = self.embed_tokens(input_ids)
392
+ bsz, q_len, _ = inputs_embeds.shape
393
+ device = inputs_embeds.device
394
+
395
+ # Normalize transformers Cache objects to a list of (k, v) tuples.
396
+ if past_key_values is not None and hasattr(past_key_values, "layers"):
397
+ layers = past_key_values.layers
398
+ past_key_values = (
399
+ [(l.keys, l.values) for l in layers if l.is_initialized]
400
+ if layers
401
+ else None
402
+ )
403
+ if past_key_values is not None and len(past_key_values) == 0:
404
+ past_key_values = None
405
+
406
+ past_len = _cache_length(past_key_values)
407
+ kv_len = past_len + q_len
408
+
409
+ if position_ids is None:
410
+ position_ids = torch.arange(past_len, kv_len, device=device).unsqueeze(0).expand(bsz, -1)
411
+
412
+ cos, sin = self.rotary_emb(inputs_embeds, position_ids)
413
+
414
+ # Causal mask. Reshape to broadcast across batch and heads.
415
+ causal_mask = self._build_causal_mask(q_len, kv_len, device, inputs_embeds.dtype)
416
+ attn_mask = causal_mask[None, None, :, :].expand(bsz, 1, q_len, kv_len).clone()
417
+ if attention_mask is not None:
418
+ # attention_mask: [B, kv_len] with 1 for keep, 0 for pad
419
+ pad_mask = (1 - attention_mask[:, None, None, :]).to(inputs_embeds.dtype) * float("-inf")
420
+ # Replace NaN from (-inf * 0)
421
+ pad_mask = torch.nan_to_num(pad_mask, nan=0.0)
422
+ attn_mask = attn_mask + pad_mask[:, :, :, :kv_len]
423
+
424
+ hidden_states = inputs_embeds
425
+ next_cache = [] if use_cache else None
426
+ all_hidden_states = [] if output_hidden_states else None
427
+
428
+ for i, layer in enumerate(self.layers):
429
+ if output_hidden_states:
430
+ all_hidden_states.append(hidden_states)
431
+ layer_past = past_key_values[i] if past_key_values is not None else None
432
+ if self.gradient_checkpointing and self.training:
433
+ hidden_states, present = torch.utils.checkpoint.checkpoint(
434
+ layer.__call__,
435
+ hidden_states, cos, sin, attn_mask, layer_past, use_cache,
436
+ use_reentrant=False,
437
+ )
438
+ else:
439
+ hidden_states, present = layer(
440
+ hidden_states=hidden_states,
441
+ cos=cos,
442
+ sin=sin,
443
+ attention_mask=attn_mask,
444
+ past_key_value=layer_past,
445
+ use_cache=use_cache,
446
+ )
447
+ if use_cache:
448
+ next_cache.append(present)
449
+
450
+ hidden_states = self.norm(hidden_states)
451
+ if output_hidden_states:
452
+ all_hidden_states.append(hidden_states)
453
+
454
+ if not return_dict:
455
+ outputs = (hidden_states,)
456
+ if use_cache:
457
+ outputs = outputs + (tuple(next_cache),)
458
+ if output_hidden_states:
459
+ outputs = outputs + (tuple(all_hidden_states),)
460
+ return outputs
461
+
462
+ return BaseModelOutputWithPast(
463
+ last_hidden_state=hidden_states,
464
+ past_key_values=tuple(next_cache) if use_cache else None,
465
+ hidden_states=tuple(all_hidden_states) if output_hidden_states else None,
466
+ attentions=None,
467
+ )
468
+
469
+
470
+ # ---------------------------------------------------------------------------
471
+ # Causal LM (text-only, for Step 1 pretraining)
472
+ # ---------------------------------------------------------------------------
473
+
474
+
475
+ def _apply_softcap(logits: torch.Tensor, cap: float) -> torch.Tensor:
476
+ return torch.tanh(logits / cap) * cap
477
+
478
+
479
+ def _z_loss(logits: torch.Tensor, ignore_index: int = -100, labels: Optional[torch.Tensor] = None) -> torch.Tensor:
480
+ """Auxiliary z-loss: -1e-4 * log(Z)^2 averaged over non-ignored positions.
481
+
482
+ Encourages logit normalizer log(Z) -> 0, stabilizing training. Apply on
483
+ pre-softcap logits to keep gradient signal meaningful.
484
+ """
485
+ if labels is not None:
486
+ mask = (labels != ignore_index).float()
487
+ log_z = torch.logsumexp(logits.float(), dim=-1) # [B, T]
488
+ denom = mask.sum().clamp(min=1.0)
489
+ return ((log_z**2) * mask).sum() / denom
490
+ log_z = torch.logsumexp(logits.float(), dim=-1)
491
+ return (log_z**2).mean()
492
+
493
+
494
+ @dataclass
495
+ class BaberuCausalLMOutput(CausalLMOutputWithPast):
496
+ z_loss: Optional[torch.FloatTensor] = None
497
+
498
+
499
+ class BaberuCausalLM(BaberuPreTrainedModel, GenerationMixin):
500
+ """Decoder-only causal LM. Used for Step 1 text pretraining."""
501
+
502
+ _tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"}
503
+
504
+ def __init__(self, config: BaberuOCRConfig):
505
+ super().__init__(config)
506
+ self.model = BaberuModel(config)
507
+ self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
508
+ if config.tie_word_embeddings:
509
+ self.lm_head.weight = self.model.embed_tokens.weight
510
+ self.post_init()
511
+
512
+ def get_input_embeddings(self) -> nn.Embedding:
513
+ return self.model.embed_tokens
514
+
515
+ def set_input_embeddings(self, value: nn.Embedding) -> None:
516
+ self.model.embed_tokens = value
517
+
518
+ def get_output_embeddings(self) -> nn.Linear:
519
+ return self.lm_head
520
+
521
+ def set_output_embeddings(self, value: nn.Linear) -> None:
522
+ self.lm_head = value
523
+
524
+ def forward(
525
+ self,
526
+ input_ids: Optional[torch.LongTensor] = None,
527
+ attention_mask: Optional[torch.Tensor] = None,
528
+ position_ids: Optional[torch.LongTensor] = None,
529
+ past_key_values: Optional[Tuple] = None,
530
+ inputs_embeds: Optional[torch.FloatTensor] = None,
531
+ labels: Optional[torch.LongTensor] = None,
532
+ use_cache: Optional[bool] = None,
533
+ output_hidden_states: Optional[bool] = None,
534
+ return_dict: Optional[bool] = None,
535
+ **kwargs,
536
+ ) -> Union[Tuple, BaberuCausalLMOutput]:
537
+ return_dict = return_dict if return_dict is not None else True
538
+ outputs = self.model(
539
+ input_ids=input_ids,
540
+ attention_mask=attention_mask,
541
+ position_ids=position_ids,
542
+ past_key_values=past_key_values,
543
+ inputs_embeds=inputs_embeds,
544
+ use_cache=use_cache,
545
+ output_hidden_states=output_hidden_states,
546
+ return_dict=True,
547
+ )
548
+ hidden_states = outputs.last_hidden_state
549
+ logits_raw = self.lm_head(hidden_states)
550
+ logits = (
551
+ _apply_softcap(logits_raw, self.config.final_logit_softcap)
552
+ if self.config.final_logit_softcap
553
+ else logits_raw
554
+ )
555
+
556
+ loss: Optional[torch.Tensor] = None
557
+ z_loss: Optional[torch.Tensor] = None
558
+ if labels is not None:
559
+ shift_logits = logits[:, :-1, :].contiguous()
560
+ shift_labels = labels[:, 1:].contiguous()
561
+ ce = F.cross_entropy(
562
+ shift_logits.view(-1, shift_logits.size(-1)),
563
+ shift_labels.view(-1),
564
+ ignore_index=-100,
565
+ )
566
+ if self.config.z_loss_weight and self.config.z_loss_weight > 0:
567
+ # Use pre-softcap logits for z-loss to retain gradient.
568
+ shift_raw = logits_raw[:, :-1, :].contiguous()
569
+ z_loss = _z_loss(shift_raw, ignore_index=-100, labels=shift_labels)
570
+ loss = ce + self.config.z_loss_weight * z_loss
571
+ else:
572
+ loss = ce
573
+
574
+ if not return_dict:
575
+ output = (logits,) + ((outputs.past_key_values,) if outputs.past_key_values is not None else ())
576
+ return ((loss,) + output) if loss is not None else output
577
+
578
+ return BaberuCausalLMOutput(
579
+ loss=loss,
580
+ logits=logits,
581
+ past_key_values=outputs.past_key_values,
582
+ hidden_states=outputs.hidden_states,
583
+ attentions=None,
584
+ z_loss=z_loss,
585
+ )
586
+
587
+ def prepare_inputs_for_generation(
588
+ self,
589
+ input_ids: torch.LongTensor,
590
+ past_key_values: Optional[Tuple] = None,
591
+ attention_mask: Optional[torch.Tensor] = None,
592
+ inputs_embeds: Optional[torch.FloatTensor] = None,
593
+ **kwargs,
594
+ ) -> dict:
595
+ # Determine how many positions are already in the cache. An empty
596
+ # DynamicCache is not None but has past_len=0, so we must inspect.
597
+ past_len = _cache_length(past_key_values)
598
+ # When KV cache has content, only the last token needs to be fed.
599
+ if past_len > 0:
600
+ input_ids = input_ids[:, -1:]
601
+ model_inputs = {"input_ids": input_ids, "inputs_embeds": None}
602
+ if inputs_embeds is not None and past_len == 0:
603
+ model_inputs = {"input_ids": None, "inputs_embeds": inputs_embeds}
604
+ model_inputs.update(
605
+ {
606
+ "past_key_values": past_key_values,
607
+ "attention_mask": attention_mask,
608
+ "use_cache": kwargs.get("use_cache", True),
609
+ }
610
+ )
611
+ return model_inputs
612
+
613
+
614
+ # ---------------------------------------------------------------------------
615
+ # Vision projector
616
+ # ---------------------------------------------------------------------------
617
+
618
+
619
+ class BaberuVisionProjector(nn.Module):
620
+ """MLP projector: vision_hidden -> hidden_size."""
621
+
622
+ def __init__(self, config: BaberuOCRConfig):
623
+ super().__init__()
624
+ self.linear1 = nn.Linear(config.vision_hidden_size, config.hidden_size, bias=True)
625
+ self.act = nn.GELU()
626
+ self.linear2 = nn.Linear(config.hidden_size, config.hidden_size, bias=True)
627
+
628
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
629
+ return self.linear2(self.act(self.linear1(x)))
630
+
631
+
632
+ # ---------------------------------------------------------------------------
633
+ # Full OCR model: vision encoder + projector + causal LM
634
+ # ---------------------------------------------------------------------------
635
+
636
+
637
+ class BaberuOCRModel(BaberuPreTrainedModel, GenerationMixin):
638
+ """Full OCR model. Reads an image, generates text.
639
+
640
+ Input layout fed to the decoder is:
641
+ [vision tokens (projected DINOv2 outputs) | BOS | text tokens | EOS]
642
+
643
+ The vision tokens are computed once per image (per generate() call) and
644
+ cached in past_key_values along with all decoded text steps.
645
+ """
646
+
647
+ _tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"}
648
+
649
+ def __init__(self, config: BaberuOCRConfig):
650
+ super().__init__(config)
651
+ if config.vision_model_name is None:
652
+ raise ValueError(
653
+ "BaberuOCRModel requires config.vision_model_name. For text-only "
654
+ "pretraining, use BaberuCausalLM instead."
655
+ )
656
+ # Build the vision encoder from its *config* (structure only, no weight
657
+ # download), which is safe under `from_pretrained`'s meta-device init —
658
+ # calling `AutoModel.from_pretrained` here breaks loading via
659
+ # `AutoModel.from_pretrained(..., trust_remote_code=True)`. When this
660
+ # model is built fresh (training, params on a real device) we populate
661
+ # the pretrained DINOv2 weights; when loaded from a checkpoint the params
662
+ # start on meta and the vision weights come from the checkpoint, so we
663
+ # skip the (redundant) download.
664
+ vision_config = AutoConfig.from_pretrained(config.vision_model_name)
665
+ self.vision_encoder = AutoModel.from_config(vision_config)
666
+ if next(self.vision_encoder.parameters()).device.type != "meta":
667
+ pretrained = AutoModel.from_pretrained(config.vision_model_name)
668
+ self.vision_encoder.load_state_dict(pretrained.state_dict())
669
+ del pretrained
670
+ if config.freeze_vision_encoder:
671
+ for p in self.vision_encoder.parameters():
672
+ p.requires_grad = False
673
+ self.vision_encoder.eval()
674
+ self.projector = BaberuVisionProjector(config)
675
+ self.model = BaberuModel(config)
676
+ self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
677
+ if config.tie_word_embeddings:
678
+ self.lm_head.weight = self.model.embed_tokens.weight
679
+ self.post_init()
680
+
681
+ def get_input_embeddings(self) -> nn.Embedding:
682
+ return self.model.embed_tokens
683
+
684
+ def set_input_embeddings(self, value: nn.Embedding) -> None:
685
+ self.model.embed_tokens = value
686
+
687
+ def get_output_embeddings(self) -> nn.Linear:
688
+ return self.lm_head
689
+
690
+ def _encode_image(self, pixel_values: torch.Tensor) -> torch.Tensor:
691
+ """[B, 3, H, W] -> [B, N_vision, hidden_size]."""
692
+ out = self.vision_encoder(pixel_values=pixel_values, return_dict=True)
693
+ # DINOv2 returns last_hidden_state of shape [B, N+1, vision_hidden_size]
694
+ # where the +1 is the CLS token. We drop it for OCR.
695
+ feats = out.last_hidden_state[:, 1:, :]
696
+ return self.projector(feats)
697
+
698
+ def forward(
699
+ self,
700
+ pixel_values: Optional[torch.FloatTensor] = None,
701
+ input_ids: Optional[torch.LongTensor] = None, # text tokens (no vision prefix)
702
+ attention_mask: Optional[torch.Tensor] = None,
703
+ position_ids: Optional[torch.LongTensor] = None,
704
+ past_key_values: Optional[Tuple] = None,
705
+ labels: Optional[torch.LongTensor] = None, # text-only labels; we shift inside
706
+ use_cache: Optional[bool] = None,
707
+ output_hidden_states: Optional[bool] = None,
708
+ return_dict: Optional[bool] = None,
709
+ **kwargs,
710
+ ) -> Union[Tuple, BaberuCausalLMOutput]:
711
+ return_dict = return_dict if return_dict is not None else True
712
+ use_cache = use_cache if use_cache is not None else self.config.use_cache
713
+
714
+ # On the first forward we must prepend the vision prefix; once it is in
715
+ # the KV cache we only feed new text. Branch on cache *length*, not
716
+ # ``is None``: HF ``generate`` passes an empty (but non-None) Cache on
717
+ # the first step, so an ``is None`` check would skip the vision prefix
718
+ # entirely and every image would decode the same unconditional text.
719
+ if _cache_length(past_key_values) == 0:
720
+ if pixel_values is None:
721
+ raise ValueError("pixel_values is required on the first forward (no KV cache).")
722
+ vision_embeds = self._encode_image(pixel_values) # [B, Nv, H]
723
+ text_embeds = (
724
+ self.model.embed_tokens(input_ids) if input_ids is not None else None
725
+ )
726
+ if text_embeds is not None:
727
+ inputs_embeds = torch.cat([vision_embeds, text_embeds], dim=1)
728
+ else:
729
+ inputs_embeds = vision_embeds
730
+ # Build attention mask that covers both.
731
+ if attention_mask is not None and input_ids is not None:
732
+ vision_mask = torch.ones(
733
+ vision_embeds.shape[:2], device=vision_embeds.device, dtype=attention_mask.dtype
734
+ )
735
+ full_mask = torch.cat([vision_mask, attention_mask], dim=1)
736
+ else:
737
+ full_mask = None
738
+ outputs = self.model(
739
+ inputs_embeds=inputs_embeds,
740
+ attention_mask=full_mask,
741
+ past_key_values=None,
742
+ use_cache=use_cache,
743
+ output_hidden_states=output_hidden_states,
744
+ return_dict=True,
745
+ )
746
+ n_vision = vision_embeds.shape[1]
747
+ else:
748
+ # KV cache already holds the 256 vision tokens + prior text; only the
749
+ # new text token goes in. HF's attention_mask counts text tokens only
750
+ # (it is unaware of the vision prefix), so its length won't match
751
+ # kv_len — drop it. There is no padding in single-sequence greedy
752
+ # generation, so the internal causal mask is sufficient; position_ids
753
+ # default to arange(past_len, kv_len), giving the new token its true
754
+ # post-vision position.
755
+ outputs = self.model(
756
+ input_ids=input_ids,
757
+ attention_mask=None,
758
+ position_ids=position_ids,
759
+ past_key_values=past_key_values,
760
+ use_cache=use_cache,
761
+ output_hidden_states=output_hidden_states,
762
+ return_dict=True,
763
+ )
764
+ n_vision = 0
765
+
766
+ hidden_states = outputs.last_hidden_state
767
+ logits_raw = self.lm_head(hidden_states)
768
+ logits = (
769
+ _apply_softcap(logits_raw, self.config.final_logit_softcap)
770
+ if self.config.final_logit_softcap
771
+ else logits_raw
772
+ )
773
+
774
+ loss: Optional[torch.Tensor] = None
775
+ z_loss: Optional[torch.Tensor] = None
776
+ if labels is not None:
777
+ # The logits include vision-prefix positions; slice them out for the loss.
778
+ text_logits = logits[:, n_vision:, :]
779
+ text_logits_raw = logits_raw[:, n_vision:, :]
780
+ shift_logits = text_logits[:, :-1, :].contiguous()
781
+ shift_labels = labels[:, 1:].contiguous()
782
+ ce = F.cross_entropy(
783
+ shift_logits.view(-1, shift_logits.size(-1)),
784
+ shift_labels.view(-1),
785
+ ignore_index=-100,
786
+ )
787
+ if self.config.z_loss_weight and self.config.z_loss_weight > 0:
788
+ shift_raw = text_logits_raw[:, :-1, :].contiguous()
789
+ z_loss = _z_loss(shift_raw, ignore_index=-100, labels=shift_labels)
790
+ loss = ce + self.config.z_loss_weight * z_loss
791
+ else:
792
+ loss = ce
793
+
794
+ if not return_dict:
795
+ output = (logits,) + ((outputs.past_key_values,) if outputs.past_key_values is not None else ())
796
+ return ((loss,) + output) if loss is not None else output
797
+
798
+ return BaberuCausalLMOutput(
799
+ loss=loss,
800
+ logits=logits,
801
+ past_key_values=outputs.past_key_values,
802
+ hidden_states=outputs.hidden_states,
803
+ attentions=None,
804
+ z_loss=z_loss,
805
+ )
806
+
807
+ def prepare_inputs_for_generation(
808
+ self,
809
+ input_ids: torch.LongTensor,
810
+ past_key_values: Optional[Tuple] = None,
811
+ attention_mask: Optional[torch.Tensor] = None,
812
+ pixel_values: Optional[torch.FloatTensor] = None,
813
+ **kwargs,
814
+ ) -> dict:
815
+ past_len = _cache_length(past_key_values)
816
+ if past_len > 0:
817
+ input_ids = input_ids[:, -1:]
818
+ pixel_values = None # already encoded in the cache
819
+ return {
820
+ "input_ids": input_ids,
821
+ "pixel_values": pixel_values,
822
+ "past_key_values": past_key_values,
823
+ "attention_mask": attention_mask,
824
+ "use_cache": kwargs.get("use_cache", True),
825
+ }
onnx/decoder_prefill_int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4f8d28d4e5e8b6c7f3a4510663aa0a9199a7eec06bcb1dc0afb3ca571494e97f
3
+ size 35133596
onnx/decoder_step_int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2eaf792ca70b67e55140ef0bcfbfc3c3b1e07d72ca277dcc46aae27c81f27ac8
3
+ size 33929034
onnx/vision_fp16.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4ce333804846b23f1983376efe363c164d4624ecc6bd16f99a15a859d8423117
3
+ size 172917304
onnx/vision_int4.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9526bcf99eb8de805df7ea2f66b385cfcbcb5dff0e99a5dcfb02bdb2d4182bde
3
+ size 52293486
onnx_infer.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained ONNX inference for Baberu OCR — no PyTorch, no transformers,
2
+ no custom package. Just `onnxruntime`, `numpy`, `pillow`.
3
+
4
+ pip install onnxruntime numpy pillow
5
+ python onnx_infer.py --onnx-dir onnx --tokenizer tokenizer --image crop.jpg
6
+
7
+ Runs the three ONNX graphs (vision -> decoder prefill -> decoder step, KV cache)
8
+ in a pure-numpy host loop with the published decode (greedy + repetition_penalty
9
+ 1.2 + symbol-aware content-run cap 12), so you get the same text as the PyTorch
10
+ model. Pick your size tier by which vision file you point at:
11
+
12
+ vision_fp16.onnx + decoder_*_int8.onnx = 242 MB (lossless, exact published nCER)
13
+ vision_int4.onnx + decoder_*_int8.onnx = 121 MB (smallest; ~+0.002 nCER)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import unicodedata
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import onnxruntime as ort
24
+ from PIL import Image
25
+
26
+ # DINOv2 / ImageNet preprocessing (do_center_crop=False, resize whole crop to 224).
27
+ _MEAN = np.array([0.485, 0.456, 0.406], np.float32)
28
+ _STD = np.array([0.229, 0.224, 0.225], np.float32)
29
+ _PAST = [f"past_k{i}" for i in range(6)] + [f"past_v{i}" for i in range(6)]
30
+
31
+
32
+ def preprocess(image: Image.Image) -> np.ndarray:
33
+ img = image.convert("RGB").resize((224, 224), Image.BICUBIC)
34
+ x = (np.asarray(img, np.float32) / 255.0 - _MEAN) / _STD
35
+ return x.transpose(2, 0, 1)[None] # [1,3,224,224]
36
+
37
+
38
+ class Vocab:
39
+ """Char-level vocab. ids 0..3 = <pad>/<bos>/<eos>/<unk>; id>=4 -> charset[id-4]."""
40
+
41
+ def __init__(self, vocab_json: Path):
42
+ charset = json.loads(Path(vocab_json).read_text(encoding="utf-8"))
43
+ self.id2ch = {0: "", 1: "", 2: "", 3: ""}
44
+ for i, ch in enumerate(charset):
45
+ self.id2ch[i + 4] = ch
46
+ self.bos, self.eos = 1, 2
47
+ self.vocab_size = len(charset) + 4
48
+ self.content_ids = {
49
+ i + 4 for i, ch in enumerate(charset)
50
+ if len(ch) == 1 and ch not in "ーー〜~" and unicodedata.category(ch)[0] in "LN"
51
+ }
52
+
53
+ def decode(self, ids) -> str:
54
+ return "".join(self.id2ch.get(i, "") for i in ids if i >= 4)
55
+
56
+
57
+ class BaberuOnnxOCR:
58
+ def __init__(self, onnx_dir, tokenizer_dir, vision="vision_fp16.onnx"):
59
+ d = Path(onnx_dir)
60
+ so = ort.SessionOptions()
61
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
62
+ mk = lambda p: ort.InferenceSession(str(p), so, providers=["CPUExecutionProvider"])
63
+ self.vis = mk(d / vision)
64
+ self.pre = mk(d / "decoder_prefill_int8.onnx")
65
+ self.stp = mk(d / "decoder_step_int8.onnx")
66
+ self.vocab = Vocab(Path(tokenizer_dir) / "vocab.json")
67
+
68
+ def __call__(self, image, max_new_tokens=128, repetition_penalty=1.2, max_content_run=12):
69
+ v = self.vocab
70
+ vis = self.vis.run(["vision_embeds"], {"pixel_values": preprocess(image)})[0]
71
+ out = self.pre.run(None, {"vision_embeds": vis, "input_ids": np.array([[v.bos]], np.int64)})
72
+ logits, present = out[0][0, -1].astype(np.float64), out[1:]
73
+ seq, toks, pos = [v.bos], [], vis.shape[1] + 1
74
+ for _ in range(max_new_tokens):
75
+ if repetition_penalty != 1.0:
76
+ for tid in set(seq):
77
+ s = logits[tid]
78
+ logits[tid] = s * repetition_penalty if s < 0 else s / repetition_penalty
79
+ if max_content_run and toks and toks[-1] in v.content_ids:
80
+ last = toks[-1]
81
+ run = 0
82
+ for t in reversed(toks):
83
+ if t == last:
84
+ run += 1
85
+ else:
86
+ break
87
+ if run >= max_content_run:
88
+ logits[last] = -np.inf
89
+ nxt = int(np.argmax(logits))
90
+ if nxt == v.eos:
91
+ break
92
+ toks.append(nxt)
93
+ seq.append(nxt)
94
+ if len(toks) >= max_new_tokens:
95
+ break
96
+ feed = {"input_ids": np.array([[nxt]], np.int64), "position_ids": np.array([[pos]], np.int64)}
97
+ feed.update({nm: t for nm, t in zip(_PAST, present)})
98
+ out = self.stp.run(None, feed)
99
+ logits, present = out[0][0, -1].astype(np.float64), out[1:]
100
+ pos += 1
101
+ return v.decode(toks)
102
+
103
+
104
+ def main():
105
+ ap = argparse.ArgumentParser(description="Self-contained ONNX OCR for a manga bubble crop.")
106
+ ap.add_argument("--onnx-dir", default="onnx")
107
+ ap.add_argument("--tokenizer", default="tokenizer")
108
+ ap.add_argument("--vision", default="vision_fp16.onnx",
109
+ help="vision_fp16.onnx (lossless) or vision_int4.onnx (smallest)")
110
+ ap.add_argument("--image", required=True)
111
+ args = ap.parse_args()
112
+ ocr = BaberuOnnxOCR(args.onnx_dir, args.tokenizer, args.vision)
113
+ print(ocr(Image.open(args.image)))
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
quantization-results.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Baberu OCR — ONNX quantization size×accuracy sweep (2026-06-25)
2
+
3
+ Model: custom 115M (DINOv2-ViT-B/14 86M + 6L GQA decoder 26M), ckpt step295000.
4
+ Eval: Manga109 independent GT n=2000, headline decode (greedy + rep-penalty 1.2 +
5
+ content-run cap 12). Baseline torch fp32 = nCER 0.0871 / lCER 0.0345.
6
+
7
+ ## On-disk sizes (ONNX, MB)
8
+
9
+ | component | fp32 | fp16 | int8(dyn) | int4(NBits) |
10
+ |---|---|---|---|---|
11
+ | vision (86M) | 349.2 | 172.9 | 91.7 | 52.3 |
12
+ | decoder prefill | 134.0 | — | 35.1 | 45.3 |
13
+ | decoder step | 132.8 | — | 33.9 | 44.1 |
14
+ | decoder total | 266.8 | — | 69.0 | 89.4 |
15
+
16
+ Notes: decoder ONNX = 2 graphs, each duplicates the tied embedding/lm_head (tie not
17
+ preserved in ONNX) → fp32 267MB. int4 on the decoder is BIGGER than int8 (89 vs 69MB):
18
+ MatMulNBits adds per-block fp scales and leaves embedding (Gather) unquantized → int4
19
+ only helps the vision tower.
20
+
21
+ ## End-to-end accuracy (n=2000)
22
+
23
+ | config | vision | decoder | bundle MB | nCER | lCER | nAcc |
24
+ |---|---|---|---|---|---|---|
25
+ | A torch fp32 (baseline) | — | — | (451 st) | 0.0871 | 0.0345 | 0.6965 |
26
+ | B pipe fp32 (ONNX) | 349.2 | 266.8 | 616.0 | 0.0871 | 0.0345 | 0.6965 |
27
+ | C vis fp16 + dec fp32 (≈current ship) | 172.9 | 266.8 | 439.7 | 0.0871 | 0.0345 | 0.6965 |
28
+ | D vis int8 + dec fp32 | 91.7 | 266.8 | 358.5 | 0.0887 | 0.0364 | 0.6935 |
29
+ | E vis int4 + dec fp32 | 52.3 | 266.8 | 319.1 | 0.0881 | 0.0350 | 0.6940 |
30
+ | F vis fp32 + dec int8 | 349.2 | 69.0 | 418.2 | 0.0867 | 0.0338 | 0.6960 |
31
+ | G vis int8 + dec int8 (full int8) | 91.7 | 69.0 | 160.7 | 0.0890 | 0.0363 | 0.6905 |
32
+ | H vis int4 + dec int8 (smallest) | 52.3 | 69.0 | 121.3 | 0.0893 | 0.0365 | 0.6920 |
33
+ | Z manga-ocr (teacher) | — | — | ~444 | 0.0954 | 0.0422 | 0.6885 |
34
+
35
+ vis fp16 ≡ vis fp32 (byte-identical preds), so vis fp16 + dec int8 = F = 0.0867 @ 241.9 MB.
36
+
37
+ ## Findings
38
+
39
+ 1. **decoder int8 is FREE.** F (dec int8) = nCER 0.0867 ≈ baseline 0.0871 (within noise,
40
+ fractionally better). This OVERTURNS the "keep decoder fp32" rule. That rule came from
41
+ fp16 *logit parity* drift (max|diff| 5-9), but logit-parity ≠ CER: greedy argmax is
42
+ robust to absolute logit noise, and int8 per-channel scales preserve the argmax-relevant
43
+ structure (unlike fp16's limited mantissa on the ±30 soft-capped logits). Decoder 267→69 MB free.
44
+
45
+ 2. **The only cost is vision quantization, and it's tiny.** vision fp16 = lossless (0.0871).
46
+ vision int4 weight-only = +0.0010 (0.0881) AND smaller than int8-dynamic. vision
47
+ int8-DYNAMIC = +0.0016 (0.0887) — worse than int4 because dynamic also quantizes
48
+ activations (adds noise); int4 here is weight-only (activations fp32).
49
+
50
+ 3. **Every quantized config still beats the teacher manga-ocr (0.0954).**
51
+
52
+ ## Recommended deployment tiers
53
+
54
+ - **Zero-regret (best): vis fp16 + dec int8 = 242 MB, nCER 0.0867** — half the current
55
+ 440MB ship at same/better accuracy. Decoder int8 free + vision fp16 lossless.
56
+ - **Balanced full-int8: 161 MB, nCER 0.0890** — 3.8× smaller than fp32-ONNX.
57
+ - **Smallest: vis int4 + dec int8 = 121 MB, nCER 0.0893** — 5.1× smaller, still beats manga-ocr.
58
+
59
+ Artifacts written to /Volumes/junkanki/onnx-export/: vision_{int8,int4}.onnx,
60
+ decoder_{prefill,step}_{int8,int4}.onnx. Scripts: experiments/{quantize_for_size,
61
+ eval_onnx_pipeline}.py + eval_ocr_cer.py --vision-onnx.
tokenization_baberu.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Character-level tokenizer for baberu-ocr v2.
2
+
3
+ Loads a charset file (JSON array of single characters), prepends special
4
+ tokens, and exposes a HuggingFace-compatible PreTrainedTokenizer.
5
+
6
+ Token layout:
7
+ 0: <pad>
8
+ 1: <bos>
9
+ 2: <eos>
10
+ 3: <unk>
11
+ 4..N: characters from charset (e.g., '\\n', ' ', '!', ..., 'あ', '世', ...)
12
+
13
+ Designed for multilingual OCR (JA + ZH + EN). Pure character-level: every
14
+ codepoint maps to one token. Unicode codepoints above U+FFFF are stored as
15
+ single Python str elements and tokenized as one token (verified for chars
16
+ like '𠝹' U+2057D).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from pathlib import Path
24
+ from typing import List, Optional, Tuple
25
+
26
+ from transformers import PreTrainedTokenizer
27
+
28
+ VOCAB_FILES_NAMES = {"vocab_file": "vocab.json"}
29
+
30
+ DEFAULT_SPECIAL_TOKENS = {
31
+ "pad_token": "<pad>",
32
+ "bos_token": "<bos>",
33
+ "eos_token": "<eos>",
34
+ "unk_token": "<unk>",
35
+ }
36
+
37
+
38
+ class BaberuTokenizer(PreTrainedTokenizer):
39
+ """Character-level tokenizer with prepended special tokens.
40
+
41
+ Args:
42
+ vocab_file: Path to a JSON array of characters (e.g., baberu_charset.txt).
43
+ pad_token / bos_token / eos_token / unk_token: Special token strings.
44
+
45
+ Example:
46
+ >>> tok = BaberuTokenizer(vocab_file="data/baberu_charset.txt")
47
+ >>> tok("こんにちは Hello").input_ids
48
+ [1, 1234, 5678, ..., 4, 'H'_id, ...] # includes <bos> ... (no <eos> by default)
49
+ """
50
+
51
+ vocab_files_names = VOCAB_FILES_NAMES
52
+ model_input_names = ["input_ids", "attention_mask"]
53
+
54
+ def __init__(
55
+ self,
56
+ vocab_file: Optional[str] = None,
57
+ pad_token: str = DEFAULT_SPECIAL_TOKENS["pad_token"],
58
+ bos_token: str = DEFAULT_SPECIAL_TOKENS["bos_token"],
59
+ eos_token: str = DEFAULT_SPECIAL_TOKENS["eos_token"],
60
+ unk_token: str = DEFAULT_SPECIAL_TOKENS["unk_token"],
61
+ add_bos_token: bool = True,
62
+ add_eos_token: bool = True,
63
+ **kwargs,
64
+ ):
65
+ if vocab_file is None:
66
+ raise ValueError("vocab_file must be provided")
67
+ if not Path(vocab_file).is_file():
68
+ raise FileNotFoundError(f"vocab_file not found: {vocab_file}")
69
+
70
+ charset = json.loads(Path(vocab_file).read_text(encoding="utf-8"))
71
+ if not isinstance(charset, list):
72
+ raise ValueError("vocab_file must contain a JSON array of strings")
73
+
74
+ # Coerce special tokens to plain strings — from_pretrained may pass
75
+ # AddedToken instances which would break string-keyed dict lookups.
76
+ pad_str = str(pad_token)
77
+ bos_str = str(bos_token)
78
+ eos_str = str(eos_token)
79
+ unk_str = str(unk_token)
80
+
81
+ # Build vocab with special tokens first, then charset.
82
+ self._token_to_id: dict[str, int] = {
83
+ pad_str: 0,
84
+ bos_str: 1,
85
+ eos_str: 2,
86
+ unk_str: 3,
87
+ }
88
+ for ch in charset:
89
+ if ch in self._token_to_id:
90
+ continue
91
+ self._token_to_id[ch] = len(self._token_to_id)
92
+ self._id_to_token: dict[int, str] = {i: t for t, i in self._token_to_id.items()}
93
+
94
+ self.vocab_file = vocab_file
95
+ self.add_bos_token = add_bos_token
96
+ self.add_eos_token = add_eos_token
97
+
98
+ super().__init__(
99
+ pad_token=pad_token,
100
+ bos_token=bos_token,
101
+ eos_token=eos_token,
102
+ unk_token=unk_token,
103
+ add_bos_token=add_bos_token,
104
+ add_eos_token=add_eos_token,
105
+ **kwargs,
106
+ )
107
+
108
+ @property
109
+ def vocab_size(self) -> int:
110
+ return len(self._token_to_id)
111
+
112
+ def get_vocab(self) -> dict[str, int]:
113
+ vocab = dict(self._token_to_id)
114
+ vocab.update(self.added_tokens_encoder)
115
+ return vocab
116
+
117
+ def _tokenize(self, text: str, **kwargs) -> List[str]:
118
+ return list(text)
119
+
120
+ def _convert_token_to_id(self, token: str) -> int:
121
+ token_str = str(token)
122
+ if token_str in self._token_to_id:
123
+ return self._token_to_id[token_str]
124
+ # Fallback to unk; tolerate self.unk_token being an AddedToken.
125
+ return self._token_to_id.get(str(self.unk_token), 3)
126
+
127
+ def _convert_id_to_token(self, index: int) -> str:
128
+ return self._id_to_token.get(index, str(self.unk_token))
129
+
130
+ def convert_tokens_to_string(self, tokens: List[str]) -> str:
131
+ return "".join(tokens)
132
+
133
+ def build_inputs_with_special_tokens(
134
+ self,
135
+ token_ids_0: List[int],
136
+ token_ids_1: Optional[List[int]] = None,
137
+ ) -> List[int]:
138
+ """Wrap a sequence with [BOS] ... [EOS] tokens.
139
+
140
+ For OCR we only ever have a single sequence (token_ids_1 unused).
141
+ """
142
+ bos = [self.bos_token_id] if self.add_bos_token else []
143
+ eos = [self.eos_token_id] if self.add_eos_token else []
144
+ result = bos + token_ids_0 + eos
145
+ if token_ids_1 is not None:
146
+ result = result + token_ids_1 + eos
147
+ return result
148
+
149
+ def get_special_tokens_mask(
150
+ self,
151
+ token_ids_0: List[int],
152
+ token_ids_1: Optional[List[int]] = None,
153
+ already_has_special_tokens: bool = False,
154
+ ) -> List[int]:
155
+ if already_has_special_tokens:
156
+ return super().get_special_tokens_mask(
157
+ token_ids_0=token_ids_0,
158
+ token_ids_1=token_ids_1,
159
+ already_has_special_tokens=True,
160
+ )
161
+ bos = [1] if self.add_bos_token else []
162
+ eos = [1] if self.add_eos_token else []
163
+ mask = bos + [0] * len(token_ids_0) + eos
164
+ if token_ids_1 is not None:
165
+ mask += [0] * len(token_ids_1) + eos
166
+ return mask
167
+
168
+ def save_vocabulary(
169
+ self, save_directory: str, filename_prefix: Optional[str] = None
170
+ ) -> Tuple[str]:
171
+ save_dir = Path(save_directory)
172
+ save_dir.mkdir(parents=True, exist_ok=True)
173
+ out_path = save_dir / (
174
+ (filename_prefix + "-" if filename_prefix else "")
175
+ + self.vocab_files_names["vocab_file"]
176
+ )
177
+ # Save only the charset (special tokens are recreated on load).
178
+ special_ids = {0, 1, 2, 3}
179
+ charset = [
180
+ self._id_to_token[i]
181
+ for i in range(len(self._id_to_token))
182
+ if i not in special_ids
183
+ ]
184
+ out_path.write_text(json.dumps(charset, ensure_ascii=False, indent=2))
185
+ return (str(out_path),)
tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "added_tokens_decoder": {
3
+ "0": {
4
+ "content": "<pad>",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false,
9
+ "special": true
10
+ },
11
+ "1": {
12
+ "content": "<bos>",
13
+ "lstrip": false,
14
+ "normalized": false,
15
+ "rstrip": false,
16
+ "single_word": false,
17
+ "special": true
18
+ },
19
+ "2": {
20
+ "content": "<eos>",
21
+ "lstrip": false,
22
+ "normalized": false,
23
+ "rstrip": false,
24
+ "single_word": false,
25
+ "special": true
26
+ },
27
+ "3": {
28
+ "content": "<unk>",
29
+ "lstrip": false,
30
+ "normalized": false,
31
+ "rstrip": false,
32
+ "single_word": false,
33
+ "special": true
34
+ }
35
+ },
36
+ "backend": "custom",
37
+ "bos_token": "<bos>",
38
+ "eos_token": "<eos>",
39
+ "is_local": true,
40
+ "model_max_length": 1000000000000000019884624838656,
41
+ "pad_token": "<pad>",
42
+ "tokenizer_class": "BaberuTokenizer",
43
+ "unk_token": "<unk>"
44
+ }
tokenizer/vocab.json ADDED
The diff for this file is too large to render. See raw diff