PP-OCRv6_manga

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.

Models

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
  • Dict: ppocrv6_dict.txt (18,708 symbols).

Benchmark Results

Evaluation Domains (562 Pages Total)

  • Manga (234 pages, 4,300 lines): Japanese manga pages and panels.
  • Webtoons (171 pages, 732 lines): Korean manhwa, Chinese manhua, and digital webtoons.
  • Illustration (157 pages, 835 lines): Color artwork, posters, and visual novel graphics.

Furigana is excluded via geometric filtering.


Table 1: Text Detection Benchmark

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.


Table 2: Text Recognition on Crops from 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%

Table 3: ONNX FP32 vs FP16 Degradation (OpenVINO / ONNXRuntime)

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

Directory Structure

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)

Quick Start

1. Lightweight Inference: ONNX / OpenVINO (CPU/GPU, ~11 MB total)

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}")

2. Paddle Inference (Pipeline with DBPostProcess)

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})
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support

Model tree for Kellenok/PP-OCRv6_manga

Finetuned
(3)
this model

Datasets used to train Kellenok/PP-OCRv6_manga

Space using Kellenok/PP-OCRv6_manga 1