sceptre 0.2.0

Rust reimplementation of EasyOCR (CRAFT detection + gen2 CRNN recognition) over ONNX.
Documentation
//! CRNN recognition + CTC decoding configuration.

use serde::{Deserialize, Serialize};

use crate::error::{OcrError, Result};

/// CTC decoding strategy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Decoder {
    /// Greedy (best-path) CTC decoding.
    #[default]
    Greedy,
    /// Beam-search CTC decoding.
    BeamSearch,
    /// Dictionary-constrained word-beam-search.
    WordBeamSearch,
}

/// Parameters controlling recognition and decoding.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RecognitionConfig {
    /// CTC decoding strategy.
    pub decoder: Decoder,
    /// Beam width for beam-search decoders. EasyOCR default `5`.
    pub beam_width: usize,
    /// Recognition batch size. EasyOCR default `1`.
    pub batch_size: usize,
    /// Only these characters may be produced (empty = model charset).
    pub allowlist: String,
    /// These characters are never produced.
    pub blocklist: String,
    /// Contrast below which a low-confidence second pass runs. Default `0.1`.
    pub contrast_ths: f32,
    /// Target contrast for the adjustment pass. Default `0.5`.
    pub adjust_contrast: f32,
    /// Minimum confidence required for a recognized region to be emitted.
    /// Default `0.1`, chosen to suppress low-confidence noise without discarding
    /// the useful recognition results in the quality corpus.
    pub filter_ths: f32,
}

impl RecognitionConfig {
    /// Validate recognition settings consumed by the engine.
    pub(crate) fn validate(&self) -> Result<()> {
        if !(0.0..=1.0).contains(&self.filter_ths) {
            return Err(OcrError::config(format!(
                "recognition.filter_ths must be finite and within [0, 1], got {}",
                self.filter_ths
            )));
        }
        Ok(())
    }
}

impl Default for RecognitionConfig {
    fn default() -> Self {
        Self {
            decoder: Decoder::Greedy,
            beam_width: 5,
            batch_size: 1,
            allowlist: String::new(),
            blocklist: String::new(),
            contrast_ths: 0.1,
            adjust_contrast: 0.5,
            filter_ths: 0.1,
        }
    }
}

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

    #[test]
    fn should_accept_filter_threshold_bounds() {
        for filter_ths in [0.0, 1.0] {
            let config = RecognitionConfig {
                filter_ths,
                ..RecognitionConfig::default()
            };

            config
                .validate()
                .expect("inclusive filter threshold bound should be valid");
        }
    }

    #[test]
    fn should_default_to_quality_corpus_filter_threshold() {
        assert_eq!(RecognitionConfig::default().filter_ths, 0.1);
    }

    #[test]
    fn should_reject_invalid_filter_thresholds() {
        for filter_ths in [-0.1, 1.1, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            let config = RecognitionConfig {
                filter_ths,
                ..RecognitionConfig::default()
            };

            let error = config.validate().expect_err("invalid filter threshold should fail");
            assert!(matches!(error, OcrError::Config { .. }));
        }
    }
}