Mayo commited on
add manga-ocr
Browse files- Cargo.lock +13 -2
- Cargo.toml +1 -1
- manga-ocr/Cargo.toml +11 -0
- manga-ocr/src/main.rs +100 -0
- scripts/export_manga_ocr_to_onnx.py +31 -0
- scripts/manga_ocr_onnx_inference.py +97 -0
Cargo.lock
CHANGED
|
@@ -130,9 +130,9 @@ dependencies = [
|
|
| 130 |
|
| 131 |
[[package]]
|
| 132 |
name = "anyhow"
|
| 133 |
-
version = "1.0.
|
| 134 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 135 |
-
checksum = "
|
| 136 |
dependencies = [
|
| 137 |
"backtrace",
|
| 138 |
]
|
|
@@ -3104,6 +3104,17 @@ version = "0.2.0"
|
|
| 3104 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 3105 |
checksum = "b8dd856d451cc0da70e2ef2ce95a18e39a93b7558bedf10201ad28503f918568"
|
| 3106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3107 |
[[package]]
|
| 3108 |
name = "markup5ever"
|
| 3109 |
version = "0.11.0"
|
|
|
|
| 130 |
|
| 131 |
[[package]]
|
| 132 |
name = "anyhow"
|
| 133 |
+
version = "1.0.98"
|
| 134 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 135 |
+
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
| 136 |
dependencies = [
|
| 137 |
"backtrace",
|
| 138 |
]
|
|
|
|
| 3104 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 3105 |
checksum = "b8dd856d451cc0da70e2ef2ce95a18e39a93b7558bedf10201ad28503f918568"
|
| 3106 |
|
| 3107 |
+
[[package]]
|
| 3108 |
+
name = "manga-ocr"
|
| 3109 |
+
version = "0.1.0"
|
| 3110 |
+
dependencies = [
|
| 3111 |
+
"anyhow",
|
| 3112 |
+
"clap",
|
| 3113 |
+
"image",
|
| 3114 |
+
"ndarray",
|
| 3115 |
+
"ort",
|
| 3116 |
+
]
|
| 3117 |
+
|
| 3118 |
[[package]]
|
| 3119 |
name = "markup5ever"
|
| 3120 |
version = "0.11.0"
|
Cargo.toml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
[workspace]
|
| 2 |
-
members = ["src-tauri", "yolo-v8", "comic-text-detector"]
|
| 3 |
resolver = "2"
|
|
|
|
| 1 |
[workspace]
|
| 2 |
+
members = ["src-tauri", "yolo-v8", "comic-text-detector", "manga-ocr"]
|
| 3 |
resolver = "2"
|
manga-ocr/Cargo.toml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "manga-ocr"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
edition = "2024"
|
| 5 |
+
|
| 6 |
+
[dependencies]
|
| 7 |
+
anyhow = "1.0.98"
|
| 8 |
+
clap = { version = "4.5.36", features = ["derive"] }
|
| 9 |
+
image = "0.25.6"
|
| 10 |
+
ndarray = "0.16.1"
|
| 11 |
+
ort = { version = "=2.0.0-rc.9", features = ["ndarray"] }
|
manga-ocr/src/main.rs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::fs;
|
| 2 |
+
|
| 3 |
+
use clap::Parser;
|
| 4 |
+
use image::imageops::FilterType;
|
| 5 |
+
use ndarray::{Array, s};
|
| 6 |
+
use ort::inputs;
|
| 7 |
+
use ort::session::Session;
|
| 8 |
+
use ort::session::builder::GraphOptimizationLevel;
|
| 9 |
+
|
| 10 |
+
#[derive(Parser, Debug)]
|
| 11 |
+
struct Args {
|
| 12 |
+
#[arg(long)]
|
| 13 |
+
image: String,
|
| 14 |
+
|
| 15 |
+
#[arg(long)]
|
| 16 |
+
model: String,
|
| 17 |
+
|
| 18 |
+
#[arg(long)]
|
| 19 |
+
vocab: String,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
fn main() -> anyhow::Result<()> {
|
| 23 |
+
let args = Args::parse();
|
| 24 |
+
|
| 25 |
+
let model = Session::builder()?
|
| 26 |
+
.with_optimization_level(GraphOptimizationLevel::Level3)?
|
| 27 |
+
.with_intra_threads(4)?
|
| 28 |
+
.commit_from_file(args.model)?;
|
| 29 |
+
|
| 30 |
+
let vocab = fs::read_to_string(args.vocab)
|
| 31 |
+
.map_err(|e| anyhow::anyhow!("Failed to read vocab file: {e}"))?
|
| 32 |
+
.lines()
|
| 33 |
+
.map(|s| s.to_string())
|
| 34 |
+
.collect::<Vec<_>>();
|
| 35 |
+
|
| 36 |
+
let image =
|
| 37 |
+
image::open(&args.image).map_err(|e| anyhow::anyhow!("Failed to open image: {e}"))?;
|
| 38 |
+
let image = image.grayscale().to_rgb8();
|
| 39 |
+
// Resize to 224x224
|
| 40 |
+
let image = image::imageops::resize(&image, 224, 224, FilterType::Lanczos3);
|
| 41 |
+
|
| 42 |
+
// Convert to float32 array and normalize
|
| 43 |
+
let mut tensor = Array::zeros((1, 3, 224, 224));
|
| 44 |
+
for (x, y, pixel) in image.enumerate_pixels() {
|
| 45 |
+
let x = x as usize;
|
| 46 |
+
let y = y as usize;
|
| 47 |
+
|
| 48 |
+
// Normalize from [0, 255] to [-1, 1]
|
| 49 |
+
tensor[[0, 0, y, x]] = (pixel[0] as f32 / 255.0 - 0.5) / 0.5;
|
| 50 |
+
tensor[[0, 1, y, x]] = (pixel[1] as f32 / 255.0 - 0.5) / 0.5;
|
| 51 |
+
tensor[[0, 2, y, x]] = (pixel[2] as f32 / 255.0 - 0.5) / 0.5;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// generate
|
| 55 |
+
let mut token_ids: Vec<i64> = vec![2i64]; // Start token
|
| 56 |
+
|
| 57 |
+
for _ in 0..300 {
|
| 58 |
+
// Create input tensors
|
| 59 |
+
let input = Array::from_shape_vec((1, token_ids.len()), token_ids.clone())?;
|
| 60 |
+
let inputs = inputs! {
|
| 61 |
+
"image" => tensor.view(),
|
| 62 |
+
"token_ids" => input,
|
| 63 |
+
}?;
|
| 64 |
+
|
| 65 |
+
// Run inference
|
| 66 |
+
let outputs = model.run(inputs)?;
|
| 67 |
+
|
| 68 |
+
// Extract logits from output
|
| 69 |
+
let logits = outputs["logits"].try_extract_tensor::<f32>()?;
|
| 70 |
+
|
| 71 |
+
// Get last token logits and find argmax
|
| 72 |
+
let logits_view = logits.view();
|
| 73 |
+
let last_token_logits = logits_view.slice(s![0, -1, ..]);
|
| 74 |
+
let (token_id, _) = last_token_logits
|
| 75 |
+
.iter()
|
| 76 |
+
.enumerate()
|
| 77 |
+
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
|
| 78 |
+
.unwrap_or((0, &0.0));
|
| 79 |
+
|
| 80 |
+
token_ids.push(token_id as i64);
|
| 81 |
+
|
| 82 |
+
// Break if end token
|
| 83 |
+
if token_id as i64 == 3 {
|
| 84 |
+
break;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// decode tokens
|
| 89 |
+
let text = token_ids
|
| 90 |
+
.iter()
|
| 91 |
+
.filter(|&&id| id >= 5)
|
| 92 |
+
.filter_map(|&id| vocab.get(id as usize).cloned())
|
| 93 |
+
.collect::<Vec<_>>();
|
| 94 |
+
|
| 95 |
+
let text = text.join("");
|
| 96 |
+
|
| 97 |
+
println!("Generated text: {}", text);
|
| 98 |
+
|
| 99 |
+
Ok(())
|
| 100 |
+
}
|
scripts/export_manga_ocr_to_onnx.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from transformers import VisionEncoderDecoderModel
|
| 3 |
+
|
| 4 |
+
model = VisionEncoderDecoderModel.from_pretrained("kha-white/manga-ocr-base")
|
| 5 |
+
model.eval()
|
| 6 |
+
|
| 7 |
+
# Dummy input for the model
|
| 8 |
+
dummy_image = torch.randn(1, 3, 224, 224)
|
| 9 |
+
dummy_token_ids = torch.tensor([[2]])
|
| 10 |
+
|
| 11 |
+
# Export the model
|
| 12 |
+
torch.onnx.export(
|
| 13 |
+
model,
|
| 14 |
+
(dummy_image, dummy_token_ids),
|
| 15 |
+
"models/manga-ocr.onnx",
|
| 16 |
+
input_names=["image", "token_ids"],
|
| 17 |
+
output_names=["logits"],
|
| 18 |
+
dynamic_axes={
|
| 19 |
+
"image": {
|
| 20 |
+
0: "batch_size",
|
| 21 |
+
},
|
| 22 |
+
"token_ids": {
|
| 23 |
+
0: "batch_size",
|
| 24 |
+
1: "sequence_length",
|
| 25 |
+
},
|
| 26 |
+
"logits": {
|
| 27 |
+
0: "batch_size",
|
| 28 |
+
1: "sequence_length",
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
)
|
scripts/manga_ocr_onnx_inference.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import jaconv
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from onnxruntime import InferenceSession
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class MangaOCR:
|
| 10 |
+
def __init__(self, model_path: str, vocab_path: str):
|
| 11 |
+
self.session = InferenceSession(model_path)
|
| 12 |
+
self.vocab = self._load_vocab(vocab_path)
|
| 13 |
+
|
| 14 |
+
def __call__(self, image: Image.Image) -> str:
|
| 15 |
+
image = self._preprocess(image)
|
| 16 |
+
token_ids = self._generate(image)
|
| 17 |
+
text = self._decode(token_ids)
|
| 18 |
+
text = self._postprocess(text)
|
| 19 |
+
|
| 20 |
+
return text
|
| 21 |
+
|
| 22 |
+
def _load_vocab(self, vocab_file: str) -> list[str]:
|
| 23 |
+
with open(vocab_file, "r", encoding="utf8") as f:
|
| 24 |
+
vocab = f.read().splitlines()
|
| 25 |
+
|
| 26 |
+
return vocab
|
| 27 |
+
|
| 28 |
+
def _preprocess(self, image: Image.Image) -> np.ndarray:
|
| 29 |
+
# convert to grayscale
|
| 30 |
+
image = image.convert("L").convert("RGB")
|
| 31 |
+
# resize
|
| 32 |
+
image = image.resize((224, 224), resample=2)
|
| 33 |
+
# rescale
|
| 34 |
+
image = np.array(image, dtype=np.float32)
|
| 35 |
+
image /= 255
|
| 36 |
+
# normalize
|
| 37 |
+
image = (image - 0.5) / 0.5
|
| 38 |
+
# reshape from (224, 224, 3) to (3, 224, 224)
|
| 39 |
+
image = image.transpose((2, 0, 1))
|
| 40 |
+
# add batch size
|
| 41 |
+
image = image[None]
|
| 42 |
+
|
| 43 |
+
return image
|
| 44 |
+
|
| 45 |
+
def _generate(self, image: np.ndarray) -> np.ndarray:
|
| 46 |
+
token_ids = [2]
|
| 47 |
+
|
| 48 |
+
for _ in range(300):
|
| 49 |
+
[logits] = self.session.run(
|
| 50 |
+
output_names=["logits"],
|
| 51 |
+
input_feed={
|
| 52 |
+
"image": image,
|
| 53 |
+
"token_ids": np.array([token_ids]),
|
| 54 |
+
},
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
token_id = logits[0, -1, :].argmax()
|
| 58 |
+
token_ids.append(int(token_id))
|
| 59 |
+
|
| 60 |
+
if token_id == 3:
|
| 61 |
+
break
|
| 62 |
+
|
| 63 |
+
return token_ids
|
| 64 |
+
|
| 65 |
+
def _decode(self, token_ids: list[int]) -> str:
|
| 66 |
+
text = ""
|
| 67 |
+
|
| 68 |
+
for token_id in token_ids:
|
| 69 |
+
if token_id < 5:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
text += self.vocab[token_id]
|
| 73 |
+
|
| 74 |
+
return text
|
| 75 |
+
|
| 76 |
+
def _postprocess(self, text: str) -> str:
|
| 77 |
+
text = "".join(text.split())
|
| 78 |
+
text = text.replace("…", "...")
|
| 79 |
+
text = re.sub("[・.]{2,}", lambda x: (x.end() - x.start()) * ".", text)
|
| 80 |
+
text = jaconv.h2z(text, ascii=True, digit=True)
|
| 81 |
+
|
| 82 |
+
return text
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
import argparse
|
| 87 |
+
|
| 88 |
+
parser = argparse.ArgumentParser(description="Manga OCR with ONNX Runtime")
|
| 89 |
+
parser.add_argument("--image", type=str, help="Path to the input image")
|
| 90 |
+
parser.add_argument("--model", type=str, help="Path to the ONNX model file")
|
| 91 |
+
parser.add_argument("--vocab", type=str, help="Path to the vocabulary file")
|
| 92 |
+
args = parser.parse_args()
|
| 93 |
+
|
| 94 |
+
ocr = MangaOCR(args.model, args.vocab)
|
| 95 |
+
image = Image.open(args.image)
|
| 96 |
+
text = ocr(image)
|
| 97 |
+
print(text)
|