feat: font detection using yuzumaker.FontDetection
Browse files- koharu-ml/Cargo.toml +4 -0
- koharu-ml/README.md +27 -0
- koharu-ml/bin/font-detect.rs +74 -0
- koharu-ml/src/font_detector/mod.rs +256 -0
- koharu-ml/src/font_detector/models.rs +524 -0
- koharu-ml/src/lib.rs +1 -0
- koharu/src/command.rs +27 -1
- koharu/src/ml.rs +31 -4
- koharu/src/state.rs +2 -0
- scripts/convert_font_detection.py +61 -0
- scripts/convert_font_labels.py +77 -0
koharu-ml/Cargo.toml
CHANGED
|
@@ -78,3 +78,7 @@ path = "bin/llm.rs"
|
|
| 78 |
[[bin]]
|
| 79 |
name = "manga-ocr"
|
| 80 |
path = "bin/manga-ocr.rs"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
[[bin]]
|
| 79 |
name = "manga-ocr"
|
| 80 |
path = "bin/manga-ocr.rs"
|
| 81 |
+
|
| 82 |
+
[[bin]]
|
| 83 |
+
name = "font-detect"
|
| 84 |
+
path = "bin/font-detect.rs"
|
koharu-ml/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Model wrappers and CLI tools for the Koharu app. Each module lazily downloads it
|
|
| 7 |
- `manga_ocr`: encoder/decoder OCR pipeline that reads cropped text regions.
|
| 8 |
- `lama`: LaMa inpainting with tiled blending to remove text using a mask.
|
| 9 |
- `llm`: quantized GGUF loader (Llama or Qwen2) using candle with chat-style prompting and generation controls.
|
|
|
|
| 10 |
|
| 11 |
## CLI tools
|
| 12 |
```bash
|
|
@@ -14,8 +15,34 @@ cargo run -p koharu-models --bin comic-text-detector -- --input page.png --outpu
|
|
| 14 |
cargo run -p koharu-models --bin manga-ocr -- --input bubble.png
|
| 15 |
cargo run -p koharu-models --bin lama -- --input page.png --mask mask.png --output filled.png
|
| 16 |
cargo run -p koharu-models --bin llm -- --prompt "konnichiwa" --model vntl-llama3-8b-v2
|
|
|
|
| 17 |
```
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
Feature `cuda` enables the CUDA execution provider for ONNX Runtime and candle; without it the models fall back to CPU.
|
| 20 |
|
| 21 |
## License
|
|
|
|
| 7 |
- `manga_ocr`: encoder/decoder OCR pipeline that reads cropped text regions.
|
| 8 |
- `lama`: LaMa inpainting with tiled blending to remove text using a mask.
|
| 9 |
- `llm`: quantized GGUF loader (Llama or Qwen2) using candle with chat-style prompting and generation controls.
|
| 10 |
+
- `font_detect`: Candle ResNet50 that reproduces YuzuMarker.FontDetection (CJK font/style classifier).
|
| 11 |
|
| 12 |
## CLI tools
|
| 13 |
```bash
|
|
|
|
| 15 |
cargo run -p koharu-models --bin manga-ocr -- --input bubble.png
|
| 16 |
cargo run -p koharu-models --bin lama -- --input page.png --mask mask.png --output filled.png
|
| 17 |
cargo run -p koharu-models --bin llm -- --prompt "konnichiwa" --model vntl-llama3-8b-v2
|
| 18 |
+
cargo run -p koharu-models --bin font-detect -- --input bubble.png --top-k 5 --model resnet50
|
| 19 |
```
|
| 20 |
|
| 21 |
+
### Font detection weights
|
| 22 |
+
|
| 23 |
+
The original checkpoints are published at [gyrojeff/YuzuMarker.FontDetection](https://huggingface.co/gyrojeff/YuzuMarker.FontDetection) in PyTorch Lightning format. Candle needs `safetensors`, so convert once and point the runtime to the file:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
python scripts/convert_font_detection.py \
|
| 27 |
+
--checkpoint name=4x-epoch=84-step=1649340.ckpt \
|
| 28 |
+
--output ~/.cache/Koharu/models/yuzumarker-font-detection.safetensors
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Set `KOHARU_FONT_DETECTION_WEIGHTS` to override the path if desired. The loader will look for the safetensors file in `~/.cache/Koharu/models/` by default.
|
| 32 |
+
Supported backbones: `resnet18`, `resnet34`, `resnet50` (default), `resnet101`, `deepfont` (pads missing regression outputs).
|
| 33 |
+
|
| 34 |
+
### Font labels (names)
|
| 35 |
+
|
| 36 |
+
The original demo ships `font_demo_cache.bin` (Python pickle) that maps class ids to font paths. Convert it to JSON so Rust can read the names:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
python scripts/convert_font_labels.py \
|
| 40 |
+
--input font_demo_cache.bin \
|
| 41 |
+
--output ~/.cache/Koharu/models/yuzumarker-font-labels.json
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
Set `KOHARU_FONT_DETECTION_LABELS` or pass `--labels` to the CLI to override the path. When labels are present, CLI output includes font names alongside ids.
|
| 45 |
+
|
| 46 |
Feature `cuda` enables the CUDA execution provider for ONNX Runtime and candle; without it the models fall back to CPU.
|
| 47 |
|
| 48 |
## License
|
koharu-ml/bin/font-detect.rs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::PathBuf;
|
| 2 |
+
|
| 3 |
+
use anyhow::Result;
|
| 4 |
+
use clap::Parser;
|
| 5 |
+
use koharu_ml::font_detector::{FontDetector, ModelKind, TextDirection};
|
| 6 |
+
|
| 7 |
+
#[derive(Parser, Debug)]
|
| 8 |
+
#[command(
|
| 9 |
+
author,
|
| 10 |
+
version,
|
| 11 |
+
about = "Run YuzuMarker.FontDetection (Candle) on an image"
|
| 12 |
+
)]
|
| 13 |
+
struct Args {
|
| 14 |
+
/// Path to the input image.
|
| 15 |
+
#[arg(short, long)]
|
| 16 |
+
input: PathBuf,
|
| 17 |
+
/// Number of top font classes to return.
|
| 18 |
+
#[arg(short = 'k', long, default_value_t = 5)]
|
| 19 |
+
top_k: usize,
|
| 20 |
+
/// Force CPU even if GPU is available.
|
| 21 |
+
#[arg(long)]
|
| 22 |
+
cpu: bool,
|
| 23 |
+
/// Backbone architecture (must match the converted checkpoint).
|
| 24 |
+
#[arg(long, default_value = "resnet50", value_enum)]
|
| 25 |
+
model: ModelKind,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#[tokio::main]
|
| 29 |
+
async fn main() -> Result<()> {
|
| 30 |
+
let args = Args::parse();
|
| 31 |
+
let detector = FontDetector::load_with_kind(args.cpu, args.model).await?;
|
| 32 |
+
let image = image::open(&args.input)?;
|
| 33 |
+
let start = std::time::Instant::now();
|
| 34 |
+
let result = detector.inference(&[image], args.top_k)?;
|
| 35 |
+
let Some(pred) = result.first() else {
|
| 36 |
+
return Ok(());
|
| 37 |
+
};
|
| 38 |
+
println!("Inference took: {:.2?}", start.elapsed());
|
| 39 |
+
|
| 40 |
+
println!("Top fonts:");
|
| 41 |
+
for (idx, prob) in &pred.top_fonts {
|
| 42 |
+
let name = pred.named_fonts.iter().find(|f| f.index == *idx);
|
| 43 |
+
if let Some(named) = name {
|
| 44 |
+
if let Some(language) = &named.language {
|
| 45 |
+
println!(" #{idx} ({} | lang={language}): {prob:.4}", named.name);
|
| 46 |
+
} else {
|
| 47 |
+
println!(" #{idx} ({}): {prob:.4}", named.name);
|
| 48 |
+
}
|
| 49 |
+
} else {
|
| 50 |
+
println!(" #{idx}: {prob:.4}");
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
println!(
|
| 54 |
+
"Direction: {:?}",
|
| 55 |
+
match pred.direction {
|
| 56 |
+
TextDirection::Horizontal => "horizontal",
|
| 57 |
+
TextDirection::Vertical => "vertical",
|
| 58 |
+
}
|
| 59 |
+
);
|
| 60 |
+
println!(
|
| 61 |
+
"Text color: rgb({},{},{})",
|
| 62 |
+
pred.text_color[0], pred.text_color[1], pred.text_color[2]
|
| 63 |
+
);
|
| 64 |
+
println!(
|
| 65 |
+
"Stroke color: rgb({},{},{}) width_px={:.2}",
|
| 66 |
+
pred.stroke_color[0], pred.stroke_color[1], pred.stroke_color[2], pred.stroke_width_px
|
| 67 |
+
);
|
| 68 |
+
println!(
|
| 69 |
+
"Font size (px): {:.2} | line height: {:.2} | angle: {:.1}°",
|
| 70 |
+
pred.font_size_px, pred.line_height, pred.angle_deg
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
Ok(())
|
| 74 |
+
}
|
koharu-ml/src/font_detector/mod.rs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::{fs, path::PathBuf};
|
| 2 |
+
|
| 3 |
+
use anyhow::{Context, Result};
|
| 4 |
+
use candle_core::{DType, Device, IndexOp, Tensor};
|
| 5 |
+
use candle_nn::{
|
| 6 |
+
VarBuilder,
|
| 7 |
+
ops::{sigmoid, softmax},
|
| 8 |
+
};
|
| 9 |
+
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
| 10 |
+
use serde::{Deserialize, Serialize};
|
| 11 |
+
|
| 12 |
+
use crate::{define_models, device};
|
| 13 |
+
|
| 14 |
+
mod models;
|
| 15 |
+
pub use models::ModelKind;
|
| 16 |
+
|
| 17 |
+
const FONT_COUNT: usize = 6_150;
|
| 18 |
+
const REGRESSION_START: usize = FONT_COUNT + 2;
|
| 19 |
+
const REGRESSION_DIM: usize = 10;
|
| 20 |
+
|
| 21 |
+
define_models! {
|
| 22 |
+
FontWeights => ("fffonion/yuzumarker-font-detection", "yuzumarker-font-detection.safetensors"),
|
| 23 |
+
FontNames => ("fffonion/yuzumarker-font-detection", "font-labels-ex.json"),
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
| 27 |
+
pub enum TextDirection {
|
| 28 |
+
Horizontal,
|
| 29 |
+
Vertical,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 33 |
+
pub struct NamedFontPrediction {
|
| 34 |
+
pub index: usize,
|
| 35 |
+
pub name: String,
|
| 36 |
+
pub language: Option<String>,
|
| 37 |
+
pub probability: f32,
|
| 38 |
+
pub serif: bool,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 42 |
+
pub struct FontPrediction {
|
| 43 |
+
pub top_fonts: Vec<(usize, f32)>,
|
| 44 |
+
pub named_fonts: Vec<NamedFontPrediction>,
|
| 45 |
+
pub direction: TextDirection,
|
| 46 |
+
pub text_color: [u8; 3],
|
| 47 |
+
pub stroke_color: [u8; 3],
|
| 48 |
+
pub font_size_px: f32,
|
| 49 |
+
pub stroke_width_px: f32,
|
| 50 |
+
pub line_height: f32,
|
| 51 |
+
pub angle_deg: f32,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// implement Default for FontPrediction
|
| 55 |
+
impl Default for FontPrediction {
|
| 56 |
+
fn default() -> Self {
|
| 57 |
+
Self {
|
| 58 |
+
top_fonts: Vec::new(),
|
| 59 |
+
named_fonts: Vec::new(),
|
| 60 |
+
direction: TextDirection::Horizontal,
|
| 61 |
+
text_color: [0, 0, 0],
|
| 62 |
+
stroke_color: [0, 0, 0],
|
| 63 |
+
font_size_px: 0.0,
|
| 64 |
+
stroke_width_px: 0.0,
|
| 65 |
+
line_height: 1.0,
|
| 66 |
+
angle_deg: 0.0,
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
pub struct FontDetector {
|
| 72 |
+
model: models::Model,
|
| 73 |
+
labels: FontLabels,
|
| 74 |
+
device: Device,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
impl FontDetector {
|
| 78 |
+
pub async fn load(use_cpu: bool) -> Result<Self> {
|
| 79 |
+
Self::load_with_kind(use_cpu, ModelKind::default()).await
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
pub async fn load_with_kind(use_cpu: bool, kind: ModelKind) -> Result<Self> {
|
| 83 |
+
let device = device(use_cpu)?;
|
| 84 |
+
let weights = Manifest::FontWeights.get().await?;
|
| 85 |
+
let vb = unsafe {
|
| 86 |
+
VarBuilder::from_mmaped_safetensors(&[weights], DType::F32, &device)?
|
| 87 |
+
.pp("model._orig_mod.model")
|
| 88 |
+
};
|
| 89 |
+
let model = models::Model::load(vb, kind)?;
|
| 90 |
+
let labels = FontLabels::load().await?;
|
| 91 |
+
|
| 92 |
+
Ok(Self {
|
| 93 |
+
model,
|
| 94 |
+
device,
|
| 95 |
+
labels,
|
| 96 |
+
})
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
pub fn inference(&self, images: &[DynamicImage], top_k: usize) -> Result<Vec<FontPrediction>> {
|
| 100 |
+
if images.is_empty() {
|
| 101 |
+
return Ok(Vec::new());
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
let mut processed = Vec::with_capacity(images.len());
|
| 105 |
+
let mut original_sizes = Vec::with_capacity(images.len());
|
| 106 |
+
let input_size = self.model.input_size();
|
| 107 |
+
for image in images {
|
| 108 |
+
let (w, _h) = image.dimensions();
|
| 109 |
+
original_sizes.push(w);
|
| 110 |
+
processed.push(preprocess_image(image, input_size, &self.device)?);
|
| 111 |
+
}
|
| 112 |
+
let batch = Tensor::stack(&processed, 0)?;
|
| 113 |
+
let logits = self.model.forward(&batch, false)?;
|
| 114 |
+
|
| 115 |
+
let mut predictions = Vec::with_capacity(images.len());
|
| 116 |
+
for (index, width) in original_sizes.into_iter().enumerate() {
|
| 117 |
+
let example = logits.i(index)?;
|
| 118 |
+
let font_logits = example.narrow(0, 0, FONT_COUNT)?;
|
| 119 |
+
let font_probs = softmax(&font_logits, 0)?;
|
| 120 |
+
let font_probs_vec: Vec<f32> = font_probs.to_vec1()?;
|
| 121 |
+
let mut ranked: Vec<(usize, f32)> = font_probs_vec.into_iter().enumerate().collect();
|
| 122 |
+
ranked.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
| 123 |
+
ranked.truncate(top_k.min(FONT_COUNT));
|
| 124 |
+
|
| 125 |
+
let named_fonts = ranked
|
| 126 |
+
.iter()
|
| 127 |
+
.filter_map(|(idx, prob)| {
|
| 128 |
+
self.labels.entry(*idx).map(|label| NamedFontPrediction {
|
| 129 |
+
index: *idx,
|
| 130 |
+
name: label.name.clone(),
|
| 131 |
+
language: label.language.clone(),
|
| 132 |
+
probability: *prob,
|
| 133 |
+
serif: label.serif,
|
| 134 |
+
})
|
| 135 |
+
})
|
| 136 |
+
.collect();
|
| 137 |
+
|
| 138 |
+
let direction_logits = example.narrow(0, FONT_COUNT, 2)?;
|
| 139 |
+
let direction_vec: Vec<f32> = direction_logits.to_vec1()?;
|
| 140 |
+
let direction = if direction_vec.len() == 2 && direction_vec[1] > direction_vec[0] {
|
| 141 |
+
TextDirection::Vertical
|
| 142 |
+
} else {
|
| 143 |
+
TextDirection::Horizontal
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
let regression = example.narrow(0, REGRESSION_START, REGRESSION_DIM)?;
|
| 147 |
+
// Regression head is trained on normalized values; bring logits into [0, 1].
|
| 148 |
+
let regression = sigmoid(®ression)?;
|
| 149 |
+
let mut regression: Vec<f32> = regression.to_vec1()?;
|
| 150 |
+
regression.resize(REGRESSION_DIM, 0.0);
|
| 151 |
+
let clamp01 = |v: f32| v.clamp(0.0, 1.0);
|
| 152 |
+
let text_color = [
|
| 153 |
+
(clamp01(regression[0]) * 255.0).round() as u8,
|
| 154 |
+
(clamp01(regression[1]) * 255.0).round() as u8,
|
| 155 |
+
(clamp01(regression[2]) * 255.0).round() as u8,
|
| 156 |
+
];
|
| 157 |
+
let font_size_px = clamp01(regression[3]) * width as f32;
|
| 158 |
+
let stroke_width_px = clamp01(regression[4]) * width as f32;
|
| 159 |
+
let stroke_color = [
|
| 160 |
+
(clamp01(regression[5]) * 255.0).round() as u8,
|
| 161 |
+
(clamp01(regression[6]) * 255.0).round() as u8,
|
| 162 |
+
(clamp01(regression[7]) * 255.0).round() as u8,
|
| 163 |
+
];
|
| 164 |
+
let line_spacing_px = clamp01(regression[8]) * width as f32;
|
| 165 |
+
let line_height = if font_size_px > 0.0 {
|
| 166 |
+
1.0 + line_spacing_px / font_size_px
|
| 167 |
+
} else {
|
| 168 |
+
1.2
|
| 169 |
+
};
|
| 170 |
+
let angle_deg = (regression[9] - 0.5) * 180.0;
|
| 171 |
+
|
| 172 |
+
predictions.push(FontPrediction {
|
| 173 |
+
top_fonts: ranked,
|
| 174 |
+
named_fonts,
|
| 175 |
+
direction,
|
| 176 |
+
text_color,
|
| 177 |
+
stroke_color,
|
| 178 |
+
font_size_px,
|
| 179 |
+
stroke_width_px,
|
| 180 |
+
line_height,
|
| 181 |
+
angle_deg,
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
Ok(predictions)
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
#[derive(Debug, Clone)]
|
| 190 |
+
pub struct FontLabel {
|
| 191 |
+
pub name: String,
|
| 192 |
+
pub language: Option<String>,
|
| 193 |
+
pub serif: bool,
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
#[derive(Debug, Clone)]
|
| 197 |
+
pub struct FontLabels {
|
| 198 |
+
labels: Vec<FontLabel>,
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
impl FontLabels {
|
| 202 |
+
pub async fn load() -> Result<Self> {
|
| 203 |
+
let path = Manifest::FontNames.get().await?;
|
| 204 |
+
Self::from_path(&path)
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
pub fn from_path(path: &PathBuf) -> Result<Self> {
|
| 208 |
+
let data = fs::read_to_string(&path)
|
| 209 |
+
.with_context(|| format!("Failed to read labels file {}", path.display()))?;
|
| 210 |
+
let entries: Vec<FontLabelEntry> = serde_json::from_str(&data)
|
| 211 |
+
.with_context(|| format!("Failed to parse labels file {}", path.display()))?;
|
| 212 |
+
let mut labels = Vec::with_capacity(entries.len());
|
| 213 |
+
for entry in entries {
|
| 214 |
+
labels.push(FontLabel {
|
| 215 |
+
name: entry.path,
|
| 216 |
+
language: entry.language,
|
| 217 |
+
serif: entry.serif,
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
Ok(Self { labels })
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
pub fn entry(&self, idx: usize) -> Option<&FontLabel> {
|
| 224 |
+
self.labels.get(idx)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
pub fn name(&self, idx: usize) -> Option<&str> {
|
| 228 |
+
self.entry(idx).map(|label| label.name.as_str())
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
pub fn language(&self, idx: usize) -> Option<&str> {
|
| 232 |
+
self.entry(idx).and_then(|label| label.language.as_deref())
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
#[derive(serde::Deserialize)]
|
| 237 |
+
struct FontLabelEntry {
|
| 238 |
+
path: String,
|
| 239 |
+
language: Option<String>,
|
| 240 |
+
serif: bool,
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
fn preprocess_image(image: &DynamicImage, target: usize, device: &Device) -> Result<Tensor> {
|
| 244 |
+
let resized = image.resize_exact(target as u32, target as u32, FilterType::CatmullRom);
|
| 245 |
+
let data = resized.to_rgb8().into_raw();
|
| 246 |
+
let tensor = Tensor::from_vec(
|
| 247 |
+
data,
|
| 248 |
+
(target, target, 3),
|
| 249 |
+
&Device::Cpu,
|
| 250 |
+
)?
|
| 251 |
+
.to_dtype(DType::F32)?
|
| 252 |
+
.permute((2, 0, 1))? // (3, H, W)
|
| 253 |
+
* (1.0 / 255.0);
|
| 254 |
+
let tensor = tensor?;
|
| 255 |
+
Ok(tensor.to_device(device)?)
|
| 256 |
+
}
|
koharu-ml/src/font_detector/models.rs
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use anyhow::Result;
|
| 2 |
+
use candle_core::{DType, Module, ModuleT, Tensor};
|
| 3 |
+
use candle_nn::{BatchNorm, Conv2d, Conv2dConfig, Linear, VarBuilder};
|
| 4 |
+
use clap::ValueEnum;
|
| 5 |
+
|
| 6 |
+
use super::{FONT_COUNT, REGRESSION_DIM};
|
| 7 |
+
|
| 8 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
| 9 |
+
#[value(rename_all = "kebab-case")]
|
| 10 |
+
pub enum ModelKind {
|
| 11 |
+
Resnet18,
|
| 12 |
+
Resnet34,
|
| 13 |
+
Resnet50,
|
| 14 |
+
Resnet101,
|
| 15 |
+
Deepfont,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
impl Default for ModelKind {
|
| 19 |
+
fn default() -> Self {
|
| 20 |
+
ModelKind::Resnet50
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
pub struct Model {
|
| 25 |
+
kind: ModelKind,
|
| 26 |
+
inner: ModelImpl,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
enum ModelImpl {
|
| 30 |
+
ResNet(ResNet),
|
| 31 |
+
DeepFont(DeepFont),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
impl Model {
|
| 35 |
+
pub fn load(vb: VarBuilder, kind: ModelKind) -> Result<Self> {
|
| 36 |
+
let model = match kind {
|
| 37 |
+
ModelKind::Resnet18 => ModelImpl::ResNet(ResNet::load_basic(vb, [2, 2, 2, 2], 1)?),
|
| 38 |
+
ModelKind::Resnet34 => ModelImpl::ResNet(ResNet::load_basic(vb, [3, 4, 6, 3], 1)?),
|
| 39 |
+
ModelKind::Resnet50 => ModelImpl::ResNet(ResNet::load_bottleneck(vb, [3, 4, 6, 3], 4)?),
|
| 40 |
+
ModelKind::Resnet101 => {
|
| 41 |
+
ModelImpl::ResNet(ResNet::load_bottleneck(vb, [3, 4, 23, 3], 4)?)
|
| 42 |
+
}
|
| 43 |
+
ModelKind::Deepfont => ModelImpl::DeepFont(DeepFont::load(vb)?),
|
| 44 |
+
};
|
| 45 |
+
Ok(Self { kind, inner: model })
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
pub fn input_size(&self) -> usize {
|
| 49 |
+
match self.kind {
|
| 50 |
+
ModelKind::Deepfont => 105,
|
| 51 |
+
_ => 512,
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
pub fn forward(&self, xs: &Tensor, train: bool) -> candle_core::Result<Tensor> {
|
| 56 |
+
let logits = match &self.inner {
|
| 57 |
+
ModelImpl::ResNet(m) => m.forward(xs, train)?,
|
| 58 |
+
ModelImpl::DeepFont(m) => m.forward(xs, train)?,
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
let (_, dim) = logits.dims2()?;
|
| 62 |
+
if dim == FONT_COUNT + REGRESSION_DIM + 2 {
|
| 63 |
+
return Ok(logits);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// For models that only output font logits (e.g., DeepFont), pad zeros for direction/regression.
|
| 67 |
+
if dim == FONT_COUNT {
|
| 68 |
+
let device = logits.device();
|
| 69 |
+
let zeros = Tensor::zeros((logits.dim(0)?, REGRESSION_DIM + 2), DType::F32, device)?;
|
| 70 |
+
return Tensor::cat(&[logits, zeros], 1);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
Err(candle_core::Error::Msg(format!(
|
| 74 |
+
"Unexpected output dimension from backbone: got {}, expected {}",
|
| 75 |
+
dim,
|
| 76 |
+
FONT_COUNT + REGRESSION_DIM + 2
|
| 77 |
+
)))
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
#[derive(Clone)]
|
| 82 |
+
struct BasicBlock {
|
| 83 |
+
conv1: Conv2d,
|
| 84 |
+
bn1: BatchNorm,
|
| 85 |
+
conv2: Conv2d,
|
| 86 |
+
bn2: BatchNorm,
|
| 87 |
+
downsample: Option<(Conv2d, BatchNorm)>,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
impl BasicBlock {
|
| 91 |
+
fn load(vb: VarBuilder, in_channels: usize, planes: usize, stride: usize) -> Result<Self> {
|
| 92 |
+
let conv1 = Conv2d::new(
|
| 93 |
+
vb.pp("conv1").get((planes, in_channels, 3, 3), "weight")?,
|
| 94 |
+
None,
|
| 95 |
+
Conv2dConfig {
|
| 96 |
+
stride,
|
| 97 |
+
padding: 1,
|
| 98 |
+
..Default::default()
|
| 99 |
+
},
|
| 100 |
+
);
|
| 101 |
+
let bn1 = load_batch_norm(&vb.pp("bn1"), planes)?;
|
| 102 |
+
let conv2 = Conv2d::new(
|
| 103 |
+
vb.pp("conv2").get((planes, planes, 3, 3), "weight")?,
|
| 104 |
+
None,
|
| 105 |
+
Conv2dConfig {
|
| 106 |
+
stride: 1,
|
| 107 |
+
padding: 1,
|
| 108 |
+
..Default::default()
|
| 109 |
+
},
|
| 110 |
+
);
|
| 111 |
+
let bn2 = load_batch_norm(&vb.pp("bn2"), planes)?;
|
| 112 |
+
|
| 113 |
+
let downsample = if stride != 1 || in_channels != planes {
|
| 114 |
+
let conv = Conv2d::new(
|
| 115 |
+
vb.pp("downsample.0")
|
| 116 |
+
.get((planes, in_channels, 1, 1), "weight")?,
|
| 117 |
+
None,
|
| 118 |
+
Conv2dConfig {
|
| 119 |
+
stride,
|
| 120 |
+
..Default::default()
|
| 121 |
+
},
|
| 122 |
+
);
|
| 123 |
+
let bn = load_batch_norm(&vb.pp("downsample.1"), planes)?;
|
| 124 |
+
Some((conv, bn))
|
| 125 |
+
} else {
|
| 126 |
+
None
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
Ok(Self {
|
| 130 |
+
conv1,
|
| 131 |
+
bn1,
|
| 132 |
+
conv2,
|
| 133 |
+
bn2,
|
| 134 |
+
downsample,
|
| 135 |
+
})
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
fn forward(&self, xs: &Tensor, train: bool) -> candle_core::Result<Tensor> {
|
| 139 |
+
let mut out = self.conv1.forward(xs)?;
|
| 140 |
+
out = self.bn1.forward_t(&out, train)?;
|
| 141 |
+
out = out.relu()?;
|
| 142 |
+
|
| 143 |
+
out = self.conv2.forward(&out)?;
|
| 144 |
+
out = self.bn2.forward_t(&out, train)?;
|
| 145 |
+
|
| 146 |
+
let residual = if let Some((conv, bn)) = &self.downsample {
|
| 147 |
+
let mut y = conv.forward(xs)?;
|
| 148 |
+
y = bn.forward_t(&y, train)?;
|
| 149 |
+
y
|
| 150 |
+
} else {
|
| 151 |
+
xs.clone()
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
(out + residual)?.relu()
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#[derive(Clone)]
|
| 159 |
+
struct Bottleneck {
|
| 160 |
+
conv1: Conv2d,
|
| 161 |
+
bn1: BatchNorm,
|
| 162 |
+
conv2: Conv2d,
|
| 163 |
+
bn2: BatchNorm,
|
| 164 |
+
conv3: Conv2d,
|
| 165 |
+
bn3: BatchNorm,
|
| 166 |
+
downsample: Option<(Conv2d, BatchNorm)>,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
impl Bottleneck {
|
| 170 |
+
fn load(
|
| 171 |
+
vb: VarBuilder,
|
| 172 |
+
in_channels: usize,
|
| 173 |
+
planes: usize,
|
| 174 |
+
stride: usize,
|
| 175 |
+
expansion: usize,
|
| 176 |
+
) -> Result<Self> {
|
| 177 |
+
let conv1 = Conv2d::new(
|
| 178 |
+
vb.pp("conv1").get((planes, in_channels, 1, 1), "weight")?,
|
| 179 |
+
None,
|
| 180 |
+
Conv2dConfig::default(),
|
| 181 |
+
);
|
| 182 |
+
let bn1 = load_batch_norm(&vb.pp("bn1"), planes)?;
|
| 183 |
+
let conv2 = Conv2d::new(
|
| 184 |
+
vb.pp("conv2").get((planes, planes, 3, 3), "weight")?,
|
| 185 |
+
None,
|
| 186 |
+
Conv2dConfig {
|
| 187 |
+
stride,
|
| 188 |
+
padding: 1,
|
| 189 |
+
..Default::default()
|
| 190 |
+
},
|
| 191 |
+
);
|
| 192 |
+
let bn2 = load_batch_norm(&vb.pp("bn2"), planes)?;
|
| 193 |
+
let conv3 = Conv2d::new(
|
| 194 |
+
vb.pp("conv3")
|
| 195 |
+
.get((planes * expansion, planes, 1, 1), "weight")?,
|
| 196 |
+
None,
|
| 197 |
+
Conv2dConfig::default(),
|
| 198 |
+
);
|
| 199 |
+
let bn3 = load_batch_norm(&vb.pp("bn3"), planes * expansion)?;
|
| 200 |
+
|
| 201 |
+
let downsample = if in_channels != planes * expansion || stride != 1 {
|
| 202 |
+
let conv = Conv2d::new(
|
| 203 |
+
vb.pp("downsample.0")
|
| 204 |
+
.get((planes * expansion, in_channels, 1, 1), "weight")?,
|
| 205 |
+
None,
|
| 206 |
+
Conv2dConfig {
|
| 207 |
+
stride,
|
| 208 |
+
..Default::default()
|
| 209 |
+
},
|
| 210 |
+
);
|
| 211 |
+
let bn = load_batch_norm(&vb.pp("downsample.1"), planes * expansion)?;
|
| 212 |
+
Some((conv, bn))
|
| 213 |
+
} else {
|
| 214 |
+
None
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
Ok(Self {
|
| 218 |
+
conv1,
|
| 219 |
+
bn1,
|
| 220 |
+
conv2,
|
| 221 |
+
bn2,
|
| 222 |
+
conv3,
|
| 223 |
+
bn3,
|
| 224 |
+
downsample,
|
| 225 |
+
})
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
fn forward(&self, xs: &Tensor, train: bool) -> candle_core::Result<Tensor> {
|
| 229 |
+
let mut out = self.conv1.forward(xs)?;
|
| 230 |
+
out = self.bn1.forward_t(&out, train)?;
|
| 231 |
+
out = out.relu()?;
|
| 232 |
+
|
| 233 |
+
out = self.conv2.forward(&out)?;
|
| 234 |
+
out = self.bn2.forward_t(&out, train)?;
|
| 235 |
+
out = out.relu()?;
|
| 236 |
+
|
| 237 |
+
out = self.conv3.forward(&out)?;
|
| 238 |
+
out = self.bn3.forward_t(&out, train)?;
|
| 239 |
+
|
| 240 |
+
let residual = if let Some((conv, bn)) = &self.downsample {
|
| 241 |
+
let mut y = conv.forward(xs)?;
|
| 242 |
+
y = bn.forward_t(&y, train)?;
|
| 243 |
+
y
|
| 244 |
+
} else {
|
| 245 |
+
xs.clone()
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
(out + residual)?.relu()
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
struct ResNet {
|
| 253 |
+
conv1: Conv2d,
|
| 254 |
+
bn1: BatchNorm,
|
| 255 |
+
layer1: Vec<ResBlock>,
|
| 256 |
+
layer2: Vec<ResBlock>,
|
| 257 |
+
layer3: Vec<ResBlock>,
|
| 258 |
+
layer4: Vec<ResBlock>,
|
| 259 |
+
fc: Linear,
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
enum ResBlock {
|
| 263 |
+
Basic(BasicBlock),
|
| 264 |
+
Bottleneck(Bottleneck),
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
impl ResNet {
|
| 268 |
+
fn load_basic(vb: VarBuilder, layers: [usize; 4], expansion: usize) -> Result<Self> {
|
| 269 |
+
Self::load_impl(vb, layers, BlockKind::Basic, expansion)
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
fn load_bottleneck(vb: VarBuilder, layers: [usize; 4], expansion: usize) -> Result<Self> {
|
| 273 |
+
Self::load_impl(vb, layers, BlockKind::Bottleneck, expansion)
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
fn load_impl(
|
| 277 |
+
vb: VarBuilder,
|
| 278 |
+
layers: [usize; 4],
|
| 279 |
+
block: BlockKind,
|
| 280 |
+
expansion: usize,
|
| 281 |
+
) -> Result<Self> {
|
| 282 |
+
let conv1 = Conv2d::new(
|
| 283 |
+
vb.pp("conv1").get((64, 3, 7, 7), "weight")?,
|
| 284 |
+
None,
|
| 285 |
+
Conv2dConfig {
|
| 286 |
+
stride: 2,
|
| 287 |
+
padding: 3,
|
| 288 |
+
..Default::default()
|
| 289 |
+
},
|
| 290 |
+
);
|
| 291 |
+
let bn1 = load_batch_norm(&vb.pp("bn1"), 64)?;
|
| 292 |
+
|
| 293 |
+
let (layer1, c1) =
|
| 294 |
+
Self::make_layer(vb.pp("layer1"), 64, 64, layers[0], 1, block, expansion)?;
|
| 295 |
+
let (layer2, c2) =
|
| 296 |
+
Self::make_layer(vb.pp("layer2"), c1, 128, layers[1], 2, block, expansion)?;
|
| 297 |
+
let (layer3, c3) =
|
| 298 |
+
Self::make_layer(vb.pp("layer3"), c2, 256, layers[2], 2, block, expansion)?;
|
| 299 |
+
let (layer4, c4) =
|
| 300 |
+
Self::make_layer(vb.pp("layer4"), c3, 512, layers[3], 2, block, expansion)?;
|
| 301 |
+
|
| 302 |
+
let fc = Linear::new(
|
| 303 |
+
vb.pp("fc")
|
| 304 |
+
.get((FONT_COUNT + REGRESSION_DIM + 2, c4), "weight")?,
|
| 305 |
+
Some(vb.pp("fc").get(FONT_COUNT + REGRESSION_DIM + 2, "bias")?),
|
| 306 |
+
);
|
| 307 |
+
|
| 308 |
+
Ok(Self {
|
| 309 |
+
conv1,
|
| 310 |
+
bn1,
|
| 311 |
+
layer1,
|
| 312 |
+
layer2,
|
| 313 |
+
layer3,
|
| 314 |
+
layer4,
|
| 315 |
+
fc,
|
| 316 |
+
})
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
fn make_layer(
|
| 320 |
+
vb: VarBuilder,
|
| 321 |
+
in_channels: usize,
|
| 322 |
+
planes: usize,
|
| 323 |
+
blocks: usize,
|
| 324 |
+
stride: usize,
|
| 325 |
+
block_kind: BlockKind,
|
| 326 |
+
expansion: usize,
|
| 327 |
+
) -> Result<(Vec<ResBlock>, usize)> {
|
| 328 |
+
let mut layers = Vec::with_capacity(blocks);
|
| 329 |
+
let first = match block_kind {
|
| 330 |
+
BlockKind::Basic => {
|
| 331 |
+
ResBlock::Basic(BasicBlock::load(vb.pp("0"), in_channels, planes, stride)?)
|
| 332 |
+
}
|
| 333 |
+
BlockKind::Bottleneck => ResBlock::Bottleneck(Bottleneck::load(
|
| 334 |
+
vb.pp("0"),
|
| 335 |
+
in_channels,
|
| 336 |
+
planes,
|
| 337 |
+
stride,
|
| 338 |
+
expansion,
|
| 339 |
+
)?),
|
| 340 |
+
};
|
| 341 |
+
layers.push(first);
|
| 342 |
+
let current_channels = planes * expansion;
|
| 343 |
+
for idx in 1..blocks {
|
| 344 |
+
let block_vb = vb.pp(idx.to_string());
|
| 345 |
+
let block = match block_kind {
|
| 346 |
+
BlockKind::Basic => {
|
| 347 |
+
ResBlock::Basic(BasicBlock::load(block_vb, current_channels, planes, 1)?)
|
| 348 |
+
}
|
| 349 |
+
BlockKind::Bottleneck => ResBlock::Bottleneck(Bottleneck::load(
|
| 350 |
+
block_vb,
|
| 351 |
+
current_channels,
|
| 352 |
+
planes,
|
| 353 |
+
1,
|
| 354 |
+
expansion,
|
| 355 |
+
)?),
|
| 356 |
+
};
|
| 357 |
+
layers.push(block);
|
| 358 |
+
}
|
| 359 |
+
Ok((layers, current_channels))
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
fn forward(&self, xs: &Tensor, train: bool) -> candle_core::Result<Tensor> {
|
| 363 |
+
let mut x = self.conv1.forward(xs)?;
|
| 364 |
+
x = self.bn1.forward_t(&x, train)?;
|
| 365 |
+
x = x.relu()?;
|
| 366 |
+
x = x.max_pool2d_with_stride(3, 2)?;
|
| 367 |
+
|
| 368 |
+
for b in &self.layer1 {
|
| 369 |
+
x = b.forward(&x, train)?;
|
| 370 |
+
}
|
| 371 |
+
for b in &self.layer2 {
|
| 372 |
+
x = b.forward(&x, train)?;
|
| 373 |
+
}
|
| 374 |
+
for b in &self.layer3 {
|
| 375 |
+
x = b.forward(&x, train)?;
|
| 376 |
+
}
|
| 377 |
+
for b in &self.layer4 {
|
| 378 |
+
x = b.forward(&x, train)?;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
let (_, c, h, w) = x.dims4()?;
|
| 382 |
+
let mut x = x.sum_keepdim(2)?;
|
| 383 |
+
x = x.sum_keepdim(3)?;
|
| 384 |
+
x = (x / ((h * w) as f64))?.reshape((xs.dim(0)?, c))?;
|
| 385 |
+
self.fc.forward(&x)
|
| 386 |
+
}
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
impl ResBlock {
|
| 390 |
+
fn forward(&self, xs: &Tensor, train: bool) -> candle_core::Result<Tensor> {
|
| 391 |
+
match self {
|
| 392 |
+
ResBlock::Basic(b) => b.forward(xs, train),
|
| 393 |
+
ResBlock::Bottleneck(b) => b.forward(xs, train),
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
#[derive(Clone, Copy)]
|
| 399 |
+
enum BlockKind {
|
| 400 |
+
Basic,
|
| 401 |
+
Bottleneck,
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
struct DeepFont {
|
| 405 |
+
conv1: Conv2d,
|
| 406 |
+
bn1: BatchNorm,
|
| 407 |
+
conv2: Conv2d,
|
| 408 |
+
bn2: BatchNorm,
|
| 409 |
+
conv3: Conv2d,
|
| 410 |
+
conv4: Conv2d,
|
| 411 |
+
conv5: Conv2d,
|
| 412 |
+
fc1: Linear,
|
| 413 |
+
fc2: Linear,
|
| 414 |
+
fc3: Linear,
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
impl DeepFont {
|
| 418 |
+
fn load(vb: VarBuilder) -> Result<Self> {
|
| 419 |
+
let conv1 = Conv2d::new(
|
| 420 |
+
vb.pp("0").get((64, 3, 11, 11), "weight")?,
|
| 421 |
+
Some(vb.pp("0").get(64, "bias")?),
|
| 422 |
+
Conv2dConfig {
|
| 423 |
+
stride: 2,
|
| 424 |
+
..Default::default()
|
| 425 |
+
},
|
| 426 |
+
);
|
| 427 |
+
let bn1 = load_batch_norm(&vb.pp("1"), 64)?;
|
| 428 |
+
let conv2 = Conv2d::new(
|
| 429 |
+
vb.pp("4").get((128, 64, 3, 3), "weight")?,
|
| 430 |
+
Some(vb.pp("4").get(128, "bias")?),
|
| 431 |
+
Conv2dConfig {
|
| 432 |
+
padding: 1,
|
| 433 |
+
..Default::default()
|
| 434 |
+
},
|
| 435 |
+
);
|
| 436 |
+
let bn2 = load_batch_norm(&vb.pp("5"), 128)?;
|
| 437 |
+
let conv3 = Conv2d::new(
|
| 438 |
+
vb.pp("8").get((256, 128, 3, 3), "weight")?,
|
| 439 |
+
Some(vb.pp("8").get(256, "bias")?),
|
| 440 |
+
Conv2dConfig {
|
| 441 |
+
padding: 1,
|
| 442 |
+
..Default::default()
|
| 443 |
+
},
|
| 444 |
+
);
|
| 445 |
+
let conv4 = Conv2d::new(
|
| 446 |
+
vb.pp("9").get((256, 256, 3, 3), "weight")?,
|
| 447 |
+
Some(vb.pp("9").get(256, "bias")?),
|
| 448 |
+
Conv2dConfig {
|
| 449 |
+
padding: 1,
|
| 450 |
+
..Default::default()
|
| 451 |
+
},
|
| 452 |
+
);
|
| 453 |
+
let conv5 = Conv2d::new(
|
| 454 |
+
vb.pp("10").get((256, 256, 3, 3), "weight")?,
|
| 455 |
+
Some(vb.pp("10").get(256, "bias")?),
|
| 456 |
+
Conv2dConfig {
|
| 457 |
+
padding: 1,
|
| 458 |
+
..Default::default()
|
| 459 |
+
},
|
| 460 |
+
);
|
| 461 |
+
let fc1 = Linear::new(
|
| 462 |
+
vb.pp("14").get((4096, 256 * 12 * 12), "weight")?,
|
| 463 |
+
Some(vb.pp("14").get(4096, "bias")?),
|
| 464 |
+
);
|
| 465 |
+
let fc2 = Linear::new(
|
| 466 |
+
vb.pp("16").get((4096, 4096), "weight")?,
|
| 467 |
+
Some(vb.pp("16").get(4096, "bias")?),
|
| 468 |
+
);
|
| 469 |
+
let fc3 = Linear::new(
|
| 470 |
+
vb.pp("18").get((FONT_COUNT, 4096), "weight")?,
|
| 471 |
+
Some(vb.pp("18").get(FONT_COUNT, "bias")?),
|
| 472 |
+
);
|
| 473 |
+
|
| 474 |
+
Ok(Self {
|
| 475 |
+
conv1,
|
| 476 |
+
bn1,
|
| 477 |
+
conv2,
|
| 478 |
+
bn2,
|
| 479 |
+
conv3,
|
| 480 |
+
conv4,
|
| 481 |
+
conv5,
|
| 482 |
+
fc1,
|
| 483 |
+
fc2,
|
| 484 |
+
fc3,
|
| 485 |
+
})
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
fn forward(&self, xs: &Tensor, train: bool) -> candle_core::Result<Tensor> {
|
| 489 |
+
let mut x = self.conv1.forward(xs)?;
|
| 490 |
+
x = self.bn1.forward_t(&x, train)?;
|
| 491 |
+
x = x.relu()?;
|
| 492 |
+
x = x.max_pool2d_with_stride(2, 2)?;
|
| 493 |
+
|
| 494 |
+
x = self.conv2.forward(&x)?;
|
| 495 |
+
x = self.bn2.forward_t(&x, train)?;
|
| 496 |
+
x = x.relu()?;
|
| 497 |
+
x = x.max_pool2d_with_stride(2, 2)?;
|
| 498 |
+
|
| 499 |
+
x = self.conv3.forward(&x)?;
|
| 500 |
+
x = x.relu()?;
|
| 501 |
+
x = self.conv4.forward(&x)?;
|
| 502 |
+
x = x.relu()?;
|
| 503 |
+
x = self.conv5.forward(&x)?;
|
| 504 |
+
x = x.relu()?;
|
| 505 |
+
|
| 506 |
+
x = x.flatten(1, x.rank() - 1)?;
|
| 507 |
+
x = self.fc1.forward(&x)?;
|
| 508 |
+
x = x.relu()?;
|
| 509 |
+
x = self.fc2.forward(&x)?;
|
| 510 |
+
x = x.relu()?;
|
| 511 |
+
self.fc3.forward(&x)
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
fn load_batch_norm(vb: &VarBuilder, channels: usize) -> Result<BatchNorm> {
|
| 516 |
+
Ok(BatchNorm::new(
|
| 517 |
+
channels,
|
| 518 |
+
vb.get(channels, "running_mean")?,
|
| 519 |
+
vb.get(channels, "running_var")?,
|
| 520 |
+
vb.get(channels, "weight")?,
|
| 521 |
+
vb.get(channels, "bias")?,
|
| 522 |
+
1e-5,
|
| 523 |
+
)?)
|
| 524 |
+
}
|
koharu-ml/src/lib.rs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
mod hf_hub;
|
| 2 |
|
| 3 |
pub mod comic_text_detector;
|
|
|
|
| 4 |
pub mod lama;
|
| 5 |
pub mod llm;
|
| 6 |
pub mod manga_ocr;
|
|
|
|
| 1 |
mod hf_hub;
|
| 2 |
|
| 3 |
pub mod comic_text_detector;
|
| 4 |
+
pub mod font_detector;
|
| 5 |
pub mod lama;
|
| 6 |
pub mod llm;
|
| 7 |
pub mod manga_ocr;
|
koharu/src/command.rs
CHANGED
|
@@ -123,10 +123,36 @@ pub async fn detect(
|
|
| 123 |
.get_mut(index)
|
| 124 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 125 |
|
| 126 |
-
let (text_blocks, segment) = model.
|
| 127 |
document.text_blocks = text_blocks;
|
| 128 |
document.segment = Some(segment);
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
Ok(document.clone())
|
| 131 |
}
|
| 132 |
|
|
|
|
| 123 |
.get_mut(index)
|
| 124 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 125 |
|
| 126 |
+
let (text_blocks, segment) = model.detect_dialog(&document.image).await?;
|
| 127 |
document.text_blocks = text_blocks;
|
| 128 |
document.segment = Some(segment);
|
| 129 |
|
| 130 |
+
// detect fonts for each text block
|
| 131 |
+
if !document.text_blocks.is_empty() {
|
| 132 |
+
let images: Vec<image::DynamicImage> = document
|
| 133 |
+
.text_blocks
|
| 134 |
+
.iter()
|
| 135 |
+
.map(|block| {
|
| 136 |
+
let sub_image = document.image.crop_imm(
|
| 137 |
+
block.x as u32,
|
| 138 |
+
block.y as u32,
|
| 139 |
+
block.width as u32,
|
| 140 |
+
block.height as u32,
|
| 141 |
+
);
|
| 142 |
+
sub_image
|
| 143 |
+
})
|
| 144 |
+
.collect();
|
| 145 |
+
let font_predictions = model.detect_fonts(&images, 1).await?;
|
| 146 |
+
for (block, prediction) in document
|
| 147 |
+
.text_blocks
|
| 148 |
+
.iter_mut()
|
| 149 |
+
.zip(font_predictions.into_iter())
|
| 150 |
+
{
|
| 151 |
+
tracing::info!("Detected font for block {:?}: {:?}", block.text, prediction);
|
| 152 |
+
block.font_info = Some(prediction);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
Ok(document.clone())
|
| 157 |
}
|
| 158 |
|
koharu/src/ml.rs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
use anyhow::Result;
|
| 2 |
use image::DynamicImage;
|
| 3 |
use koharu_ml::comic_text_detector::{self, ComicTextDetector};
|
|
|
|
| 4 |
use koharu_ml::lama::{self, Lama};
|
| 5 |
use koharu_ml::manga_ocr::{self, MangaOcr};
|
| 6 |
|
|
@@ -8,25 +9,27 @@ use crate::image::SerializableDynamicImage;
|
|
| 8 |
use crate::state::TextBlock;
|
| 9 |
|
| 10 |
pub struct Model {
|
| 11 |
-
|
| 12 |
ocr: MangaOcr,
|
| 13 |
lama: Lama,
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
impl Model {
|
| 17 |
pub async fn new(use_cpu: bool) -> Result<Self> {
|
| 18 |
Ok(Self {
|
| 19 |
-
|
| 20 |
ocr: MangaOcr::load(use_cpu).await?,
|
| 21 |
lama: Lama::load(use_cpu).await?,
|
|
|
|
| 22 |
})
|
| 23 |
}
|
| 24 |
|
| 25 |
-
pub async fn
|
| 26 |
&self,
|
| 27 |
image: &SerializableDynamicImage,
|
| 28 |
) -> Result<(Vec<TextBlock>, SerializableDynamicImage)> {
|
| 29 |
-
let (bboxes, segment) = self.
|
| 30 |
|
| 31 |
let mut text_blocks: Vec<TextBlock> = bboxes
|
| 32 |
.into_iter()
|
|
@@ -91,12 +94,36 @@ impl Model {
|
|
| 91 |
|
| 92 |
Ok(result.into())
|
| 93 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
|
| 96 |
pub async fn prefetch() -> Result<()> {
|
| 97 |
comic_text_detector::prefetch().await?;
|
| 98 |
manga_ocr::prefetch().await?;
|
| 99 |
lama::prefetch().await?;
|
|
|
|
| 100 |
|
| 101 |
Ok(())
|
| 102 |
}
|
|
|
|
| 1 |
use anyhow::Result;
|
| 2 |
use image::DynamicImage;
|
| 3 |
use koharu_ml::comic_text_detector::{self, ComicTextDetector};
|
| 4 |
+
use koharu_ml::font_detector::{self, FontDetector};
|
| 5 |
use koharu_ml::lama::{self, Lama};
|
| 6 |
use koharu_ml::manga_ocr::{self, MangaOcr};
|
| 7 |
|
|
|
|
| 9 |
use crate::state::TextBlock;
|
| 10 |
|
| 11 |
pub struct Model {
|
| 12 |
+
dialog_detector: ComicTextDetector,
|
| 13 |
ocr: MangaOcr,
|
| 14 |
lama: Lama,
|
| 15 |
+
font_detector: FontDetector,
|
| 16 |
}
|
| 17 |
|
| 18 |
impl Model {
|
| 19 |
pub async fn new(use_cpu: bool) -> Result<Self> {
|
| 20 |
Ok(Self {
|
| 21 |
+
dialog_detector: ComicTextDetector::load(use_cpu).await?,
|
| 22 |
ocr: MangaOcr::load(use_cpu).await?,
|
| 23 |
lama: Lama::load(use_cpu).await?,
|
| 24 |
+
font_detector: FontDetector::load(use_cpu).await?,
|
| 25 |
})
|
| 26 |
}
|
| 27 |
|
| 28 |
+
pub async fn detect_dialog(
|
| 29 |
&self,
|
| 30 |
image: &SerializableDynamicImage,
|
| 31 |
) -> Result<(Vec<TextBlock>, SerializableDynamicImage)> {
|
| 32 |
+
let (bboxes, segment) = self.dialog_detector.inference(image)?;
|
| 33 |
|
| 34 |
let mut text_blocks: Vec<TextBlock> = bboxes
|
| 35 |
.into_iter()
|
|
|
|
| 94 |
|
| 95 |
Ok(result.into())
|
| 96 |
}
|
| 97 |
+
|
| 98 |
+
pub async fn detect_font(
|
| 99 |
+
&self,
|
| 100 |
+
image: &DynamicImage,
|
| 101 |
+
top_k: usize,
|
| 102 |
+
) -> Result<font_detector::FontPrediction> {
|
| 103 |
+
let mut results = self
|
| 104 |
+
.detect_fonts(std::slice::from_ref(image), top_k)
|
| 105 |
+
.await?;
|
| 106 |
+
Ok(results.pop().unwrap_or_default())
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
pub async fn detect_fonts(
|
| 110 |
+
&self,
|
| 111 |
+
images: &[DynamicImage],
|
| 112 |
+
top_k: usize,
|
| 113 |
+
) -> Result<Vec<font_detector::FontPrediction>> {
|
| 114 |
+
if images.is_empty() {
|
| 115 |
+
return Ok(Vec::new());
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
self.font_detector.inference(images, top_k)
|
| 119 |
+
}
|
| 120 |
}
|
| 121 |
|
| 122 |
pub async fn prefetch() -> Result<()> {
|
| 123 |
comic_text_detector::prefetch().await?;
|
| 124 |
manga_ocr::prefetch().await?;
|
| 125 |
lama::prefetch().await?;
|
| 126 |
+
font_detector::prefetch().await?;
|
| 127 |
|
| 128 |
Ok(())
|
| 129 |
}
|
koharu/src/state.rs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
use std::{path::PathBuf, sync::Arc};
|
| 2 |
|
| 3 |
use image::GenericImageView;
|
|
|
|
| 4 |
use koharu_renderer::types::Color;
|
| 5 |
use serde::{Deserialize, Serialize};
|
| 6 |
use tokio::sync::RwLock;
|
|
@@ -17,6 +18,7 @@ pub struct TextBlock {
|
|
| 17 |
pub text: Option<String>,
|
| 18 |
pub translation: Option<String>,
|
| 19 |
pub style: Option<TextStyle>,
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
| 1 |
use std::{path::PathBuf, sync::Arc};
|
| 2 |
|
| 3 |
use image::GenericImageView;
|
| 4 |
+
use koharu_ml::font_detector::FontPrediction;
|
| 5 |
use koharu_renderer::types::Color;
|
| 6 |
use serde::{Deserialize, Serialize};
|
| 7 |
use tokio::sync::RwLock;
|
|
|
|
| 18 |
pub text: Option<String>,
|
| 19 |
pub translation: Option<String>,
|
| 20 |
pub style: Option<TextStyle>,
|
| 21 |
+
pub font_info: Option<FontPrediction>,
|
| 22 |
}
|
| 23 |
|
| 24 |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
scripts/convert_font_detection.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Convert YuzuMarker.FontDetection checkpoints (.ckpt) to safetensors for Candle.
|
| 4 |
+
|
| 5 |
+
Example:
|
| 6 |
+
python scripts/convert_font_detection.py \
|
| 7 |
+
--checkpoint name=4x-epoch=84-step=1649340.ckpt
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from huggingface_hub import hf_hub_download
|
| 14 |
+
import torch
|
| 15 |
+
from safetensors.torch import save_file
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
DEFAULT_CKPT = "name=4x-epoch=84-step=1649340.ckpt"
|
| 19 |
+
REPO_ID = "gyrojeff/YuzuMarker.FontDetection"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def parse_args() -> argparse.Namespace:
|
| 23 |
+
cache_dir = (
|
| 24 |
+
Path.home() / ".cache" / "Koharu" / "models" / "yuzumarker-font-detection.safetensors"
|
| 25 |
+
)
|
| 26 |
+
parser = argparse.ArgumentParser(description="Convert YuzuMarker.FontDetection checkpoint.")
|
| 27 |
+
parser.add_argument(
|
| 28 |
+
"-c",
|
| 29 |
+
"--checkpoint",
|
| 30 |
+
default=DEFAULT_CKPT,
|
| 31 |
+
help=f"Checkpoint filename from {REPO_ID} (default: {DEFAULT_CKPT})",
|
| 32 |
+
)
|
| 33 |
+
parser.add_argument(
|
| 34 |
+
"-o",
|
| 35 |
+
"--output",
|
| 36 |
+
type=Path,
|
| 37 |
+
default=cache_dir,
|
| 38 |
+
help=f"Output safetensors path (default: {cache_dir})",
|
| 39 |
+
)
|
| 40 |
+
return parser.parse_args()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def main() -> None:
|
| 44 |
+
args = parse_args()
|
| 45 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
print(f"Downloading {args.checkpoint} from {REPO_ID} ...")
|
| 48 |
+
ckpt_path = hf_hub_download(repo_id=REPO_ID, filename=args.checkpoint)
|
| 49 |
+
print(f"Loaded checkpoint at {ckpt_path}")
|
| 50 |
+
|
| 51 |
+
state = torch.load(ckpt_path, map_location="cpu")
|
| 52 |
+
if "state_dict" not in state:
|
| 53 |
+
raise RuntimeError("Unexpected checkpoint format: missing state_dict")
|
| 54 |
+
state_dict = state["state_dict"]
|
| 55 |
+
print(f"Saving {len(state_dict)} tensors to {args.output}")
|
| 56 |
+
save_file(state_dict, str(args.output))
|
| 57 |
+
print("Done.")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
main()
|
scripts/convert_font_labels.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Convert font_demo_cache.bin (from YuzuMarker.FontDetection) to a JSON list that Rust can read.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python scripts/convert_font_labels.py \
|
| 7 |
+
--input font_demo_cache.bin \
|
| 8 |
+
--output yuzumarker-font-labels.json
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
import sys
|
| 14 |
+
import types
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args():
|
| 19 |
+
parser = argparse.ArgumentParser(description="Convert font_demo_cache.bin to JSON")
|
| 20 |
+
parser.add_argument(
|
| 21 |
+
"-i",
|
| 22 |
+
"--input",
|
| 23 |
+
type=Path,
|
| 24 |
+
default=Path("font_demo_cache.bin"),
|
| 25 |
+
help="Input pickle file (font_demo_cache.bin from the original repo)",
|
| 26 |
+
)
|
| 27 |
+
parser.add_argument(
|
| 28 |
+
"-o",
|
| 29 |
+
"--output",
|
| 30 |
+
type=Path,
|
| 31 |
+
default=Path("yuzumarker-font-labels.json"),
|
| 32 |
+
help="Output JSON path (default: yuzumarker-font-labels.json)",
|
| 33 |
+
)
|
| 34 |
+
return parser.parse_args()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def main():
|
| 38 |
+
args = parse_args()
|
| 39 |
+
if not args.input.exists():
|
| 40 |
+
sys.exit(f"Input file not found: {args.input}")
|
| 41 |
+
|
| 42 |
+
# Stub module/classes so pickle can load without the original code
|
| 43 |
+
font_dataset_mod = types.ModuleType("font_dataset")
|
| 44 |
+
font_mod = types.ModuleType("font_dataset.font")
|
| 45 |
+
|
| 46 |
+
class DSFont:
|
| 47 |
+
def __init__(self, path=None, language=None):
|
| 48 |
+
self.path = path
|
| 49 |
+
self.language = language
|
| 50 |
+
|
| 51 |
+
font_mod.DSFont = DSFont
|
| 52 |
+
sys.modules["font_dataset"] = font_dataset_mod
|
| 53 |
+
sys.modules["font_dataset.font"] = font_mod
|
| 54 |
+
font_dataset_mod.font = font_mod
|
| 55 |
+
|
| 56 |
+
import pickle # noqa: E402
|
| 57 |
+
|
| 58 |
+
with open(args.input, "rb") as f:
|
| 59 |
+
data = pickle.load(f)
|
| 60 |
+
|
| 61 |
+
entries = []
|
| 62 |
+
for item in data:
|
| 63 |
+
path = getattr(item, "path", None)
|
| 64 |
+
language = getattr(item, "language", None)
|
| 65 |
+
if path is None:
|
| 66 |
+
continue
|
| 67 |
+
entries.append({"path": path, "language": language})
|
| 68 |
+
|
| 69 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
with open(args.output, "w", encoding="utf-8") as f:
|
| 71 |
+
json.dump(entries, f, ensure_ascii=False, indent=2)
|
| 72 |
+
|
| 73 |
+
print(f"Wrote {len(entries)} labels to {args.output}")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
main()
|