PP-OCRv6 Hanzi Unicode OCR
Single-character OCR for CJK glyph images, built on PaddleOCR PP-OCRv6: one cropped image of one glyph in, one Unicode ideograph out.
- Scope: clean, single-colour, dark-on-light renderings of one character from a computer font — the rare-character images embedded in dictionary data. Handwriting, scans, rubbings and photographs are outside the validated envelope.
- Accuracy: 97–99% on that input, across modern regular-script computer fonts (宋体 / 黑体 / 仿宋 / 楷体). Decorative and calligraphic faces are excluded.
- Coverage: all 101,996 CJK unified ideographs of Unicode 17.0 are classes — Extensions A–J plus the URO block, no gaps.
单字字图识别:输入一张只含一个字的图片,输出该字的 Unicode 码位。面向电脑字体渲染的 黑字白底单字图(最初为 OCR 字典中的生僻字插图而做),完整覆盖 Unicode 17.0 全部 101,996 个中日韩统一表意文字。在上述输入条件下(现代电脑正文字体:宋 / 黑 / 仿宋 / 楷) 准确率 **97–99%**,花体字、书法字不在其列。与 IDS 模型相互独立,可交叉核验。
| Model | Output |
|---|---|
| pp-ocrv6-hanzi-unicode-ocr (this repo) | the character, e.g. 明 |
| pp-ocrv6-hanzi-ids-ocr | its structure, e.g. ⿰日月 |
The two are independent. Run both and compare: disagreements get surfaced for review instead of being silently accepted.
Files
| File | Size | SHA-256 |
|---|---|---|
model.onnx |
140,836,271 B | 78367b92ea954129eac2153aa6f0ace6a32b087b45456d322658135c057f0aca |
metadata.json |
788,577 B | 684d5c955ac556a8f32737215e4f9d0e12dee6f5a8d8d28c721b1e00c620a61d |
example_usage.py |
— | — |
metadata.json holds the class dictionary (vocab), input shape and preprocessing spec.
Model details
| Base | PaddleOCR PP-OCRv6 PP-OCRv6_medium_gaiji_fonts_v2 (medium recognition), fine-tuned on glyph fonts, exported to ONNX |
| Head | CTC, 101,997 classes (1 blank + 101,996 ideographs) |
| Input | x — (N, 3, 48, W) float32, dynamic batch and width |
| Output | fetch_name_0 — (N, T, 101997) float32, already softmax-normalised |
| Time steps | T = ceil(W / 8); at the released W = 320, (N, 40, 101997) |
| Runtime | onnxruntime (CPU, CUDA, DirectML, CoreML) |
The width axis is dynamic in the graph, but the released configuration always feeds
W = 320 zero-padded on the right, which is how the model was trained.
Coverage
vocab lists all 101,997 classes in order: index 0 is the CTC blank, then characters by
ascending code point. Compared code point by code point against the official
UnicodeData.txt of Unicode 17.0:
| Block | Range | Classes | Missing |
|---|---|---|---|
| Ext A | U+3400–U+4DBF |
6,592 | 0 |
| URO | U+4E00–U+9FFF |
20,992 | 0 |
| Ext B | U+20000–U+2A6DF |
42,720 | 0 |
| Ext C | U+2A700–U+2B73F |
4,160 | 0 |
| Ext D | U+2B740–U+2B81F |
222 | 0 |
| Ext E | U+2B820–U+2CEAF |
5,774 | 0 |
| Ext F | U+2CEB0–U+2EBEF |
7,473 | 0 |
| Ext I | U+2EBF0–U+2EE5F |
622 | 0 |
| Ext G | U+30000–U+3134F |
4,939 | 0 |
| Ext H | U+31350–U+323AF |
4,192 | 0 |
| Ext J | U+323B0–U+3347F |
4,298 | 0 |
| subtotal | 101,984 | 0 | |
| CJK Compatibility Ideographs | non-normalising only | 12 | 0 |
| 101,996 | 0 |
Counts are the officially assigned code points; unassigned slots (U+3347A–U+3347F in
Ext J, 2 in Ext I) are correctly absent. The 12 compatibility ideographs shipped are
exactly those Unicode normalisation maps to themselves (U+FA0E 﨎 U+FA0F 﨏 U+FA11 﨑 U+FA13 﨓 U+FA14 﨔 U+FA1F 﨟 U+FA21 﨡 U+FA23 﨣 U+FA24 﨤 U+FA27 﨧 U+FA28 﨨 U+FA29 﨩); the other 990 normalise to an existing unified ideograph, so the model reports
that unified code point rather than duplicating it. No class collapses to another under
NFC/NFKC, and nothing outside the CJK ideograph repertoire is in the dictionary — a glyph
outside it cannot be produced.
Usage
pip install onnxruntime pillow numpy
python example_usage.py glyph.png
import json
import math
import numpy as np
import onnxruntime as ort
from PIL import Image, ImageDraw, ImageFont
meta = json.load(open("metadata.json", encoding="utf-8"))
vocab = meta["vocab"]
C, H, W = meta["image_shape"] # [3, 48, 320]
session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
input_name = session.get_inputs()[0].name
def prepare(im):
im = im.convert("RGB")
iw = min(math.ceil(H * im.width / im.height), W)
bgr = np.asarray(im)[:, :, ::-1] # RGB -> BGR
bgr = np.asarray(Image.fromarray(bgr).resize((iw, H), Image.BILINEAR))
plane = bgr.astype(np.float32).transpose(2, 0, 1) / 127.5 - 1
canvas = np.zeros((C, H, W), np.float32)
canvas[:, :, :iw] = plane
return canvas[None] # (1, 3, 48, 320)
def recognise(im):
probs = session.run(None, {input_name: prepare(im)})[0][0] # (T, 101997)
index, scores = probs.argmax(-1), probs.max(-1)
text, kept, last = [], [], None
for i, s in zip(index, scores):
if i != last and i != 0: # collapse repeats, drop blank
text.append(vocab[i])
kept.append(float(s))
last = i
return "".join(text), (sum(kept) / len(kept) if kept else 0.0)
# Round-trip a character through a font, then recognise it back.
char = "明"
font = ImageFont.truetype("C:/Windows/Fonts/simsun.ttc", 200)
box = font.getbbox(char)
img = Image.new("L", (box[2] - box[0] + 20, box[3] - box[1] + 20), 255)
ImageDraw.Draw(img).text((10 - box[0], 10 - box[1]), char, font=font, fill=0)
print(recognise(img)) # ('明', 0.9954...)
Preprocessing must be exact — the model is sensitive to channel order and normalisation:
RGB source read as BGR, aspect-preserving resize to height 48, scale to [-1, 1],
left-aligned in a (3, 48, 320) zero array with the padding on the right. Decoding is
greedy CTC: argmax, collapse repeats, drop the blank index 0. The output is already a
per-step distribution, so no extra softmax. The score is the mean of the retained step
probabilities — a confidence hint, not a calibrated accuracy.
Limitations
- One character per image. No detection, no segmentation.
- High confidence is not proof. It is a closed-set 101,996-way classifier, so a malformed or out-of-repertoire glyph is still forced onto the nearest known character, sometimes with a high score. Cross-check with the IDS model when correctness matters.
- Coverage stops at Unicode 17.0. Ideographs assigned later will be missing until the model is retrained.
Provenance
- Upstream licence: PaddleOCR is Apache-2.0 — see THIRD_PARTY_NOTICES.md.
- The conversion keeps the original 101,997-class CTC dictionary unchanged. On a sample compared against the source Paddle model, the CPU ONNX outputs are character-identical, with a maximum per-frame probability difference of 8.43×10⁻⁵.