Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,11 +364,11 @@ cargo run -p oar-ocr-vl --features cuda --example paddleocr_vl -- \
| `PaddleOcrVlTask::Spotting` | Text spotting (localization + recognition) | Structured text |
| `PaddleOcrVlTask::Seal` | Seal recognition | Plain text |

## HunyuanOCR
## HunyuanOCR 1.5

[HunyuanOCR](https://huggingface.co/tencent/HunyuanOCR) is a 1B parameter OCR expert VLM. It's available in the `oar-ocr-vl` crate and supports prompt-driven image-to-text OCR.
[HunyuanOCR 1.5](https://huggingface.co/tencent/HunyuanOCR) is a lightweight OCR expert VLM. It is available in the `oar-ocr-vl` crate and supports prompt-driven image-to-text OCR. `HunyuanOcr::from_dir` automatically detects 1.5 at the model repository root and remains compatible with archived 1.0 weights under `v1.0/`.

Note: inputs are automatically resized to satisfy the model's image/token limits (e.g., max side length 2048).
HunyuanOCR 1.5 inputs use the checkpoint's 16M-pixel budget (up to a 4K square input). The 2048 value in `vision_config.max_image_size` describes the learned positional-embedding base grid; larger input grids are interpolated, as in the official implementation.

### Downloading the Model

Expand All @@ -380,6 +380,8 @@ git clone https://huggingface.co/tencent/HunyuanOCR
hf download tencent/HunyuanOCR --local-dir HunyuanOCR
```

The download places 1.5 weights directly in `HunyuanOCR/`. To use 1.0 instead, pass `HunyuanOCR/v1.0` as the model directory.

### Basic Usage

```rust,no_run
Expand All @@ -391,6 +393,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let image = load_image("document.jpg")?;
let device = parse_device("cpu")?; // or "cuda", "cuda:0"

// Repository root = HunyuanOCR 1.5; `HunyuanOCR/v1.0` also works.
let model = HunyuanOcr::from_dir("HunyuanOCR", device)?;

let prompt = "Detect and recognize text in the image, and output the text coordinates in a formatted manner.";
Expand Down
7 changes: 4 additions & 3 deletions oar-ocr-vl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This crate provides native Rust inference for document VLMs using [Candle](https
| [PaddleOCR-VL](https://huggingface.co/PaddlePaddle/PaddleOCR-VL) | 0.9B | SOTA document parsing VLM supporting 109 languages, text, tables, formulas, and 11 chart types |
| [PaddleOCR-VL-1.5](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5) | 0.9B | Next-gen PaddleOCR-VL with 94.5% on OmniDocBench v1.5, adds text spotting and seal recognition |
| [PaddleOCR-VL-1.6](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6) | 1.0B | Region-aware refinement on top of PaddleOCR-VL-1.5; 96.33% on OmniDocBench v1.6 (SOTA), drop-in compatible with the 1.5 loader |
| [HunyuanOCR](https://huggingface.co/tencent/HunyuanOCR) | 1B | End-to-end OCR VLM for multilingual document parsing, text spotting, and information extraction |
| [HunyuanOCR 1.5](https://huggingface.co/tencent/HunyuanOCR) | Lightweight | End-to-end OCR VLM for multilingual document parsing, text spotting, and information extraction (archived 1.0 weights also supported) |
| [GLM-OCR](https://huggingface.co/zai-org/GLM-OCR) | 0.9B | #1 on OmniDocBench v1.5 (94.62), optimized for real-world scenarios with MTP loss and RL training |
| [MinerU2.5](https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B) | 1.2B | Decoupled document parsing VLM with strong text, formula, and table recognition |

Expand Down Expand Up @@ -186,7 +186,7 @@ cargo run --release --features cuda --example paddleocr_vl -- \
seal.jpg
```

### HunyuanOCR (Direct Inference)
### HunyuanOCR 1.5 (Direct Inference)

```bash
cargo run --release --features cuda --example hunyuanocr -- \
Expand All @@ -196,6 +196,8 @@ cargo run --release --features cuda --example hunyuanocr -- \
document.jpg
```

The model repository root contains HunyuanOCR 1.5. The loader detects it automatically; use `--model-dir models/HunyuanOCR/v1.0` for the archived 1.0 checkpoint.

### GLM-OCR (Direct Inference)

```bash
Expand All @@ -216,4 +218,3 @@ cargo run --release --features cuda --example mineru -- \
--device cuda:0 \
document.jpg
```

10 changes: 6 additions & 4 deletions oar-ocr-vl/examples/hunyuanocr.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! HunyuanOCR Recognition Example (Candle-based)
//! HunyuanOCR 1.5 / 1.0 Recognition Example (Candle-based)
//!
//! This example demonstrates how to run `tencent/HunyuanOCR` (HunYuanVL) in Rust.
//! This example demonstrates how to run `tencent/HunyuanOCR` (1.5 at the model
//! repository root, or archived 1.0 under `v1.0/`) in Rust.
//!
//! # Usage
//!
Expand Down Expand Up @@ -30,7 +31,7 @@ use oar_ocr_vl::utils::parse_device;

#[derive(Parser)]
#[command(name = "hunyuanocr")]
#[command(about = "HunyuanOCR Recognition Example - image-to-text using Candle")]
#[command(about = "HunyuanOCR 1.5/1.0 - image-to-text using Candle")]
struct Args {
/// Path to the HunyuanOCR model directory
#[arg(short, long)]
Expand Down Expand Up @@ -91,7 +92,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let load_start = Instant::now();
let model = HunyuanOcr::from_dir(&args.model_dir, device)?;
info!(
"Model loaded in {:.2}ms",
"HunyuanOCR {} loaded in {:.2}ms",
model.version(),
load_start.elapsed().as_secs_f64() * 1000.0
);

Expand Down
39 changes: 39 additions & 0 deletions oar-ocr-vl/src/hunyuanocr/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ use oar_ocr_core::core::OCRError;
use serde::Deserialize;
use std::path::Path;

/// Checkpoint generation supported by the HunyuanOCR backend.
///
/// The upstream config does not expose a dedicated version field. V1.5 uses
/// the newer nested `text_config` schema, while the archived V1.0 checkpoint
/// keeps all text fields at the top level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HunyuanOcrVersion {
V1,
V1_5,
}

impl std::fmt::Display for HunyuanOcrVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::V1 => f.write_str("1.0"),
Self::V1_5 => f.write_str("1.5"),
}
}
}

#[derive(Debug, Clone, Deserialize)]
pub struct HunyuanOcrRopeScaling {
#[serde(default)]
Expand Down Expand Up @@ -66,12 +86,31 @@ pub struct HunyuanOcrConfig {
pub use_qk_norm: bool,
pub rope_scaling: HunyuanOcrRopeScaling,
pub vision_config: HunyuanOcrVisionConfig,

/// Present in HunyuanOCR 1.5's Transformers config. The text backbone
/// parameters remain duplicated at the top level for checkpoint
/// compatibility, so only presence is needed for version detection.
#[serde(default)]
pub text_config: Option<serde_json::Value>,
}

impl HunyuanOcrConfig {
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, OCRError> {
crate::utils::load_json_config(path, "HunyuanOCR", "config.json")
}

pub fn version(&self) -> HunyuanOcrVersion {
if self
.text_config
.as_ref()
.and_then(serde_json::Value::as_object)
.is_some()
{
HunyuanOcrVersion::V1_5
} else {
HunyuanOcrVersion::V1
}
}
Comment thread
GreatV marked this conversation as resolved.
}

#[derive(Debug, Clone, Deserialize)]
Expand Down
10 changes: 6 additions & 4 deletions oar-ocr-vl/src/hunyuanocr/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! HunyuanOCR (HunYuanVL) Vision-Language model.
//! HunyuanOCR 1.5 / 1.0 (HunYuanVL) Vision-Language model.
//!
//! This module provides native Rust inference for the `tencent/HunyuanOCR` model
//! (config `model_type=hunyuan_vl`) using Candle.
//! This module provides native Rust inference for the `tencent/HunyuanOCR`
//! model (1.5 at the repository root and archived 1.0 under `v1.0/`) using
//! Candle.

mod config;
mod llm;
Expand All @@ -10,6 +11,7 @@ mod processing;
mod vision;

pub use config::{
HunyuanOcrConfig, HunyuanOcrImageProcessorConfig, HunyuanOcrRopeScaling, HunyuanOcrVisionConfig,
HunyuanOcrConfig, HunyuanOcrImageProcessorConfig, HunyuanOcrRopeScaling, HunyuanOcrVersion,
HunyuanOcrVisionConfig,
};
pub use model::HunyuanOcr;
70 changes: 55 additions & 15 deletions oar-ocr-vl/src/hunyuanocr/model.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! HunyuanOCR model wrapper (HunYuanVLForConditionalGeneration).

use super::config::{HunyuanOcrConfig, HunyuanOcrImageProcessorConfig};
use super::config::{HunyuanOcrConfig, HunyuanOcrImageProcessorConfig, HunyuanOcrVersion};
use super::llm::HunyuanLlm;
use super::processing::{HunyuanOcrImageInputs, preprocess_image};
use super::vision::HunyuanVisionModel;
Expand All @@ -14,20 +14,21 @@ use oar_ocr_core::core::OCRError;
use std::path::Path;
use tokenizers::Tokenizer;

/// Read `generation_config.json::repetition_penalty`. Returns 1.0 (no-op) if
/// the file is missing, unparseable, or the field is absent — matches
/// HuggingFace's default. Local HunyuanOCR config ships 1.03.
fn load_repetition_penalty(model_dir: &Path) -> f64 {
/// Read `generation_config.json::repetition_penalty`, using the checkpoint
/// generation's reference fallback when the field is absent. V1.0 ships 1.03
/// in the file; V1.5 omits it there but specifies 1.08 for its benchmark and
/// client inference paths.
fn load_repetition_penalty(model_dir: &Path, default: f64) -> f64 {
let path = model_dir.join("generation_config.json");
let Ok(contents) = std::fs::read_to_string(&path) else {
return 1.0;
return default;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&contents) else {
return 1.0;
return default;
};
v.get("repetition_penalty")
.and_then(|x| x.as_f64())
.unwrap_or(1.0)
.unwrap_or(default)
}

/// Read `generation_config.json::eos_token_id`. The official config provides
Expand Down Expand Up @@ -106,13 +107,15 @@ pub struct HunyuanOcr {
/// `A, B, … BZ, BZW, BZWW, BZWWZ …`) that never hit EOS. Default 1.0 means
/// the value isn't applied.
repetition_penalty: f64,
version: HunyuanOcrVersion,
}

impl HunyuanOcr {
pub fn from_dir(model_dir: impl AsRef<Path>, device: Device) -> Result<Self, OCRError> {
let model_dir = model_dir.as_ref();

let cfg = HunyuanOcrConfig::from_path(model_dir.join("config.json"))?;
let version = cfg.version();
let image_cfg =
HunyuanOcrImageProcessorConfig::from_path(model_dir.join("preprocessor_config.json"))?;

Expand Down Expand Up @@ -148,9 +151,15 @@ impl HunyuanOcr {
};

let llm = HunyuanLlm::load(&cfg, vb.pp("model"))?;
let vision = HunyuanVisionModel::load(&cfg.vision_config, vb.pp("vit"))?;
let vision = HunyuanVisionModel::load(&cfg.vision_config, version, vb.pp("vit"))?;

let repetition_penalty = load_repetition_penalty(model_dir);
// V1.5's generation_config.json omits the value, while its reference
// benchmark/client paths use 1.08. V1.0 ships its own value (1.03).
let default_repetition_penalty = match version {
HunyuanOcrVersion::V1 => 1.0,
HunyuanOcrVersion::V1_5 => 1.08,
};
let repetition_penalty = load_repetition_penalty(model_dir, default_repetition_penalty);

Ok(Self {
device,
Expand All @@ -162,9 +171,15 @@ impl HunyuanOcr {
vision,
stop_token_ids,
repetition_penalty,
version,
})
}

/// The checkpoint generation detected from `config.json`.
pub fn version(&self) -> HunyuanOcrVersion {
self.version
}

/// Generate OCR output for one or more images with custom instructions.
///
/// Supports true GPU batching when multiple images are provided.
Expand Down Expand Up @@ -269,11 +284,12 @@ impl HunyuanOcr {
image,
&self.image_cfg,
&self.cfg.vision_config,
self.version,
&self.device,
self.dtype,
)?;

let prompt = build_prompt(instruction);
let prompt = build_prompt(instruction, self.version);
let enc = self
.tokenizer
.encode(prompt, false)
Expand Down Expand Up @@ -565,11 +581,16 @@ impl HunyuanOcr {
}
}

fn build_prompt(instruction: &str) -> String {
// Matches the tokenizer chat_template in the model repo (empty system message).
// The model expects the generation prompt to end with `<|hy_User|>`.
fn build_prompt(instruction: &str, version: HunyuanOcrVersion) -> String {
// V1.0's reference invocation supplies an empty system message, which the
// template renders as placeholder no.3. V1.5's official invocation starts
// directly with the user message and therefore omits that token.
let system_prefix = match version {
HunyuanOcrVersion::V1 => "<|hy_place▁holder▁no▁3|>",
HunyuanOcrVersion::V1_5 => "",
};
format!(
"<|hy_begin▁of▁sentence|><|hy_place▁holder▁no▁3|>\
"<|hy_begin▁of▁sentence|>{system_prefix}\
<|hy_place▁holder▁no▁100|><|hy_place▁holder▁no▁102|><|hy_place▁holder▁no▁101|>{instruction}\
<|hy_User|>"
)
Expand Down Expand Up @@ -701,3 +722,22 @@ fn build_position_ids(
)
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn v15_prompt_omits_legacy_empty_system_token() {
let prompt = build_prompt("read", HunyuanOcrVersion::V1_5);
assert!(prompt.starts_with("<|hy_begin▁of▁sentence|><|hy_place▁holder▁no▁100|>"));
assert!(!prompt.contains("<|hy_place▁holder▁no▁3|>"));
assert!(prompt.ends_with("read<|hy_User|>"));
}

#[test]
fn v1_prompt_keeps_empty_system_token() {
let prompt = build_prompt("read", HunyuanOcrVersion::V1);
assert!(prompt.starts_with("<|hy_begin▁of▁sentence|><|hy_place▁holder▁no▁3|>"));
}
}
Loading
Loading