#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub mod connector;
pub mod decoder;
pub mod mtp;
pub mod preprocess;
pub mod tokenizer;
pub mod vision;
const GLM_OCR_REVISION: &str = "ca5d8b3e287e52589e37c28385d9655ee4372f9d";
const GLM_OCR_CONFIG_SHA256: &str = "4e1daf0d8a3f63e58960ac14bcb58b7be96758cad231fb7a1e5fec60f42dcd8c";
const GLM_OCR_TOKENIZER_SHA256: &str = "aa0fd058c73a5718bb191f6672dc16d122ee0147b20c123d1726514298f9968a";
const GLM_OCR_MODEL_SHA256: &str = "a16eb0de98d199293371c560f95f83130d2a2c9612449df16839f08ff9498815";
use serde::{Deserialize, Serialize};
pub(crate) fn glm_debug_tensor(label: &str, t: &candle_core::Tensor) {
if !tracing::enabled!(tracing::Level::TRACE) {
return;
}
let dims = t.dims().to_vec();
let flat = match t
.to_dtype(candle_core::DType::F32)
.and_then(|x| x.flatten_all())
.and_then(|x| x.to_vec1::<f32>())
{
Ok(v) => v,
Err(e) => {
tracing::trace!("[glm-debug] {label}: shape={dims:?} (stat error: {e})");
return;
}
};
let nan = flat.iter().filter(|x| x.is_nan()).count();
let inf = flat.iter().filter(|x| x.is_infinite()).count();
let finite: Vec<f32> = flat.iter().copied().filter(|x| x.is_finite()).collect();
let (min, max, mean) = if finite.is_empty() {
(f32::NAN, f32::NAN, f32::NAN)
} else {
let min = finite.iter().copied().fold(f32::INFINITY, f32::min);
let max = finite.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let mean = finite.iter().sum::<f32>() / finite.len() as f32;
(min, max, mean)
};
tracing::trace!(
"[glm-debug] {label}: shape={dims:?} n={} nan={nan} inf={inf} min={min:.4} max={max:.4} mean={mean:.4}",
flat.len()
);
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GlmOcrTask {
#[default]
Ocr,
Table,
Formula,
Chart,
Caption,
}
impl GlmOcrTask {
pub fn prompt(&self) -> &'static str {
match self {
GlmOcrTask::Ocr => "Text Recognition:",
GlmOcrTask::Table => "Table to Markdown:",
GlmOcrTask::Formula => "Formula to LaTeX:",
GlmOcrTask::Chart => "Chart to JSON:",
GlmOcrTask::Caption => "Image Caption:",
}
}
}
impl std::fmt::Display for GlmOcrTask {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
GlmOcrTask::Ocr => "ocr",
GlmOcrTask::Table => "table",
GlmOcrTask::Formula => "formula",
GlmOcrTask::Chart => "chart",
GlmOcrTask::Caption => "caption",
};
write!(f, "{}", name)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GlmOcrConfig {
pub vision_config: vision::VisionConfig,
pub text_config: decoder::DecoderConfig,
#[serde(default)]
pub connector_config: connector::ConnectorConfig,
#[serde(default)]
pub mtp_config: mtp::MtpConfig,
#[serde(default = "default_max_new_tokens")]
pub max_new_tokens: usize,
#[serde(default = "default_image_token_id")]
pub image_token_id: u32,
#[serde(default = "default_image_start_token_id")]
pub image_start_token_id: u32,
#[serde(default = "default_image_end_token_id")]
pub image_end_token_id: u32,
}
fn default_max_new_tokens() -> usize {
2048
}
fn default_image_token_id() -> u32 {
59280
}
fn default_image_start_token_id() -> u32 {
59256
}
fn default_image_end_token_id() -> u32 {
59257
}
#[cfg(not(target_arch = "wasm32"))]
mod engine {
use std::sync::Arc;
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use parking_lot::Mutex;
use tokenizers::Tokenizer;
use super::mtp;
use super::{GlmOcrConfig, GlmOcrTask};
use super::{connector::VisionConnector, decoder::Glm4Decoder, vision::CogVit};
use super::{preprocess, tokenizer};
use crate::error::Result;
use crate::{CandleOcrError, CandleOcrOutput};
pub struct GlmOcrEngine {
pub(crate) vision: Arc<Mutex<CogVit>>,
pub(crate) connector: Arc<Mutex<VisionConnector>>,
pub(crate) decoder: Arc<Mutex<Glm4Decoder>>,
pub(crate) tokenizer: Tokenizer,
pub(crate) config: GlmOcrConfig,
pub(crate) task: GlmOcrTask,
pub(crate) device: Device,
pub(crate) dtype: DType,
pub(crate) special: tokenizer::SpecialTokens,
}
impl GlmOcrEngine {
pub fn revision() -> &'static str {
super::GLM_OCR_REVISION
}
pub fn new(task: GlmOcrTask, device: Device, dtype: DType) -> Result<Self> {
Self::new_with_hf(task, device, dtype, None, None)
}
pub fn new_with_hf(
task: GlmOcrTask,
device: Device,
dtype: DType,
cache_dir: Option<&std::path::Path>,
revision: Option<&str>,
) -> Result<Self> {
if matches!(dtype, candle_core::DType::BF16) && device.is_metal() {
return Err(CandleOcrError::InferenceFailed(
"BF16 on Metal is unsupported in candle 0.10 (kernel gap). Use DType::F32 instead.".into(),
));
}
let revision = revision.unwrap_or(super::GLM_OCR_REVISION);
if revision != super::GLM_OCR_REVISION {
return Err(CandleOcrError::UnsupportedConfig(format!(
"GLM-OCR is checksum-pinned to revision {}; requested {revision}",
super::GLM_OCR_REVISION
)));
}
let config_file = crate::download_guard::hf_download(
"zai-org/GLM-OCR",
"config.json",
revision,
cache_dir,
super::GLM_OCR_CONFIG_SHA256,
)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to get config: {}", e)))?;
let config_str = std::fs::read_to_string(&config_file)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to read config: {}", e)))?;
let config: GlmOcrConfig = serde_json::from_str(&config_str)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Config parse error: {}", e)))?;
let tokenizer_file = crate::download_guard::hf_download(
"zai-org/GLM-OCR",
"tokenizer.json",
revision,
cache_dir,
super::GLM_OCR_TOKENIZER_SHA256,
)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to get tokenizer: {}", e)))?;
let tokenizer = Tokenizer::from_file(&tokenizer_file)
.map_err(|e| CandleOcrError::Tokenizer(format!("Tokenizer load error: {}", e)))?;
let model_file = crate::download_guard::hf_download(
"zai-org/GLM-OCR",
"model.safetensors",
revision,
cache_dir,
super::GLM_OCR_MODEL_SHA256,
)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to get model weights: {e}")))?;
let model_files = [model_file];
tracing::debug!("Loading GLM-OCR weights from {:?}", model_files);
#[allow(unsafe_code)]
let vb = if model_files.len() == 1 {
unsafe {
VarBuilder::from_mmaped_safetensors(&[&model_files[0]], dtype, &device)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to load safetensors: {}", e)))?
}
} else {
unsafe {
let file_refs: Vec<&std::path::Path> = model_files.iter().map(|f| f.as_path()).collect();
VarBuilder::from_mmaped_safetensors(&file_refs, dtype, &device).map_err(|e| {
CandleOcrError::ModelLoadFailed(format!("Failed to load safetensors shards: {}", e))
})?
}
};
let special = tokenizer::resolve_special_tokens(&tokenizer)?;
let visual_vb = vb.pp("model").pp("visual");
let vision = CogVit::new(&config.vision_config, visual_vb.clone(), device.clone())
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to load vision encoder: {}", e)))?;
let connector = VisionConnector::new(&config.connector_config, visual_vb)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to load connector: {}", e)))?;
let mut decoder = Glm4Decoder::new(
&config.text_config,
vb.pp("model").pp("language_model"),
vb.pp("lm_head"),
)
.map_err(|e| CandleOcrError::ModelLoadFailed(format!("Failed to load decoder: {}", e)))?;
decoder.clear_kv_cache();
tracing::debug!(
eos_token_id = special.eos,
image_start = special.image_start,
image_end = special.image_end,
image_token = special.image_token,
"Resolved GLM-OCR special tokens"
);
Ok(Self {
vision: Arc::new(Mutex::new(vision)),
connector: Arc::new(Mutex::new(connector)),
decoder: Arc::new(Mutex::new(decoder)),
tokenizer,
config,
task,
device,
dtype,
special,
})
}
pub fn process_image_with_task(&self, image_bytes: &[u8], task: GlmOcrTask) -> Result<CandleOcrOutput> {
self.process_image_inner(image_bytes, task)
}
pub fn process_image(&self, image_bytes: &[u8]) -> Result<CandleOcrOutput> {
self.process_image_inner(image_bytes, self.task)
}
fn process_image_inner(&self, image_bytes: &[u8], task: GlmOcrTask) -> Result<CandleOcrOutput> {
tracing::debug!(image_size = image_bytes.len(), task = %task, "GLM-OCR: starting inference");
let preprocess_config = preprocess::PreprocessConfig {
patch_size: self.config.vision_config.patch_size,
t_patch_size: self.config.vision_config.temporal_patch_size,
..preprocess::PreprocessConfig::default()
};
let (pixel_values, grid_thw) =
preprocess::preprocess(image_bytes, &preprocess_config, &self.device, self.dtype)?;
super::glm_debug_tensor("pixel_values", &pixel_values);
let grid_vec = grid_thw
.to_vec2::<u32>()
.map_err(|e| CandleOcrError::InferenceFailed(format!("Grid shape error: {}", e)))?;
let g = &grid_vec[0];
let h_patches = g[1] as usize;
let w_patches = g[2] as usize;
let vision_embeds = {
let vision = self.vision.lock();
vision
.forward(&pixel_values)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Vision encoding: {}", e)))?
};
super::glm_debug_tensor("vision_embeds", &vision_embeds);
let projected = {
let connector = self.connector.lock();
connector
.forward(&vision_embeds, h_patches, w_patches)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Vision projection: {}", e)))?
};
super::glm_debug_tensor("projected", &projected);
let merge = self.config.connector_config.spatial_merge_size.max(1);
let h_merged = h_patches / merge;
let w_merged = w_patches / merge;
let num_image_tokens_after_merge = h_merged * w_merged;
let (input_ids, image_tokens_start) = tokenizer::build_input_ids(
&self.special,
&self.tokenizer,
task.prompt(),
num_image_tokens_after_merge,
)?;
let ids_vec: Vec<i64> = input_ids.iter().map(|&id| id as i64).collect();
let input_ids_tensor = Tensor::new(ids_vec.as_slice(), &self.device)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Token tensor creation: {}", e)))?
.unsqueeze(0)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Unsqueeze batch: {}", e)))?;
let text_embeds = {
let decoder = self.decoder.lock();
decoder
.embed_tokens(&input_ids_tensor)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Text embedding: {}", e)))?
};
let input_embeds = Self::splice_embeddings(
&text_embeds,
&projected,
image_tokens_start,
num_image_tokens_after_merge,
)?;
super::glm_debug_tensor("text_embeds", &text_embeds);
super::glm_debug_tensor("input_embeds", &input_embeds);
let seq_len = input_embeds
.dim(1)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Seq len: {}", e)))?;
let vision_end = image_tokens_start + num_image_tokens_after_merge;
let vision_max_offset = h_merged.max(w_merged);
let post_vision_base = image_tokens_start + vision_max_offset;
let mut t_positions = Vec::with_capacity(seq_len);
let mut h_positions = Vec::with_capacity(seq_len);
let mut w_positions = Vec::with_capacity(seq_len);
for idx in 0..seq_len {
if idx < image_tokens_start {
let p = idx as u32;
t_positions.push(p);
h_positions.push(p);
w_positions.push(p);
} else if idx < vision_end {
let local = idx - image_tokens_start;
let row = local / w_merged;
let col = local % w_merged;
t_positions.push(image_tokens_start as u32);
h_positions.push((image_tokens_start + row) as u32);
w_positions.push((image_tokens_start + col) as u32);
} else {
let post_offset = idx - vision_end;
let p = (post_vision_base + post_offset) as u32;
t_positions.push(p);
h_positions.push(p);
w_positions.push(p);
}
}
let mut packed: Vec<u32> = Vec::with_capacity(3 * seq_len);
packed.extend_from_slice(&t_positions);
packed.extend_from_slice(&h_positions);
packed.extend_from_slice(&w_positions);
let prefill_position_ids = Tensor::from_vec(packed, (3, 1, seq_len), &self.device)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Position tensor: {}", e)))?;
let next_text_pos_start = (post_vision_base + (seq_len - vision_end)) as u32;
let output_ids = {
let mut decoder = self.decoder.lock();
decoder.clear_kv_cache();
mtp::generate_mrope(
&mut decoder,
&input_embeds,
&prefill_position_ids,
next_text_pos_start,
&self.config.mtp_config,
self.config.max_new_tokens,
&self.special.eos_token_ids,
)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Generation: {}", e)))?
};
let output_text = tokenizer::decode_output(&self.tokenizer, &output_ids)?;
if output_text.trim().is_empty() {
tracing::warn!(num_output_tokens = output_ids.len(), "GLM-OCR: output is empty");
} else {
tracing::debug!(
text_len = output_text.len(),
num_output_tokens = output_ids.len(),
is_markdown = Self::detect_structured_markdown(&output_text),
"GLM-OCR: decoding complete"
);
}
Ok(CandleOcrOutput {
content: output_text.clone(),
is_structured_markdown: Self::detect_structured_markdown(&output_text),
confidence: None,
})
}
fn splice_embeddings(
text_embeds: &Tensor,
vision_embeds: &Tensor,
image_start: usize,
num_image_tokens: usize,
) -> Result<Tensor> {
let (text_b, text_seq, text_hidden) = text_embeds
.dims3()
.map_err(|e| CandleOcrError::InferenceFailed(format!("Text embeds shape: {}", e)))?;
let (vision_b, vision_seq, vision_hidden) = vision_embeds
.dims3()
.map_err(|e| CandleOcrError::InferenceFailed(format!("Vision embeds shape: {}", e)))?;
if text_b != vision_b {
return Err(CandleOcrError::InferenceFailed(format!(
"Batch size mismatch: text {} vs vision {}",
text_b, vision_b
)));
}
if text_hidden != vision_hidden {
return Err(CandleOcrError::InferenceFailed(format!(
"Hidden size mismatch: text {} vs vision {}",
text_hidden, vision_hidden
)));
}
if vision_seq != num_image_tokens {
return Err(CandleOcrError::InferenceFailed(format!(
"Vision token count {} does not match expected placeholders {}",
vision_seq, num_image_tokens
)));
}
if image_start + num_image_tokens > text_seq {
return Err(CandleOcrError::InferenceFailed(format!(
"Image token range [{}, {}) exceeds sequence length {}",
image_start,
image_start + num_image_tokens,
text_seq
)));
}
let after_start = image_start + num_image_tokens;
let mut parts: Vec<Tensor> = Vec::with_capacity(3);
if image_start > 0 {
parts.push(
text_embeds
.narrow(1, 0, image_start)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Narrow before: {}", e)))?,
);
}
parts.push(vision_embeds.clone());
if after_start < text_seq {
parts.push(
text_embeds
.narrow(1, after_start, text_seq - after_start)
.map_err(|e| CandleOcrError::InferenceFailed(format!("Narrow after: {}", e)))?,
);
}
Tensor::cat(&parts, 1).map_err(|e| CandleOcrError::InferenceFailed(format!("Cat embeddings: {}", e)))
}
pub(crate) fn detect_structured_markdown(text: &str) -> bool {
let mut bullet_count: usize = 0;
for line in text.lines() {
let t = line.trim_start();
if t.starts_with("## ") || t.starts_with("# ") {
return true;
}
if t.starts_with('|') && t.ends_with('|') && t.matches('|').count() >= 2 {
return true;
}
if t.starts_with("```") || t.starts_with("$$") {
return true;
}
if t.starts_with("- ") {
bullet_count += 1;
if bullet_count >= 2 {
return true;
}
}
}
false
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub use engine::GlmOcrEngine;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(test)]
mod tests {
use super::engine;
#[test]
fn detect_structured_markdown_recognises_table() {
let text = "| a | b |\n|---|---|\n| 1 | 2 |";
assert!(engine::GlmOcrEngine::detect_structured_markdown(text));
}
#[test]
fn detect_structured_markdown_recognises_heading() {
assert!(engine::GlmOcrEngine::detect_structured_markdown("## Hello"));
}
#[test]
fn detect_structured_markdown_rejects_plain_text() {
assert!(!engine::GlmOcrEngine::detect_structured_markdown(
"just a plain sentence"
));
}
#[test]
fn detect_structured_markdown_rejects_single_dash() {
assert!(!engine::GlmOcrEngine::detect_structured_markdown(
"- hyphen but not a list"
));
}
#[test]
fn detect_structured_markdown_recognises_two_dash_lines() {
let text = "- first item\n- second item";
assert!(engine::GlmOcrEngine::detect_structured_markdown(text));
}
}