File size: 10,875 Bytes
96b6ece
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0583b32
 
 
 
96b6ece
 
 
0583b32
 
96b6ece
0583b32
 
 
96b6ece
 
0583b32
 
96b6ece
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0583b32
 
 
 
 
96b6ece
 
 
0583b32
 
96b6ece
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0583b32
 
96b6ece
131c599
 
96b6ece
0583b32
 
 
 
 
96b6ece
 
 
 
 
 
 
 
0583b32
 
 
 
 
 
 
 
 
96b6ece
 
 
 
 
 
131c599
 
 
 
96b6ece
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
---
license: apache-2.0
tags:
- executorch
- xnnpack
- pte
- on-device
- image-to-text
base_model:
- kha-white/manga-ocr-base
---
# manga-ocr β€” ExecuTorch (Japanese text out of a picture of Japanese text)

Hand one line of Japanese to this and get the characters back. Vertical or horizontal,
furigana and all, printed or laid over artwork. It **generates** the characters rather
than classifying them: a ViT-base encoder reads the crop once, and a two-layer BERT
decoder emits one character at a time until it stops.

The shelf's other OCR is docTR, which reads Latin script in two stages β€” find the boxes,
then read each crop. This one is the second stage for Japanese, and it is the only shelf
model that reads vertical text.

```
manga_ocr_encoder  pixel_values (1, 3, 224, 224) fp32
    -> encoder_hidden (1, 197, 768) fp32
manga_ocr_decoder  input_ids (1, L) int64, encoder_hidden (1, 197, 768) fp32
    -> logits (1, 6144) fp32      # the next character, at the last position only
```

- **Source**: [kha-white/manga-ocr-base](https://huggingface.co/kha-white/manga-ocr-base),
  trained on Manga109s β€” 111.0M parameters, 6,144-character vocabulary
- **License**: Apache-2.0
- **Reference implementation**: [kha-white/manga_ocr](https://github.com/kha-white/manga_ocr)

## Files

Pick one row. The two halves must match: the encoder's hidden state is what the decoder
was measured against.

| build | encoder | decoder | encoder speed | decoder speed |
|---|---|---|---|---|
| **Core ML + XNNPACK int8** | `manga_ocr_encoder_coreml_all.pte` **172.1 MB** | `manga_ocr_decoder_xnnpack_int8.pte` **45.0 MB** | **4.2 ms** | 2.5 ms |
| XNNPACK int8 | `manga_ocr_encoder_xnnpack_int8.pte` 88.9 MB | `manga_ocr_decoder_xnnpack_int8.pte` 45.0 MB | 25.5 ms | 2.5 ms |
| XNNPACK fp32 | `manga_ocr_encoder_xnnpack_fp32.pte` 343.3 MB | `manga_ocr_decoder_xnnpack_fp32.pte` 117.4 MB | 29.5 ms | 3.2 ms |
| XNNPACK fp16 | `manga_ocr_encoder_xnnpack_fp16.pte` 173.1 MB | `manga_ocr_decoder_xnnpack_fp16.pte` 58.8 MB | 71.2 ms | 3.1 ms |

Host numbers, on a loaded machine, decoder timed at a 3-character prefix β€” read them as a
lower bound and as ratios rather than as device latency. Eager fp32 on the same host is
29.9 ms for the encoder and 4.6 ms for the decoder. XNNPACK takes 82.9% of the encoder
and 51.9% of the decoder; Core ML takes all of the encoder.

**fp16 is the row nothing should pick.** Its encoder is the size of the Core ML build and
less than half the speed of fp32, because XNNPACK inserts a cast at every boundary it
cannot fuse. It is listed because it was built and measured, not because it wins anything.

**int8 is the row to pick on Android**: half of fp16 on the encoder and three-quarters of
it on the decoder, **faster than eager PyTorch on both**, and no character different from
eager anywhere it was measured. What that costs is margin, not characters β€” see below.

## Running it

**1. Preprocess β€” the checkpoint's recipe, not PIL's defaults.**

```python
image = image.convert("L").convert("RGB").resize((224, 224), processor.resample)
mean = np.array(processor.image_mean, dtype=np.float32)   # 0.5, 0.5, 0.5
std = np.array(processor.image_std, dtype=np.float32)     # 0.5, 0.5, 0.5
x = (np.asarray(image, dtype=np.float32) / 255.0 - mean) / std
pixel_values = torch.from_numpy(np.ascontiguousarray(x.transpose(2, 0, 1)))[None]
```

Three details that are each load-bearing. **Greyscale and back to RGB** β€” the reference
pipeline does this, so a colour page and a scan of the same panel give the same tensor.
**`resample` from the config, which is bilinear** β€” `Image.resize` defaults to bicubic,
and a filter the model was not trained under changes what it reads. **The aspect ratio is
squashed to a square on purpose**: 224x224 regardless of whether the line is a wide
horizontal strip or a tall vertical one, which is what the model saw in training.

**2. The encoder, once per crop. The decoder, once per character.**

```python
hidden = encoder.execute([pixel_values])[0]
ids = torch.tensor([[2]])                  # decoder_start_token_id
for _ in range(300):
    token = int(decoder.execute([ids, hidden])[0][0].argmax())
    if token == 3:                         # eos_token_id
        break
    ids = torch.cat([ids, torch.tensor([[token]])], dim=1)
```

**3. Decode with `vocab.txt` β€” there is no tokenizer to install.** The checkpoint names
`BertJapaneseTokenizer`, whose MeCab step exists only to split *text* into words, and this
model never reads text. `subword_tokenizer_type` is `character` and the vocabulary holds
no `##` continuations, so a character is a line of `vocab.txt` and decoding is a lookup
and a join:

```python
vocab = open("vocab.txt", encoding="utf-8").read().splitlines()
text = "".join(vocab[i] for i in ids[0, 1:].tolist()
               if vocab[i] not in {"[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"})
```

Worth knowing because transformers 5.x cannot instantiate that tokenizer class at all β€”
it has dropped the slow tokenizers. Reading the vocabulary directly is not a workaround,
it is what an app on the device does anyway.

## Why there is no KV cache

The decoder is **two** layers over a 6,144-entry vocabulary. Re-running the whole prefix
each step is cheap enough that a cache would buy less than it costs: it would turn one
method with a dynamic length into a cache protocol the caller has to hold, allocate and
reset, and it would pin a maximum length into the file.

The decoder also **returns only the last row of logits**. The other L-1 rows are the model
re-deriving characters the caller already has; dropping them keeps 6,144 floats crossing
the boundary per step instead of L times that.

## Greedy, not beam search

The checkpoint's own generation config asks for 4 beams. Greedy is what the graph is
shaped for and what every number here was measured with; on the horizontal gate image
greedy and beam-4 read the same string, and the 84-line set never needed more than greedy
to match eager. Beam search remains possible from outside β€” the decoder is a pure function
of `(prefix, hidden)`, so a caller can run it once per beam per step β€” but what it buys on
harder input than this has not been measured.

## Core ML: the encoder yes, the decoder no

**The encoder is the whole speed story**: 100% delegated, 4.2 ms against 29.9 ms for eager
on the same host, at 172.1 MB. Worst-output correlation against fp32 eager is 0.999984.

**The decoder is not shipped for Core ML, and the reason is specific.** It converts, it
delegates 100%, and β€” once eight zero-element constants that transformers' BERT attention
leaves behind are pruned β€” it loads. What it will not do is run at a length other than the
one it was exported at: `execute() failed with error 0x32`. A decoder whose prefix grows by
one character per step is exactly the case that needs the dynamic axis, so the decoder
stays on XNNPACK, where it runs at every length from 1 to 300. This is not "Core ML rejects
dynamic shapes" β€” it accepts the export and pins the shape.

## What was measured

The gate is **the string that came out**, not correlation. The encoder's output is a hidden
state nobody reads, and the decoder's output is one row of logits whose argmax is all that
survives β€” a correlation of 0.999 and a different character are the same file.

**84 generated lines**: 12 sentences x 4 typefaces (Hiragino gothic W3 and W6, rounded
gothic, mincho) x both orientations x 7 degradations, from clean through gaussian blur,
sensor noise, low contrast, heavy smear, heavy grain, and a near-contrastless JPEG at
quality 12.

The set is generated deterministically, so the numbers below reproduce run to run.

| build | lines identical to eager | mean CER vs eager |
|---|---|---|
| XNNPACK fp32 | 84 / 84 | 0.0000 |
| XNNPACK fp16 | 84 / 84 | 0.0000 |
| XNNPACK int8 | 84 / 84 | 0.0000 |

Eager itself gets 82 of 84 exactly right (CER 0.0022 against the rendered truth), so the
model is not perfect on this set and every build reproduces it character for character
anyway β€” its two mistakes included.

### How close it came

84 lines agreeing is 84 samples. What generalises is how much room each decision had. A
character changes only if a build moves the winning logit past its best rival, so this is
measured at **every one of the 1,008 decoding steps**: the winner's lead in the build's
own logits, as a fraction of the lead the fp32 reference left it. 1.0 is untouched, 0.0
is a tie, below 0.0 is a changed character.

| build | median | worst step | steps changed |
|---|---|---|---|
| XNNPACK fp32 | 1.0000 | 1.0000 | 0 / 1008 |
| XNNPACK fp16 | 0.9999 | 0.9820 | 0 / 1008 |
| XNNPACK int8 | 0.9999 | **0.6513** | 0 / 1008 |

**int8's worst moment still kept 65% of the margin**, with the median step untouched at
0.9999. That is the number behind the recommendation, not the 84 identical strings.

The same steps also carry a worst-case bound β€” the reference gap over twice the largest
error anywhere in the 6,144 logits, which assumes that error landed exactly where it would
do the most damage. Its minimum is **1.16 for int8**, above 1.0, so on this set a changed
character is not merely absent but arithmetically impossible. Both numbers are printed by
`convert/check_manga_ocr.py`.

### What a whole line costs

The decoder re-reads the prefix each step, so its cost grows with the characters already
emitted. Measured per call, median of 20:

| prefix | fp32 | fp16 | int8 |
|---|---|---|---|
| 1 | 2.36 ms | 2.27 ms | 2.18 ms |
| 8 | 3.37 ms | 3.45 ms | 2.90 ms |
| 32 | 3.94 ms | 4.88 ms | 4.26 ms |
| 64 | 5.31 ms | 6.94 ms | 4.99 ms |

**Sixty-four times the prefix costs 2.3 times the step**, which is what makes the missing
cache a fair trade. Summed over the prefixes a real line walks through, the decoder side of
a **12-character line is 33 ms on int8** (39 ms fp32); add one encoder run β€” 4.2 ms on
Core ML, 25.5 ms on XNNPACK int8 β€” for the whole read.

### What this does not say

- **These are rendered lines, not photographed manga.** The set varies what a renderer can
  vary; it has no screentone, no handwriting, no speech-bubble clutter, no page curl. Read
  it as how far a build has drifted from eager, not as an accuracy score for the model.
- **The degradations never broke eager.** Its two mistakes are both on the clean and
  lightly-noised rows; heavy smear, heavy grain and the quality-12 JPEG produced none. So
  the set never reached a regime where the model itself starts failing, and cannot say
  what the builds do there.
- **Nothing here ran on a phone.** Every number is host CPU, and the machine was busy.

## Source

Converted with [executorch-convert](https://github.com/john-rocky) β€” `convert/export_manga_ocr.py`
for the two files, `convert/check_manga_ocr.py` for everything measured above.
ExecuTorch 1.4.0, PyTorch 2.13.0.