use crate::ocr_error::OcrError;
use ndarray::{Array, Array4};
use ort::{
inputs,
session::{builder::GraphOptimizationLevel, Session},
value::Tensor,
};
use std::path::Path;
pub const FORMULA_INPUT_SIZE: u32 = 768;
const BOS_ID: i64 = 0;
const PAD_ID: i64 = 1;
const EOS_ID: i64 = 2;
const MAX_NEW_TOKENS: usize = 2560;
#[derive(Debug, Clone)]
pub struct FormulaResult {
pub latex: String,
pub score: f32,
}
struct Tokenizer {
id_to_token: Vec<String>,
}
impl Tokenizer {
fn from_json(path: &Path) -> Result<Self, OcrError> {
let content = std::fs::read_to_string(path)
.map_err(|e| OcrError::ModelHubError(format!("tokenizer.json: {e}")))?;
let json: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| OcrError::ModelHubError(format!("parse tokenizer.json: {e}")))?;
let vocab = json.get("model")
.and_then(|m| m.get("vocab"))
.or_else(|| json.get("vocab"))
.and_then(|v| v.as_object())
.ok_or_else(|| OcrError::ModelHubError(
"tokenizer.json: campo 'model.vocab' non trovato".into()
))?;
let max_id = vocab.values()
.filter_map(|v| v.as_i64())
.max()
.unwrap_or(0) as usize;
let mut id_to_token = vec![String::new(); max_id + 1];
for (token, id_val) in vocab {
if let Some(id) = id_val.as_i64() {
let id = id as usize;
if id < id_to_token.len() {
id_to_token[id] = token.clone();
}
}
}
eprintln!("[FormulaRec] tokenizer caricato: {} token", id_to_token.len());
Ok(Self { id_to_token })
}
fn fallback() -> Self {
let mut id_to_token = vec![String::new(); 50_000];
id_to_token[BOS_ID as usize] = "<s>".to_string();
id_to_token[PAD_ID as usize] = "<pad>".to_string();
id_to_token[EOS_ID as usize] = "</s>".to_string();
eprintln!(
"[FormulaRec] WARN: tokenizer.json non disponibile — \
output sarà token IDs testuale. Scarica il modello con \
ModelHub::ensure_single(PpStructureModel::FormulaRec)."
);
Self { id_to_token }
}
fn decode(&self, ids: &[i64]) -> String {
let mut out = String::new();
let mut prev_needs_space = false;
for &id in ids {
if id == BOS_ID || id == PAD_ID { continue; }
if id == EOS_ID { break; }
let token = match self.id_to_token.get(id as usize) {
Some(t) if !t.is_empty() => t.as_str(),
_ => continue,
};
if let Some(stripped) = token.strip_prefix('▁') {
if prev_needs_space && !out.is_empty() {
out.push(' ');
}
out.push_str(stripped);
prev_needs_space = true;
} else if let Some(stripped) = token.strip_prefix('Ġ') {
if !out.is_empty() { out.push(' '); }
out.push_str(stripped);
prev_needs_space = false;
} else {
out.push_str(token);
prev_needs_space = false;
}
}
out.trim().to_string()
}
fn vocab_size(&self) -> usize { self.id_to_token.len() }
}
#[derive(Debug, Clone, Copy)]
enum InferenceMode {
SinglePass,
Autoregressive,
}
pub struct FormulaRecognizer {
session: Session,
tokenizer: Tokenizer,
mode: InferenceMode,
img_input: String,
pub decoder_enabled: bool,
}
impl FormulaRecognizer {
pub fn from_paths(
model_path: impl AsRef<Path>,
tokenizer_path: Option<&Path>,
) -> Result<Self, OcrError> {
let session = Session::builder()?
.with_optimization_level(GraphOptimizationLevel::Level3)?
.commit_from_file(model_path)?;
let tokenizer = match tokenizer_path {
Some(p) => Tokenizer::from_json(p)?,
None => Tokenizer::fallback(),
};
let mode = detect_inference_mode(&session);
let img_input = find_image_input(&session);
eprintln!(
"[FormulaRec] modalità: {mode:?}, input immagine: '{img_input}', vocab: {}",
tokenizer.vocab_size()
);
Ok(Self { session, tokenizer, mode, img_input, decoder_enabled: false })
}
pub fn recognize(&self, image: &image::RgbImage) -> Result<FormulaResult, OcrError> {
if !self.decoder_enabled {
return Ok(FormulaResult { latex: String::new(), score: 0.0 });
}
let blob = preprocess(image);
match self.mode {
InferenceMode::SinglePass => self.recognize_single_pass(blob),
InferenceMode::Autoregressive => self.recognize_autoregressive(blob),
}
}
fn recognize_single_pass(&self, blob: Array4<f32>) -> Result<FormulaResult, OcrError> {
let outputs = self.session.run(
inputs![self.img_input.clone() => Tensor::from_array(blob)?]?
)?;
let (_, first) = outputs.iter().next()
.ok_or_else(|| OcrError::ModelOutput("FormulaNet: nessun output".into()))?;
let (shape, data_f32) = crate::compat::tensor_extract_with_shape_f32(&first)?;
eprintln!("[FormulaNet] single-pass output shape: {shape:?}");
match shape.as_slice() {
[1, t_len, v_len] => {
let t = *t_len as usize;
let v = *v_len as usize;
let mut ids: Vec<i64> = Vec::with_capacity(t);
let mut scores: Vec<f32> = Vec::with_capacity(t);
for step in 0..t {
let base = step * v;
let (idx, score) = argmax_slice(&data_f32[base..base + v]);
let id = idx as i64;
if id == EOS_ID { break; }
if id == PAD_ID || id == BOS_ID { continue; }
ids.push(id);
scores.push(score);
}
let latex = self.tokenizer.decode(&ids);
let score = mean_score(&scores);
Ok(FormulaResult { latex, score })
}
[1, _t] => {
let ids: Vec<i64> = data_f32.iter().map(|&f| f.round() as i64).collect();
let latex = self.tokenizer.decode(&ids);
Ok(FormulaResult { latex, score: 0.0 })
}
other => Err(OcrError::ModelOutput(format!(
"FormulaNet single-pass: shape output non riconosciuta: {other:?}"
))),
}
}
fn recognize_autoregressive(&self, blob: Array4<f32>) -> Result<FormulaResult, OcrError> {
let dec_input_name = self.session.inputs.iter()
.find(|i| {
let n = i.name.to_lowercase();
n.contains("decoder") || n.contains("input_ids") || n.contains("tgt")
})
.map(|i| i.name.clone())
.unwrap_or_else(|| "decoder_input_ids".to_string());
let mut ids: Vec<i64> = vec![BOS_ID];
let mut scores: Vec<f32> = Vec::new();
for _ in 0..MAX_NEW_TOKENS {
let dec_len = ids.len();
let dec_arr: ndarray::Array2<i64> = ndarray::Array2::from_shape_vec(
(1, dec_len),
ids.clone(),
).map_err(|e| OcrError::ModelInput(format!("dec_arr shape: {e}")))?;
let outputs = self.session.run(inputs![
self.img_input.clone() => Tensor::from_array(blob.clone())?,
dec_input_name.clone() => Tensor::from_array(dec_arr)?,
]?)?;
let (_, last_out) = outputs.iter().next()
.ok_or_else(|| OcrError::ModelOutput("FormulaNet auto: nessun output".into()))?;
let (shape, data) = crate::compat::tensor_extract_with_shape_f32(&last_out)?;
let next_id = match shape.as_slice() {
[1, _t, v] => {
let v = *v as usize;
let last_step_base = (dec_len - 1) * v;
let (idx, score) = argmax_slice(&data[last_step_base..last_step_base + v]);
scores.push(score);
idx as i64
}
[1, v] => {
let (idx, score) = argmax_slice(&data[..(*v as usize)]);
scores.push(score);
idx as i64
}
other => return Err(OcrError::ModelOutput(format!(
"FormulaNet auto step: shape {other:?}"
))),
};
if next_id == EOS_ID || next_id == PAD_ID { break; }
ids.push(next_id);
}
let decode_ids: Vec<i64> = ids.into_iter().skip(1).collect();
let latex = self.tokenizer.decode(&decode_ids);
let score = mean_score(&scores);
Ok(FormulaResult { latex, score })
}
}
fn preprocess(image: &image::RgbImage) -> Array4<f32> {
let s = FORMULA_INPUT_SIZE;
let resized = image::imageops::resize(image, s, s, image::imageops::FilterType::Triangle);
let mean = [0.485f32, 0.456, 0.406];
let std = [0.229f32, 0.224, 0.225];
let mut blob: Array4<f32> = Array::zeros((1, 3, s as usize, s as usize));
for y in 0..s as usize {
for x in 0..s as usize {
let p = resized.get_pixel(x as u32, y as u32);
for c in 0..3usize {
blob[[0, c, y, x]] = (p[c] as f32 / 255.0 - mean[c]) / std[c];
}
}
}
blob
}
fn detect_inference_mode(session: &Session) -> InferenceMode {
let has_dec_input = session.inputs.iter().any(|i| {
let n = i.name.to_lowercase();
n.contains("decoder") || n.contains("input_ids") || n.contains("tgt")
});
if has_dec_input {
InferenceMode::Autoregressive
} else {
InferenceMode::SinglePass
}
}
fn find_image_input(session: &Session) -> String {
session.inputs.iter()
.find(|i| {
let n = i.name.to_lowercase();
n == "x" || n == "image" || n == "img" || n.contains("pixel")
})
.or_else(|| session.inputs.first())
.map(|i| i.name.clone())
.unwrap_or_else(|| "x".to_string())
}
fn argmax_slice(s: &[f32]) -> (usize, f32) {
s.iter().copied().enumerate()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, 0.0))
}
fn mean_score(v: &[f32]) -> f32 {
if v.is_empty() { 0.0 } else { v.iter().sum::<f32>() / v.len() as f32 }
}