use image::RgbImage;
use ndarray::{Array2, Array4};
use ort::inputs;
use ort::session::Session;
use ort::value::{Tensor, TensorRef};
use crate::core::config::AccelerationConfig;
use crate::layout::error::LayoutError;
#[cfg(not(paddle_ocr))]
use crate::layout::model_manager::ModelManifestEntry;
use crate::layout::session::build_session;
#[cfg(paddle_ocr)]
use crate::paddle_ocr::ModelManifestEntry;
const RELEASE_BASE_URL: &str = "https://github.com/RapidAI/RapidLaTeXOCR/releases/download/v0.0.0";
const MODEL_FILES: [(&str, &str, u64); 4] = [
(
"image_resizer.onnx",
"e0b075c39700f64d50400f39c8fc186bbb3b5d84d31864008313f376603aca9d",
38_967_751,
),
(
"encoder.onnx",
"01bf5dc25539ca0cd5b1bd29296ea495977a6ba5f629dc4178277809d26e5e7d",
89_008_136,
),
(
"decoder.onnx",
"bd695497bf1b22279b7626f5916c79226e1e244c84355f8da7edfd2d921d0072",
50_952_726,
),
(
"tokenizer.json",
"1dc27b18d6a518d0d5ff3f4bb7bd98521fe80ad39e5b2a246d4109f1bb9d5019",
24_174,
),
];
const MAX_WIDTH: u32 = 672;
const MAX_HEIGHT: u32 = 192;
const MIN_WIDTH: u32 = 32;
const MIN_HEIGHT: u32 = 32;
const DIVISOR: u32 = 32;
const BOS_TOKEN: i64 = 1;
const EOS_TOKEN: i64 = 2;
const FIRST_CONTENT_TOKEN: i64 = 4;
const MAX_SEQ_LEN: usize = 512;
const REPETITION_CUTOFF: usize = 8;
const NORM_MEAN: f32 = 0.7931;
const NORM_STD: f32 = 0.1738;
const INK_BORDER: u32 = 8;
const INIT_RETRY_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
#[derive(Debug, Clone)]
#[cfg_attr(alef, alef(skip))]
pub struct FormulaModelPaths {
pub resizer: std::path::PathBuf,
pub encoder: std::path::PathBuf,
pub decoder: std::path::PathBuf,
pub tokenizer: std::path::PathBuf,
}
fn default_cache_dir() -> std::path::PathBuf {
hf_hub::resolve_cache_dir().join("formula-recognition")
}
#[cfg_attr(alef, alef(skip))]
pub fn manifest() -> Vec<ModelManifestEntry> {
MODEL_FILES
.iter()
.map(|(name, sha256, size)| ModelManifestEntry {
relative_path: format!("formula-recognition/{name}"),
sha256: (*sha256).to_string(),
size_bytes: *size,
source_url: format!("{RELEASE_BASE_URL}/{name}"),
})
.collect()
}
#[cfg_attr(alef, alef(skip))]
pub fn models_cached_in(dir: Option<&std::path::Path>) -> bool {
let dir = dir.map(std::path::Path::to_path_buf).unwrap_or_else(default_cache_dir);
MODEL_FILES.iter().all(|(name, ..)| dir.join(name).is_file())
}
#[cfg_attr(alef, alef(skip))]
pub fn models_cached() -> bool {
models_cached_in(None)
}
pub(crate) fn probe_models_in(dir: Option<&std::path::Path>) -> (usize, usize, usize) {
use std::io::Read;
let dir = dir.map(std::path::Path::to_path_buf).unwrap_or_else(default_cache_dir);
let mut present = 0;
let mut missing = 0;
let mut invalid = 0;
for (name, _, expected_size) in MODEL_FILES {
let path = dir.join(name);
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
missing += 1;
continue;
}
Err(_) => {
invalid += 1;
continue;
}
};
if metadata.len() != expected_size {
invalid += 1;
continue;
}
let readable = std::fs::File::open(path)
.and_then(|mut file| {
let mut byte = [0_u8; 1];
file.read_exact(&mut byte)
})
.is_ok();
if readable {
present += 1;
} else {
invalid += 1;
}
}
(present, missing, invalid)
}
pub(crate) fn cached_models_verified_in(dir: Option<&std::path::Path>) -> bool {
let dir = dir.map(std::path::Path::to_path_buf).unwrap_or_else(default_cache_dir);
MODEL_FILES
.iter()
.all(|(name, sha256, _)| crate::model_download::verify_sha256(&dir.join(name), sha256, name).is_ok())
}
const MAX_MODEL_BYTES: u64 = 256 * 1024 * 1024;
fn download_to_staging(url: &str, staging: &std::path::Path) -> Result<(), String> {
let result = (|| {
let response = ureq::get(url)
.call()
.map_err(|e| format!("download {url} failed: {e}"))?;
if response.status() != 200 {
return Err(format!("download {url} failed: HTTP {}", response.status()));
}
let bytes = response
.into_body()
.with_config()
.limit(MAX_MODEL_BYTES)
.read_to_vec()
.map_err(|e| format!("download {url} read failed: {e}"))?;
std::fs::write(staging, bytes).map_err(|e| format!("write {} failed: {e}", staging.display()))?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(staging);
}
result
}
#[cfg_attr(alef, alef(skip))]
pub fn ensure_models_in(dir: Option<&std::path::Path>) -> Result<FormulaModelPaths, String> {
let dir = dir.map(std::path::Path::to_path_buf).unwrap_or_else(default_cache_dir);
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create model cache dir {}: {e}", dir.display()))?;
for (name, sha256, _) in MODEL_FILES {
let target = dir.join(name);
let lock = crate::model_download::download_lock(&format!("formula-recognition/{name}"));
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if target.is_file() && crate::model_download::verify_sha256(&target, sha256, name).is_ok() {
continue;
}
let url = format!("{RELEASE_BASE_URL}/{name}");
let staging = dir.join(format!(".{name}.{}.tmp", std::process::id()));
let dl_staging = staging.clone();
crate::model_download::with_download_deadline(name, move || download_to_staging(&url, &dl_staging))?;
let published = crate::layout::model_manager::atomic_publish(&staging, &target, &dir, sha256, name);
let _ = std::fs::remove_file(&staging);
published?;
}
Ok(FormulaModelPaths {
resizer: dir.join("image_resizer.onnx"),
encoder: dir.join("encoder.onnx"),
decoder: dir.join("decoder.onnx"),
tokenizer: dir.join("tokenizer.json"),
})
}
#[cfg_attr(alef, alef(skip))]
pub fn ensure_models() -> Result<FormulaModelPaths, String> {
ensure_models_in(None)
}
struct PooledRecognizer {
recognizer: FormulaRecognizer,
acceleration: Option<AccelerationConfig>,
}
static RECOGNIZER: std::sync::Mutex<Option<PooledRecognizer>> = std::sync::Mutex::new(None);
static LAST_INIT_FAILURE: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
pub(crate) fn recognize_crop(crop: &RgbImage, accel: Option<&AccelerationConfig>) -> Result<Option<String>, String> {
{
let last = LAST_INIT_FAILURE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(at) = *last
&& at.elapsed() < INIT_RETRY_COOLDOWN
{
return Err("formula recognizer initialization failed recently; retry later".to_string());
}
}
let mut pool = RECOGNIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let rebuild = match pool.as_ref() {
None => true,
Some(pooled) => pooled.acceleration.as_ref() != accel,
};
if rebuild {
let init = ensure_models().and_then(|paths| {
FormulaRecognizer::load(&paths, accel).map_err(|e| format!("formula model load failed: {e}"))
});
match init {
Ok(recognizer) => {
*pool = Some(PooledRecognizer {
recognizer,
acceleration: accel.cloned(),
});
*LAST_INIT_FAILURE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
Err(e) => {
*LAST_INIT_FAILURE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(std::time::Instant::now());
return Err(e);
}
}
}
let pooled = pool.as_mut().expect("initialized above");
pooled
.recognizer
.recognize(crop)
.map_err(|e| format!("formula recognition failed: {e}"))
}
#[cfg(any(feature = "pdf", feature = "ocr", feature = "ocr-wasm"))]
pub(crate) async fn recognize_crop_blocking(
crop: RgbImage,
accel: Option<AccelerationConfig>,
) -> Result<Option<String>, String> {
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
{
tokio::task::spawn_blocking(move || recognize_crop(&crop, accel.as_ref()))
.await
.map_err(|e| format!("formula recognition task failed: {e}"))?
}
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
{
recognize_crop(&crop, accel.as_ref())
}
}
#[cfg_attr(alef, alef(skip))]
pub fn recognize_for_test(crop: &RgbImage) -> Result<Option<String>, String> {
recognize_crop(crop, None)
}
pub(crate) struct FormulaRecognizer {
resizer: Session,
encoder: Session,
decoder: Session,
tokenizer: tokenizers::Tokenizer,
}
impl FormulaRecognizer {
pub(crate) fn load(paths: &FormulaModelPaths, accel: Option<&AccelerationConfig>) -> Result<Self, LayoutError> {
let threads = crate::core::config::concurrency::resolve_thread_budget(None);
let resizer = build_session(&paths.resizer.to_string_lossy(), accel, threads)?;
let encoder = build_session(&paths.encoder.to_string_lossy(), accel, threads)?;
let decoder = build_session(&paths.decoder.to_string_lossy(), accel, threads)?;
let mut tokenizer = tokenizers::Tokenizer::from_file(&paths.tokenizer)
.map_err(|e| LayoutError::ModelDownload(format!("formula tokenizer failed to load: {e}")))?;
if tokenizer.get_decoder().is_none() {
tokenizer.with_decoder(Some(tokenizers::decoders::byte_level::ByteLevel::default()));
}
Ok(Self {
resizer,
encoder,
decoder,
tokenizer,
})
}
pub(crate) fn recognize(&mut self, crop: &RgbImage) -> Result<Option<String>, LayoutError> {
let Some(gray) = preprocess_gray(crop) else {
return Ok(None);
};
let sized = self.resize_to_model_width(&gray)?;
let context = self.encode(&sized)?;
let ids = self.greedy_decode(&context)?;
if ids.is_empty() {
return Ok(None);
}
let raw = self
.tokenizer
.decode(&ids.iter().map(|&i| i as u32).collect::<Vec<_>>(), true)
.map_err(|e| LayoutError::InvalidOutput(format!("formula token decode failed: {e}")))?;
let cleaned = post_process(&raw);
Ok(if cleaned.is_empty() { None } else { Some(cleaned) })
}
fn resize_to_model_width(&mut self, gray: &GrayCanvas) -> Result<Array4<f32>, LayoutError> {
let mut width = gray.width.clamp(MIN_WIDTH, MAX_WIDTH);
let mut height = gray.height.clamp(MIN_HEIGHT, MAX_HEIGHT);
let mut tensor = gray.to_tensor(width, height);
for _ in 0..10 {
let input = Tensor::from_array(tensor.clone()).map_err(LayoutError::Ort)?;
let outputs = self.resizer.run(inputs!["input" => input]).map_err(LayoutError::Ort)?;
let (shape, data) = outputs[0].try_extract_tensor::<f32>().map_err(LayoutError::Ort)?;
let argmax = argmax_last_row(shape, data)?;
let predicted = ((argmax as u32) + 1) * DIVISOR;
let current_padded = pad_up(width, DIVISOR);
if predicted == current_padded {
break;
}
let ratio = f64::from(predicted) / f64::from(current_padded);
width = ((f64::from(width) * ratio).round().max(1.0) as u32).clamp(1, MAX_WIDTH);
height = ((f64::from(height) * ratio).round().max(1.0) as u32).clamp(1, MAX_HEIGHT);
tensor = gray.to_tensor(width, height);
}
Ok(tensor)
}
fn encode(&mut self, x: &Array4<f32>) -> Result<ndarray::Array3<f32>, LayoutError> {
let input = Tensor::from_array(x.clone()).map_err(LayoutError::Ort)?;
let outputs = self.encoder.run(inputs!["input" => input]).map_err(LayoutError::Ort)?;
let (shape, data) = outputs[0].try_extract_tensor::<f32>().map_err(LayoutError::Ort)?;
let dims: Vec<usize> = shape.iter().map(|&d| d as usize).collect();
if dims.len() != 3 {
return Err(LayoutError::InvalidOutput(format!(
"formula encoder returned rank {} output, expected 3",
dims.len()
)));
}
ndarray::Array3::from_shape_vec((dims[0], dims[1], dims[2]), data.to_vec())
.map_err(|e| LayoutError::InvalidOutput(format!("formula encoder output reshape failed: {e}")))
}
fn greedy_decode(&mut self, context: &ndarray::Array3<f32>) -> Result<Vec<i64>, LayoutError> {
let mut out: Vec<i64> = vec![BOS_TOKEN];
let mut repeats = 1usize;
for _ in 0..MAX_SEQ_LEN {
let window = &out[out.len().saturating_sub(MAX_SEQ_LEN)..];
let len = window.len();
let x = Array2::from_shape_vec((1, len), window.to_vec())
.map_err(|e| LayoutError::InvalidOutput(format!("decoder input build failed: {e}")))?;
let mask = Array2::from_elem((1, len), true);
let x_t = Tensor::from_array(x).map_err(LayoutError::Ort)?;
let mask_t = Tensor::from_array(mask).map_err(LayoutError::Ort)?;
let ctx_t = TensorRef::from_array_view(context.view()).map_err(LayoutError::Ort)?;
let outputs = self
.decoder
.run(inputs!["x" => x_t, "mask" => mask_t, "context" => ctx_t])
.map_err(LayoutError::Ort)?;
let (shape, data) = outputs[0].try_extract_tensor::<f32>().map_err(LayoutError::Ort)?;
let next = argmax_last_row(shape, data)? as i64;
if next == EOS_TOKEN {
break;
}
repeats = if Some(&next) == out.last() { repeats + 1 } else { 1 };
out.push(next);
if repeats >= REPETITION_CUTOFF {
let keep = out.len() - repeats;
out.truncate(keep);
break;
}
}
Ok(out.into_iter().skip(1).filter(|&t| t >= FIRST_CONTENT_TOKEN).collect())
}
}
struct GrayCanvas {
pixels: image::GrayImage,
width: u32,
height: u32,
}
impl GrayCanvas {
fn to_tensor(&self, width: u32, height: u32) -> Array4<f32> {
let w = width.clamp(1, MAX_WIDTH);
let h = height.clamp(1, MAX_HEIGHT);
let filter = if w > self.width || h > self.height {
image::imageops::FilterType::Triangle
} else {
image::imageops::FilterType::Lanczos3
};
let resized = image::imageops::resize(&self.pixels, w, h, filter);
let padded_w = pad_up(w.max(MIN_WIDTH), DIVISOR);
let padded_h = pad_up(h.max(MIN_HEIGHT), DIVISOR);
let white = (1.0 - NORM_MEAN) / NORM_STD;
let mut tensor = Array4::<f32>::from_elem((1, 1, padded_h as usize, padded_w as usize), white);
for y in 0..h {
for x in 0..w {
let v = f32::from(resized.get_pixel(x, y).0[0]) / 255.0;
tensor[[0, 0, y as usize, x as usize]] = (v - NORM_MEAN) / NORM_STD;
}
}
tensor
}
}
fn pad_up(v: u32, divisor: u32) -> u32 {
v.div_ceil(divisor) * divisor
}
fn argmax_last_row(shape: &[i64], data: &[f32]) -> Result<usize, LayoutError> {
let classes = *shape.last().unwrap_or(&0) as usize;
if classes == 0 || data.len() < classes {
return Err(LayoutError::InvalidOutput(format!(
"logits buffer of {} values cannot hold a row of {classes}",
data.len()
)));
}
let row = &data[data.len() - classes..];
Ok(row
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(i, _)| i)
.unwrap_or(0))
}
fn preprocess_gray(crop: &RgbImage) -> Option<GrayCanvas> {
let gray = image::imageops::grayscale(crop);
let (min, max) = gray
.pixels()
.fold((u8::MAX, u8::MIN), |(lo, hi), p| (lo.min(p.0[0]), hi.max(p.0[0])));
if max <= min {
return None; }
let range = f32::from(max - min);
let mut normalized = image::GrayImage::new(gray.width(), gray.height());
let mut sum: u64 = 0;
for (src, dst) in gray.pixels().zip(normalized.pixels_mut()) {
let v = ((f32::from(src.0[0] - min) / range) * 255.0).round() as u8;
dst.0[0] = v;
sum += u64::from(v);
}
let mean = sum / (normalized.len() as u64).max(1);
if mean <= 128 {
image::imageops::invert(&mut normalized);
}
let mut min_x = u32::MAX;
let mut min_y = u32::MAX;
let mut max_x = 0u32;
let mut max_y = 0u32;
for (x, y, p) in normalized.enumerate_pixels() {
if p.0[0] < 250 {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
if min_x > max_x {
return None; }
let x0 = min_x.saturating_sub(INK_BORDER);
let y0 = min_y.saturating_sub(INK_BORDER);
let x1 = (max_x + 1 + INK_BORDER).min(normalized.width());
let y1 = (max_y + 1 + INK_BORDER).min(normalized.height());
let cropped = image::imageops::crop_imm(&normalized, x0, y0, x1 - x0, y1 - y0).to_image();
let (width, height) = cropped.dimensions();
Some(GrayCanvas {
pixels: cropped,
width,
height,
})
}
fn post_process(s: &str) -> String {
use std::sync::OnceLock;
static TEXT_RE: OnceLock<regex::Regex> = OnceLock::new();
static PAIR_RES: OnceLock<[regex::Regex; 3]> = OnceLock::new();
let text_re = TEXT_RE.get_or_init(|| {
regex::Regex::new(r"(\\(operatorname|mathrm|text|mathbf)\s?\*?\s?\{.*?\})").expect("static regex")
});
let pair_res = PAIR_RES.get_or_init(|| {
let letter = "[a-zA-Z]";
let noletter = r"[\W_^\d]";
[
regex::Regex::new(&format!(r"(?P<a>{noletter})\s+(?P<b>{noletter})")).expect("static regex"),
regex::Regex::new(&format!(r"(?P<a>{noletter})\s+(?P<b>{letter})")).expect("static regex"),
regex::Regex::new(&format!(r"(?P<a>{letter})\s+(?P<b>{noletter})")).expect("static regex"),
]
});
const SPACE_SENTINEL: &str = "\u{E000}";
let mut out = text_re
.replace_all(s, |caps: ®ex::Captures<'_>| caps[0].replace(' ', ""))
.into_owned();
out = out.replace("\\ ", SPACE_SENTINEL);
loop {
let mut next = out.clone();
for re in pair_res.iter() {
next = re.replace_all(&next, "$a$b").into_owned();
}
if next == out {
break;
}
out = next;
}
out.replace(SPACE_SENTINEL, "\\ ").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pad_up_rounds_to_divisor() {
assert_eq!(pad_up(1, 32), 32);
assert_eq!(pad_up(32, 32), 32);
assert_eq!(pad_up(33, 32), 64);
}
#[test]
fn argmax_picks_the_last_row_maximum() {
let data = [9.0, 0.0, 0.0, 0.1, 5.0, 0.2];
assert_eq!(argmax_last_row(&[2, 3], &data).unwrap(), 1);
}
#[test]
fn argmax_on_empty_output_errors_instead_of_panicking() {
assert!(argmax_last_row(&[0], &[]).is_err());
assert!(argmax_last_row(&[1, 4], &[0.0]).is_err());
}
#[test]
fn post_process_collapses_bpe_spaces() {
assert_eq!(post_process("E = m c ^ { 2 }"), "E=m c^{2}");
}
#[test]
fn post_process_keeps_operatorname_groups() {
let s = r"\operatorname* { l i m }";
let out = post_process(s);
assert!(out.starts_with(r"\operatorname*"), "got: {out}");
assert!(!out.contains("{ l"), "inner spaces collapse: {out}");
}
#[test]
fn post_process_preserves_explicit_space_command() {
assert_eq!(post_process(r"a \ b"), r"a\ b");
}
#[test]
fn models_cached_in_requires_every_file() {
let dir = std::env::temp_dir().join(format!("xberg-formula-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(!models_cached_in(Some(&dir)), "empty dir is not cached");
for (name, ..) in MODEL_FILES {
std::fs::write(dir.join(name), b"stub").unwrap();
}
assert!(models_cached_in(Some(&dir)), "all files present counts as cached");
std::fs::remove_file(dir.join(MODEL_FILES[0].0)).unwrap();
assert!(!models_cached_in(Some(&dir)), "one missing file breaks the cache");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn manifest_lists_every_model_file() {
let m = manifest();
assert_eq!(m.len(), 4);
assert!(m.iter().all(|e| e.source_url.starts_with(RELEASE_BASE_URL)));
assert!(m.iter().all(|e| e.sha256.len() == 64));
}
#[test]
fn bounded_cache_probe_does_not_hash_exact_size_artifacts() {
let dir = tempfile::TempDir::new().unwrap();
for (name, _, size) in MODEL_FILES {
let file = std::fs::File::create(dir.path().join(name)).unwrap();
file.set_len(size).unwrap();
}
assert_eq!(probe_models_in(Some(dir.path())), (MODEL_FILES.len(), 0, 0));
assert!(!cached_models_verified_in(Some(dir.path())));
}
#[test]
fn bounded_cache_probe_reports_wrong_size_artifact_invalid() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join(MODEL_FILES[0].0), b"truncated").unwrap();
assert_eq!(probe_models_in(Some(dir.path())), (0, MODEL_FILES.len() - 1, 1));
}
#[test]
fn gray_canvas_tensor_is_padded_and_normalized() {
let mut img = RgbImage::from_pixel(100, 40, image::Rgb([255, 255, 255]));
for x in 30..70 {
img.put_pixel(x, 20, image::Rgb([0, 0, 0]));
}
let canvas = preprocess_gray(&img).expect("inked crop");
let t = canvas.to_tensor(canvas.width, canvas.height);
let shape = t.shape();
assert_eq!(shape[0], 1);
assert_eq!(shape[1], 1);
assert_eq!(shape[2] % 32, 0);
assert_eq!(shape[3] % 32, 0);
let white = (1.0 - NORM_MEAN) / NORM_STD;
assert!((t[[0, 0, 0, 0]] - white).abs() < 0.2, "border stays white-ish");
}
#[test]
fn blank_crops_yield_no_canvas() {
let blank = RgbImage::from_pixel(96, 48, image::Rgb([255, 255, 255]));
assert!(preprocess_gray(&blank).is_none());
let gray_flat = RgbImage::from_pixel(96, 48, image::Rgb([180, 180, 180]));
assert!(preprocess_gray(&gray_flat).is_none());
}
#[test]
fn low_contrast_sparse_ink_survives_normalization() {
let mut img = RgbImage::from_pixel(300, 120, image::Rgb([230, 230, 230]));
for x in 40..260 {
img.put_pixel(x, 60, image::Rgb([180, 180, 180]));
}
let canvas = preprocess_gray(&img).expect("sparse ink must survive");
assert!(canvas.height <= 1 + 2 * INK_BORDER);
}
#[test]
fn dark_background_inverts() {
let mut img = RgbImage::from_pixel(64, 32, image::Rgb([10, 10, 10]));
for x in 20..44 {
img.put_pixel(x, 16, image::Rgb([240, 240, 240]));
}
let canvas = preprocess_gray(&img).expect("inked");
let light = canvas.pixels.pixels().filter(|p| p.0[0] > 128).count();
assert!(light * 2 > canvas.pixels.len());
}
}