refactor(lightcraft-raw): move opcodes.rs tests to crates/raw/src/opcodes/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:16:08 +00:00
parent b61a981f73
commit 64e4683045
2 changed files with 445 additions and 446 deletions
+1 -446
View File
@@ -754,449 +754,4 @@ pub fn warp_rectilinear2(img: &Rgb32f, planes: &[[f64; WR2_LEN]], center: [f64;
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) struct ListWriter(pub Vec<u8>, pub u32);
impl ListWriter {
pub fn new() -> Self {
ListWriter(Vec::new(), 0)
}
pub fn op(&mut self, id: u32, params: &[u8]) -> &mut Self {
for v in [id, 0x01030000, 0, params.len() as u32] {
self.0.extend_from_slice(&v.to_be_bytes());
}
self.0.extend_from_slice(params);
self.1 += 1;
self
}
pub fn finish(&self) -> Vec<u8> {
let mut v = self.1.to_be_bytes().to_vec();
v.extend_from_slice(&self.0);
v
}
}
pub(crate) fn be(vals: &[f64]) -> Vec<u8> {
vals.iter().flat_map(|v| v.to_be_bytes()).collect()
}
pub(crate) fn area(a: [u32; 8]) -> Vec<u8> {
a.iter().flat_map(|v| v.to_be_bytes()).collect()
}
/// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them
/// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must
/// still apply, exactly, so the same image comes out.
#[test]
fn list3_pass_cap_skips_repeated_records() {
let mut img = Rgb32f::new(8, 6);
for (i, px) in img.data.iter_mut().enumerate() {
*px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32];
}
let orig: Vec<[f32; 3]> = img.data.clone();
let list: Vec<Opcode> =
(0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect();
apply_list3(&list, &mut img);
assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest");
// a real warp below the cap is still applied
let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }];
apply_list3(&one, &mut img);
assert_ne!(img.data, orig, "a genuine warp record is not skipped");
}
#[test]
fn parse_and_apply_area_ops() {
let (w, h) = (4usize, 3usize);
let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]);
for v in [2u32, 2] {
gm.extend_from_slice(&v.to_be_bytes());
}
gm.extend(be(&[1.0, 1.0, 0.0, 0.0]));
gm.extend_from_slice(&1u32.to_be_bytes());
for g in [1.0f32, 2.0, 1.0, 2.0] {
gm.extend_from_slice(&g.to_be_bytes());
}
let mut dpr = area([0, 0, 3, 4, 0, 1, 1, 1]);
dpr.extend_from_slice(&3u32.to_be_bytes());
for d in [0.0f32, 0.1, 0.2] {
dpr.extend_from_slice(&d.to_be_bytes());
}
let mut poly = area([0, 0, 3, 4, 0, 1, 1, 2]);
poly.extend_from_slice(&1u32.to_be_bytes());
poly.extend(be(&[0.0, 2.0]));
let blob = ListWriter::new().op(9, &gm).op(10, &dpr).op(8, &poly).op(99, &[1, 2, 3]).finish();
let list = parse_list(&blob);
assert_eq!(list.len(), 4);
assert!(matches!(list[3], Opcode::Unknown { id: 99, .. }));
assert!(list[..3].iter().all(|o| o.is_applied()));
let mut buf = vec![0.25f32; w * h];
apply_list(&list, &mut buf, w, h, 1, None, 1.0);
// gain map: horizontal ramp 1 → 2 across relative x 0..1 (x / w)
let expect = |x: usize, y: usize| {
let g = 1.0 + x as f32 / w as f32;
let v = 0.25 * g + [0.0, 0.1, 0.2][y];
if x.is_multiple_of(2) { v * 2.0 } else { v }
};
for y in 0..h {
for x in 0..w {
assert!((buf[y * w + x] - expect(x, y)).abs() < 1e-6, "{x},{y}: {} vs {}", buf[y * w + x], expect(x, y));
}
}
}
#[test]
fn map_table_raw_scale_and_bad_pixels() {
let mut t = area([0, 0, 2, 2, 0, 1, 1, 1]);
t.extend_from_slice(&3u32.to_be_bytes());
for v in [0u16, 30000, 65535] {
t.extend_from_slice(&v.to_be_bytes());
}
let list = parse_list(&ListWriter::new().op(7, &t).finish());
let mut buf = vec![0.0, 1.0 / 65535.0, 2.0 / 65535.0, 0.5];
apply_list(&list, &mut buf, 2, 2, 1, None, 1.0);
assert_eq!(buf[0], 0.0);
assert!((buf[1] - 30000.0 / 65535.0).abs() < 1e-7);
assert_eq!(buf[3], 1.0);
let mut p = vec![];
for v in [7u32, 0] {
p.extend_from_slice(&v.to_be_bytes());
}
let list = parse_list(&ListWriter::new().op(4, &p).finish());
let cfa = Cfa::bayer("RGGB").unwrap();
let mut img: Vec<f32> = (0..36).map(|i| 100.0 + (i % 6) as f32).collect();
img[2 * 6 + 2] = 7.0;
apply_list(&list, &mut img, 6, 6, 1, Some(&cfa), 65535.0);
assert_eq!(img[2 * 6 + 2], 102.0);
}
/// A mosaic equal to the bad-pixel constant, or one rectangle over the whole image, used to list every pixel as
/// bad: w × h entries (plus a set of them), tens of GB at the decoder's size limit. Here the rows between a good
/// first row (500) and a good last row (900) are bad: 2052 rows of 2048 pixels, four rows more than the 1 << 22
/// cap. Row 1, within the cap, is repaired from the first row; the second-last row, past the cap, must be left
/// as it is (uncapped, it was rewritten from the last row).
fn assert_bad_pixels_capped(op: Opcode) {
let (w, h) = (2048, 2054);
let mut buf = vec![0.0f32; w * h];
buf[..w].fill(500.0);
buf[(h - 1) * w..].fill(900.0);
apply_list(&[op], &mut buf, w, h, 1, None, 65535.0);
assert!(buf[w..2 * w].iter().all(|&v| v == 500.0), "row 1 (within the cap) not repaired: {:?}", &buf[w..w + 4]);
let past = &buf[(h - 2) * w..(h - 1) * w];
assert!(past.iter().all(|&v| v == 0.0), "a row past the cap was rewritten: {:?}", &past[..4]);
}
#[test]
fn bad_pixels_constant_is_capped() {
assert_bad_pixels_capped(Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 0 });
}
#[test]
fn bad_pixels_list_is_capped_inside_a_rectangle() {
// top, left, bottom, right: every row but the first and the last
assert_bad_pixels_capped(Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![], rects: vec![[1, 0, 2053, 2048]] });
}
#[test]
fn vignette_and_warp_identity() {
let mut img = Rgb32f::from_fn(9, 7, |x, y| [x as f32 / 9.0, y as f32 / 7.0, 0.5]);
let orig = img.clone();
let mut p = be(&[0.0; 5]);
p.extend(be(&[0.5, 0.5]));
let mut warp = 1u32.to_be_bytes().to_vec();
warp.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5]));
let list = parse_list(&ListWriter::new().op(3, &p).op(1, &warp).finish());
assert_eq!(list.len(), 2);
apply_list3(&list, &mut img);
for (a, b) in img.data.iter().zip(&orig.data) {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 1e-5);
}
}
// positive k0 brightens the corners more than the centre
let mut p = be(&[1.0, 0.0, 0.0, 0.0, 0.0]);
p.extend(be(&[0.5, 0.5]));
let list = parse_list(&ListWriter::new().op(3, &p).finish());
let mut flat = Rgb32f::filled(9, 9, [0.5; 3]);
apply_list3(&list, &mut flat);
assert!(flat.get(0, 0)[0] > 0.85 && (flat.get(4, 4)[0] - 0.5).abs() < 0.01, "{:?}", flat.get(0, 0));
}
#[test]
fn warp_barrel_moves_pixels_outward() {
let img = Rgb32f::from_fn(31, 31, |x, y| if x == 24 && y == 15 { [1.0; 3] } else { [0.0; 3] });
// source radius = r (1 + k r²) with k < 0 → content moves outward
let out = warp_rectilinear(&img, &[[1.0, -0.6, 0.0, 0.0, 0.0, 0.0]], [0.5, 0.5]);
let (bx, _) = (0..31).map(|x| (x, out.get(x, 15)[0])).fold((0, 0.0), |a, b| if b.1 > a.1 { b } else { a });
assert!(bx >= 25, "{bx}");
}
#[test]
fn write_parse_roundtrip() {
let a = Area { top: 1, left: 2, bottom: 30, right: 40, plane: 0, planes: 3, row_pitch: 1, col_pitch: 2 };
let list = vec![
Opcode::WarpRectilinear { planes: vec![[1.0, 0.01, -0.002, 0.0, 1e-4, -2e-4]; 3], center: [0.5, 0.49] },
Opcode::WarpFisheye { planes: vec![[1.0, 0.1, 0.0, 0.0]], center: [0.5, 0.5] },
Opcode::FixVignetteRadial { k: [0.1, 0.2, 0.0, 0.0, 0.0], center: [0.5, 0.5] },
Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 1 },
Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![(3, 4)], rects: vec![[1, 1, 2, 2]] },
Opcode::TrimBounds { top: 1, left: 1, bottom: 9, right: 9 },
Opcode::MapTable { area: a, table: vec![0, 100, 65535] },
Opcode::MapPolynomial { area: a, coefficients: vec![0.0, 1.0, -0.1] },
Opcode::GainMap { area: a, points_v: 2, points_h: 1, spacing: [1.0, 1.0], origin: [0.0, 0.0], map_planes: 1, gains: vec![1.0, 1.5] },
Opcode::DeltaPerRow { area: a, deltas: vec![0.1, 0.2] },
Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] },
Opcode::ScalePerRow { area: a, scales: vec![1.1] },
Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] },
Opcode::WarpRectilinear2 {
planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])],
center: [0.5, 0.49],
reciprocal: true,
optional: true,
},
Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] },
];
assert_eq!(parse_list(&write_list(&list)), list);
}
#[test]
fn truncated_lists_do_not_panic() {
let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]);
gm.extend_from_slice(&[0xff; 64]);
let blob = ListWriter::new().op(9, &gm).op(5, &[0xff; 12]).op(1, &[0, 0, 0, 9]).finish();
for n in 0..blob.len() {
let l = parse_list(&blob[..n]);
let mut buf = vec![0.5f32; 12];
apply_list(&l, &mut buf, 4, 3, 1, None, 1.0);
}
}
/// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius,
/// then the centre and `reciprocalRadial`).
fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec<u8> {
let mut p = (sets.len() as u32).to_be_bytes().to_vec();
for s in sets {
p.extend(be(s));
}
p.extend(be(&center));
p.extend_from_slice(&reciprocal.to_be_bytes());
p
}
fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] {
let mut k = [0.0; WR2_LEN];
for &(i, v) in kr {
k[i] = v;
}
k[18] = 1.0;
k
}
#[test]
fn warp_rectilinear2_parses_the_spec_layout() {
let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]);
k[15] = 0.001;
k[16] = -0.002;
k[17] = 0.1;
k[18] = 0.9;
let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish();
let list = parse_list(&blob);
assert_eq!(
list,
vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }]
);
assert!(list[0].is_applied() && list[0].is_lens_correction());
// written back as opcode 14, version 1.6.0.0, optional flag kept
let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }];
let bytes = write_list(&again);
assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..]));
assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..]));
assert_eq!(parse_list(&bytes), again);
}
#[test]
fn warp_rectilinear2_identity_leaves_the_image() {
let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]);
for reciprocal in [false, true] {
let mut i = img.clone();
let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32);
apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i);
for (a, b) in i.data.iter().zip(&img.data) {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}");
}
}
}
}
#[test]
fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() {
// kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4
let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]);
let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]);
let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]);
k[15] = 0.01;
k[16] = -0.02;
let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false);
for (p, q) in a.data.iter().zip(&b.data) {
for c in 0..3 {
assert!((p[c] - q[c]).abs() < 1e-5);
}
}
// odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines
let k = wr2_set(&[(0, 1.0), (1, 0.5)]);
assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25));
assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8));
let mut k = k;
k[18] = 0.5; // max_valid_radius
assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25));
k[17] = 0.25; // min_valid_radius
assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125));
assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0");
}
#[test]
fn malformed_warp_rectilinear2_is_unknown_not_a_panic() {
let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0);
let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]);
assert!(is_wr2(&good));
// every truncation and any trailing byte
for n in 0..good.len() {
assert!(!is_wr2(&good[..n]), "{n}");
}
assert!(!is_wr2(&[&good[..], &[0u8][..]].concat()));
// N = 0, N too big for the data, N beyond the plane limit
for n in [0u32, 2, 5, u32::MAX] {
let mut p = good.clone();
p[..4].copy_from_slice(&n.to_be_bytes());
assert!(!is_wr2(&p), "N = {n}");
}
// non-finite or absurd values, in a coefficient, the valid range and the centre
let patch = |at: usize, v: f64| {
let mut p = good.clone();
p[at..at + 8].copy_from_slice(&v.to_be_bytes());
p
};
for (at, v) in [
(4, f64::NAN),
(4 + 8, f64::INFINITY),
(4 + 8 * 3, 1e300),
(4 + 8 * 17, -0.5),
(4 + 8 * 18, 1.5),
(4 + 8 * 17, 1.0),
(4 + 19 * 8, f64::NAN),
(4 + 19 * 8 + 8, 1e30),
] {
assert!(!is_wr2(&patch(at, v)), "{at} {v}");
}
// refused ones stay Unknown and are not applied
let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish());
assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }]));
let mut img = Rgb32f::filled(5, 5, [0.5; 3]);
apply_list3(&list, &mut img);
assert_eq!(img.get(2, 2), [0.5; 3]);
// a hostile but accepted set (huge radial terms) still gives a finite image
let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]);
let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]);
for reciprocal in [false, true] {
let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal);
assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite())));
}
}
#[test]
fn warp_list_order_and_the_optional_skip_rule() {
let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional };
let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] };
let d = std::mem::discriminant::<Opcode>;
let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::<Vec<_>>();
// WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped
assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]);
assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]);
// not optional: both run, in list order
assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]);
// only the opcode immediately after is skipped
assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]);
// two in a row: both run, and the WarpRectilinear after the optional second one is skipped
assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]);
// a WarpRectilinear before the WarpRectilinear2 runs, in list order
assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]);
// the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1
let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0);
let mut one = 1u32.to_be_bytes().to_vec();
one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5]));
let mut blob = 2u32.to_be_bytes().to_vec();
for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] {
for v in [id, 0x0106_0000, flags, p.len() as u32] {
blob.extend_from_slice(&v.to_be_bytes());
}
blob.extend_from_slice(p);
}
let list = parse_list(&blob);
assert_eq!(list.len(), 2);
assert_eq!(applied_ops(&list).count(), 1);
// a refused (Unknown) opcode 14 triggers no skip
let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] };
assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. })));
}
/// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A
/// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ.
#[test]
fn cropped_lens_corrections_correct_the_crop_like_the_whole() {
let (w, h) = (90usize, 64usize);
let crop = crate::Rect::new(7, 5, 70, 50);
let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]);
let mut wr2 = [0.0; WR2_LEN];
wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]);
wr2[15] = 0.002;
wr2[16] = -0.001;
wr2[17] = 0.05;
wr2[18] = 0.9;
let warps = [
Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] },
Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true },
Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false },
];
for op in &warps {
let mut whole = ramp.clone();
apply_list3(std::slice::from_ref(op), &mut whole);
let re = op.cropped(w, h, crop).unwrap();
let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height);
apply_list3(std::slice::from_ref(&re), &mut part);
let mut worst = 0f32;
for y in 6..crop.height - 6 {
for x in 6..crop.width - 6 {
let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]);
worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max);
}
}
assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}");
}
// the vignette, in the warps' centre convention the develop pipeline reads it with
let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| {
let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0);
let (px, py) = (c[0] * aw, c[1] * ah);
let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2);
1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::<f64>()
};
let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]);
let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else {
panic!("vignette")
};
for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] {
let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y));
assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}");
}
// no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge
assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]);
assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none());
assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none());
assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none());
assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none());
}
}
pub(crate) mod tests;
+444
View File
@@ -0,0 +1,444 @@
use super::*;
pub(crate) struct ListWriter(pub Vec<u8>, pub u32);
impl ListWriter {
pub fn new() -> Self {
ListWriter(Vec::new(), 0)
}
pub fn op(&mut self, id: u32, params: &[u8]) -> &mut Self {
for v in [id, 0x01030000, 0, params.len() as u32] {
self.0.extend_from_slice(&v.to_be_bytes());
}
self.0.extend_from_slice(params);
self.1 += 1;
self
}
pub fn finish(&self) -> Vec<u8> {
let mut v = self.1.to_be_bytes().to_vec();
v.extend_from_slice(&self.0);
v
}
}
pub(crate) fn be(vals: &[f64]) -> Vec<u8> {
vals.iter().flat_map(|v| v.to_be_bytes()).collect()
}
pub(crate) fn area(a: [u32; 8]) -> Vec<u8> {
a.iter().flat_map(|v| v.to_be_bytes()).collect()
}
/// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them
/// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must
/// still apply, exactly, so the same image comes out.
#[test]
fn list3_pass_cap_skips_repeated_records() {
let mut img = Rgb32f::new(8, 6);
for (i, px) in img.data.iter_mut().enumerate() {
*px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32];
}
let orig: Vec<[f32; 3]> = img.data.clone();
let list: Vec<Opcode> =
(0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect();
apply_list3(&list, &mut img);
assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest");
// a real warp below the cap is still applied
let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }];
apply_list3(&one, &mut img);
assert_ne!(img.data, orig, "a genuine warp record is not skipped");
}
#[test]
fn parse_and_apply_area_ops() {
let (w, h) = (4usize, 3usize);
let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]);
for v in [2u32, 2] {
gm.extend_from_slice(&v.to_be_bytes());
}
gm.extend(be(&[1.0, 1.0, 0.0, 0.0]));
gm.extend_from_slice(&1u32.to_be_bytes());
for g in [1.0f32, 2.0, 1.0, 2.0] {
gm.extend_from_slice(&g.to_be_bytes());
}
let mut dpr = area([0, 0, 3, 4, 0, 1, 1, 1]);
dpr.extend_from_slice(&3u32.to_be_bytes());
for d in [0.0f32, 0.1, 0.2] {
dpr.extend_from_slice(&d.to_be_bytes());
}
let mut poly = area([0, 0, 3, 4, 0, 1, 1, 2]);
poly.extend_from_slice(&1u32.to_be_bytes());
poly.extend(be(&[0.0, 2.0]));
let blob = ListWriter::new().op(9, &gm).op(10, &dpr).op(8, &poly).op(99, &[1, 2, 3]).finish();
let list = parse_list(&blob);
assert_eq!(list.len(), 4);
assert!(matches!(list[3], Opcode::Unknown { id: 99, .. }));
assert!(list[..3].iter().all(|o| o.is_applied()));
let mut buf = vec![0.25f32; w * h];
apply_list(&list, &mut buf, w, h, 1, None, 1.0);
// gain map: horizontal ramp 1 → 2 across relative x 0..1 (x / w)
let expect = |x: usize, y: usize| {
let g = 1.0 + x as f32 / w as f32;
let v = 0.25 * g + [0.0, 0.1, 0.2][y];
if x.is_multiple_of(2) { v * 2.0 } else { v }
};
for y in 0..h {
for x in 0..w {
assert!((buf[y * w + x] - expect(x, y)).abs() < 1e-6, "{x},{y}: {} vs {}", buf[y * w + x], expect(x, y));
}
}
}
#[test]
fn map_table_raw_scale_and_bad_pixels() {
let mut t = area([0, 0, 2, 2, 0, 1, 1, 1]);
t.extend_from_slice(&3u32.to_be_bytes());
for v in [0u16, 30000, 65535] {
t.extend_from_slice(&v.to_be_bytes());
}
let list = parse_list(&ListWriter::new().op(7, &t).finish());
let mut buf = vec![0.0, 1.0 / 65535.0, 2.0 / 65535.0, 0.5];
apply_list(&list, &mut buf, 2, 2, 1, None, 1.0);
assert_eq!(buf[0], 0.0);
assert!((buf[1] - 30000.0 / 65535.0).abs() < 1e-7);
assert_eq!(buf[3], 1.0);
let mut p = vec![];
for v in [7u32, 0] {
p.extend_from_slice(&v.to_be_bytes());
}
let list = parse_list(&ListWriter::new().op(4, &p).finish());
let cfa = Cfa::bayer("RGGB").unwrap();
let mut img: Vec<f32> = (0..36).map(|i| 100.0 + (i % 6) as f32).collect();
img[2 * 6 + 2] = 7.0;
apply_list(&list, &mut img, 6, 6, 1, Some(&cfa), 65535.0);
assert_eq!(img[2 * 6 + 2], 102.0);
}
/// A mosaic equal to the bad-pixel constant, or one rectangle over the whole image, used to list every pixel as
/// bad: w × h entries (plus a set of them), tens of GB at the decoder's size limit. Here the rows between a good
/// first row (500) and a good last row (900) are bad: 2052 rows of 2048 pixels, four rows more than the 1 << 22
/// cap. Row 1, within the cap, is repaired from the first row; the second-last row, past the cap, must be left
/// as it is (uncapped, it was rewritten from the last row).
fn assert_bad_pixels_capped(op: Opcode) {
let (w, h) = (2048, 2054);
let mut buf = vec![0.0f32; w * h];
buf[..w].fill(500.0);
buf[(h - 1) * w..].fill(900.0);
apply_list(&[op], &mut buf, w, h, 1, None, 65535.0);
assert!(buf[w..2 * w].iter().all(|&v| v == 500.0), "row 1 (within the cap) not repaired: {:?}", &buf[w..w + 4]);
let past = &buf[(h - 2) * w..(h - 1) * w];
assert!(past.iter().all(|&v| v == 0.0), "a row past the cap was rewritten: {:?}", &past[..4]);
}
#[test]
fn bad_pixels_constant_is_capped() {
assert_bad_pixels_capped(Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 0 });
}
#[test]
fn bad_pixels_list_is_capped_inside_a_rectangle() {
// top, left, bottom, right: every row but the first and the last
assert_bad_pixels_capped(Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![], rects: vec![[1, 0, 2053, 2048]] });
}
#[test]
fn vignette_and_warp_identity() {
let mut img = Rgb32f::from_fn(9, 7, |x, y| [x as f32 / 9.0, y as f32 / 7.0, 0.5]);
let orig = img.clone();
let mut p = be(&[0.0; 5]);
p.extend(be(&[0.5, 0.5]));
let mut warp = 1u32.to_be_bytes().to_vec();
warp.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5]));
let list = parse_list(&ListWriter::new().op(3, &p).op(1, &warp).finish());
assert_eq!(list.len(), 2);
apply_list3(&list, &mut img);
for (a, b) in img.data.iter().zip(&orig.data) {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 1e-5);
}
}
// positive k0 brightens the corners more than the centre
let mut p = be(&[1.0, 0.0, 0.0, 0.0, 0.0]);
p.extend(be(&[0.5, 0.5]));
let list = parse_list(&ListWriter::new().op(3, &p).finish());
let mut flat = Rgb32f::filled(9, 9, [0.5; 3]);
apply_list3(&list, &mut flat);
assert!(flat.get(0, 0)[0] > 0.85 && (flat.get(4, 4)[0] - 0.5).abs() < 0.01, "{:?}", flat.get(0, 0));
}
#[test]
fn warp_barrel_moves_pixels_outward() {
let img = Rgb32f::from_fn(31, 31, |x, y| if x == 24 && y == 15 { [1.0; 3] } else { [0.0; 3] });
// source radius = r (1 + k r²) with k < 0 → content moves outward
let out = warp_rectilinear(&img, &[[1.0, -0.6, 0.0, 0.0, 0.0, 0.0]], [0.5, 0.5]);
let (bx, _) = (0..31).map(|x| (x, out.get(x, 15)[0])).fold((0, 0.0), |a, b| if b.1 > a.1 { b } else { a });
assert!(bx >= 25, "{bx}");
}
#[test]
fn write_parse_roundtrip() {
let a = Area { top: 1, left: 2, bottom: 30, right: 40, plane: 0, planes: 3, row_pitch: 1, col_pitch: 2 };
let list = vec![
Opcode::WarpRectilinear { planes: vec![[1.0, 0.01, -0.002, 0.0, 1e-4, -2e-4]; 3], center: [0.5, 0.49] },
Opcode::WarpFisheye { planes: vec![[1.0, 0.1, 0.0, 0.0]], center: [0.5, 0.5] },
Opcode::FixVignetteRadial { k: [0.1, 0.2, 0.0, 0.0, 0.0], center: [0.5, 0.5] },
Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 1 },
Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![(3, 4)], rects: vec![[1, 1, 2, 2]] },
Opcode::TrimBounds { top: 1, left: 1, bottom: 9, right: 9 },
Opcode::MapTable { area: a, table: vec![0, 100, 65535] },
Opcode::MapPolynomial { area: a, coefficients: vec![0.0, 1.0, -0.1] },
Opcode::GainMap { area: a, points_v: 2, points_h: 1, spacing: [1.0, 1.0], origin: [0.0, 0.0], map_planes: 1, gains: vec![1.0, 1.5] },
Opcode::DeltaPerRow { area: a, deltas: vec![0.1, 0.2] },
Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] },
Opcode::ScalePerRow { area: a, scales: vec![1.1] },
Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] },
Opcode::WarpRectilinear2 {
planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])],
center: [0.5, 0.49],
reciprocal: true,
optional: true,
},
Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] },
];
assert_eq!(parse_list(&write_list(&list)), list);
}
#[test]
fn truncated_lists_do_not_panic() {
let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]);
gm.extend_from_slice(&[0xff; 64]);
let blob = ListWriter::new().op(9, &gm).op(5, &[0xff; 12]).op(1, &[0, 0, 0, 9]).finish();
for n in 0..blob.len() {
let l = parse_list(&blob[..n]);
let mut buf = vec![0.5f32; 12];
apply_list(&l, &mut buf, 4, 3, 1, None, 1.0);
}
}
/// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius,
/// then the centre and `reciprocalRadial`).
fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec<u8> {
let mut p = (sets.len() as u32).to_be_bytes().to_vec();
for s in sets {
p.extend(be(s));
}
p.extend(be(&center));
p.extend_from_slice(&reciprocal.to_be_bytes());
p
}
fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] {
let mut k = [0.0; WR2_LEN];
for &(i, v) in kr {
k[i] = v;
}
k[18] = 1.0;
k
}
#[test]
fn warp_rectilinear2_parses_the_spec_layout() {
let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]);
k[15] = 0.001;
k[16] = -0.002;
k[17] = 0.1;
k[18] = 0.9;
let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish();
let list = parse_list(&blob);
assert_eq!(
list,
vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }]
);
assert!(list[0].is_applied() && list[0].is_lens_correction());
// written back as opcode 14, version 1.6.0.0, optional flag kept
let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }];
let bytes = write_list(&again);
assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..]));
assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..]));
assert_eq!(parse_list(&bytes), again);
}
#[test]
fn warp_rectilinear2_identity_leaves_the_image() {
let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]);
for reciprocal in [false, true] {
let mut i = img.clone();
let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32);
apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i);
for (a, b) in i.data.iter().zip(&img.data) {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}");
}
}
}
}
#[test]
fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() {
// kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4
let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]);
let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]);
let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]);
k[15] = 0.01;
k[16] = -0.02;
let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false);
for (p, q) in a.data.iter().zip(&b.data) {
for c in 0..3 {
assert!((p[c] - q[c]).abs() < 1e-5);
}
}
// odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines
let k = wr2_set(&[(0, 1.0), (1, 0.5)]);
assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25));
assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8));
let mut k = k;
k[18] = 0.5; // max_valid_radius
assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25));
k[17] = 0.25; // min_valid_radius
assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125));
assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0");
}
#[test]
fn malformed_warp_rectilinear2_is_unknown_not_a_panic() {
let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0);
let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]);
assert!(is_wr2(&good));
// every truncation and any trailing byte
for n in 0..good.len() {
assert!(!is_wr2(&good[..n]), "{n}");
}
assert!(!is_wr2(&[&good[..], &[0u8][..]].concat()));
// N = 0, N too big for the data, N beyond the plane limit
for n in [0u32, 2, 5, u32::MAX] {
let mut p = good.clone();
p[..4].copy_from_slice(&n.to_be_bytes());
assert!(!is_wr2(&p), "N = {n}");
}
// non-finite or absurd values, in a coefficient, the valid range and the centre
let patch = |at: usize, v: f64| {
let mut p = good.clone();
p[at..at + 8].copy_from_slice(&v.to_be_bytes());
p
};
for (at, v) in [
(4, f64::NAN),
(4 + 8, f64::INFINITY),
(4 + 8 * 3, 1e300),
(4 + 8 * 17, -0.5),
(4 + 8 * 18, 1.5),
(4 + 8 * 17, 1.0),
(4 + 19 * 8, f64::NAN),
(4 + 19 * 8 + 8, 1e30),
] {
assert!(!is_wr2(&patch(at, v)), "{at} {v}");
}
// refused ones stay Unknown and are not applied
let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish());
assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }]));
let mut img = Rgb32f::filled(5, 5, [0.5; 3]);
apply_list3(&list, &mut img);
assert_eq!(img.get(2, 2), [0.5; 3]);
// a hostile but accepted set (huge radial terms) still gives a finite image
let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]);
let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]);
for reciprocal in [false, true] {
let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal);
assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite())));
}
}
#[test]
fn warp_list_order_and_the_optional_skip_rule() {
let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional };
let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] };
let d = std::mem::discriminant::<Opcode>;
let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::<Vec<_>>();
// WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped
assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]);
assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]);
// not optional: both run, in list order
assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]);
// only the opcode immediately after is skipped
assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]);
// two in a row: both run, and the WarpRectilinear after the optional second one is skipped
assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]);
// a WarpRectilinear before the WarpRectilinear2 runs, in list order
assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]);
// the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1
let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0);
let mut one = 1u32.to_be_bytes().to_vec();
one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5]));
let mut blob = 2u32.to_be_bytes().to_vec();
for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] {
for v in [id, 0x0106_0000, flags, p.len() as u32] {
blob.extend_from_slice(&v.to_be_bytes());
}
blob.extend_from_slice(p);
}
let list = parse_list(&blob);
assert_eq!(list.len(), 2);
assert_eq!(applied_ops(&list).count(), 1);
// a refused (Unknown) opcode 14 triggers no skip
let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] };
assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. })));
}
/// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A
/// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ.
#[test]
fn cropped_lens_corrections_correct_the_crop_like_the_whole() {
let (w, h) = (90usize, 64usize);
let crop = crate::Rect::new(7, 5, 70, 50);
let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]);
let mut wr2 = [0.0; WR2_LEN];
wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]);
wr2[15] = 0.002;
wr2[16] = -0.001;
wr2[17] = 0.05;
wr2[18] = 0.9;
let warps = [
Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] },
Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true },
Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false },
];
for op in &warps {
let mut whole = ramp.clone();
apply_list3(std::slice::from_ref(op), &mut whole);
let re = op.cropped(w, h, crop).unwrap();
let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height);
apply_list3(std::slice::from_ref(&re), &mut part);
let mut worst = 0f32;
for y in 6..crop.height - 6 {
for x in 6..crop.width - 6 {
let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]);
worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max);
}
}
assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}");
}
// the vignette, in the warps' centre convention the develop pipeline reads it with
let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| {
let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0);
let (px, py) = (c[0] * aw, c[1] * ah);
let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2);
1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::<f64>()
};
let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]);
let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else {
panic!("vignette")
};
for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] {
let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y));
assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}");
}
// no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge
assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]);
assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none());
assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none());
assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none());
assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none());
}