sceptre 0.2.0

Rust reimplementation of EasyOCR (CRAFT detection + gen2 CRNN recognition) over ONNX.
Documentation
//! Benchmark seam: thin `#[doc(hidden)]` wrappers over the crate-internal
//! detection and recognition hot paths, plus deterministic input builders.
//!
//! The detection and recognition stages are `pub(crate)`, so they cannot be
//! measured from an external `benches/` crate without widening the public API.
//! This module — compiled only under the `bench` cargo feature and hidden from
//! the docs — exposes just enough surface to drive those stages from Criterion
//! benchmarks. Every wrapper binds its result and hands it to
//! [`std::hint::black_box`] so the optimizer cannot elide the work; wrappers
//! return `()` to avoid naming crate-private result types across the seam. See
//! ADR 0015.

use std::hint::black_box;
use std::path::{Path, PathBuf};

use image::GrayImage;
use ndarray::Array2;

use crate::config::{DetectionConfig, Language};
use crate::recognize::Charset;
use crate::types::Image;

/// Committed fallback image (relative to the crate manifest) used when the
/// optional `test_documents/` corpus submodule is not checked out, so benches
/// run offline.
const FALLBACK_IMAGE: &str = "tests/data/images/english.png";

/// Region-score value written into synthetic heat-map blobs; above the default
/// `text_threshold` (0.7) and `low_text` (0.4).
const BLOB_SCORE: f32 = 0.9;
/// Faint link-score baseline; below the default `link_threshold` (0.4).
const LINK_BASELINE: f32 = 0.1;
/// Side length, in pixels, of one synthetic heat-map blob.
const BLOB_BLOCK: usize = 6;

/// Horizontal stride between successive synthetic boxes.
const BOX_STRIDE: f32 = 45.0;
/// Width of each synthetic box.
const BOX_WIDTH: f32 = 40.0;
/// Top edge (y) of the synthetic box row.
const BOX_TOP: f32 = 10.0;
/// Height of each synthetic box.
const BOX_HEIGHT: f32 = 20.0;

/// Slope of the per-timestep logit ramp; larger values sharpen the argmax peak.
const LOGIT_SLOPE: f32 = 0.5;

/// `inv_ratio` fed to [`detect_postprocess_for_benchmark`]'s coordinate-adjust
/// step; any positive scale exercises the same work.
const BENCH_INV_RATIO: f32 = 2.0;

/// Load a corpus image by path relative to the `test_documents/` directory.
///
/// Walks up from the crate manifest looking for a repository root that holds
/// both a `Cargo.toml` and a `test_documents/` directory; when found and the
/// requested file decodes, returns it. Otherwise — the corpus is absent, or the
/// file is present only as an un-fetched git-LFS pointer that fails to decode —
/// falls back to the committed [`FALLBACK_IMAGE`] so benchmarks run offline.
pub fn load_corpus_image(relative: &str) -> Image {
    let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
    if let Some(root) = repository_root_with_corpus(manifest) {
        let candidate = root.join("test_documents").join(relative);
        if let Ok(image) = Image::from_path(&candidate) {
            return image;
        }
    }
    let fallback = manifest.join(FALLBACK_IMAGE);
    Image::from_path(&fallback)
        .unwrap_or_else(|error| panic!("failed to decode fallback image {}: {error}", fallback.display()))
}

/// First ancestor of `start` (inclusive) that contains both a `Cargo.toml` and a
/// `test_documents/` directory, if any.
fn repository_root_with_corpus(start: &Path) -> Option<PathBuf> {
    start
        .ancestors()
        .find(|dir| dir.join("Cargo.toml").is_file() && dir.join("test_documents").is_dir())
        .map(Path::to_path_buf)
}

/// Build synthetic CRAFT `(region, link)` heat-maps of shape `[height, width]`.
///
/// The region map carries a grid of high-score rectangular blobs (so
/// connected-component labelling has realistic work), and the link map holds a
/// uniform sub-threshold baseline. Postprocessing consumes model *output*, not
/// an image, so synthetic heat-maps are the intrinsic input for that stage.
pub fn synthetic_heatmaps(height: usize, width: usize) -> (Array2<f32>, Array2<f32>) {
    let mut region = Array2::<f32>::zeros((height, width));
    let mut link = Array2::<f32>::zeros((height, width));

    let step = BLOB_BLOCK * 2;
    let mut y = BLOB_BLOCK;
    while y + BLOB_BLOCK < height {
        let mut x = BLOB_BLOCK;
        while x + BLOB_BLOCK < width {
            for row in y..(y + BLOB_BLOCK) {
                for col in x..(x + BLOB_BLOCK) {
                    region[[row, col]] = BLOB_SCORE;
                }
            }
            x += step;
        }
        y += step;
    }
    link.fill(LINK_BASELINE);
    (region, link)
}

/// Build `count` axis-aligned boxes (corners `[TL, TR, BR, BL]`) laid out in a
/// single row with overlapping strides, so line grouping has adjacent boxes to
/// merge. Grouping consumes detector output, so synthetic boxes are intrinsic.
pub fn synthetic_boxes(count: usize) -> Vec<[[f32; 2]; 4]> {
    (0..count)
        .map(|index| {
            let x0 = index as f32 * BOX_STRIDE;
            let x1 = x0 + BOX_WIDTH;
            let (y0, y1) = (BOX_TOP, BOX_TOP + BOX_HEIGHT);
            [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
        })
        .collect()
}

/// Build deterministic recognizer logits of shape `[timesteps, classes]`.
///
/// Each timestep has a single peak class (`t % classes`) formed by a symmetric
/// ramp, so greedy CTC decoding selects varied non-blank classes and exercises
/// the repeat-collapse path. CTC decoding consumes model output, so synthetic
/// logits are the intrinsic input for that stage.
pub fn synthetic_logits(timesteps: usize, classes: usize) -> Array2<f32> {
    let mut logits = Array2::<f32>::zeros((timesteps, classes));
    let span = classes.max(1);
    for t in 0..timesteps {
        let peak = (t % span) as f32;
        for c in 0..classes {
            logits[[t, c]] = -(c as f32 - peak).abs() * LOGIT_SLOPE;
        }
    }
    logits
}

/// Number of CTC classes for the English gen2 charset, including the blank.
/// Lets an external bench size [`synthetic_logits`] to a realistic class count.
pub fn english_class_count() -> usize {
    english_charset().num_classes()
}

/// The English gen2 charset (crate-internal; used by [`ctc_decode_for_benchmark`]).
pub(crate) fn english_charset() -> Charset {
    Charset::for_language(Language::English)
}

/// Drive detection preprocessing (aspect-ratio resize + ImageNet normalization)
/// on a full image.
pub fn detect_preprocess_for_benchmark(image: &Image, canvas_size: u32, mag_ratio: f32) {
    let tensor = crate::detect::bench_preprocess(image, canvas_size, mag_ratio).expect("corpus image preprocesses");
    black_box(&tensor);
}

/// A/B baseline twin of [`detect_preprocess_for_benchmark`] driving the reference
/// (pre-optimization) detection normalize.
pub fn detect_preprocess_reference_for_benchmark(image: &Image, canvas_size: u32, mag_ratio: f32) {
    let tensor =
        crate::detect::bench_preprocess_reference(image, canvas_size, mag_ratio).expect("corpus image preprocesses");
    black_box(&tensor);
}

/// Drive detection postprocessing (threshold + connected components → boxes,
/// then coordinate adjustment) on synthetic heat-maps.
pub fn detect_postprocess_for_benchmark(
    region: &Array2<f32>,
    link: &Array2<f32>,
    text_threshold: f32,
    link_threshold: f32,
    low_text: f32,
) {
    let boxes =
        crate::detect::bench_postprocess(region, link, text_threshold, link_threshold, low_text, BENCH_INV_RATIO)
            .expect("synthetic heat-maps decode to boxes");
    black_box(&boxes);
}

/// Drive line grouping (slope split + line merge + margin) on synthetic boxes.
pub fn detect_group_for_benchmark(boxes: &[[[f32; 2]; 4]], config: &DetectionConfig) {
    let grouped = crate::detect::bench_group(boxes, config);
    black_box(&grouped);
}

/// Drive recognition crop + batch preprocessing: crop one axis-aligned region
/// from the grayscale image, then normalize and pad it into a batch tensor.
pub fn recognize_crop_preprocess_for_benchmark(gray: &GrayImage, corners: &[[f32; 2]; 4]) {
    let tensor = crate::recognize::bench_crop_preprocess(gray, corners).expect("crop preprocesses to a batch");
    black_box(&tensor);
}

/// A/B baseline twin of [`recognize_crop_preprocess_for_benchmark`] driving the
/// reference (pre-optimization) recognition normalize.
pub fn recognize_crop_preprocess_reference_for_benchmark(gray: &GrayImage, corners: &[[f32; 2]; 4]) {
    let tensor =
        crate::recognize::bench_crop_preprocess_reference(gray, corners).expect("crop preprocesses to a batch");
    black_box(&tensor);
}

/// Drive greedy CTC decoding (softmax + argmax + collapse + confidence) on
/// synthetic logits, using the English charset and no ignored classes.
pub fn ctc_decode_for_benchmark(logits: &Array2<f32>) {
    let decoded = crate::recognize::bench_ctc_decode(logits.view());
    black_box(&decoded);
}

/// A/B baseline twin of [`ctc_decode_for_benchmark`] driving the reference
/// (materialized `Array2`) greedy CTC decoder.
pub fn ctc_decode_reference_for_benchmark(logits: &Array2<f32>) {
    let decoded = crate::recognize::bench_ctc_decode_reference(logits.view());
    black_box(&decoded);
}

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

    #[test]
    fn should_load_a_valid_fallback_image_when_corpus_is_absent() {
        let image = load_corpus_image("images/does_not_exist_in_any_corpus.png");
        assert!(image.width() > 0, "fallback image has non-zero width");
        assert!(image.height() > 0, "fallback image has non-zero height");
    }

    #[test]
    fn should_build_heatmaps_with_requested_shape_and_a_high_score_blob() {
        let (region, link) = synthetic_heatmaps(40, 60);
        assert_eq!(region.dim(), (40, 60));
        assert_eq!(link.dim(), (40, 60));
        let region_max = region.iter().copied().fold(f32::NEG_INFINITY, f32::max);
        assert!(
            (region_max - BLOB_SCORE).abs() < 1e-6,
            "region contains a high-score blob"
        );
    }

    #[test]
    fn should_build_the_requested_number_of_boxes() {
        let boxes = synthetic_boxes(7);
        assert_eq!(boxes.len(), 7);
    }

    #[test]
    fn should_build_logits_with_requested_shape() {
        let logits = synthetic_logits(12, 97);
        assert_eq!(logits.dim(), (12, 97));
    }

    #[test]
    fn english_class_count_includes_the_blank() {
        assert!(english_class_count() > 1, "charset has classes beyond the blank");
    }
}