Mayo commited on
feat: llama.cpp-based paddleocr-vl-1.5
Browse files- Cargo.lock +2 -0
- koharu-llm/Cargo.toml +5 -0
- koharu-llm/bin/paddleocr-vl.rs +308 -0
- koharu-llm/src/lib.rs +1 -0
- koharu-llm/src/model.rs +1 -2
- koharu-llm/src/paddleocr_vl.rs +579 -0
- koharu-llm/src/safe/model.rs +8 -2
- koharu-llm/src/sys/mod.rs +6 -5
- koharu-ml/Cargo.toml +1 -0
- koharu-ml/src/facade.rs +2 -2
- koharu-runtime/src/cuda.rs +4 -2
- koharu-runtime/src/lib.rs +1 -2
- koharu-runtime/src/llama.rs +7 -5
- koharu/src/app.rs +0 -1
Cargo.lock
CHANGED
|
@@ -3947,6 +3947,7 @@ dependencies = [
|
|
| 3947 |
"flate2",
|
| 3948 |
"futures",
|
| 3949 |
"hf-hub",
|
|
|
|
| 3950 |
"keyring",
|
| 3951 |
"koharu-http",
|
| 3952 |
"koharu-runtime",
|
|
@@ -3987,6 +3988,7 @@ dependencies = [
|
|
| 3987 |
"imageproc",
|
| 3988 |
"keyring",
|
| 3989 |
"koharu-http",
|
|
|
|
| 3990 |
"koharu-types",
|
| 3991 |
"libloading 0.8.9",
|
| 3992 |
"minijinja",
|
|
|
|
| 3947 |
"flate2",
|
| 3948 |
"futures",
|
| 3949 |
"hf-hub",
|
| 3950 |
+
"image",
|
| 3951 |
"keyring",
|
| 3952 |
"koharu-http",
|
| 3953 |
"koharu-runtime",
|
|
|
|
| 3988 |
"imageproc",
|
| 3989 |
"keyring",
|
| 3990 |
"koharu-http",
|
| 3991 |
+
"koharu-llm",
|
| 3992 |
"koharu-types",
|
| 3993 |
"libloading 0.8.9",
|
| 3994 |
"minijinja",
|
koharu-llm/Cargo.toml
CHANGED
|
@@ -22,6 +22,10 @@ path = "bin/llama.rs"
|
|
| 22 |
name = "llm"
|
| 23 |
path = "bin/llm.rs"
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
[dependencies]
|
| 26 |
anyhow = { workspace = true }
|
| 27 |
clap = { workspace = true, features = ["derive"] }
|
|
@@ -29,6 +33,7 @@ dirs = { workspace = true }
|
|
| 29 |
flate2 = { workspace = true }
|
| 30 |
futures = { workspace = true }
|
| 31 |
hf-hub = { workspace = true }
|
|
|
|
| 32 |
koharu-http = { workspace = true }
|
| 33 |
koharu-runtime = { workspace = true }
|
| 34 |
koharu-types = { workspace = true }
|
|
|
|
| 22 |
name = "llm"
|
| 23 |
path = "bin/llm.rs"
|
| 24 |
|
| 25 |
+
[[bin]]
|
| 26 |
+
name = "paddleocr-vl-llm"
|
| 27 |
+
path = "bin/paddleocr-vl.rs"
|
| 28 |
+
|
| 29 |
[dependencies]
|
| 30 |
anyhow = { workspace = true }
|
| 31 |
clap = { workspace = true, features = ["derive"] }
|
|
|
|
| 33 |
flate2 = { workspace = true }
|
| 34 |
futures = { workspace = true }
|
| 35 |
hf-hub = { workspace = true }
|
| 36 |
+
image = { workspace = true }
|
| 37 |
koharu-http = { workspace = true }
|
| 38 |
koharu-runtime = { workspace = true }
|
| 39 |
koharu-types = { workspace = true }
|
koharu-llm/bin/paddleocr-vl.rs
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::{Path, PathBuf};
|
| 2 |
+
use std::time::Instant;
|
| 3 |
+
|
| 4 |
+
use anyhow::{Context, Result, bail};
|
| 5 |
+
use clap::{Parser, ValueEnum};
|
| 6 |
+
use koharu_llm::paddleocr_vl::{PaddleOcrVl, PaddleOcrVlOutput, PaddleOcrVlTask};
|
| 7 |
+
|
| 8 |
+
#[derive(Debug, Clone, Copy, ValueEnum)]
|
| 9 |
+
enum TaskArg {
|
| 10 |
+
Ocr,
|
| 11 |
+
Table,
|
| 12 |
+
Formula,
|
| 13 |
+
Chart,
|
| 14 |
+
Spotting,
|
| 15 |
+
Seal,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
impl From<TaskArg> for PaddleOcrVlTask {
|
| 19 |
+
fn from(value: TaskArg) -> Self {
|
| 20 |
+
match value {
|
| 21 |
+
TaskArg::Ocr => Self::Ocr,
|
| 22 |
+
TaskArg::Table => Self::Table,
|
| 23 |
+
TaskArg::Formula => Self::Formula,
|
| 24 |
+
TaskArg::Chart => Self::Chart,
|
| 25 |
+
TaskArg::Spotting => Self::Spotting,
|
| 26 |
+
TaskArg::Seal => Self::Seal,
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[derive(Parser)]
|
| 32 |
+
struct Cli {
|
| 33 |
+
#[arg(long, value_name = "FILE", num_args = 1..)]
|
| 34 |
+
input: Vec<PathBuf>,
|
| 35 |
+
|
| 36 |
+
#[arg(long, value_name = "DIR")]
|
| 37 |
+
model_dir: Option<PathBuf>,
|
| 38 |
+
|
| 39 |
+
#[arg(long, value_enum, default_value = "ocr")]
|
| 40 |
+
task: TaskArg,
|
| 41 |
+
|
| 42 |
+
#[arg(long, default_value_t = 128)]
|
| 43 |
+
max_new_tokens: usize,
|
| 44 |
+
|
| 45 |
+
#[arg(long, value_name = "FILE")]
|
| 46 |
+
json_output: Option<PathBuf>,
|
| 47 |
+
|
| 48 |
+
#[arg(long, default_value_t = false)]
|
| 49 |
+
cpu: bool,
|
| 50 |
+
|
| 51 |
+
#[arg(long, value_name = "DIR")]
|
| 52 |
+
dataset_root: Option<PathBuf>,
|
| 53 |
+
|
| 54 |
+
#[arg(long, value_name = "FILE")]
|
| 55 |
+
ground_truth: Option<PathBuf>,
|
| 56 |
+
|
| 57 |
+
#[arg(long)]
|
| 58 |
+
limit: Option<usize>,
|
| 59 |
+
|
| 60 |
+
#[arg(long, default_value_t = 0)]
|
| 61 |
+
offset: usize,
|
| 62 |
+
|
| 63 |
+
#[arg(long, default_value_t = 10)]
|
| 64 |
+
sample_errors: usize,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
#[derive(serde::Serialize)]
|
| 68 |
+
#[serde(rename_all = "camelCase")]
|
| 69 |
+
struct OutputEnvelope {
|
| 70 |
+
outputs: Vec<PaddleOcrVlOutput>,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
#[derive(Debug, Clone)]
|
| 74 |
+
struct GroundTruthRecord {
|
| 75 |
+
relative_path: PathBuf,
|
| 76 |
+
text: String,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
#[derive(Debug, serde::Serialize)]
|
| 80 |
+
#[serde(rename_all = "camelCase")]
|
| 81 |
+
struct EvaluationError {
|
| 82 |
+
image: String,
|
| 83 |
+
expected: String,
|
| 84 |
+
predicted: String,
|
| 85 |
+
char_distance: usize,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
#[derive(Debug, serde::Serialize)]
|
| 89 |
+
#[serde(rename_all = "camelCase")]
|
| 90 |
+
struct EvaluationSummary {
|
| 91 |
+
dataset_root: String,
|
| 92 |
+
ground_truth: String,
|
| 93 |
+
evaluated_examples: usize,
|
| 94 |
+
exact_matches: usize,
|
| 95 |
+
exact_match_rate: f64,
|
| 96 |
+
total_reference_chars: usize,
|
| 97 |
+
total_char_distance: usize,
|
| 98 |
+
cer: f64,
|
| 99 |
+
elapsed_seconds: f64,
|
| 100 |
+
sample_errors: Vec<EvaluationError>,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
fn init_tracing() {
|
| 104 |
+
tracing_subscriber::fmt()
|
| 105 |
+
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
|
| 106 |
+
.init();
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
#[tokio::main]
|
| 110 |
+
async fn main() -> Result<()> {
|
| 111 |
+
init_tracing();
|
| 112 |
+
koharu_runtime::initialize()
|
| 113 |
+
.await
|
| 114 |
+
.context("failed to initialize runtime libraries")?;
|
| 115 |
+
|
| 116 |
+
let cli = Cli::parse();
|
| 117 |
+
let task: PaddleOcrVlTask = cli.task.into();
|
| 118 |
+
|
| 119 |
+
if cli.input.is_empty() && cli.dataset_root.is_none() {
|
| 120 |
+
bail!("provide either --input or --dataset-root");
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
let mut model = if let Some(model_dir) = &cli.model_dir {
|
| 124 |
+
PaddleOcrVl::load_from_dir(model_dir, cli.cpu)?
|
| 125 |
+
} else {
|
| 126 |
+
PaddleOcrVl::load(cli.cpu).await?
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
if let Some(dataset_root) = &cli.dataset_root {
|
| 130 |
+
let summary = evaluate_dataset(&mut model, dataset_root, &cli, task)?;
|
| 131 |
+
println!("{}", serde_json::to_string_pretty(&summary)?);
|
| 132 |
+
if let Some(path) = &cli.json_output {
|
| 133 |
+
std::fs::write(path, serde_json::to_string_pretty(&summary)?)?;
|
| 134 |
+
}
|
| 135 |
+
return Ok(());
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
let images = cli
|
| 139 |
+
.input
|
| 140 |
+
.iter()
|
| 141 |
+
.map(image::open)
|
| 142 |
+
.collect::<Result<Vec<_>, _>>()?;
|
| 143 |
+
let outputs = model.inference_images(&images, task, cli.max_new_tokens)?;
|
| 144 |
+
for (input, output) in cli.input.iter().zip(&outputs) {
|
| 145 |
+
if cli.input.len() > 1 {
|
| 146 |
+
println!("== {} ==", input.display());
|
| 147 |
+
}
|
| 148 |
+
println!("{}", output.text);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if let Some(path) = &cli.json_output {
|
| 152 |
+
std::fs::write(
|
| 153 |
+
path,
|
| 154 |
+
serde_json::to_string_pretty(&OutputEnvelope { outputs })?,
|
| 155 |
+
)?;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
Ok(())
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
fn evaluate_dataset(
|
| 162 |
+
model: &mut PaddleOcrVl,
|
| 163 |
+
dataset_root: &Path,
|
| 164 |
+
cli: &Cli,
|
| 165 |
+
task: PaddleOcrVlTask,
|
| 166 |
+
) -> Result<EvaluationSummary> {
|
| 167 |
+
let started = Instant::now();
|
| 168 |
+
let split_name = dataset_root
|
| 169 |
+
.file_name()
|
| 170 |
+
.and_then(|name| name.to_str())
|
| 171 |
+
.context("dataset root must end with a split directory like `train`")?;
|
| 172 |
+
let ground_truth = cli
|
| 173 |
+
.ground_truth
|
| 174 |
+
.clone()
|
| 175 |
+
.unwrap_or_else(|| dataset_root.with_file_name(format!("rec_gt_{split_name}.txt")));
|
| 176 |
+
let dataset_base = dataset_root
|
| 177 |
+
.parent()
|
| 178 |
+
.and_then(Path::parent)
|
| 179 |
+
.context("dataset root must look like `<base>/rec/<split>`")?;
|
| 180 |
+
|
| 181 |
+
let records = read_ground_truth(&ground_truth)?;
|
| 182 |
+
let selected = records
|
| 183 |
+
.into_iter()
|
| 184 |
+
.skip(cli.offset)
|
| 185 |
+
.take(cli.limit.unwrap_or(usize::MAX))
|
| 186 |
+
.collect::<Vec<_>>();
|
| 187 |
+
if selected.is_empty() {
|
| 188 |
+
bail!("no evaluation records selected");
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
let mut exact_matches = 0usize;
|
| 192 |
+
let mut total_reference_chars = 0usize;
|
| 193 |
+
let mut total_char_distance = 0usize;
|
| 194 |
+
let mut sample_errors = Vec::new();
|
| 195 |
+
|
| 196 |
+
for (index, record) in selected.iter().enumerate() {
|
| 197 |
+
let image_path = dataset_base.join(&record.relative_path);
|
| 198 |
+
let image = image::open(&image_path)
|
| 199 |
+
.with_context(|| format!("failed to open `{}`", image_path.display()))?;
|
| 200 |
+
let output = model.inference_with_max_new_tokens(&image, task, cli.max_new_tokens)?;
|
| 201 |
+
let predicted = output.text;
|
| 202 |
+
let expected = record.text.as_str();
|
| 203 |
+
let char_distance = levenshtein_chars(expected, &predicted);
|
| 204 |
+
let reference_chars = expected.chars().count();
|
| 205 |
+
|
| 206 |
+
total_reference_chars += reference_chars;
|
| 207 |
+
total_char_distance += char_distance;
|
| 208 |
+
if predicted == expected {
|
| 209 |
+
exact_matches += 1;
|
| 210 |
+
} else if sample_errors.len() < cli.sample_errors {
|
| 211 |
+
sample_errors.push(EvaluationError {
|
| 212 |
+
image: image_path.display().to_string(),
|
| 213 |
+
expected: expected.to_string(),
|
| 214 |
+
predicted,
|
| 215 |
+
char_distance,
|
| 216 |
+
});
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
if (index + 1) % 50 == 0 || index + 1 == selected.len() {
|
| 220 |
+
let cer = ratio(total_char_distance, total_reference_chars);
|
| 221 |
+
let exact_match_rate = ratio(exact_matches, index + 1);
|
| 222 |
+
eprintln!(
|
| 223 |
+
"evaluated {}/{} exact={:.4} cer={:.4}",
|
| 224 |
+
index + 1,
|
| 225 |
+
selected.len(),
|
| 226 |
+
exact_match_rate,
|
| 227 |
+
cer
|
| 228 |
+
);
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
Ok(EvaluationSummary {
|
| 233 |
+
dataset_root: dataset_root.display().to_string(),
|
| 234 |
+
ground_truth: ground_truth.display().to_string(),
|
| 235 |
+
evaluated_examples: selected.len(),
|
| 236 |
+
exact_matches,
|
| 237 |
+
exact_match_rate: ratio(exact_matches, selected.len()),
|
| 238 |
+
total_reference_chars,
|
| 239 |
+
total_char_distance,
|
| 240 |
+
cer: ratio(total_char_distance, total_reference_chars),
|
| 241 |
+
elapsed_seconds: started.elapsed().as_secs_f64(),
|
| 242 |
+
sample_errors,
|
| 243 |
+
})
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
fn read_ground_truth(path: &Path) -> Result<Vec<GroundTruthRecord>> {
|
| 247 |
+
let bytes = std::fs::read(path)
|
| 248 |
+
.with_context(|| format!("failed to read ground-truth file `{}`", path.display()))?;
|
| 249 |
+
let (text, _, had_errors) = encoding_rs::UTF_8.decode(&bytes);
|
| 250 |
+
if had_errors {
|
| 251 |
+
bail!("ground-truth file `{}` is not valid UTF-8", path.display());
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
let mut records = Vec::new();
|
| 255 |
+
for line in text.lines().filter(|line| !line.trim().is_empty()) {
|
| 256 |
+
if let Some((relative_path, value)) = line.split_once('\t') {
|
| 257 |
+
records.push(GroundTruthRecord {
|
| 258 |
+
relative_path: PathBuf::from(relative_path),
|
| 259 |
+
text: value.to_string(),
|
| 260 |
+
});
|
| 261 |
+
continue;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
let previous = records.last_mut().with_context(|| {
|
| 265 |
+
format!("invalid ground-truth line without a preceding record: `{line}`")
|
| 266 |
+
})?;
|
| 267 |
+
previous.text.push('\n');
|
| 268 |
+
previous.text.push_str(line);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
Ok(records)
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
fn levenshtein_chars(expected: &str, predicted: &str) -> usize {
|
| 275 |
+
let expected = expected.chars().collect::<Vec<_>>();
|
| 276 |
+
let predicted = predicted.chars().collect::<Vec<_>>();
|
| 277 |
+
|
| 278 |
+
if expected.is_empty() {
|
| 279 |
+
return predicted.len();
|
| 280 |
+
}
|
| 281 |
+
if predicted.is_empty() {
|
| 282 |
+
return expected.len();
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
let mut prev = (0..=predicted.len()).collect::<Vec<_>>();
|
| 286 |
+
let mut curr = vec![0usize; predicted.len() + 1];
|
| 287 |
+
|
| 288 |
+
for (i, expected_char) in expected.iter().enumerate() {
|
| 289 |
+
curr[0] = i + 1;
|
| 290 |
+
for (j, predicted_char) in predicted.iter().enumerate() {
|
| 291 |
+
let substitution_cost = usize::from(expected_char != predicted_char);
|
| 292 |
+
curr[j + 1] = (prev[j + 1] + 1)
|
| 293 |
+
.min(curr[j] + 1)
|
| 294 |
+
.min(prev[j] + substitution_cost);
|
| 295 |
+
}
|
| 296 |
+
std::mem::swap(&mut prev, &mut curr);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
prev[predicted.len()]
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
fn ratio(numerator: usize, denominator: usize) -> f64 {
|
| 303 |
+
if denominator == 0 {
|
| 304 |
+
0.0
|
| 305 |
+
} else {
|
| 306 |
+
numerator as f64 / denominator as f64
|
| 307 |
+
}
|
| 308 |
+
}
|
koharu-llm/src/lib.rs
CHANGED
|
@@ -2,6 +2,7 @@ pub mod api;
|
|
| 2 |
pub mod facade;
|
| 3 |
pub mod language;
|
| 4 |
mod model;
|
|
|
|
| 5 |
pub mod prompt;
|
| 6 |
pub mod providers;
|
| 7 |
pub mod safe;
|
|
|
|
| 2 |
pub mod facade;
|
| 3 |
pub mod language;
|
| 4 |
mod model;
|
| 5 |
+
pub mod paddleocr_vl;
|
| 6 |
pub mod prompt;
|
| 7 |
pub mod providers;
|
| 8 |
pub mod safe;
|
koharu-llm/src/model.rs
CHANGED
|
@@ -67,8 +67,7 @@ impl Llm {
|
|
| 67 |
}
|
| 68 |
|
| 69 |
fn load_from_path(id: ModelId, cpu: bool, model_path: PathBuf) -> Result<Self> {
|
| 70 |
-
crate::sys::initialize()
|
| 71 |
-
.context("failed to initialize llama.cpp runtime bindings")?;
|
| 72 |
|
| 73 |
LOGGING_READY.call_once(|| {
|
| 74 |
send_logs_to_tracing(LogOptions::default().with_logs_enabled(true));
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
fn load_from_path(id: ModelId, cpu: bool, model_path: PathBuf) -> Result<Self> {
|
| 70 |
+
crate::sys::initialize().context("failed to initialize llama.cpp runtime bindings")?;
|
|
|
|
| 71 |
|
| 72 |
LOGGING_READY.call_once(|| {
|
| 73 |
send_logs_to_tracing(LogOptions::default().with_logs_enabled(true));
|
koharu-llm/src/paddleocr_vl.rs
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::ffi::CString;
|
| 2 |
+
use std::num::NonZeroU32;
|
| 3 |
+
use std::path::{Path, PathBuf};
|
| 4 |
+
use std::sync::Once;
|
| 5 |
+
use std::time::Instant;
|
| 6 |
+
|
| 7 |
+
use anyhow::{Context, Result, bail};
|
| 8 |
+
use image::DynamicImage;
|
| 9 |
+
use minijinja::{Environment, context};
|
| 10 |
+
use serde::{Deserialize, Serialize};
|
| 11 |
+
|
| 12 |
+
use crate::safe::context::params::{LlamaAttentionType, LlamaContextParams};
|
| 13 |
+
use crate::safe::llama_backend::LlamaBackend;
|
| 14 |
+
use crate::safe::llama_batch::LlamaBatch;
|
| 15 |
+
use crate::safe::model::LlamaModel;
|
| 16 |
+
use crate::safe::model::params::LlamaModelParams;
|
| 17 |
+
use crate::safe::mtmd::{
|
| 18 |
+
MtmdBitmap, MtmdContext, MtmdContextParams, MtmdInputChunkType, MtmdInputText,
|
| 19 |
+
};
|
| 20 |
+
use crate::safe::sampling::LlamaSampler;
|
| 21 |
+
use crate::safe::token::LlamaToken;
|
| 22 |
+
use crate::safe::{LogOptions, send_logs_to_tracing};
|
| 23 |
+
|
| 24 |
+
const HF_REPO: &str = "PaddlePaddle/PaddleOCR-VL-1.5-GGUF";
|
| 25 |
+
const MODEL_FILENAME: &str = "PaddleOCR-VL-1.5.gguf";
|
| 26 |
+
const MMPROJ_FILENAME: &str = "PaddleOCR-VL-1.5-mmproj.gguf";
|
| 27 |
+
const DEFAULT_MEDIA_MARKER: &str = "<__media__>";
|
| 28 |
+
const DEFAULT_GPU_LAYERS: u32 = 1000;
|
| 29 |
+
const DEFAULT_MAX_NEW_TOKENS: usize = 128;
|
| 30 |
+
const MAX_UBATCH: u32 = 512;
|
| 31 |
+
|
| 32 |
+
static LOGGING_READY: Once = Once::new();
|
| 33 |
+
|
| 34 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
| 35 |
+
#[serde(rename_all = "snake_case")]
|
| 36 |
+
pub enum PaddleOcrVlTask {
|
| 37 |
+
Ocr,
|
| 38 |
+
Table,
|
| 39 |
+
Formula,
|
| 40 |
+
Chart,
|
| 41 |
+
Spotting,
|
| 42 |
+
Seal,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
impl PaddleOcrVlTask {
|
| 46 |
+
fn prompt(self) -> &'static str {
|
| 47 |
+
match self {
|
| 48 |
+
Self::Ocr => "OCR:",
|
| 49 |
+
Self::Table => "Table Recognition:",
|
| 50 |
+
Self::Formula => "Formula Recognition:",
|
| 51 |
+
Self::Chart => "Chart Recognition:",
|
| 52 |
+
Self::Spotting => "Spotting:",
|
| 53 |
+
Self::Seal => "Seal Recognition:",
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 59 |
+
#[serde(rename_all = "camelCase")]
|
| 60 |
+
pub struct PaddleOcrVlOutput {
|
| 61 |
+
pub task: PaddleOcrVlTask,
|
| 62 |
+
pub text: String,
|
| 63 |
+
pub token_ids: Vec<u32>,
|
| 64 |
+
pub original_width: u32,
|
| 65 |
+
pub original_height: u32,
|
| 66 |
+
pub processed_width: u32,
|
| 67 |
+
pub processed_height: u32,
|
| 68 |
+
pub grid_thw: [u32; 3],
|
| 69 |
+
pub num_image_tokens: usize,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
struct ModelFiles {
|
| 73 |
+
model: PathBuf,
|
| 74 |
+
mmproj: PathBuf,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
struct RenderedPrompt {
|
| 78 |
+
text: String,
|
| 79 |
+
add_special: bool,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
#[derive(Debug, Clone, Serialize)]
|
| 83 |
+
struct PromptMessage {
|
| 84 |
+
role: &'static str,
|
| 85 |
+
content: String,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
pub struct PaddleOcrVl {
|
| 89 |
+
backend: LlamaBackend,
|
| 90 |
+
model: LlamaModel,
|
| 91 |
+
chat_template: String,
|
| 92 |
+
bos_token: String,
|
| 93 |
+
eos_token_text: String,
|
| 94 |
+
mtmd: MtmdContext,
|
| 95 |
+
eos_token: LlamaToken,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
impl PaddleOcrVl {
|
| 99 |
+
pub async fn load(cpu: bool) -> Result<Self> {
|
| 100 |
+
let files = download_model_files().await?;
|
| 101 |
+
tokio::task::spawn_blocking(move || Self::load_from_files(files, cpu))
|
| 102 |
+
.await
|
| 103 |
+
.context("failed to join PaddleOCR-VL loading task")?
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
pub fn load_from_dir(dir: impl AsRef<Path>, cpu: bool) -> Result<Self> {
|
| 107 |
+
let files = resolve_local_model_files(dir.as_ref())?;
|
| 108 |
+
Self::load_from_files(files, cpu)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
fn load_from_files(files: ModelFiles, cpu: bool) -> Result<Self> {
|
| 112 |
+
crate::sys::initialize().context("failed to initialize llama.cpp runtime bindings")?;
|
| 113 |
+
|
| 114 |
+
LOGGING_READY.call_once(|| {
|
| 115 |
+
send_logs_to_tracing(LogOptions::default().with_logs_enabled(true));
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
let backend = LlamaBackend::init().context("unable to initialize llama.cpp backend")?;
|
| 119 |
+
let model_params = model_params(cpu, &backend);
|
| 120 |
+
let model = LlamaModel::load_from_file(&backend, &files.model, &model_params)
|
| 121 |
+
.with_context(|| format!("unable to load model from `{}`", files.model.display()))?;
|
| 122 |
+
let eos_token = model.token_eos();
|
| 123 |
+
let chat_template = model
|
| 124 |
+
.meta_val_str("tokenizer.ggml.chat_template")
|
| 125 |
+
.or_else(|_| model.meta_val_str("tokenizer.chat_template"))
|
| 126 |
+
.context("missing embedded PaddleOCR-VL chat template")?;
|
| 127 |
+
let bos_token = token_text(&model, model.token_bos());
|
| 128 |
+
let eos_token_text = token_text(&model, eos_token);
|
| 129 |
+
let mmproj_path = files
|
| 130 |
+
.mmproj
|
| 131 |
+
.to_str()
|
| 132 |
+
.with_context(|| format!("invalid mmproj path `{}`", files.mmproj.display()))?;
|
| 133 |
+
let mtmd = MtmdContext::init_from_file(
|
| 134 |
+
mmproj_path,
|
| 135 |
+
&model,
|
| 136 |
+
&MtmdContextParams {
|
| 137 |
+
use_gpu: !cpu && backend.supports_gpu_offload(),
|
| 138 |
+
print_timings: true,
|
| 139 |
+
n_threads: num_cpus::get().try_into().unwrap_or(i32::MAX),
|
| 140 |
+
media_marker: CString::new(DEFAULT_MEDIA_MARKER)
|
| 141 |
+
.expect("default media marker contains no null bytes"),
|
| 142 |
+
},
|
| 143 |
+
)
|
| 144 |
+
.context("unable to initialize multimodal projector")?;
|
| 145 |
+
|
| 146 |
+
if !mtmd.support_vision() {
|
| 147 |
+
bail!("loaded PaddleOCR-VL projector does not advertise vision support");
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
tracing::info!(
|
| 151 |
+
has_encoder = unsafe { crate::sys::llama_model_has_encoder(model.model.as_ptr()) },
|
| 152 |
+
has_decoder = unsafe { crate::sys::llama_model_has_decoder(model.model.as_ptr()) },
|
| 153 |
+
decoder_start_token = model.decode_start_token().0,
|
| 154 |
+
"loaded PaddleOCR-VL model capabilities"
|
| 155 |
+
);
|
| 156 |
+
|
| 157 |
+
Ok(Self {
|
| 158 |
+
backend,
|
| 159 |
+
model,
|
| 160 |
+
chat_template,
|
| 161 |
+
bos_token,
|
| 162 |
+
eos_token_text,
|
| 163 |
+
mtmd,
|
| 164 |
+
eos_token,
|
| 165 |
+
})
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
pub fn inference(
|
| 169 |
+
&mut self,
|
| 170 |
+
image: &DynamicImage,
|
| 171 |
+
task: PaddleOcrVlTask,
|
| 172 |
+
) -> Result<PaddleOcrVlOutput> {
|
| 173 |
+
self.inference_with_max_new_tokens(image, task, DEFAULT_MAX_NEW_TOKENS)
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
pub fn inference_with_max_new_tokens(
|
| 177 |
+
&mut self,
|
| 178 |
+
image: &DynamicImage,
|
| 179 |
+
task: PaddleOcrVlTask,
|
| 180 |
+
max_new_tokens: usize,
|
| 181 |
+
) -> Result<PaddleOcrVlOutput> {
|
| 182 |
+
let started = Instant::now();
|
| 183 |
+
let original_width = image.width();
|
| 184 |
+
let original_height = image.height();
|
| 185 |
+
let bitmap = bitmap_from_image(image)?;
|
| 186 |
+
let prompt = self.render_prompt(task)?;
|
| 187 |
+
let chunks = self
|
| 188 |
+
.mtmd
|
| 189 |
+
.tokenize(
|
| 190 |
+
MtmdInputText {
|
| 191 |
+
text: prompt.text,
|
| 192 |
+
add_special: prompt.add_special,
|
| 193 |
+
parse_special: true,
|
| 194 |
+
},
|
| 195 |
+
&[&bitmap],
|
| 196 |
+
)
|
| 197 |
+
.context("failed to tokenize multimodal OCR input")?;
|
| 198 |
+
if chunks.is_empty() {
|
| 199 |
+
bail!("multimodal tokenization produced no chunks");
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
let batch_tokens = max_chunk_tokens(&chunks).max(1);
|
| 203 |
+
let prompt_positions =
|
| 204 |
+
usize::try_from(chunks.total_positions()).context("prompt positions overflow usize")?;
|
| 205 |
+
let prompt_total_tokens = chunks.total_tokens();
|
| 206 |
+
let num_image_tokens = image_chunk_tokens(&chunks);
|
| 207 |
+
let ctx_params = context_params(
|
| 208 |
+
&self.mtmd,
|
| 209 |
+
prompt_positions,
|
| 210 |
+
prompt_total_tokens,
|
| 211 |
+
batch_tokens,
|
| 212 |
+
max_new_tokens,
|
| 213 |
+
)?;
|
| 214 |
+
let mut ctx = self
|
| 215 |
+
.model
|
| 216 |
+
.new_context(&self.backend, ctx_params)
|
| 217 |
+
.context("unable to create PaddleOCR-VL llama.cpp context")?;
|
| 218 |
+
let n_batch = i32::try_from(batch_tokens).context("batch size exceeds i32")?;
|
| 219 |
+
|
| 220 |
+
let prompt_started = Instant::now();
|
| 221 |
+
let n_past = chunks
|
| 222 |
+
.eval_chunks(&self.mtmd, &ctx, 0, 0, n_batch, true)
|
| 223 |
+
.context("failed to evaluate multimodal prompt")?;
|
| 224 |
+
let prompt_elapsed = prompt_started.elapsed();
|
| 225 |
+
|
| 226 |
+
let generation_started = Instant::now();
|
| 227 |
+
let mut sampler = LlamaSampler::greedy();
|
| 228 |
+
let mut decoder = encoding_rs::UTF_8.new_decoder();
|
| 229 |
+
let mut token_ids = Vec::new();
|
| 230 |
+
let mut text = String::new();
|
| 231 |
+
|
| 232 |
+
if max_new_tokens > 0 {
|
| 233 |
+
let decoder_start = self.decoder_start_token();
|
| 234 |
+
let (mut next_token, mut position) = if let Some(decoder_start) = decoder_start {
|
| 235 |
+
let mut batch = LlamaBatch::new(1, 1);
|
| 236 |
+
batch
|
| 237 |
+
.add(decoder_start, 0, &[0], true)
|
| 238 |
+
.context("failed to build decoder start batch")?;
|
| 239 |
+
ctx.decode(&mut batch)
|
| 240 |
+
.context("failed to decode PaddleOCR-VL decoder start token")?;
|
| 241 |
+
(sampler.sample(&ctx, -1), 1)
|
| 242 |
+
} else {
|
| 243 |
+
(sampler.sample(&ctx, -1), n_past)
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
while token_ids.len() < max_new_tokens && !self.should_stop(next_token) {
|
| 247 |
+
sampler.accept(next_token);
|
| 248 |
+
token_ids
|
| 249 |
+
.push(u32::try_from(next_token.0).context("generated token id was negative")?);
|
| 250 |
+
text.push_str(&decode_token(&self.model, next_token, &mut decoder)?);
|
| 251 |
+
|
| 252 |
+
if token_ids.len() >= max_new_tokens {
|
| 253 |
+
break;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
let mut batch = LlamaBatch::new(1, 1);
|
| 257 |
+
batch
|
| 258 |
+
.add(next_token, position, &[0], true)
|
| 259 |
+
.context("failed to build generation batch")?;
|
| 260 |
+
ctx.decode(&mut batch)
|
| 261 |
+
.context("failed to decode generated OCR token")?;
|
| 262 |
+
position += 1;
|
| 263 |
+
next_token = sampler.sample(&ctx, -1);
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
tracing::info!(
|
| 268 |
+
task = ?task,
|
| 269 |
+
original_width,
|
| 270 |
+
original_height,
|
| 271 |
+
prompt_total_tokens,
|
| 272 |
+
prompt_positions,
|
| 273 |
+
batch_tokens,
|
| 274 |
+
num_image_tokens,
|
| 275 |
+
prompt_ms = prompt_elapsed.as_millis(),
|
| 276 |
+
generation_ms = generation_started.elapsed().as_millis(),
|
| 277 |
+
total_ms = started.elapsed().as_millis(),
|
| 278 |
+
"paddleocr-vl inference timings"
|
| 279 |
+
);
|
| 280 |
+
|
| 281 |
+
Ok(PaddleOcrVlOutput {
|
| 282 |
+
task,
|
| 283 |
+
text: text.trim().to_string(),
|
| 284 |
+
token_ids,
|
| 285 |
+
original_width,
|
| 286 |
+
original_height,
|
| 287 |
+
processed_width: original_width,
|
| 288 |
+
processed_height: original_height,
|
| 289 |
+
grid_thw: [0, 0, 0],
|
| 290 |
+
num_image_tokens,
|
| 291 |
+
})
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
pub fn inference_images(
|
| 295 |
+
&mut self,
|
| 296 |
+
images: &[DynamicImage],
|
| 297 |
+
task: PaddleOcrVlTask,
|
| 298 |
+
max_new_tokens: usize,
|
| 299 |
+
) -> Result<Vec<PaddleOcrVlOutput>> {
|
| 300 |
+
let started = Instant::now();
|
| 301 |
+
let mut outputs = Vec::with_capacity(images.len());
|
| 302 |
+
for image in images {
|
| 303 |
+
outputs.push(self.inference_with_max_new_tokens(image, task, max_new_tokens)?);
|
| 304 |
+
}
|
| 305 |
+
tracing::info!(
|
| 306 |
+
images = images.len(),
|
| 307 |
+
total_ms = started.elapsed().as_millis(),
|
| 308 |
+
"paddleocr-vl batch timings"
|
| 309 |
+
);
|
| 310 |
+
Ok(outputs)
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
fn should_stop(&self, token: LlamaToken) -> bool {
|
| 314 |
+
token == self.eos_token || self.model.is_eog_token(token)
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
fn render_prompt(&self, task: PaddleOcrVlTask) -> Result<RenderedPrompt> {
|
| 318 |
+
let text = render_chat_prompt(
|
| 319 |
+
&self.chat_template,
|
| 320 |
+
&self.bos_token,
|
| 321 |
+
&self.eos_token_text,
|
| 322 |
+
task,
|
| 323 |
+
)
|
| 324 |
+
.context("failed to render PaddleOCR-VL prompt from embedded chat template")?;
|
| 325 |
+
Ok(RenderedPrompt {
|
| 326 |
+
text,
|
| 327 |
+
add_special: false,
|
| 328 |
+
})
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
fn decoder_start_token(&self) -> Option<LlamaToken> {
|
| 332 |
+
let has_encoder = unsafe { crate::sys::llama_model_has_encoder(self.model.model.as_ptr()) };
|
| 333 |
+
if !has_encoder {
|
| 334 |
+
return None;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
let decoder_start = self.model.decode_start_token();
|
| 338 |
+
if decoder_start.0 >= 0 {
|
| 339 |
+
Some(decoder_start)
|
| 340 |
+
} else {
|
| 341 |
+
Some(self.model.token_bos())
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
pub async fn prefetch() -> Result<()> {
|
| 347 |
+
download_model_files().await?;
|
| 348 |
+
Ok(())
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
async fn download_model_files() -> Result<ModelFiles> {
|
| 352 |
+
let (model, mmproj) = tokio::try_join!(
|
| 353 |
+
koharu_http::download::model(HF_REPO, MODEL_FILENAME),
|
| 354 |
+
koharu_http::download::model(HF_REPO, MMPROJ_FILENAME),
|
| 355 |
+
)?;
|
| 356 |
+
|
| 357 |
+
Ok(ModelFiles { model, mmproj })
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
fn resolve_local_model_files(dir: &Path) -> Result<ModelFiles> {
|
| 361 |
+
let preferred_model = dir.join(MODEL_FILENAME);
|
| 362 |
+
let preferred_mmproj = dir.join(MMPROJ_FILENAME);
|
| 363 |
+
if preferred_model.exists() && preferred_mmproj.exists() {
|
| 364 |
+
return Ok(ModelFiles {
|
| 365 |
+
model: preferred_model,
|
| 366 |
+
mmproj: preferred_mmproj,
|
| 367 |
+
});
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
let mut model = None;
|
| 371 |
+
let mut mmproj = None;
|
| 372 |
+
for entry in std::fs::read_dir(dir)
|
| 373 |
+
.with_context(|| format!("unable to read PaddleOCR-VL model dir `{}`", dir.display()))?
|
| 374 |
+
{
|
| 375 |
+
let entry = entry?;
|
| 376 |
+
let path = entry.path();
|
| 377 |
+
if !path
|
| 378 |
+
.extension()
|
| 379 |
+
.and_then(|ext| ext.to_str())
|
| 380 |
+
.is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
|
| 381 |
+
{
|
| 382 |
+
continue;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
let filename = path
|
| 386 |
+
.file_name()
|
| 387 |
+
.and_then(|name| name.to_str())
|
| 388 |
+
.unwrap_or("");
|
| 389 |
+
if filename.contains("mmproj") {
|
| 390 |
+
if mmproj.is_none() {
|
| 391 |
+
mmproj = Some(path);
|
| 392 |
+
}
|
| 393 |
+
} else if model.is_none() {
|
| 394 |
+
model = Some(path);
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
Ok(ModelFiles {
|
| 399 |
+
model: model.with_context(|| {
|
| 400 |
+
format!(
|
| 401 |
+
"missing `{MODEL_FILENAME}` (or any non-mmproj GGUF) in `{}`",
|
| 402 |
+
dir.display()
|
| 403 |
+
)
|
| 404 |
+
})?,
|
| 405 |
+
mmproj: mmproj.with_context(|| {
|
| 406 |
+
format!(
|
| 407 |
+
"missing `{MMPROJ_FILENAME}` (or any mmproj GGUF) in `{}`",
|
| 408 |
+
dir.display()
|
| 409 |
+
)
|
| 410 |
+
})?,
|
| 411 |
+
})
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
fn model_params(cpu: bool, backend: &LlamaBackend) -> LlamaModelParams {
|
| 415 |
+
if !cpu && backend.supports_gpu_offload() {
|
| 416 |
+
LlamaModelParams::default().with_n_gpu_layers(DEFAULT_GPU_LAYERS)
|
| 417 |
+
} else {
|
| 418 |
+
LlamaModelParams::default()
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
fn context_params(
|
| 423 |
+
mtmd: &MtmdContext,
|
| 424 |
+
prompt_positions: usize,
|
| 425 |
+
prompt_total_tokens: usize,
|
| 426 |
+
batch_tokens: usize,
|
| 427 |
+
max_tokens: usize,
|
| 428 |
+
) -> Result<LlamaContextParams> {
|
| 429 |
+
let required_ctx = prompt_positions
|
| 430 |
+
.saturating_add(max_tokens)
|
| 431 |
+
.saturating_add(1)
|
| 432 |
+
.max(
|
| 433 |
+
prompt_total_tokens
|
| 434 |
+
.saturating_add(max_tokens)
|
| 435 |
+
.saturating_add(1),
|
| 436 |
+
)
|
| 437 |
+
.max(batch_tokens.saturating_add(1));
|
| 438 |
+
let n_ctx = NonZeroU32::new(u32::try_from(required_ctx).context("context size exceeds u32")?)
|
| 439 |
+
.expect("required context size is non-zero");
|
| 440 |
+
let n_batch = u32::try_from(batch_tokens.max(1)).context("batch size exceeds u32")?;
|
| 441 |
+
let n_ubatch = if mtmd.decode_use_non_causal() {
|
| 442 |
+
n_batch
|
| 443 |
+
} else {
|
| 444 |
+
n_batch.min(MAX_UBATCH)
|
| 445 |
+
};
|
| 446 |
+
|
| 447 |
+
let mut params = LlamaContextParams::default()
|
| 448 |
+
.with_n_ctx(Some(n_ctx))
|
| 449 |
+
.with_n_batch(n_batch)
|
| 450 |
+
.with_n_ubatch(n_ubatch);
|
| 451 |
+
if mtmd.decode_use_non_causal() {
|
| 452 |
+
params = params.with_attention_type(LlamaAttentionType::NonCausal);
|
| 453 |
+
}
|
| 454 |
+
Ok(params)
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
fn bitmap_from_image(image: &DynamicImage) -> Result<MtmdBitmap> {
|
| 458 |
+
let rgb = image.to_rgb8();
|
| 459 |
+
let (width, height) = rgb.dimensions();
|
| 460 |
+
MtmdBitmap::from_image_data(width, height, &rgb.into_raw())
|
| 461 |
+
.context("failed to create MTMD bitmap from image")
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
fn build_user_message_content(task: PaddleOcrVlTask) -> String {
|
| 465 |
+
format!("{DEFAULT_MEDIA_MARKER}{}", task.prompt())
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
fn render_chat_prompt(
|
| 469 |
+
chat_template: &str,
|
| 470 |
+
bos_token: &str,
|
| 471 |
+
eos_token: &str,
|
| 472 |
+
task: PaddleOcrVlTask,
|
| 473 |
+
) -> Result<String> {
|
| 474 |
+
let mut env = Environment::new();
|
| 475 |
+
env.add_filter("trim", |s: String| s.trim().to_string());
|
| 476 |
+
let tmpl = env
|
| 477 |
+
.template_from_str(chat_template)
|
| 478 |
+
.map_err(anyhow::Error::msg)
|
| 479 |
+
.context("failed to parse embedded PaddleOCR-VL chat template")?;
|
| 480 |
+
tmpl.render(context! {
|
| 481 |
+
messages => vec![PromptMessage {
|
| 482 |
+
role: "user",
|
| 483 |
+
content: build_user_message_content(task),
|
| 484 |
+
}],
|
| 485 |
+
bos_token => bos_token,
|
| 486 |
+
eos_token => eos_token,
|
| 487 |
+
add_generation_prompt => true,
|
| 488 |
+
})
|
| 489 |
+
.map_err(anyhow::Error::msg)
|
| 490 |
+
.context("failed to evaluate embedded PaddleOCR-VL chat template")
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
fn max_chunk_tokens(chunks: &crate::safe::mtmd::MtmdInputChunks) -> usize {
|
| 494 |
+
(0..chunks.len())
|
| 495 |
+
.filter_map(|index| chunks.get(index))
|
| 496 |
+
.map(|chunk| chunk.n_tokens())
|
| 497 |
+
.max()
|
| 498 |
+
.unwrap_or(0)
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
fn image_chunk_tokens(chunks: &crate::safe::mtmd::MtmdInputChunks) -> usize {
|
| 502 |
+
(0..chunks.len())
|
| 503 |
+
.filter_map(|index| chunks.get(index))
|
| 504 |
+
.filter(|chunk| chunk.chunk_type() == MtmdInputChunkType::Image)
|
| 505 |
+
.map(|chunk| chunk.n_tokens())
|
| 506 |
+
.sum()
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
fn decode_token(
|
| 510 |
+
model: &LlamaModel,
|
| 511 |
+
token: LlamaToken,
|
| 512 |
+
decoder: &mut encoding_rs::Decoder,
|
| 513 |
+
) -> Result<String> {
|
| 514 |
+
model
|
| 515 |
+
.token_to_piece(token, decoder, true, None)
|
| 516 |
+
.context("failed to decode generated OCR token")
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
fn token_text(model: &LlamaModel, token: LlamaToken) -> String {
|
| 520 |
+
let mut decoder = encoding_rs::UTF_8.new_decoder();
|
| 521 |
+
match model.token_to_piece(token, &mut decoder, true, None) {
|
| 522 |
+
Ok(piece) if !piece.is_empty() => piece,
|
| 523 |
+
_ => token.to_string(),
|
| 524 |
+
}
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
#[cfg(test)]
|
| 528 |
+
mod tests {
|
| 529 |
+
use super::{
|
| 530 |
+
DEFAULT_MEDIA_MARKER, PaddleOcrVlTask, build_user_message_content, render_chat_prompt,
|
| 531 |
+
};
|
| 532 |
+
|
| 533 |
+
#[test]
|
| 534 |
+
fn user_message_places_media_marker_before_task() {
|
| 535 |
+
assert_eq!(
|
| 536 |
+
build_user_message_content(PaddleOcrVlTask::Ocr),
|
| 537 |
+
format!("{DEFAULT_MEDIA_MARKER}OCR:")
|
| 538 |
+
);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
#[test]
|
| 542 |
+
fn user_message_contents_match_paddleocr_tasks() {
|
| 543 |
+
assert_eq!(
|
| 544 |
+
build_user_message_content(PaddleOcrVlTask::Table),
|
| 545 |
+
"<__media__>Table Recognition:"
|
| 546 |
+
);
|
| 547 |
+
assert_eq!(
|
| 548 |
+
build_user_message_content(PaddleOcrVlTask::Formula),
|
| 549 |
+
"<__media__>Formula Recognition:"
|
| 550 |
+
);
|
| 551 |
+
assert_eq!(
|
| 552 |
+
build_user_message_content(PaddleOcrVlTask::Chart),
|
| 553 |
+
"<__media__>Chart Recognition:"
|
| 554 |
+
);
|
| 555 |
+
assert_eq!(
|
| 556 |
+
build_user_message_content(PaddleOcrVlTask::Spotting),
|
| 557 |
+
"<__media__>Spotting:"
|
| 558 |
+
);
|
| 559 |
+
assert_eq!(
|
| 560 |
+
build_user_message_content(PaddleOcrVlTask::Seal),
|
| 561 |
+
"<__media__>Seal Recognition:"
|
| 562 |
+
);
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
#[test]
|
| 566 |
+
fn embedded_template_render_keeps_media_marker_in_user_turn() -> anyhow::Result<()> {
|
| 567 |
+
let rendered = render_chat_prompt(
|
| 568 |
+
"{{ bos_token }}{% for message in messages %}User: {{ message['content'] }}\n{% endfor %}{% if add_generation_prompt %}Assistant:\n{% endif %}",
|
| 569 |
+
"<|begin_of_sentence|>",
|
| 570 |
+
"</s>",
|
| 571 |
+
PaddleOcrVlTask::Formula,
|
| 572 |
+
)?;
|
| 573 |
+
assert_eq!(
|
| 574 |
+
rendered,
|
| 575 |
+
"<|begin_of_sentence|>User: <__media__>Formula Recognition:\nAssistant:\n"
|
| 576 |
+
);
|
| 577 |
+
Ok(())
|
| 578 |
+
}
|
| 579 |
+
}
|
koharu-llm/src/safe/model.rs
CHANGED
|
@@ -401,8 +401,14 @@ impl LlamaModel {
|
|
| 401 |
),
|
| 402 |
x => x,
|
| 403 |
}?;
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
// _result only tells if there is nothing more in the input, or if the output was full
|
| 407 |
// but further decoding will happen on the next interation anyway
|
| 408 |
let (_result, _somesize, _truthy) =
|
|
|
|
| 401 |
),
|
| 402 |
x => x,
|
| 403 |
}?;
|
| 404 |
+
let mut output_piece = String::new();
|
| 405 |
+
// encoding_rs treats the String capacity as the output buffer size and will not
|
| 406 |
+
// reallocate, so reserve using the decoder's current state before decoding.
|
| 407 |
+
output_piece.reserve(
|
| 408 |
+
decoder
|
| 409 |
+
.max_utf8_buffer_length(bytes.len())
|
| 410 |
+
.unwrap_or(bytes.len().saturating_mul(3).saturating_add(3)),
|
| 411 |
+
);
|
| 412 |
// _result only tells if there is nothing more in the input, or if the output was full
|
| 413 |
// but further decoding will happen on the next interation anyway
|
| 414 |
let (_result, _somesize, _truthy) =
|
koharu-llm/src/sys/mod.rs
CHANGED
|
@@ -63,8 +63,9 @@ const LIB_NAMES: [&str; 4] = [
|
|
| 63 |
static LIBRARIES: OnceLock<LoadedLibraries> = OnceLock::new();
|
| 64 |
|
| 65 |
pub(crate) fn initialize() -> Result<()> {
|
| 66 |
-
let runtime_dir = koharu_runtime::llama_runtime_dir()
|
| 67 |
-
|
|
|
|
| 68 |
|
| 69 |
if !runtime_dir.exists() {
|
| 70 |
bail!(
|
|
@@ -73,9 +74,9 @@ pub(crate) fn initialize() -> Result<()> {
|
|
| 73 |
);
|
| 74 |
}
|
| 75 |
|
| 76 |
-
let dir = runtime_dir
|
| 77 |
-
|
| 78 |
-
|
| 79 |
|
| 80 |
if let Some(existing) = LIBRARIES.get() {
|
| 81 |
if existing.path == dir {
|
|
|
|
| 63 |
static LIBRARIES: OnceLock<LoadedLibraries> = OnceLock::new();
|
| 64 |
|
| 65 |
pub(crate) fn initialize() -> Result<()> {
|
| 66 |
+
let runtime_dir = koharu_runtime::llama_runtime_dir().context(
|
| 67 |
+
"failed to resolve the llama runtime directory; call `koharu_runtime::initialize()` first",
|
| 68 |
+
)?;
|
| 69 |
|
| 70 |
if !runtime_dir.exists() {
|
| 71 |
bail!(
|
|
|
|
| 74 |
);
|
| 75 |
}
|
| 76 |
|
| 77 |
+
let dir = runtime_dir
|
| 78 |
+
.canonicalize()
|
| 79 |
+
.with_context(|| format!("failed to canonicalize `{}`", runtime_dir.display()))?;
|
| 80 |
|
| 81 |
if let Some(existing) = LIBRARIES.get() {
|
| 82 |
if existing.path == dir {
|
koharu-ml/Cargo.toml
CHANGED
|
@@ -13,6 +13,7 @@ publish.workspace = true
|
|
| 13 |
|
| 14 |
[dependencies]
|
| 15 |
koharu-http = { workspace = true }
|
|
|
|
| 16 |
koharu-types = { workspace = true }
|
| 17 |
anyhow = { workspace = true }
|
| 18 |
clap = { workspace = true }
|
|
|
|
| 13 |
|
| 14 |
[dependencies]
|
| 15 |
koharu-http = { workspace = true }
|
| 16 |
+
koharu-llm = { workspace = true }
|
| 17 |
koharu-types = { workspace = true }
|
| 18 |
anyhow = { workspace = true }
|
| 19 |
clap = { workspace = true }
|
koharu-ml/src/facade.rs
CHANGED
|
@@ -2,12 +2,12 @@ use std::{sync::Mutex, time::Instant};
|
|
| 2 |
|
| 3 |
use anyhow::Result;
|
| 4 |
use image::DynamicImage;
|
|
|
|
| 5 |
use koharu_types::{Document, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection};
|
| 6 |
|
| 7 |
use crate::comic_text_detector::{self, ComicTextDetector, crop_text_block_bbox};
|
| 8 |
use crate::font_detector::{self, FontDetector};
|
| 9 |
use crate::lama::{self, Lama};
|
| 10 |
-
use crate::paddleocr_vl::{self, PaddleOcrVl, PaddleOcrVlTask};
|
| 11 |
use crate::pp_doclayout_v3::{self, LayoutRegion, PPDocLayoutV3};
|
| 12 |
|
| 13 |
const NEAR_BLACK_THRESHOLD: u8 = 12;
|
|
@@ -244,7 +244,7 @@ impl Model {
|
|
| 244 |
pub async fn prefetch() -> Result<()> {
|
| 245 |
pp_doclayout_v3::prefetch().await?;
|
| 246 |
comic_text_detector::prefetch_segmentation().await?;
|
| 247 |
-
|
| 248 |
lama::prefetch().await?;
|
| 249 |
font_detector::prefetch().await?;
|
| 250 |
|
|
|
|
| 2 |
|
| 3 |
use anyhow::Result;
|
| 4 |
use image::DynamicImage;
|
| 5 |
+
use koharu_llm::paddleocr_vl::{self as paddleocr_vl_llm, PaddleOcrVl, PaddleOcrVlTask};
|
| 6 |
use koharu_types::{Document, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection};
|
| 7 |
|
| 8 |
use crate::comic_text_detector::{self, ComicTextDetector, crop_text_block_bbox};
|
| 9 |
use crate::font_detector::{self, FontDetector};
|
| 10 |
use crate::lama::{self, Lama};
|
|
|
|
| 11 |
use crate::pp_doclayout_v3::{self, LayoutRegion, PPDocLayoutV3};
|
| 12 |
|
| 13 |
const NEAR_BLACK_THRESHOLD: u8 = 12;
|
|
|
|
| 244 |
pub async fn prefetch() -> Result<()> {
|
| 245 |
pp_doclayout_v3::prefetch().await?;
|
| 246 |
comic_text_detector::prefetch_segmentation().await?;
|
| 247 |
+
paddleocr_vl_llm::prefetch().await?;
|
| 248 |
lama::prefetch().await?;
|
| 249 |
font_detector::prefetch().await?;
|
| 250 |
|
koharu-runtime/src/cuda.rs
CHANGED
|
@@ -256,8 +256,10 @@ mod tests {
|
|
| 256 |
}
|
| 257 |
}
|
| 258 |
|
| 259 |
-
let all_dylibs: Vec<&str> =
|
| 260 |
-
|
|
|
|
|
|
|
| 261 |
for dylib in &all_dylibs {
|
| 262 |
assert!(root.join(dylib).exists());
|
| 263 |
}
|
|
|
|
| 256 |
}
|
| 257 |
}
|
| 258 |
|
| 259 |
+
let all_dylibs: Vec<&str> = WHEELS
|
| 260 |
+
.iter()
|
| 261 |
+
.flat_map(|w| w.dylibs().iter().copied())
|
| 262 |
+
.collect();
|
| 263 |
for dylib in &all_dylibs {
|
| 264 |
assert!(root.join(dylib).exists());
|
| 265 |
}
|
koharu-runtime/src/lib.rs
CHANGED
|
@@ -58,8 +58,7 @@ fn is_up_to_date(install_dir: &Path, source_id: &str) -> bool {
|
|
| 58 |
|
| 59 |
fn reset_dir(dir: &Path) -> Result<()> {
|
| 60 |
if dir.exists() {
|
| 61 |
-
fs::remove_dir_all(dir)
|
| 62 |
-
.with_context(|| format!("failed to reset `{}`", dir.display()))?;
|
| 63 |
}
|
| 64 |
fs::create_dir_all(dir).with_context(|| format!("failed to create `{}`", dir.display()))?;
|
| 65 |
Ok(())
|
|
|
|
| 58 |
|
| 59 |
fn reset_dir(dir: &Path) -> Result<()> {
|
| 60 |
if dir.exists() {
|
| 61 |
+
fs::remove_dir_all(dir).with_context(|| format!("failed to reset `{}`", dir.display()))?;
|
|
|
|
| 62 |
}
|
| 63 |
fs::create_dir_all(dir).with_context(|| format!("failed to create `{}`", dir.display()))?;
|
| 64 |
Ok(())
|
koharu-runtime/src/llama.rs
CHANGED
|
@@ -235,11 +235,13 @@ mod tests {
|
|
| 235 |
fn install_dir_includes_tag_and_id() {
|
| 236 |
let root = Path::new("/tmp/rt");
|
| 237 |
let dir = LlamaRuntime::WindowsVulkanX64.install_dir(root);
|
| 238 |
-
assert!(
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
| 243 |
}
|
| 244 |
|
| 245 |
#[test]
|
|
|
|
| 235 |
fn install_dir_includes_tag_and_id() {
|
| 236 |
let root = Path::new("/tmp/rt");
|
| 237 |
let dir = LlamaRuntime::WindowsVulkanX64.install_dir(root);
|
| 238 |
+
assert!(
|
| 239 |
+
dir.ends_with(
|
| 240 |
+
Path::new("llama.cpp")
|
| 241 |
+
.join(LLAMA_CPP_TAG)
|
| 242 |
+
.join("windows-vulkan-x64")
|
| 243 |
+
)
|
| 244 |
+
);
|
| 245 |
}
|
| 246 |
|
| 247 |
#[test]
|
koharu/src/app.rs
CHANGED
|
@@ -292,4 +292,3 @@ pub async fn run() -> Result<()> {
|
|
| 292 |
|
| 293 |
Ok(())
|
| 294 |
}
|
| 295 |
-
|
|
|
|
| 292 |
|
| 293 |
Ok(())
|
| 294 |
}
|
|
|