use clap::{Parser, Subcommand};
use rapid_structure_rs::evaluation::ocr::{
EvaluationCase, EvaluationReport, EvaluationSummary, evaluate_case,
};
use rapid_structure_rs::{
ClassifierPlan, ClassifierPolicy, DetectionPolicy, EngineConfig, FormulaPolicy, ImageInput,
OcrEngine, OcrRequest, OutputPolicy, PreprocessPolicy, RapidOcrEngine, RecognitionPolicy,
ReportMode, StagePlan, TablePolicy, TextOrder, WordOutputMode, render_output_report,
to_output_items, to_output_json, to_output_markdown,
};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
#[cfg(feature = "serve")]
#[path = "serve/mod.rs"]
mod serve;
#[derive(Debug, Clone, Default, clap::Args)]
struct FormulaArgs {
#[arg(long = "formula-model", value_name = "ONNX")]
model: Option<PathBuf>,
#[arg(long = "formula-detector", value_name = "ONNX")]
detector: Option<PathBuf>,
#[arg(long = "formula-sha256")]
sha256: Option<String>,
#[arg(long = "formula-confidence", default_value_t = 0.25)]
confidence: f32,
#[arg(long = "formula-max-regions", default_value_t = 64)]
max_regions: usize,
#[arg(long = "formula-token-ids")]
token_ids: bool,
}
impl FormulaArgs {
fn policy(&self) -> FormulaPolicy {
match &self.model {
Some(model) => FormulaPolicy {
enabled: true,
model_path: Some(model.clone()),
expected_model_sha256: self.sha256.clone(),
detector_path: self.detector.clone(),
confidence_threshold: self.confidence,
max_regions: self.max_regions,
include_token_ids: self.token_ids,
..FormulaPolicy::default()
},
None => FormulaPolicy::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum OutputFormat {
Plain,
Json,
Html,
Markdown,
}
#[derive(Debug, clap::Args)]
struct TableArgs {
#[arg(long, value_name = "MODEL")]
table_model: Option<PathBuf>,
#[arg(long, value_name = "N")]
table_intra_threads: Option<usize>,
}
impl TableArgs {
fn policy(&self) -> TablePolicy {
match &self.table_model {
Some(model_path) => TablePolicy {
enabled: true,
model_path: Some(model_path.clone()),
intra_threads: self.table_intra_threads,
..TablePolicy::default()
},
None => TablePolicy::default(),
}
}
}
#[derive(Debug, Parser)]
#[command(name = "rapidstruct", about = "Run PaddleOCR ONNX models")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Run {
#[arg(long, value_name = "IMAGE")]
img_path: PathBuf,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
words: bool,
#[arg(long)]
chars: bool,
#[arg(long, conflicts_with = "format")]
json: bool,
#[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
format: OutputFormat,
#[command(flatten)]
formula: FormulaArgs,
#[command(flatten)]
table: TableArgs,
},
Report {
#[arg(long = "input-dir")]
input_dir: PathBuf,
#[arg(long = "output-dir")]
output_dir: PathBuf,
#[arg(long)]
config: Option<PathBuf>,
#[command(flatten)]
formula: FormulaArgs,
#[command(flatten)]
table: TableArgs,
},
Evaluate {
#[arg(long)]
manifest: PathBuf,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long, default_value_t = 0.5)]
iou_threshold: f32,
#[arg(long)]
output: Option<PathBuf>,
},
Check,
#[cfg(feature = "serve")]
Serve(serve::ServeArgs),
#[cfg(not(feature = "serve"))]
Serve(UnavailableServeArgs),
}
#[cfg(not(feature = "serve"))]
#[derive(Debug, clap::Args)]
struct UnavailableServeArgs {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
rest: Vec<String>,
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
match Cli::parse().command {
Command::Run {
img_path,
config,
words,
chars,
json,
format,
formula,
table,
} => {
let cfg = match config {
Some(path) => EngineConfig::from_yaml_file(path)?,
None => EngineConfig::default(),
};
let mut engine = RapidOcrEngine::new(cfg)?;
let bytes = std::fs::read(&img_path)?;
let word_mode = word_mode_from_flags(words, chars);
let format = if json { OutputFormat::Json } else { format };
let mut request = make_request(bytes, word_mode, table.policy());
request.formula = formula.policy();
let output = engine.recognize(request)?;
match format {
OutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&to_output_json(&output)?)?
),
OutputFormat::Html => {
let source_name = img_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("source.png");
let timing = format!("total {:.1} ms", output.timings.total_ms);
print!(
"{}",
render_output_report(
source_name,
source_name,
&output,
&timing,
ReportMode::Static,
)?
);
}
OutputFormat::Markdown => {
println!("{}", to_output_markdown(&output));
for (index, table) in output.tables(TextOrder::Reading) {
println!(
"table[{index}] rows={} cols={} cells={} confidence={}",
table.rows,
table.cols,
table.cells.len(),
table.confidence.as_str()
);
}
}
OutputFormat::Plain => {
println!("{}", output.plain_text(TextOrder::Reading));
for (index, latex) in output.formula_latex(TextOrder::Reading) {
println!("formula[{index}] = {latex}");
}
for (index, table) in output.tables(TextOrder::Reading) {
println!(
"table[{index}] rows={} cols={} cells={} confidence={}",
table.rows,
table.cols,
table.cells.len(),
table.confidence.as_str()
);
}
println!(
"regions={} formulas={} tables={} total_ms={:.1}",
output.regions.len(),
output.formula_count(),
output.table_count(),
output.timings.total_ms
);
}
}
}
Command::Check => println!("rapid-structure-rs ready"),
Command::Report {
input_dir,
output_dir,
config,
formula,
table,
} => report_cmd(
input_dir,
output_dir,
config,
formula.policy(),
table.policy(),
)?,
Command::Evaluate {
manifest,
config,
iou_threshold,
output,
} => evaluate_cmd(manifest, config, iou_threshold, output)?,
#[cfg(feature = "serve")]
Command::Serve(args) => serve::serve_main(args)?,
#[cfg(not(feature = "serve"))]
Command::Serve(_) => {
return Err(
"`rapidstruct serve` is not compiled into this binary: rebuild with \
`cargo build --features serve` (docs/05 §2.1: the HTTP layer is an optional \
feature and is deliberately not part of the default build)"
.into(),
);
}
}
Ok(())
}
fn word_mode_from_flags(words: bool, chars: bool) -> WordOutputMode {
if chars {
WordOutputMode::Chars
} else if words {
WordOutputMode::Words
} else {
WordOutputMode::Off
}
}
fn make_request(bytes: Vec<u8>, words: WordOutputMode, table: TablePolicy) -> OcrRequest {
OcrRequest {
input: ImageInput::Encoded(Arc::from(bytes)),
roi: None,
scale_hint: None,
stages: StagePlan {
detect: true,
classify: ClassifierPlan {
policy: ClassifierPolicy::IfAvailable,
apply_rotation: true,
},
recognize: true,
},
preprocess: PreprocessPolicy::default(),
detection: DetectionPolicy::default(),
recognition: RecognitionPolicy { words },
output: OutputPolicy::default(),
formula: FormulaPolicy::default(),
table,
}
}
fn report_cmd(
input_dir: PathBuf,
output_dir: PathBuf,
config: Option<PathBuf>,
formula: FormulaPolicy,
table: TablePolicy,
) -> Result<(), Box<dyn std::error::Error>> {
let cfg = match config {
Some(path) => EngineConfig::from_yaml_file(path)?,
None => EngineConfig::default(),
};
let mut engine = RapidOcrEngine::new(cfg)?;
std::fs::create_dir_all(&output_dir)?;
for entry in std::fs::read_dir(&input_dir)? {
let path = entry?.path();
if !path.is_file() {
continue;
}
let Some(ext) = path.extension().and_then(|value| value.to_str()) else {
continue;
};
if !matches!(
ext.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "bmp" | "webp"
) {
continue;
}
let mut request = make_request(std::fs::read(&path)?, WordOutputMode::Off, table.clone());
request.formula = formula.clone();
let output = engine.recognize(request)?;
let source_name = path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("source.png");
let html_name = format!(
"{}.html",
path.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("report")
);
std::fs::copy(&path, output_dir.join(source_name))?;
let html = render_output_report(
source_name,
source_name,
&output,
&format!("total {:.1} ms", output.timings.total_ms),
ReportMode::Full,
)?;
std::fs::write(output_dir.join(html_name), html)?;
}
Ok(())
}
fn evaluate_cmd(
manifest: PathBuf,
config: Option<PathBuf>,
iou_threshold: f32,
output: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let raw = std::fs::read_to_string(&manifest)?;
let manifest_value: serde_json::Value = serde_json::from_str(&raw)?;
if rapid_structure_rs::evaluation::table::TableEvalManifest::matches(&manifest_value) {
return evaluate_table_cmd(manifest, config, output);
}
let cases: Vec<EvaluationCase> = serde_json::from_value(manifest_value)?;
let manifest_dir = manifest.parent().unwrap_or_else(|| Path::new("."));
let cfg = match config {
Some(path) => EngineConfig::from_yaml_file(path)?,
None => EngineConfig::default(),
};
let mut engine = RapidOcrEngine::new(cfg)?;
let mut reports = Vec::with_capacity(cases.len());
for case in cases {
let image_path = Path::new(&case.image);
let resolved_image = if image_path.is_absolute() {
image_path.to_path_buf()
} else {
manifest_dir.join(image_path)
};
let result = engine.recognize(make_request(
std::fs::read(&resolved_image)?,
WordOutputMode::Off,
TablePolicy::default(),
))?;
let metrics = evaluate_case(
&case.text,
&case.boxes,
&to_output_items(&result),
iou_threshold,
);
reports.push(EvaluationReport {
image: case.image,
metrics,
});
}
let summary = EvaluationSummary::from_cases(reports);
let report = evaluation_report_value(&summary)?;
let text = serde_json::to_string_pretty(&report)?;
if let Some(path) = output {
std::fs::write(path, &text)?;
}
println!("{text}");
Ok(())
}
fn evaluate_table_cmd(
manifest: PathBuf,
config: Option<PathBuf>,
output: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
use rapid_structure_rs::evaluation::table::run::{TableEvalEvent, run_table_evaluation};
if config.is_some() {
return Err(
"--config selects the page-level OCR engine, which the table domain does not \
run; remove it (or use the OCR manifest)"
.into(),
);
}
let parsed: rapid_structure_rs::evaluation::table::TableEvalManifest =
serde_json::from_str(&std::fs::read_to_string(&manifest)?)?;
let eval_config = parsed.into_config()?;
let outcome = run_table_evaluation(&eval_config, &mut |event| match event {
TableEvalEvent::Message(text) => eprintln!("{text}"),
TableEvalEvent::Progress { done, total } => eprintln!(" {done}/{total}"),
})?;
let text = serde_json::to_string_pretty(&outcome.report)?;
if let Some(path) = output {
std::fs::write(path, &text)?;
}
println!("{text}");
Ok(())
}
pub(crate) fn evaluation_report_value(
summary: &EvaluationSummary,
) -> Result<serde_json::Value, rapid_structure_rs::RapidOcrError> {
let mut report = serde_json::to_value(summary)
.map_err(|error| rapid_structure_rs::RapidOcrError::Decode(error.to_string()))?;
rapid_structure_rs::evaluation::runtime_report::attach_runtime_fields(&mut report)?;
Ok(report)
}
#[cfg(test)]
mod tests {
use super::{make_request, word_mode_from_flags};
use rapid_structure_rs::WordOutputMode;
#[test]
fn the_cli_flags_map_to_the_same_modes_as_the_query_parameter() {
for (words, chars, name) in [
(false, false, "off"),
(true, false, "words"),
(false, true, "chars"),
(true, true, "chars"),
] {
let from_flags = word_mode_from_flags(words, chars);
assert_eq!(
from_flags,
WordOutputMode::from_name(name).expect("the documented query vocabulary"),
"--words={words} --chars={chars}"
);
assert_eq!(from_flags.as_str(), name);
}
}
#[test]
fn the_word_mode_reaches_the_request_recognition_policy() {
for mode in WordOutputMode::ALL {
let request = make_request(Vec::new(), mode, Default::default());
assert_eq!(request.recognition.words, mode, "{}", mode.as_str());
}
}
}