saccade 0.2.7

Visual-regression CLI for renderers and graphics code, built on NVIDIA FLIP
//! O12/O17 additive transport; optional OCR never replaces pixel evidence.
use crate::agent::CliError;
use std::path::PathBuf;
#[derive(clap::Args)]
pub(crate) struct TofuArgs {
    image: PathBuf,
    /// Binary text-region mask: nonzero red includes; dimensions must match.
    #[arg(long)]
    mask: Option<PathBuf>,
    /// Declared expected Unicode text, never interpreted as instructions.
    #[arg(long)]
    expected_text: Option<String>,
    /// Image-bound imported OCR observations (saccade-ui-source.v1).
    #[arg(long, conflicts_with = "ocr")]
    source: Option<PathBuf>,
    /// Use cached default PaddleOCR; never downloads.
    #[arg(long)]
    ocr: bool,
    /// Optional report directory; --json always emits the full versioned report.
    #[arg(long)]
    out: Option<PathBuf>,
    #[arg(long)]
    json: bool,
}
#[derive(clap::Args)]
pub(crate) struct LegibilityArgs {
    baseline: PathBuf,
    /// One or more variant captures, in report order.
    #[arg(required = true, num_args = 1..)]
    variants: Vec<PathBuf>,
    /// Baseline capture-pixel rectangle x,y,width,height (repeatable, max 64).
    #[arg(long, required = true, value_parser = rect)]
    region: Vec<[u32; 4]>,
    #[arg(long, default_value_t = 4.5)]
    minimum_contrast: f64,
    #[arg(long, default_value_t = 8.)]
    minimum_x_height_px: f64,
    #[arg(long, default_value_t = 0.35)]
    minimum_sharpness: f64,
    #[arg(long, default_value_t = 1.)]
    minimum_stroke_px: f64,
    /// Use cached default PaddleOCR on baseline and variants; never downloads.
    #[arg(long)]
    ocr: bool,
    /// Imported OCR for baseline, paired with one --variant-source per variant.
    #[arg(long, conflicts_with = "ocr", requires = "variant_source")]
    baseline_source: Option<PathBuf>,
    #[arg(long, requires = "baseline_source")]
    variant_source: Vec<PathBuf>,
    #[arg(long)]
    out: Option<PathBuf>,
    #[arg(long)]
    json: bool,
}
fn rect(text: &str) -> Result<[u32; 4], String> {
    let values: Vec<u32> = text
        .split(',')
        .map(|v| v.parse::<u32>())
        .collect::<Result<_, _>>()
        .map_err(|_| "region requires x,y,width,height integers".to_string())?;
    values
        .try_into()
        .map_err(|_| "region requires exactly four integers".into())
}
pub(crate) fn tofu(args: TofuArgs) -> Result<u8, CliError> {
    #[cfg(feature = "text-quality")]
    {
        enabled::tofu(args)
    }
    #[cfg(not(feature = "text-quality"))]
    {
        let _ = args;
        Err(CliError::new(
            "feature_unavailable",
            "requires text-quality",
        ))
    }
}
pub(crate) fn legibility(args: LegibilityArgs) -> Result<u8, CliError> {
    #[cfg(feature = "text-quality")]
    {
        enabled::legibility(args)
    }
    #[cfg(not(feature = "text-quality"))]
    {
        let _ = args;
        Err(CliError::new(
            "feature_unavailable",
            "requires text-quality",
        ))
    }
}
#[cfg(feature = "text-quality")]
mod enabled {
    use super::*;
    use saccade_core::{
        general::input,
        localized::digest,
        text_quality::{self as tq, State},
        ui_review::Source,
    };
    use std::path::Path;
    fn load(path: &Path) -> Result<(Vec<u8>, image::RgbaImage), CliError> {
        let bytes = input::bytes(path, input::MAX_BYTES)?;
        let image = input::decode(&bytes)?;
        Ok((bytes, image))
    }
    fn observations(
        source: Option<&Path>,
        use_ocr: bool,
        bytes: &[u8],
        size: [u32; 2],
    ) -> Result<(Option<Source>, String), CliError> {
        if let Some(path) = source {
            let value: Source = serde_json::from_slice(&input::bytes(path, 16 << 20)?)?;
            value.validate(&digest(bytes), size)?;
            if !["paddle_ocr", "tesseract_tsv", "ocrs", "provider_ocr"]
                .contains(&value.kind.as_str())
            {
                return Err(CliError::usage(
                    "text-quality imported sources must be OCR observations",
                ));
            }
            return Ok((Some(value), "image-bound imported OCR".into()));
        }
        if !use_ocr {
            return Ok((None, "OCR not requested; no model downloaded".into()));
        }
        #[cfg(feature = "ocr")]
        {
            let contract = saccade_core::general::ocr::default_contract()?;
            let cache = saccade_core::media::default_model_dir();
            if let Err(error) = saccade_core::general::ocr::preflight(&contract, &cache, false) {
                return Ok((None, format!("cached PaddleOCR unavailable: {error}")));
            }
            let source = saccade_core::general::ocr::recognize(&contract, &cache, bytes, false)?;
            Ok((
                Some(source),
                "cached PaddleOCR; confidence is uncalibrated".into(),
            ))
        }
        #[cfg(not(feature = "ocr"))]
        {
            Ok((
                None,
                "PaddleOCR unavailable: build lacks ocr feature".into(),
            ))
        }
    }
    fn evidence(source: Option<&Source>, reason: &str, r: Option<[u32; 4]>) -> tq::OcrEvidence {
        let Some(source) = source else {
            return tq::unavailable_ocr(reason);
        };
        let mut nodes: Vec<_> = source
            .nodes
            .iter()
            .filter(|n| {
                r.is_none_or(|r| {
                    n.bounds.is_some_and(|b| {
                        b[0] >= r[0] as f64
                            && b[1] >= r[1] as f64
                            && b[0] + b[2] <= (r[0] + r[2]) as f64
                            && b[1] + b[3] <= (r[1] + r[3]) as f64
                    })
                })
            })
            .collect();
        nodes.sort_by(|a, b| {
            let aa = a.bounds.unwrap_or([0.; 4]);
            let bb = b.bounds.unwrap_or([0.; 4]);
            aa[1]
                .total_cmp(&bb[1])
                .then(aa[0].total_cmp(&bb[0]))
                .then(a.id.cmp(&b.id))
        });
        if nodes.is_empty()
            || nodes
                .iter()
                .any(|n| n.text.is_empty() || n.ocr_confidence.is_none_or(|v| v < 80.))
        {
            return tq::unavailable_ocr("no sufficiently confident OCR text in region");
        }
        let text = nodes
            .iter()
            .map(|n| n.text.as_str())
            .collect::<Vec<_>>()
            .join(" ");
        let replacement = text.contains('\u{fffd}');
        tq::OcrEvidence {
            state: if replacement {
                State::Candidates
            } else {
                State::InsufficientEvidence
            },
            text: Some(text),
            agrees_with_baseline: None,
            reason: reason.into(),
        }
    }
    fn emit<T: serde::Serialize>(
        report: &T,
        state: State,
        out: Option<&Path>,
        json: bool,
        inputs: &[&Path],
    ) -> Result<u8, CliError> {
        let value = serde_json::to_value(report)?;
        if let Some(out) = out {
            crate::general_cmd::prepare_out(out, inputs)?;
            crate::general_cmd::persist_document(&value, out)?;
        }
        if json {
            crate::emit(&format!("{}\n", serde_json::to_string(&value)?))?;
        } else {
            crate::emit(&format!(
                "{}: {}\n",
                value["schema"].as_str().unwrap_or("text-quality"),
                value["state"].as_str().unwrap_or("unavailable")
            ))?;
        }
        Ok(match state {
            State::Candidates | State::Illegible => 1,
            State::Legible => 0,
            _ => 4,
        })
    }
    pub(super) fn tofu(args: TofuArgs) -> Result<u8, CliError> {
        if args.expected_text.as_ref().is_some_and(|s| s.len() > 65536) {
            return Err(CliError::usage("expected text exceeds 64 KiB"));
        }
        let (bytes, image) = load(&args.image)?;
        let mask = args.mask.as_deref().map(load).transpose()?;
        let (source, reason) = observations(
            args.source.as_deref(),
            args.ocr,
            &bytes,
            [image.width(), image.height()],
        )?;
        let mut report = tq::tofu(
            &image,
            mask.as_ref().map(|(_, i)| i),
            digest(&bytes),
            mask.as_ref().map(|(b, _)| digest(b)),
            args.expected_text,
            evidence(source.as_ref(), &reason, None),
        )?;
        if report.ocr.state == State::Candidates {
            report
                .reasons
                .push("OCR observed replacement character; corroborating evidence only".into());
        }
        let mut inputs = vec![args.image.as_path()];
        inputs.extend(args.mask.as_deref());
        inputs.extend(args.source.as_deref());
        emit(
            &report,
            report.state,
            args.out.as_deref(),
            args.json,
            &inputs,
        )
    }
    fn mapped(r: [u32; 4], a: [u32; 2], b: [u32; 2]) -> [u32; 4] {
        let x = (u64::from(r[0]) * u64::from(b[0]) / u64::from(a[0])) as u32;
        let y = (u64::from(r[1]) * u64::from(b[1]) / u64::from(a[1])) as u32;
        let endx = (u64::from(r[0] + r[2]) * u64::from(b[0])).div_ceil(u64::from(a[0])) as u32;
        let endy = (u64::from(r[1] + r[3]) * u64::from(b[1])).div_ceil(u64::from(a[1])) as u32;
        [x, y, endx - x, endy - y]
    }
    pub(super) fn legibility(args: LegibilityArgs) -> Result<u8, CliError> {
        if args.variants.len() > 64 || args.region.len() > 64 {
            return Err(CliError::usage("at most 64 variants and 64 regions"));
        }
        if args.baseline_source.is_some() && args.variant_source.len() != args.variants.len() {
            return Err(CliError::usage(
                "one variant-source per variant is required",
            ));
        }
        let policy = tq::Policy {
            minimum_contrast: args.minimum_contrast,
            minimum_x_height_px: args.minimum_x_height_px,
            minimum_sharpness: args.minimum_sharpness,
            minimum_stroke_px: args.minimum_stroke_px,
        };
        let (bytes, image) = load(&args.baseline)?;
        let size = [image.width(), image.height()];
        let (source, reason) =
            observations(args.baseline_source.as_deref(), args.ocr, &bytes, size)?;
        let mut baseline = Vec::new();
        for (index, r) in args.region.iter().enumerate() {
            let mut result = tq::legibility(&image, *r, index, policy)?;
            result.ocr = evidence(source.as_ref(), &reason, Some(*r));
            baseline.push(result);
        }
        let mut variants = Vec::new();
        for (index, path) in args.variants.iter().enumerate() {
            let (bytes, image) = load(path)?;
            let dim = [image.width(), image.height()];
            let (source, reason) = observations(
                args.variant_source.get(index).map(PathBuf::as_path),
                args.ocr,
                &bytes,
                dim,
            )?;
            let mut regions = Vec::new();
            for (i, r) in args.region.iter().enumerate() {
                let r = mapped(*r, size, dim);
                let mut result = tq::legibility(&image, r, i, policy)?;
                result.ocr = evidence(source.as_ref(), &reason, Some(r));
                if let (Some(a), Some(b)) = (&baseline[i].ocr.text, &result.ocr.text) {
                    let same = a == b;
                    result.ocr.agrees_with_baseline = Some(same);
                    result.ocr.state = if same {
                        State::Legible
                    } else {
                        State::Illegible
                    };
                    if !same {
                        result.state = State::Illegible;
                        result.reasons.push("ocr_disagreement".into());
                    }
                }
                if baseline[i].state != State::Legible && result.state == State::Legible {
                    result.state = State::InsufficientEvidence;
                    result
                        .reasons
                        .push("baseline did not establish legible pixel evidence".into());
                }
                regions.push(result);
            }
            variants.push(tq::Variant {
                index,
                image_sha256: digest(&bytes),
                dimensions: dim,
                regions,
            });
        }
        let states: Vec<_> = baseline
            .iter()
            .chain(variants.iter().flat_map(|v| &v.regions))
            .map(|r| r.state)
            .collect();
        let state = if states.contains(&State::Illegible) {
            State::Illegible
        } else if states.iter().all(|s| *s == State::Legible) {
            State::Legible
        } else {
            State::InsufficientEvidence
        };
        let report=tq::LegibilityReport {report_id:None, source_refs:Vec::new(), schema:tq::LEGIBILITY_SCHEMA.into(),state,baseline_sha256:digest(&bytes),baseline_dimensions:size,policy,baseline,variants,limitations:vec!["declared text regions and proportional dimension mapping required; layout changes need separately aligned captures".into(),"x-height is a minimum connected-component body-height proxy, not font metrics; small marks may lower it".into(),"SDR sRGB luminance on opaque dominant-background pixels; not WCAG certification or human readability".into(),"OCR agreement is exact observed Unicode and optional; missing OCR never becomes agreement".into(),"square .notdef and legitimate box-shaped symbols are ambiguous; glyph coverage remains unverified".into()]};
        let mut inputs = vec![args.baseline.as_path()];
        inputs.extend(args.variants.iter().map(PathBuf::as_path));
        inputs.extend(args.baseline_source.as_deref());
        inputs.extend(args.variant_source.iter().map(PathBuf::as_path));
        emit(&report, state, args.out.as_deref(), args.json, &inputs)
    }
}