AngleNet
AngleNet predicts the reading orientation and rotation angle (0° to 180°) for manga text boxes. It is a lightweight post-processing model (+0.22 ms per box / 1.0 MB) for meiki.text.detect.v0. It converts straight boxes into oriented polygons and keeps the 95.66% detector precision.
Native meiki recognition has high error on vertical manga text (~23% end-to-end CER). Adding AngleNet directly to meiki recognition does not fix this limit. This pipeline uses PP-OCRv6 Small Rec instead. Unrotated boxes still fail on tilted text (58.4% CER). AngleNet fixes this problem. It reduces median slant error from 9.15° to 2.87° and lowers tilted dialogue CER from 58.4% to 11.4%. The full pipeline reaches 12.8% end-to-end CER (8.9% matched CER, 86.54% Tilt IoU >= 0.5).
Full pipeline (detection + recognition)
Evaluated on 300 unseen manga and webtoon test pages (2,631 dialogue lines) with a shared PP-OCRv6 Small recognizer (23.0 MB FP16) to isolate the exact impact of bounding box detection and reading orientation on recognition accuracy.
Bold = Best, Underline = 2nd place.
| Detection pipeline | Footprint (FP16) | Detection latency * | Recall | Clean precision | Tilted CER | Matched CER | End-to-end CER |
|---|---|---|---|---|---|---|---|
| meiki.text.detect.v0 + AngleNet v0.1 | 23.5 MB (22.5 + 1.0) | 233.9 ms (+5.8 ms overhead) ⚡ | 96.35% | 95.66% | 11.4% | 8.9% | 12.8% |
| meiki.text.detect.v0 (Base AABB, No AngleNet) | 22.5 MB | 228.1 ms | 96.35% | 95.66% | 58.4% (+47.0%) | 9.8% | 13.4% |
| PP-OCRv6 DBNet Medium | 49.0 MB | 1850.0 ms | 96.95% | 92.40% | 16.8% | 9.9% | 12.4% |
| PP-OCRv6 DBNet Small | 15.2 MB | 580.0 ms | 96.20% | 91.20% | 19.4% | 10.3% | 13.0% |
| PP-OCRv6 DBNet Tiny | 8.4 MB | 310.0 ms | 95.20% | 91.80% | 24.5% | 10.8% | 13.7% |
Angle detection
Bold = Best, Underline = 2nd place. Sorted by Tilt IoU >= 0.5.
| Model / Pipeline | Params | FP32 size | FP16 size | Detection latency * | Clean precision | GT-fit | Median slant error | Tilt IoU >= 0.5 |
|---|---|---|---|---|---|---|---|---|
| PP-OCRv6 medium (DBNet 960px) | 24.5M | 98.0 MB | 49.0 MB | 1850.0 ms | 92.40% | 96.95 | 1.82° | 88.46% |
| Meiki + AngleNet v0.1 pro (128x128) | 11.2M + 3.8M | 45.0 + 14.4 MB | 22.5 + 7.3 MB | 298.7 ms (+70.6 ms Teacher) | 95.55% | 95.40 | 2.58° | 86.54% |
| Meiki + AngleNet v0.1 distill (96x96) | 11.2M + 516K | 45.0 + 2.0 MB | 22.5 + 1.0 MB | 239.1 ms (+11.0 ms) | 95.66% | 95.15 | 3.11° | 86.54% |
| Meiki + AngleNet v0.1 base (64x64) | 11.2M + 516K | 45.0 + 2.0 MB | 22.5 + 1.0 MB | 234.0 ms (+5.9 ms) | 95.62% | 94.91 | 3.56° | 86.54% |
| PP-OCRv6 small (DBNet 960px) | 7.6M | 30.4 MB | 15.2 MB | 580.0 ms | 91.20% | 95.18 | 2.15° | 84.62% |
| Meiki + AngleNet v0.1 distill (64x64) | 11.2M + 516K | 45.0 + 2.0 MB | 22.5 + 1.0 MB | 233.9 ms (+5.8 ms) ⚡ | 95.52% | 95.24 | 2.87° | 82.69% |
| PP-OCRv6 tiny_base (DBNet 960px) | 4.2M | 16.8 MB | 8.4 MB | 310.0 ms | 89.90% | 94.74 | 2.45° | 80.77% |
| Yomitoku DBNet v2.1 (960px) | 11.2M | 44.8 MB | 22.4 MB | 2183.3 ms | 88.50% | 91.28 | 3.20° | 76.92% |
| ScreenAI | - | - | - | 450.0 ms | 86.40% | 91.02 | 3.40° | 75.00% |
| Google Lens | - | - | - | - | 84.20% | 89.14 | 4.10° | 69.23% |
| Meiki (conf=0.44, No AngleNet) | 11.2M | 45.0 MB | 22.5 MB | 228.1 ms | 95.66% | 90.69 | 9.15° | 32.69% |
* Measured per page on strictly 1 single CPU thread (single-core edge/mobile/WASM baseline, ONNX Runtime).
Architecture
- Student model (
v0.1 base,v0.1 distill):- 4-stage Re-parameterizable Convolutional Network (RepBlock with 3x3 and 1x1 training branches fused into single 3x3 convolutions at deployment).
- Multi-aspect pooling head combining Global Average Pool (1x1), Vertical Column Pool (3x1), and Horizontal Line Pool (1x3).
- Two output heads: 180-bin Circular Smooth Label (CSL) angle classifier and a binary tilt classifier.
- Total parameters: 516,405 (2.00 MB FP32 / 1.02 MB FP16).
- 1-Thread CPU latency per crop: 0.216 ms (64x64) / 0.438 ms (96x96).
- Teacher model (
v0.1 pro):- 5-stage RepBlock network with Coordinate Attention (CoordAtt) at 128x128 resolution.
- Total parameters: 3,842,105 (14.45 MB FP32 / 7.26 MB FP16).
- 1-Thread CPU latency per crop: 2.976 ms (128x128).
Datasets
The model was trained on 345,265 samples:
- Manga and webtoon (Manga109, AnimeText, others): 261,543 samples
- Synthetic manga crops: 35,000 samples
- Japanese scene text (JA-WildText): 48,722 samples
Training setup
- Dataset size: 310,737 train / 34,528 val
- Batch size: 512
- Steps per epoch: 607
- Epochs: 15 to 20
- Total steps: 9,105 to 12,140
- Optimizer: AdamW (lr = 0.002, weight decay = 0.0001)
- Schedule: Cosine Annealing
- Distillation: Student is trained via ground-truth CSL cross-entropy and KL-divergence distillation from the 128x128 Teacher (temperature = 2.0).
Quick start: inference
import cv2, math, numpy as np, onnxruntime as ort
from huggingface_hub import hf_hub_download
# Load models (auto-download from Hugging Face)
meiki_path = hf_hub_download(repo_id="rtr46/meiki.text.detect.v0", filename="meiki.text.detect.small.v0.onnx")
angle_path = hf_hub_download(repo_id="Kellenok/anglenet", filename="anglenet_v0_1_distill_64x64.onnx")
meiki_sess = ort.InferenceSession(meiki_path)
angle_sess = ort.InferenceSession(angle_path)
def letterbox_crop(crop_gray, target_size=64):
h, w = crop_gray.shape[:2]
scale = min(target_size / max(1, w), target_size / max(1, h))
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
resized = cv2.resize(crop_gray, (nw, nh), interpolation=cv2.INTER_AREA)
pad = np.zeros((target_size, target_size), dtype=np.uint8)
pad[(target_size - nh) // 2 : (target_size - nh) // 2 + nh,
(target_size - nw) // 2 : (target_size - nw) // 2 + nw] = resized
return (pad.astype(np.float32) / 255.0)[None, None, :, :]
def get_rotated_polygon(x1, y1, x2, y2, rot_deg, width_scale=1.05):
cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
w, h = (x2 - x1) * width_scale, (y2 - y1)
rad = math.radians(rot_deg)
cos_a, sin_a = math.cos(rad), math.sin(rad)
hw, hh = w / 2.0, h / 2.0
corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
return [[round(cx + dx*cos_a - dy*sin_a, 1), round(cy + dx*sin_a + dy*cos_a, 1)] for dx, dy in corners]
def detect_and_orient(image_bgr, conf_thresh=0.44):
H, W = image_bgr.shape[:2]
img_gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
# 1. Run Meiki Detector (640x640)
img_640 = cv2.resize(image_bgr, (640, 640)).transpose(2, 0, 1)[None].astype(np.float32) / 255.0
orig_sz = np.array([[H, W]], dtype=np.int64)
_, boxes, scores = meiki_sess.run(None, {"images": img_640, "orig_target_sizes": orig_sz})
# 2. Filter boxes by confidence
valid = scores[0] >= conf_thresh
boxes = boxes[0][valid]
# 3. Predict angles with AngleNet
polygons = []
for (x1, y1, x2, y2) in boxes:
w, h = x2 - x1, y2 - y1
if w < 4 or h < 4:
continue
# 10% context margin around crop
pw, ph = int(w * 0.10), int(h * 0.10)
crop = img_gray[max(0, int(y1 - ph)):min(H, int(y2 + ph)), max(0, int(x1 - pw)):min(W, int(x2 + pw))]
# Infer AngleNet (0.11 ms on CPU)
input_tensor = letterbox_crop(crop, target_size=64)
csl_logits, tilt_logit = angle_sess.run(None, {"input": input_tensor})
# CSL continuous trigonometric decoding
probs = np.exp(csl_logits[0] - np.max(csl_logits[0]))
probs /= np.sum(probs)
bins = np.arange(180, dtype=np.float32)
pred_deg = (0.5 * np.degrees(np.arctan2(
np.sum(probs * np.sin(np.radians(2.0 * bins))),
np.sum(probs * np.cos(np.radians(2.0 * bins)))
))) % 180.0
# Compute rotation delta
is_vert = (h >= w)
rot_delta = (pred_deg - 90.0) if is_vert else (pred_deg if pred_deg <= 90.0 else pred_deg - 180.0)
is_tilted = (abs(rot_delta) >= 0.5) and ((max(w, h) / max(1.0, min(w, h))) >= 1.1)
poly = get_rotated_polygon(x1, y1, x2, y2, rot_delta, width_scale=1.05) if is_tilted else [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
polygons.append(poly)
return polygons