mlboydaisuke commited on
Commit
96b6ece
Β·
verified Β·
1 Parent(s): 1f0b0cc

Upload README.md with huggingface_hub

Browse files
Files changed (1) hide show
  1. README.md +216 -0
README.md ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - executorch
5
+ - xnnpack
6
+ - pte
7
+ - on-device
8
+ - image-to-text
9
+ base_model:
10
+ - kha-white/manga-ocr-base
11
+ ---
12
+ # manga-ocr β€” ExecuTorch (Japanese text out of a picture of Japanese text)
13
+
14
+ Hand one line of Japanese to this and get the characters back. Vertical or horizontal,
15
+ furigana and all, printed or laid over artwork. It **generates** the characters rather
16
+ than classifying them: a ViT-base encoder reads the crop once, and a two-layer BERT
17
+ decoder emits one character at a time until it stops.
18
+
19
+ The shelf's other OCR is docTR, which reads Latin script in two stages β€” find the boxes,
20
+ then read each crop. This one is the second stage for Japanese, and it is the only shelf
21
+ model that reads vertical text.
22
+
23
+ ```
24
+ manga_ocr_encoder pixel_values (1, 3, 224, 224) fp32
25
+ -> encoder_hidden (1, 197, 768) fp32
26
+ manga_ocr_decoder input_ids (1, L) int64, encoder_hidden (1, 197, 768) fp32
27
+ -> logits (1, 6144) fp32 # the next character, at the last position only
28
+ ```
29
+
30
+ - **Source**: [kha-white/manga-ocr-base](https://huggingface.co/kha-white/manga-ocr-base),
31
+ trained on Manga109s β€” 111.0M parameters, 6,144-character vocabulary
32
+ - **License**: Apache-2.0
33
+ - **Reference implementation**: [kha-white/manga_ocr](https://github.com/kha-white/manga_ocr)
34
+
35
+ ## Files
36
+
37
+ Pick one row. The two halves must match: the encoder's hidden state is what the decoder
38
+ was measured against.
39
+
40
+ | build | encoder | decoder | encoder speed | decoder speed |
41
+ |---|---|---|---|---|
42
+ | **Core ML + XNNPACK int8** | `manga_ocr_encoder_coreml_all.pte` **172.1 MB** | `manga_ocr_decoder_xnnpack_int8.pte` **45.0 MB** | **4.1 ms** | 2.6 ms |
43
+ | XNNPACK int8 | `manga_ocr_encoder_xnnpack_int8.pte` 88.9 MB | `manga_ocr_decoder_xnnpack_int8.pte` 45.0 MB | 33.6 ms | 2.6 ms |
44
+ | XNNPACK fp32 | `manga_ocr_encoder_xnnpack_fp32.pte` 343.4 MB | `manga_ocr_decoder_xnnpack_fp32.pte` 117.4 MB | 36.0 ms | 3.0 ms |
45
+ | XNNPACK fp16 | `manga_ocr_encoder_xnnpack_fp16.pte` 173.1 MB | `manga_ocr_decoder_xnnpack_fp16.pte` 58.8 MB | 69.5 ms | 4.6 ms |
46
+
47
+ Host numbers, on a loaded machine, decoder timed at a 3-character prefix β€” read them as a
48
+ lower bound and as ratios rather than as device latency. Eager fp32 on the same host is
49
+ 29.2 ms for the encoder and 4.4 ms for the decoder.
50
+
51
+ **fp16 is the row nothing should pick.** It is the same size as the Core ML encoder and
52
+ half the speed of fp32 on the encoder, because XNNPACK inserts a cast at every boundary
53
+ it cannot fuse. It is listed because it was built and measured, not because it wins
54
+ anything.
55
+
56
+ **int8 is the row to pick on Android**: half of fp16 on the encoder and three-quarters of
57
+ it on the decoder, faster than fp32 on both, and no character different from eager
58
+ anywhere it was measured. What that costs is margin, not characters β€” see below.
59
+
60
+ ## Running it
61
+
62
+ **1. Preprocess β€” the checkpoint's recipe, not PIL's defaults.**
63
+
64
+ ```python
65
+ image = image.convert("L").convert("RGB").resize((224, 224), processor.resample)
66
+ mean = np.array(processor.image_mean, dtype=np.float32) # 0.5, 0.5, 0.5
67
+ std = np.array(processor.image_std, dtype=np.float32) # 0.5, 0.5, 0.5
68
+ x = (np.asarray(image, dtype=np.float32) / 255.0 - mean) / std
69
+ pixel_values = torch.from_numpy(np.ascontiguousarray(x.transpose(2, 0, 1)))[None]
70
+ ```
71
+
72
+ Three details that are each load-bearing. **Greyscale and back to RGB** β€” the reference
73
+ pipeline does this, so a colour page and a scan of the same panel give the same tensor.
74
+ **`resample` from the config, which is bilinear** β€” `Image.resize` defaults to bicubic,
75
+ and a filter the model was not trained under changes what it reads. **The aspect ratio is
76
+ squashed to a square on purpose**: 224x224 regardless of whether the line is a wide
77
+ horizontal strip or a tall vertical one, which is what the model saw in training.
78
+
79
+ **2. The encoder, once per crop. The decoder, once per character.**
80
+
81
+ ```python
82
+ hidden = encoder.execute([pixel_values])[0]
83
+ ids = torch.tensor([[2]]) # decoder_start_token_id
84
+ for _ in range(300):
85
+ token = int(decoder.execute([ids, hidden])[0][0].argmax())
86
+ if token == 3: # eos_token_id
87
+ break
88
+ ids = torch.cat([ids, torch.tensor([[token]])], dim=1)
89
+ ```
90
+
91
+ **3. Decode with `vocab.txt` β€” there is no tokenizer to install.** The checkpoint names
92
+ `BertJapaneseTokenizer`, whose MeCab step exists only to split *text* into words, and this
93
+ model never reads text. `subword_tokenizer_type` is `character` and the vocabulary holds
94
+ no `##` continuations, so a character is a line of `vocab.txt` and decoding is a lookup
95
+ and a join:
96
+
97
+ ```python
98
+ vocab = open("vocab.txt", encoding="utf-8").read().splitlines()
99
+ text = "".join(vocab[i] for i in ids[0, 1:].tolist()
100
+ if vocab[i] not in {"[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"})
101
+ ```
102
+
103
+ Worth knowing because transformers 5.x cannot instantiate that tokenizer class at all β€”
104
+ it has dropped the slow tokenizers. Reading the vocabulary directly is not a workaround,
105
+ it is what an app on the device does anyway.
106
+
107
+ ## Why there is no KV cache
108
+
109
+ The decoder is **two** layers over a 6,144-entry vocabulary. Re-running the whole prefix
110
+ each step is cheap enough that a cache would buy less than it costs: it would turn one
111
+ method with a dynamic length into a cache protocol the caller has to hold, allocate and
112
+ reset, and it would pin a maximum length into the file.
113
+
114
+ The decoder also **returns only the last row of logits**. The other L-1 rows are the model
115
+ re-deriving characters the caller already has; dropping them keeps 6,144 floats crossing
116
+ the boundary per step instead of L times that.
117
+
118
+ ## Greedy, not beam search
119
+
120
+ The checkpoint's own generation config asks for 4 beams. Greedy is what the graph is
121
+ shaped for, and it is what these files were measured with. On the shelf's gate images
122
+ greedy and beam-4 read the same string. Beam search remains possible from outside β€” the
123
+ decoder is a pure function of `(prefix, hidden)`, so a caller can run it once per beam per
124
+ step β€” but nothing here has measured what that buys.
125
+
126
+ ## Core ML: the encoder yes, the decoder no
127
+
128
+ **The encoder is the whole speed story**: 100% delegated, 4.1 ms against 29.2 ms for eager
129
+ on the same host, at 172.1 MB. Worst-output correlation against fp32 eager is 0.999985.
130
+
131
+ **The decoder is not shipped for Core ML, and the reason is specific.** It converts, it
132
+ delegates 100%, and β€” once eight zero-element constants that transformers' BERT attention
133
+ leaves behind are pruned β€” it loads. What it will not do is run at a length other than the
134
+ one it was exported at: `execute() failed with error 0x32`. A decoder whose prefix grows by
135
+ one character per step is exactly the case that needs the dynamic axis, so the decoder
136
+ stays on XNNPACK, where it runs at every length from 1 to 300. This is not "Core ML rejects
137
+ dynamic shapes" β€” it accepts the export and pins the shape.
138
+
139
+ ## What was measured
140
+
141
+ The gate is **the string that came out**, not correlation. The encoder's output is a hidden
142
+ state nobody reads, and the decoder's output is one row of logits whose argmax is all that
143
+ survives β€” a correlation of 0.999 and a different character are the same file.
144
+
145
+ **84 generated lines**: 12 sentences x 4 typefaces (Hiragino gothic W3 and W6, rounded
146
+ gothic, mincho) x both orientations x 7 degradations, from clean through gaussian blur,
147
+ sensor noise, low contrast, heavy smear, heavy grain, and a near-contrastless JPEG at
148
+ quality 12.
149
+
150
+ The set is generated deterministically, so the numbers below reproduce run to run.
151
+
152
+ | build | lines identical to eager | mean CER vs eager |
153
+ |---|---|---|
154
+ | XNNPACK fp32 | 84 / 84 | 0.0000 |
155
+ | XNNPACK fp16 | 84 / 84 | 0.0000 |
156
+ | XNNPACK int8 | 84 / 84 | 0.0000 |
157
+
158
+ Eager itself gets 82 of 84 exactly right (CER 0.0022 against the rendered truth), so the
159
+ model is not perfect on this set and every build reproduces it character for character
160
+ anyway β€” its two mistakes included.
161
+
162
+ ### How close it came
163
+
164
+ 84 lines agreeing is 84 samples. What generalises is how much room each decision had. A
165
+ character changes only if a build moves the winning logit past its best rival, so this is
166
+ measured at **every one of the 1,008 decoding steps**: the winner's lead in the build's
167
+ own logits, as a fraction of the lead the fp32 reference left it. 1.0 is untouched, 0.0
168
+ is a tie, below 0.0 is a changed character.
169
+
170
+ | build | median | worst step | steps changed |
171
+ |---|---|---|---|
172
+ | XNNPACK fp32 | 1.0000 | 1.0000 | 0 / 1008 |
173
+ | XNNPACK fp16 | 1.0000 | 0.9884 | 0 / 1008 |
174
+ | XNNPACK int8 | 1.0000 | **0.8661** | 0 / 1008 |
175
+
176
+ **int8's worst moment still kept 87% of the margin.** That is the number behind the
177
+ recommendation, not the 84 identical strings.
178
+
179
+ A worst-case bound over the same steps β€” the reference gap divided by twice the largest
180
+ error anywhere in the 6,144 logits β€” puts int8 at 0.87, below 1.0. That bound assumes the
181
+ build's largest error landed exactly on the two logits that matter, which is not what
182
+ happened; it says a flip cannot be ruled out from that statistic alone, not that one
183
+ nearly occurred. Both are printed by `convert/check_manga_ocr.py`.
184
+
185
+ ### What a whole line costs
186
+
187
+ The decoder re-reads the prefix each step, so its cost grows with the characters already
188
+ emitted. Measured per call, median of 20:
189
+
190
+ | prefix | fp32 | fp16 | int8 |
191
+ |---|---|---|---|
192
+ | 1 | 2.39 ms | 3.66 ms | 2.17 ms |
193
+ | 8 | 3.49 ms | 5.07 ms | 3.11 ms |
194
+ | 32 | 4.91 ms | 7.70 ms | 4.98 ms |
195
+ | 64 | 6.43 ms | 10.95 ms | 5.98 ms |
196
+
197
+ **Sixty-four times the prefix costs 2.8 times the step**, which is what makes the missing
198
+ cache a fair trade. Summed over the prefixes a real line walks through, the decoder side
199
+ of a **12-character line is 35 ms on int8** (40 ms fp32, 59 ms fp16); add one encoder run β€”
200
+ 4.1 ms on Core ML, 33.6 ms on XNNPACK int8 β€” for the whole read.
201
+
202
+ ### What this does not say
203
+
204
+ - **These are rendered lines, not photographed manga.** The set varies what a renderer can
205
+ vary; it has no screentone, no handwriting, no speech-bubble clutter, no page curl. Read
206
+ it as how far a build has drifted from eager, not as an accuracy score for the model.
207
+ - **The set never broke eager.** Even the heaviest degradations left eager reading
208
+ perfectly, so the set cannot say what the builds do in a regime where the model itself
209
+ starts failing.
210
+ - **Nothing here ran on a phone.** Every number is host CPU, and the machine was busy.
211
+
212
+ ## Source
213
+
214
+ Converted with [executorch-convert](https://github.com/john-rocky) β€” `convert/export_manga_ocr.py`
215
+ for the two files, `convert/check_manga_ocr.py` for everything measured above.
216
+ ExecuTorch 1.4.0, PyTorch 2.13.0.