use image::RgbImage;
use ort::session::Session;
use ort::value::Tensor;
use tokenizers::Tokenizer;
use docling_core::PictureClass;
pub const CLASSIFIER_SCALE: f32 = 2.0;
pub const CODE_FORMULA_SCALE: f32 = 1.67;
pub const CODE_FORMULA_EXPANSION: f32 = 0.18;
const PICTURE_CLASSES: [&str; 26] = [
"logo",
"photograph",
"icon",
"engineering_drawing",
"line_chart",
"bar_chart",
"other",
"table",
"flow_chart",
"screenshot_from_computer",
"signature",
"screenshot_from_manual",
"geographical_map",
"pie_chart",
"page_thumbnail",
"stamp",
"music",
"calendar",
"qr_code",
"bar_code",
"full_page_image",
"scatter_plot",
"chemistry_structure",
"topographical_map",
"crossword_puzzle",
"box_plot",
];
const CLASSIFIER_SIDE: u32 = 224;
const CLASSIFIER_MEAN: [f32; 3] = [0.485, 0.456, 0.406];
const CLASSIFIER_STD: [f32; 3] = [0.478_539_44, 0.473_286_4, 0.474_341_63];
pub struct PictureClassifier {
session: Session,
}
impl PictureClassifier {
pub fn load_with(intra: usize) -> Option<Self> {
let path = crate::model_path(
"DOCLING_PICTURE_CLASSIFIER_ONNX",
"models/picture_classifier.onnx",
"models/picture_classifier_int8.onnx",
);
if !std::path::Path::new(&path).exists() {
eprintln!(
"docling-pdf: picture classifier model not found ({path}); \
picture classification skipped. Run scripts/install/download_dependencies.sh."
);
return None;
}
let builder = Session::builder().ok()?.with_intra_threads(intra).ok()?;
let session = crate::ep::apply(builder)
.map_err(|e| eprintln!("docling-pdf: picture classifier: {e}"))
.ok()?
.commit_from_file(&path)
.map_err(|e| eprintln!("docling-pdf: picture classifier load {path}: {e}"))
.ok()?;
Some(Self { session })
}
pub fn classify(&mut self, crop: &RgbImage) -> Result<Vec<PictureClass>, String> {
let resized = image::imageops::resize(
crop,
CLASSIFIER_SIDE,
CLASSIFIER_SIDE,
image::imageops::FilterType::Triangle,
);
let n = (CLASSIFIER_SIDE * CLASSIFIER_SIDE) as usize;
let mut data = vec![0f32; 3 * n];
for (i, px) in resized.pixels().enumerate() {
for c in 0..3 {
data[c * n + i] = (px[c] as f32 / 255.0 - CLASSIFIER_MEAN[c]) / CLASSIFIER_STD[c];
}
}
let input = Tensor::from_array((
[
1usize,
3,
CLASSIFIER_SIDE as usize,
CLASSIFIER_SIDE as usize,
],
data,
))
.map_err(|e| format!("picture classifier: input: {e}"))?;
let outputs = self
.session
.run(ort::inputs!["input" => input])
.map_err(|e| format!("picture classifier: inference: {e}"))?;
let (_, logits) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(|e| format!("picture classifier: output: {e}"))?;
let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let exp: Vec<f32> = logits.iter().map(|&v| (v - max).exp()).collect();
let sum: f32 = exp.iter().sum();
let mut preds: Vec<PictureClass> = exp
.iter()
.enumerate()
.map(|(i, &e)| PictureClass {
class_name: PICTURE_CLASSES.get(i).copied().unwrap_or("other").into(),
confidence: e / sum,
})
.collect();
preds.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
Ok(preds)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeFormulaKind {
Code,
Formula,
}
const TILE: u32 = 512; const LONGEST_EDGE: u32 = 2048; const MAX_IMAGE_SIZE: u32 = 4096; const IMAGE_SEQ_LEN: usize = 64; const IMAGE_TOKEN_ID: i64 = 100270; const EOS_ID: i64 = 100338; const MODEL_MAX_LEN: usize = 8192;
const HIDDEN: usize = 576;
const N_LAYERS: usize = 30;
const N_KV: usize = 3;
const HEAD_DIM: usize = 64;
pub struct CodeFormula {
vision: Session,
embed: Session,
decoder: Session,
tokenizer: Tokenizer,
}
impl CodeFormula {
pub fn load_with(intra: usize) -> Option<Self> {
let dir = std::env::var("DOCLING_CODE_FORMULA_DIR")
.unwrap_or_else(|_| crate::resolve_asset("models/code_formula"));
let file = |name: &str| format!("{dir}/{name}");
let graph = |base: &str| {
let int8 = file(&format!("{base}_int8.onnx"));
if !crate::prefer_fp32() && std::path::Path::new(&int8).exists() {
int8
} else {
file(&format!("{base}.onnx"))
}
};
for f in [&graph("vision"), &graph("embed"), &graph("decoder_kv")] {
if !std::path::Path::new(f.as_str()).exists() {
eprintln!(
"docling-pdf: CodeFormula model not found ({f}); code/formula \
enrichment skipped. Run scripts/install/download_dependencies.sh."
);
return None;
}
}
let load = |p: String| {
let builder = Session::builder().ok()?.with_intra_threads(intra).ok()?;
crate::ep::apply(builder)
.map_err(|e| eprintln!("docling-pdf: CodeFormula: {e}"))
.ok()?
.commit_from_file(&p)
.map_err(|e| eprintln!("docling-pdf: CodeFormula load {p}: {e}"))
.ok()
};
let tokenizer = Tokenizer::from_file(file("tokenizer.json"))
.map_err(|e| eprintln!("docling-pdf: CodeFormula tokenizer: {e}"))
.ok()?;
Some(Self {
vision: load(graph("vision"))?,
embed: load(graph("embed"))?,
decoder: load(graph("decoder_kv"))?,
tokenizer,
})
}
pub fn predict(&mut self, crop: &RgbImage, kind: CodeFormulaKind) -> Result<String, String> {
if let Ok(dir) = std::env::var("DOCLING_RS_ENRICH_DEBUG") {
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let _ = crop.save(format!("{dir}/rs_crop_{n}.png"));
}
let (tiles, rows, cols) = preprocess_idefics3(crop);
let n_tiles = tiles.len() / (3 * (TILE * TILE) as usize);
let feats: Vec<f32> = {
let input = Tensor::from_array(([n_tiles, 3, TILE as usize, TILE as usize], tiles))
.map_err(|e| format!("code-formula: vision input: {e}"))?;
let outputs = self
.vision
.run(ort::inputs!["pixel_values" => input])
.map_err(|e| format!("code-formula: vision: {e}"))?;
let (_, feats) = outputs["image_features"]
.try_extract_tensor::<f32>()
.map_err(|e| format!("code-formula: vision output: {e}"))?;
feats.to_vec()
};
let query = match kind {
CodeFormulaKind::Code => "<code>",
CodeFormulaKind::Formula => "<formula>",
};
let prompt = format!(
"<|start_of_role|>user:{}{query}<end_of_utterance>\nassistant:",
image_prompt(rows, cols)
);
let enc = self
.tokenizer
.encode(prompt, false)
.map_err(|e| format!("code-formula: tokenize: {e}"))?;
let ids: Vec<i64> = enc.get_ids().iter().map(|&v| v as i64).collect();
let seq = ids.len();
let mut embeds = self.embed_ids(&ids)?;
let image_positions: Vec<usize> = ids
.iter()
.enumerate()
.filter(|(_, &t)| t == IMAGE_TOKEN_ID)
.map(|(i, _)| i)
.collect();
if image_positions.len() != n_tiles * IMAGE_SEQ_LEN {
return Err(format!(
"code-formula: {} image tokens for {} tiles",
image_positions.len(),
n_tiles
));
}
for (v, &pos) in image_positions.iter().enumerate() {
embeds[pos * HIDDEN..(pos + 1) * HIDDEN]
.copy_from_slice(&feats[v * HIDDEN..(v + 1) * HIDDEN]);
}
let mut cache: Option<(ort::value::DynValue, ort::value::DynValue)> = None;
let empty = {
let mk = || {
Tensor::<f32>::new(
self.decoder.allocator(),
[N_LAYERS, 1, N_KV, 0usize, HEAD_DIM],
)
.map_err(|e| format!("code-formula: empty kv cache: {e}"))
};
(mk()?, mk()?)
};
let mut past_len = 0usize;
let mut positions: Vec<i64> = (0..seq as i64).collect();
let mut x = embeds;
let mut x_seq = seq;
let mut out_ids: Vec<u32> = Vec::new();
let max_new = MODEL_MAX_LEN.saturating_sub(seq);
for _ in 0..max_new {
let embeds_t = Tensor::from_array(([1usize, x_seq, HIDDEN], x))
.map_err(|e| format!("code-formula: embeds: {e}"))?;
let pos_t = Tensor::from_array(([1usize, positions.len()], positions.clone()))
.map_err(|e| format!("code-formula: positions: {e}"))?;
let next = {
let mut out = match cache.as_ref() {
Some((k, v)) => self.decoder.run(ort::inputs![
"inputs_embeds" => embeds_t, "position_ids" => pos_t,
"past_k" => k, "past_v" => v]),
None => self.decoder.run(ort::inputs![
"inputs_embeds" => embeds_t, "position_ids" => pos_t,
"past_k" => &empty.0, "past_v" => &empty.1]),
}
.map_err(|e| format!("code-formula: decoder: {e}"))?;
let (_, logits) = out["logits"]
.try_extract_tensor::<f32>()
.map_err(|e| format!("code-formula: logits: {e}"))?;
let next = logits
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(i, _)| i as i64)
.unwrap_or(EOS_ID);
cache = Some((
out.remove("new_k")
.ok_or_else(|| "code-formula: new_k missing".to_string())?,
out.remove("new_v")
.ok_or_else(|| "code-formula: new_v missing".to_string())?,
));
next
};
past_len += x_seq;
if next == EOS_ID {
break;
}
out_ids.push(next as u32);
x = self.embed_ids(&[next])?;
x_seq = 1;
positions = vec![past_len as i64];
}
let text = self
.tokenizer
.decode(&out_ids, false)
.map_err(|e| format!("code-formula: decode: {e}"))?;
Ok(post_process(&text))
}
fn embed_ids(&mut self, ids: &[i64]) -> Result<Vec<f32>, String> {
let input = Tensor::from_array(([1usize, ids.len()], ids.to_vec()))
.map_err(|e| format!("code-formula: ids: {e}"))?;
let out = self
.embed
.run(ort::inputs!["input_ids" => input])
.map_err(|e| format!("code-formula: embed: {e}"))?;
let (_, embeds) = out["inputs_embeds"]
.try_extract_tensor::<f32>()
.map_err(|e| format!("code-formula: embed output: {e}"))?;
Ok(embeds.to_vec())
}
}
fn image_prompt(rows: u32, cols: u32) -> String {
let img = "<image>".repeat(IMAGE_SEQ_LEN);
let mut s = String::new();
for r in 1..=rows {
for c in 1..=cols {
s.push_str(&format!("<fake_token_around_image><row_{r}_col_{c}>{img}"));
}
s.push('\n');
}
s.push_str(&format!(
"\n<fake_token_around_image><global-img>{img}<fake_token_around_image>"
));
s
}
fn preprocess_idefics3(crop: &RgbImage) -> (Vec<f32>, u32, u32) {
use image::imageops::FilterType;
let (w0, h0) = crop.dimensions();
let (mut w, mut h) = rescale_to_max_len(w0, h0, LONGEST_EDGE);
(h, w) = scale_below_upper_bound(h, w, MAX_IMAGE_SIZE);
let img = image::imageops::resize(crop, w, h, FilterType::Lanczos3);
let (tw, th) = if w >= h {
let tw = w.div_ceil(TILE) * TILE;
let th0 = (tw as f64 / (w as f64 / h as f64)) as u32;
(tw, th0.div_ceil(TILE) * TILE)
} else {
let th = h.div_ceil(TILE) * TILE;
let tw0 = (th as f64 * (w as f64 / h as f64)) as u32;
(tw0.div_ceil(TILE) * TILE, th)
};
let img = image::imageops::resize(&img, tw, th, FilterType::Lanczos3);
let (rows, cols) = (th / TILE, tw / TILE);
let mut tensor = Vec::with_capacity(((rows * cols + 1) * 3 * TILE * TILE) as usize);
for r in 0..rows {
for c in 0..cols {
let tile = image::imageops::crop_imm(&img, c * TILE, r * TILE, TILE, TILE).to_image();
push_normalized(&mut tensor, &tile);
}
}
let global = image::imageops::resize(&img, TILE, TILE, FilterType::Lanczos3);
push_normalized(&mut tensor, &global);
(tensor, rows, cols)
}
fn rescale_to_max_len(w0: u32, h0: u32, max_len: u32) -> (u32, u32) {
let aspect = w0 as f64 / h0 as f64;
let (w, h) = if w0 >= h0 {
let w = max_len;
let mut h = (w as f64 / aspect) as u32;
if !h.is_multiple_of(2) {
h += 1;
}
(w, h)
} else {
let h = max_len;
let mut w = (h as f64 * aspect) as u32;
if !w.is_multiple_of(2) {
w += 1;
}
(w, h)
};
(w.max(1), h.max(1))
}
fn scale_below_upper_bound(h0: u32, w0: u32, max_len: u32) -> (u32, u32) {
let aspect = w0 as f64 / h0 as f64;
let (h, w) = if w0 >= h0 && w0 > max_len {
let w = max_len;
(((w as f64 / aspect) as u32).max(1), w)
} else if h0 > w0 && h0 > max_len {
let h = max_len;
(h, ((h as f64 * aspect) as u32).max(1))
} else {
(h0, w0)
};
(h.max(1), w.max(1))
}
fn push_normalized(tensor: &mut Vec<f32>, tile: &RgbImage) {
let n = (TILE * TILE) as usize;
let base = tensor.len();
tensor.resize(base + 3 * n, 0.0);
for (i, px) in tile.pixels().enumerate() {
for c in 0..3 {
tensor[base + c * n + i] = px[c] as f32 / 255.0 * 2.0 - 1.0;
}
}
}
fn post_process(text: &str) -> String {
let mut t = match text.find("<end_of_utterance>") {
Some(i) => &text[..i],
None => text,
}
.to_string();
for tok in ["</code>", "</formula>", "<loc_0><loc_0><loc_500><loc_500>"] {
t = t.replace(tok, "");
}
t.trim_start().to_string()
}
pub fn extract_code_language(s: &str) -> (String, Option<String>) {
let rest = match s.strip_prefix("<_") {
Some(r) => r,
None => return (s.to_string(), None),
};
match rest.find("_>") {
Some(end) if !rest[..end].is_empty() && !rest[..end].contains(['_', '>']) => {
let lang = rest[..end].to_string();
let remainder = rest[end + 2..].trim_start().to_string();
(remainder, Some(lang))
}
_ => (s.to_string(), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_prefix_extraction() {
assert_eq!(
extract_code_language("<_JavaScript_> function f() {}"),
(
"function f() {}".to_string(),
Some("JavaScript".to_string())
)
);
assert_eq!(
extract_code_language("plain text"),
("plain text".to_string(), None)
);
assert_eq!(
extract_code_language("<_x_y_> t"),
("<_x_y_> t".to_string(), None)
);
}
#[test]
fn idefics3_grid_matches_processor() {
let img = RgbImage::new(800, 300);
let (tensor, rows, cols) = preprocess_idefics3(&img);
assert_eq!((rows, cols), (2, 4));
assert_eq!(tensor.len(), 9 * 3 * 512 * 512);
}
#[test]
fn image_prompt_layout() {
let p = image_prompt(1, 2);
assert!(p.starts_with("<fake_token_around_image><row_1_col_1><image>"));
assert!(p.contains("<row_1_col_2>"));
let tail = "<fake_token_around_image><global-img>".to_owned()
+ &"<image>".repeat(64)
+ "<fake_token_around_image>";
assert!(p.ends_with(&tail));
assert_eq!(p.matches("<image>").count(), 3 * 64);
}
}