fffonion commited on
Commit
e3db6f9
·
1 Parent(s): 7dbde7d

feat: font detection using yuzumaker.FontDetection

Browse files
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(&regression)?;
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.detect(&document.image).await?;
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
- detector: ComicTextDetector,
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
- detector: ComicTextDetector::load(use_cpu).await?,
20
  ocr: MangaOcr::load(use_cpu).await?,
21
  lama: Lama::load(use_cpu).await?,
 
22
  })
23
  }
24
 
25
- pub async fn detect(
26
  &self,
27
  image: &SerializableDynamicImage,
28
  ) -> Result<(Vec<TextBlock>, SerializableDynamicImage)> {
29
- let (bboxes, segment) = self.detector.inference(image)?;
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()