#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
use anyhow::{Context as _, Result};
use encoding_rs::UTF_8;
use indicatif::ProgressBar;
use llama_cpp_2::LogOptions;
use llama_cpp_2::context::LlamaContext;
use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::llama_backend::LlamaBackend;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{LlamaChatMessage, LlamaChatTemplate, LlamaModel};
use llama_cpp_2::mtmd::{MtmdBitmap, MtmdContext, MtmdContextParams, MtmdInputText};
use llama_cpp_2::sampling::LlamaSampler;
type GgmlLogCallback = Option<unsafe extern "C" fn(c_int, *const c_char, *mut c_void)>;
unsafe extern "C" {
fn mtmd_helper_log_set(callback: GgmlLogCallback, user_data: *mut c_void);
}
unsafe extern "C" fn noop_log(_level: c_int, _text: *const c_char, _user_data: *mut c_void) {}
use serde::{Deserialize, Serialize};
use std::ffi::CString;
use std::io::IsTerminal as _;
use std::num::NonZeroU32;
use std::os::raw::{c_char, c_int, c_void};
use std::time::{Duration, Instant};
const CTX: u32 = 8192;
const NONZERO_CTX: NonZeroU32 = NonZeroU32::new(CTX).expect("CTX is nonzero");
const N_BATCH: i32 = 512;
const MAX_NEW_TOKENS: i32 = 4096;
const IMAGE_MAX_TOKENS: i32 = 2048;
const LOC_BINS: f32 = 1000.0;
const PROGRESS_DEADLINE: Duration = Duration::from_secs(2);
const PROGRESS_TICK: Duration = Duration::from_millis(100);
const PROGRESS_MSG: &str = "Analyzing image...";
const FIELD_TRANSCRIBE: &str = "\"regions\": an array of {\"text\": string, \"quad\": [x1,y1,x2,y2,x3,y3,x4,y4]} for each run of visible text, where the quad is its bounding quadrilateral with coordinates normalized to 0-1000 relative to image width and height (omit the quad if you cannot localize the text)";
const FIELD_CAPTION: &str = "\"caption\": a single paragraph describing the image";
const FIELD_OBJECTS: &str = "\"objects\": an array of {\"label\": string, \"bbox\": [x1,y1,x2,y2]} for the salient objects, where bbox is the axis-aligned bounding box with coordinates normalized to 0-1000 relative to image width and height";
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OcrText {
text: String,
regions: Vec<OcrRegion>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OcrRegion {
text: String,
quad: [i32; 8],
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct DetectedObject {
label: String,
bbox: [i32; 4],
}
#[derive(Clone, Copy)]
pub(crate) struct Request {
pub(crate) transcribe: bool,
pub(crate) caption: bool,
pub(crate) objects: bool,
}
#[derive(Default)]
pub(crate) struct Analysis {
pub(crate) transcribe: Option<OcrText>,
pub(crate) caption: Option<String>,
pub(crate) objects: Option<Vec<DetectedObject>>,
}
pub(crate) fn analyze(image_bytes: &[u8], request: Request) -> Result<Analysis> {
llama_cpp_2::send_logs_to_tracing(LogOptions::default().with_logs_enabled(false));
unsafe { mtmd_helper_log_set(Some(noop_log), std::ptr::null_mut()) };
let backend = LlamaBackend::init()?;
let model_path = crate::engine::model::file("Qwen3VL-2B-Instruct-Q4_K_M.gguf")?;
let mmproj_path = crate::engine::model::file("mmproj-Qwen3VL-2B-Instruct-F16.gguf")?;
let model = LlamaModel::load_from_file(&backend, &model_path, &LlamaModelParams::default())?;
let n_threads = num_cpus::get_physical() as i32;
let mtmd_params = MtmdContextParams {
use_gpu: false,
print_timings: false,
n_threads,
media_marker: CString::new(llama_cpp_2::mtmd::mtmd_default_marker())
.context("media marker contains null")?,
image_min_tokens: -1,
image_max_tokens: IMAGE_MAX_TOKENS,
};
let mtmd_ctx =
MtmdContext::init_from_file(&mmproj_path.to_string_lossy(), &model, &mtmd_params)?;
let chat_template = model.chat_template(None)?;
let bitmap = MtmdBitmap::from_buffer(&mtmd_ctx, image_bytes, false)?;
let width = bitmap.nx();
let height = bitmap.ny();
let context_params = LlamaContextParams::default()
.with_n_threads(n_threads)
.with_n_threads_batch(n_threads)
.with_n_batch(N_BATCH.try_into()?)
.with_n_ctx(Some(NONZERO_CTX));
let mut context = model.new_context(&backend, context_params)?;
let prompt = build_prompt(request);
let raw = generate(
&model,
&mtmd_ctx,
&chat_template,
&mut context,
&bitmap,
&prompt,
)?;
Ok(parse_analysis(&raw, request, width, height))
}
fn build_prompt(request: Request) -> String {
let mut fields = Vec::new();
if request.transcribe {
fields.push(FIELD_TRANSCRIBE);
}
if request.caption {
fields.push(FIELD_CAPTION);
}
if request.objects {
fields.push(FIELD_OBJECTS);
}
format!(
"Analyze the image and respond with a single JSON object containing {}. Output only the JSON object.",
fields.join(", ")
)
}
struct InferenceProgress {
is_tty: bool,
started: Instant,
bar: Option<ProgressBar>,
}
impl InferenceProgress {
fn new() -> Self {
Self {
is_tty: std::io::stdout().is_terminal(),
started: Instant::now(),
bar: None,
}
}
fn maybe_reveal(&mut self) {
if self.bar.is_some() || !self.is_tty {
return;
}
if self.started.elapsed() >= PROGRESS_DEADLINE {
let bar = ProgressBar::new_spinner();
bar.set_message(PROGRESS_MSG);
bar.enable_steady_tick(PROGRESS_TICK);
self.bar = Some(bar);
}
}
}
impl Drop for InferenceProgress {
fn drop(&mut self) {
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
}
}
}
fn generate(
model: &LlamaModel,
mtmd_ctx: &MtmdContext,
chat_template: &LlamaChatTemplate,
context: &mut LlamaContext,
bitmap: &MtmdBitmap,
prompt: &str,
) -> Result<String> {
let marker = llama_cpp_2::mtmd::mtmd_default_marker();
let full_prompt = if prompt.contains(marker) {
prompt.to_string()
} else {
format!("{prompt}{marker}")
};
let msg = LlamaChatMessage::new("user".to_string(), full_prompt)?;
let formatted = model.apply_chat_template(chat_template, &[msg], true)?;
let input = MtmdInputText {
text: formatted,
add_special: true,
parse_special: true,
};
let chunks = mtmd_ctx.tokenize(input, &[bitmap])?;
let mut batch = LlamaBatch::new(1, 1);
let mut progress = InferenceProgress::new();
let n_past_start = chunks.eval_chunks(mtmd_ctx, context, 0, 0, N_BATCH, true)?;
progress.maybe_reveal();
let budget = (CTX as i32 - chunks.total_tokens() as i32).clamp(0, MAX_NEW_TOKENS);
let mut sampler = LlamaSampler::chain_simple([LlamaSampler::greedy()]);
let mut output = String::new();
let mut decoder = UTF_8.new_decoder();
for n_past in (n_past_start..).take(budget as usize) {
progress.maybe_reveal();
let token = sampler.sample(context, -1);
sampler.accept(token);
if model.is_eog_token(token) {
break;
}
let piece = model.token_to_piece(token, &mut decoder, true, None)?;
output.push_str(&piece);
batch.clear();
batch.add(token, n_past, &[0], true)?;
context.decode(&mut batch)?;
}
Ok(output)
}
fn loc_to_px(loc: i32, dim: u32) -> i32 {
((loc as f32 + 0.5) / LOC_BINS * dim as f32).round() as i32
}
fn extract_json(raw: &str) -> Option<&str> {
let (open, close) = if raw.contains('{') {
('{', '}')
} else if raw.contains('[') {
('[', ']')
} else {
return None;
};
let start = raw.find(open)?;
let end = raw.rfind(close)?;
if end >= start {
Some(&raw[start..=end])
} else {
None
}
}
#[derive(serde::Deserialize)]
struct RegionJson {
text: String,
quad: Vec<i32>,
}
#[derive(serde::Deserialize)]
struct ObjectJson {
label: String,
bbox: Vec<i32>,
}
#[derive(Default, serde::Deserialize)]
struct CombinedJson {
regions: Option<Vec<RegionJson>>,
caption: Option<String>,
objects: Option<Vec<ObjectJson>>,
}
fn parse_analysis(raw: &str, request: Request, width: u32, height: u32) -> Analysis {
let parsed = extract_json(raw)
.and_then(|json| serde_json::from_str::<CombinedJson>(json).ok())
.unwrap_or_else(|| {
log::warn!("vision JSON parse failed, returning empty results");
CombinedJson::default()
});
let mut analysis = Analysis::default();
if request.transcribe {
analysis.transcribe = parsed.regions.map(|regions| build_ocr(regions, width, height));
}
if request.caption {
analysis.caption = parsed.caption.map(|caption| strip_special(&caption));
}
if request.objects {
analysis.objects = parsed
.objects
.map(|objects| build_objects(objects, width, height));
}
analysis
}
fn build_ocr(regions: Vec<RegionJson>, width: u32, height: u32) -> OcrText {
let mut parsed = Vec::new();
for region in regions {
if region.text.is_empty() {
continue;
}
let corners = match *region.quad.as_slice() {
[x1, y1, x2, y2] => [x1, y1, x2, y1, x2, y2, x1, y2],
[x1, y1, x2, y2, x3, y3, x4, y4] => [x1, y1, x2, y2, x3, y3, x4, y4],
_ => continue,
};
let mut quad = [0i32; 8];
for (i, &loc) in corners.iter().enumerate() {
quad[i] = loc_to_px(loc, if i % 2 == 0 { width } else { height });
}
parsed.push(OcrRegion {
text: region.text,
quad,
});
}
let text = parsed
.iter()
.map(|region| region.text.as_str())
.collect::<Vec<_>>()
.join("\n");
OcrText {
text,
regions: parsed,
}
}
fn build_objects(objects: Vec<ObjectJson>, width: u32, height: u32) -> Vec<DetectedObject> {
objects
.into_iter()
.filter(|object| !object.label.is_empty() && object.bbox.len() == 4)
.map(|object| DetectedObject {
label: object.label,
bbox: [
loc_to_px(object.bbox[0], width),
loc_to_px(object.bbox[1], height),
loc_to_px(object.bbox[2], width),
loc_to_px(object.bbox[3], height),
],
})
.collect()
}
fn strip_special(raw: &str) -> String {
raw.replace("<|im_end|>", "").trim().to_string()
}