Files
Mangalord/backend/src/analysis/ocr.rs
MechaCat02 ce9a727c73 fix: enforce the analysis image cap while reading, not after
The OCR and vision dispatchers read the whole page image into a Vec via
storage.get() and only then checked max_image_bytes, so the cap couldn't
bound the read. Stream via get_stream() + safety::accumulate_capped so the
read bails as soon as the running total exceeds the cap.

Bump to 0.124.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:10:29 +02:00

389 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The in-process OCR analysis backend (the `ocrs` engine).
//!
//! A lightweight alternative to [`crate::analysis::vision`]: instead of a slow
//! local LLM, each page is run through `ocrs` — a pure-Rust detect→recognize
//! OCR pipeline on the `rten` runtime. It extracts **text only** (no tags,
//! scene description or NSFW flags — those stay the vision backend's job), then
//! reuses [`repo::page_analysis::persist_analysis`] so the OCR lines land in
//! `page_ocr_text` and the weighted `search_doc` tsvector exactly as the vision
//! path produces them. That makes the existing text-search surfaces
//! (`/v1/me/page-search` and the tag aggregations) work with no further wiring.
//!
//! The engine is split behind the [`OcrEngine`] trait so the dispatcher is
//! unit-testable without shipping the (multi-MB) `.rten` model files: tests use
//! [`test_support::StubOcrEngine`], production uses [`OcrsEngine`].
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use crate::analysis::daemon::AnalyzeDispatcher;
use crate::domain::page_analysis::{OcrResult, SafetyFlag, VisionAnalysis};
use crate::repo;
use crate::storage::Storage;
/// The `model` label stamped onto `page_analysis` rows written by this backend.
pub const OCR_MODEL_LABEL: &str = "ocrs";
/// Extracts text lines from a decoded-or-encoded page image. The production
/// impl ([`OcrsEngine`]) decodes the bytes itself; the trait takes the raw
/// stored image bytes so the dispatcher stays engine-agnostic.
pub trait OcrEngine: Send + Sync {
/// Run OCR over one page image (the bytes as stored, e.g. PNG/JPEG/WebP).
/// Returns the recognized text lines in reading order (top→bottom).
fn recognize(&self, image: &[u8]) -> anyhow::Result<Vec<String>>;
}
/// Turn the OCR engine's ordered text lines into the [`VisionAnalysis`] shape
/// that [`repo::page_analysis::persist_analysis`] consumes. OCR-only: the tag,
/// scene and safety fields are left empty/default. The line `kind` is left
/// blank — `persist_analysis` maps an empty kind to the neutral mid-weight
/// `OcrKind::Narration` bucket (classifying speech/sfx/… is the deferred
/// vision backend's job).
pub fn lines_to_analysis(lines: Vec<String>) -> VisionAnalysis {
let ocr_results = lines
.into_iter()
.map(|text| OcrResult { text, kind: String::new(), y: None })
.collect();
VisionAnalysis {
ocr_results,
tagging_results: Vec::new(),
scene_description: String::new(),
safety_flag: SafetyFlag::default(),
}
}
/// Production OCR engine: an `ocrs` detect+recognize pipeline with the two
/// `.rten` models loaded once at startup. Cheap to share across workers — the
/// recognize path borrows `&self`.
pub struct OcrsEngine {
engine: ocrs::OcrEngine,
/// Hard cap on decoded pixel count (decompression-bomb guard). See
/// [`decode_rgb8_within`].
max_decode_pixels: u64,
}
impl OcrsEngine {
/// Load the detection + recognition models from disk and build the engine.
/// Fails (at startup) if either model file is missing or unreadable, so a
/// misconfigured path is a loud boot error rather than a per-page failure.
///
/// `max_decode_pixels` bounds the decoded image size (see
/// [`decode_rgb8_within`]) — wired from `AnalysisConfig::ocr_max_decode_pixels`.
pub fn from_model_paths(
detection: &str,
recognition: &str,
max_decode_pixels: u64,
) -> anyhow::Result<Self> {
use anyhow::Context;
let detection_model = rten::Model::load_file(detection)
.with_context(|| format!("load ocrs detection model {detection}"))?;
let recognition_model = rten::Model::load_file(recognition)
.with_context(|| format!("load ocrs recognition model {recognition}"))?;
let engine = ocrs::OcrEngine::new(ocrs::OcrEngineParams {
detection_model: Some(detection_model),
recognition_model: Some(recognition_model),
..Default::default()
})
.context("construct ocrs engine")?;
Ok(Self { engine, max_decode_pixels })
}
}
/// Decode encoded image bytes to RGB8 while refusing decompression bombs.
///
/// `image::load_from_memory` allocates the full decoded buffer up front, so a
/// tiny file declaring enormous dimensions (e.g. a 50000×50000 PNG) inflates
/// to billions of bytes and OOM-kills the process. We cap the decoder's
/// allocation at `max_decode_pixels` worth of RGBA (4 bytes/px headroom over
/// the RGB8 result), which the `image` crate checks against the header
/// *before* allocating — so an over-size image fails fast instead of dying.
fn decode_rgb8_within(image: &[u8], max_decode_pixels: u64) -> anyhow::Result<image::RgbImage> {
use anyhow::Context;
use std::io::Cursor;
let mut reader = image::ImageReader::new(Cursor::new(image))
.with_guessed_format()
.context("guess page image format for OCR")?;
let mut limits = image::Limits::default();
// 4 bytes/px (RGBA) gives headroom over the eventual RGB8 buffer and any
// single intermediate the decoder allocates per pixel.
limits.max_alloc = Some(max_decode_pixels.saturating_mul(4));
reader.limits(limits);
Ok(reader
.decode()
.context("decode page image for OCR")?
.into_rgb8())
}
impl OcrEngine for OcrsEngine {
fn recognize(&self, image: &[u8]) -> anyhow::Result<Vec<String>> {
// Decode to RGB8 so `ImageSource` gets a known channel layout,
// bounding the decoded size against the decompression-bomb cap.
let rgb = decode_rgb8_within(image, self.max_decode_pixels)?;
let source = ocrs::ImageSource::from_bytes(rgb.as_raw(), rgb.dimensions())
.map_err(|e| anyhow::anyhow!("build OCR image source: {e}"))?;
let input = self.engine.prepare_input(source)?;
// detect words → group into lines → recognize each line. Mirrors
// `OcrEngine::get_text`, but keeps the lines as a Vec instead of
// joining them, so each becomes its own `page_ocr_text` row.
let words = self.engine.detect_words(&input)?;
let line_rects = self.engine.find_text_lines(&input, &words);
let lines = self
.engine
.recognize_text(&input, &line_rects)?
.into_iter()
.filter_map(|line| line.map(|l| l.to_string()))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(lines)
}
}
/// Bound on concurrent CPU-bound OCR inferences, given the number of analysis
/// workers and the host's available parallelism.
///
/// Each worker's `dispatch` fires one `spawn_blocking` OCR run, so without a
/// bound `ANALYSIS_WORKERS` blocking tasks can run at once. OCR is fully
/// CPU-bound, so running more than there are cores just thrashes the scheduler
/// (and balloons the blocking pool). Cap at the core count, but never below 1
/// and never above the worker count (more permits than workers is pointless).
pub fn ocr_concurrency_limit(workers: usize, cores: usize) -> usize {
workers.min(cores.max(1)).max(1)
}
/// Run a CPU-bound OCR closure on the blocking pool while holding an
/// **owned** permit for the entire duration of the work.
///
/// The permit is acquired with `acquire_owned` and moved *into* the
/// blocking task rather than being held by the caller's future. This
/// matters on cancellation: if the dispatcher future is dropped (graceful
/// shutdown), a borrowed permit would be released the instant the future
/// unwinds — but `spawn_blocking` work is not cancellable and keeps
/// running detached, so the concurrency bound (ANALYSIS_WORKERS) would be
/// briefly exceeded. Moving the permit into the task ties the slot's
/// lifetime to the actual CPU work.
async fn run_ocr_blocking<T, F>(
permits: Arc<tokio::sync::Semaphore>,
f: F,
) -> anyhow::Result<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
let permit = permits
.acquire_owned()
.await
.map_err(|e| anyhow::anyhow!("OCR semaphore closed: {e}"))?;
tokio::task::spawn_blocking(move || {
let _permit = permit; // held until f() returns, even if the caller is cancelled
f()
})
.await
.map_err(|e| anyhow::anyhow!("OCR task join error: {e}"))
}
/// Production dispatcher for the OCR backend: load the page, read its image
/// from storage, run OCR on the blocking pool, and persist the lines. Mirrors
/// [`crate::analysis::daemon::RealAnalyzeDispatcher`] but with no network I/O.
pub struct OcrAnalyzeDispatcher {
pub db: PgPool,
pub storage: Arc<dyn Storage>,
pub engine: Arc<dyn OcrEngine>,
pub max_image_bytes: usize,
/// Caps concurrent CPU-bound OCR inferences across all workers (see
/// [`ocr_concurrency_limit`]). Shared via the `Arc<dyn AnalyzeDispatcher>`,
/// so one permit pool covers every worker.
pub ocr_permits: Arc<tokio::sync::Semaphore>,
}
#[async_trait]
impl AnalyzeDispatcher for OcrAnalyzeDispatcher {
async fn dispatch(&self, page_id: Uuid) -> anyhow::Result<()> {
let Some(page) = repo::page::find_by_id(&self.db, page_id).await? else {
// Page was deleted between enqueue and dispatch — nothing to do.
return Ok(());
};
// Stream the blob through the byte cap so the read itself bails once
// the running total exceeds `max_image_bytes` — a plain `get()` would
// buffer the whole (possibly huge) image into memory before the cap
// could reject it.
let file = self
.storage
.get_stream(&page.storage_key)
.await
.map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?;
let bytes =
crate::crawler::safety::accumulate_capped(file.stream, self.max_image_bytes)
.await
.map_err(|e| {
anyhow::anyhow!("page image {} over the byte cap: {e}", page.storage_key)
})?;
// OCR inference is CPU-bound and synchronous — keep it off the async
// worker's runtime thread, and gate it behind the shared permit pool so
// ANALYSIS_WORKERS > cores can't oversubscribe the blocking pool.
let engine = Arc::clone(&self.engine);
let lines =
run_ocr_blocking(Arc::clone(&self.ocr_permits), move || engine.recognize(&bytes))
.await??;
let analysis = lines_to_analysis(lines);
repo::page_analysis::persist_analysis(&self.db, page_id, &analysis, OCR_MODEL_LABEL).await?;
Ok(())
}
}
/// Stubs for the OCR dispatcher's integration tests. Public because the tests
/// live in the `tests/` dir (a separate crate).
pub mod test_support {
use super::*;
/// An [`OcrEngine`] that returns a fixed set of lines regardless of input,
/// so the dispatcher's storage→persist path can be tested without models.
pub struct StubOcrEngine {
pub lines: Vec<String>,
}
impl StubOcrEngine {
pub fn new(lines: &[&str]) -> Arc<Self> {
Arc::new(Self { lines: lines.iter().map(|s| s.to_string()).collect() })
}
}
impl OcrEngine for StubOcrEngine {
fn recognize(&self, _image: &[u8]) -> anyhow::Result<Vec<String>> {
Ok(self.lines.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn run_ocr_blocking_holds_permit_until_work_completes_despite_cancellation() {
use std::sync::mpsc;
use tokio::sync::{Notify, Semaphore};
let permits = Arc::new(Semaphore::new(1));
let (release_tx, release_rx) = mpsc::channel::<()>();
let started = Arc::new(Notify::new());
// Model the dispatch path: acquire an owned permit and run blocking
// work that we hold open via a channel.
let sem = Arc::clone(&permits);
let started2 = Arc::clone(&started);
let caller = tokio::spawn(async move {
run_ocr_blocking(sem, move || {
// Signal that the blocking task now holds the permit, then
// block until the test releases us.
started2.notify_one();
release_rx.recv().ok();
})
.await
.unwrap();
});
// Wait until the blocking task is running and owns the permit.
started.notified().await;
assert_eq!(permits.available_permits(), 0, "permit taken by blocking work");
// Cancel the caller future (simulates graceful shutdown). The
// blocking task is not cancellable and keeps running detached; with
// an *owned* permit the slot must stay occupied. A borrowed permit
// would have been released here, regressing the concurrency bound.
caller.abort();
let _ = caller.await;
assert_eq!(
permits.available_permits(),
0,
"owned permit must remain held by the still-running blocking task"
);
// Let the blocking work finish; the permit is then returned.
release_tx.send(()).unwrap();
let permit = tokio::time::timeout(std::time::Duration::from_secs(5), permits.acquire())
.await
.expect("permit should be released once blocking work completes")
.unwrap();
drop(permit);
}
#[test]
fn lines_to_analysis_maps_lines_in_order_and_leaves_rest_empty() {
let v = lines_to_analysis(vec!["Hello".to_string(), "world!".to_string()]);
assert_eq!(v.ocr_results.len(), 2);
assert_eq!(v.ocr_results[0].text, "Hello");
assert_eq!(v.ocr_results[1].text, "world!");
// OCR-only: kind blank (→ Narration at persist), no tags/scene/safety.
assert!(v.ocr_results.iter().all(|r| r.kind.is_empty()));
assert!(v.tagging_results.is_empty());
assert_eq!(v.scene_description, "");
assert!(!v.safety_flag.is_nsfw);
assert!(v.safety_flag.content_type.is_empty());
}
#[test]
fn lines_to_analysis_handles_no_text() {
let v = lines_to_analysis(Vec::new());
assert!(v.ocr_results.is_empty());
}
/// A minimal valid PNG of `w`×`h` (single black pixel scaled via IHDR is
/// not valid; instead encode a real tiny image, then we patch the IHDR
/// dimensions for the bomb case). For the happy path we just encode a real
/// small image.
fn encode_png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::new(w, h);
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[test]
fn decode_rgb8_within_accepts_normal_page() {
// A perfectly ordinary page decodes fine under a generous cap.
let png = encode_png(64, 96);
let rgb = decode_rgb8_within(&png, 100_000_000).unwrap();
assert_eq!(rgb.dimensions(), (64, 96));
}
#[test]
fn decode_rgb8_within_rejects_oversize_image() {
// The same image, but the cap is set below its pixel count: the
// allocation limit must trip rather than the decode succeeding. This
// is the decompression-bomb guard in miniature — a real bomb declares
// a huge size in a few bytes; here we shrink the budget instead.
let png = encode_png(2000, 2000); // 4 MP
let err = decode_rgb8_within(&png, 1_000).unwrap_err();
assert!(
err.to_string().contains("decode page image"),
"expected a decode error, got: {err}"
);
}
#[test]
fn ocr_concurrency_limit_caps_at_cores() {
// More workers than cores → clamp to cores (don't oversubscribe).
assert_eq!(ocr_concurrency_limit(8, 4), 4);
}
#[test]
fn ocr_concurrency_limit_caps_at_workers() {
// Fewer workers than cores → only `workers` ever run anyway.
assert_eq!(ocr_concurrency_limit(2, 16), 2);
}
#[test]
fn ocr_concurrency_limit_never_zero() {
// Degenerate inputs still yield at least one permit.
assert_eq!(ocr_concurrency_limit(0, 0), 1);
assert_eq!(ocr_concurrency_limit(1, 0), 1);
}
}