diff --git a/crates/comp-verbs/src/build_phase.rs b/crates/comp-verbs/src/build_phase.rs index a3632d1d8..cad6aa284 100644 --- a/crates/comp-verbs/src/build_phase.rs +++ b/crates/comp-verbs/src/build_phase.rs @@ -565,7 +565,13 @@ fn gate_plates(io: &Io) -> Gate { rr.pointer("/px/h").and_then(Value::as_f64).unwrap_or(0.0), ); let same = impeccable_comp::metrics::structure_score(&raw, &r::resize(&img.image, raw.width as f64, raw.height as f64), 256); - if same >= 0.95 { + let copied_pixels = impeccable_comp::source_pixels::is_transformed_copy(&raw, &img.image) + || impeccable_comp::source_pixels::is_transformed_copy(refimg, &img.image); + if copied_pixels { + reasons.push(format!( + "plate {file} is the comp crop of region {id} (registered RGB pixels match after resampling and a small translation): a crop of the comp is never a plate; regenerate the plate using the crop only as a reference" + )); + } else if same >= 0.95 { reasons.push(format!( "plate {file} is the comp crop of region {id} (structure {}% against the raw region, a resample of the same pixels): a crop of the comp is never a plate; generate the plate from the crop as reference ({s} generate-image --ref --prompt-file --out for {id})", to_fixed(same * 100.0, 0) diff --git a/crates/comp-verbs/src/build_phase/integrity_tests.rs b/crates/comp-verbs/src/build_phase/integrity_tests.rs index 40c2fb6c1..0709c761b 100644 --- a/crates/comp-verbs/src/build_phase/integrity_tests.rs +++ b/crates/comp-verbs/src/build_phase/integrity_tests.rs @@ -732,3 +732,25 @@ fn caller_supplied_fake_metadata_cannot_bypass_the_crop_check() { assert!(!gate.ok); assert!(gate.reasons.iter().any(|reason| reason.contains("is the comp crop")), "{:?}", gate.reasons); } + +#[test] +fn stripped_blurred_shifted_comp_pixels_cannot_pass_with_a_generation_prompt() { + let ws=Workspace::new(); + ws.write("comp.png", include_bytes!("../../../comp/tests/fixtures/comp-copy/reference.png")); + let changed=png_io::decode_png(include_bytes!("../../../comp/tests/fixtures/comp-copy/transformed.png")).unwrap().image; + ws.write("plate.png", &png_io::encode_png(&changed,&[("impeccable:prompt".into(),"High resolution photography".into())]).unwrap()); + ws.write(SPEC_PATH,util::json_pretty(&json!({"comp":"comp.png","regions":[ + {"id":"photo","kind":"plate","medium":"raster","plate":"plate.png","px":{"x":0,"y":0,"w":128,"h":96}} + ]})).as_bytes()); + let gate=gate_plates(&ws.io()); + assert!(!gate.ok); + assert!(gate.reasons.iter().any(|r|r.contains("registered RGB pixels match")),"{:?}",gate.reasons); + assert_eq!(gate.plates.unwrap()[0]["status"],"invalid"); + // Texture patches are explicitly allowed by new-work.md. Fidelity checks + // still apply, but this source-pixel prohibition must not apply to them. + ws.write(SPEC_PATH,util::json_pretty(&json!({"comp":"comp.png","regions":[ + {"id":"material","kind":"texture","medium":"raster","plate":"plate.png","px":{"x":0,"y":0,"w":128,"h":96}} + ]})).as_bytes()); + let texture_gate = gate_plates(&ws.io()); + assert!(!texture_gate.reasons.iter().any(|r|r.contains("is the comp crop")),"{:?}",texture_gate.reasons); +} diff --git a/crates/comp/src/lib.rs b/crates/comp/src/lib.rs index caa4baecb..a40f4c5de 100644 --- a/crates/comp/src/lib.rs +++ b/crates/comp/src/lib.rs @@ -20,6 +20,7 @@ pub mod jsnum; pub mod metrics; pub mod png_io; pub mod raster; +pub mod source_pixels; /// CRC-32 (IEEE, the same polynomial the JS png encoder uses) over a byte /// slice. Exposed so parity tests can checksum decoded pixel buffers. diff --git a/crates/comp/src/source_pixels.rs b/crates/comp/src/source_pixels.rs new file mode 100644 index 000000000..5761346b2 --- /dev/null +++ b/crates/comp/src/source_pixels.rs @@ -0,0 +1,185 @@ +//! Source-pixel reuse is separate from compositional fidelity. +use crate::raster::Image; + +/// Require near-identical registered RGB pixels, not just the same silhouette. +/// Downsampling absorbs encoding/resampling noise; a bounded fractional shift +/// catches small rolls without searching for arbitrary lookalike subimages. +/// Flat fields are not sufficient evidence of common origin. +/// The residual allowance is 7/255 per channel after registration; this is a +/// conservative copy signal, not a provenance proof for arbitrary transforms. +pub fn is_transformed_copy(reference: &Image, candidate: &Image) -> bool { + if reference.width < 16 + || reference.height < 16 + || candidate.width < 16 + || candidate.height < 16 + { + return false; + } + let ratio = reference.width as f64 / reference.height as f64; + let other = candidate.width as f64 / candidate.height as f64; + if (ratio / other - 1.0).abs() > 0.03 { + return false; + } + let w = 32usize; + let h = ((32.0 / ratio).round() as usize).clamp(16, 64); + let a = smooth(&area_reduce(reference, w, h)); + let b = smooth(&area_reduce(candidate, w, h)); + let sample = |x: f64, y: f64, c: usize| { + let ix = x.floor() as usize; + let iy = y.floor() as usize; + let fx = x - ix as f64; + let fy = y - iy as f64; + let p = |xx, yy| b.data[(yy * w + xx) * 4 + c] as f64; + p(ix, iy) * (1.0 - fx) * (1.0 - fy) + + p(ix + 1, iy) * fx * (1.0 - fy) + + p(ix, iy + 1) * (1.0 - fx) * fy + + p(ix + 1, iy + 1) * fx * fy + }; + for sy in -8..=8 { + for sx in -8..=8 { + let (dx, dy) = (sx as f64 / 8.0, sy as f64 / 8.0); + let (mut n, mut sa, mut sb, mut aa, mut bb, mut ab, mut error) = + (0.0, [0.0; 3], [0.0; 3], 0.0, 0.0, 0.0, 0.0); + for y in 2..h - 2 { + for x in 2..w - 2 { + for c in 0..3 { + let av = a.data[(y * w + x) * 4 + c] as f64; + let bv = sample(x as f64 + dx, y as f64 + dy, c); + n += 1.0; + sa[c] += av; + sb[c] += bv; + aa += av * av; + bb += bv * bv; + ab += av * bv; + error += (av - bv) * (av - bv); + } + } + } + let pixels = n / 3.0; + let va = aa - sa.iter().map(|v| v * v / pixels).sum::(); + let vb = bb - sb.iter().map(|v| v * v / pixels).sum::(); + if va / n < 324.0 || vb / n < 324.0 { + continue; + } + let covariance = ab - (0..3).map(|c| sa[c] * sb[c] / pixels).sum::(); + let correlation = covariance / (va * vb).sqrt(); + if correlation >= 0.995 && (error / n).sqrt() <= 7.0 { + return true; + } + } + } + false +} + +// A small, identical low-pass filter removes residual phase differences at +// hard edges. Comparisons below skip the untouched outer margin. +fn smooth(image: &Image) -> Image { + let mut out = image.clone(); + let weights = [1.0, 2.0, 1.0]; + for y in 1..image.height - 1 { + for x in 1..image.width - 1 { + for c in 0..3 { + let mut sum = 0.0; + for dy in 0..3 { + for dx in 0..3 { + sum += image.data[((y + dy - 1) * image.width + x + dx - 1) * 4 + c] as f64 + * weights[dy] + * weights[dx]; + } + } + out.data[(y * image.width + x) * 4 + c] = (sum / 16.0).round() as u8; + } + } + } + out +} + +// Area averaging removes aliasing before comparing differently sized versions. +// Bilinear resize alone samples high-frequency edges differently after a roll. +fn area_reduce(image: &Image, w: usize, h: usize) -> Image { + if image.width < w || image.height < h { + return crate::raster::resize(image, w as f64, h as f64); + } + let mut out = crate::raster::create_image(w, h, [0, 0, 0, 255]); + let sx = image.width as f64 / w as f64; + let sy = image.height as f64 / h as f64; + for y in 0..h { + let top = y as f64 * sy; + let bottom = (y + 1) as f64 * sy; + for x in 0..w { + let left = x as f64 * sx; + let right = (x + 1) as f64 * sx; + let mut sums = [0.0; 3]; + for iy in top.floor() as usize..(bottom.ceil() as usize).min(image.height) { + let wy = bottom.min((iy + 1) as f64) - top.max(iy as f64); + for ix in left.floor() as usize..(right.ceil() as usize).min(image.width) { + let weight = wy * (right.min((ix + 1) as f64) - left.max(ix as f64)); + for c in 0..3 { + sums[c] += image.data[(iy * image.width + ix) * 4 + c] as f64 * weight; + } + } + } + for c in 0..3 { + out.data[(y * w + x) * 4 + c] = (sums[c] / (sx * sy)).round() as u8; + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{png_io::decode_png, raster}; + fn reference() -> Image { + decode_png(include_bytes!("../tests/fixtures/comp-copy/reference.png")) + .unwrap() + .image + } + #[test] + fn stripped_rescaled_blurred_and_rolled_reference_is_still_a_copy() { + let transformed = decode_png(include_bytes!( + "../tests/fixtures/comp-copy/transformed.png" + )) + .unwrap(); + assert!(transformed.text.is_empty()); + assert!(is_transformed_copy(&reference(), &transformed.image)); + } + #[test] + fn hard_edges_survive_resampling_without_aliasing_away_the_match() { + let original = decode_png(include_bytes!( + "../tests/fixtures/comp-copy/detailed-reference.png" + )) + .unwrap(); + let transformed = decode_png(include_bytes!( + "../tests/fixtures/comp-copy/detailed-transformed.png" + )) + .unwrap(); + assert!(is_transformed_copy(&original.image, &transformed.image)); + } + #[test] + fn matching_geometry_with_different_color_or_aspect_is_not_enough() { + let original = reference(); + let mut recolored = original.clone(); + for pixel in recolored.data.chunks_exact_mut(4) { + pixel[0] = pixel[0].saturating_sub(25); + pixel[2] = pixel[2].saturating_add(25); + } + assert!(!is_transformed_copy(&original, &recolored)); + let stretched = raster::resize(&original, 256.0, 96.0); + assert!(!is_transformed_copy(&original, &stretched)); + } + #[test] + fn independent_pixels_and_common_flat_backgrounds_are_not_copy_proof() { + let independent = decode_png(include_bytes!( + "../tests/fixtures/comp-copy/independent.png" + )) + .unwrap() + .image; + assert!(!is_transformed_copy(&reference(), &independent)); + let flat = raster::create_image(128, 96, [240, 230, 210, 255]); + assert!(!is_transformed_copy(&flat, &flat)); + let saturated = raster::create_image(128, 96, [250, 20, 20, 255]); + assert!(!is_transformed_copy(&saturated, &saturated)); + } +} diff --git a/crates/comp/tests/fixtures/comp-copy/README.md b/crates/comp/tests/fixtures/comp-copy/README.md new file mode 100644 index 000000000..6f767d6e2 --- /dev/null +++ b/crates/comp/tests/fixtures/comp-copy/README.md @@ -0,0 +1,24 @@ +# Source-pixel regression fixtures + +`reference.png` is synthetic RGB artwork with smoothly varying color, shapes, +and fine noise. `independent.png` uses the same palette and construction with a +different seed. Neither contains photographs or user artifacts. + +`transformed.png` reproduces the image-processing sequence observed in a Gemini +comp-led eval. From this directory: + +```sh +magick reference.png -strip -resize 300% -scale 94% -scale 106.38% -blur 0x0.7 -roll +2+1 transformed.png +``` + +The crate test verifies the PNG has no retained source metadata. The gate test +adds an invented generation prompt before checking it: prompt metadata is not +proof of generation. Texture patches retain their explicit exemption. + +This is a regression for resampling, mild blur and small translations, not a +claim to identify every possible transformed copy. The independent-pixel and +flat-field checks guard against treating a shared palette as proof of copying. + +`detailed-reference.png` is a synthetic grid of colored panels with hard edges +(seed 917). `detailed-transformed.png` applies the same command to it, guarding +against aliasing during differently sized image comparisons. diff --git a/crates/comp/tests/fixtures/comp-copy/detailed-reference.png b/crates/comp/tests/fixtures/comp-copy/detailed-reference.png new file mode 100644 index 000000000..6e7c87e00 Binary files /dev/null and b/crates/comp/tests/fixtures/comp-copy/detailed-reference.png differ diff --git a/crates/comp/tests/fixtures/comp-copy/detailed-transformed.png b/crates/comp/tests/fixtures/comp-copy/detailed-transformed.png new file mode 100644 index 000000000..bd23e8d34 Binary files /dev/null and b/crates/comp/tests/fixtures/comp-copy/detailed-transformed.png differ diff --git a/crates/comp/tests/fixtures/comp-copy/independent.png b/crates/comp/tests/fixtures/comp-copy/independent.png new file mode 100644 index 000000000..421d5debf Binary files /dev/null and b/crates/comp/tests/fixtures/comp-copy/independent.png differ diff --git a/crates/comp/tests/fixtures/comp-copy/reference.png b/crates/comp/tests/fixtures/comp-copy/reference.png new file mode 100644 index 000000000..8515f2323 Binary files /dev/null and b/crates/comp/tests/fixtures/comp-copy/reference.png differ diff --git a/crates/comp/tests/fixtures/comp-copy/transformed.png b/crates/comp/tests/fixtures/comp-copy/transformed.png new file mode 100644 index 000000000..a4b46aea4 Binary files /dev/null and b/crates/comp/tests/fixtures/comp-copy/transformed.png differ