use crate::agent::CliError;
use saccade_core::localized::{self, FrozenRegion};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(clap::Args)]
#[command(group(clap::ArgGroup::new("region-source").required(true).args(["bbox","mask","selector","region","required_effect"])))]
pub(crate) struct Args {
#[arg(long)]
required_effect: Option<PathBuf>,
reference: PathBuf,
candidate: PathBuf,
#[arg(long = "box", value_delimiter = ',', num_args = 1)]
bbox: Option<Vec<u32>>,
#[arg(long)]
mask: Option<PathBuf>,
#[arg(long, requires = "metadata")]
selector: Option<String>,
#[arg(long, requires = "selector")]
metadata: Option<PathBuf>,
#[arg(long)]
region: Option<PathBuf>,
#[arg(long)]
out: PathBuf,
#[arg(long)]
perceptual_outside: bool,
#[arg(long, default_value_t = 0.01)]
maximum_outside_flip: f32,
#[arg(long, default_value_t = 67.0)]
ppd: f32,
#[arg(long)]
json: bool,
}
pub(crate) fn run(args: Args) -> Result<u8, CliError> {
let reference_bytes = saccade_core::evidence_quality::read(&args.reference, 128 << 20)
.map_err(|e| CliError::io(e.to_string()))?;
let candidate_bytes = saccade_core::evidence_quality::read(&args.candidate, 128 << 20)
.map_err(|e| CliError::io(e.to_string()))?;
let reference =
image::load_from_memory(&reference_bytes).map_err(|e| CliError::usage(e.to_string()))?;
let candidate =
image::load_from_memory(&candidate_bytes).map_err(|e| CliError::usage(e.to_string()))?;
if let Some(path) = &args.required_effect {
use saccade_core::evidence_quality::effect;
let policy: effect::RequiredEffect =
serde_json::from_slice(&saccade_core::evidence_quality::read(path, 1 << 20)?)?;
let root = path.parent().unwrap_or(std::path::Path::new("."));
let dimensions = (reference.width(), reference.height());
let b = effect::select(&policy.selection, &args.reference, root, dimensions)?;
let c = effect::select(&policy.selection, &args.candidate, root, dimensions)?;
let comparison = saccade_core::compare::compare_rgba(
&candidate.to_rgba8(),
&reference.to_rgba8(),
&saccade_core::compare::CompareOptions {
pixels_per_degree: args.ppd,
..Default::default()
},
)?;
let result = effect::measure(
&policy,
&b,
&c,
&reference.to_rgba8(),
&candidate.to_rgba8(),
&comparison.error_map,
)?;
std::fs::create_dir(&args.out).map_err(|e| CliError::io(e.to_string()))?;
crate::local_cmd::write_value(
&args.out.join("required-effect.json"),
&serde_json::to_value(&result)?,
)?;
if args.json {
crate::emit(&format!("{}\n", serde_json::to_string(&result)?))?;
} else {
crate::emit(&format!(
"effect {}: baseline {} candidate {}; {:?}\n",
policy.name, result.baseline_pixels, result.candidate_pixels, result.failures
))?;
}
return Ok(u8::from(!result.failures.is_empty()));
}
let hash = localized::digest(&reference_bytes);
let dimensions = [reference.width(), reference.height()];
let region: FrozenRegion = if let Some(path) = args.region {
crate::parse_contract(
&std::fs::read(path).map_err(|e| CliError::io(e.to_string()))?,
"saccade-frozen-region.v1",
)?
} else if let Some(name) = args.selector {
let path = args
.metadata
.ok_or_else(|| CliError::usage("selector metadata missing"))?;
let metadata = crate::parse_contract(
&std::fs::read(path).map_err(|e| CliError::io(e.to_string()))?,
"saccade-dom-regions.v1",
)?;
localized::selector(&metadata, &name, &hash, dimensions)?
} else if let Some(path) = args.mask {
let data = std::fs::read(path).map_err(|e| CliError::io(e.to_string()))?;
let mask = image::load_from_memory(&data).map_err(|e| CliError::usage(e.to_string()))?;
if mask.color() != image::ColorType::L8
|| [mask.width(), mask.height()] != dimensions
|| mask.as_bytes().iter().any(|&v| v != 0 && v != 255)
{
return Err(CliError::usage(
"mask must be binary L8, reference-sized; 255 includes",
));
}
localized::freeze(
hash.clone(),
dimensions,
mask.as_bytes()
.iter()
.map(|&v| u8::from(v == 255))
.collect(),
BTreeMap::from([
("method".into(), "mask_import".into()),
("source_mask_sha256".into(), localized::digest(&data)),
]),
)?
} else {
let v = args.bbox.ok_or_else(|| CliError::usage("region missing"))?;
let b: [u32; 4] = v
.try_into()
.map_err(|_| CliError::usage("box needs four coordinates"))?;
localized::boxes(
hash.clone(),
dimensions,
&[b],
BTreeMap::from([("method".into(), "box".into())]),
)?
};
std::fs::create_dir(&args.out).map_err(|e| CliError::io(e.to_string()))?;
crate::local_cmd::write_value(
&args.out.join("frozen-region.json"),
&serde_json::to_value(®ion)?,
)?;
let measurement = localized::measure(
&reference,
&candidate,
&hash,
localized::digest(&candidate_bytes),
region,
localized::Policy {
ppd: args.ppd,
exact_outside: !args.perceptual_outside,
maximum_outside_flip: args.maximum_outside_flip,
},
)?;
crate::local_cmd::write_value(
&args.out.join("localized.json"),
&serde_json::to_value(&measurement)?,
)?;
if args.json {
crate::emit(&format!(
"{}\n",
serde_json::json!({"schema":"saccade-localized-summary.v1","artifact":args.out.join("localized.json"),"region_id":measurement.region.region_id,"mask_sha256":measurement.region.mask_sha256,"inside":measurement.inside,"outside":measurement.outside,"boundary":measurement.boundary,"intended_change_detected":measurement.intended_change_detected,"collateral":measurement.collateral,"semantic_success":"unproven"})
))?;
} else {
crate::emit(&format!(
"localized: {}; {} inside and {} outside changed pixels; semantic success unproven\n",
measurement.collateral,
measurement.inside.changed_pixels,
measurement.outside.changed_pixels
))?;
}
Ok(u8::from(
measurement.collateral != "preserved" || !measurement.intended_change_detected,
))
}