deepghs/AnimeText
Updated β’ 892 β’ 7
PP-OCRv6_manga is a pipeline for manga and anime text detection and recognition.
The models were trained in multiple stages, including domain fine-tuning, weight soups, and refinement. The training dataset contains approximately 10,000 pages and 100,000 text crops. All models were trained on an NVIDIA RTX 4070.
| Component | Architecture | Paddle | ONNX FP32 | ONNX FP16 |
|---|---|---|---|---|
Det manga_det_v0.1 |
PP-OCRv6 tiny (PPLCNetV4 + RepLKFPN + DBHead) |
~2.1 MB | 1.73 MB | 0.92 MB |
Rec manga_rec_v0.1 |
PP-OCRv6 small (PPLCNetV4 + lightSVTR + MultiHead CTC/NRTR) |
~20.3 MB | 20.19 MB | 10.14 MB |
ppocrv6_dict.txt (18,708 symbols).Furigana is excluded via geometric filtering.
| Model | Size | Manga Recall | Webtoons Recall | Illus Recall | Total Recall | Total F1 | Total FP |
|---|---|---|---|---|---|---|---|
manga_det_v0.1 |
2.05 MB | 94.65% | 93.44% | 88.50% | 93.62% | 92.17% | 559 |
base_medium |
64.3 MB | 93.19% | 92.90% | 89.94% | 92.69% | 91.35% | 601 |
base_small |
10.3 MB | 91.88% | 91.26% | 85.75% | 90.93% | 90.67% | 566 |
base_tiny |
2.05 MB | 91.53% | 91.12% | 88.26% | 91.01% | 86.20% | 1,183 |
Detection benchmark post-processing: thresh=0.15, box_thresh=0.25, unclip_ratio=1.4.
manga_det_v0.1
Japanese and Chinese only.
| Model | Size | Manga CER | Webtoons CER | Illus CER | Total CER | Total Exact |
|---|---|---|---|---|---|---|
manga_rec_v0.1 |
20.3 MB | 7.94% | 5.97% | 19.21% | 10.09% | 75.24% |
hayai-ocr-v2.1 |
594 MB | 8.47% | 19.05% | 20.05% | 11.45% | 75.06% |
base_medium_rec |
73.3 MB | 12.83% | 5.20% | 25.32% | 14.87% | 63.18% |
base_small_rec |
20.3 MB | 16.91% | 5.82% | 28.75% | 18.61% | 57.16% |
Reference: Hayai-OCR v2.1 evaluated directly on ground truth speech bubbles / text blocks:
| Model | Size | Manga CER | Webtoons CER | Illus CER | Total CER | Total Exact |
|---|---|---|---|---|---|---|
hayai-ocr-v2.1 |
594 MB | 8.69% | 11.11% | 20.27% | 11.04% | 69.87% |
| Task | Format | Model | Size | Total Metric | Total FP / Exact |
|---|---|---|---|---|---|
| Detection | FP32 | manga_det_v0.1.onnx |
1.73 MB | 93.60% Recall (92.10% F1) | 570 FP |
| Detection | FP16 | manga_det_v0.1_fp16.onnx |
0.92 MB | 93.63% Recall (92.14% F1) | 567 FP |
| Recognition | FP32 | manga_rec_v0.1.onnx |
20.19 MB | 10.92% CER | 71.67% Exact |
| Recognition | FP16 | manga_rec_v0.1_fp16.onnx |
10.14 MB | 11.11% CER | 71.25% Exact |
PP-OCRv6_manga/
βββ README.md
βββ ppocrv6_dict.txt # Character dictionary (18,708 symbols)
βββ det/
β βββ manga_det_v0.1.pdparams # Paddle checkpoint (2.05 MB)
β βββ manga_det_v0.1.onnx # ONNX FP32 (1.73 MB)
β βββ manga_det_v0.1_fp16.onnx # ONNX FP16 (0.92 MB)
βββ rec/
βββ manga_rec_v0.1.pdparams # Paddle inference checkpoint (20.3 MB)
βββ manga_rec_v0.1.onnx # ONNX FP32 (20.19 MB)
βββ manga_rec_v0.1_fp16.onnx # ONNX FP16 (10.14 MB)
No PaddlePaddle installation required β only pip install openvino opencv-python numpy pyclipper.
import cv2, numpy as np, openvino as ov, pyclipper
# 1. Load models (FP16: Det 0.92 MB, Rec 10.14 MB)
core = ov.Core()
det_model = core.compile_model("det/manga_det_v0.1_fp16.onnx", "CPU")
rec_model = core.compile_model("rec/manga_rec_v0.1_fp16.onnx", "CPU")
# Load dictionary
with open("ppocrv6_dict.txt", encoding="utf-8") as f:
vocab = ["blank"] + [line.strip("\r\n") for line in f] + [" "]
# 2. Image Detection
img = cv2.imread("page.jpg")
H, W = img.shape[:2]
ratio = 960.0 / max(H, W)
rh, rw = max(int(round(H * ratio / 32) * 32), 32), max(int(round(W * ratio / 32) * 32), 32)
inp = cv2.resize(img, (rw, rh)).astype(np.float32) / 255.0
inp = (inp - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225])
pred_map = det_model(inp.transpose((2, 0, 1))[np.newaxis, ...])[0][0, 0]
# Recommended DB post-processing parameters
thresh = 0.15 # binarization threshold
box_thresh = 0.25 # minimum average contour confidence
unclip_ratio = 1.4 # polygon expansion ratio (compensates for DB kernel shrinkage)
def box_score_fast(pred, box):
h, w = pred.shape[:2]
b = box.copy().astype(np.int32)
xmin = int(np.clip(np.floor(b[:, 0].min()), 0, w - 1))
xmax = int(np.clip(np.ceil(b[:, 0].max()), 0, w - 1))
ymin = int(np.clip(np.floor(b[:, 1].min()), 0, h - 1))
ymax = int(np.clip(np.ceil(b[:, 1].max()), 0, h - 1))
score_mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=np.uint8)
b[:, 0] -= xmin; b[:, 1] -= ymin
cv2.fillPoly(score_mask, [b.reshape(-1, 2)], 1)
return cv2.mean(pred[ymin:ymax + 1, xmin:xmax + 1], score_mask)[0]
def unclip(box, ratio):
# PaddleOCR DBPostProcess: constant offset, not isotropic width/height scaling.
# Shoelace area and perimeter avoid an extra geometry dependency.
x, y = box[:, 0], box[:, 1]
area = 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
perimeter = np.linalg.norm(np.roll(box, -1, axis=0) - box, axis=1).sum()
if area <= 0 or perimeter <= 0: return None
offset = pyclipper.PyclipperOffset()
offset.AddPath(box.tolist(), pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
paths = offset.Execute(area * ratio / perimeter)
return np.asarray(paths[0], dtype=np.float32) if len(paths) == 1 else None
mask = (pred_map > thresh)
contours, _ = cv2.findContours((mask * 255).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
boxes = []
for cnt in contours:
pts = cnt.squeeze(1)
if pts.ndim != 2 or pts.shape[0] < 4: continue
if box_score_fast(pred_map, pts) < box_thresh: continue
pts = unclip(pts, unclip_ratio)
if pts is None or len(pts) == 0: continue
_, (bw, bh), _ = cv2.minAreaRect(pts)
if min(bw, bh) < 3: continue
pts[:, 0] = np.clip(np.round(pts[:, 0] / rw * W), 0, W)
pts[:, 1] = np.clip(np.round(pts[:, 1] / rh * H), 0, H)
boxes.append(pts)
# 3. Recognition on Crops
for box in boxes:
xs, ys = box[:, 0], box[:, 1]
x1, y1, x2, y2 = max(0, int(min(xs))), max(0, int(min(ys))), min(W, int(max(xs))), min(H, int(max(ys)))
crop = img[y1:y2, x1:x2]
if crop.size == 0: continue
if crop.shape[0] > crop.shape[1]: # Rotate vertical text 90Β° CCW
crop = cv2.rotate(crop, cv2.ROTATE_90_COUNTERCLOCKWISE)
ch, cw = crop.shape[:2]
target_w = max(16, int(round(48.0 * cw / max(1, ch))))
c_inp = cv2.resize(crop, (target_w, 48)).astype(np.float32) / 255.0
c_inp = ((c_inp - 0.5) / 0.5).transpose((2, 0, 1))[np.newaxis, ...]
logits = rec_model(c_inp)[0][0] # [T, V]
indices = np.argmax(logits, axis=-1)
# CTC greedy decode
text = "".join(vocab[idx] for i, idx in enumerate(indices) if idx != 0 and (i == 0 or idx != indices[i - 1]))
print(f"Line at ({x1}, {y1}): {text}")
import cv2, paddle, numpy as np
from ppocr.modeling.architectures import build_model
from ppocr.postprocess import build_post_process
# 1. Models
det_model = build_model({
"model_type": "det", "algorithm": "DB", "Transform": None,
"Backbone": {"name": "PPLCNetV4", "det": True, "model_size": "tiny"},
"Neck": {"name": "RepLKFPN", "out_channels": 64, "dilated_kernel_size": 5, "shortcut": True},
"Head": {"name": "DBHead", "k": 50, "fix_nan": True, "aux_in_channels": 64}
})
det_model.set_state_dict(paddle.load("det/manga_det_v0.1.pdparams"))
det_model.eval()
rec_model = build_model({
"model_type": "rec", "algorithm": "SVTR_LCNet", "Transform": None,
"Backbone": {"name": "PPLCNetV4", "model_size": "small"},
"Head": {
"name": "MultiHead",
"out_channels_list": {"CTCLabelDecode": 18710, "NRTRLabelDecode": 18713},
"head_list": [
{"CTCHead": {"Neck": {"name": "lightsvtr", "dims": 120, "depth": 2, "mlp_ratio": 2.0, "local_kernel": 7}, "Head": {"fc_decay": 0.00001}}},
{"NRTRHead": {"nrtr_dim": 384, "max_text_length": 25}}
]
}
})
rec_model.set_state_dict(paddle.load("rec/manga_rec_v0.1.pdparams"))
rec_model.eval()
pp_det = build_post_process({"name": "DBPostProcess", "thresh": 0.15, "box_thresh": 0.25, "unclip_ratio": 1.4, "max_candidates": 3000})
decode = build_post_process({"name": "CTCLabelDecode", "character_dict_path": "ppocrv6_dict.txt", "use_space_char": True})
Base model
PaddlePaddle/PP-OCRv6_small_rec