From f70c72aeb8d44fba30dac2d0a86f719d0d3d2d32 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 21:46:40 +0200 Subject: [PATCH 001/133] build(core): add the negpy_core Rust crate as a uv workspace member A pure-Rust PyO3 extension (abi3-py313) built by maturin. The core functions build without Python, so cargo test needs no interpreter; the python feature adds the PyO3 wrappers. The setuptools package search narrows to negpy and negpy.*, so it skips the crate folder. --- negpy_core/.gitignore | 1 + negpy_core/Cargo.lock | 280 +++++++++++++++++++++++++++++++++ negpy_core/Cargo.toml | 18 +++ negpy_core/pyproject.toml | 18 +++ negpy_core/rust-toolchain.toml | 3 + negpy_core/src/lib.rs | 10 ++ negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/stats.rs | 23 +++ negpy_core/src/stats.rs | 269 +++++++++++++++++++++++++++++++ pyproject.toml | 9 +- uv.lock | 19 +++ 11 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 negpy_core/.gitignore create mode 100644 negpy_core/Cargo.lock create mode 100644 negpy_core/Cargo.toml create mode 100644 negpy_core/pyproject.toml create mode 100644 negpy_core/rust-toolchain.toml create mode 100644 negpy_core/src/lib.rs create mode 100644 negpy_core/src/py/mod.rs create mode 100644 negpy_core/src/py/stats.rs create mode 100644 negpy_core/src/stats.rs diff --git a/negpy_core/.gitignore b/negpy_core/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/negpy_core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/negpy_core/Cargo.lock b/negpy_core/Cargo.lock new file mode 100644 index 000000000..f3f8f3fe5 --- /dev/null +++ b/negpy_core/Cargo.lock @@ -0,0 +1,280 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "negpy_core" +version = "0.1.0" +dependencies = [ + "numpy", + "pyo3", + "rayon", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0b9111761885449e894981fa8051dcf69e0bd114aecbd4bcdd830b6be4ed92" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229ca13b8a15bb0d1487b81ab975bfcd97513416499a8312011b6036e42e0de4" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70130e2b6dd1ee589a8788026a429561ca5c40fa7a2fa7914c2282eddae24700" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7ff73c555c286d212d4ce7e5fbda845a973c4f61bd5f160f5f2c3c2a04d1c4" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c46d2e5862c7bb8207e3b639f713dda3c499933444784154c11d013c5a691b60" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b89f066f2ae72a2aef52d1537ca2fcd104440ebfadee06e97aa90738a25b9b" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" diff --git a/negpy_core/Cargo.toml b/negpy_core/Cargo.toml new file mode 100644 index 000000000..9cd10bafd --- /dev/null +++ b/negpy_core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "negpy_core" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0-only" +publish = false + +[lib] +name = "negpy_core" +crate-type = ["cdylib", "rlib"] + +[features] +python = ["dep:pyo3", "dep:numpy"] + +[dependencies] +rayon = "1.12" +pyo3 = { version = "0.30", features = ["abi3-py313"], optional = true } +numpy = { version = "0.30", optional = true } diff --git a/negpy_core/pyproject.toml b/negpy_core/pyproject.toml new file mode 100644 index 000000000..9b9dd97de --- /dev/null +++ b/negpy_core/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "negpy-core" +version = "0.1.0" +description = "NegPy's native CPU and I/O kernels." +license = "GPL-3.0" +requires-python = ">=3.13" +dependencies = ["numpy"] + +[build-system] +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" + +[tool.maturin] +features = ["python"] +module-name = "negpy_core" + +[tool.uv] +cache-keys = [{ file = "pyproject.toml" }, { file = "Cargo.toml" }, { file = "Cargo.lock" }, { file = "src/**/*.rs" }] diff --git a/negpy_core/rust-toolchain.toml b/negpy_core/rust-toolchain.toml new file mode 100644 index 000000000..454825c92 --- /dev/null +++ b/negpy_core/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.99" +components = ["rustfmt", "clippy"] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs new file mode 100644 index 000000000..10fb7697c --- /dev/null +++ b/negpy_core/src/lib.rs @@ -0,0 +1,10 @@ +pub mod stats; + +#[cfg(feature = "python")] +mod py; + +#[cfg(feature = "python")] +#[pyo3::pymodule] +fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { + py::stats::register(m) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs new file mode 100644 index 000000000..9d34677fc --- /dev/null +++ b/negpy_core/src/py/mod.rs @@ -0,0 +1 @@ +pub mod stats; diff --git a/negpy_core/src/py/stats.rs b/negpy_core/src/py/stats.rs new file mode 100644 index 000000000..0403ba53a --- /dev/null +++ b/negpy_core/src/py/stats.rs @@ -0,0 +1,23 @@ +use numpy::PyReadonlyArray1; +use pyo3::exceptions::{PyIndexError, PyValueError}; +use pyo3::prelude::*; + +use crate::stats; + +/// `np.percentile(values, q)` for each q, bit for bit, on a contiguous 1-D float32 array. +#[pyfunction] +fn percentiles( + py: Python<'_>, + values: PyReadonlyArray1<'_, f32>, + qs: Vec, +) -> PyResult> { + let values = values + .as_slice() + .map_err(|_| PyValueError::new_err("percentiles needs a contiguous array"))?; + py.detach(|| stats::percentiles(values, &qs)) + .ok_or_else(|| PyIndexError::new_err("percentiles of an empty array")) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(percentiles, m)?) +} diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs new file mode 100644 index 000000000..9abffbfac --- /dev/null +++ b/negpy_core/src/stats.rs @@ -0,0 +1,269 @@ +use rayon::prelude::*; + +const BUCKETS: usize = 1 << 16; +const CHUNK: usize = 1 << 16; +/// Below this size one `select_nth_unstable` beats the histogram passes. +const DIRECT_MAX: usize = 1 << 15; + +/// Total-order key of an f32. Every NaN maps to the top, where `np.partition` puts it. +fn order_key(v: f32) -> u32 { + if v.is_nan() { + return u32::MAX; + } + let bits = v.to_bits(); + if bits & 0x8000_0000 != 0 { + !bits + } else { + bits | 0x8000_0000 + } +} + +fn from_key(key: u32) -> f32 { + if key & 0x8000_0000 != 0 { + f32::from_bits(key & 0x7fff_ffff) + } else { + f32::from_bits(!key) + } +} + +/// The lower rank of `q` and its interpolation weight, in numpy's float64 arithmetic. +fn virtual_rank(n: usize, q: f64) -> (usize, f64) { + let virtual_index = (n - 1) as f64 * (q / 100.0); + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + (lo, virtual_index - lo as f64) +} + +/// numpy's interpolation for a Python-number q: the weight joins float32 math (NEP 50), +/// and the far half counts back from the upper sample. +fn interpolate(a: f32, b: f32, t: f64) -> f32 { + let diff = b - a; + if t >= 0.5 { + b - diff * ((1.0 - t) as f32) + } else { + a + diff * (t as f32) + } +} + +/// The value at each rank in `ranks` (sorted, unique) if `values` were sorted. +fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { + if values.len() <= DIRECT_MAX { + let mut keys: Vec = values.iter().map(|&v| order_key(v)).collect(); + return ranks + .iter() + .map(|&r| from_key(*keys.select_nth_unstable(r).1)) + .collect(); + } + + let hist = values + .par_chunks(CHUNK) + .fold( + || vec![0usize; BUCKETS], + |mut h, chunk| { + for &v in chunk { + h[(order_key(v) >> 16) as usize] += 1; + } + h + }, + ) + .reduce( + || vec![0usize; BUCKETS], + |mut a, b| { + a.iter_mut().zip(&b).for_each(|(x, y)| *x += y); + a + }, + ); + + // Each rank becomes (bucket, rank inside the bucket); ranks are sorted, so one walk finds all. + let mut located = Vec::with_capacity(ranks.len()); + let mut below = 0usize; + let mut bucket = 0usize; + for &r in ranks { + while below + hist[bucket] <= r { + below += hist[bucket]; + bucket += 1; + } + located.push((bucket, r - below)); + } + + let mut slot_of = vec![u8::MAX; BUCKETS]; + let mut wanted: Vec = located.iter().map(|&(b, _)| b).collect(); + wanted.dedup(); + for (slot, &b) in wanted.iter().enumerate() { + slot_of[b] = slot as u8; + } + + let gathered = values + .par_chunks(CHUNK) + .fold( + || vec![Vec::new(); wanted.len()], + |mut g: Vec>, chunk| { + for &v in chunk { + let key = order_key(v); + let slot = slot_of[(key >> 16) as usize]; + if slot != u8::MAX { + g[slot as usize].push(key); + } + } + g + }, + ) + .reduce( + || vec![Vec::new(); wanted.len()], + |mut a, b| { + a.iter_mut().zip(b).for_each(|(x, y)| x.extend(y)); + a + }, + ); + + let mut gathered = gathered; + located + .iter() + .map(|&(b, r)| { + let keys = &mut gathered[slot_of[b] as usize]; + from_key(*keys.select_nth_unstable(r).1) + }) + .collect() +} + +/// `np.percentile(values, q)` (method "linear") for each q, bit for bit. +/// `None` for an empty input, where numpy raises. +pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { + let n = values.len(); + if n == 0 { + return None; + } + let spans: Vec<(usize, usize, f64)> = qs + .iter() + .map(|&q| { + let (lo, t) = virtual_rank(n, q); + (lo, (lo + 1).min(n - 1), t) + }) + .collect(); + let mut ranks: Vec = spans.iter().flat_map(|&(lo, hi, _)| [lo, hi]).collect(); + ranks.sort_unstable(); + ranks.dedup(); + let picked = select_ranks(values, &ranks); + let at = |r: usize| picked[ranks.binary_search(&r).unwrap_or_default()]; + Some( + spans + .iter() + .map(|&(lo, hi, t)| interpolate(at(lo), at(hi), t)) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sorted_percentile(values: &[f32], q: f64) -> f32 { + let mut s = values.to_vec(); + s.sort_by(f32::total_cmp); + let (lo, t) = virtual_rank(s.len(), q); + interpolate(s[lo], s[(lo + 1).min(s.len() - 1)], t) + } + + fn lcg(n: usize, seed: u64) -> Vec { + let mut x = seed; + (0..n) + .map(|_| { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((x >> 40) as f32 / (1u64 << 24) as f32) * 4.0 - 1.0 + }) + .collect() + } + + #[test] + fn empty_is_none() { + assert!(percentiles(&[], &[50.0]).is_none()); + } + + #[test] + fn one_element() { + assert_eq!( + percentiles(&[3.5], &[0.0, 50.0, 100.0]).unwrap(), + vec![3.5; 3] + ); + } + + #[test] + fn two_elements_interpolate() { + assert_eq!( + percentiles(&[1.0, 3.0], &[50.0, 25.0, 75.0]).unwrap(), + vec![2.0, 1.5, 2.5] + ); + } + + #[test] + fn all_equal() { + let v = vec![0.25f32; 100_000]; + assert_eq!(percentiles(&v, &[1.0, 50.0, 99.0]).unwrap(), vec![0.25; 3]); + } + + #[test] + fn infinities_and_signed_zero() { + let v = [f32::INFINITY, -0.0, 0.0, f32::NEG_INFINITY, 1.0]; + // numpy's lerp gives NaN at an infinite end (inf * 0), and so does the port. + let got = percentiles(&v, &[0.0, 50.0, 100.0]).unwrap(); + assert!(got[0].is_nan() && got[2].is_nan()); + assert_eq!(got[1], 0.0); + } + + #[test] + fn nan_sorts_last() { + let v = [f32::NAN, 2.0, 1.0, -f32::NAN, 3.0]; + assert_eq!(percentiles(&v, &[0.0, 25.0]).unwrap(), vec![1.0, 2.0]); + assert!(percentiles(&v, &[50.0]).unwrap()[0].is_nan()); + } + + #[test] + fn order_key_round_trips_and_orders() { + let vals = [ + f32::NEG_INFINITY, + -2.5, + -f32::MIN_POSITIVE, + -0.0, + 0.0, + 1e-30, + 7.0, + f32::INFINITY, + ]; + for w in vals.windows(2) { + assert!(order_key(w[0]) < order_key(w[1])); + } + for v in vals { + assert_eq!(from_key(order_key(v)).to_bits(), v.to_bits()); + } + } + + #[test] + fn histogram_path_matches_sort() { + let qs = [0.0, 0.5, 1.0, 20.0, 50.0, 60.0, 80.0, 99.0, 99.5, 100.0]; + for (n, seed) in [(DIRECT_MAX + 1, 1), (1_000_003, 2)] { + let v = lcg(n, seed); + let got = percentiles(&v, &qs).unwrap(); + for (g, &q) in got.iter().zip(&qs) { + assert_eq!( + g.to_bits(), + sorted_percentile(&v, q).to_bits(), + "n={n} q={q}" + ); + } + } + } + + #[test] + fn histogram_path_with_heavy_repeats() { + let v: Vec = lcg(200_000, 3) + .iter() + .map(|x| (x * 4.0).round() / 4.0) + .collect(); + let qs = [1.0, 50.0, 99.0]; + let got = percentiles(&v, &qs).unwrap(); + for (g, &q) in got.iter().zip(&qs) { + assert_eq!(g.to_bits(), sorted_percentile(&v, q).to_bits()); + } + } +} diff --git a/pyproject.toml b/pyproject.toml index eaf61f7f0..0c784010e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "qtawesome==1.4.2", "wgpu==0.31.1", "pyserial>=3.5", + "negpy-core", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -81,12 +82,18 @@ Documentation = "https://github.com/marcinz606/NegPy/docs" Repository = "https://github.com/marcinz606/NegPy.git" Issues = "https://github.com/marcinz606/NegPy/issues" +[tool.uv.workspace] +members = ["negpy_core"] + +[tool.uv.sources] +negpy-core = { workspace = true } + [build-system] requires = ["setuptools~=80.10.2"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["negpy*"] +include = ["negpy", "negpy.*"] [tool.setuptools.package-data] negpy = ["translations/*.json", "translations/*.md"] diff --git a/uv.lock b/uv.lock index 86e74843f..dff4c7988 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.13" +[manifest] +members = [ + "negpy", + "negpy-core", +] + [[package]] name = "altgraph" version = "0.17.5" @@ -365,6 +371,7 @@ dependencies = [ { name = "imagecodecs" }, { name = "imageio" }, { name = "jinja2" }, + { name = "negpy-core" }, { name = "numba" }, { name = "numpy" }, { name = "onnxruntime" }, @@ -424,6 +431,7 @@ requires-dist = [ { name = "imagecodecs", specifier = "==2026.6.6" }, { name = "imageio", specifier = "==2.37.3" }, { name = "jinja2", specifier = "==3.1.6" }, + { name = "negpy-core", editable = "negpy_core" }, { name = "nkscan", marker = "extra == 'nkscan'", specifier = ">=0.14.2" }, { name = "numba", specifier = "==0.65.1" }, { name = "numpy", specifier = "==2.4.4" }, @@ -457,6 +465,17 @@ pieusb = [{ name = "pieusb", specifier = ">=0.3.7" }] plustek = [{ name = "pyopticfilm", specifier = ">=1.3.7" }] sane = [{ name = "python-sane", specifier = ">=2.9" }] +[[package]] +name = "negpy-core" +version = "0.1.0" +source = { editable = "negpy_core" } +dependencies = [ + { name = "numpy" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy" }] + [[package]] name = "nkscan" version = "0.15.0" From c237f2dae9d1edc2197ee1cb87b3941e2b0353c6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 21:46:40 +0200 Subject: [PATCH 002/133] perf(core): exact percentiles in Rust percentiles() calls negpy_core.percentiles: a 16-bit radix histogram, a gather of the buckets that hold the ranks, then a select in each. Results are bit for bit np.percentile (test_percentiles adds a 1M signed array, heavy repeats and a strided view). On 23 sample scans, autocrop detection and measure_base_log give identical results. Best of N, 7800X3D, float32: 19 MP (1, 99): 152 ms -> 7.9 ms (16 threads), 23.6 ms (1 thread) 1.7 MP preview: 16.9 ms -> 0.70 ms, 2.1 ms (1 thread) 20k strip (3 qs): 0.20 ms -> 0.08 ms 60k strip (3 qs): 0.65 ms -> 0.10 ms --- negpy/kernel/image/logic.py | 17 ++++++----------- tests/test_percentiles.py | 8 ++++++++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index 477606794..ad48d0174 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -1,6 +1,7 @@ import hashlib import os from typing import Any, Optional, Sequence +import negpy_core import numpy as np from numba import njit, prange # type: ignore from negpy.kernel.system.parallel import parallel_njit @@ -723,14 +724,8 @@ def percentile_from_sorted(sorted_grid: np.ndarray, q: float) -> np.ndarray: def percentiles(values: np.ndarray, qs: Sequence[float]) -> tuple: - """`np.percentile(values, q)` for each q, bit for bit, from one partition instead of one per q.""" - flat = values.ravel() - n = flat.size - if n == 0: - raise IndexError("percentiles of an empty array") - ranks = [] - for q in qs: - lo = min(max(int(np.floor((n - 1) * np.true_divide(q, 100))), 0), n - 1) - ranks += [lo, min(lo + 1, n - 1)] - part = np.partition(flat, sorted(set(ranks))) - return tuple(percentile_from_sorted(part, q) for q in qs) + """`np.percentile(values, q)` of a float32 array for each Python-number q, bit for bit.""" + if values.dtype != np.float32: + raise TypeError(f"percentiles needs float32, got {values.dtype}") + flat = np.ascontiguousarray(values).ravel() + return tuple(np.float32(v) for v in negpy_core.percentiles(flat, [float(q) for q in qs])) diff --git a/tests/test_percentiles.py b/tests/test_percentiles.py index ba454cab2..33c787e45 100644 --- a/tests/test_percentiles.py +++ b/tests/test_percentiles.py @@ -12,6 +12,9 @@ "len10001": _RNG.random(10_001, dtype=np.float32), "repeats": np.round(_RNG.random(5_000, dtype=np.float32), 2), "image": _RNG.random((37, 53), dtype=np.float32) ** 2.2, + "large_signed": (_RNG.standard_normal(1_000_003) * 3).astype(np.float32), + "large_repeats": np.round(_RNG.random(200_000, dtype=np.float32) * 8) / 8 - 0.5, + "strided": (_RNG.random((300, 400), dtype=np.float32))[::2, 1::3], } @@ -33,6 +36,11 @@ def test_percentiles_leave_the_input_unchanged(): np.testing.assert_array_equal(a, _ARRAYS["len10001"]) +def test_percentiles_reject_other_dtypes(): + with pytest.raises(TypeError): + percentiles(np.arange(10, dtype=np.float64), (50,)) + + def test_percentiles_empty_raises(): with pytest.raises(IndexError): np.percentile(np.array([], dtype=np.float32), 50) From 34adea390badf53fe9cd6d8ab8948601494a0ddd Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 21:55:24 +0200 Subject: [PATCH 003/133] build(core): make, CI, release and nix targets for negpy_core make rust runs cargo fmt --check, clippy -D warnings and cargo test; make all and make format include it. The Makefile unexports ARCHFLAGS, because maturin reads it on every OS. CI adds the Rust checks, cargo-deny (licenses, a ban on crates that compile C) and a four-OS native job that runs the Rust tests and the ported-function oracles. CI also runs on mz/core. release.yml installs the pinned toolchain and takes a publish input, so a test build makes no release. flake.nix builds negpy-core from the vendored Cargo.lock (not tested: no nix here). --- .github/workflows/ci.yml | 61 +++++++++++++++++++++++++++++++++-- .github/workflows/release.yml | 16 ++++++++- Makefile | 18 ++++++++++- flake.nix | 19 +++++++++++ negpy_core/deny.toml | 19 +++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 negpy_core/deny.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a80b19fe..9ade1f4c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, mz/core ] pull_request: - branches: [ main ] + branches: [ main, mz/core ] jobs: test: @@ -23,6 +23,20 @@ jobs: - name: Set up Python run: uv python install 3.13 + - name: Set up Rust + # Installs the toolchain pinned in negpy_core/rust-toolchain.toml. + working-directory: negpy_core + run: rustup toolchain install + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: negpy_core + + - name: Rust licenses and bans (cargo-deny) + uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: negpy_core/Cargo.toml + - name: Install System Dependencies (Linux) if: runner.os == 'Linux' run: | @@ -34,6 +48,10 @@ jobs: # them those backend tests importorskip away. run: uv sync --group dev --group plustek --group pieusb + - name: Rust checks (fmt, clippy, test) + # Under uv run, PyO3's build script finds the project's Python 3.13, not the runner's. + run: uv run --no-sync make rust + - name: Run Linting (Ruff) run: uv run ruff check . @@ -63,3 +81,42 @@ jobs: name: coverage path: coverage.xml retention-days: 7 + + native: + # negpy_core must give the same bits on every release platform: run the Rust tests and + # the pytest oracles of the ported functions on each one. + name: Native (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-14, macos-15-intel] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + run: uv python install 3.13 + + - name: Set up Rust + working-directory: negpy_core + run: rustup toolchain install + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: negpy_core + + - name: Rust tests + run: cargo test --manifest-path negpy_core/Cargo.toml + + - name: Install Dependencies + run: uv sync --group dev + + - name: Ported-function oracles + run: uv run pytest tests/test_percentiles.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 967bec815..1e1b7565e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ on: - windows - linux - macos + publish: + description: 'Create the GitHub release (off for a test build)' + required: true + default: true + type: boolean jobs: setup: @@ -56,6 +61,15 @@ jobs: - name: Set up Python run: uv python install 3.13 + - name: Set up Rust + # uv sync builds negpy_core with the toolchain pinned in negpy_core/rust-toolchain.toml. + working-directory: negpy_core + run: rustup toolchain install + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: negpy_core + - name: Install System Dependencies (Linux) if: matrix.os == 'ubuntu-latest' run: | @@ -133,7 +147,7 @@ jobs: publish: needs: build runs-on: ubuntu-latest - if: success() + if: success() && inputs.publish environment: production permissions: contents: write diff --git a/Makefile b/Makefile index 1990f2891..d3f48386f 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,23 @@ $(error NEGPY_USER_DIR is "$(NEGPY_USER_DIR)", which is not an absolute path. ma endif endif +# maturin reads ARCHFLAGS on every OS, so a stray "-arch x86_64" builds negpy_core for macOS. +unexport ARCHFLAGS + +CARGO = cargo --quiet +CORE = --manifest-path negpy_core/Cargo.toml + # Default target .PHONY: all -all: format lint type test +all: format rust lint type test + +# Rust checks for negpy_core: format, clippy (warnings fail), unit tests +.PHONY: rust +rust: + @echo "Running Rust checks (cargo fmt, clippy, test)..." + @$(CARGO) fmt $(CORE) --check + @$(CARGO) clippy $(CORE) --all-targets --all-features -- -D warnings + @$(CARGO) test $(CORE) .PHONY: translations translations-check translations: @@ -74,6 +88,7 @@ test: .PHONY: format format: @echo "Running ruff format and fix..." + @$(CARGO) fmt $(CORE) @$(UV) ruff format . @$(UV) ruff check --fix . @@ -165,4 +180,5 @@ clean: rm -rf .ruff_cache rm -rf build rm -rf dist + rm -rf negpy_core/target find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/flake.nix b/flake.nix index 3eee678d7..1b2503e6f 100644 --- a/flake.nix +++ b/flake.nix @@ -40,8 +40,22 @@ sourcePreference = "wheel"; }; + # negpy-core stays a normal build: a Rust edit needs a new `nix develop`. editableOverlay = workspace.mkEditablePyprojectOverlay { root = "$REPO_ROOT"; + members = ["negpy"]; + }; + + # The Rust extension builds with maturin from the vendored Cargo.lock; nothing is + # downloaded at build time. + rustOverlay = pkgs: final: prev: { + negpy-core = prev.negpy-core.overrideAttrs (old: { + cargoDeps = pkgs.rustPlatform.importCargoLock {lockFile = ./negpy_core/Cargo.lock;}; + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [pkgs.rustPlatform.cargoSetupHook pkgs.cargo pkgs.rustc] + ++ final.resolveBuildSystem {maturin = [];}; + }); }; # Packages that need system Qt6/Vulkan/LLVM rather than what autoPatchelf can wire @@ -141,6 +155,7 @@ pyproject-build-systems.overlays.wheel overlay (nixpkgsFallbackOverlay pkgs pkgs.python313Packages) + (rustOverlay pkgs) (dataFixupOverlay python) ] ); @@ -254,6 +269,10 @@ virtualenv pkgs.uv pkgs.qt6.qtbase + pkgs.cargo + pkgs.rustc + pkgs.rustfmt + pkgs.clippy ]; env = { UV_NO_SYNC = "1"; diff --git a/negpy_core/deny.toml b/negpy_core/deny.toml new file mode 100644 index 000000000..fd78dec9e --- /dev/null +++ b/negpy_core/deny.toml @@ -0,0 +1,19 @@ +[graph] +all-features = true + +[licenses] +# Each must be GPL-3.0 compatible. Add a license here only after that check. +allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "Unicode-3.0"] +private = { ignore = true } + +[bans] +multiple-versions = "allow" +# negpy_core builds no C or C++: a codec that needs a C library stays on its Python wheel. +deny = [{ name = "cc" }, { name = "cmake" }, { name = "pkg-config" }, { name = "bindgen" }] + +[advisories] +yanked = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" From 5066ad6458dda8204cf833a37bc4ac9f1c5d7afc Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 21:55:24 +0200 Subject: [PATCH 004/133] docs(core): negpy_core rules, setup and crate notices --- CLAUDE.md | 19 ++++++++++++++++--- CONTRIBUTING.md | 12 ++++++++---- NOTICE.md | 6 ++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f0c96ae4..226b8ea9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,18 +15,19 @@ Guidance for Claude Code in this repository. It holds rules and traps the code d ```bash make run # Launch the desktop app make run-sim # Launch with a simulated camera, Scanlight and film scanners -make all # lint + type check + tests (run before committing) +make all # format + Rust checks + lint + type check + tests (run before committing) +make rust # cargo fmt --check + clippy -D warnings + cargo test for negpy_core make test # pytest only make lint # ruff check make type # ty check (not mypy) -make format # ruff format + autofix +make format # cargo fmt + ruff format + autofix make translations # Extract translation sources and preserve existing translations # Single test uv run pytest tests/test_exposure_logic.py::test_name -v ``` -All commands run through `uv run`; never invoke pytest/ruff/ty directly. Run `make format` before committing; if it reformats unrelated files, commit those as lint fixes. +All commands run through `uv run`; never invoke pytest/ruff/ty directly. `uv run` compiles `negpy_core` again after a Rust file changes (`tool.uv.cache-keys`). Run `make format` before committing; if it reformats unrelated files, commit those as lint fixes. ## Architecture @@ -50,6 +51,18 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored **Film edge print** (`services/film_edge/`): a stock's own marks, DX number and respool source (`prints_as`) are the `edge` object in `gear/film_stocks.json`, and each entry names its `source`. The `edge-print-from-photo` skill adds a stock from a photo of the film. +### negpy_core (Rust) + +`negpy_core/` is a PyO3 extension (abi3-py313, maturin, a `uv` workspace member) that takes over CPU kernels one group at a time. + +- A ported function keeps its Python signature; the Python body becomes a call into `negpy_core`, and the Python implementation goes in the same change. No runtime switch, no fallback. +- Core code in `src/.rs` takes slices or `ndarray` views and plain numbers, never config. `src/py/.rs` holds only the wrappers: dtype, shape and contiguity checks, `py.detach` around the work, errors as Python exceptions. No panic crosses into Python. +- `cargo test` builds without Python: PyO3 is behind the `python` feature, which maturin turns on. +- Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. +- No `unsafe` except SIMD, each with a reason. Names mirror the Python names. +- **Bit-exact**: a port matches the Python result bit for bit, checked by a pytest oracle against the numpy or `cv2` call it replaces, run on every release OS in CI (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. +- A port that cannot be bit-exact needs the user's agreement, then the `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` bumps. + ### Pipeline - **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`): base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`. Base and exposure route on `process.path.render_path`: `PRINT` runs `features/exposure/`; `TRANSFER` and `POSITIVE` run `features/transparency/`. `features/exposure/` holds no slide branch and never imports `features/transparency/` (`tests/test_slide_separation.py`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8bd37caf..4efb0e78f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,10 +11,12 @@ A 👀 reaction on your comment confirms that the command ran. ## Development Setup -NegPy requires Python 3.13+ and `uv`. +NegPy requires Python 3.13+, `uv` and a Rust toolchain. ### 1. Prerequisites -Install [uv](https://docs.astral.sh/uv/getting-started/installation/). +Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and [rustup](https://rustup.rs/). `rustup` reads the toolchain version from `negpy_core/rust-toolchain.toml`. + +The `negpy_core/` folder is a Rust library with NegPy's CPU kernels. The first `uv sync` compiles it. After you edit a Rust file, the next `uv run` or `make test` compiles it again. `negpy_core` builds no C or C++ code: `cargo-deny` (`negpy_core/deny.toml`) rejects a dependency that does. Scanner support (optional): @@ -108,6 +110,7 @@ backend are fake. - `negpy/kernel/`: core system services (Logging, Config, caching). - `negpy/services/`: high-level orchestration (Rendering engine, Export service). - `negpy/desktop/`: PyQt6 UI (View, Controller, Workers). +- `negpy_core/`: Rust library for CPU kernels (`src/.rs`), with its PyO3 wrappers in `src/py/`. - `tests/`: unit and integration tests. ## Coding Standards @@ -149,8 +152,9 @@ Every developer command runs through `uv run`: - `make lint`: Ruff checks. - `make type`: `ty` type checks. - `make test`: unit tests. -- `make format`: auto-format with Ruff. -- `make all`: lint, type and test in sequence. +- `make format`: auto-format with `cargo fmt` and Ruff. +- `make rust`: `cargo fmt --check`, `cargo clippy` (warnings fail) and `cargo test` for `negpy_core`. +- `make all`: format, Rust checks, lint, type and test in sequence. - `make clean`: remove cache and build artifacts. - `make run-sim`: run with simulated scanning hardware (see [above](#running-without-scanning-hardware)). - `make clear-devhome`: delete the development user directory (see [above](#a-separate-user-directory-for-development)). diff --git a/NOTICE.md b/NOTICE.md index f01b65e93..3b7163611 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -58,3 +58,9 @@ without warranties: Modified by NegPy: each four-digit DX number is split into the part 1 and part 2 of the film edge barcode. NegPy distributes them under the GPL v3, which Creative Commons lists as a BY-SA compatible license. + +The negpy_core extension module links these Rust crates: PyO3 and rust-numpy +(MIT OR Apache-2.0; rust-numpy BSD-2-Clause), rayon and crossbeam (MIT OR +Apache-2.0), ndarray, num-traits, num-complex, num-integer, matrixmultiply and +rawpointer (MIT OR Apache-2.0), libc, once_cell, either, portable-atomic and +rustc-hash (MIT OR Apache-2.0). negpy_core/Cargo.lock lists the exact versions. From 82d324a4c69f1c48cfaee12af4310cceead62fdf Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 22:00:14 +0200 Subject: [PATCH 005/133] fix(core): review fixes for the percentile port and the build - More than 255 histogram buckets wrapped the u8 slot index onto the empty-slot marker and panicked into Python; slots are u32. - The wrapper rejects a q outside [0, 100], as np.percentile does. - make rust runs cargo from negpy_core/, so rustup uses the pinned toolchain; the CI native job does the same and installs the Qt libraries the pytest session fixture needs on Linux. - Docs state the signed-zero exception and the pkg-config ban. --- .github/workflows/ci.yml | 9 ++++++++- CLAUDE.md | 4 ++-- Makefile | 12 ++++++------ negpy_core/src/py/stats.rs | 5 +++++ negpy_core/src/stats.rs | 20 +++++++++++++++----- 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ade1f4c1..e8976dfd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,14 @@ jobs: workspaces: negpy_core - name: Rust tests - run: cargo test --manifest-path negpy_core/Cargo.toml + working-directory: negpy_core + run: cargo test + + - name: Install System Dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 - name: Install Dependencies run: uv sync --group dev diff --git a/CLAUDE.md b/CLAUDE.md index 226b8ea9a..948838d41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,9 +58,9 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - A ported function keeps its Python signature; the Python body becomes a call into `negpy_core`, and the Python implementation goes in the same change. No runtime switch, no fallback. - Core code in `src/.rs` takes slices or `ndarray` views and plain numbers, never config. `src/py/.rs` holds only the wrappers: dtype, shape and contiguity checks, `py.detach` around the work, errors as Python exceptions. No panic crosses into Python. - `cargo test` builds without Python: PyO3 is behind the `python` feature, which maturin turns on. -- Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. +- Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `pkg-config`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. -- **Bit-exact**: a port matches the Python result bit for bit, checked by a pytest oracle against the numpy or `cv2` call it replaces, run on every release OS in CI (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. +- **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces, run on every release OS in CI (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - A port that cannot be bit-exact needs the user's agreement, then the `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` bumps. ### Pipeline diff --git a/Makefile b/Makefile index d3f48386f..ae3511f39 100644 --- a/Makefile +++ b/Makefile @@ -19,8 +19,8 @@ endif # maturin reads ARCHFLAGS on every OS, so a stray "-arch x86_64" builds negpy_core for macOS. unexport ARCHFLAGS -CARGO = cargo --quiet -CORE = --manifest-path negpy_core/Cargo.toml +# From inside negpy_core/, so rustup picks the toolchain pinned in its rust-toolchain.toml. +CARGO = cd negpy_core && cargo --quiet # Default target .PHONY: all @@ -30,9 +30,9 @@ all: format rust lint type test .PHONY: rust rust: @echo "Running Rust checks (cargo fmt, clippy, test)..." - @$(CARGO) fmt $(CORE) --check - @$(CARGO) clippy $(CORE) --all-targets --all-features -- -D warnings - @$(CARGO) test $(CORE) + @$(CARGO) fmt --check + @$(CARGO) clippy --all-targets --all-features -- -D warnings + @$(CARGO) test .PHONY: translations translations-check translations: @@ -88,7 +88,7 @@ test: .PHONY: format format: @echo "Running ruff format and fix..." - @$(CARGO) fmt $(CORE) + @$(CARGO) fmt @$(UV) ruff format . @$(UV) ruff check --fix . diff --git a/negpy_core/src/py/stats.rs b/negpy_core/src/py/stats.rs index 0403ba53a..5e9be79c6 100644 --- a/negpy_core/src/py/stats.rs +++ b/negpy_core/src/py/stats.rs @@ -11,6 +11,11 @@ fn percentiles( values: PyReadonlyArray1<'_, f32>, qs: Vec, ) -> PyResult> { + if qs.iter().any(|q| !(0.0..=100.0).contains(q)) { + return Err(PyValueError::new_err( + "Percentiles must be in the range [0, 100]", + )); + } let values = values .as_slice() .map_err(|_| PyValueError::new_err("percentiles needs a contiguous array"))?; diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index 9abffbfac..87b351863 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -85,11 +85,11 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { located.push((bucket, r - below)); } - let mut slot_of = vec![u8::MAX; BUCKETS]; + let mut slot_of = vec![u32::MAX; BUCKETS]; let mut wanted: Vec = located.iter().map(|&(b, _)| b).collect(); wanted.dedup(); for (slot, &b) in wanted.iter().enumerate() { - slot_of[b] = slot as u8; + slot_of[b] = slot as u32; } let gathered = values @@ -100,7 +100,7 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { for &v in chunk { let key = order_key(v); let slot = slot_of[(key >> 16) as usize]; - if slot != u8::MAX { + if slot != u32::MAX { g[slot as usize].push(key); } } @@ -125,8 +125,8 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { .collect() } -/// `np.percentile(values, q)` (method "linear") for each q, bit for bit. -/// `None` for an empty input, where numpy raises. +/// `np.percentile(values, q)` (method "linear") for each q, bit for bit, except that -0.0 and +/// 0.0 may swap: `np.partition` ranks them equal and returns either. `None` for an empty input. pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { let n = values.len(); if n == 0 { @@ -254,6 +254,16 @@ mod tests { } } + #[test] + fn histogram_path_with_more_buckets_than_a_byte() { + let v = lcg(100_000, 4); + let qs: Vec = (0..300).map(|i| i as f64 / 3.0).collect(); + let got = percentiles(&v, &qs).unwrap(); + for (g, &q) in got.iter().zip(&qs) { + assert_eq!(g.to_bits(), sorted_percentile(&v, q).to_bits(), "q={q}"); + } + } + #[test] fn histogram_path_with_heavy_repeats() { let v: Vec = lcg(200_000, 3) From 232e4e19c4229f8465a32681db51a5790374c54d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 22:03:31 +0200 Subject: [PATCH 006/133] ci(core): build llvmlite against LLVM 20 in the Intel macOS native job --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8976dfd2..f0eceeeb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,17 @@ jobs: sudo apt-get update sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 + - name: Configure LLVM for llvmlite (macOS x86_64) + # llvmlite has no Intel macOS wheel, so it builds from source against LLVM 20. + if: matrix.os == 'macos-15-intel' + shell: bash + run: | + brew install llvm@20 + LLVM20="$(brew --prefix llvm@20)" + echo "PATH=/usr/local/bin:${LLVM20}/bin:${PATH}" >> "$GITHUB_ENV" + echo "LLVM_CONFIG=${LLVM20}/bin/llvm-config" >> "$GITHUB_ENV" + echo "CMAKE_ARGS=-DLLVM_DIR=${LLVM20}/lib/cmake/llvm -DCMAKE_PREFIX_PATH=${LLVM20}" >> "$GITHUB_ENV" + - name: Install Dependencies run: uv sync --group dev From b040bc0fc452217c5693d6bbce7eba925dea8a5b Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 22:22:05 +0200 Subject: [PATCH 007/133] ci: run the GPU tests on lavapipe The test job installs mesa-vulkan-drivers and pins wgpu to Vulkan, so the GPU tests and WGSL parity tests run in CI instead of skipping. --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0eceeeb3..af872ce0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 mesa-vulkan-drivers - name: Install Dependencies # pieusb / plustek are pure Python (+ bundled libusb where needed); without @@ -73,6 +73,10 @@ jobs: --ignore "call-non-callable" - name: Run Tests + # Lavapipe (Mesa's software Vulkan) runs the GPU tests; without the backend pin, + # wgpu picks the GL adapter first. + env: + WGPU_BACKEND_TYPE: Vulkan run: uv run pytest tests/ --cov=negpy --cov-report=term-missing --cov-report=xml - name: Upload Coverage Report From 5bb65be88f222e8b0f564fa78d280a04dcc76eb9 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 22:23:06 +0200 Subject: [PATCH 008/133] build(core): task manifest of the functions negpy_core replaces scripts/core_manifest.py scans negpy/ for functions that call cv2, a numpy order statistic or are Numba kernels, and writes negpy_core/manifest.toml: 232 entries with a seeded phase, the calls they make and the manifest functions they depend on. Phase, status, owner and note are kept by hand across rescans. test_core_manifest fails when the file is stale, so a merge from main that adds a hot function shows up. --- CLAUDE.md | 1 + negpy_core/manifest.toml | 1401 +++++++++++++++++++++++++++++++++++ scripts/core_manifest.py | 156 ++++ tests/test_core_manifest.py | 18 + 4 files changed, 1576 insertions(+) create mode 100644 negpy_core/manifest.toml create mode 100644 scripts/core_manifest.py create mode 100644 tests/test_core_manifest.py diff --git a/CLAUDE.md b/CLAUDE.md index 948838d41..2db12d291 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `pkg-config`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces, run on every release OS in CI (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. +- `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - A port that cannot be bit-exact needs the user's agreement, then the `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` bumps. ### Pipeline diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml new file mode 100644 index 000000000..2d2297752 --- /dev/null +++ b/negpy_core/manifest.toml @@ -0,0 +1,1401 @@ +# One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes +# calls and depends; phase, status (todo, ported, stays), owner and note are kept by hand. + +["kernel/image/logic.py::percentiles"] +phase = 1 +status = "ported" +calls = ["np.partition"] +depends = [] +note = "negpy_core::stats::percentiles" + +["features/exposure/analysis.py::loupe_acutance"] +phase = 2 +status = "todo" +calls = ["cv2.Laplacian"] +depends = [] + +["features/exposure/analysis.py::paper_grid_mosaic"] +phase = 2 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/exposure/analysis.py::zone_anchors"] +phase = 2 +status = "todo" +calls = ["cv2.connectedComponentsWithStats", "cv2.distanceTransform"] +depends = [] + +["features/exposure/analysis.py::zone_field"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.medianBlur", "cv2.resize"] +depends = [] + +["features/exposure/analysis.py::zone_grid"] +phase = 2 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/exposure/analysis.py::zone_region_labels"] +phase = 2 +status = "todo" +calls = ["cv2.connectedComponentsWithStats"] +depends = [] + +["features/exposure/normalization.py::_block_median_grid"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = ["_median"] + +["features/exposure/normalization.py::_block_median_grid._median"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/exposure/normalization.py::_normalize_log_image_jit"] +phase = 2 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/normalization.py::_same_pixel_color_floor_refs"] +phase = 2 +status = "todo" +calls = ["np.median", "np.percentile", "np.quantile"] +depends = ["_select"] + +["features/exposure/normalization.py::_same_pixel_color_floor_refs._select"] +phase = 2 +status = "todo" +calls = ["np.median", "np.quantile"] +depends = [] + +["features/exposure/normalization.py::_sample_log_bounds"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = ["_pct"] + +["features/exposure/normalization.py::_sample_log_bounds._pct"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/exposure/normalization.py::_smooth_along_scan_order"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/exposure/normalization.py::analysis_grid"] +phase = 2 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/exposure/normalization.py::contrast_mask_plane"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur"] +depends = ["analysis_grid"] + +["features/exposure/normalization.py::density_above_base"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/exposure/normalization.py::detect_light_groups"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = ["_smooth_along_scan_order", "pool_neutral_axis"] + +["features/exposure/normalization.py::diffusion_grid"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur"] +depends = ["analysis_grid"] + +["features/exposure/normalization.py::measure_anchor_from_log"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::measure_base_log"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/exposure/normalization.py::measure_highlight_point_from_log"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::measure_neutral_axis_from_log"] +phase = 2 +status = "todo" +calls = ["np.median", "np.percentile", "np.quantile"] +depends = ["_band_refs", "_block_median_grid"] + +["features/exposure/normalization.py::measure_neutral_axis_from_log._band_refs"] +phase = 2 +status = "todo" +calls = ["np.median", "np.percentile", "np.quantile"] +depends = [] + +["features/exposure/normalization.py::measure_shadow_point_from_log"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::measure_textural_range_from_log"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::pool_frame_bounds"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/exposure/normalization.py::pool_neutral_axis"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/batch_autocrop.py::_mad"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/batch_autocrop.py::_map_rect_between_rotations"] +phase = 2 +status = "todo" +calls = ["cv2.getRotationMatrix2D"] +depends = [] + +["features/geometry/batch_autocrop.py::_rect_from_edge_profile"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/geometry/batch_autocrop.py::_roll_border"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/batch_autocrop.py::_threshold_fallback_frames"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/batch_autocrop.py::_top_edge_slope"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.median", "np.quantile"] +depends = [] + +["features/geometry/batch_autocrop.py::_vertical_edge_profile"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.percentile"] +depends = [] + +["features/geometry/batch_autocrop.py::build_roll_template"] +phase = 2 +status = "todo" +calls = ["np.median", "np.percentile"] +depends = ["_mad", "_roll_border", "_threshold_fallback_frames"] + +["features/geometry/logic.py::_adaptive_threshold_levels"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/geometry/logic.py::_adaptive_threshold_mask"] +phase = 2 +status = "todo" +calls = ["cv2.getStructuringElement", "cv2.morphologyEx"] +depends = [] + +["features/geometry/logic.py::_border_edge_scatter"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/logic.py::_candidate_from_contour"] +phase = 2 +status = "todo" +calls = ["cv2.boundingRect", "cv2.boxPoints", "cv2.contourArea", "cv2.minAreaRect"] +depends = ["_oriented_boundary_evidence"] + +["features/geometry/logic.py::_candidates_from_mask"] +phase = 2 +status = "todo" +calls = ["cv2.findContours"] +depends = ["_candidate_from_contour"] + +["features/geometry/logic.py::_dark_region_bounds"] +phase = 2 +status = "todo" +calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.cvtColor", "cv2.findContours", "cv2.getStructuringElement", "cv2.morphologyEx", "np.percentile"] +depends = ["_ensure_color"] + +["features/geometry/logic.py::_detection_luma"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = ["_ensure_color"] + +["features/geometry/logic.py::_edge_border_depth"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = ["_border_edge_scatter", "_pool_border_depth", "_walk_border_depth"] + +["features/geometry/logic.py::_ensure_color"] +phase = 2 +status = "todo" +calls = ["cv2.cvtColor"] +depends = [] + +["features/geometry/logic.py::_estimate_tier_levels"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = ["_find_rebate_level"] + +["features/geometry/logic.py::_film_surround_is_plausible"] +phase = 2 +status = "todo" +calls = ["np.median", "np.percentile"] +depends = [] + +["features/geometry/logic.py::_find_rebate_level"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/geometry/logic.py::_mask_from_blackhat"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.morphologyEx", "cv2.threshold"] +depends = [] + +["features/geometry/logic.py::_mask_from_edges"] +phase = 2 +status = "todo" +calls = ["cv2.Canny", "cv2.GaussianBlur", "cv2.dilate", "cv2.getStructuringElement", "cv2.morphologyEx"] +depends = [] + +["features/geometry/logic.py::_mask_from_inverse_threshold"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.morphologyEx", "cv2.threshold"] +depends = [] + +["features/geometry/logic.py::_normalize_detection_input"] +phase = 2 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/geometry/logic.py::_oriented_boundary_evidence"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = ["_polygon_values"] + +["features/geometry/logic.py::_polygon_values"] +phase = 2 +status = "todo" +calls = ["cv2.fillConvexPoly"] +depends = [] + +["features/geometry/logic.py::_pool_border_depth"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/geometry/logic.py::_pre_keystone_point"] +phase = 2 +status = "todo" +calls = ["cv2.getRotationMatrix2D"] +depends = [] + +["features/geometry/logic.py::_refine_frame_bounds"] +phase = 2 +status = "todo" +calls = ["cv2.Sobel", "cv2.convertScaleAbs", "cv2.cvtColor", "np.percentile"] +depends = ["_dark_region_bounds", "_ensure_color"] + +["features/geometry/logic.py::_remap_tiled"] +phase = 2 +status = "todo" +calls = ["cv2.remap"] +depends = [] + +["features/geometry/logic.py::_score_contour"] +phase = 2 +status = "todo" +calls = ["cv2.boxPoints", "cv2.contourArea", "cv2.minAreaRect"] +depends = [] + +["features/geometry/logic.py::_snap_edge_to_gradient"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/logic.py::_threshold_polarities"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/logic.py::_vertical_edge_profile"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.percentile"] +depends = [] + +["features/geometry/logic.py::_walk_border_depth"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/logic.py::apply_fine_rotation"] +phase = 2 +status = "todo" +calls = ["cv2.getRotationMatrix2D", "cv2.invertAffineTransform", "cv2.warpAffine"] +depends = ["_remap_tiled"] + +["features/geometry/logic.py::apply_keystone"] +phase = 2 +status = "todo" +calls = ["cv2.warpPerspective"] +depends = ["_remap_tiled"] + +["features/geometry/logic.py::apply_radial_distortion"] +phase = 2 +status = "todo" +calls = ["cv2.remap"] +depends = ["_remap_tiled"] + +["features/geometry/logic.py::compute_geometry_crop_rect"] +phase = 2 +status = "todo" +calls = ["cv2.getRotationMatrix2D"] +depends = [] + +["features/geometry/logic.py::detect_film_bounds_with_confidence"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.cvtColor", "np.median", "np.percentile"] +depends = ["_adaptive_threshold_levels", "_adaptive_threshold_mask", "_candidates_from_mask", "_detection_luma", "_ensure_color", "_film_surround_is_plausible", "_mask_from_blackhat", "_mask_from_edges", "_mask_from_inverse_threshold", "_threshold_polarities", "_vertical_edge_profile"] + +["features/geometry/logic.py::keystone_matrix_normalized"] +phase = 2 +status = "todo" +calls = ["cv2.getPerspectiveTransform"] +depends = [] + +["features/geometry/logic.py::measure_film_border"] +phase = 2 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/geometry/logic.py::resolve_autocrop_rect"] +phase = 2 +status = "todo" +calls = ["cv2.resize"] +depends = ["_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion"] + +["features/geometry/skew.py::_at_canvas_edges"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/geometry/skew.py::_fit_side"] +phase = 2 +status = "todo" +calls = ["np.median"] +depends = ["_profile_peaks"] + +["features/geometry/skew.py::_map"] +phase = 2 +status = "todo" +calls = ["cv2.getRotationMatrix2D"] +depends = [] + +["features/geometry/skew.py::_profile_lines"] +phase = 2 +status = "todo" +calls = ["cv2.Sobel", "cv2.dilate", "np.percentile"] +depends = ["_fit_side"] + +["features/geometry/skew.py::_profile_peaks"] +phase = 2 +status = "todo" +calls = ["cv2.GaussianBlur", "np.median"] +depends = [] + +["features/geometry/skew.py::_unrotate"] +phase = 2 +status = "todo" +calls = ["cv2.getRotationMatrix2D"] +depends = [] + +["infrastructure/loaders/logluv.py::normalize_linear"] +phase = 3 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["infrastructure/loaders/rawpy_loader.py::_stream_linearraw_preview"] +phase = 3 +status = "todo" +calls = ["cv2.erode", "cv2.getStructuringElement", "cv2.resize"] +depends = [] + +["kernel/image/logic.py::_float_to_uint16_luma_jit"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_float_to_uint8_luma_jit"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_gamut_aware_chroma_scale_kernel"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = ["_in_gamut_lab"] + +["kernel/image/logic.py::_get_luminance_jit"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_in_gamut_lab"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_lab_to_rgb_kernel"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_matmul_3x3_kernel"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_oetf_decode_flat"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_oetf_encode_flat"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_rgb_to_lab_kernel"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_skin_chroma_rein_kernel"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = ["_skin_weight"] + +["kernel/image/logic.py::_skin_weight"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = ["_smoothstep_scalar"] + +["kernel/image/logic.py::_smoothstep_scalar"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_to_uint16_jit"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_to_uint8_jit"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::rgb_to_rgba_into"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::rgba_to_rgb_into"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::uint16_to_float32"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::uint8_to_float32"] +phase = 3 +status = "todo" +calls = ["numba"] +depends = [] + +["features/flatfield/logic.py::_evenness"] +phase = 4 +status = "todo" +calls = ["np.median", "np.percentile"] +depends = ["_lit_mask"] + +["features/flatfield/logic.py::_fill_from_nearest"] +phase = 4 +status = "todo" +calls = ["cv2.distanceTransformWithLabels"] +depends = [] + +["features/flatfield/logic.py::_lit_mask"] +phase = 4 +status = "todo" +calls = ["cv2.erode", "np.percentile"] +depends = [] + +["features/flatfield/logic.py::apply_flatfield"] +phase = 4 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/flatfield/logic.py::compute_gain"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur"] +depends = ["_fill_from_nearest", "_lit_mask", "work_copy"] + +["features/flatfield/logic.py::self_corrected"] +phase = 4 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/flatfield/logic.py::work_copy"] +phase = 4 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/lens/logic.py::apply_lens"] +phase = 4 +status = "todo" +calls = ["cv2.remap"] +depends = [] + +["features/lens/warps.py::RectilinearWarp.remap"] +phase = 4 +status = "todo" +calls = ["cv2.undistortPointsIter"] +depends = [] + +["features/local/logic.py::rasterise"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.fillPoly"] +depends = [] + +["features/process/capture_color.py::lightbox_level"] +phase = 4 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/process/chart.py::sample_patches"] +phase = 4 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/process/logic.py::detect_process_mode"] +phase = 4 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/process/sensor.py::_log_unmix_kernel"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/process/sensor.py::_two_scale_kernel"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/process/sensor.py::_two_scale_layer_kernel"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/process/sensor.py::_two_scale_unmix"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.resize"] +depends = ["_two_scale_kernel", "_two_scale_layer_kernel", "film_base"] + +["features/process/sensor.py::_unmix_kernel"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/process/sensor.py::film_base"] +phase = 4 +status = "todo" +calls = ["cv2.resize", "np.median", "np.percentile", "np.quantile"] +depends = [] + +["features/retouch/clone.py::_cover"] +phase = 4 +status = "todo" +calls = ["cv2.circle", "cv2.polylines"] +depends = [] + +["features/retouch/clone.py::_local_mean"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur"] +depends = [] + +["features/retouch/clone.py::apply_clone_stroke"] +phase = 4 +status = "todo" +calls = ["cv2.distanceTransform"] +depends = ["_cover", "_local_mean"] + +["features/retouch/logic.py::_add_floor_deficit"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/retouch/logic.py::_blend_fill"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/retouch/logic.py::_borrow_clean_grain"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.distanceTransformWithLabels"] +depends = [] + +["features/retouch/logic.py::_box_mean_std"] +phase = 4 +status = "todo" +calls = ["cv2.blur"] +depends = [] + +["features/retouch/logic.py::_erode_resize_bounded"] +phase = 4 +status = "todo" +calls = ["cv2.erode", "cv2.resize"] +depends = [] + +["features/retouch/logic.py::_fill_rung"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/retouch/logic.py::_fit_refraction_gammas"] +phase = 4 +status = "todo" +calls = ["cv2.Laplacian", "np.median", "np.percentile"] +depends = [] + +["features/retouch/logic.py::_hair_encode"] +phase = 4 +status = "todo" +calls = ["numba"] +depends = [] + +["features/retouch/logic.py::_ir_clean_base"] +phase = 4 +status = "todo" +calls = ["cv2.blur", "cv2.dilate", "cv2.getStructuringElement"] +depends = [] + +["features/retouch/logic.py::_ir_decontaminate"] +phase = 4 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/retouch/logic.py::_ir_glare"] +phase = 4 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/retouch/logic.py::_ir_live"] +phase = 4 +status = "todo" +calls = ["cv2.connectedComponentsWithStats"] +depends = [] + +["features/retouch/logic.py::_ir_normalize_ratio"] +phase = 4 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/retouch/logic.py::_is_hair"] +phase = 4 +status = "todo" +calls = ["cv2.distanceTransform"] +depends = [] + +["features/retouch/logic.py::_mask_to_score"] +phase = 4 +status = "todo" +calls = ["cv2.distanceTransform"] +depends = [] + +["features/retouch/logic.py::_proxy_norm"] +phase = 4 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/retouch/logic.py::_scratch_ridge"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.blur"] +depends = [] + +["features/retouch/logic.py::_shear_rows"] +phase = 4 +status = "todo" +calls = ["cv2.warpAffine"] +depends = [] + +["features/retouch/logic.py::apply_ir_attenuation"] +phase = 4 +status = "todo" +calls = ["cv2.multiply", "cv2.resize"] +depends = [] + +["features/retouch/logic.py::apply_score_repair"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.resize"] +depends = ["_add_floor_deficit", "_blend_fill", "_borrow_clean_grain", "score_weighted_fill"] + +["features/retouch/logic.py::compute_dust_stats"] +phase = 4 +status = "todo" +calls = ["cv2.blur", "cv2.getStructuringElement", "cv2.medianBlur", "cv2.morphologyEx"] +depends = ["_box_mean_std", "_proxy_norm"] + +["features/retouch/logic.py::detect_luma_score"] +phase = 4 +status = "todo" +calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.getStructuringElement", "np.median"] +depends = ["_box_mean_std", "_is_hair", "_mask_to_score", "compute_dust_stats", "split_hairs"] + +["features/retouch/logic.py::downsample_ir"] +phase = 4 +status = "todo" +calls = ["cv2.erode", "cv2.getStructuringElement", "cv2.resize"] +depends = ["_erode_resize_bounded"] + +["features/retouch/logic.py::drop_exclusions"] +phase = 4 +status = "todo" +calls = ["cv2.distanceTransform"] +depends = ["exclusion_cover"] + +["features/retouch/logic.py::exclusion_cover"] +phase = 4 +status = "todo" +calls = ["cv2.circle", "cv2.polylines"] +depends = [] + +["features/retouch/logic.py::ir_defect_score"] +phase = 4 +status = "todo" +calls = ["cv2.erode"] +depends = [] + +["features/retouch/logic.py::ir_ratio_and_gain"] +phase = 4 +status = "todo" +calls = ["cv2.resize"] +depends = ["_fit_refraction_gammas", "_ir_clean_base", "_ir_decontaminate", "_ir_glare", "_ir_live", "_ir_normalize_ratio", "normalize_ir"] + +["features/retouch/logic.py::lines_to_score"] +phase = 4 +status = "todo" +calls = ["cv2.blur"] +depends = ["_mask_to_score", "_shear_rows"] + +["features/retouch/logic.py::normalize_ir"] +phase = 4 +status = "todo" +calls = ["cv2.blur", "cv2.dilate", "cv2.getStructuringElement"] +depends = [] + +["features/retouch/logic.py::repair_components"] +phase = 4 +status = "todo" +calls = ["cv2.connectedComponentsWithStats", "cv2.resize"] +depends = ["apply_score_repair"] + +["features/retouch/logic.py::route_wide_defects"] +phase = 4 +status = "todo" +calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement"] +depends = [] + +["features/retouch/logic.py::score_weighted_fill"] +phase = 4 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.boxFilter"] +depends = ["_fill_rung"] + +["features/retouch/logic.py::split_hairs"] +phase = 4 +status = "todo" +calls = ["cv2.connectedComponentsWithStats"] +depends = ["_is_hair"] + +["features/retouch/logic.py::strokes_to_score"] +phase = 4 +status = "todo" +calls = ["cv2.blur", "cv2.circle", "cv2.connectedComponents", "cv2.distanceTransform", "cv2.polylines", "np.median"] +depends = ["_mask_to_score"] + +["features/retouch/logic.py::trace_scratch"] +phase = 4 +status = "todo" +calls = ["cv2.blur", "np.median"] +depends = ["_shear_rows"] + +["features/hdr/logic.py::_accumulate"] +phase = 5 +status = "todo" +calls = ["numba"] +depends = [] + +["features/hdr/logic.py::_align"] +phase = 5 +status = "todo" +calls = ["cv2.warpAffine"] +depends = [] + +["features/hdr/logic.py::_count_clipped_rows"] +phase = 5 +status = "todo" +calls = ["numba"] +depends = [] + +["features/hdr/logic.py::measure_exposure"] +phase = 5 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["features/hdr/logic.py::output_scale"] +phase = 5 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/hdr/logic.py::pair_ratio"] +phase = 5 +status = "todo" +calls = ["np.median"] +depends = [] + +["features/rgbscan/logic.py::_align_to"] +phase = 5 +status = "todo" +calls = ["cv2.warpAffine"] +depends = ["estimate_shift"] + +["features/stitch/logic.py::_seam_weight"] +phase = 5 +status = "todo" +calls = ["cv2.distanceTransform"] +depends = [] + +["features/stitch/logic.py::build_proxy"] +phase = 5 +status = "todo" +calls = ["cv2.resize", "np.percentile"] +depends = [] + +["features/stitch/logic.py::compute_canvas"] +phase = 5 +status = "todo" +calls = ["cv2.transform"] +depends = [] + +["features/stitch/logic.py::feather_blend"] +phase = 5 +status = "todo" +calls = ["cv2.distanceTransform"] +depends = [] + +["features/stitch/logic.py::warp_into_canvas"] +phase = 5 +status = "todo" +calls = ["cv2.warpAffine"] +depends = [] + +["features/exposure/logic.py::_apply_print_curve_kernel"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = ["_black_hold", "_fast_sigmoid", "_softplus", "plane_taps", "quadratic_core", "separation_damping_gain", "tone_key_weight"] + +["features/exposure/logic.py::_black_hold"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::_fast_sigmoid"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::_softplus"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::expand_mask_plane"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["features/exposure/logic.py::plane_taps"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::quadratic_core"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::separation_damping_gain"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::tone_key_weight"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/exposure/print_lut.py::tetra_lookup"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["features/lab/logic.py::_edge_mask"] +phase = 6 +status = "todo" +calls = ["cv2.blur"] +depends = [] + +["features/lab/logic.py::_usm_sharpening_block"] +phase = 6 +status = "todo" +calls = ["cv2.dilate", "cv2.erode", "cv2.merge", "cv2.sepFilter2D", "cv2.split"] +depends = ["_edge_mask"] + +["features/lab/logic.py::apply_chroma_denoise"] +phase = 6 +status = "todo" +calls = ["cv2.sepFilter2D"] +depends = ["chroma_edge_keep"] + +["features/lab/logic.py::apply_glow_and_halation"] +phase = 6 +status = "todo" +calls = ["cv2.GaussianBlur"] +depends = [] + +["features/lab/logic.py::apply_rl_sharpening"] +phase = 6 +status = "todo" +calls = ["cv2.sepFilter2D"] +depends = ["_edge_mask"] + +["features/lab/logic.py::chroma_edge_keep"] +phase = 6 +status = "todo" +calls = ["cv2.filter2D"] +depends = [] + +["features/sabattier/logic.py::apply_sabattier"] +phase = 6 +status = "todo" +calls = ["cv2.sepFilter2D"] +depends = [] + +["features/toning/logic.py::_apply_chemical_toning_jit"] +phase = 6 +status = "todo" +calls = ["numba"] +depends = [] + +["services/rendering/gpu_engine.py::GPUEngine._process_tiled"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = ["_downsample_for_analysis"] + +["services/rendering/gpu_engine.py::_downsample_for_analysis"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/rendering/image_processor.py::ImageProcessor.render_display_array"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = ["_downsample_to_long_edge"] + +["services/rendering/image_processor.py::_downsample_to_long_edge"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/rendering/image_processor.py::_resize_mask"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/rendering/preview_manager.py::PreviewManager._load_from_open_raw"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/rendering/preview_manager.py::PreviewManager._try_splash_from_open_raw"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = ["_load"] + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr._load"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = ["_match"] + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb._match"] +phase = 6 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["desktop/controller.py::AppController._paint_flatfield_peek"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["desktop/controller.py::_interactive_proxy"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["desktop/view/canvas/overlay.py::CanvasOverlay._ir_layer_qimage"] +phase = 7 +status = "todo" +calls = ["cv2.remap"] +depends = [] + +["desktop/view/canvas/overlay.py::CanvasOverlay._mask_wash_qimage"] +phase = 7 +status = "todo" +calls = ["cv2.remap"] +depends = [] + +["desktop/view/canvas/overlay.py::CanvasOverlay._zone_outline"] +phase = 7 +status = "todo" +calls = ["cv2.approxPolyDP", "cv2.contourArea", "cv2.findContours"] +depends = [] + +["desktop/view/sidebar/files.py::print_dmax"] +phase = 7 +status = "todo" +calls = ["np.quantile"] +depends = [] + +["desktop/view/sidebar/scanlight.py::_RawClip.at"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["desktop/view/widgets/charts.py::VectorscopeWidget._trace_image"] +phase = 7 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["desktop/view/widgets/charts.py::WaveformWidget._trace_images"] +phase = 7 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["desktop/view/widgets/prescan_dialog.py::_preview_u8"] +phase = 7 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["desktop/view/widgets/scan_preview_common.py::preview_positive"] +phase = 7 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["desktop/workers/chart_fit.py::ChartFitWorker.__init__"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["desktop/workers/export.py::_looks_border_crushed"] +phase = 7 +status = "todo" +calls = ["np.median"] +depends = [] + +["infrastructure/display/icc_lut.py::_apply_lut_f32_jit"] +phase = 7 +status = "todo" +calls = ["numba"] +depends = [] + +["infrastructure/display/icc_lut.py::_apply_lut_u16_jit"] +phase = 7 +status = "todo" +calls = ["numba"] +depends = [] + +["infrastructure/display/icc_lut.py::_matrix_trc_u8_jit"] +phase = 7 +status = "todo" +calls = ["numba"] +depends = [] + +["services/assets/half_frame.py::_edge_band"] +phase = 7 +status = "todo" +calls = ["np.median"] +depends = [] + +["services/assets/half_frame.py::_gutter_scan"] +phase = 7 +status = "todo" +calls = ["np.median"] +depends = [] + +["services/assets/thumbnails.py::preview_positive"] +phase = 7 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["services/capture/calibration.py::_ladder_stops"] +phase = 7 +status = "todo" +calls = ["np.median"] +depends = [] + +["services/capture/calibration.py::meter_base"] +phase = 7 +status = "todo" +calls = ["np.percentile"] +depends = [] + +["services/capture/meter.py::meter_frame"] +phase = 7 +status = "todo" +calls = ["np.median", "np.percentile"] +depends = ["ranked_thumb"] + +["services/capture/meter.py::ranked_thumb"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/export/contact_sheet.py::_place_tile"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/export/linear_output.py::_apply_ice"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/export/print.py::PrintService.apply_layout"] +phase = 7 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["services/view/coordinate_mapping.py::CoordinateMapping._grid_homography"] +phase = 7 +status = "todo" +calls = ["cv2.getPerspectiveTransform"] +depends = [] + +["services/view/coordinate_mapping.py::CoordinateMapping.create_uv_grid"] +phase = 7 +status = "todo" +calls = ["cv2.getRotationMatrix2D", "cv2.invertAffineTransform"] +depends = [] + +["features/geometry/skew.py::_coarse_angles"] +phase = 9 +status = "todo" +calls = ["cv2.GaussianBlur", "cv2.createLineSegmentDetector", "np.percentile"] +depends = [] + +["features/retouch/logic.py::apply_hair_inpaint"] +phase = 9 +status = "todo" +calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement", "cv2.inpaint", "cv2.resize", "np.percentile"] +depends = ["_borrow_clean_grain", "_hair_encode"] + +["features/rgbscan/logic.py::estimate_shift"] +phase = 9 +status = "todo" +calls = ["cv2.createHanningWindow", "cv2.phaseCorrelate", "cv2.resize"] +depends = [] + +["features/stitch/logic.py::find_seams"] +phase = 9 +status = "todo" +calls = ["cv2.UMat", "cv2.detail_DpSeamFinder", "cv2.resize"] +depends = [] + +["features/stitch/logic.py::register_pair"] +phase = 9 +status = "todo" +calls = ["cv2.BFMatcher", "cv2.SIFT_create", "cv2.estimateAffinePartial2D"] +depends = [] + +["infrastructure/scanners/sane_backend.py::_align_ir_to_rgb"] +phase = 9 +status = "todo" +calls = ["cv2.createHanningWindow", "cv2.phaseCorrelate", "cv2.resize"] +depends = [] + +["infrastructure/scanners/sane_backend.py::_resample_rows_to_dpi"] +phase = 9 +status = "todo" +calls = ["cv2.resize"] +depends = [] + +["infrastructure/simulated/gphoto.py::SimGphoto.preview_jpeg"] +phase = 9 +status = "stays" +calls = ["cv2.cvtColor", "cv2.imencode"] +depends = [] diff --git a/scripts/core_manifest.py b/scripts/core_manifest.py new file mode 100644 index 000000000..4f6880bdd --- /dev/null +++ b/scripts/core_manifest.py @@ -0,0 +1,156 @@ +"""Keep negpy_core/manifest.toml in step with the Python functions that negpy_core will replace.""" + +import argparse +import ast +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "negpy_core" / "manifest.toml" + +ORDER_STATS = {"percentile", "median", "partition", "quantile", "nanpercentile", "nanmedian"} +# cv2 calls the plan ports last, or may never port: each needs a decision of its own. +LAST_CV2 = {"SIFT_create", "BFMatcher", "detail_DpSeamFinder", "createLineSegmentDetector", "inpaint", "phaseCorrelate", "UMat"} +STATUSES = ("todo", "ported", "stays") + +# First match wins. Entries keep a hand-set phase; this only seeds new ones. +PHASE_BY_PATH = [ + ("features/geometry/", 2), + ("features/exposure/normalization.py", 2), + ("features/exposure/analysis.py", 2), + ("infrastructure/loaders/", 3), + ("kernel/image/", 3), + ("features/flatfield/", 4), + ("features/process/", 4), + ("features/retouch/", 4), + ("features/lens/", 4), + ("features/local/", 4), + ("features/hdr/", 5), + ("features/stitch/", 5), + ("features/rgbscan/", 5), + ("features/", 6), + ("services/rendering/", 6), + ("infrastructure/display/", 7), + ("services/", 7), + ("desktop/", 7), + ("infrastructure/", 9), +] + + +def _phase(rel: str, calls: list[str]) -> int: + if any(c.removeprefix("cv2.") in LAST_CV2 for c in calls): + return 9 + return next((phase for prefix, phase in PHASE_BY_PATH if rel.startswith(prefix)), 9) + + +class _Scanner(ast.NodeVisitor): + def __init__(self, rel: str) -> None: + self.rel = rel + self.stack: list[str] = [] + self.found: dict[str, dict] = {} + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.stack.append(node.name) + self.generic_visit(node) + self.stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.stack.append(node.name) + jit = any("jit" in ast.unparse(d) for d in node.decorator_list) + calls: set[str] = set() + names: set[str] = set() + for n in ast.walk(node): + if not isinstance(n, ast.Call): + continue + f = n.func + if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): + if f.value.id == "cv2" or (f.value.id == "np" and f.attr in ORDER_STATS): + calls.add(f"{f.value.id}.{f.attr}") + elif isinstance(f, ast.Name): + names.add(f.id) + if jit: + calls.add("numba") + if calls: + self.found[f"{self.rel}::{'.'.join(self.stack)}"] = {"calls": sorted(calls), "names": names} + self.generic_visit(node) + self.stack.pop() + + +def scan() -> dict[str, dict]: + """Every function under negpy/ that calls cv2, a numpy order statistic or is a Numba kernel.""" + found: dict[str, dict] = {} + for path in sorted((ROOT / "negpy").rglob("*.py")): + rel = path.relative_to(ROOT / "negpy").as_posix() + scanner = _Scanner(rel) + scanner.visit(ast.parse(path.read_text(encoding="utf-8"))) + found.update(scanner.found) + by_module: dict[str, set[str]] = {} + for key in found: + module, qual = key.split("::") + by_module.setdefault(module, set()).add(qual.split(".")[-1]) + for key, entry in found.items(): + module, qual = key.split("::") + own = qual.split(".")[-1] + entry["depends"] = sorted(n for n in entry.pop("names") & by_module[module] if n != own) + return found + + +def load() -> dict[str, dict]: + return tomllib.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else {} + + +def merge(current: dict[str, dict], found: dict[str, dict]) -> dict[str, dict]: + """Scanned call sites with the hand-kept fields (phase, status, owner, note) of existing entries. + A ported entry stays when its function is gone from Python or calls negpy_core.""" + merged: dict[str, dict] = {} + for key, entry in found.items(): + old = current.get(key, {}) + merged[key] = { + "phase": old.get("phase", _phase(key.split("::")[0], entry["calls"])), + "status": old.get("status", "todo"), + "calls": entry["calls"], + "depends": entry["depends"], + **{k: old[k] for k in ("owner", "note") if k in old}, + } + for key, old in current.items(): + if key not in merged and old.get("status") == "ported": + merged[key] = old + return merged + + +def _toml_value(value) -> str: + if isinstance(value, list): + return "[" + ", ".join(_toml_value(v) for v in value) + "]" + if isinstance(value, int): + return str(value) + return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def dump(manifest: dict[str, dict]) -> str: + lines = [ + "# One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes", + "# calls and depends; phase, status (todo, ported, stays), owner and note are kept by hand.", + ] + for key in sorted(manifest, key=lambda k: (manifest[k]["phase"], k)): + lines.append("") + lines.append(f"[{_toml_value(key)}]") + for field in ("phase", "status", "owner", "calls", "depends", "note"): + if field in manifest[key]: + lines.append(f"{field} = {_toml_value(manifest[key][field])}") + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail when the manifest is out of date") + args = parser.parse_args() + text = dump(merge(load(), scan())) + if args.check: + if MANIFEST.read_text(encoding="utf-8") != text: + raise SystemExit("negpy_core/manifest.toml is out of date: run uv run python scripts/core_manifest.py") + return + MANIFEST.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/test_core_manifest.py b/tests/test_core_manifest.py new file mode 100644 index 000000000..9beb29f10 --- /dev/null +++ b/tests/test_core_manifest.py @@ -0,0 +1,18 @@ +import subprocess +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_manifest_is_current(): + result = subprocess.run([sys.executable, "scripts/core_manifest.py", "--check"], cwd=ROOT, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_manifest_fields_are_valid(): + manifest = tomllib.loads((ROOT / "negpy_core" / "manifest.toml").read_text(encoding="utf-8")) + for key, entry in manifest.items(): + assert entry["status"] in ("todo", "ported", "stays"), key + assert entry["phase"] in range(1, 10), key From 1d40244eec8e99ceadd83e1ea08ff15c53cc0a56 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 22:25:23 +0200 Subject: [PATCH 009/133] build(core): replay tool, make replay and make bench scripts/replay.py records real calls of the manifest functions while frames run through the preview load and the CPU pipeline (inputs, result, one-thread time called alone), then replays them through the current code bit for bit, or times them under a machine-wide lock. Three sample frames give 35 phase-2 functions and all replay the same. The manifest scan also takes functions built on numpy reductions, so the measure_base_log chain is in it (273 entries). --- CLAUDE.md | 3 + CONTRIBUTING.md | 1 + Makefile | 11 ++ negpy_core/manifest.toml | 268 ++++++++++++++++++++++++++++++++++++-- scripts/core_manifest.py | 6 +- scripts/replay.py | 229 ++++++++++++++++++++++++++++++++ tests/test_replay_same.py | 23 ++++ 7 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 scripts/replay.py create mode 100644 tests/test_replay_same.py diff --git a/CLAUDE.md b/CLAUDE.md index 2db12d291..bc1961bdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ make run # Launch the desktop app make run-sim # Launch with a simulated camera, Scanlight and film scanners make all # format + Rust checks + lint + type check + tests (run before committing) make rust # cargo fmt --check + clippy -D warnings + cargo test for negpy_core +make replay # replay recorded real calls through the current code, bit for bit (PHASE=N) +make bench # one-thread speed of ported functions against the recorded Python (PHASE=N) make test # pytest only make lint # ruff check make type # ty check (not mypy) @@ -62,6 +64,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces, run on every release OS in CI (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. +- **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. - A port that cannot be bit-exact needs the user's agreement, then the `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` bumps. ### Pipeline diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4efb0e78f..076ded443 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -155,6 +155,7 @@ Every developer command runs through `uv run`: - `make format`: auto-format with `cargo fmt` and Ruff. - `make rust`: `cargo fmt --check`, `cargo clippy` (warnings fail) and `cargo test` for `negpy_core`. - `make all`: format, Rust checks, lint, type and test in sequence. +- `make replay` / `make bench`: check a `negpy_core` port against calls recorded on real frames, for identical results and speed. Record with `uv run python scripts/replay.py record FRAME...`; the corpus goes to `$NEGPY_CORPUS` (default `~/negpy-corpus`). - `make clean`: remove cache and build artifacts. - `make run-sim`: run with simulated scanning hardware (see [above](#running-without-scanning-hardware)). - `make clear-devhome`: delete the development user directory (see [above](#a-separate-user-directory-for-development)). diff --git a/Makefile b/Makefile index ae3511f39..09e440fb8 100644 --- a/Makefile +++ b/Makefile @@ -84,6 +84,17 @@ test: @echo "Running unit tests (pytest)..." @$(UV) pytest tests/ --cov=negpy --cov-report=term-missing +# negpy_core replay corpus ($$NEGPY_CORPUS, default ~/negpy-corpus). PHASE=2 limits to one phase. +# Record first, on main: uv run python scripts/replay.py record FRAME... [--phase N] +REPLAY_PHASE = $(if $(PHASE),--phase $(PHASE)) + +.PHONY: replay bench +replay: + @$(UV) python scripts/replay.py replay $(REPLAY_PHASE) + +bench: + @$(UV) python scripts/replay.py bench $(REPLAY_PHASE) + # Auto-format and fix (ruff) .PHONY: format format: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 2d2297752..b628102d2 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -8,10 +8,40 @@ calls = ["np.partition"] depends = [] note = "negpy_core::stats::percentiles" +["features/exposure/analysis.py::_scope_lookup"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["features/exposure/analysis.py::color_histogram"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["features/exposure/analysis.py::density_histogram"] +phase = 2 +status = "todo" +calls = ["np.histogram"] +depends = [] + +["features/exposure/analysis.py::density_parade"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + ["features/exposure/analysis.py::loupe_acutance"] phase = 2 status = "todo" -calls = ["cv2.Laplacian"] +calls = ["cv2.Laplacian", "np.mean"] +depends = [] + +["features/exposure/analysis.py::output_histogram"] +phase = 2 +status = "todo" +calls = ["np.bincount", "np.histogram"] depends = [] ["features/exposure/analysis.py::paper_grid_mosaic"] @@ -20,10 +50,40 @@ status = "todo" calls = ["cv2.resize"] depends = [] +["features/exposure/analysis.py::scan_full_scale_columns"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["features/exposure/analysis.py::vectorscope_histogram"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["features/exposure/analysis.py::waveform"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["features/exposure/analysis.py::waveform_color"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["features/exposure/analysis.py::waveform_parade"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + ["features/exposure/analysis.py::zone_anchors"] phase = 2 status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.distanceTransform"] +calls = ["cv2.connectedComponentsWithStats", "cv2.distanceTransform", "np.unique"] depends = [] ["features/exposure/analysis.py::zone_field"] @@ -41,7 +101,19 @@ depends = [] ["features/exposure/analysis.py::zone_region_labels"] phase = 2 status = "todo" -calls = ["cv2.connectedComponentsWithStats"] +calls = ["cv2.connectedComponentsWithStats", "np.unique"] +depends = [] + +["features/exposure/analysis.py::zone_shares"] +phase = 2 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["features/exposure/normalization.py::_base_blocks"] +phase = 2 +status = "todo" +calls = ["np.argsort"] depends = [] ["features/exposure/normalization.py::_block_median_grid"] @@ -92,6 +164,12 @@ status = "todo" calls = ["np.median"] depends = [] +["features/exposure/normalization.py::_weighted_median"] +phase = 2 +status = "todo" +calls = ["np.argsort", "np.cumsum"] +depends = [] + ["features/exposure/normalization.py::analysis_grid"] phase = 2 status = "todo" @@ -132,6 +210,12 @@ depends = ["_block_median_grid"] phase = 2 status = "todo" calls = ["np.median"] +depends = ["_base_blocks"] + +["features/exposure/normalization.py::measure_clip_fractions"] +phase = 2 +status = "todo" +calls = ["np.mean"] depends = [] ["features/exposure/normalization.py::measure_highlight_point_from_log"] @@ -174,6 +258,18 @@ depends = [] phase = 2 status = "todo" calls = ["np.median"] +depends = ["_weighted_median"] + +["features/exposure/normalization.py::resolve_crosstalk_matrix"] +phase = 2 +status = "todo" +calls = ["np.sum"] +depends = [] + +["features/exposure/normalization.py::sorted_channel_grid"] +phase = 2 +status = "todo" +calls = ["np.sort"] depends = [] ["features/geometry/batch_autocrop.py::_mad"] @@ -296,6 +392,12 @@ status = "todo" calls = ["np.percentile"] depends = [] +["features/geometry/logic.py::_get_threshold_autocrop_coords"] +phase = 2 +status = "todo" +calls = ["np.mean"] +depends = [] + ["features/geometry/logic.py::_mask_from_blackhat"] phase = 2 status = "todo" @@ -326,6 +428,12 @@ status = "todo" calls = ["np.median"] depends = ["_polygon_values"] +["features/geometry/logic.py::_place_window_by_occupancy"] +phase = 2 +status = "todo" +calls = ["np.cumsum"] +depends = [] + ["features/geometry/logic.py::_polygon_values"] phase = 2 status = "todo" @@ -377,7 +485,7 @@ depends = [] ["features/geometry/logic.py::_vertical_edge_profile"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.percentile"] +calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.mean", "np.percentile"] depends = [] ["features/geometry/logic.py::_walk_border_depth"] @@ -386,6 +494,12 @@ status = "todo" calls = ["np.median"] depends = [] +["features/geometry/logic.py::_weighted_median"] +phase = 2 +status = "todo" +calls = ["np.argsort", "np.cumsum", "np.sum"] +depends = [] + ["features/geometry/logic.py::apply_fine_rotation"] phase = 2 status = "todo" @@ -443,8 +557,8 @@ depends = [] ["features/geometry/skew.py::_fit_side"] phase = 2 status = "todo" -calls = ["np.median"] -depends = ["_profile_peaks"] +calls = ["np.mean", "np.median"] +depends = ["_profile_peaks", "_ransac_line"] ["features/geometry/skew.py::_map"] phase = 2 @@ -452,6 +566,12 @@ status = "todo" calls = ["cv2.getRotationMatrix2D"] depends = [] +["features/geometry/skew.py::_measure_from"] +phase = 2 +status = "todo" +calls = ["np.mean"] +depends = ["_at_canvas_edges", "_profile_lines", "_unrotate"] + ["features/geometry/skew.py::_profile_lines"] phase = 2 status = "todo" @@ -464,6 +584,12 @@ status = "todo" calls = ["cv2.GaussianBlur", "np.median"] depends = [] +["features/geometry/skew.py::_ransac_line"] +phase = 2 +status = "todo" +calls = ["np.unique"] +depends = [] + ["features/geometry/skew.py::_unrotate"] phase = 2 status = "todo" @@ -476,6 +602,12 @@ status = "todo" calls = ["np.percentile"] depends = [] +["infrastructure/loaders/pakon_loader.py::PakonLoader._looks_interleaved"] +phase = 3 +status = "todo" +calls = ["np.mean"] +depends = [] + ["infrastructure/loaders/rawpy_loader.py::_stream_linearraw_preview"] phase = 3 status = "todo" @@ -638,6 +770,12 @@ status = "todo" calls = ["cv2.resize"] depends = [] +["features/lens/logic.py::_lookup"] +phase = 4 +status = "todo" +calls = ["np.unique"] +depends = [] + ["features/lens/logic.py::apply_lens"] phase = 4 status = "todo" @@ -662,18 +800,42 @@ status = "todo" calls = ["np.percentile"] depends = [] +["features/process/chart.py::detect_orientation"] +phase = 4 +status = "todo" +calls = ["np.mean"] +depends = ["measure_lab"] + +["features/process/chart.py::grid_corners"] +phase = 4 +status = "todo" +calls = ["np.argsort"] +depends = [] + +["features/process/chart.py::measure_lab"] +phase = 4 +status = "todo" +calls = ["np.mean"] +depends = ["sample_patches"] + ["features/process/chart.py::sample_patches"] phase = 4 status = "todo" calls = ["np.median"] depends = [] -["features/process/logic.py::detect_process_mode"] +["features/process/logic.py::_corr"] phase = 4 status = "todo" -calls = ["np.percentile"] +calls = ["np.sum"] depends = [] +["features/process/logic.py::detect_process_mode"] +phase = 4 +status = "todo" +calls = ["np.mean", "np.percentile"] +depends = ["_corr"] + ["features/process/sensor.py::_log_unmix_kernel"] phase = 4 status = "todo" @@ -755,7 +917,7 @@ depends = [] ["features/retouch/logic.py::_erode_resize_bounded"] phase = 4 status = "todo" -calls = ["cv2.erode", "cv2.resize"] +calls = ["cv2.erode", "cv2.resize", "np.sum"] depends = [] ["features/retouch/logic.py::_fill_rung"] @@ -857,7 +1019,7 @@ depends = ["_box_mean_std", "_proxy_norm"] ["features/retouch/logic.py::detect_luma_score"] phase = 4 status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.getStructuringElement", "np.median"] +calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.getStructuringElement", "np.median", "np.unique"] depends = ["_box_mean_std", "_is_hair", "_mask_to_score", "compute_dust_stats", "split_hairs"] ["features/retouch/logic.py::downsample_ir"] @@ -929,7 +1091,7 @@ depends = ["_is_hair"] ["features/retouch/logic.py::strokes_to_score"] phase = 4 status = "todo" -calls = ["cv2.blur", "cv2.circle", "cv2.connectedComponents", "cv2.distanceTransform", "cv2.polylines", "np.median"] +calls = ["cv2.blur", "cv2.circle", "cv2.connectedComponents", "cv2.distanceTransform", "cv2.polylines", "np.median", "np.unique"] depends = ["_mask_to_score"] ["features/retouch/logic.py::trace_scratch"] @@ -980,6 +1142,12 @@ status = "todo" calls = ["cv2.warpAffine"] depends = ["estimate_shift"] +["features/rgbscan/logic.py::probe_frame"] +phase = 5 +status = "todo" +calls = ["np.mean"] +depends = [] + ["features/stitch/logic.py::_seam_weight"] phase = 5 status = "todo" @@ -1070,6 +1238,18 @@ status = "todo" calls = ["numba"] depends = [] +["features/finish/logic.py::_filed_edge"] +phase = 6 +status = "todo" +calls = ["np.cumsum"] +depends = [] + +["features/lab/logic.py::_clahe_cdfs"] +phase = 6 +status = "todo" +calls = ["np.bincount", "np.cumsum"] +depends = [] + ["features/lab/logic.py::_edge_mask"] phase = 6 status = "todo" @@ -1214,12 +1394,24 @@ status = "todo" calls = ["cv2.approxPolyDP", "cv2.contourArea", "cv2.findContours"] depends = [] +["desktop/view/sidebar/files.py::FileBrowser._measure_rebate"] +phase = 7 +status = "todo" +calls = ["np.mean"] +depends = ["print_dmax"] + ["desktop/view/sidebar/files.py::print_dmax"] phase = 7 status = "todo" calls = ["np.quantile"] depends = [] +["desktop/view/sidebar/scanlight.py::ScanlightSidebar._current_raw_clip"] +phase = 7 +status = "todo" +calls = ["np.mean"] +depends = [] + ["desktop/view/sidebar/scanlight.py::_RawClip.at"] phase = 7 status = "todo" @@ -1250,6 +1442,12 @@ status = "todo" calls = ["np.percentile"] depends = [] +["desktop/view/widgets/sensor_calibration_dialog.py::SensorCalibrationDialog._compute_and_save"] +phase = 7 +status = "todo" +calls = ["np.mean"] +depends = [] + ["desktop/workers/chart_fit.py::ChartFitWorker.__init__"] phase = 7 status = "todo" @@ -1292,6 +1490,12 @@ status = "todo" calls = ["np.median"] depends = [] +["services/assets/semantic_model.py::rank_by_similarity"] +phase = 7 +status = "todo" +calls = ["np.argsort"] +depends = [] + ["services/assets/thumbnails.py::preview_positive"] phase = 7 status = "todo" @@ -1304,22 +1508,40 @@ status = "todo" calls = ["np.median"] depends = [] +["services/capture/calibration.py::clip_fraction"] +phase = 7 +status = "todo" +calls = ["np.mean"] +depends = [] + ["services/capture/calibration.py::meter_base"] phase = 7 status = "todo" calls = ["np.percentile"] depends = [] +["services/capture/focus_meter.py::sharpness"] +phase = 7 +status = "todo" +calls = ["np.mean"] +depends = [] + +["services/capture/meter.py::framing_mismatch"] +phase = 7 +status = "todo" +calls = ["np.mean"] +depends = ["ranked_thumb"] + ["services/capture/meter.py::meter_frame"] phase = 7 status = "todo" -calls = ["np.median", "np.percentile"] +calls = ["np.mean", "np.median", "np.percentile"] depends = ["ranked_thumb"] ["services/capture/meter.py::ranked_thumb"] phase = 7 status = "todo" -calls = ["cv2.resize"] +calls = ["cv2.resize", "np.argsort"] depends = [] ["services/export/contact_sheet.py::_place_tile"] @@ -1340,6 +1562,12 @@ status = "todo" calls = ["cv2.resize"] depends = [] +["services/scanning/writer.py::_to_grey"] +phase = 7 +status = "todo" +calls = ["np.sum"] +depends = [] + ["services/view/coordinate_mapping.py::CoordinateMapping._grid_homography"] phase = 7 status = "todo" @@ -1355,7 +1583,7 @@ depends = [] ["features/geometry/skew.py::_coarse_angles"] phase = 9 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.createLineSegmentDetector", "np.percentile"] +calls = ["cv2.GaussianBlur", "cv2.createLineSegmentDetector", "np.histogram", "np.percentile"] depends = [] ["features/retouch/logic.py::apply_hair_inpaint"] @@ -1382,6 +1610,18 @@ status = "todo" calls = ["cv2.BFMatcher", "cv2.SIFT_create", "cv2.estimateAffinePartial2D"] depends = [] +["infrastructure/capture/raw_demosaic.py::_plateau_clip_fraction"] +phase = 9 +status = "todo" +calls = ["np.bincount"] +depends = [] + +["infrastructure/capture/raw_demosaic.py::raw_channel_clip_fraction"] +phase = 9 +status = "todo" +calls = ["np.mean"] +depends = ["_plateau_clip_fraction"] + ["infrastructure/scanners/sane_backend.py::_align_ir_to_rgb"] phase = 9 status = "todo" diff --git a/scripts/core_manifest.py b/scripts/core_manifest.py index 4f6880bdd..2e4504386 100644 --- a/scripts/core_manifest.py +++ b/scripts/core_manifest.py @@ -9,6 +9,8 @@ MANIFEST = ROOT / "negpy_core" / "manifest.toml" ORDER_STATS = {"percentile", "median", "partition", "quantile", "nanpercentile", "nanmedian"} +# Reductions over a whole image: the Python-overhead chains (measure_base_log, bounds) live on these. +REDUCTIONS = {"std", "var", "mean", "sum", "histogram", "bincount", "cumsum", "sort", "argsort", "unique"} # cv2 calls the plan ports last, or may never port: each needs a decision of its own. LAST_CV2 = {"SIFT_create", "BFMatcher", "detail_DpSeamFinder", "createLineSegmentDetector", "inpaint", "phaseCorrelate", "UMat"} STATUSES = ("todo", "ported", "stays") @@ -64,7 +66,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: continue f = n.func if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): - if f.value.id == "cv2" or (f.value.id == "np" and f.attr in ORDER_STATS): + if f.value.id == "cv2" or (f.value.id == "np" and f.attr in ORDER_STATS | REDUCTIONS): calls.add(f"{f.value.id}.{f.attr}") elif isinstance(f, ast.Name): names.add(f.id) @@ -77,7 +79,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: def scan() -> dict[str, dict]: - """Every function under negpy/ that calls cv2, a numpy order statistic or is a Numba kernel.""" + """Every function under negpy/ that calls cv2, a numpy order statistic or reduction, or is a Numba kernel.""" found: dict[str, dict] = {} for path in sorted((ROOT / "negpy").rglob("*.py")): rel = path.relative_to(ROOT / "negpy").as_posix() diff --git a/scripts/replay.py b/scripts/replay.py new file mode 100644 index 000000000..f8ebed19d --- /dev/null +++ b/scripts/replay.py @@ -0,0 +1,229 @@ +"""Record real calls of the functions in negpy_core/manifest.toml, then replay them through the current code. + + record FILE... run each frame through the preview load and the CPU pipeline, and save every call + of a recorded function: inputs, result and its time on one thread, called alone. + replay call the current code with each saved input and compare the result bit for bit. + bench time the current code on one thread against the saved time. + +Record on main just before a group is ported, so the corpus holds the behavior users have now. +The corpus lives in $NEGPY_CORPUS (default ~/negpy-corpus), on a local disk that every worktree reads. +""" + +import argparse +import copy +import dataclasses +import fcntl +import importlib +import os +import pickle +import statistics +import sys +import time +from pathlib import Path + +if len(sys.argv) > 1 and sys.argv[1] in ("record", "bench"): + # One thread on both sides: the speed gate is per core. Numba reads this at import. + os.environ["NUMBA_NUM_THREADS"] = "1" + os.environ["RAYON_NUM_THREADS"] = "1" + +import numpy as np # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from core_manifest import load as load_manifest # noqa: E402 + +CORPUS = Path(os.environ.get("NEGPY_CORPUS", Path.home() / "negpy-corpus")) +CALLS_PER_FRAME = 3 +CALLS_PER_FUNCTION = 60 +MAX_CALL_BYTES = 256 << 20 +TIMING_REPEATS = 3 +BENCH_LOCK = Path("/tmp/negpy-bench.lock") + + +def _resolve(key: str): + """(owner, attribute name, function) for a manifest key, or None for a nested function.""" + rel, qual = key.split("::") + module = importlib.import_module("negpy." + rel.removesuffix(".py").replace("/", ".")) + owner, *rest = qual.split(".") + if not rest: + return module, owner, getattr(module, owner) + if len(rest) == 1 and isinstance(getattr(module, owner, None), type): + cls = getattr(module, owner) + return cls, rest[0], cls.__dict__[rest[0]] + return None + + +def _folder(key: str) -> Path: + return CORPUS / key.replace("/", "__").replace("::", "--") + + +def _targets(phases: list[int] | None, statuses: tuple[str, ...]) -> list[str]: + manifest = load_manifest() + return sorted(k for k, v in manifest.items() if v["status"] in statuses and (not phases or v["phase"] in phases)) + + +def _time(fn, args, kwargs) -> float: + best = float("inf") + for _ in range(TIMING_REPEATS): + a, kw = copy.deepcopy((args, kwargs)) + start = time.perf_counter() + fn(*a, **kw) + best = min(best, time.perf_counter() - start) + return best + + +class Recorder: + def __init__(self, keys: list[str]) -> None: + self.frame = "" + self.active = True + self.per_frame: dict[str, int] = {} + self.saved: dict[str, int] = {k: len(list(_folder(k).glob("*.pkl"))) for k in keys} + self.originals: list[tuple] = [] + for key in keys: + resolved = _resolve(key) + if resolved is None: + continue + owner, name, fn = resolved + wrapper = self._wrap(key, fn) + setattr(owner, name, wrapper) + self.originals.append((owner, name, fn)) + # `from module import fn` binds the name in each importer too. + for module in list(sys.modules.values()): + if getattr(module, "__name__", "").startswith("negpy.") and module is not owner: + for attr, value in list(vars(module).items()): + if value is fn: + setattr(module, attr, wrapper) + self.originals.append((module, attr, fn)) + + def _wrap(self, key: str, fn): + def wrapper(*args, **kwargs): + if not self.active or self.per_frame.get(key, 0) >= CALLS_PER_FRAME or self.saved[key] >= CALLS_PER_FUNCTION: + return fn(*args, **kwargs) + inputs = copy.deepcopy((args, kwargs)) + result = fn(*args, **kwargs) + self.per_frame[key] = self.per_frame.get(key, 0) + 1 + self.active = False + try: + payload = pickle.dumps({"args": inputs[0], "kwargs": inputs[1], "result": result, "frame": self.frame}, protocol=5) + if len(payload) <= MAX_CALL_BYTES: + record = pickle.loads(payload) + record["seconds"] = _time(fn, inputs[0], inputs[1]) + folder = _folder(key) + folder.mkdir(parents=True, exist_ok=True) + (folder / f"{self.saved[key]:04d}.pkl").write_bytes(pickle.dumps(record, protocol=5)) + self.saved[key] += 1 + except (pickle.PicklingError, TypeError, AttributeError): + pass # a GPU texture or a lock in the arguments: not replayable + finally: + self.active = True + return result + + return wrapper + + def restore(self) -> None: + for owner, name, fn in reversed(self.originals): + setattr(owner, name, fn) + + +def _run_frame(path: str) -> None: + from negpy.domain.models import WorkspaceConfig + from negpy.features.geometry.models import with_auto_crop + from negpy.services.rendering.image_processor import ImageProcessor + from negpy.services.rendering.preview_manager import PreviewManager + + buffer, _, _ = PreviewManager().load_linear_preview(path) + config = with_auto_crop(WorkspaceConfig(), True) + ImageProcessor(use_gpu=False).run_pipeline( + buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False, cache_stages=False + ) + + +def record(files: list[str], phases: list[int] | None) -> None: + keys = _targets(phases, ("todo",)) + import negpy.services.rendering.image_processor # noqa: F401 (load the pipeline before wrapping) + import negpy.services.rendering.preview_manager # noqa: F401 + + recorder = Recorder(keys) + try: + for path in files: + recorder.frame, recorder.per_frame = Path(path).name, {} + _run_frame(path) + print(f"recorded {path}") + finally: + recorder.restore() + for key in keys: + if recorder.saved.get(key): + print(f"{recorder.saved[key]:4d} {key}") + + +def same(a, b) -> bool: + """Bit-for-bit equality, NaN payloads and the sign of zero included.""" + if type(a) is not type(b): + return False + if isinstance(a, np.ndarray): + return a.dtype == b.dtype and a.shape == b.shape and np.ascontiguousarray(a).tobytes() == np.ascontiguousarray(b).tobytes() + if isinstance(a, (np.generic, float)): + return np.asarray(a).tobytes() == np.asarray(b).tobytes() + if isinstance(a, (list, tuple)): + return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b)) + if isinstance(a, dict): + return a.keys() == b.keys() and all(same(a[k], b[k]) for k in a) + if dataclasses.is_dataclass(a) or hasattr(a, "__dict__"): + return same(vars(a), vars(b)) + return a == b + + +def _calls(keys: list[str]): + for key in keys: + resolved = _resolve(key) + files = sorted(_folder(key).glob("*.pkl")) + if resolved is None or not files: + continue + yield key, resolved[2], files + + +def replay(phases: list[int] | None) -> int: + failed = 0 + for key, fn, files in _calls(_targets(phases, ("todo", "ported"))): + bad = [] + for f in files: + rec = pickle.loads(f.read_bytes()) + if not same(fn(*rec["args"], **rec["kwargs"]), rec["result"]): + bad.append(f"{f.name} ({rec['frame']})") + failed += bool(bad) + print(f"{'DIFF' if bad else 'same'} {len(files) - len(bad):3d}/{len(files):<3d} {key} {' '.join(bad[:5])}") + return 1 if failed else 0 + + +def bench(phases: list[int] | None) -> int: + slower = 0 + BENCH_LOCK.touch(exist_ok=True) + with BENCH_LOCK.open() as lock: + fcntl.flock(lock, fcntl.LOCK_EX) # one benchmark at a time on this machine + for key, fn, files in _calls(_targets(phases, ("ported",))): + ratios = [] + for f in files: + rec = pickle.loads(f.read_bytes()) + ratios.append(_time(fn, rec["args"], rec["kwargs"]) / max(rec["seconds"], 1e-9)) + ratio = statistics.median(ratios) + slower += ratio > 1.0 + print(f"{'SLOWER' if ratio > 1.0 else 'ok':6s} {ratio:6.3f}x {key}") + return 1 if slower else 0 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("mode", choices=("record", "replay", "bench")) + parser.add_argument("files", nargs="*") + parser.add_argument("--phase", type=int, action="append", help="only functions of this phase (repeatable)") + args = parser.parse_args() + if args.mode == "record": + if not args.files: + parser.error("record needs at least one frame") + record(args.files, args.phase) + else: + sys.exit((replay if args.mode == "replay" else bench)(args.phase)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_replay_same.py b/tests/test_replay_same.py new file mode 100644 index 000000000..72fd2f2aa --- /dev/null +++ b/tests/test_replay_same.py @@ -0,0 +1,23 @@ +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from replay import same # noqa: E402 + + +def test_same_is_bitwise(): + a = np.array([1.0, np.nan, 0.0], dtype=np.float32) + assert same(a, a.copy()) + assert not same(a, a.astype(np.float64)) + assert not same(a, np.array([1.0, np.nan, -0.0], dtype=np.float32)) + assert not same(np.float32(0.1), np.float64(0.1)) + assert same(float("nan"), float("nan")) + assert not same(0.0, -0.0) + + +def test_same_walks_containers(): + assert same((1, [np.uint8(3)], {"k": None}), (1, [np.uint8(3)], {"k": None})) + assert not same((1, 2), [1, 2]) + assert not same({"k": np.int32(1)}, {"k": np.int64(1)}) From c62332528663aa21be2f811bb718aca0eea183f3 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 22:46:53 +0200 Subject: [PATCH 010/133] perf(core): INTER_AREA downscale in Rust for autocrop detection negpy_core.resize_area copies OpenCV's area resize for float32: the general path (computeResizeAreaTab weights, the horizontal pass per source row, then the vertical sum) and the integer-scale path, where a 2x2 one- or four-channel image takes the 4-lane SIMD order over the columns the vector loop reaches and the scalar order elsewhere. test_core_resize compares it with cv2 bit for bit (31 cases). _normalize_detection_input uses it: the phase 2 replay over 33 real frames gives 40/40 functions identical. replay.py record takes --edits, so each frame runs with its saved edit from a copy of edits.db. 5668x3945x3 -> 1600x1114: cv2 32.3 ms / Rust 27.6 ms on one thread; cv2 5.3 ms / Rust 10.7 ms on 16. --- .github/workflows/ci.yml | 2 +- negpy/features/geometry/logic.py | 3 +- negpy_core/manifest.toml | 8 +- negpy_core/src/lib.rs | 4 +- negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/resize.rs | 47 ++++++ negpy_core/src/resize.rs | 250 +++++++++++++++++++++++++++++++ scripts/replay.py | 29 +++- tests/test_core_resize.py | 42 ++++++ 9 files changed, 371 insertions(+), 15 deletions(-) create mode 100644 negpy_core/src/py/resize.rs create mode 100644 negpy_core/src/resize.rs create mode 100644 tests/test_core_resize.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af872ce0a..ceee45ddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,4 +141,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py + run: uv run pytest tests/test_percentiles.py tests/test_core_resize.py diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 98f55a5b3..604adc38f 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -4,6 +4,7 @@ from typing import Callable, Dict, List, NamedTuple, Optional, Tuple import cv2 +import negpy_core import numpy as np from negpy.domain.types import ROI, ImageBuffer @@ -104,7 +105,7 @@ def _normalize_detection_input(img: ImageBuffer, detect_res: int) -> tuple[np.nd if det_scale >= 1.0: return img, 1.0 d_w, d_h = max(1, round(w * det_scale)), max(1, round(h * det_scale)) - det = cv2.resize(np.ascontiguousarray(img), (d_w, d_h), interpolation=cv2.INTER_AREA) + det = negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), (d_w, d_h)) return det, det_scale diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index b628102d2..3e8cadbb4 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -416,12 +416,6 @@ status = "todo" calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.morphologyEx", "cv2.threshold"] depends = [] -["features/geometry/logic.py::_normalize_detection_input"] -phase = 2 -status = "todo" -calls = ["cv2.resize"] -depends = [] - ["features/geometry/logic.py::_oriented_boundary_evidence"] phase = 2 status = "todo" @@ -546,7 +540,7 @@ depends = [] phase = 2 status = "todo" calls = ["cv2.resize"] -depends = ["_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion"] +depends = ["apply_fine_rotation", "apply_keystone", "apply_radial_distortion"] ["features/geometry/skew.py::_at_canvas_edges"] phase = 2 diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 10fb7697c..e966a1421 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod resize; pub mod stats; #[cfg(feature = "python")] @@ -6,5 +7,6 @@ mod py; #[cfg(feature = "python")] #[pyo3::pymodule] fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { - py::stats::register(m) + py::stats::register(m)?; + py::resize::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 9d34677fc..0c5a4b95e 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1 +1,2 @@ +pub mod resize; pub mod stats; diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs new file mode 100644 index 000000000..29fe4c02a --- /dev/null +++ b/negpy_core/src/py/resize.rs @@ -0,0 +1,47 @@ +use numpy::{IntoPyArray, PyArrayDyn, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::resize; + +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_AREA)` for a contiguous float32 downscale. +#[pyfunction] +fn resize_area<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dsize: (usize, usize), +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_area needs a 2-D or 3-D array", + )); + } + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_area needs a contiguous array"))?; + let (dw, dh) = dsize; + let out = py + .detach(|| resize::resize_area(data, h, w, cn, dh, dw)) + .ok_or_else(|| { + PyValueError::new_err( + "resize_area covers a float32 downscale, not a 2x2 two-channel one", + ) + })?; + let dims: Vec = if shape.len() == 2 { + vec![dh, dw] + } else { + vec![dh, dw, cn] + }; + let arr = numpy::ndarray::ArrayD::from_shape_vec(dims, out) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(arr.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(resize_area, m)?) +} diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs new file mode 100644 index 000000000..1f9178436 --- /dev/null +++ b/negpy_core/src/resize.rs @@ -0,0 +1,250 @@ +use rayon::prelude::*; + +/// One source-to-destination contribution of OpenCV's area table (`DecimateAlpha`). +#[derive(Clone, Copy)] +struct Tap { + di: usize, + si: usize, + alpha: f32, +} + +/// OpenCV's `computeResizeAreaTab`, in its f64 arithmetic. +fn area_tab(ssize: usize, dsize: usize, scale: f64) -> Vec { + let mut tab = Vec::with_capacity(ssize * 2); + for dx in 0..dsize { + let fsx1 = dx as f64 * scale; + let fsx2 = fsx1 + scale; + let cell = scale.min(ssize as f64 - fsx1); + let mut sx1 = fsx1.ceil() as i64; + let mut sx2 = fsx2.floor() as i64; + sx2 = sx2.min(ssize as i64 - 1); + sx1 = sx1.min(sx2); + if sx1 as f64 - fsx1 > 1e-3 { + tab.push(Tap { + di: dx, + si: (sx1 - 1) as usize, + alpha: ((sx1 as f64 - fsx1) / cell) as f32, + }); + } + for sx in sx1..sx2 { + tab.push(Tap { + di: dx, + si: sx as usize, + alpha: (1.0 / cell) as f32, + }); + } + if fsx2 - sx2 as f64 > 1e-3 { + tab.push(Tap { + di: dx, + si: sx2 as usize, + alpha: ((fsx2 - sx2 as f64).min(1.0).min(cell) / cell) as f32, + }); + } + } + tab +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_AREA)` for a float32 downscale. `src` is +/// row-major with `cn` channels interleaved. None for an upscale or a 2x2 two-channel image. +pub fn resize_area( + src: &[f32], + h: usize, + w: usize, + cn: usize, + dh: usize, + dw: usize, +) -> Option> { + if h == 0 || w == 0 || dh == 0 || dw == 0 || cn == 0 || src.len() != h * w * cn { + return None; + } + // OpenCV computes inv_scale = dsize / ssize, then scale = 1 / inv_scale. + let scale_x = 1.0 / (dw as f64 / w as f64); + let scale_y = 1.0 / (dh as f64 / h as f64); + let int_x = (scale_x - scale_x.round()).abs() < f64::EPSILON; + let int_y = (scale_y - scale_y.round()).abs() < f64::EPSILON; + if scale_x < 1.0 || scale_y < 1.0 { + return None; + } + if dh == h && dw == w { + return Some(src.to_vec()); + } + if int_x && int_y { + return resize_area_fast( + src, + w, + cn, + dh, + dw, + scale_x.round() as usize, + scale_y.round() as usize, + ); + } + let xtab = area_tab(w, dw, scale_x); + let ytab = area_tab(h, dh, scale_y); + let dwidth = dw * cn; + let mut dst = vec![0f32; dh * dwidth]; + + // Each destination pixel's taps are consecutive in its table, so rows (and pixels) are + // independent and one accumulator per pixel keeps OpenCV's order: ((0 + s0*a0) + s1*a1) ... + let xspans = spans(&xtab, dw); + let yspans = spans(&ytab, dh); + + dst.par_chunks_mut(dwidth) + .enumerate() + .for_each(|(dy, out)| { + let mut first = true; + for t in &ytab[yspans[dy]..yspans[dy + 1]] { + let row = &src[t.si * w * cn..(t.si + 1) * w * cn]; + match cn { + 1 => accumulate::<1>(row, &xtab, &xspans, t.alpha, first, out), + 3 => accumulate::<3>(row, &xtab, &xspans, t.alpha, first, out), + _ => accumulate_any(row, cn, &xtab, &xspans, t.alpha, first, out), + } + first = false; + } + }); + Some(dst) +} + +/// OpenCV's `resizeAreaFast_` for an integer scale. A 2x2 one- or four-channel image takes its +/// SIMD order, ((a + b) + (c + d)) * 0.25, over the columns the 4-lane vector loop reaches; the +/// rest sums each cell row by row, four at a time: sum + (((a + b) + c) + d). +fn resize_area_fast( + src: &[f32], + w: usize, + cn: usize, + dh: usize, + dw: usize, + sx: usize, + sy: usize, +) -> Option> { + let simd_cols = match (sx, sy, cn) { + (2, 2, 1) => dw / 4 * 4, + (2, 2, 4) => dw, + (2, 2, 2) => return None, + _ => 0, + }; + let area = sx * sy; + let scale = 1.0f32 / area as f32; + let row_len = w * cn; + let ofs: Vec = (0..sy) + .flat_map(|y| (0..sx).map(move |x| y * row_len + x * cn)) + .collect(); + let mut dst = vec![0f32; dh * dw * cn]; + dst.par_chunks_mut(dw * cn) + .enumerate() + .for_each(|(dy, out)| { + let base = dy * sy * row_len; + for dx in 0..dw { + for c in 0..cn { + let s0 = base + dx * sx * cn + c; + out[dx * cn + c] = if dx < simd_cols { + let (a, b) = (src[s0], src[s0 + cn]); + let (p, q) = (src[s0 + row_len], src[s0 + row_len + cn]); + ((a + b) + (p + q)) * 0.25 + } else { + let mut sum = 0f32; + let mut k = 0; + while k + 4 <= area { + sum += ((src[s0 + ofs[k]] + src[s0 + ofs[k + 1]]) + + src[s0 + ofs[k + 2]]) + + src[s0 + ofs[k + 3]]; + k += 4; + } + for o in &ofs[k..] { + sum += src[s0 + o]; + } + sum * scale + }; + } + } + }); + Some(dst) +} + +/// Start index in `tab` of each destination index's taps, plus the end. +fn spans(tab: &[Tap], dsize: usize) -> Vec { + let mut starts = vec![tab.len(); dsize + 1]; + for (j, t) in tab.iter().enumerate().rev() { + starts[t.di] = j; + } + starts +} + +/// One source row's horizontal pass, weighted by `beta` into `out` (OpenCV's buf and sum). +fn accumulate( + row: &[f32], + xtab: &[Tap], + xspans: &[usize], + beta: f32, + first: bool, + out: &mut [f32], +) { + for (dx, px) in out.as_chunks_mut::().0.iter_mut().enumerate() { + let mut acc = [0f32; CN]; + for tap in &xtab[xspans[dx]..xspans[dx + 1]] { + let s = &row[tap.si * CN..tap.si * CN + CN]; + for c in 0..CN { + acc[c] += s[c] * tap.alpha; + } + } + for c in 0..CN { + px[c] = if first { + beta * acc[c] + } else { + px[c] + beta * acc[c] + }; + } + } +} + +fn accumulate_any( + row: &[f32], + cn: usize, + xtab: &[Tap], + xspans: &[usize], + beta: f32, + first: bool, + out: &mut [f32], +) { + for (dx, px) in out.chunks_exact_mut(cn).enumerate() { + for (c, p) in px.iter_mut().enumerate() { + let mut acc = 0f32; + for tap in &xtab[xspans[dx]..xspans[dx + 1]] { + acc += row[tap.si * cn + c] * tap.alpha; + } + *p = if first { beta * acc } else { *p + beta * acc }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_image_stays_flat() { + let src = vec![0.25f32; 37 * 53 * 3]; + let out = resize_area(&src, 37, 53, 3, 11, 17).unwrap(); + assert!(out.iter().all(|&v| (v - 0.25).abs() < 1e-6)); + } + + #[test] + fn upscale_is_not_covered() { + let src = vec![0f32; 40 * 40]; + assert!(resize_area(&src, 40, 40, 1, 20, 20).is_some()); + assert!(resize_area(&src, 40, 40, 1, 80, 30).is_none()); + assert!(resize_area(&src, 40, 40, 1, 20, 30).is_some()); + } + + #[test] + fn taps_cover_each_destination_with_unit_weight() { + let tab = area_tab(1600, 1059, 1600.0 / 1059.0); + let mut total = vec![0f64; 1059]; + for t in &tab { + total[t.di] += t.alpha as f64; + } + // OpenCV drops a fragment under 1e-3 of a source pixel, so a cell can fall short by that much. + assert!(total.iter().all(|&s| (s - 1.0).abs() < 2e-3)); + } +} diff --git a/scripts/replay.py b/scripts/replay.py index f8ebed19d..0a176219f 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -125,21 +125,39 @@ def restore(self) -> None: setattr(owner, name, fn) -def _run_frame(path: str) -> None: +def _run_frame(path: str, repo=None) -> None: + """The frame's saved edit (or the defaults), with Auto Crop armed so detection runs.""" from negpy.domain.models import WorkspaceConfig from negpy.features.geometry.models import with_auto_crop + from negpy.kernel.image.logic import file_hashes from negpy.services.rendering.image_processor import ImageProcessor from negpy.services.rendering.preview_manager import PreviewManager buffer, _, _ = PreviewManager().load_linear_preview(path) - config = with_auto_crop(WorkspaceConfig(), True) + saved = repo.load_file_settings(file_hashes(path)[0]) if repo else None + config = with_auto_crop(saved or WorkspaceConfig(), True) ImageProcessor(use_gpu=False).run_pipeline( buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False, cache_stages=False ) -def record(files: list[str], phases: list[int] | None) -> None: +def _edits_repo(edits_db: str | None): + """A repository over a copy of *edits_db*, so recording never writes to the user's edits.""" + if not edits_db: + return None + import shutil + import tempfile + + from negpy.infrastructure.storage.repository import StorageRepository + + folder = Path(tempfile.mkdtemp(prefix="negpy-replay-")) + shutil.copy(edits_db, folder / "edits.db") + return StorageRepository(str(folder / "edits.db"), str(folder / "settings.db")) + + +def record(files: list[str], phases: list[int] | None, edits_db: str | None) -> None: keys = _targets(phases, ("todo",)) + repo = _edits_repo(edits_db) import negpy.services.rendering.image_processor # noqa: F401 (load the pipeline before wrapping) import negpy.services.rendering.preview_manager # noqa: F401 @@ -147,7 +165,7 @@ def record(files: list[str], phases: list[int] | None) -> None: try: for path in files: recorder.frame, recorder.per_frame = Path(path).name, {} - _run_frame(path) + _run_frame(path, repo) print(f"recorded {path}") finally: recorder.restore() @@ -216,11 +234,12 @@ def main() -> None: parser.add_argument("mode", choices=("record", "replay", "bench")) parser.add_argument("files", nargs="*") parser.add_argument("--phase", type=int, action="append", help="only functions of this phase (repeatable)") + parser.add_argument("--edits", help="record each frame with its saved edit from this edits.db (read from a copy)") args = parser.parse_args() if args.mode == "record": if not args.files: parser.error("record needs at least one frame") - record(args.files, args.phase) + record(args.files, args.phase, args.edits) else: sys.exit((replay if args.mode == "replay" else bench)(args.phase)) diff --git a/tests/test_core_resize.py b/tests/test_core_resize.py new file mode 100644 index 000000000..8fd4444b6 --- /dev/null +++ b/tests/test_core_resize.py @@ -0,0 +1,42 @@ +import cv2 +import negpy_core +import numpy as np +import pytest + +_RNG = np.random.default_rng(5) + + +def _image(h, w, cn): + return _RNG.random((h, w, cn) if cn > 1 else (h, w), dtype=np.float32) ** 2.2 + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize( + "shape, dsize", + [ + ((394, 566), (160, 111)), # the detection downscale, at a tenth of its size + ((1600, 1059), (1059, 701)), + ((37, 53), (17, 11)), + ((100, 98), (49, 40)), # integer scale on one axis only + ((64, 64), (32, 32)), # 2x2: SIMD order + ((100, 98), (49, 50)), + ((40, 74), (37, 20)), # 2x2 with a scalar tail column + ((90, 90), (30, 30)), # 3x3 + ((80, 80), (20, 20)), # 4x4 + ((80, 60), (60, 80)), # same size + ], +) +def test_resize_area_matches_cv2_bit_for_bit(shape, dsize, cn): + img = _image(*shape, cn) + want = cv2.resize(img, dsize, interpolation=cv2.INTER_AREA) + got = negpy_core.resize_area(img, dsize) + assert got.shape == want.shape + assert got.tobytes() == want.tobytes() + + +def test_resize_area_rejects_upscale_and_strided_input(): + img = _image(20, 20, 3) + with pytest.raises(ValueError): + negpy_core.resize_area(img, (40, 40)) + with pytest.raises(ValueError): + negpy_core.resize_area(img[:, ::2], (5, 10)) From 5e0125ebbc6449c30c83a033e2ff499539c70441 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 22:47:28 +0200 Subject: [PATCH 011/133] Revert "ci: run the GPU tests on lavapipe" This reverts commit 9d5f0821ca53a1ccf1d2ca07ed12ece1cb9fcaf1. --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceee45ddc..f26808d7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 mesa-vulkan-drivers + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 - name: Install Dependencies # pieusb / plustek are pure Python (+ bundled libusb where needed); without @@ -73,10 +73,6 @@ jobs: --ignore "call-non-callable" - name: Run Tests - # Lavapipe (Mesa's software Vulkan) runs the GPU tests; without the backend pin, - # wgpu picks the GL adapter first. - env: - WGPU_BACKEND_TYPE: Vulkan run: uv run pytest tests/ --cov=negpy --cov-report=term-missing --cov-report=xml - name: Upload Coverage Report From ecfbc93c896214c2695df99a3d86b29ea3ebea8f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:16:17 +0200 Subject: [PATCH 012/133] feat(core): OpenCV's float32 separable filter and GaussianBlur in Rust negpy_core.gaussian_blur, sep_filter2d and gaussian_kernel copy OpenCV's sepFilter2D engine for float32 as opencv-python's AVX2 build runs it: the 8-lane vector region with fused multiply-adds, then the scalar loops in the arithmetic GCC compiled them to (contracted or not, per loop, found by experiment and kept as rules). The Gaussian kernel follows getGaussianKernelBitExact, with OpenCV's softdouble exp. Borders: reflect-101, replicate, constant, reflect. Symmetric kernels and Sobel's 3-tap antisymmetric one; others raise. test_core_filter compares with cv2 bit for bit (366 cases). The cv2 oracles now run on Linux x86-64 only: opencv-python on macOS arm64 differs from it in the last bits (CI showed resize_area failing there while Windows and Intel macOS matched). test_core_same_bits holds output hashes every release OS must match. The passes run in strips of rows with AVX2+FMA code picked at runtime. 1600x1059, interleaved medians: one thread 1.3-1.5x slower than cv2; 16 threads 2-3.4x faster (5x5x3 2.78 -> 1.32 ms, sigma 2 2.25 -> 0.65). --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- NOTICE.md | 5 + negpy_core/src/exptab.rs | 68 ++++ negpy_core/src/filter.rs | 651 +++++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/filter.rs | 111 ++++++ negpy_core/src/py/mod.rs | 1 + tests/core_oracle.py | 11 + tests/test_core_filter.py | 67 ++++ tests/test_core_resize.py | 3 + tests/test_core_same_bits.py | 42 +++ 12 files changed, 964 insertions(+), 3 deletions(-) create mode 100644 negpy_core/src/exptab.rs create mode 100644 negpy_core/src/filter.rs create mode 100644 negpy_core/src/py/filter.rs create mode 100644 tests/core_oracle.py create mode 100644 tests/test_core_filter.py create mode 100644 tests/test_core_same_bits.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f26808d7c..fac9bd035 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_resize.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py diff --git a/CLAUDE.md b/CLAUDE.md index bc1961bdb..2fe91456c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - `cargo test` builds without Python: PyO3 is behind the `python` feature, which maturin turns on. - Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `pkg-config`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. -- **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces, run on every release OS in CI (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. +- **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. - A port that cannot be bit-exact needs the user's agreement, then the `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` bumps. diff --git a/NOTICE.md b/NOTICE.md index 3b7163611..74f2ecf24 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -64,3 +64,8 @@ The negpy_core extension module links these Rust crates: PyO3 and rust-numpy Apache-2.0), ndarray, num-traits, num-complex, num-integer, matrixmultiply and rawpointer (MIT OR Apache-2.0), libc, once_cell, either, portable-atomic and rustc-hash (MIT OR Apache-2.0). negpy_core/Cargo.lock lists the exact versions. + +negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area +resize, separable filter engine and bit-exact Gaussian kernel (including its +softfloat exp table) so their results match cv2 bit for bit. OpenCV, Apache +License 2.0. https://github.com/opencv/opencv diff --git a/negpy_core/src/exptab.rs b/negpy_core/src/exptab.rs new file mode 100644 index 000000000..61b78ea9b --- /dev/null +++ b/negpy_core/src/exptab.rs @@ -0,0 +1,68 @@ +/// OpenCV's `expTab` (softfloat.cpp): 2^(i/64) for i in 0..64. +const EXP_TAB: [u64; 64] = [ + 0x3ff0000000000000, + 0x3ff02c9a3e778061, + 0x3ff059b0d3158574, + 0x3ff0874518759bc8, + 0x3ff0b5586cf9890f, + 0x3ff0e3ec32d3d1a2, + 0x3ff11301d0125b51, + 0x3ff1429aaea92de0, + 0x3ff172b83c7d517b, + 0x3ff1a35beb6fcb75, + 0x3ff1d4873168b9aa, + 0x3ff2063b88628cd6, + 0x3ff2387a6e756238, + 0x3ff26b4565e27cdd, + 0x3ff29e9df51fdee1, + 0x3ff2d285a6e4030b, + 0x3ff306fe0a31b715, + 0x3ff33c08b26416ff, + 0x3ff371a7373aa9cb, + 0x3ff3a7db34e59ff7, + 0x3ff3dea64c123422, + 0x3ff4160a21f72e2a, + 0x3ff44e086061892d, + 0x3ff486a2b5c13cd0, + 0x3ff4bfdad5362a27, + 0x3ff4f9b2769d2ca7, + 0x3ff5342b569d4f82, + 0x3ff56f4736b527da, + 0x3ff5ab07dd485429, + 0x3ff5e76f15ad2148, + 0x3ff6247eb03a5585, + 0x3ff6623882552225, + 0x3ff6a09e667f3bcd, + 0x3ff6dfb23c651a2f, + 0x3ff71f75e8ec5f74, + 0x3ff75feb564267c9, + 0x3ff7a11473eb0187, + 0x3ff7e2f336cf4e62, + 0x3ff82589994cce13, + 0x3ff868d99b4492ed, + 0x3ff8ace5422aa0db, + 0x3ff8f1ae99157736, + 0x3ff93737b0cdc5e5, + 0x3ff97d829fde4e50, + 0x3ff9c49182a3f090, + 0x3ffa0c667b5de565, + 0x3ffa5503b23e255d, + 0x3ffa9e6b5579fdbf, + 0x3ffae89f995ad3ad, + 0x3ffb33a2b84f15fb, + 0x3ffb7f76f2fb5e47, + 0x3ffbcc1e904bc1d2, + 0x3ffc199bdd85529c, + 0x3ffc67f12e57d14b, + 0x3ffcb720dcef9069, + 0x3ffd072d4a07897c, + 0x3ffd5818dcfba487, + 0x3ffda9e603db3285, + 0x3ffdfc97337b9b5f, + 0x3ffe502ee78b3ff6, + 0x3ffea4afa2a490da, + 0x3ffefa1bee615a27, + 0x3fff50765b6e4540, + 0x3fffa7c1819e90d8, +]; + diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs new file mode 100644 index 000000000..176d22bae --- /dev/null +++ b/negpy_core/src/filter.rs @@ -0,0 +1,651 @@ +//! OpenCV's float32 separable filter engine (`sepFilter2D`, `GaussianBlur`), bit for bit with +//! opencv-python's AVX2 build: an 8-lane vector region with fused multiply-adds, then the scalar +//! loops in the arithmetic GCC compiled them to. + +use rayon::prelude::*; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Border { + Constant, + Replicate, + Reflect, + Reflect101, +} + +impl Border { + /// From an OpenCV border code; `BORDER_ISOLATED` is ignored, since inputs are whole arrays. + pub fn from_cv(code: i32) -> Option { + match code & !16 { + 0 => Some(Border::Constant), + 1 => Some(Border::Replicate), + 2 => Some(Border::Reflect), + 4 => Some(Border::Reflect101), + _ => None, + } + } +} + +/// OpenCV's `borderInterpolate`; None for a constant (zero) border. +pub fn border_interpolate(p: isize, len: usize, border: Border) -> Option { + let n = len as isize; + if (0..n).contains(&p) { + return Some(p as usize); + } + match border { + Border::Constant => None, + Border::Replicate => Some(if p < 0 { 0 } else { len - 1 }), + Border::Reflect | Border::Reflect101 => { + if len == 1 { + return Some(0); + } + let delta = (border == Border::Reflect101) as isize; + let mut p = p; + while !(0..n).contains(&p) { + p = if p < 0 { + -p - 1 + delta + } else { + n - 1 - (p - n) - delta + }; + } + Some(p as usize) + } + } +} + +include!("exptab.rs"); + +/// OpenCV's `exp(softdouble)` (softfloat.cpp `f64_exp`): a table and a polynomial, not libm. +fn soft_exp(x: f64) -> f64 { + if x.is_nan() { + return f64::NAN; + } + if x.is_infinite() { + return if x > 0.0 { x } else { 0.0 }; + } + let a0p = f64::from_bits(0x3f83ce0f3e46f431); + let a5 = 1.0 / a0p; + let a4 = f64::from_bits(0x3fe62e42fefa39f1) / a0p; + let a3 = f64::from_bits(0x3fcebfbdff82a45a) / a0p; + let a2 = f64::from_bits(0x3fac6b08d81fec75) / a0p; + let a1 = f64::from_bits(0x3f83b2a72b4f3cd3) / a0p; + let a0 = f64::from_bits(0x3f55e7aa1566c2a4) / a0p; + let prescale = f64::from_bits(0x3ff71547652b82fe) * 64.0; + let postscale = 1.0 / 64.0; + let max_val = 3000.0 * 64.0; + + let biased = ((x.to_bits() >> 52) & 0x7ff) as i64; + let mut x0 = if biased > 1023 + 10 { + if x < 0.0 { -max_val } else { max_val } + } else { + x * prescale + }; + let val0 = x0.round_ties_even() as i32; + let t = ((val0 >> 6) + 1023).clamp(0, 2047); + let buf = f64::from_bits((t as u64) << 52); + x0 = (x0 - x0.round_ties_even()) * postscale; + buf * a0p + * f64::from_bits(EXP_TAB[(val0 & 63) as usize]) + * (((((a0 * x0 + a1) * x0 + a2) * x0 + a3) * x0 + a4) * x0 + a5) +} + +/// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)` (`getGaussianKernelBitExact`). +pub fn gaussian_kernel(n: usize, sigma: f64) -> Vec { + if sigma <= 0.0 { + let fixed: Option<&[f64]> = match n { + 1 => Some(&[1.0]), + 3 => Some(&[0.25, 0.5, 0.25]), + 5 => Some(&[0.0625, 0.25, 0.375, 0.25, 0.0625]), + 7 => Some(&[ + 0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125, + ]), + 9 => Some(&[ + 4.0 / 256.0, + 13.0 / 256.0, + 30.0 / 256.0, + 51.0 / 256.0, + 60.0 / 256.0, + 51.0 / 256.0, + 30.0 / 256.0, + 13.0 / 256.0, + 4.0 / 256.0, + ]), + _ => None, + }; + if let Some(k) = fixed { + return k.iter().map(|&v| v as f32).collect(); + } + } + let sigma_x = if sigma > 0.0 { + sigma + } else { + (n as f64).mul_add( + f64::from_bits(0x3fc3333333333333), + f64::from_bits(0x3fd6666666666666), + ) + }; + let scale2x = -0.125 / (sigma_x * sigma_x); + let n2 = (n - 1) / 2; + let mut values = Vec::with_capacity(n2); + let mut sum = 0.0f64; + let mut x = 1 - n as i64; + for _ in 0..n2 { + let t = soft_exp((x * x) as f64 * scale2x); + values.push(t); + sum += t; + x += 2; + } + sum *= 2.0; + sum += 1.0; + if n.is_multiple_of(2) { + sum += 1.0; + } + let mul1 = 1.0 / sum; + let mut result = vec![0f64; n]; + for (i, v) in values.iter().enumerate() { + let t = v * mul1; + result[i] = t; + result[n - 1 - i] = t; + } + result[n2] = mul1; + if n.is_multiple_of(2) { + result[n2 + 1] = result[n2]; + } + result.iter().map(|&v| v as f32).collect() +} + +/// `createGaussianKernels`' size from sigma for a float image. +pub fn gaussian_ksize(sigma: f64) -> usize { + ((sigma * 4.0 * 2.0 + 1.0).round_ties_even() as i64 | 1) as usize +} + +#[derive(Clone, Copy, PartialEq)] +enum Symmetry { + Symmetric, + Asymmetric, + None, +} + +fn symmetry(k: &[f32]) -> Symmetry { + let n = k.len(); + if (0..n).all(|i| k[i] == k[n - 1 - i]) { + Symmetry::Symmetric + } else if (0..n).all(|i| k[i] == -k[n - 1 - i]) { + Symmetry::Asymmetric + } else { + Symmetry::None + } +} + +const LANES: usize = 8; + +/// The 8-lane vector region of a row, `out.len()` a multiple of 8. Tap-outer slice loops keep +/// each output's order of operations and let the compiler vectorize. +#[inline(always)] +fn row_vector(p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + let n = out.len(); + let ks = k.len(); + let r = ks / 2; + let sl = |o: isize| &p[((r * cn) as isize + o * cn as isize) as usize..][..n]; + if ks == 1 { + out.iter_mut() + .zip(&p[..n]) + .for_each(|(o, &x)| *o = k[0] * x); + } else if sym == Symmetry::Symmetric && ks <= 5 { + let (c, m1, p1) = (sl(0), sl(-1), sl(1)); + let (k0, k1) = (k[r], k[r + 1]); + if ks == 3 { + if k0.abs() == 2.0 && k1 == 1.0 { + for i in 0..n { + out[i] = c[i].mul_add(k0, m1[i] + p1[i]); + } + } else { + for i in 0..n { + out[i] = c[i].mul_add(k0, (m1[i] + p1[i]) * k1); + } + } + } else { + let (m2, p2, k2) = (sl(-2), sl(2), k[r + 2]); + if k0 == -2.0 && k1 == 0.0 && k2 == 1.0 { + for i in 0..n { + out[i] = c[i].mul_add(-2.0, m2[i] + p2[i]); + } + } else { + for i in 0..n { + out[i] = (p2[i] + m2[i]).mul_add(k2, c[i].mul_add(k0, (m1[i] + p1[i]) * k1)); + } + } + } + } else if sym == Symmetry::Asymmetric && ks == 3 { + let (m1, p1, k1) = (sl(-1), sl(1), k[r + 1]); + if k[r] == 0.0 && k1 == 1.0 { + for i in 0..n { + out[i] = p1[i] - m1[i]; + } + } else { + for i in 0..n { + out[i] = (p1[i] - m1[i]) * k1; + } + } + } else { + let (blocks, rest) = out.split_at_mut(n / 16 * 16); + for (b, chunk) in blocks.as_chunks_mut::<16>().0.iter_mut().enumerate() { + row_taps::<16>(p, chunk, b * 16, cn, k); + } + if !rest.is_empty() { + row_taps::<8>(p, rest, n / 16 * 16, cn, k); + } + } +} + +/// RowVec_32f's sum for `B` outputs from `base`, the accumulators held in registers. +#[inline(always)] +fn row_taps(p: &[f32], out: &mut [f32], base: usize, cn: usize, k: &[f32]) { + let mut acc = [0f32; B]; + let t0 = &p[base..base + B]; + for l in 0..B { + acc[l] = t0[l].mul_add(k[0], 0.0); + } + for (j, &kj) in k.iter().enumerate().skip(1) { + let t = &p[base + j * cn..base + j * cn + B]; + for l in 0..B { + acc[l] = t[l].mul_add(kj, acc[l]); + } + } + out.copy_from_slice(&acc); +} + +/// One row: `p` is the source row padded by `r = k.len() / 2` pixels on each side. +#[inline(always)] +fn row_filter_impl(p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + let n = out.len(); + let ks = k.len(); + let r = ks / 2; + let vec_end = n / LANES * LANES; + let at = |i: usize, o: isize| p[(i as isize + (r * cn) as isize + o * cn as isize) as usize]; + row_vector(p, &mut out[..vec_end], cn, k, sym); + let tail = out.iter_mut().enumerate().skip(vec_end); + + if ks == 1 { + for (i, o) in tail { + *o = k[0] * p[i]; + } + return; + } + // After the vector region: a pair loop, then the odd last float alone, centre first + // (fused for 3 taps, plain for 5). The pair loop matches the vector arithmetic. + let single = |i: usize| i + 2 > n && (n - vec_end) % 2 == 1; + let small = sym != Symmetry::None && ks <= 5; + if small && sym == Symmetry::Symmetric { + let (k0, k1) = (k[r], k[r + 1]); + let k2 = if ks == 5 { k[r + 2] } else { 0.0 }; + for (i, o) in tail { + let (s0, p1) = (at(i, 0), at(i, -1) + at(i, 1)); + *o = if ks == 3 { + if single(i) { + k1.mul_add(at(i, 1) + at(i, -1), k0 * s0) + } else if k0 == 2.0 && k1 == 1.0 { + (at(i, -1) + s0 * 2.0) + at(i, 1) + } else if k0 == -2.0 && k1 == 1.0 { + (at(i, -1) - s0 * 2.0) + at(i, 1) + } else { + s0.mul_add(k0, p1 * k1) + } + } else if single(i) { + (k0 * s0 + k1 * (at(i, 1) + at(i, -1))) + k2 * (at(i, 2) + at(i, -2)) + } else if k0 == -2.0 && k1 == 0.0 && k2 == 1.0 { + (-2.0 * s0 + at(i, -2)) + at(i, 2) + } else { + (at(i, 2) + at(i, -2)).mul_add(k2, s0.mul_add(k0, p1 * k1)) + }; + } + return; + } + if small && ks == 3 { + let (k0, k1) = (k[r], k[r + 1]); + for (i, o) in tail { + let d = at(i, 1) - at(i, -1); + *o = if single(i) { + k1.mul_add(d, k0 * at(i, 0)) + } else if k0 == 0.0 && k1 == 1.0 { + d + } else { + d * k1 + }; + } + return; + } + // RowFilter's scalar loops: four at a time (contracted), then one at a time, where GCC + // unrolled the tap loop by four uncontracted and fused the remainder. + let unrolled_end = vec_end + (n - vec_end) / 4 * 4; + let plain_adds = (ks - 1) / 4 * 4; + for (i, o) in tail { + let tap = |j: usize| p[i + j * cn]; + *o = if i < unrolled_end { + (1..ks).fold(k[0] * tap(0), |s, j| k[j].mul_add(tap(j), s)) + } else { + (1..ks).fold(k[0] * tap(0), |s, j| { + if j <= plain_adds { + s + k[j] * tap(j) + } else { + k[j].mul_add(tap(j), s) + } + }) + }; + } +} + +/// One output row from the row-filtered rows `rows[0..ks]` (top to bottom). +#[inline(always)] +fn column_filter_impl(rows: &[&[f32]], out: &mut [f32], k: &[f32], sym: Symmetry) { + let n = out.len(); + let ks = k.len(); + let r = ks / 2; + let vec_end = n / LANES * LANES; + let (vec, tail) = out.split_at_mut(vec_end); + match sym { + Symmetry::Symmetric if ks == 3 => { + let (f0, f1) = (k[1], k[2]); + let (s0, s1, s2) = (rows[0], rows[1], rows[2]); + if f0.abs() == 2.0 && f1 == 1.0 { + for i in 0..vec_end { + vec[i] = s1[i].mul_add(f0, (s0[i] + s2[i]) + 0.0); + } + } else { + for i in 0..vec_end { + vec[i] = (s0[i] + s2[i]).mul_add(f1, s1[i].mul_add(f0, 0.0)); + } + } + for (t, o) in tail.iter_mut().enumerate() { + let i = vec_end + t; + *o = if f0 == 2.0 && f1 == 1.0 { + ((s0[i] + s1[i] * 2.0) + s2[i]) + 0.0 + } else if f0 == -2.0 && f1 == 1.0 { + ((s0[i] - s1[i] * 2.0) + s2[i]) + 0.0 + } else { + (s0[i] + s2[i]).mul_add(f1, s1[i] * f0) + 0.0 + }; + } + } + Symmetry::Symmetric => { + let c = rows[r]; + let (blocks, rest) = vec.split_at_mut(vec_end / 16 * 16); + for (b, chunk) in blocks.as_chunks_mut::<16>().0.iter_mut().enumerate() { + column_taps::<16>(rows, chunk, b * 16, k); + } + if !rest.is_empty() { + column_taps::<8>(rows, rest, vec_end / 16 * 16, k); + } + // GCC left the scalar column loops uncontracted: plain multiply, then add. + for (t, o) in tail.iter_mut().enumerate() { + let i = vec_end + t; + *o = (1..=r).fold(k[r] * c[i] + 0.0, |s, j| { + s + k[r + j] * (rows[r + j][i] + rows[r - j][i]) + }); + } + } + // sep_filter2d admits only a 3-tap antisymmetric column kernel. + Symmetry::Asymmetric | Symmetry::None => { + let f1 = k[2]; + let (lo, hi) = if f1 < 0.0 { + (rows[2], rows[0]) + } else { + (rows[0], rows[2]) + }; + for i in 0..n { + let o = if i < vec_end { + &mut vec[i] + } else { + &mut tail[i - vec_end] + }; + *o = if f1.abs() == 1.0 { + (hi[i] - lo[i]) + 0.0 + } else { + (rows[2][i] - rows[0][i]).mul_add(f1, 0.0) + }; + } + } + } +} + +/// SymmColumnVec_32f's sum for `B` outputs from `base`, the accumulators held in registers. +#[inline(always)] +fn column_taps(rows: &[&[f32]], out: &mut [f32], base: usize, k: &[f32]) { + let r = k.len() / 2; + let mut acc = [0f32; B]; + let c = &rows[r][base..base + B]; + for l in 0..B { + acc[l] = c[l].mul_add(k[r], 0.0); + } + for j in 1..=r { + let (a, b, kj) = ( + &rows[r + j][base..base + B], + &rows[r - j][base..base + B], + k[r + j], + ); + for l in 0..B { + acc[l] = (a[l] + b[l]).mul_add(kj, acc[l]); + } + } + out.copy_from_slice(&acc); +} + +/// Copies of the row and column passes compiled for AVX2 + FMA, so `mul_add` is one +/// instruction and the slice loops vectorize; the plain copies serve other CPUs. +#[cfg(target_arch = "x86_64")] +mod fma { + use super::Symmetry; + + #[target_feature(enable = "avx2,fma")] + pub fn row_filter(p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + super::row_filter_impl(p, out, cn, k, sym) + } + + #[target_feature(enable = "avx2,fma")] + pub fn column_filter(rows: &[&[f32]], out: &mut [f32], k: &[f32], sym: Symmetry) { + super::column_filter_impl(rows, out, k, sym) + } +} + +fn has_fma() -> bool { + #[cfg(target_arch = "x86_64")] + { + std::arch::is_x86_feature_detected!("avx2") && std::arch::is_x86_feature_detected!("fma") + } + #[cfg(not(target_arch = "x86_64"))] + { + false + } +} + +fn row_filter(fast: bool, p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + #[cfg(target_arch = "x86_64")] + if fast { + // SAFETY: `fast` is true only when has_fma() found AVX2 and FMA on this CPU. + return unsafe { fma::row_filter(p, out, cn, k, sym) }; + } + let _ = fast; + row_filter_impl(p, out, cn, k, sym) +} + +fn column_filter(fast: bool, rows: &[&[f32]], out: &mut [f32], k: &[f32], sym: Symmetry) { + #[cfg(target_arch = "x86_64")] + if fast { + // SAFETY: `fast` is true only when has_fma() found AVX2 and FMA on this CPU. + return unsafe { fma::column_filter(rows, out, k, sym) }; + } + let _ = fast; + column_filter_impl(rows, out, k, sym) +} + +/// `cv2.sepFilter2D(src, -1, kx, ky, borderType=border)` for float32, anchor at the center, +/// written into `dst`. None for a kernel OpenCV filters on a path this port does not cover. +pub fn sep_filter2d( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + (kx, ky): (&[f32], &[f32]), + border: Border, +) -> Option<()> { + if h == 0 + || w == 0 + || cn == 0 + || src.len() != h * w * cn + || dst.len() != src.len() + || kx.len().is_multiple_of(2) + || ky.len().is_multiple_of(2) + { + return None; + } + let (sx, sy) = (symmetry(kx), symmetry(ky)); + // Every caller passes a symmetric kernel, or Sobel's antisymmetric 3-tap one. + let covered = |s: Symmetry, len: usize| { + s == Symmetry::Symmetric || (s == Symmetry::Asymmetric && len == 3) + }; + if !covered(sx, kx.len()) || !covered(sy, ky.len()) { + return None; + } + let (rx, ry) = (kx.len() / 2, ky.len() / 2); + let n = w * cn; + + let fast = has_fma(); + let xmap: Vec> = (0..w + 2 * rx) + .map(|px| border_interpolate(px as isize - rx as isize, w, border)) + .collect(); + let row_pass = |padded: &mut [f32], sy: usize, out: &mut [f32]| { + let row = &src[sy * n..(sy + 1) * n]; + padded[rx * cn..(rx + w) * cn].copy_from_slice(row); + for (px, m) in xmap + .iter() + .enumerate() + .filter(|(px, _)| *px < rx || *px >= rx + w) + { + let chunk = &mut padded[px * cn..(px + 1) * cn]; + match m { + Some(sxi) => chunk.copy_from_slice(&row[sxi * cn..(sxi + 1) * cn]), + None => chunk.fill(0.0), + } + } + row_filter(fast, padded, out, cn, kx, sx); + }; + + // Strips of output rows, each with the row-filtered rows it reads kept in cache. + const STRIP: usize = 32; + dst.par_chunks_mut(STRIP * n).enumerate().for_each_init( + || { + ( + vec![0f32; (w + 2 * rx) * cn], + vec![0f32; (STRIP + 2 * ry) * n], + ) + }, + |(padded, buf), (strip, out)| { + let y0 = strip * STRIP; + let count = out.len() / n; + for t in 0..count + 2 * ry { + let line = &mut buf[t * n..(t + 1) * n]; + match border_interpolate((y0 + t) as isize - ry as isize, h, border) { + Some(sy) => row_pass(padded, sy, line), + None => line.fill(0.0), + } + } + for (yy, line) in out.chunks_exact_mut(n).enumerate() { + let rows: Vec<&[f32]> = (0..ky.len()) + .map(|j| &buf[(yy + j) * n..(yy + j + 1) * n]) + .collect(); + column_filter(fast, &rows, line, ky, sy); + } + }, + ); + Some(()) +} + +/// `cv2.GaussianBlur(src, (kw, kh), sigma_x, sigmaY=sigma_y, borderType=border)` for float32. +pub fn gaussian_blur( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + ksize: (usize, usize), + (sigma_x, sigma_y): (f64, f64), + border: Border, +) -> Option<()> { + let (mut kw, mut kh) = ksize; + let sigma_y = if sigma_y <= 0.0 { sigma_x } else { sigma_y }; + if kw == 0 && sigma_x > 0.0 { + kw = gaussian_ksize(sigma_x); + } + if kh == 0 && sigma_y > 0.0 { + kh = gaussian_ksize(sigma_y); + } + if kw.is_multiple_of(2) || kh.is_multiple_of(2) { + return None; + } + if border != Border::Constant { + if h == 1 { + kh = 1; + } + if w == 1 { + kw = 1; + } + } + if kw == 1 && kh == 1 { + dst.copy_from_slice(src); + return Some(()); + } + let kx = gaussian_kernel(kw, sigma_x.max(0.0)); + let ky = if kh == kw && (sigma_x - sigma_y).abs() < f64::EPSILON { + kx.clone() + } else { + gaussian_kernel(kh, sigma_y.max(0.0)) + }; + sep_filter2d(src, dst, (h, w, cn), (&kx, &ky), border) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reflect101_and_reflect_borders() { + let r101: Vec<_> = (-3..8) + .map(|p| border_interpolate(p, 5, Border::Reflect101).unwrap()) + .collect(); + assert_eq!(r101, vec![3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1]); + let r: Vec<_> = (-2..7) + .map(|p| border_interpolate(p, 5, Border::Reflect).unwrap()) + .collect(); + assert_eq!(r, vec![1, 0, 0, 1, 2, 3, 4, 4, 3]); + assert_eq!(border_interpolate(-1, 5, Border::Constant), None); + assert_eq!(border_interpolate(9, 5, Border::Replicate), Some(4)); + } + + #[test] + fn soft_exp_is_close_to_exp() { + for x in [-30.0, -7.5, -1.0, -0.125, 0.0, 0.5, 3.0] { + let rel = (soft_exp(x) - f64::exp(x)).abs() / f64::exp(x); + assert!(rel < 1e-14, "x={x} rel={rel}"); + } + } + + #[test] + fn kernels_sum_to_one() { + for (n, sigma) in [(5, 0.0), (11, 0.0), (17, 2.0), (25, 3.0)] { + let s: f64 = gaussian_kernel(n, sigma).iter().map(|&v| v as f64).sum(); + assert!((s - 1.0).abs() < 1e-6, "n={n}"); + } + } + + #[test] + fn flat_image_stays_flat() { + let src = vec![0.5f32; 23 * 31 * 3]; + let mut out = vec![0f32; src.len()]; + gaussian_blur( + &src, + &mut out, + (23, 31, 3), + (5, 5), + (0.0, 0.0), + Border::Reflect101, + ) + .unwrap(); + assert!(out.iter().all(|&v| v == 0.5)); + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index e966a1421..ea2b3ff0a 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod filter; pub mod resize; pub mod stats; @@ -8,5 +9,6 @@ mod py; #[pyo3::pymodule] fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { py::stats::register(m)?; - py::resize::register(m) + py::resize::register(m)?; + py::filter::register(m) } diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs new file mode 100644 index 000000000..e7ba57393 --- /dev/null +++ b/negpy_core/src/py/filter.rs @@ -0,0 +1,111 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::filter::{self, Border}; + +/// A contiguous HxW or HxWxC float32 array. +struct Image<'a> { + data: &'a [f32], + shape: Vec, + dims: (usize, usize, usize), +} + +fn image<'a>(src: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult> { + let shape = src.shape().to_vec(); + let dims = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs a 2-D or 3-D array")), + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok(Image { data, shape, dims }) +} + +fn border(code: i32) -> PyResult { + Border::from_cv(code) + .ok_or_else(|| PyValueError::new_err(format!("unsupported border type {code}"))) +} + +/// Runs `fill` on a new `numpy.empty` array shaped like the input, with the GIL released. +/// `numpy.empty` neither zeroes the memory nor faults in 4 KiB pages one at a time. +fn filled<'py>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "float32"))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| fill(dst)) + .ok_or_else(|| PyValueError::new_err("kernel shape not covered by negpy_core"))?; + } + Ok(out) +} + +/// `cv2.GaussianBlur(src, ksize, sigma_x, sigmaY=sigma_y, borderType=border)` for float32. +#[pyfunction] +#[pyo3(signature = (src, ksize, sigma_x, sigma_y=0.0, border_type=4))] +fn gaussian_blur<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + ksize: (usize, usize), + sigma_x: f64, + sigma_y: f64, + border_type: i32, +) -> PyResult>> { + let img = image(&src)?; + let b = border(border_type)?; + filled(py, &img.shape, |dst| { + filter::gaussian_blur(img.data, dst, img.dims, ksize, (sigma_x, sigma_y), b) + }) +} + +/// `cv2.sepFilter2D(src, -1, kx, ky, borderType=border)` for float32. +#[pyfunction] +#[pyo3(signature = (src, kx, ky, border_type=4))] +fn sep_filter2d<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + kx: PyReadonlyArray1<'py, f32>, + ky: PyReadonlyArray1<'py, f32>, + border_type: i32, +) -> PyResult>> { + let img = image(&src)?; + let (h, w, cn) = img.dims; + let b = border(border_type)?; + let kx = kx + .as_slice() + .map_err(|_| PyValueError::new_err("kx must be contiguous"))? + .to_vec(); + let ky = ky + .as_slice() + .map_err(|_| PyValueError::new_err("ky must be contiguous"))? + .to_vec(); + filled(py, &img.shape, |dst| { + filter::sep_filter2d(img.data, dst, (h, w, cn), (&kx, &ky), b) + }) +} + +/// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)` as a flat list. +#[pyfunction] +fn gaussian_kernel(n: usize, sigma: f64) -> Vec { + filter::gaussian_kernel(n, sigma) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(gaussian_blur, m)?)?; + m.add_function(wrap_pyfunction!(sep_filter2d, m)?)?; + m.add_function(wrap_pyfunction!(gaussian_kernel, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 0c5a4b95e..ba53e80b1 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,2 +1,3 @@ +pub mod filter; pub mod resize; pub mod stats; diff --git a/tests/core_oracle.py b/tests/core_oracle.py new file mode 100644 index 000000000..ebba992d3 --- /dev/null +++ b/tests/core_oracle.py @@ -0,0 +1,11 @@ +import platform +import sys + +import pytest + +# opencv-python's AVX2 build on Linux x86-64 is the reference negpy_core copies. Its other +# builds differ in the last bits (NEON lanes, FMA contraction), so they are not oracles. +cv2_oracle = pytest.mark.skipif( + not (sys.platform == "linux" and platform.machine() == "x86_64"), + reason="cv2 is the oracle only on its Linux x86-64 build", +) diff --git a/tests/test_core_filter.py b/tests/test_core_filter.py new file mode 100644 index 000000000..a220fda9e --- /dev/null +++ b/tests/test_core_filter.py @@ -0,0 +1,67 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(11) +_SHAPES = [(41, 83, 1), (37, 53, 3), (57, 61, 4), (1, 301, 1), (300, 1, 1), (13, 9, 1)] +_BORDERS = [cv2.BORDER_REFLECT_101, cv2.BORDER_REPLICATE, cv2.BORDER_CONSTANT, cv2.BORDER_REFLECT] + + +def _image(h, w, cn): + return (_RNG.random((h, w, cn) if cn > 1 else (h, w), dtype=np.float32) - 0.3) * 3 + + +def _symmetric(n): + half = _RNG.random(n // 2 + 1).astype(np.float32) + return np.concatenate([half[:-1], half[-1:], half[:-1][::-1]]).astype(np.float32) + + +@pytest.mark.parametrize("n, sigma", [(1, 0), (3, 0), (5, 0), (7, 0), (9, 0), (11, 0), (15, 0), (17, 2.0), (13, 1.5), (33, 4.0), (5, 0.8)]) +def test_gaussian_kernel_matches_cv2(n, sigma): + want = cv2.getGaussianKernel(n, sigma, cv2.CV_32F).ravel() + assert np.array(negpy_core.gaussian_kernel(n, sigma), np.float32).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("border", _BORDERS) +@pytest.mark.parametrize( + "ksize, sigma_x, sigma_y", + [((5, 5), 0, 0), ((7, 7), 0, 0), ((11, 1), 0, 0), ((0, 0), 2.0, 0), ((0, 0), 0.7, 0), ((1, 0), 0, 2.5), ((3, 3), 0, 0), ((9, 9), 0, 0)], +) +def test_gaussian_blur_matches_cv2_bit_for_bit(shape, border, ksize, sigma_x, sigma_y): + img = _image(*shape) + want = cv2.GaussianBlur(img, ksize, sigma_x, sigmaY=sigma_y, borderType=border) + got = negpy_core.gaussian_blur(img, ksize, sigma_x, sigma_y, border) + assert got.tobytes() == want.tobytes() + + +_KERNELS = { + "sym3": (_symmetric(3), _symmetric(3)), + "sym5": (_symmetric(5), _symmetric(5)), + "sym9x3": (_symmetric(9), _symmetric(3)), + "sym11": (_symmetric(11), _symmetric(11)), + "sobel_x": (np.array([-1, 0, 1], np.float32), np.array([1, 2, 1], np.float32)), + "sobel_y": (np.array([1, 2, 1], np.float32), np.array([-1, 0, 1], np.float32)), + "scaled_derivative": (np.array([-0.5, 0, 0.5], np.float32), np.array([0.25, 0.5, 0.25], np.float32)), + "second_derivative": (np.array([1, -2, 1], np.float32), np.array([1, -2, 1], np.float32)), + "row5_special": (np.array([1, 0, -2, 0, 1], np.float32), np.array([1], np.float32)), +} + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("border", _BORDERS[:3]) +@pytest.mark.parametrize("name", sorted(_KERNELS)) +def test_sep_filter2d_matches_cv2_bit_for_bit(shape, border, name): + kx, ky = _KERNELS[name] + img = _image(*shape) + want = cv2.sepFilter2D(img, -1, kx, ky, borderType=border) + assert negpy_core.sep_filter2d(img, kx, ky, border).tobytes() == want.tobytes() + + +def test_sep_filter2d_rejects_a_kernel_it_does_not_cover(): + with pytest.raises(ValueError): + negpy_core.sep_filter2d(_image(9, 9, 1), np.array([0.2, 0.5, 0.3], np.float32), np.array([1], np.float32)) diff --git a/tests/test_core_resize.py b/tests/test_core_resize.py index 8fd4444b6..00e253eb8 100644 --- a/tests/test_core_resize.py +++ b/tests/test_core_resize.py @@ -2,6 +2,9 @@ import negpy_core import numpy as np import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle _RNG = np.random.default_rng(5) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py new file mode 100644 index 000000000..106243a68 --- /dev/null +++ b/tests/test_core_same_bits.py @@ -0,0 +1,42 @@ +import hashlib + +import negpy_core +import numpy as np +import pytest + +# Recorded on Linux x86-64, where the cv2 oracles pass. Every release platform must give these +# bytes; CI runs this file on each one. A port adds its case here; a hash only changes with a +# decided change of output. +_RNG = np.random.default_rng(2026) +_IMG3 = _RNG.random((97, 131, 3), dtype=np.float32) +_IMG1 = (_RNG.random((83, 61), dtype=np.float32) - 0.4) * 5 + +_CASES = { + "percentiles": lambda: np.array(negpy_core.percentiles(_IMG1.ravel(), [0.5, 1.0, 37.0, 50.0, 99.0]), np.float32), + "resize_area_general": lambda: negpy_core.resize_area(_IMG3, (47, 31)), + "resize_area_2x2": lambda: negpy_core.resize_area(_IMG1[:82, :60].copy(), (30, 41)), + "gaussian_5x5": lambda: negpy_core.gaussian_blur(_IMG3, (5, 5), 0.0), + "gaussian_sigma2_replicate": lambda: negpy_core.gaussian_blur(_IMG1, (0, 0), 2.0, 0.0, 1), + "gaussian_kernel_17": lambda: np.array(negpy_core.gaussian_kernel(17, 2.0), np.float32), + "sobel_x": lambda: negpy_core.sep_filter2d(_IMG1, np.array([-1, 0, 1], np.float32), np.array([1, 2, 1], np.float32)), +} + +_HASHES = { + "percentiles": "0874152b6e9f91bd3a3b73e15a9d083d15b6e8c7c6f6a9279d9241df10a88464", + "resize_area_general": "dbe0fb9e51b0e8bee54cc2bb35756bbf0934077b458c1a44ea27c2e5dd96a16f", + "resize_area_2x2": "cae2830d9848ee6f270000d57b3dc448138cc99cb5a2303108ac3e095b060488", + "gaussian_5x5": "988a867049827fd5912f1950b8574b637d671c3565e0b7af87def261c9f0b084", + "gaussian_sigma2_replicate": "2b9be6f95b7f66a03493795d8c7a5e1bb2565a28087578f1981a9cd329c8a74f", + "gaussian_kernel_17": "5c2eb92d43931221106d80ca89a0b6dea1dca09b261b0fbf6ff51fdcae748d8b", + "sobel_x": "ba798e861db64c102fee6b08fd7aa34d488cce431ae0d95fc3e7836e1dedb44a", +} + + +@pytest.mark.parametrize("name", sorted(_CASES)) +def test_same_bits_on_every_platform(name): + out = np.ascontiguousarray(_CASES[name]()) + assert hashlib.sha256(out.tobytes()).hexdigest() == _HASHES[name] + + +def test_every_case_has_a_hash(): + assert _CASES.keys() == _HASHES.keys() From 293f902b5738898c24aeea18264c36fc7d525a27 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:19:08 +0200 Subject: [PATCH 013/133] perf(core): phase 2 float32 GaussianBlur and Sobel go through negpy_core Every float32 cv2.GaussianBlur and 3x3 cv2.Sobel in geometry, skew, batch autocrop, normalization and exposure analysis now calls negpy_core.gaussian_blur or the new negpy_core.sobel (sepFilter2D with getDerivKernels' kernels, 10/10 identical to cv2.Sobel). The uint8 calls stay on cv2 until the fixed-point path is ported. The phase 2 replay over 33 real frames gives 40/40 functions identical. The manifest scan keeps functions that call negpy_core, so a function half moved to Rust stays tracked. --- negpy/features/exposure/analysis.py | 3 ++- negpy/features/exposure/normalization.py | 5 ++-- negpy/features/geometry/batch_autocrop.py | 7 +++--- negpy/features/geometry/logic.py | 6 ++--- negpy/features/geometry/skew.py | 9 ++++--- negpy_core/manifest.toml | 30 ++++++++++++++--------- negpy_core/src/py/filter.rs | 27 ++++++++++++++++++++ scripts/core_manifest.py | 4 +-- 8 files changed, 64 insertions(+), 27 deletions(-) diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index fc1aa93f5..d1fcf7ad1 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -4,6 +4,7 @@ from typing import Any, List, Optional, Sequence, Tuple import cv2 +import negpy_core import numpy as np from negpy.kernel.image.logic import get_luminance, rgb_to_lab_working, working_oetf_decode, working_oetf_encode @@ -381,7 +382,7 @@ def zone_field(rgb: Any, max_edge: int = ZONE_FIELD_MAX_EDGE) -> Optional[np.nda scale = min(1.0, max_edge / max(small.shape[:2])) sh, sw = small.shape[:2] lum = get_luminance(cv2.resize(small, (max(1, round(sw * scale)), max(1, round(sh * scale))), interpolation=cv2.INTER_AREA)) - lum = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), 2.0) + lum = negpy_core.gaussian_blur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), 2.0) zones = np.clip(np.rint(np.nan_to_num(zone_of_encoded(lum))), 0, ZONE_COUNT).astype(np.uint8) return cv2.medianBlur(zones, 5) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 18a934797..ac1c760cf 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Optional, TYPE_CHECKING, Tuple import cv2 +import negpy_core import numpy as np from numba import njit # type: ignore @@ -821,7 +822,7 @@ def contrast_mask_plane( val = normalize_log_image(unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), bounds) lum = LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] sigma = min(max(spacer, MASK_SPACER_MIN), MASK_SPACER_MAX) * 0.01 * min(lum.shape[:2]) - blurred = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), sigma, borderType=cv2.BORDER_REPLICATE) + blurred = negpy_core.gaussian_blur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), sigma, 0, cv2.BORDER_REPLICATE) centre = float(blurred.mean()) return blurred - centre, centre @@ -843,7 +844,7 @@ def diffusion_grid( image = analysis_grid(image, rotation, fine_rotation, flip_horizontal, flip_vertical, distortion_k1, converge_v, converge_h, roi_norm) light = np.ascontiguousarray(10.0 ** unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), dtype=np.float32) sigma = DIFFUSION_SPREAD * 0.01 * min(light.shape[:2]) - return np.log10(cv2.GaussianBlur(light, (0, 0), sigma, borderType=cv2.BORDER_REPLICATE)) + return np.log10(negpy_core.gaussian_blur(light, (0, 0), sigma, 0, cv2.BORDER_REPLICATE)) def diffusion_plane(grid: np.ndarray, bounds: LogNegativeBounds, panchromatic: bool = False) -> Optional[np.ndarray]: diff --git a/negpy/features/geometry/batch_autocrop.py b/negpy/features/geometry/batch_autocrop.py index 28ab73649..5f4bfd2a3 100644 --- a/negpy/features/geometry/batch_autocrop.py +++ b/negpy/features/geometry/batch_autocrop.py @@ -11,6 +11,7 @@ from typing import Sequence import cv2 +import negpy_core import numpy as np from negpy.domain.types import ROI, ImageBuffer @@ -156,12 +157,12 @@ def _vertical_edge_profile(img: ImageBuffer) -> tuple[np.ndarray, float]: low, high = np.percentile(finite, (2.0, 98.0)) span = max(float(high - low), 1e-6) normalized = np.clip((gray - low) / span, 0.0, 1.0).astype(np.float32) - grad = np.abs(cv2.Sobel(normalized, cv2.CV_32F, 1, 0, ksize=3)) + grad = np.abs(negpy_core.sobel(normalized, 1, 0)) profile = np.percentile(grad, 80, axis=0).astype(np.float32) window = max(5, int(round(profile.size * 0.012))) if window % 2 == 0: window += 1 - profile = cv2.GaussianBlur(profile.reshape(1, -1), (window, 1), 0).ravel() + profile = negpy_core.gaussian_blur(profile.reshape(1, -1), (window, 1), 0).ravel() peak = float(np.max(profile)) if profile.size else 0.0 contrast = max(0.0, float(np.percentile(profile, 99.5)) - float(np.percentile(profile, 50.0))) if profile.size else 0.0 if peak > 0.0: @@ -188,7 +189,7 @@ def _top_edge_slope(lum: np.ndarray, roi: ROI) -> float | None: if high - low < 4: return None - gradient = np.abs(cv2.Sobel(cv2.GaussianBlur(lum.astype(np.float32), (0, 0), 1.5), cv2.CV_32F, 0, 1, ksize=3)) + gradient = np.abs(negpy_core.sobel(negpy_core.gaussian_blur(lum.astype(np.float32), (0, 0), 1.5), 0, 1)) band = gradient[low:high, columns] rows = low + np.argmax(band, axis=0) strength = band.max(axis=0) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 604adc38f..ea75479f5 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -770,10 +770,10 @@ def _select_consensus_cluster(candidates: list[_FilmCandidate], image_shape: tup def _vertical_edge_profile(lum: np.ndarray) -> tuple[np.ndarray, float]: if lum.size == 0: return np.empty(0, dtype=np.float32), 0.0 - gradient = np.abs(cv2.Sobel(lum.astype(np.float32), cv2.CV_32F, 1, 0, ksize=3)) + gradient = np.abs(negpy_core.sobel(lum.astype(np.float32), 1, 0)) profile = np.mean(gradient, axis=0).astype(np.float32) window = _odd_kernel_size(0.006 * profile.size, 3, 15) - profile = cv2.GaussianBlur(profile.reshape(1, -1), (window, 1), 0).ravel() + profile = negpy_core.gaussian_blur(profile.reshape(1, -1), (window, 1), 0).ravel() baseline = float(np.percentile(profile, 50.0)) if profile.size else 0.0 normalizer = float(np.percentile(profile, 99.5)) if profile.size else 0.0 contrast = max(0.0, normalizer - baseline) @@ -823,7 +823,7 @@ def detect_film_bounds_with_confidence(img: ImageBuffer) -> AutocropDetection: min_dim = min(lum.shape[:2]) blur_size = _odd_kernel_size(0.004 * min_dim, 3, 9) - blurred_lum = cv2.GaussianBlur(lum.astype(np.float32), (blur_size, blur_size), 0) + blurred_lum = negpy_core.gaussian_blur(lum.astype(np.float32), (blur_size, blur_size), 0) image_shape = lum.shape[:2] candidates: list[_FilmCandidate] = [] for polarity in polarities: diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 976ae8bca..88f9898ce 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, replace import cv2 +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -106,7 +107,7 @@ def _coarse_angles(lum: np.ndarray) -> list[float]: return [] bins = np.arange(-_MAX_SKEW_DEGREES, _MAX_SKEW_DEGREES + _ANGLE_BIN, _ANGLE_BIN) hist, _ = np.histogram(angle[keep], bins=bins, weights=length[keep] ** 2) - hist = cv2.GaussianBlur(hist.astype(np.float32).reshape(1, -1), (0, 0), 3.0).ravel() + hist = negpy_core.gaussian_blur(hist.astype(np.float32).reshape(1, -1), (0, 0), 3.0).ravel() first = int(np.argmax(hist)) modes = [float(bins[first] + _ANGLE_BIN / 2)] rest = hist.copy() @@ -121,7 +122,7 @@ def _profile_peaks(profile: np.ndarray) -> np.ndarray: """Subpixel positions of the strong gradient steps along one profile.""" if profile.size < 5: return np.empty(0) - grad = np.abs(np.gradient(cv2.GaussianBlur(profile.reshape(-1, 1).astype(np.float32), (1, 0), 1.0).ravel())) + grad = np.abs(np.gradient(negpy_core.gaussian_blur(profile.reshape(-1, 1).astype(np.float32), (1, 0), 0, 1.0).ravel())) peak = float(grad.max()) if peak <= 3.0 * float(np.median(grad)) + 1e-6: return np.empty(0) @@ -297,7 +298,7 @@ def _profile_lines(lum: np.ndarray) -> list[EdgeLine]: h, w = lum.shape lines: list[EdgeLine] = [] spread = max(3, int(0.02 * max(h, w))) - gy = np.abs(cv2.Sobel(lum.astype(np.float32), cv2.CV_32F, 0, 1, ksize=3)) + gy = np.abs(negpy_core.sobel(lum.astype(np.float32), 0, 1)) strong = (gy >= 0.25 * float(np.percentile(gy, 99.5))).astype(np.uint8) strong = cv2.dilate(strong, np.ones((2 * spread + 1, 1), np.uint8)) rows = _outer_peaks(strong[:, int(0.05 * w) : int(0.95 * w)].mean(axis=1), 0.35) @@ -308,7 +309,7 @@ def _profile_lines(lum: np.ndarray) -> list[EdgeLine]: if line is not None: lines.append(line) - gx = np.abs(cv2.Sobel(lum.astype(np.float32), cv2.CV_32F, 1, 0, ksize=3)) + gx = np.abs(negpy_core.sobel(lum.astype(np.float32), 1, 0)) strong = (gx >= 0.25 * float(np.percentile(gx, 99.5))).astype(np.uint8) strong = cv2.dilate(strong, np.ones((1, 2 * spread + 1), np.uint8)) lo, hi = top + int(0.05 * (bottom - top)), bottom - int(0.05 * (bottom - top)) diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 3e8cadbb4..50b45b108 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -4,7 +4,7 @@ ["kernel/image/logic.py::percentiles"] phase = 1 status = "ported" -calls = ["np.partition"] +calls = ["negpy_core.percentiles"] depends = [] note = "negpy_core::stats::percentiles" @@ -89,7 +89,7 @@ depends = [] ["features/exposure/analysis.py::zone_field"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.medianBlur", "cv2.resize"] +calls = ["cv2.medianBlur", "cv2.resize", "negpy_core.gaussian_blur"] depends = [] ["features/exposure/analysis.py::zone_grid"] @@ -179,7 +179,7 @@ depends = [] ["features/exposure/normalization.py::contrast_mask_plane"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur"] +calls = ["negpy_core.gaussian_blur"] depends = ["analysis_grid"] ["features/exposure/normalization.py::density_above_base"] @@ -197,7 +197,7 @@ depends = ["_smooth_along_scan_order", "pool_neutral_axis"] ["features/exposure/normalization.py::diffusion_grid"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur"] +calls = ["negpy_core.gaussian_blur"] depends = ["analysis_grid"] ["features/exposure/normalization.py::measure_anchor_from_log"] @@ -305,13 +305,13 @@ depends = [] ["features/geometry/batch_autocrop.py::_top_edge_slope"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.median", "np.quantile"] +calls = ["negpy_core.gaussian_blur", "negpy_core.sobel", "np.median", "np.quantile"] depends = [] ["features/geometry/batch_autocrop.py::_vertical_edge_profile"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.percentile"] +calls = ["negpy_core.gaussian_blur", "negpy_core.sobel", "np.percentile"] depends = [] ["features/geometry/batch_autocrop.py::build_roll_template"] @@ -416,6 +416,12 @@ status = "todo" calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.morphologyEx", "cv2.threshold"] depends = [] +["features/geometry/logic.py::_normalize_detection_input"] +phase = 2 +status = "todo" +calls = ["negpy_core.resize_area"] +depends = [] + ["features/geometry/logic.py::_oriented_boundary_evidence"] phase = 2 status = "todo" @@ -479,7 +485,7 @@ depends = [] ["features/geometry/logic.py::_vertical_edge_profile"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.Sobel", "np.mean", "np.percentile"] +calls = ["negpy_core.gaussian_blur", "negpy_core.sobel", "np.mean", "np.percentile"] depends = [] ["features/geometry/logic.py::_walk_border_depth"] @@ -521,7 +527,7 @@ depends = [] ["features/geometry/logic.py::detect_film_bounds_with_confidence"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.cvtColor", "np.median", "np.percentile"] +calls = ["cv2.cvtColor", "negpy_core.gaussian_blur", "np.median", "np.percentile"] depends = ["_adaptive_threshold_levels", "_adaptive_threshold_mask", "_candidates_from_mask", "_detection_luma", "_ensure_color", "_film_surround_is_plausible", "_mask_from_blackhat", "_mask_from_edges", "_mask_from_inverse_threshold", "_threshold_polarities", "_vertical_edge_profile"] ["features/geometry/logic.py::keystone_matrix_normalized"] @@ -540,7 +546,7 @@ depends = [] phase = 2 status = "todo" calls = ["cv2.resize"] -depends = ["apply_fine_rotation", "apply_keystone", "apply_radial_distortion"] +depends = ["_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion"] ["features/geometry/skew.py::_at_canvas_edges"] phase = 2 @@ -569,13 +575,13 @@ depends = ["_at_canvas_edges", "_profile_lines", "_unrotate"] ["features/geometry/skew.py::_profile_lines"] phase = 2 status = "todo" -calls = ["cv2.Sobel", "cv2.dilate", "np.percentile"] +calls = ["cv2.dilate", "negpy_core.sobel", "np.percentile"] depends = ["_fit_side"] ["features/geometry/skew.py::_profile_peaks"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "np.median"] +calls = ["negpy_core.gaussian_blur", "np.median"] depends = [] ["features/geometry/skew.py::_ransac_line"] @@ -1577,7 +1583,7 @@ depends = [] ["features/geometry/skew.py::_coarse_angles"] phase = 9 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.createLineSegmentDetector", "np.histogram", "np.percentile"] +calls = ["cv2.createLineSegmentDetector", "negpy_core.gaussian_blur", "np.histogram", "np.percentile"] depends = [] ["features/retouch/logic.py::apply_hair_inpaint"] diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs index e7ba57393..762a4a652 100644 --- a/negpy_core/src/py/filter.rs +++ b/negpy_core/src/py/filter.rs @@ -98,6 +98,32 @@ fn sep_filter2d<'py>( }) } +/// `cv2.Sobel(src, cv2.CV_32F, dx, dy, ksize=3, borderType=border)` for float32: sepFilter2D +/// with getDerivKernels' 3-tap kernels. +#[pyfunction] +#[pyo3(signature = (src, dx, dy, border_type=4))] +fn sobel<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dx: u8, + dy: u8, + border_type: i32, +) -> PyResult>> { + let kernel = |order: u8| match order { + 0 => Ok(vec![1.0f32, 2.0, 1.0]), + 1 => Ok(vec![-1.0f32, 0.0, 1.0]), + _ => Err(PyValueError::new_err( + "sobel covers derivative orders 0 and 1", + )), + }; + let (kx, ky) = (kernel(dx)?, kernel(dy)?); + let img = image(&src)?; + let b = border(border_type)?; + filled(py, &img.shape, |dst| { + filter::sep_filter2d(img.data, dst, img.dims, (&kx, &ky), b) + }) +} + /// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)` as a flat list. #[pyfunction] fn gaussian_kernel(n: usize, sigma: f64) -> Vec { @@ -107,5 +133,6 @@ fn gaussian_kernel(n: usize, sigma: f64) -> Vec { pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(gaussian_blur, m)?)?; m.add_function(wrap_pyfunction!(sep_filter2d, m)?)?; + m.add_function(wrap_pyfunction!(sobel, m)?)?; m.add_function(wrap_pyfunction!(gaussian_kernel, m)?) } diff --git a/scripts/core_manifest.py b/scripts/core_manifest.py index 2e4504386..cd83aa877 100644 --- a/scripts/core_manifest.py +++ b/scripts/core_manifest.py @@ -66,7 +66,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: continue f = n.func if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): - if f.value.id == "cv2" or (f.value.id == "np" and f.attr in ORDER_STATS | REDUCTIONS): + if f.value.id in ("cv2", "negpy_core") or (f.value.id == "np" and f.attr in ORDER_STATS | REDUCTIONS): calls.add(f"{f.value.id}.{f.attr}") elif isinstance(f, ast.Name): names.add(f.id) @@ -79,7 +79,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: def scan() -> dict[str, dict]: - """Every function under negpy/ that calls cv2, a numpy order statistic or reduction, or is a Numba kernel.""" + """Every function under negpy/ that calls cv2, negpy_core, a numpy order statistic or reduction, or is a Numba kernel.""" found: dict[str, dict] = {} for path in sorted((ROOT / "negpy").rglob("*.py")): rel = path.relative_to(ROOT / "negpy").as_posix() From d4982a114bfde62be1ba471166e82e17ec551795 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:26:19 +0200 Subject: [PATCH 014/133] feat(core): morphology for rectangular kernels in Rust negpy_core.morphology_ex mirrors cv2.morphologyEx (erode, dilate, open, close, gradient, top hat, black hat) for uint8 and float32 with a kernel of all ones: OpenCV's rectangle-for-iterations rewrite, its default border value and the other borders. A min or max has no rounding, so a separable pass of overlapping power-of-two windows gives cv2's bits (2772/2772 in the oracle). Other kernels raise. The phase 2 masks in geometry and skew use it; the phase 2 replay over 33 real frames gives 40/40 functions identical. 1600x1059 uint8, one thread: close 31x31 x2 2.02 -> 2.07 ms, black hat 31 0.93 -> 1.05 ms, open 9x9 0.31 -> 0.77 ms; float32 erode 15 1.25 -> 5.45 ms (not on a phase 2 path). --- .github/workflows/ci.yml | 2 +- negpy/features/geometry/logic.py | 20 +- negpy/features/geometry/skew.py | 4 +- negpy_core/manifest.toml | 12 +- negpy_core/src/filter.rs | 8 +- negpy_core/src/lib.rs | 4 +- negpy_core/src/morph.rs | 313 +++++++++++++++++++++++++++++++ negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/morph.rs | 82 ++++++++ tests/test_core_morph.py | 45 +++++ tests/test_core_same_bits.py | 2 + 11 files changed, 467 insertions(+), 26 deletions(-) create mode 100644 negpy_core/src/morph.rs create mode 100644 negpy_core/src/py/morph.rs create mode 100644 tests/test_core_morph.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fac9bd035..9084d9cf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index ea75479f5..a5f96024a 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -200,8 +200,8 @@ def _dark_region_bounds(image: np.ndarray) -> tuple[int, int, int, int] | None: mask = (gray <= threshold).astype(np.uint8) * 255 open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)) - mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel) - mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, close_kernel, iterations=2) + mask = negpy_core.morphology_ex(mask, cv2.MORPH_OPEN, open_kernel) + mask = negpy_core.morphology_ex(mask, cv2.MORPH_CLOSE, close_kernel, iterations=2) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: @@ -315,11 +315,11 @@ def _refine_frame_bounds(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int, def _mask_from_blackhat(gray: np.ndarray) -> np.ndarray: kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)) - blackhat = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel) + blackhat = negpy_core.morphology_ex(gray, cv2.MORPH_BLACKHAT, kernel) blackhat = cv2.GaussianBlur(blackhat, (5, 5), 0) _, thresh = cv2.threshold(blackhat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 21)) - return cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) + return negpy_core.morphology_ex(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) def _mask_from_inverse_threshold(gray: np.ndarray) -> np.ndarray: @@ -327,8 +327,8 @@ def _mask_from_inverse_threshold(gray: np.ndarray) -> np.ndarray: _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17)) open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) - cleaned = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) - return cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, open_kernel, iterations=1) + cleaned = negpy_core.morphology_ex(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) + return negpy_core.morphology_ex(cleaned, cv2.MORPH_OPEN, open_kernel, iterations=1) def _mask_from_edges(gray: np.ndarray) -> np.ndarray: @@ -336,8 +336,8 @@ def _mask_from_edges(gray: np.ndarray) -> np.ndarray: edges = cv2.Canny(blurred, 40, 160) dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) - dilated = cv2.dilate(edges, dilate_kernel, iterations=2) - return cv2.morphologyEx(dilated, cv2.MORPH_CLOSE, close_kernel, iterations=2) + dilated = negpy_core.morphology_ex(edges, cv2.MORPH_DILATE, dilate_kernel, iterations=2) + return negpy_core.morphology_ex(dilated, cv2.MORPH_CLOSE, close_kernel, iterations=2) def _score_contour(contour: np.ndarray, image_area: float) -> tuple[float, np.ndarray] | None: @@ -427,8 +427,8 @@ def _adaptive_threshold_mask(blurred_lum: np.ndarray, threshold: float, polarity open_size = _odd_kernel_size(0.003 * min_dim, 3, 9) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (close_size, close_size)) open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (open_size, open_size)) - mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, close_kernel, iterations=1) - return cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel, iterations=1) + mask = negpy_core.morphology_ex(mask, cv2.MORPH_CLOSE, close_kernel, iterations=1) + return negpy_core.morphology_ex(mask, cv2.MORPH_OPEN, open_kernel, iterations=1) def _order_quad(quad: np.ndarray) -> np.ndarray: diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 88f9898ce..94fe26b59 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -300,7 +300,7 @@ def _profile_lines(lum: np.ndarray) -> list[EdgeLine]: spread = max(3, int(0.02 * max(h, w))) gy = np.abs(negpy_core.sobel(lum.astype(np.float32), 0, 1)) strong = (gy >= 0.25 * float(np.percentile(gy, 99.5))).astype(np.uint8) - strong = cv2.dilate(strong, np.ones((2 * spread + 1, 1), np.uint8)) + strong = negpy_core.morphology_ex(strong, cv2.MORPH_DILATE, np.ones((2 * spread + 1, 1), np.uint8)) rows = _outer_peaks(strong[:, int(0.05 * w) : int(0.95 * w)].mean(axis=1), 0.35) top, bottom = rows if rows is not None else (0, h) if rows is not None: @@ -311,7 +311,7 @@ def _profile_lines(lum: np.ndarray) -> list[EdgeLine]: gx = np.abs(negpy_core.sobel(lum.astype(np.float32), 1, 0)) strong = (gx >= 0.25 * float(np.percentile(gx, 99.5))).astype(np.uint8) - strong = cv2.dilate(strong, np.ones((1, 2 * spread + 1), np.uint8)) + strong = negpy_core.morphology_ex(strong, cv2.MORPH_DILATE, np.ones((1, 2 * spread + 1), np.uint8)) lo, hi = top + int(0.05 * (bottom - top)), bottom - int(0.05 * (bottom - top)) cols = _outer_peaks(strong[lo:hi].mean(axis=0), 0.35) if hi - lo > 10 else None if cols is not None: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 50b45b108..10d12238b 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -329,7 +329,7 @@ depends = [] ["features/geometry/logic.py::_adaptive_threshold_mask"] phase = 2 status = "todo" -calls = ["cv2.getStructuringElement", "cv2.morphologyEx"] +calls = ["cv2.getStructuringElement", "negpy_core.morphology_ex"] depends = [] ["features/geometry/logic.py::_border_edge_scatter"] @@ -353,7 +353,7 @@ depends = ["_candidate_from_contour"] ["features/geometry/logic.py::_dark_region_bounds"] phase = 2 status = "todo" -calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.cvtColor", "cv2.findContours", "cv2.getStructuringElement", "cv2.morphologyEx", "np.percentile"] +calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.cvtColor", "cv2.findContours", "cv2.getStructuringElement", "negpy_core.morphology_ex", "np.percentile"] depends = ["_ensure_color"] ["features/geometry/logic.py::_detection_luma"] @@ -401,19 +401,19 @@ depends = [] ["features/geometry/logic.py::_mask_from_blackhat"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.morphologyEx", "cv2.threshold"] +calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.threshold", "negpy_core.morphology_ex"] depends = [] ["features/geometry/logic.py::_mask_from_edges"] phase = 2 status = "todo" -calls = ["cv2.Canny", "cv2.GaussianBlur", "cv2.dilate", "cv2.getStructuringElement", "cv2.morphologyEx"] +calls = ["cv2.Canny", "cv2.GaussianBlur", "cv2.getStructuringElement", "negpy_core.morphology_ex"] depends = [] ["features/geometry/logic.py::_mask_from_inverse_threshold"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.morphologyEx", "cv2.threshold"] +calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.threshold", "negpy_core.morphology_ex"] depends = [] ["features/geometry/logic.py::_normalize_detection_input"] @@ -575,7 +575,7 @@ depends = ["_at_canvas_edges", "_profile_lines", "_unrotate"] ["features/geometry/skew.py::_profile_lines"] phase = 2 status = "todo" -calls = ["cv2.dilate", "negpy_core.sobel", "np.percentile"] +calls = ["negpy_core.morphology_ex", "negpy_core.sobel", "np.percentile"] depends = ["_fit_side"] ["features/geometry/skew.py::_profile_peaks"] diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs index 176d22bae..ca1c5e8d6 100644 --- a/negpy_core/src/filter.rs +++ b/negpy_core/src/filter.rs @@ -514,13 +514,9 @@ pub fn sep_filter2d( let row_pass = |padded: &mut [f32], sy: usize, out: &mut [f32]| { let row = &src[sy * n..(sy + 1) * n]; padded[rx * cn..(rx + w) * cn].copy_from_slice(row); - for (px, m) in xmap - .iter() - .enumerate() - .filter(|(px, _)| *px < rx || *px >= rx + w) - { + for px in (0..rx).chain(rx + w..w + 2 * rx) { let chunk = &mut padded[px * cn..(px + 1) * cn]; - match m { + match xmap[px] { Some(sxi) => chunk.copy_from_slice(&row[sxi * cn..(sxi + 1) * cn]), None => chunk.fill(0.0), } diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index ea2b3ff0a..dff1630ff 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,4 +1,5 @@ pub mod filter; +pub mod morph; pub mod resize; pub mod stats; @@ -10,5 +11,6 @@ mod py; fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { py::stats::register(m)?; py::resize::register(m)?; - py::filter::register(m) + py::filter::register(m)?; + py::morph::register(m) } diff --git a/negpy_core/src/morph.rs b/negpy_core/src/morph.rs new file mode 100644 index 000000000..2d10767ba --- /dev/null +++ b/negpy_core/src/morph.rs @@ -0,0 +1,313 @@ +//! OpenCV's erode, dilate and morphologyEx for rectangular kernels. A min or max has no +//! rounding, so a separable row-then-column pass gives OpenCV's bits. + +use rayon::prelude::*; + +use crate::filter::{Border, border_interpolate}; + +pub trait Pixel: Copy + PartialOrd + Send + Sync { + /// Erode's identity, OpenCV's default constant border for it. + const HIGH: Self; + /// Dilate's identity. + const LOW: Self; + fn sub(self, other: Self) -> Self; + fn lo(self, other: Self) -> Self; + fn hi(self, other: Self) -> Self; +} + +impl Pixel for u8 { + const HIGH: Self = u8::MAX; + const LOW: Self = u8::MIN; + fn sub(self, other: Self) -> Self { + self.saturating_sub(other) + } + fn lo(self, other: Self) -> Self { + self.min(other) + } + fn hi(self, other: Self) -> Self { + self.max(other) + } +} + +impl Pixel for f32 { + const HIGH: Self = f32::INFINITY; + const LOW: Self = f32::NEG_INFINITY; + fn sub(self, other: Self) -> Self { + self - other + } + fn lo(self, other: Self) -> Self { + if other < self { other } else { self } + } + fn hi(self, other: Self) -> Self { + if other > self { other } else { self } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Op { + Erode, + Dilate, + Open, + Close, + Gradient, + TopHat, + BlackHat, +} + +impl Op { + /// From `cv2.MORPH_*`. + pub fn from_cv(code: i32) -> Option { + Some(match code { + 0 => Op::Erode, + 1 => Op::Dilate, + 2 => Op::Open, + 3 => Op::Close, + 4 => Op::Gradient, + 5 => Op::TopHat, + 6 => Op::BlackHat, + _ => return None, + }) + } +} + +#[inline(always)] +fn pick(a: T, b: T) -> T { + if ERODE { a.lo(b) } else { a.hi(b) } +} + +/// `out[i] = min (or max) of src[i + j * stride]` for `j < len`, from overlapping power-of-two +/// windows: a min or max is idempotent, so the overlap changes nothing and a window of +/// `len` costs log2(len) vector passes. +#[inline(always)] +fn window( + cur: &mut Vec, + scratch: &mut Vec, + out: &mut [T], + len: usize, + stride: usize, +) { + let mut win = 1; + while win * 2 <= len { + let off = win * stride; + let valid = cur.len() - off; + scratch.resize(valid, cur[0]); + for ((o, &x), &y) in scratch.iter_mut().zip(&cur[..valid]).zip(&cur[off..]) { + *o = pick::(x, y); + } + std::mem::swap(cur, scratch); + win *= 2; + } + let off = (len - win) * stride; + for ((o, &x), &y) in out.iter_mut().zip(cur.iter()).zip(&cur[off..]) { + *o = pick::(x, y); + } +} + +/// One erode (`ERODE`) or dilate pass with a `kw` x `kh` rectangle anchored at `(ax, ay)`. +fn rect_pass( + src: &[T], + dst: &mut [T], + (h, w, cn): (usize, usize, usize), + (kw, kh): (usize, usize), + (ax, ay): (usize, usize), + border: Border, +) { + let n = w * cn; + let identity = if ERODE { T::HIGH } else { T::LOW }; + let xmap: Vec> = (0..w + kw - 1) + .map(|px| border_interpolate(px as isize - ax as isize, w, border)) + .collect(); + + let mut rows = vec![identity; h * n]; + rows.par_chunks_mut(n).enumerate().for_each_init( + || (Vec::new(), Vec::new()), + |(padded, scratch): &mut (Vec, Vec), (y, out)| { + padded.resize((w + kw - 1) * cn, identity); + let row = &src[y * n..(y + 1) * n]; + padded[ax * cn..(ax + w) * cn].copy_from_slice(row); + for px in (0..ax).chain(ax + w..w + kw - 1) { + let chunk = &mut padded[px * cn..(px + 1) * cn]; + match xmap[px] { + Some(sx) => chunk.copy_from_slice(&row[sx * cn..(sx + 1) * cn]), + None => chunk.fill(identity), + } + } + window::(padded, scratch, out, kw, cn); + }, + ); + + // The column pass doubles whole rows: row t of `a` holds the extreme of rows t..t+win. + let total = h + kh - 1; + let mut a = vec![identity; total * n]; + a.par_chunks_mut(n).enumerate().for_each(|(t, line)| { + if let Some(sy) = border_interpolate(t as isize - ay as isize, h, border) { + line.copy_from_slice(&rows[sy * n..(sy + 1) * n]); + } + }); + let mut b = vec![identity; total * n]; + let mut win = 1; + while win * 2 <= kh { + let valid = total - win * 2 + 1; + b[..valid * n] + .par_chunks_mut(n) + .enumerate() + .for_each(|(t, line)| { + let (x, y) = (&a[t * n..(t + 1) * n], &a[(t + win) * n..(t + win + 1) * n]); + for ((o, &p), &q) in line.iter_mut().zip(x).zip(y) { + *o = pick::(p, q); + } + }); + std::mem::swap(&mut a, &mut b); + win *= 2; + } + let off = kh - win; + dst.par_chunks_mut(n).enumerate().for_each(|(y, line)| { + let (x, z) = (&a[y * n..(y + 1) * n], &a[(y + off) * n..(y + off + 1) * n]); + for ((o, &p), &q) in line.iter_mut().zip(x).zip(z) { + *o = pick::(p, q); + } + }); +} + +/// `cv2.erode`/`cv2.dilate` with a full `kw` x `kh` rectangle and `iterations`: OpenCV turns +/// iterations of a rectangle into one larger rectangle with the anchor scaled. +fn erode_dilate( + src: &[T], + dst: &mut [T], + dims: (usize, usize, usize), + ksize: (usize, usize), + iterations: usize, + erode: bool, + border: Border, +) { + let (kw, kh) = ksize; + let (mut ax, mut ay) = (kw / 2, kh / 2); + let (mut kw, mut kh) = (kw, kh); + if iterations == 0 || (kw == 1 && kh == 1) { + dst.copy_from_slice(src); + return; + } + if iterations > 1 { + ax *= iterations; + ay *= iterations; + kw += (iterations - 1) * (kw - 1); + kh += (iterations - 1) * (kh - 1); + } + if erode { + rect_pass::(src, dst, dims, (kw, kh), (ax, ay), border); + } else { + rect_pass::(src, dst, dims, (kw, kh), (ax, ay), border); + } +} + +/// `cv2.morphologyEx(src, op, ones((kh, kw)), iterations=..., borderType=...)`, the border value +/// OpenCV's default. +pub fn morphology( + src: &[T], + dst: &mut [T], + dims: (usize, usize, usize), + op: Op, + ksize: (usize, usize), + iterations: usize, + border: Border, +) -> Option<()> { + let (h, w, cn) = dims; + if h == 0 + || w == 0 + || cn == 0 + || ksize.0 == 0 + || ksize.1 == 0 + || src.len() != h * w * cn + || dst.len() != src.len() + { + return None; + } + let ed = |s: &[T], d: &mut [T], erode: bool| { + erode_dilate(s, d, dims, ksize, iterations, erode, border) + }; + let mut tmp = vec![src[0]; src.len()]; + match op { + Op::Erode => ed(src, dst, true), + Op::Dilate => ed(src, dst, false), + Op::Open => { + ed(src, &mut tmp, true); + ed(&tmp, dst, false); + } + Op::Close => { + ed(src, &mut tmp, false); + ed(&tmp, dst, true); + } + Op::Gradient => { + ed(src, &mut tmp, false); + ed(src, dst, true); + dst.par_iter_mut() + .zip(tmp.par_iter()) + .for_each(|(d, &t)| *d = t.sub(*d)); + } + Op::TopHat => { + ed(src, &mut tmp, true); + ed(&tmp, dst, false); + dst.par_iter_mut() + .zip(src.par_iter()) + .for_each(|(d, &s)| *d = s.sub(*d)); + } + Op::BlackHat => { + ed(src, &mut tmp, false); + ed(&tmp, dst, true); + dst.par_iter_mut() + .zip(src.par_iter()) + .for_each(|(d, &s)| *d = d.sub(s)); + } + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erode_takes_the_window_minimum() { + let src: Vec = vec![5, 9, 1, 7, 3, 8]; + let mut dst = vec![0u8; 6]; + morphology( + &src, + &mut dst, + (1, 6, 1), + Op::Erode, + (3, 1), + 1, + Border::Constant, + ) + .unwrap(); + assert_eq!(dst, vec![5, 1, 1, 1, 3, 3]); + morphology( + &src, + &mut dst, + (1, 6, 1), + Op::Dilate, + (3, 1), + 1, + Border::Constant, + ) + .unwrap(); + assert_eq!(dst, vec![9, 9, 9, 7, 8, 8]); + } + + #[test] + fn blackhat_of_a_flat_image_is_zero() { + let src = vec![0.5f32; 7 * 9]; + let mut dst = vec![1f32; 7 * 9]; + morphology( + &src, + &mut dst, + (7, 9, 1), + Op::BlackHat, + (3, 3), + 2, + Border::Constant, + ) + .unwrap(); + assert!(dst.iter().all(|&v| v == 0.0)); + } +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index ba53e80b1..928256184 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,3 +1,4 @@ pub mod filter; +pub mod morph; pub mod resize; pub mod stats; diff --git a/negpy_core/src/py/morph.rs b/negpy_core/src/py/morph.rs new file mode 100644 index 000000000..fb9b6ee1a --- /dev/null +++ b/negpy_core/src/py/morph.rs @@ -0,0 +1,82 @@ +use numpy::{ + Element, PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; + +use crate::filter::Border; +use crate::morph::{self, Op, Pixel}; + +fn run<'py, T: Pixel + Element>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + op: Op, + ksize: (usize, usize), + iterations: usize, + border: Border, + dtype: &str, +) -> PyResult> { + let src: PyReadonlyArrayDyn<'py, T> = src.extract()?; + let shape = src.shape().to_vec(); + let dims = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs a 2-D or 3-D array")), + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.clone(), dtype))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| morph::morphology(data, dst, dims, op, ksize, iterations, border)) + .ok_or_else(|| PyValueError::new_err("invalid morphology input"))?; + } + Ok(out.into_any()) +} + +/// `cv2.morphologyEx(src, op, kernel, iterations=..., borderType=...)` for a uint8 or float32 +/// image and a kernel of all ones, with OpenCV's default anchor and border value. +#[pyfunction] +#[pyo3(signature = (src, op, kernel, iterations=1, border_type=0))] +fn morphology_ex<'py>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + op: i32, + kernel: PyReadonlyArray2<'py, u8>, + iterations: usize, + border_type: i32, +) -> PyResult> { + let op = + Op::from_cv(op).ok_or_else(|| PyValueError::new_err(format!("unsupported op {op}")))?; + let border = Border::from_cv(border_type) + .ok_or_else(|| PyValueError::new_err(format!("unsupported border type {border_type}")))?; + let k = kernel.as_array(); + if k.iter().any(|&v| v == 0) { + return Err(PyValueError::new_err( + "negpy_core morphology covers rectangular kernels only", + )); + } + let ksize = (k.ncols(), k.nrows()); + if src.extract::>().is_ok() { + run::(py, src, op, ksize, iterations, border, "uint8") + } else if src.extract::>().is_ok() { + run::(py, src, op, ksize, iterations, border, "float32") + } else { + Err(PyTypeError::new_err( + "morphology needs a uint8 or float32 array", + )) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(morphology_ex, m)?) +} diff --git a/tests/test_core_morph.py b/tests/test_core_morph.py new file mode 100644 index 000000000..544379067 --- /dev/null +++ b/tests/test_core_morph.py @@ -0,0 +1,45 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(12) +_OPS = [cv2.MORPH_ERODE, cv2.MORPH_DILATE, cv2.MORPH_OPEN, cv2.MORPH_CLOSE, cv2.MORPH_GRADIENT, cv2.MORPH_TOPHAT, cv2.MORPH_BLACKHAT] +_SHAPES = [(41, 83, 1), (37, 53, 3), (1, 301, 1), (300, 1, 1), (13, 9, 1)] + + +def _image(dtype, h, w, cn): + shape = (h, w, cn) if cn > 1 else (h, w) + if dtype == np.uint8: + return _RNG.integers(0, 256, shape).astype(np.uint8) + return (_RNG.random(shape, dtype=np.float32) - 0.3) * 3 + + +@pytest.mark.parametrize("dtype", [np.uint8, np.float32]) +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("op", _OPS) +@pytest.mark.parametrize("ksize, iterations", [((31, 31), 2), ((9, 9), 1), ((5, 5), 1), ((1, 7), 2), ((4, 6), 1), ((1, 1), 1)]) +def test_morphology_matches_cv2_bit_for_bit(dtype, shape, op, ksize, iterations): + img = _image(dtype, *shape) + kernel = np.ones((ksize[1], ksize[0]), np.uint8) + want = cv2.morphologyEx(img, op, kernel, iterations=iterations) + got = negpy_core.morphology_ex(img, op, kernel, iterations) + assert got.dtype == want.dtype + assert got.tobytes() == want.tobytes() + + +@pytest.mark.parametrize("border", [cv2.BORDER_REPLICATE, cv2.BORDER_REFLECT_101]) +@pytest.mark.parametrize("op", [cv2.MORPH_ERODE, cv2.MORPH_DILATE]) +def test_erode_dilate_borders_match_cv2(border, op): + img = _image(np.float32, 37, 53, 3) + kernel = np.ones((7, 5), np.uint8) + want = cv2.morphologyEx(img, op, kernel, iterations=2, borderType=border) + assert negpy_core.morphology_ex(img, op, kernel, 2, border).tobytes() == want.tobytes() + + +def test_morphology_rejects_a_non_rectangular_kernel(): + with pytest.raises(ValueError): + negpy_core.morphology_ex(_image(np.uint8, 9, 9, 1), cv2.MORPH_ERODE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 106243a68..e78380382 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -19,6 +19,7 @@ "gaussian_sigma2_replicate": lambda: negpy_core.gaussian_blur(_IMG1, (0, 0), 2.0, 0.0, 1), "gaussian_kernel_17": lambda: np.array(negpy_core.gaussian_kernel(17, 2.0), np.float32), "sobel_x": lambda: negpy_core.sep_filter2d(_IMG1, np.array([-1, 0, 1], np.float32), np.array([1, 2, 1], np.float32)), + "morph_close_u8": lambda: negpy_core.morphology_ex((_IMG1 > 0.5).astype(np.uint8) * 255, 3, np.ones((9, 7), np.uint8), 2), } _HASHES = { @@ -29,6 +30,7 @@ "gaussian_sigma2_replicate": "2b9be6f95b7f66a03493795d8c7a5e1bb2565a28087578f1981a9cd329c8a74f", "gaussian_kernel_17": "5c2eb92d43931221106d80ca89a0b6dea1dca09b261b0fbf6ff51fdcae748d8b", "sobel_x": "ba798e861db64c102fee6b08fd7aa34d488cce431ae0d95fc3e7836e1dedb44a", + "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", } From 609c06b0e20f322177cf8ebbf438021ccc8a0262 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:33:41 +0200 Subject: [PATCH 015/133] feat(core): uint8 gray, GaussianBlur, Otsu and Canny in Rust negpy_core.bgr_to_gray (15-bit weights, checked over all 2^24 colours), gaussian_blur_u8 (OpenCV's fixed-point kernel with error diffusion, integer rows and columns, one rounding), threshold_otsu (getThreshVal_Otsu's double arithmetic; IPP agrees with it) and canny (16-bit Sobel, L1 magnitude, TG22 suppression, hysteresis). All integer arithmetic, so all bit-exact: test_core_gray, 84 cases. cv2's parallel uint8 5x5 GaussianBlur is not deterministic: repeated calls on one image can return different bytes, so autocrop detection could differ between runs of the same frame. negpy_core matches cv2 on one thread, and the oracle pins cv2 to one thread. The phase 2 detection masks use these; replay over 33 frames gives 40/40 functions identical. --- .github/workflows/ci.yml | 2 +- negpy/features/geometry/logic.py | 24 +-- negpy_core/manifest.toml | 26 ++- negpy_core/src/filter.rs | 14 +- negpy_core/src/gray.rs | 293 +++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/gray.rs | 134 ++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_gray.py | 68 +++++++ tests/test_core_same_bits.py | 9 + 10 files changed, 542 insertions(+), 33 deletions(-) create mode 100644 negpy_core/src/gray.rs create mode 100644 negpy_core/src/py/gray.rs create mode 100644 tests/test_core_gray.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9084d9cf4..ba5192fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index a5f96024a..f2901c5f3 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -144,7 +144,7 @@ def _normalize_to_uint8(image: np.ndarray) -> np.ndarray: def _ensure_color(image: np.ndarray) -> np.ndarray: if image.ndim == 2: - return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + return np.repeat(image[..., None], 3, axis=2) return image @@ -194,7 +194,7 @@ def _boundary_candidates(signal: np.ndarray, *, from_start: bool) -> tuple[int, def _dark_region_bounds(image: np.ndarray) -> tuple[int, int, int, int] | None: preview = _normalize_to_uint8(_ensure_color(image)) - gray = cv2.cvtColor(preview, cv2.COLOR_BGR2GRAY) + gray = negpy_core.bgr_to_gray(preview) threshold = float(np.percentile(gray, 55)) mask = (gray <= threshold).astype(np.uint8) * 255 @@ -236,10 +236,10 @@ def _dark_region_bounds(image: np.ndarray) -> tuple[int, int, int, int] | None: def _refine_frame_bounds(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, int]]: preview = _normalize_to_uint8(_ensure_color(image)) - gray = cv2.cvtColor(preview, cv2.COLOR_BGR2GRAY) + gray = negpy_core.bgr_to_gray(preview) - grad_x = cv2.convertScaleAbs(cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)) - grad_y = cv2.convertScaleAbs(cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)) + grad_x = np.minimum(np.abs(negpy_core.sobel(gray.astype(np.float32), 1, 0)), 255).astype(np.uint8) + grad_y = np.minimum(np.abs(negpy_core.sobel(gray.astype(np.float32), 0, 1)), 255).astype(np.uint8) col_signal = _smooth_signal(np.percentile(grad_x, 95, axis=0), 31) row_signal = _smooth_signal(np.percentile(grad_y, 95, axis=1), 31) @@ -316,15 +316,15 @@ def _refine_frame_bounds(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int, def _mask_from_blackhat(gray: np.ndarray) -> np.ndarray: kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)) blackhat = negpy_core.morphology_ex(gray, cv2.MORPH_BLACKHAT, kernel) - blackhat = cv2.GaussianBlur(blackhat, (5, 5), 0) - _, thresh = cv2.threshold(blackhat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + blackhat = negpy_core.gaussian_blur_u8(blackhat, (5, 5), 0) + _, thresh = negpy_core.threshold_otsu(blackhat) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 21)) return negpy_core.morphology_ex(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) def _mask_from_inverse_threshold(gray: np.ndarray) -> np.ndarray: - blurred = cv2.GaussianBlur(gray, (7, 7), 0) - _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) + blurred = negpy_core.gaussian_blur_u8(gray, (7, 7), 0) + _, thresh = negpy_core.threshold_otsu(blurred, 255, True) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17)) open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) cleaned = negpy_core.morphology_ex(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) @@ -332,8 +332,8 @@ def _mask_from_inverse_threshold(gray: np.ndarray) -> np.ndarray: def _mask_from_edges(gray: np.ndarray) -> np.ndarray: - blurred = cv2.GaussianBlur(gray, (5, 5), 0) - edges = cv2.Canny(blurred, 40, 160) + blurred = negpy_core.gaussian_blur_u8(gray, (5, 5), 0) + edges = negpy_core.canny(blurred, 40, 160) dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)) close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) dilated = negpy_core.morphology_ex(edges, cv2.MORPH_DILATE, dilate_kernel, iterations=2) @@ -845,7 +845,7 @@ def detect_film_bounds_with_confidence(img: ImageBuffer) -> AutocropDetection: color = _ensure_color(img) preview = _normalize_to_uint8(color) - gray = cv2.cvtColor(preview, cv2.COLOR_BGR2GRAY) + gray = negpy_core.bgr_to_gray(preview) structural_masks = ( ("inverse-threshold", _mask_from_inverse_threshold(gray)), ("blackhat", _mask_from_blackhat(gray)), diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 10d12238b..fd7149003 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -353,14 +353,14 @@ depends = ["_candidate_from_contour"] ["features/geometry/logic.py::_dark_region_bounds"] phase = 2 status = "todo" -calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.cvtColor", "cv2.findContours", "cv2.getStructuringElement", "negpy_core.morphology_ex", "np.percentile"] -depends = ["_ensure_color"] +calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.findContours", "cv2.getStructuringElement", "negpy_core.bgr_to_gray", "negpy_core.morphology_ex", "np.percentile"] +depends = [] ["features/geometry/logic.py::_detection_luma"] phase = 2 status = "todo" calls = ["np.percentile"] -depends = ["_ensure_color"] +depends = [] ["features/geometry/logic.py::_edge_border_depth"] phase = 2 @@ -368,12 +368,6 @@ status = "todo" calls = ["np.median"] depends = ["_border_edge_scatter", "_pool_border_depth", "_walk_border_depth"] -["features/geometry/logic.py::_ensure_color"] -phase = 2 -status = "todo" -calls = ["cv2.cvtColor"] -depends = [] - ["features/geometry/logic.py::_estimate_tier_levels"] phase = 2 status = "todo" @@ -401,19 +395,19 @@ depends = [] ["features/geometry/logic.py::_mask_from_blackhat"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.threshold", "negpy_core.morphology_ex"] +calls = ["cv2.getStructuringElement", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex", "negpy_core.threshold_otsu"] depends = [] ["features/geometry/logic.py::_mask_from_edges"] phase = 2 status = "todo" -calls = ["cv2.Canny", "cv2.GaussianBlur", "cv2.getStructuringElement", "negpy_core.morphology_ex"] +calls = ["cv2.getStructuringElement", "negpy_core.canny", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex"] depends = [] ["features/geometry/logic.py::_mask_from_inverse_threshold"] phase = 2 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.getStructuringElement", "cv2.threshold", "negpy_core.morphology_ex"] +calls = ["cv2.getStructuringElement", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex", "negpy_core.threshold_otsu"] depends = [] ["features/geometry/logic.py::_normalize_detection_input"] @@ -455,8 +449,8 @@ depends = [] ["features/geometry/logic.py::_refine_frame_bounds"] phase = 2 status = "todo" -calls = ["cv2.Sobel", "cv2.convertScaleAbs", "cv2.cvtColor", "np.percentile"] -depends = ["_dark_region_bounds", "_ensure_color"] +calls = ["negpy_core.bgr_to_gray", "negpy_core.sobel", "np.percentile"] +depends = ["_dark_region_bounds"] ["features/geometry/logic.py::_remap_tiled"] phase = 2 @@ -527,8 +521,8 @@ depends = [] ["features/geometry/logic.py::detect_film_bounds_with_confidence"] phase = 2 status = "todo" -calls = ["cv2.cvtColor", "negpy_core.gaussian_blur", "np.median", "np.percentile"] -depends = ["_adaptive_threshold_levels", "_adaptive_threshold_mask", "_candidates_from_mask", "_detection_luma", "_ensure_color", "_film_surround_is_plausible", "_mask_from_blackhat", "_mask_from_edges", "_mask_from_inverse_threshold", "_threshold_polarities", "_vertical_edge_profile"] +calls = ["negpy_core.bgr_to_gray", "negpy_core.gaussian_blur", "np.median", "np.percentile"] +depends = ["_adaptive_threshold_levels", "_adaptive_threshold_mask", "_candidates_from_mask", "_detection_luma", "_film_surround_is_plausible", "_mask_from_blackhat", "_mask_from_edges", "_mask_from_inverse_threshold", "_threshold_polarities", "_vertical_edge_profile"] ["features/geometry/logic.py::keystone_matrix_normalized"] phase = 2 diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs index ca1c5e8d6..b0e365405 100644 --- a/negpy_core/src/filter.rs +++ b/negpy_core/src/filter.rs @@ -88,8 +88,16 @@ fn soft_exp(x: f64) -> f64 { * (((((a0 * x0 + a1) * x0 + a2) * x0 + a3) * x0 + a4) * x0 + a5) } -/// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)` (`getGaussianKernelBitExact`). +/// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)`. pub fn gaussian_kernel(n: usize, sigma: f64) -> Vec { + gaussian_kernel_f64(n, sigma) + .iter() + .map(|&v| v as f32) + .collect() +} + +/// `getGaussianKernelBitExact`, in its softdouble (here f64) values. +pub fn gaussian_kernel_f64(n: usize, sigma: f64) -> Vec { if sigma <= 0.0 { let fixed: Option<&[f64]> = match n { 1 => Some(&[1.0]), @@ -112,7 +120,7 @@ pub fn gaussian_kernel(n: usize, sigma: f64) -> Vec { _ => None, }; if let Some(k) = fixed { - return k.iter().map(|&v| v as f32).collect(); + return k.to_vec(); } } let sigma_x = if sigma > 0.0 { @@ -150,7 +158,7 @@ pub fn gaussian_kernel(n: usize, sigma: f64) -> Vec { if n.is_multiple_of(2) { result[n2 + 1] = result[n2]; } - result.iter().map(|&v| v as f32).collect() + result } /// `createGaussianKernels`' size from sigma for a float image. diff --git a/negpy_core/src/gray.rs b/negpy_core/src/gray.rs new file mode 100644 index 000000000..c6b853583 --- /dev/null +++ b/negpy_core/src/gray.rs @@ -0,0 +1,293 @@ +//! OpenCV's uint8 grayscale operations, all integer arithmetic: BGR to gray, the fixed-point +//! GaussianBlur, Otsu's threshold and Canny. + +use rayon::prelude::*; + +use crate::filter::{Border, border_interpolate, gaussian_kernel_f64}; + +/// `cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)`: 15-bit fixed-point weights, rounded. +pub fn bgr_to_gray(src: &[u8], dst: &mut [u8]) { + dst.par_iter_mut() + .zip(src.par_chunks_exact(3)) + .for_each(|(d, p)| { + *d = ((p[0] as u32 * 3735 + p[1] as u32 * 19235 + p[2] as u32 * 9798 + (1 << 14)) >> 15) + as u8; + }); +} + +/// `getGaussianKernelFixedPoint_ED`: the bit-exact kernel in 8 fractional bits, rounding errors +/// carried along the half kernel and the centre taking what makes the sum exactly 256. +pub fn gaussian_kernel_u8(n: usize, sigma: f64) -> Vec { + let k = gaussian_kernel_f64(n, sigma); + let mut out = vec![0u32; n]; + let mut err = 0.0f64; + let mut sum = 0i64; + for i in 0..n / 2 { + let adj = k[i] * 256.0 + err; + let v = adj.round_ties_even() as i64; + err = adj - v as f64; + out[i] = v as u32; + out[n - 1 - i] = v as u32; + sum += v; + } + out[n / 2] = (256 - 2 * sum) as u32; + out +} + +/// `cv2.GaussianBlur` for uint8 (`GaussianBlurFixedPoint`): integer rows, then integer columns, +/// rounded once from 16 fractional bits. +pub fn gaussian_blur_u8( + src: &[u8], + dst: &mut [u8], + (h, w, cn): (usize, usize, usize), + ksize: (usize, usize), + (sigma_x, sigma_y): (f64, f64), + border: Border, +) -> Option<()> { + if h == 0 || w == 0 || cn == 0 || src.len() != h * w * cn || dst.len() != src.len() { + return None; + } + let (mut kw, mut kh) = ksize; + let sigma_y = if sigma_y <= 0.0 { sigma_x } else { sigma_y }; + let size_of = |s: f64| ((s * 3.0 * 2.0 + 1.0).round_ties_even() as i64 | 1) as usize; + if kw == 0 && sigma_x > 0.0 { + kw = size_of(sigma_x); + } + if kh == 0 && sigma_y > 0.0 { + kh = size_of(sigma_y); + } + if kw.is_multiple_of(2) || kh.is_multiple_of(2) { + return None; + } + if border != Border::Constant { + if h == 1 { + kh = 1; + } + if w == 1 { + kw = 1; + } + } + if kw == 1 && kh == 1 { + dst.copy_from_slice(src); + return Some(()); + } + let kx = gaussian_kernel_u8(kw, sigma_x.max(0.0)); + let ky = if kh == kw && (sigma_x - sigma_y).abs() < f64::EPSILON { + kx.clone() + } else { + gaussian_kernel_u8(kh, sigma_y.max(0.0)) + }; + let (rx, ry) = (kw / 2, kh / 2); + let n = w * cn; + let xmap: Vec> = (0..w + 2 * rx) + .map(|px| border_interpolate(px as isize - rx as isize, w, border)) + .collect(); + + let mut rows = vec![0u32; h * n]; + rows.par_chunks_mut(n).enumerate().for_each(|(y, out)| { + let row = &src[y * n..(y + 1) * n]; + let px = |p: usize, c: usize| xmap[p].map_or(0, |sx| row[sx * cn + c] as u32); + for x in 0..w { + for c in 0..cn { + out[x * cn + c] = (0..kw).map(|j| kx[j] * px(x + j, c)).sum(); + } + } + }); + dst.par_chunks_mut(n).enumerate().for_each(|(y, out)| { + let lines: Vec> = (0..kh) + .map(|j| { + border_interpolate(y as isize + j as isize - ry as isize, h, border) + .map(|sy| &rows[sy * n..(sy + 1) * n]) + }) + .collect(); + for (i, o) in out.iter_mut().enumerate() { + let v: u32 = lines + .iter() + .zip(&ky) + .map(|(l, &k)| l.map_or(0, |l| k * l[i])) + .sum(); + *o = ((v + (1 << 15)) >> 16).min(255) as u8; + } + }); + Some(()) +} + +/// Otsu's threshold for uint8 (`getThreshVal_Otsu`), in its double arithmetic and first-max rule. +pub fn otsu_threshold(src: &[u8]) -> f64 { + let mut hist = [0u64; 256]; + for &v in src { + hist[v as usize] += 1; + } + let scale = 1.0 / src.len() as f64; + let mut mu = 0.0f64; + for (i, &c) in hist.iter().enumerate() { + mu += i as f64 * c as f64; + } + mu *= scale; + let (mut mu1, mut q1, mut max_sigma, mut max_val) = (0.0f64, 0.0f64, 0.0f64, 0.0f64); + let eps = f32::EPSILON as f64; + for (i, &c) in hist.iter().enumerate() { + let p = c as f64 * scale; + mu1 *= q1; + q1 += p; + let q2 = 1.0 - q1; + if q1.min(q2) < eps || q1.max(q2) > 1.0 - eps { + continue; + } + mu1 = (mu1 + i as f64 * p) / q1; + let mu2 = (mu - q1 * mu1) / q2; + let sigma = q1 * q2 * (mu1 - mu2) * (mu1 - mu2); + if sigma > max_sigma { + max_sigma = sigma; + max_val = i as f64; + } + } + max_val +} + +/// `cv2.threshold` with THRESH_BINARY (`invert` false) or THRESH_BINARY_INV on uint8. +pub fn threshold_binary(src: &[u8], dst: &mut [u8], thresh: f64, maxval: u8, invert: bool) { + // OpenCV compares against cvFloor(thresh) for uint8. + let t = thresh.floor().clamp(-1.0, 255.0) as i32; + dst.par_iter_mut().zip(src.par_iter()).for_each(|(d, &s)| { + *d = if (s as i32 > t) != invert { maxval } else { 0 }; + }); +} + +/// `cv2.Canny(src, low, high)` for a single-channel uint8 image, aperture 3, L1 gradient. +pub fn canny(src: &[u8], dst: &mut [u8], h: usize, w: usize, low: f64, high: f64) -> Option<()> { + if h == 0 || w == 0 || src.len() != h * w || dst.len() != src.len() { + return None; + } + let (low, high) = if low > high { (high, low) } else { (low, high) }; + let (low, high) = (low.floor() as i32, high.floor() as i32); + let at = |y: isize, x: isize| { + src[y.clamp(0, h as isize - 1) as usize * w + x.clamp(0, w as isize - 1) as usize] as i32 + }; + + // Sobel 3x3 in 16-bit integers with a replicated border, then |dx| + |dy|. + let mut dx = vec![0i32; h * w]; + let mut dy = vec![0i32; h * w]; + dx.par_chunks_mut(w) + .zip(dy.par_chunks_mut(w)) + .enumerate() + .for_each(|(y, (rx, ry))| { + let y = y as isize; + for x in 0..w { + let xi = x as isize; + let col = |c: isize| at(y - 1, c) + 2 * at(y, c) + at(y + 1, c); + let row = |r: isize| at(r, xi - 1) + 2 * at(r, xi) + at(r, xi + 1); + rx[x] = col(xi + 1) - col(xi - 1); + ry[x] = row(y + 1) - row(y - 1); + } + }); + let mag = |y: isize, x: isize| -> i32 { + if y < 0 || y >= h as isize || x < 0 || x >= w as isize { + 0 + } else { + let i = y as usize * w + x as usize; + dx[i].abs() + dy[i].abs() + } + }; + + // Map: 0 may be an edge, 1 cannot, 2 is one. A one-pixel frame of 1 surrounds the image. + const TG22: i64 = 13573; + let mw = w + 2; + let mut map = vec![1u8; (h + 2) * mw]; + map.par_chunks_mut(mw) + .enumerate() + .skip(1) + .take(h) + .for_each(|(my, line)| { + let y = my as isize - 1; + for x in 0..w { + let xi = x as isize; + let m = mag(y, xi); + let mut v = 1u8; + if m > low { + let i = y as usize * w + x; + let (xs, ys) = (dx[i], dy[i]); + let ax = xs.abs() as i64; + let ay = (ys.abs() as i64) << 15; + let tg22x = ax * TG22; + let peak = if ay < tg22x { + m > mag(y, xi - 1) && m >= mag(y, xi + 1) + } else if ay > tg22x + (ax << 16) { + m > mag(y - 1, xi) && m >= mag(y + 1, xi) + } else { + let s: isize = if (xs ^ ys) < 0 { -1 } else { 1 }; + m > mag(y - 1, xi - s) && m > mag(y + 1, xi + s) + }; + if peak { + v = if m > high { 2 } else { 0 }; + } + } + line[x + 1] = v; + } + }); + + // Hysteresis: grow every edge pixel into 8-connected candidates. + let mut stack: Vec = map + .iter() + .enumerate() + .filter(|(_, v)| **v == 2) + .map(|(i, _)| i) + .collect(); + while let Some(i) = stack.pop() { + for o in [ + i - mw - 1, + i - mw, + i - mw + 1, + i - 1, + i + 1, + i + mw - 1, + i + mw, + i + mw + 1, + ] { + if map[o] == 0 { + map[o] = 2; + stack.push(o); + } + } + } + dst.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let line = &map[(y + 1) * mw + 1..(y + 1) * mw + 1 + w]; + for (o, &v) in out.iter_mut().zip(line) { + *o = if v == 2 { 255 } else { 0 }; + } + }); + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_point_kernels_sum_to_256() { + for (n, s) in [(5, 0.0), (7, 0.0), (11, 0.0), (13, 2.0), (25, 4.0)] { + assert_eq!(gaussian_kernel_u8(n, s).iter().sum::(), 256, "n={n}"); + } + assert_eq!(gaussian_kernel_u8(5, 0.0), vec![16, 64, 96, 64, 16]); + } + + #[test] + fn otsu_splits_two_levels() { + let mut img = vec![20u8; 500]; + img.extend(vec![200u8; 500]); + let t = otsu_threshold(&img); + assert!((20.0..200.0).contains(&t)); + } + + #[test] + fn canny_finds_a_step() { + let (h, w) = (20, 20); + let src: Vec = (0..h * w) + .map(|i| if i % w < 10 { 0 } else { 255 }) + .collect(); + let mut dst = vec![0u8; h * w]; + canny(&src, &mut dst, h, w, 40.0, 160.0).unwrap(); + assert!(dst.iter().any(|&v| v == 255)); + assert!((0..h).all(|y| dst[y * w] == 0)); + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index dff1630ff..84c0dc39e 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,4 +1,5 @@ pub mod filter; +pub mod gray; pub mod morph; pub mod resize; pub mod stats; @@ -12,5 +13,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::stats::register(m)?; py::resize::register(m)?; py::filter::register(m)?; - py::morph::register(m) + py::morph::register(m)?; + py::gray::register(m) } diff --git a/negpy_core/src/py/gray.rs b/negpy_core/src/py/gray.rs new file mode 100644 index 000000000..c7c3c4d52 --- /dev/null +++ b/negpy_core/src/py/gray.rs @@ -0,0 +1,134 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::filter::Border; +use crate::gray; + +fn empty_u8<'py>(py: Python<'py>, shape: &[usize]) -> PyResult>> { + Ok(py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "uint8"))? + .cast_into::>()?) +} + +fn contiguous<'a>(src: &'a PyReadonlyArrayDyn<'_, u8>) -> PyResult<&'a [u8]> { + src.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)` for an HxWx3 uint8 image. +#[pyfunction] +fn bgr_to_gray<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, +) -> PyResult>> { + let shape = src.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err("bgr_to_gray needs an HxWx3 array")); + } + let data = contiguous(&src)?; + let out = empty_u8(py, &shape[..2])?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| gray::bgr_to_gray(data, dst)); + } + Ok(out) +} + +/// `cv2.GaussianBlur(src, ksize, sigma_x, sigmaY=sigma_y, borderType=border)` for uint8. +#[pyfunction] +#[pyo3(signature = (src, ksize, sigma_x, sigma_y=0.0, border_type=4))] +fn gaussian_blur_u8<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + ksize: (usize, usize), + sigma_x: f64, + sigma_y: f64, + border_type: i32, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let dims = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs a 2-D or 3-D array")), + }; + let border = Border::from_cv(border_type) + .ok_or_else(|| PyValueError::new_err(format!("unsupported border type {border_type}")))?; + let data = contiguous(&src)?; + let out = empty_u8(py, &shape)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border)) + .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; + } + Ok(out) +} + +/// `cv2.threshold(src, 0, maxval, THRESH_BINARY[_INV] + THRESH_OTSU)` for uint8: (threshold, mask). +#[pyfunction] +#[pyo3(signature = (src, maxval=255, invert=false))] +fn threshold_otsu<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + maxval: u8, + invert: bool, +) -> PyResult<(f64, Bound<'py, PyArrayDyn>)> { + let shape = src.shape().to_vec(); + let data = contiguous(&src)?; + if data.is_empty() { + return Err(PyValueError::new_err("threshold of an empty array")); + } + let out = empty_u8(py, &shape)?; + let t = { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| { + let t = gray::otsu_threshold(data); + gray::threshold_binary(data, dst, t, maxval, invert); + t + }) + }; + Ok((t, out)) +} + +/// `cv2.Canny(src, low, high)` for a 2-D uint8 image (aperture 3, L1 gradient). +#[pyfunction] +fn canny<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + low: f64, + high: f64, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let [h, w] = shape[..] else { + return Err(PyValueError::new_err("canny needs a 2-D array")); + }; + let data = contiguous(&src)?; + let out = empty_u8(py, &shape)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| gray::canny(data, dst, h, w, low, high)) + .ok_or_else(|| PyValueError::new_err("invalid canny input"))?; + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(bgr_to_gray, m)?)?; + m.add_function(wrap_pyfunction!(gaussian_blur_u8, m)?)?; + m.add_function(wrap_pyfunction!(threshold_otsu, m)?)?; + m.add_function(wrap_pyfunction!(canny, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 928256184..f998c5153 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,4 +1,5 @@ pub mod filter; +pub mod gray; pub mod morph; pub mod resize; pub mod stats; diff --git a/tests/test_core_gray.py b/tests/test_core_gray.py new file mode 100644 index 000000000..f0ec78eba --- /dev/null +++ b/tests/test_core_gray.py @@ -0,0 +1,68 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(13) +_SHAPES = [(31, 281), (15, 243), (6, 297), (1, 77), (40, 1), (2, 2)] + + +@pytest.fixture +def one_cv2_thread(): + # cv2's parallel uint8 5x5 GaussianBlur can return different bytes on repeated calls; + # on one thread it is deterministic and negpy_core matches it. + before = cv2.getNumThreads() + cv2.setNumThreads(1) + yield + cv2.setNumThreads(before) + + +def _gray(shape): + return cv2.GaussianBlur(_RNG.integers(0, 256, shape).astype(np.uint8), (0, 0), 1.5) + + +@pytest.mark.parametrize("shape", _SHAPES) +def test_bgr_to_gray_matches_cv2(shape): + img = _RNG.integers(0, 256, (*shape, 3)).astype(np.uint8) + assert np.array_equal(negpy_core.bgr_to_gray(img), cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize( + "ksize, sigma, border", + [ + ((5, 5), 0, 4), + ((7, 7), 0, 4), + ((3, 3), 0, 4), + ((0, 0), 1.5, 4), + ((0, 0), 2.5, 1), + ((9, 1), 0, 4), + ((0, 0), 1.0, 0), + ((11, 11), 0, 2), + ], +) +def test_gaussian_blur_u8_matches_cv2(one_cv2_thread, shape, ksize, sigma, border): + img = _gray(shape) + want = cv2.GaussianBlur(img, ksize, sigma, borderType=border) + assert np.array_equal(negpy_core.gaussian_blur_u8(img, ksize, sigma, 0.0, border), want) + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("invert", [False, True]) +def test_threshold_otsu_matches_cv2(shape, invert): + img = _gray(shape) + flag = (cv2.THRESH_BINARY_INV if invert else cv2.THRESH_BINARY) + cv2.THRESH_OTSU + want_t, want = cv2.threshold(img, 0, 255, flag) + got_t, got = negpy_core.threshold_otsu(img, 255, invert) + assert got_t == want_t + assert np.array_equal(got, want) + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("low, high", [(40, 160), (10, 30), (100, 50)]) +def test_canny_matches_cv2(shape, low, high): + img = _gray(shape) + assert np.array_equal(negpy_core.canny(img, low, high), cv2.Canny(img, low, high)) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index e78380382..173a0f802 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -10,6 +10,7 @@ _RNG = np.random.default_rng(2026) _IMG3 = _RNG.random((97, 131, 3), dtype=np.float32) _IMG1 = (_RNG.random((83, 61), dtype=np.float32) - 0.4) * 5 +_GRAY = negpy_core.bgr_to_gray((_IMG3 * 255).astype(np.uint8)) _CASES = { "percentiles": lambda: np.array(negpy_core.percentiles(_IMG1.ravel(), [0.5, 1.0, 37.0, 50.0, 99.0]), np.float32), @@ -19,6 +20,10 @@ "gaussian_sigma2_replicate": lambda: negpy_core.gaussian_blur(_IMG1, (0, 0), 2.0, 0.0, 1), "gaussian_kernel_17": lambda: np.array(negpy_core.gaussian_kernel(17, 2.0), np.float32), "sobel_x": lambda: negpy_core.sep_filter2d(_IMG1, np.array([-1, 0, 1], np.float32), np.array([1, 2, 1], np.float32)), + "bgr_to_gray": lambda: _GRAY, + "gaussian_blur_u8": lambda: negpy_core.gaussian_blur_u8(_GRAY, (5, 5), 0.0), + "otsu": lambda: negpy_core.threshold_otsu(_GRAY)[1], + "canny": lambda: negpy_core.canny(_GRAY, 40, 160), "morph_close_u8": lambda: negpy_core.morphology_ex((_IMG1 > 0.5).astype(np.uint8) * 255, 3, np.ones((9, 7), np.uint8), 2), } @@ -30,6 +35,10 @@ "gaussian_sigma2_replicate": "2b9be6f95b7f66a03493795d8c7a5e1bb2565a28087578f1981a9cd329c8a74f", "gaussian_kernel_17": "5c2eb92d43931221106d80ca89a0b6dea1dca09b261b0fbf6ff51fdcae748d8b", "sobel_x": "ba798e861db64c102fee6b08fd7aa34d488cce431ae0d95fc3e7836e1dedb44a", + "bgr_to_gray": "ea18764e8727ce823408e9962b86969c2de631c5f0246b1d33c3bc833101dac9", + "gaussian_blur_u8": "7817f7444ec9c843b3c529098d650d653dd0081250770eb96bb378da20de046e", + "otsu": "cff78130e93898fd1d8ff6080127920c71a5fa1bbecd734a35631b253866ffdb", + "canny": "1e3125ae47a5dd1719f97531ffebffc79a6461ba1fae024d26d4d7a85b89b968", "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", } From aa3cb735232d51b99fab76c0bdff1f1aff6385c4 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:34:46 +0200 Subject: [PATCH 016/133] fix(core): clippy manual_contains in the canny test --- negpy_core/src/gray.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/negpy_core/src/gray.rs b/negpy_core/src/gray.rs index c6b853583..b10f36de2 100644 --- a/negpy_core/src/gray.rs +++ b/negpy_core/src/gray.rs @@ -287,7 +287,7 @@ mod tests { .collect(); let mut dst = vec![0u8; h * w]; canny(&src, &mut dst, h, w, 40.0, 160.0).unwrap(); - assert!(dst.iter().any(|&v| v == 255)); + assert!(dst.contains(&255)); assert!((0..h).all(|y| dst[y * w] == 0)); } } From 87d20cccbfb3887c1bc228eb16fd2968acb6b0d0 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:39:11 +0200 Subject: [PATCH 017/133] feat(core): findContours (external, simple chains) in Rust negpy_core.find_external_contours ports OpenCV 4.13's Suzuki-Abe scanner from contours_new.cpp for RETR_EXTERNAL + CHAIN_APPROX_SIMPLE: its pixel marks, the left-neighbour test that skips borders inside a traced contour, direction-change points, and the output order (each new contour prepended to the root, so the last found comes first). Contours and hierarchy match cv2 on 150 random masks and the 24-case test_core_contours. The two phase 2 contour searches use it; replay over 33 frames gives 39/39 functions identical. _ensure_color left the manifest: it is np.repeat now, with no cv2 call to port. --- .github/workflows/ci.yml | 2 +- negpy/features/geometry/logic.py | 4 +- negpy_core/manifest.toml | 4 +- negpy_core/src/contours.rs | 172 +++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/contours.rs | 51 +++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_contours.py | 36 +++++++ tests/test_core_same_bits.py | 4 + 9 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 negpy_core/src/contours.rs create mode 100644 negpy_core/src/py/contours.rs create mode 100644 tests/test_core_contours.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5192fa7..957a8479d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index f2901c5f3..fcfe483ca 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -203,7 +203,7 @@ def _dark_region_bounds(image: np.ndarray) -> tuple[int, int, int, int] | None: mask = negpy_core.morphology_ex(mask, cv2.MORPH_OPEN, open_kernel) mask = negpy_core.morphology_ex(mask, cv2.MORPH_CLOSE, close_kernel, iterations=2) - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + contours, _ = negpy_core.find_external_contours(mask) if not contours: return None @@ -604,7 +604,7 @@ def _candidates_from_mask( threshold_index: int | None = None, polarity: str | None = None, ) -> list[_FilmCandidate]: - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + contours, _ = negpy_core.find_external_contours(mask) candidates: list[_FilmCandidate] = [] for contour in sorted(contours, key=cv2.contourArea, reverse=True)[:12]: candidate = _candidate_from_contour( diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index fd7149003..784cf6660 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -347,13 +347,13 @@ depends = ["_oriented_boundary_evidence"] ["features/geometry/logic.py::_candidates_from_mask"] phase = 2 status = "todo" -calls = ["cv2.findContours"] +calls = ["negpy_core.find_external_contours"] depends = ["_candidate_from_contour"] ["features/geometry/logic.py::_dark_region_bounds"] phase = 2 status = "todo" -calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.findContours", "cv2.getStructuringElement", "negpy_core.bgr_to_gray", "negpy_core.morphology_ex", "np.percentile"] +calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.getStructuringElement", "negpy_core.bgr_to_gray", "negpy_core.find_external_contours", "negpy_core.morphology_ex", "np.percentile"] depends = [] ["features/geometry/logic.py::_detection_luma"] diff --git a/negpy_core/src/contours.rs b/negpy_core/src/contours.rs new file mode 100644 index 000000000..7090fe338 --- /dev/null +++ b/negpy_core/src/contours.rs @@ -0,0 +1,172 @@ +//! `cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)`, OpenCV 4.13's +//! Suzuki–Abe scanner (contours_new.cpp) with its marks, so contours and points come out in +//! OpenCV's order. + +const DELTAS: [(isize, isize); 8] = [ + (1, 0), + (1, -1), + (0, -1), + (-1, -1), + (-1, 0), + (-1, 1), + (0, 1), + (1, 1), +]; +const RIGHT: i8 = -128; // 0x80 +const NEW: i8 = 2; +const FLAGS: i8 = -2; // 0xFE + +/// Outer contours of the nonzero pixels of an `h` x `w` uint8 image, each a list of (x, y) +/// corner points, in OpenCV's output order (last found first). +pub fn find_external_contours(src: &[u8], h: usize, w: usize) -> Vec> { + // One pixel of zero border, values thresholded to 0/1, as findContours prepares them. + let (pw, ph) = (w + 2, h + 2); + let mut img = vec![0i8; pw * ph]; + for y in 0..h { + for x in 0..w { + img[(y + 1) * pw + x + 1] = (src[y * w + x] > 0) as i8; + } + } + let delta = |s: usize| DELTAS[s % 8].0 + DELTAS[s % 8].1 * pw as isize; + let mut found: Vec> = Vec::new(); + + let (width, height) = (pw - 1, ph - 1); + let mut last_pos = (0usize, 1usize); + for y in 1..height { + let mut prev: i8 = 0; + let mut x = 1; + while x < width { + let mut p = img[y * pw + x]; + while x < width && p == prev { + x += 1; + p = img[y * pw + x]; + } + if x >= width { + break; + } + // contourScan for RETR_EXTERNAL: start only at an outer border whose left + // neighbour is not inside an already traced contour. + let mut start = false; + if prev == 0 && p == 1 { + start = img[last_pos.1 * pw + last_pos.0] <= 0; + } else if p == 0 && prev >= 1 && prev & FLAGS != 0 { + last_pos.0 = x - 1; + } + if start { + last_pos.0 = x; + found.push(trace( + &mut img, + y * pw + x, + (x as i32 - 1, y as i32 - 1), + &delta, + )); + } else { + prev = p; + if prev & FLAGS != 0 { + last_pos.0 = x; + } + } + x += 1; + if start { + prev = img[y * pw + x - 1]; + } + } + last_pos = (0, y + 1); + } + found.reverse(); + found +} + +/// `icvFetchContourEx` for an outer border from `start` (an index into the padded image), +/// with CHAIN_APPROX_SIMPLE: a point wherever the chain direction changes. +fn trace( + img: &mut [i8], + start: usize, + origin: (i32, i32), + delta: &dyn Fn(usize) -> isize, +) -> Vec<(i32, i32)> { + let at = |i: usize, d: isize| (i as isize + d) as usize; + let mut pts = Vec::new(); + let mut pt = origin; + let i0 = start; + let s_end_outer = 4usize; + let mut s = s_end_outer; + let mut i1; + loop { + s = (s + 7) & 7; + i1 = at(i0, delta(s)); + if img[i1] != 0 || s == s_end_outer { + break; + } + } + if s == s_end_outer { + img[i0] = NEW | RIGHT; + pts.push(pt); + return pts; + } + let mut i3 = i0; + let mut prev_s = s ^ 4; + loop { + let s_end = s; + let mut i4 = i3; + s = s.min(15); + while s < 15 { + s += 1; + i4 = at(i3, delta(s)); + if img[i4] != 0 { + break; + } + } + s &= 7; + if s.wrapping_sub(1) < s_end { + img[i3] = NEW | RIGHT; + } else if img[i3] == 1 { + img[i3] = NEW; + } + if s != prev_s { + pts.push(pt); + } + prev_s = s; + pt = (pt.0 + DELTAS[s].0 as i32, pt.1 + DELTAS[s].1 as i32); + if i4 == i0 && i3 == i1 { + break; + } + i3 = i4; + s = (s + 4) & 7; + } + pts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn square_gives_its_four_corners() { + let (h, w) = (6, 7); + let mut img = vec![0u8; h * w]; + for y in 1..4 { + for x in 2..6 { + img[y * w + x] = 255; + } + } + let c = find_external_contours(&img, h, w); + assert_eq!(c, vec![vec![(2, 1), (2, 3), (5, 3), (5, 1)]]); + } + + #[test] + fn single_pixel_and_nested_blob() { + let (h, w) = (9, 9); + let mut img = vec![0u8; h * w]; + img[w + 1] = 1; + for y in 3..8 { + for x in 3..8 { + img[y * w + x] = 1; + } + } + img[5 * w + 5] = 0; // a hole: not an external contour + let c = find_external_contours(&img, h, w); + assert_eq!(c.len(), 2); + assert_eq!(c[1], vec![(1, 1)]); + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 84c0dc39e..040c862b4 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod contours; pub mod filter; pub mod gray; pub mod morph; @@ -14,5 +15,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::resize::register(m)?; py::filter::register(m)?; py::morph::register(m)?; - py::gray::register(m) + py::gray::register(m)?; + py::contours::register(m) } diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs new file mode 100644 index 000000000..eeeeab98d --- /dev/null +++ b/negpy_core/src/py/contours.rs @@ -0,0 +1,51 @@ +use numpy::{PyArray3, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyTuple; + +use crate::contours; + +/// `cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)` for a uint8 image: +/// (contours as Nx1x2 int32 arrays, hierarchy as 1xKx4 int32), in OpenCV's order. +#[pyfunction] +fn find_external_contours<'py>( + py: Python<'py>, + src: PyReadonlyArray2<'py, u8>, +) -> PyResult<(Bound<'py, PyTuple>, Bound<'py, PyAny>)> { + let arr = src.as_array(); + let (h, w) = arr.dim(); + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let found = py.detach(|| contours::find_external_contours(data, h, w)); + let k = found.len(); + let arrays: Vec>> = found + .iter() + .map(|c| { + let flat: Vec>> = c.iter().map(|&(x, y)| vec![vec![x, y]]).collect(); + PyArray3::from_vec3(py, &flat) + }) + .collect::>() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let hierarchy: Vec> = (0..k) + .map(|i| { + vec![ + if i + 1 < k { i as i32 + 1 } else { -1 }, + i as i32 - 1, + -1, + -1, + ] + }) + .collect(); + let np = py.import("numpy")?; + let hier = if k == 0 { + py.None().into_bound(py) + } else { + np.getattr("array")?.call1((vec![hierarchy], "int32"))? + }; + Ok((PyTuple::new(py, arrays)?, hier)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(find_external_contours, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index f998c5153..c591f475d 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,3 +1,4 @@ +pub mod contours; pub mod filter; pub mod gray; pub mod morph; diff --git a/tests/test_core_contours.py b/tests/test_core_contours.py new file mode 100644 index 000000000..a4a92958e --- /dev/null +++ b/tests/test_core_contours.py @@ -0,0 +1,36 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(14) + + +def _mask(kind, shape): + if kind == "noise": + m = _RNG.random(shape) > 0.5 + elif kind == "blobs": + m = cv2.GaussianBlur(_RNG.random(shape).astype(np.float32), (0, 0), 3) > 0.5 + elif kind == "sparse": + m = _RNG.random(shape) > 0.97 + else: + m = np.zeros(shape, bool) + for _ in range(5): + y0, x0 = _RNG.integers(0, shape[0]), _RNG.integers(0, shape[1]) + m[y0 : y0 + _RNG.integers(1, 60), x0 : x0 + _RNG.integers(1, 60)] ^= True + return m.astype(np.uint8) * np.uint8(255) + + +@pytest.mark.parametrize("kind", ["noise", "blobs", "sparse", "rects"]) +@pytest.mark.parametrize("shape", [(1, 1), (1, 50), (50, 1), (3, 3), (120, 97), (199, 200)]) +def test_find_external_contours_matches_cv2(kind, shape): + img = _mask(kind, shape) + want, want_h = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + got, got_h = negpy_core.find_external_contours(img) + assert len(got) == len(want) + for g, w in zip(got, want): + assert g.dtype == w.dtype and g.shape == w.shape and np.array_equal(g, w) + assert (want_h is None and got_h is None) or np.array_equal(got_h, want_h) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 173a0f802..7d7a8fc42 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -24,6 +24,9 @@ "gaussian_blur_u8": lambda: negpy_core.gaussian_blur_u8(_GRAY, (5, 5), 0.0), "otsu": lambda: negpy_core.threshold_otsu(_GRAY)[1], "canny": lambda: negpy_core.canny(_GRAY, 40, 160), + "find_external_contours": lambda: np.concatenate( + [c.ravel() for c in negpy_core.find_external_contours((_IMG1 > 0.5).astype(np.uint8) * np.uint8(255))[0]] + ), "morph_close_u8": lambda: negpy_core.morphology_ex((_IMG1 > 0.5).astype(np.uint8) * 255, 3, np.ones((9, 7), np.uint8), 2), } @@ -39,6 +42,7 @@ "gaussian_blur_u8": "7817f7444ec9c843b3c529098d650d653dd0081250770eb96bb378da20de046e", "otsu": "cff78130e93898fd1d8ff6080127920c71a5fa1bbecd734a35631b253866ffdb", "canny": "1e3125ae47a5dd1719f97531ffebffc79a6461ba1fae024d26d4d7a85b89b968", + "find_external_contours": "ebb4302dd2bc7226a7ed24fbb5e16e14f407d5693ce47128a649cee9e4a098be", "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", } From 3b6f560f6499ae5f5939e54b3986688d4f12664c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:48:01 +0200 Subject: [PATCH 018/133] perf(core): port get_luminance, _normalize_to_uint8 and _detection_luma The first whole functions of the measure_base_log chain move to Rust: get_luminance (Numba's fastmath kernel as f64 fused multiply-adds, the Numba kernel deleted), _normalize_to_uint8 and _detection_luma (NumPy's float32 arithmetic, NaN rules and truncating cast). The functions that need them call negpy_core through unchanged Python signatures. Loops with mul_add compile twice through the multiversion crate, so a CPU with AVX2+FMA runs one instruction and the release baseline stays portable; mul_add is exactly fused on both, so the bits do not change. The manifest now also holds the pure-Python links of a chain (callers of in-scope functions under features/ and kernel/image/): 422 entries. The phase 2 corpus was re-recorded for them: 53 functions, 30 GB. Replay: 53/53 phase 2 functions identical. make bench (one thread, against the recorded Python): _detection_luma 0.56x, _normalize_to_uint8 0.79x. --- CLAUDE.md | 1 + NOTICE.md | 3 +- negpy/features/geometry/logic.py | 18 +- negpy/kernel/image/logic.py | 16 +- negpy_core/Cargo.lock | 44 +- negpy_core/Cargo.toml | 1 + negpy_core/manifest.toml | 959 ++++++++++++++++++++++++++++++- negpy_core/src/analysis.rs | 88 +++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/analysis.rs | 91 +++ negpy_core/src/py/mod.rs | 1 + scripts/core_manifest.py | 16 +- tests/test_numba_nogil.py | 1 - 13 files changed, 1174 insertions(+), 69 deletions(-) create mode 100644 negpy_core/src/analysis.rs create mode 100644 negpy_core/src/py/analysis.rs diff --git a/CLAUDE.md b/CLAUDE.md index 2fe91456c..55ae5897d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - `cargo test` builds without Python: PyO3 is behind the `python` feature, which maturin turns on. - Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `pkg-config`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. +- `mul_add` is one instruction only on a CPU with FMA; the release baseline has none, so a loop that uses it takes `#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))]`. `mul_add` is exactly fused on every path, so the bits never depend on the CPU. - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. diff --git a/NOTICE.md b/NOTICE.md index 74f2ecf24..ef39d03c2 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -63,7 +63,8 @@ The negpy_core extension module links these Rust crates: PyO3 and rust-numpy (MIT OR Apache-2.0; rust-numpy BSD-2-Clause), rayon and crossbeam (MIT OR Apache-2.0), ndarray, num-traits, num-complex, num-integer, matrixmultiply and rawpointer (MIT OR Apache-2.0), libc, once_cell, either, portable-atomic and -rustc-hash (MIT OR Apache-2.0). negpy_core/Cargo.lock lists the exact versions. +rustc-hash (MIT OR Apache-2.0), multiversion and target-features (MIT OR Apache-2.0). +negpy_core/Cargo.lock lists the exact versions. negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area resize, separable filter engine and bit-exact Gaussian kernel (including its diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index fcfe483ca..582faa178 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -127,19 +127,7 @@ def _scale_roi(roi: ROI, det_scale: float, h: int, w: int) -> ROI: def _normalize_to_uint8(image: np.ndarray) -> np.ndarray: if image.dtype == np.uint8: return image - - image_float = image.astype(np.float32) - finite_mask = np.isfinite(image_float) - if not np.any(finite_mask): - return np.zeros(image.shape, dtype=np.uint8) - - valid = image_float[finite_mask] - low, high = (float(v) for v in percentiles(valid, (1, 99))) - if high <= low: - high = low + 1.0 - - scaled = np.clip((image_float - low) * (255.0 / (high - low)), 0, 255) - return scaled.astype(np.uint8) + return negpy_core.normalize_to_uint8(np.ascontiguousarray(image, dtype=np.float32)) def _ensure_color(image: np.ndarray) -> np.ndarray: @@ -956,9 +944,7 @@ def _detection_luma(img: np.ndarray) -> np.ndarray: Luminance normalized so the light bed sits near 1.0 (anchored at P99.5). Content-stable alternative to _normalize_to_uint8's 1/99 stretch. """ - lum = get_luminance(ensure_image(_ensure_color(img))) - anchor = float(np.percentile(lum, 99.5)) - return np.clip(lum / max(anchor, 1e-6), 0.0, 2.0) + return negpy_core.detection_luma(get_luminance(ensure_image(_ensure_color(img)))) BORDER_SIDES: Tuple[str, ...] = ("top", "bottom", "left", "right") diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index ad48d0174..f13e18118 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -6,25 +6,11 @@ from numba import njit, prange # type: ignore from negpy.kernel.system.parallel import parallel_njit from negpy.domain.types import LUMA_R, LUMA_G, LUMA_B -from negpy.kernel.image.validation import ensure_image from negpy.kernel.system.logging import get_logger logger = get_logger(__name__) -@njit(cache=True, nogil=True, fastmath=True) -def _get_luminance_jit(img: np.ndarray) -> np.ndarray: - """ - Rec. 709 luminance. - """ - h, w, _ = img.shape - res = np.empty((h, w), dtype=np.float32) - for y in range(h): - for x in range(w): - res[y, x] = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - return res - - @njit(cache=True, nogil=True, fastmath=True) def _to_uint16_jit(img: np.ndarray) -> np.ndarray: """ @@ -633,7 +619,7 @@ def get_luminance(img: np.ndarray) -> np.ndarray: Calculates relative luminance. Supports (H, W, 3) and (N, 3) arrays. """ if img.ndim == 3: - return ensure_image(_get_luminance_jit(np.ascontiguousarray(img.astype(np.float32)))) + return negpy_core.luminance(np.ascontiguousarray(img, dtype=np.float32)) return LUMA_R * img[..., 0] + LUMA_G * img[..., 1] + LUMA_B * img[..., 2] diff --git a/negpy_core/Cargo.lock b/negpy_core/Cargo.lock index f3f8f3fe5..c84223e8b 100644 --- a/negpy_core/Cargo.lock +++ b/negpy_core/Cargo.lock @@ -61,6 +61,28 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "multiversion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + [[package]] name = "ndarray" version = "0.17.2" @@ -80,6 +102,7 @@ dependencies = [ name = "negpy_core" version = "0.1.0" dependencies = [ + "multiversion", "numpy", "pyo3", "rayon", @@ -200,7 +223,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -212,7 +235,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -256,6 +279,17 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -267,6 +301,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "target-lexicon" version = "0.13.5" diff --git a/negpy_core/Cargo.toml b/negpy_core/Cargo.toml index 9cd10bafd..49c1b08de 100644 --- a/negpy_core/Cargo.toml +++ b/negpy_core/Cargo.toml @@ -16,3 +16,4 @@ python = ["dep:pyo3", "dep:numpy"] rayon = "1.12" pyo3 = { version = "0.30", features = ["abi3-py313"], optional = true } numpy = { version = "0.30", optional = true } +multiversion = "0.8" diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 784cf6660..c000701bb 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -8,12 +8,24 @@ calls = ["negpy_core.percentiles"] depends = [] note = "negpy_core::stats::percentiles" +["features/exposure/analysis.py::_mid_gray_encoded"] +phase = 2 +status = "todo" +calls = [] +depends = [] + ["features/exposure/analysis.py::_scope_lookup"] phase = 2 status = "todo" calls = ["np.bincount"] depends = [] +["features/exposure/analysis.py::_waveform_cells"] +phase = 2 +status = "todo" +calls = [] +depends = [] + ["features/exposure/analysis.py::color_histogram"] phase = 2 status = "todo" @@ -32,6 +44,12 @@ status = "todo" calls = ["np.bincount"] depends = [] +["features/exposure/analysis.py::encoded_of_zone"] +phase = 2 +status = "todo" +calls = [] +depends = ["_mid_gray_encoded"] + ["features/exposure/analysis.py::loupe_acutance"] phase = 2 status = "todo" @@ -56,6 +74,18 @@ status = "todo" calls = ["np.bincount"] depends = [] +["features/exposure/analysis.py::scope_point"] +phase = 2 +status = "todo" +calls = [] +depends = [] + +["features/exposure/analysis.py::vectorscope_density"] +phase = 2 +status = "todo" +calls = [] +depends = ["_scope_lookup"] + ["features/exposure/analysis.py::vectorscope_histogram"] phase = 2 status = "todo" @@ -66,19 +96,19 @@ depends = [] phase = 2 status = "todo" calls = ["np.bincount"] -depends = [] +depends = ["_waveform_cells"] ["features/exposure/analysis.py::waveform_color"] phase = 2 status = "todo" calls = ["np.bincount"] -depends = [] +depends = ["_waveform_cells"] ["features/exposure/analysis.py::waveform_parade"] phase = 2 status = "todo" calls = ["np.bincount"] -depends = [] +depends = ["_waveform_cells"] ["features/exposure/analysis.py::zone_anchors"] phase = 2 @@ -90,13 +120,25 @@ depends = [] phase = 2 status = "todo" calls = ["cv2.medianBlur", "cv2.resize", "negpy_core.gaussian_blur"] -depends = [] +depends = ["zone_of_encoded"] ["features/exposure/analysis.py::zone_grid"] phase = 2 status = "todo" calls = ["cv2.resize"] -depends = [] +depends = ["zone_of_encoded"] + +["features/exposure/analysis.py::zone_occupancy"] +phase = 2 +status = "todo" +calls = [] +depends = ["zone_of_encoded"] + +["features/exposure/analysis.py::zone_of_encoded"] +phase = 2 +status = "todo" +calls = [] +depends = ["_mid_gray_encoded"] ["features/exposure/analysis.py::zone_region_labels"] phase = 2 @@ -176,11 +218,23 @@ status = "todo" calls = ["cv2.resize"] depends = [] +["features/exposure/normalization.py::analyze_log_exposure_bounds"] +phase = 2 +status = "todo" +calls = [] +depends = ["analyze_log_exposure_bounds_from_log"] + +["features/exposure/normalization.py::analyze_log_exposure_bounds_from_log"] +phase = 2 +status = "todo" +calls = [] +depends = ["_block_median_grid", "_same_pixel_color_floor_refs", "_sample_log_bounds"] + ["features/exposure/normalization.py::contrast_mask_plane"] phase = 2 status = "todo" calls = ["negpy_core.gaussian_blur"] -depends = ["analysis_grid"] +depends = ["analysis_grid", "normalize_log_image", "prefilter_log_grid"] ["features/exposure/normalization.py::density_above_base"] phase = 2 @@ -198,7 +252,25 @@ depends = ["_smooth_along_scan_order", "pool_neutral_axis"] phase = 2 status = "todo" calls = ["negpy_core.gaussian_blur"] -depends = ["analysis_grid"] +depends = ["analysis_grid", "prefilter_log_grid"] + +["features/exposure/normalization.py::diffusion_plane"] +phase = 2 +status = "todo" +calls = [] +depends = ["normalize_log_image"] + +["features/exposure/normalization.py::effective_crosstalk_matrix"] +phase = 2 +status = "todo" +calls = [] +depends = ["resolve_crosstalk_matrix"] + +["features/exposure/normalization.py::measure_anchor"] +phase = 2 +status = "todo" +calls = [] +depends = ["measure_anchor_from_log"] ["features/exposure/normalization.py::measure_anchor_from_log"] phase = 2 @@ -224,11 +296,17 @@ status = "todo" calls = ["np.percentile"] depends = ["_block_median_grid"] +["features/exposure/normalization.py::measure_neutral_axis"] +phase = 2 +status = "todo" +calls = [] +depends = ["measure_neutral_axis_from_log"] + ["features/exposure/normalization.py::measure_neutral_axis_from_log"] phase = 2 status = "todo" calls = ["np.median", "np.percentile", "np.quantile"] -depends = ["_band_refs", "_block_median_grid"] +depends = ["_band_refs", "_block_median_grid", "normalize_log_image"] ["features/exposure/normalization.py::measure_neutral_axis_from_log._band_refs"] phase = 2 @@ -242,12 +320,24 @@ status = "todo" calls = ["np.percentile"] depends = ["_block_median_grid"] +["features/exposure/normalization.py::measure_textural_range"] +phase = 2 +status = "todo" +calls = [] +depends = ["measure_textural_range_from_log"] + ["features/exposure/normalization.py::measure_textural_range_from_log"] phase = 2 status = "todo" calls = ["np.percentile"] depends = ["_block_median_grid"] +["features/exposure/normalization.py::normalize_log_image"] +phase = 2 +status = "todo" +calls = [] +depends = ["_normalize_log_image_jit"] + ["features/exposure/normalization.py::pool_frame_bounds"] phase = 2 status = "todo" @@ -258,8 +348,20 @@ depends = [] phase = 2 status = "todo" calls = ["np.median"] +depends = ["_weighted_median", "band"] + +["features/exposure/normalization.py::pool_neutral_axis.band"] +phase = 2 +status = "todo" +calls = [] depends = ["_weighted_median"] +["features/exposure/normalization.py::prefilter_log_grid"] +phase = 2 +status = "todo" +calls = [] +depends = ["_block_median_grid"] + ["features/exposure/normalization.py::resolve_crosstalk_matrix"] phase = 2 status = "todo" @@ -272,6 +374,24 @@ status = "todo" calls = ["np.sort"] depends = [] +["features/geometry/batch_autocrop.py::_border_without_a_film_box"] +phase = 2 +status = "todo" +calls = [] +depends = ["_bright_side_border", "_side_border"] + +["features/geometry/batch_autocrop.py::_bright_side_border"] +phase = 2 +status = "todo" +calls = [] +depends = [] + +["features/geometry/batch_autocrop.py::_edge_fit"] +phase = 2 +status = "todo" +calls = [] +depends = [] + ["features/geometry/batch_autocrop.py::_mad"] phase = 2 status = "todo" @@ -284,6 +404,12 @@ status = "todo" calls = ["cv2.getRotationMatrix2D"] depends = [] +["features/geometry/batch_autocrop.py::_no_box_evidence"] +phase = 2 +status = "todo" +calls = [] +depends = ["_border_without_a_film_box"] + ["features/geometry/batch_autocrop.py::_rect_from_edge_profile"] phase = 2 status = "todo" @@ -296,6 +422,12 @@ status = "todo" calls = ["np.median"] depends = [] +["features/geometry/batch_autocrop.py::_side_border"] +phase = 2 +status = "todo" +calls = [] +depends = [] + ["features/geometry/batch_autocrop.py::_threshold_fallback_frames"] phase = 2 status = "todo" @@ -320,6 +452,18 @@ status = "todo" calls = ["np.median", "np.percentile"] depends = ["_mad", "_roll_border", "_threshold_fallback_frames"] +["features/geometry/batch_autocrop.py::detect_crop_candidate"] +phase = 2 +status = "todo" +calls = [] +depends = ["_bright_side_border", "_edge_fit", "_no_box_evidence", "_side_border", "_top_edge_slope", "_vertical_edge_profile"] + +["features/geometry/batch_autocrop.py::resolve_roll_crops"] +phase = 2 +status = "todo" +calls = [] +depends = ["_map_rect_between_rotations", "_rect_from_edge_profile", "build_roll_template"] + ["features/geometry/logic.py::_adaptive_threshold_levels"] phase = 2 status = "todo" @@ -354,13 +498,20 @@ depends = ["_candidate_from_contour"] phase = 2 status = "todo" calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.getStructuringElement", "negpy_core.bgr_to_gray", "negpy_core.find_external_contours", "negpy_core.morphology_ex", "np.percentile"] -depends = [] +depends = ["_normalize_to_uint8"] -["features/geometry/logic.py::_detection_luma"] +["features/geometry/logic.py::_detect_film_bounds"] phase = 2 status = "todo" -calls = ["np.percentile"] +calls = [] +depends = ["detect_film_bounds_with_confidence"] + +["features/geometry/logic.py::_detection_luma"] +phase = 2 +status = "ported" +calls = ["negpy_core.detection_luma"] depends = [] +note = "negpy_core::analysis::detection_luma" ["features/geometry/logic.py::_edge_border_depth"] phase = 2 @@ -368,6 +519,12 @@ status = "todo" calls = ["np.median"] depends = ["_border_edge_scatter", "_pool_border_depth", "_walk_border_depth"] +["features/geometry/logic.py::_enforce_ratio_by_occupancy"] +phase = 2 +status = "todo" +calls = [] +depends = ["_place_window_by_occupancy"] + ["features/geometry/logic.py::_estimate_tier_levels"] phase = 2 status = "todo" @@ -380,6 +537,12 @@ status = "todo" calls = ["np.median", "np.percentile"] depends = [] +["features/geometry/logic.py::_find_autocrop_roi_from_contours"] +phase = 2 +status = "todo" +calls = [] +depends = ["_detect_film_bounds", "_refine_roi_to_image"] + ["features/geometry/logic.py::_find_rebate_level"] phase = 2 status = "todo" @@ -416,6 +579,13 @@ status = "todo" calls = ["negpy_core.resize_area"] depends = [] +["features/geometry/logic.py::_normalize_to_uint8"] +phase = 2 +status = "ported" +calls = ["negpy_core.normalize_to_uint8"] +depends = [] +note = "negpy_core::analysis::normalize_to_uint8" + ["features/geometry/logic.py::_oriented_boundary_evidence"] phase = 2 status = "todo" @@ -446,11 +616,23 @@ status = "todo" calls = ["cv2.getRotationMatrix2D"] depends = [] +["features/geometry/logic.py::_refine_film_roi_by_tiers"] +phase = 2 +status = "todo" +calls = [] +depends = ["_estimate_tier_levels", "_snap_edge_to_gradient"] + ["features/geometry/logic.py::_refine_frame_bounds"] phase = 2 status = "todo" calls = ["negpy_core.bgr_to_gray", "negpy_core.sobel", "np.percentile"] -depends = ["_dark_region_bounds"] +depends = ["_dark_region_bounds", "_normalize_to_uint8"] + +["features/geometry/logic.py::_refine_roi_to_image"] +phase = 2 +status = "todo" +calls = [] +depends = ["_detection_luma", "_find_rebate_level", "_refine_film_roi_by_tiers", "_refine_frame_bounds", "_roi_from_measured_border", "_trim_film_edges"] ["features/geometry/logic.py::_remap_tiled"] phase = 2 @@ -458,24 +640,48 @@ status = "todo" calls = ["cv2.remap"] depends = [] +["features/geometry/logic.py::_roi_from_measured_border"] +phase = 2 +status = "todo" +calls = [] +depends = ["measure_film_border"] + ["features/geometry/logic.py::_score_contour"] phase = 2 status = "todo" calls = ["cv2.boxPoints", "cv2.contourArea", "cv2.minAreaRect"] depends = [] +["features/geometry/logic.py::_select_consensus_cluster"] +phase = 2 +status = "todo" +calls = [] +depends = ["_weighted_median"] + ["features/geometry/logic.py::_snap_edge_to_gradient"] phase = 2 status = "todo" calls = ["np.median"] depends = [] +["features/geometry/logic.py::_snap_film_bounds_to_bed_gradient"] +phase = 2 +status = "todo" +calls = [] +depends = ["_snap_edge_to_gradient"] + ["features/geometry/logic.py::_threshold_polarities"] phase = 2 status = "todo" calls = ["np.median"] depends = [] +["features/geometry/logic.py::_trim_film_edges"] +phase = 2 +status = "todo" +calls = [] +depends = ["measure_film_edges"] + ["features/geometry/logic.py::_vertical_edge_profile"] phase = 2 status = "todo" @@ -504,7 +710,7 @@ depends = ["_remap_tiled"] phase = 2 status = "todo" calls = ["cv2.warpPerspective"] -depends = ["_remap_tiled"] +depends = ["_remap_tiled", "keystone_matrix"] ["features/geometry/logic.py::apply_radial_distortion"] phase = 2 @@ -516,13 +722,43 @@ depends = ["_remap_tiled"] phase = 2 status = "todo" calls = ["cv2.getRotationMatrix2D"] -depends = [] +depends = ["keystone_matrix"] + +["features/geometry/logic.py::detect_closest_aspect_ratio"] +phase = 2 +status = "todo" +calls = [] +depends = ["_find_autocrop_roi_from_contours", "_get_threshold_autocrop_coords", "_normalize_detection_input"] ["features/geometry/logic.py::detect_film_bounds_with_confidence"] phase = 2 status = "todo" calls = ["negpy_core.bgr_to_gray", "negpy_core.gaussian_blur", "np.median", "np.percentile"] -depends = ["_adaptive_threshold_levels", "_adaptive_threshold_mask", "_candidates_from_mask", "_detection_luma", "_film_surround_is_plausible", "_mask_from_blackhat", "_mask_from_edges", "_mask_from_inverse_threshold", "_threshold_polarities", "_vertical_edge_profile"] +depends = ["_adaptive_threshold_levels", "_adaptive_threshold_mask", "_candidates_from_mask", "_detection_luma", "_film_surround_is_plausible", "_mask_from_blackhat", "_mask_from_edges", "_mask_from_inverse_threshold", "_normalize_to_uint8", "_select_consensus_cluster", "_snap_film_bounds_to_bed_gradient", "_threshold_polarities", "_vertical_edge_profile"] + +["features/geometry/logic.py::film_and_picture"] +phase = 2 +status = "todo" +calls = [] +depends = ["_refine_roi_to_image", "detect_film_bounds_with_confidence"] + +["features/geometry/logic.py::get_autocrop_coords"] +phase = 2 +status = "todo" +calls = [] +depends = ["_detect_film_bounds", "_detection_luma", "_enforce_ratio_by_occupancy", "_get_threshold_autocrop_coords", "_normalize_detection_input", "_refine_roi_to_image", "_trim_film_edges"] + +["features/geometry/logic.py::keystone_inverse_normalized"] +phase = 2 +status = "todo" +calls = [] +depends = ["keystone_matrix_normalized"] + +["features/geometry/logic.py::keystone_matrix"] +phase = 2 +status = "todo" +calls = [] +depends = ["keystone_matrix_normalized"] ["features/geometry/logic.py::keystone_matrix_normalized"] phase = 2 @@ -530,17 +766,71 @@ status = "todo" calls = ["cv2.getPerspectiveTransform"] depends = [] +["features/geometry/logic.py::map_coords_to_geometry"] +phase = 2 +status = "todo" +calls = [] +depends = ["_pre_keystone_point", "map_point_keystone"] + +["features/geometry/logic.py::map_point_keystone"] +phase = 2 +status = "todo" +calls = [] +depends = ["keystone_matrix"] + ["features/geometry/logic.py::measure_film_border"] phase = 2 status = "todo" calls = ["np.percentile"] depends = [] +["features/geometry/logic.py::measure_film_edges"] +phase = 2 +status = "todo" +calls = [] +depends = ["_edge_border_depth"] + ["features/geometry/logic.py::resolve_autocrop_rect"] phase = 2 status = "todo" calls = ["cv2.resize"] -depends = ["_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion"] +depends = ["_detect_film_bounds", "_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion", "get_autocrop_coords"] + +["features/geometry/logic.py::resolve_meter_rect"] +phase = 2 +status = "todo" +calls = [] +depends = ["resolve_autocrop_rect"] + +["features/geometry/logic.py::solve_keystone_from_edges"] +phase = 2 +status = "todo" +calls = [] +depends = ["_pre_keystone_point", "map_point_keystone", "residuals", "slope_h", "slope_v"] + +["features/geometry/logic.py::solve_keystone_from_edges.residuals"] +phase = 2 +status = "todo" +calls = [] +depends = ["slope_h", "slope_v"] + +["features/geometry/logic.py::solve_keystone_from_edges.slope_h"] +phase = 2 +status = "todo" +calls = [] +depends = ["map_point_keystone"] + +["features/geometry/logic.py::solve_keystone_from_edges.slope_v"] +phase = 2 +status = "todo" +calls = [] +depends = ["map_point_keystone"] + +["features/geometry/processor.py::GeometryProcessor.process"] +phase = 2 +status = "todo" +calls = [] +depends = [] ["features/geometry/skew.py::_at_canvas_edges"] phase = 2 @@ -552,7 +842,25 @@ depends = [] phase = 2 status = "todo" calls = ["np.mean", "np.median"] -depends = ["_profile_peaks", "_ransac_line"] +depends = ["_profile_peaks", "_ransac_line", "sample"] + +["features/geometry/skew.py::_fit_side.sample"] +phase = 2 +status = "todo" +calls = [] +depends = ["_profile_peaks"] + +["features/geometry/skew.py::_frame_lines"] +phase = 2 +status = "todo" +calls = [] +depends = ["_fit_side"] + +["features/geometry/skew.py::_line_angles"] +phase = 2 +status = "todo" +calls = [] +depends = ["_map"] ["features/geometry/skew.py::_map"] phase = 2 @@ -564,7 +872,7 @@ depends = [] phase = 2 status = "todo" calls = ["np.mean"] -depends = ["_at_canvas_edges", "_profile_lines", "_unrotate"] +depends = ["_at_canvas_edges", "_frame_lines", "_profile_lines", "_solve", "_unrotate"] ["features/geometry/skew.py::_profile_lines"] phase = 2 @@ -584,12 +892,36 @@ status = "todo" calls = ["np.unique"] depends = [] +["features/geometry/skew.py::_solve"] +phase = 2 +status = "todo" +calls = [] +depends = ["_line_angles"] + ["features/geometry/skew.py::_unrotate"] phase = 2 status = "todo" calls = ["cv2.getRotationMatrix2D"] depends = [] +["features/geometry/skew.py::measure_frame_skew"] +phase = 2 +status = "todo" +calls = [] +depends = ["_coarse_angles", "_measure_from"] + +["features/geometry/skew.py::squared_geometry"] +phase = 2 +status = "todo" +calls = [] +depends = ["trusted_frame_skew"] + +["features/geometry/skew.py::trusted_frame_skew"] +phase = 2 +status = "todo" +calls = [] +depends = ["measure_frame_skew"] + ["infrastructure/loaders/logluv.py::normalize_linear"] phase = 3 status = "todo" @@ -626,12 +958,6 @@ status = "todo" calls = ["numba"] depends = ["_in_gamut_lab"] -["kernel/image/logic.py::_get_luminance_jit"] -phase = 3 -status = "todo" -calls = ["numba"] -depends = [] - ["kernel/image/logic.py::_in_gamut_lab"] phase = 3 status = "todo" @@ -698,6 +1024,55 @@ status = "todo" calls = ["numba"] depends = [] +["kernel/image/logic.py::apply_primaries_transform"] +phase = 3 +status = "todo" +calls = [] +depends = ["_matmul_3x3_kernel", "working_oetf_decode", "working_oetf_encode"] + +["kernel/image/logic.py::float_to_uint16"] +phase = 3 +status = "todo" +calls = [] +depends = ["_to_uint16_jit"] + +["kernel/image/logic.py::float_to_uint8"] +phase = 3 +status = "todo" +calls = [] +depends = ["_to_uint8_jit"] + +["kernel/image/logic.py::float_to_uint_luma"] +phase = 3 +status = "todo" +calls = [] +depends = ["_float_to_uint16_luma_jit", "_float_to_uint8_luma_jit"] + +["kernel/image/logic.py::gamut_aware_chroma_scale"] +phase = 3 +status = "todo" +calls = [] +depends = ["_gamut_aware_chroma_scale_kernel"] + +["kernel/image/logic.py::get_luminance"] +phase = 3 +status = "ported" +calls = ["negpy_core.luminance"] +depends = [] +note = "negpy_core::analysis::luminance" + +["kernel/image/logic.py::lab_to_rgb_working"] +phase = 3 +status = "todo" +calls = [] +depends = ["_lab_to_rgb_kernel"] + +["kernel/image/logic.py::rgb_to_lab_working"] +phase = 3 +status = "todo" +calls = [] +depends = ["_rgb_to_lab_kernel"] + ["kernel/image/logic.py::rgb_to_rgba_into"] phase = 3 status = "todo" @@ -710,6 +1085,12 @@ status = "todo" calls = ["numba"] depends = [] +["kernel/image/logic.py::skin_chroma_rein"] +phase = 3 +status = "todo" +calls = [] +depends = ["_skin_chroma_rein_kernel"] + ["kernel/image/logic.py::uint16_to_float32"] phase = 3 status = "todo" @@ -722,6 +1103,18 @@ status = "todo" calls = ["numba"] depends = [] +["kernel/image/logic.py::working_oetf_decode"] +phase = 3 +status = "todo" +calls = [] +depends = ["_oetf_decode_flat"] + +["kernel/image/logic.py::working_oetf_encode"] +phase = 3 +status = "todo" +calls = [] +depends = ["_oetf_encode_flat"] + ["features/flatfield/logic.py::_evenness"] phase = 4 status = "todo" @@ -734,6 +1127,12 @@ status = "todo" calls = ["cv2.distanceTransformWithLabels"] depends = [] +["features/flatfield/logic.py::_is_clipped"] +phase = 4 +status = "todo" +calls = [] +depends = ["_lit_mask"] + ["features/flatfield/logic.py::_lit_mask"] phase = 4 status = "todo" @@ -746,12 +1145,36 @@ status = "todo" calls = ["cv2.resize"] depends = [] +["features/flatfield/logic.py::check_copy"] +phase = 4 +status = "todo" +calls = [] +depends = ["work_copy"] + +["features/flatfield/logic.py::check_reference"] +phase = 4 +status = "todo" +calls = [] +depends = ["_is_clipped", "check_copy", "evenness", "self_corrected"] + ["features/flatfield/logic.py::compute_gain"] phase = 4 status = "todo" calls = ["cv2.GaussianBlur"] depends = ["_fill_from_nearest", "_lit_mask", "work_copy"] +["features/flatfield/logic.py::evenness"] +phase = 4 +status = "todo" +calls = [] +depends = ["_evenness"] + +["features/flatfield/logic.py::evenness_view"] +phase = 4 +status = "todo" +calls = [] +depends = ["_evenness"] + ["features/flatfield/logic.py::self_corrected"] phase = 4 status = "todo" @@ -764,17 +1187,35 @@ status = "todo" calls = ["cv2.resize"] depends = [] +["features/lens/logic.py::_edge_reads"] +phase = 4 +status = "todo" +calls = [] +depends = ["_lookup"] + ["features/lens/logic.py::_lookup"] phase = 4 status = "todo" calls = ["np.unique"] depends = [] +["features/lens/logic.py::_search_fill_scale"] +phase = 4 +status = "todo" +calls = [] +depends = ["_edge_reads"] + ["features/lens/logic.py::apply_lens"] phase = 4 status = "todo" calls = ["cv2.remap"] -depends = [] +depends = ["fill_scale"] + +["features/lens/logic.py::fill_scale"] +phase = 4 +status = "todo" +calls = [] +depends = ["_search_fill_scale"] ["features/lens/warps.py::RectilinearWarp.remap"] phase = 4 @@ -782,6 +1223,24 @@ status = "todo" calls = ["cv2.undistortPointsIter"] depends = [] +["features/local/logic.py::_tinted_area"] +phase = 4 +status = "todo" +calls = [] +depends = ["rasterise"] + +["features/local/logic.py::compute_local_maps"] +phase = 4 +status = "todo" +calls = [] +depends = ["rasterise"] + +["features/local/logic.py::overlapping_masks"] +phase = 4 +status = "todo" +calls = [] +depends = ["_tinted_area"] + ["features/local/logic.py::rasterise"] phase = 4 status = "todo" @@ -800,6 +1259,18 @@ status = "todo" calls = ["np.mean"] depends = ["measure_lab"] +["features/process/chart.py::fit_chart_matrix"] +phase = 4 +status = "todo" +calls = [] +depends = ["detect_orientation", "lab_for", "measure_lab"] + +["features/process/chart.py::fit_chart_matrix.lab_for"] +phase = 4 +status = "todo" +calls = [] +depends = ["measure_lab"] + ["features/process/chart.py::grid_corners"] phase = 4 status = "todo" @@ -812,10 +1283,28 @@ status = "todo" calls = ["np.mean"] depends = ["sample_patches"] +["features/process/chart.py::patch_centers"] +phase = 4 +status = "todo" +calls = [] +depends = ["grid_corners"] + ["features/process/chart.py::sample_patches"] phase = 4 status = "todo" calls = ["np.median"] +depends = ["patch_centers", "sample_radius"] + +["features/process/chart.py::sample_radius"] +phase = 4 +status = "todo" +calls = [] +depends = ["grid_corners"] + +["features/process/hue.py::apply_hue_trim"] +phase = 4 +status = "todo" +calls = [] depends = [] ["features/process/logic.py::_corr"] @@ -830,6 +1319,12 @@ status = "todo" calls = ["np.mean", "np.percentile"] depends = ["_corr"] +["features/process/sensor.py::_density_unmix"] +phase = 4 +status = "todo" +calls = [] +depends = ["_log_unmix_kernel", "film_base"] + ["features/process/sensor.py::_log_unmix_kernel"] phase = 4 status = "todo" @@ -860,6 +1355,12 @@ status = "todo" calls = ["numba"] depends = [] +["features/process/sensor.py::apply_sensor_correction"] +phase = 4 +status = "todo" +calls = [] +depends = ["_density_unmix", "_two_scale_unmix", "_unmix_kernel"] + ["features/process/sensor.py::film_base"] phase = 4 status = "todo" @@ -884,6 +1385,12 @@ status = "todo" calls = ["cv2.distanceTransform"] depends = ["_cover", "_local_mean"] +["features/retouch/clone.py::apply_clone_strokes"] +phase = 4 +status = "todo" +calls = [] +depends = ["apply_clone_stroke"] + ["features/retouch/logic.py::_add_floor_deficit"] phase = 4 status = "todo" @@ -908,6 +1415,12 @@ status = "todo" calls = ["cv2.blur"] depends = [] +["features/retouch/logic.py::_density"] +phase = 4 +status = "todo" +calls = [] +depends = [] + ["features/retouch/logic.py::_erode_resize_bounded"] phase = 4 status = "todo" @@ -984,7 +1497,13 @@ depends = [] phase = 4 status = "todo" calls = ["cv2.GaussianBlur", "cv2.blur"] -depends = [] +depends = ["_density"] + +["features/retouch/logic.py::_scratch_ridge_rows"] +phase = 4 +status = "todo" +calls = [] +depends = ["_scratch_ridge"] ["features/retouch/logic.py::_shear_rows"] phase = 4 @@ -1008,7 +1527,7 @@ depends = ["_add_floor_deficit", "_blend_fill", "_borrow_clean_grain", "score_we phase = 4 status = "todo" calls = ["cv2.blur", "cv2.getStructuringElement", "cv2.medianBlur", "cv2.morphologyEx"] -depends = ["_box_mean_std", "_proxy_norm"] +depends = ["_box_mean_std", "_density", "_proxy_norm"] ["features/retouch/logic.py::detect_luma_score"] phase = 4 @@ -1050,7 +1569,7 @@ depends = ["_fit_refraction_gammas", "_ir_clean_base", "_ir_decontaminate", "_ir phase = 4 status = "todo" calls = ["cv2.blur"] -depends = ["_mask_to_score", "_shear_rows"] +depends = ["_mask_to_score", "_scratch_ridge_rows", "_shear_rows"] ["features/retouch/logic.py::normalize_ir"] phase = 4 @@ -1086,13 +1605,13 @@ depends = ["_is_hair"] phase = 4 status = "todo" calls = ["cv2.blur", "cv2.circle", "cv2.connectedComponents", "cv2.distanceTransform", "cv2.polylines", "np.median", "np.unique"] -depends = ["_mask_to_score"] +depends = ["_density", "_mask_to_score"] ["features/retouch/logic.py::trace_scratch"] phase = 4 status = "todo" calls = ["cv2.blur", "np.median"] -depends = ["_shear_rows"] +depends = ["_scratch_ridge_rows", "_shear_rows"] ["features/hdr/logic.py::_accumulate"] phase = 5 @@ -1112,11 +1631,41 @@ status = "todo" calls = ["numba"] depends = [] +["features/hdr/logic.py::apply_render_exposure"] +phase = 5 +status = "todo" +calls = [] +depends = ["output_scale"] + +["features/hdr/logic.py::clipped_fraction"] +phase = 5 +status = "todo" +calls = [] +depends = ["_count_clipped_rows"] + ["features/hdr/logic.py::measure_exposure"] phase = 5 status = "todo" calls = ["np.percentile"] -depends = [] +depends = ["clipped_fraction"] + +["features/hdr/logic.py::merge_bracket"] +phase = 5 +status = "todo" +calls = [] +depends = ["apply_render_exposure", "merge_providers"] + +["features/hdr/logic.py::merge_frames"] +phase = 5 +status = "todo" +calls = [] +depends = ["apply_render_exposure", "merge_providers"] + +["features/hdr/logic.py::merge_providers"] +phase = 5 +status = "todo" +calls = [] +depends = ["_accumulate", "_align"] ["features/hdr/logic.py::output_scale"] phase = 5 @@ -1130,12 +1679,60 @@ status = "todo" calls = ["np.median"] depends = [] +["features/hdr/logic.py::probe_exposures"] +phase = 5 +status = "todo" +calls = [] +depends = ["measure_exposure"] + +["features/hdr/logic.py::seed_shadow_density"] +phase = 5 +status = "todo" +calls = [] +depends = ["shadow_reach_stops"] + +["features/hdr/logic.py::shadow_reach_stops"] +phase = 5 +status = "todo" +calls = [] +depends = ["output_scale"] + +["features/hdr/logic.py::solve_ratios"] +phase = 5 +status = "todo" +calls = [] +depends = ["measure_exposure", "pair_ratio", "step"] + +["features/hdr/logic.py::solve_ratios.step"] +phase = 5 +status = "todo" +calls = [] +depends = ["pair_ratio"] + ["features/rgbscan/logic.py::_align_to"] phase = 5 status = "todo" calls = ["cv2.warpAffine"] depends = ["estimate_shift"] +["features/rgbscan/logic.py::assemble_rgb"] +phase = 5 +status = "todo" +calls = [] +depends = ["_align_to"] + +["features/rgbscan/logic.py::merge_rgb_triplet"] +phase = 5 +status = "todo" +calls = [] +depends = ["assemble_rgb"] + +["features/rgbscan/logic.py::probe_channel_means"] +phase = 5 +status = "todo" +calls = [] +depends = ["probe_frame"] + ["features/rgbscan/logic.py::probe_frame"] phase = 5 status = "todo" @@ -1148,6 +1745,12 @@ status = "todo" calls = ["cv2.distanceTransform"] depends = [] +["features/stitch/logic.py::blend_ir"] +phase = 5 +status = "todo" +calls = [] +depends = ["warp_into_canvas"] + ["features/stitch/logic.py::build_proxy"] phase = 5 status = "todo" @@ -1166,12 +1769,48 @@ status = "todo" calls = ["cv2.distanceTransform"] depends = [] +["features/stitch/logic.py::register_parts"] +phase = 5 +status = "todo" +calls = [] +depends = ["build_proxy", "compute_canvas", "register_pair"] + +["features/stitch/logic.py::seam_blend"] +phase = 5 +status = "todo" +calls = [] +depends = ["_seam_weight"] + +["features/stitch/logic.py::stitch_composite"] +phase = 5 +status = "todo" +calls = [] +depends = ["blend_ir", "feather_blend", "find_seams", "seam_blend", "warp_into_canvas"] + ["features/stitch/logic.py::warp_into_canvas"] phase = 5 status = "todo" calls = ["cv2.warpAffine"] depends = [] +["features/cyanotype/logic.py::apply_cyanotype"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/cyanotype/processor.py::CyanotypeProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/exposure/densitometer.py::compute_reading"] +phase = 6 +status = "todo" +calls = [] +depends = [] + ["features/exposure/logic.py::_apply_print_curve_kernel"] phase = 6 status = "todo" @@ -1196,6 +1835,18 @@ status = "todo" calls = ["numba"] depends = [] +["features/exposure/logic.py::apply_characteristic_curve"] +phase = 6 +status = "todo" +calls = [] +depends = ["_apply_print_curve_kernel"] + +["features/exposure/logic.py::contrast_mask_ev"] +phase = 6 +status = "todo" +calls = [] +depends = ["expand_mask_plane"] + ["features/exposure/logic.py::expand_mask_plane"] phase = 6 status = "todo" @@ -1208,6 +1859,12 @@ status = "todo" calls = ["numba"] depends = [] +["features/exposure/logic.py::print_curve_output"] +phase = 6 +status = "todo" +calls = [] +depends = [] + ["features/exposure/logic.py::quadratic_core"] phase = 6 status = "todo" @@ -1226,18 +1883,156 @@ status = "todo" calls = ["numba"] depends = [] +["features/exposure/placement.py::_pick_knee"] +phase = 6 +status = "todo" +calls = [] +depends = ["predicted_zone"] + +["features/exposure/placement.py::_solve_density"] +phase = 6 +status = "todo" +calls = [] +depends = ["predicted_zone"] + +["features/exposure/placement.py::_solve_density.residual"] +phase = 6 +status = "todo" +calls = [] +depends = ["predicted_zone"] + +["features/exposure/placement.py::_solve_grade_and_density"] +phase = 6 +status = "todo" +calls = [] +depends = ["_solve_density", "predicted_zone"] + +["features/exposure/placement.py::_solve_grade_and_density.light_residual"] +phase = 6 +status = "todo" +calls = [] +depends = ["_solve_density", "predicted_zone"] + +["features/exposure/placement.py::_solve_knee_field"] +phase = 6 +status = "todo" +calls = [] +depends = ["predicted_zone"] + +["features/exposure/placement.py::_solve_knee_field.residual"] +phase = 6 +status = "todo" +calls = [] +depends = ["predicted_zone"] + +["features/exposure/placement.py::_solve_with_knee"] +phase = 6 +status = "todo" +calls = [] +depends = ["_pick_knee", "_solve_grade_and_density", "_solve_knee_field", "predicted_zone"] + +["features/exposure/placement.py::_zones_of"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/exposure/placement.py::key_edges"] +phase = 6 +status = "todo" +calls = [] +depends = ["_zones_of"] + +["features/exposure/placement.py::limited_mask_params"] +phase = 6 +status = "todo" +calls = [] +depends = ["key_edges"] + +["features/exposure/placement.py::predicted_zone"] +phase = 6 +status = "todo" +calls = [] +depends = ["_zones_of"] + +["features/exposure/placement.py::solve_placement"] +phase = 6 +status = "todo" +calls = [] +depends = ["_solve_density", "_solve_grade_and_density", "_solve_with_knee", "predicted_zone"] + ["features/exposure/print_lut.py::tetra_lookup"] phase = 6 status = "todo" calls = ["numba"] depends = [] +["features/exposure/processor.py::NormalizationProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/exposure/processor.py::NormalizationProcessor.process.analyze_base"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/exposure/processor.py::PhotometricProcessor._build_local_maps"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/exposure/processor.py::PhotometricProcessor._process_flat"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/exposure/processor.py::PhotometricProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/finish/logic.py::_carrier_block"] +phase = 6 +status = "todo" +calls = [] +depends = ["carrier_profiles"] + ["features/finish/logic.py::_filed_edge"] phase = 6 status = "todo" calls = ["np.cumsum"] depends = [] +["features/finish/logic.py::apply_carrier"] +phase = 6 +status = "todo" +calls = [] +depends = ["_carrier_block"] + +["features/finish/logic.py::carrier_profiles"] +phase = 6 +status = "todo" +calls = [] +depends = ["_filed_edge"] + +["features/finish/processor.py::FinishProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/finish/processor.py::rebate_tone"] +phase = 6 +status = "todo" +calls = [] +depends = [] + ["features/lab/logic.py::_clahe_cdfs"] phase = 6 status = "todo" @@ -1250,6 +2045,12 @@ status = "todo" calls = ["cv2.blur"] depends = [] +["features/lab/logic.py::_saturation_block"] +phase = 6 +status = "todo" +calls = [] +depends = [] + ["features/lab/logic.py::_usm_sharpening_block"] phase = 6 status = "todo" @@ -1262,36 +2063,132 @@ status = "todo" calls = ["cv2.sepFilter2D"] depends = ["chroma_edge_keep"] +["features/lab/logic.py::apply_clahe"] +phase = 6 +status = "todo" +calls = [] +depends = ["_clahe_cdfs"] + ["features/lab/logic.py::apply_glow_and_halation"] phase = 6 status = "todo" calls = ["cv2.GaussianBlur"] depends = [] +["features/lab/logic.py::apply_resize_sharpening"] +phase = 6 +status = "todo" +calls = [] +depends = ["_usm_sharpening_block"] + +["features/lab/logic.py::apply_resize_sharpening.block"] +phase = 6 +status = "todo" +calls = [] +depends = ["_usm_sharpening_block"] + ["features/lab/logic.py::apply_rl_sharpening"] phase = 6 status = "todo" calls = ["cv2.sepFilter2D"] depends = ["_edge_mask"] +["features/lab/logic.py::apply_saturation"] +phase = 6 +status = "todo" +calls = [] +depends = ["_saturation_block"] + +["features/lab/logic.py::apply_usm_sharpening"] +phase = 6 +status = "todo" +calls = [] +depends = ["_usm_sharpening_block"] + ["features/lab/logic.py::chroma_edge_keep"] phase = 6 status = "todo" calls = ["cv2.filter2D"] depends = [] +["features/lab/processor.py::PhotoLabProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/lith/logic.py::apply_lith"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/lith/processor.py::LithProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + ["features/sabattier/logic.py::apply_sabattier"] phase = 6 status = "todo" calls = ["cv2.sepFilter2D"] depends = [] +["features/sabattier/processor.py::SabattierProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + ["features/toning/logic.py::_apply_chemical_toning_jit"] phase = 6 status = "todo" calls = ["numba"] depends = [] +["features/toning/logic.py::apply_chemical_toning"] +phase = 6 +status = "todo" +calls = [] +depends = ["_apply_chemical_toning_jit"] + +["features/toning/logic.py::apply_split_toning"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/toning/processor.py::ToningProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/transparency/logic.py::apply_transfer_curve"] +phase = 6 +status = "todo" +calls = [] +depends = ["shape"] + +["features/transparency/logic.py::apply_transfer_curve.shape"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/transparency/processor.py::TransferProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + +["features/transparency/processor.py::TransparencyBaseProcessor.process"] +phase = 6 +status = "todo" +calls = [] +depends = [] + ["services/rendering/gpu_engine.py::GPUEngine._process_tiled"] phase = 6 status = "todo" diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs new file mode 100644 index 000000000..8079ca055 --- /dev/null +++ b/negpy_core/src/analysis.rs @@ -0,0 +1,88 @@ +//! Ports of the frame analysis in negpy/features/geometry/logic.py and exposure/normalization.py. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::stats::percentiles; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; + +/// `kernel.image.logic.get_luminance` of an HxWx3 float32 image: Numba's fastmath kernel, +/// f64 with fused multiply-adds, then float32. +pub fn luminance(rgb: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(4096) + .zip(rgb.par_chunks(4096 * 3)) + .for_each(|(d, p)| luminance_chunk(p, d)); +} + +/// `mul_add` is one instruction only where the CPU has FMA, so this compiles twice. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn luminance_chunk(rgb: &[f32], dst: &mut [f32]) { + for (d, p) in dst.iter_mut().zip(rgb.as_chunks::<3>().0) { + let t = LUMA_R * p[0] as f64; + let t = LUMA_G.mul_add(p[1] as f64, t); + *d = LUMA_B.mul_add(p[2] as f64, t) as f32; + } +} + +/// `_normalize_to_uint8` for a float32 image: the 1st and 99th percentiles of its finite +/// values stretched to 0..255, NumPy's float32 arithmetic (NEP 50) and truncating cast. +pub fn normalize_to_uint8(img: &[f32], dst: &mut [u8]) { + let valid: Vec = img.iter().copied().filter(|v| v.is_finite()).collect(); + let Some(p) = percentiles(&valid, &[1.0, 99.0]) else { + dst.fill(0); + return; + }; + let low = p[0] as f64; + let high = if p[1] as f64 <= low { + low + 1.0 + } else { + p[1] as f64 + }; + let (low32, scale32) = (low as f32, (255.0 / (high - low)) as f32); + dst.par_iter_mut().zip(img.par_iter()).for_each(|(d, &v)| { + let s = ((v - low32) * scale32).clamp(0.0, 255.0); + // NumPy's float32 to uint8 cast truncates, NaN to 0 on x86; Rust's `as` does the same. + *d = s as u8; + }); +} + +/// `_detection_luma` of a float32 luminance plane: divided by its 99.5th percentile and +/// clipped to 0..2, in float32. +pub fn detection_luma(lum: &[f32], dst: &mut [f32]) -> Option<()> { + // np.percentile is NaN when any value is, and Python's max(nan, 1e-6) keeps the NaN. + let p = percentiles(lum, &[99.5])?[0] as f64; + let anchor = if lum.iter().any(|v| v.is_nan()) { + f32::NAN + } else { + p.max(1e-6) as f32 + }; + dst.par_iter_mut().zip(lum.par_iter()).for_each(|(d, &v)| { + *d = (v / anchor).clamp(0.0, 2.0); + }); + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_spans_0_to_255() { + let img: Vec = (0..1000).map(|i| i as f32 / 999.0).collect(); + let mut out = vec![0u8; img.len()]; + normalize_to_uint8(&img, &mut out); + assert_eq!(out[0], 0); + assert_eq!(out[999], 255); + } + + #[test] + fn all_nan_normalizes_to_zero() { + let img = vec![f32::NAN; 10]; + let mut out = vec![7u8; 10]; + normalize_to_uint8(&img, &mut out); + assert!(out.iter().all(|&v| v == 0)); + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 040c862b4..60c4eb184 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod analysis; pub mod contours; pub mod filter; pub mod gray; @@ -16,5 +17,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::filter::register(m)?; py::morph::register(m)?; py::gray::register(m)?; - py::contours::register(m) + py::contours::register(m)?; + py::analysis::register(m) } diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs new file mode 100644 index 000000000..0da303ab0 --- /dev/null +++ b/negpy_core/src/py/analysis.rs @@ -0,0 +1,91 @@ +use numpy::{Element, PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::analysis; + +fn empty<'py, T: Element>( + py: Python<'py>, + shape: &[usize], + dtype: &str, +) -> PyResult>> { + Ok(py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), dtype))? + .cast_into::>()?) +} + +fn slice<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `get_luminance` of an HxWx3 float32 image. +#[pyfunction] +fn luminance<'py>( + py: Python<'py>, + rgb: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = rgb.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err("luminance needs an HxWx3 array")); + } + let data = slice(&rgb)?; + let out = empty::(py, &shape[..2], "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| analysis::luminance(data, dst)); + } + Ok(out) +} + +/// `_normalize_to_uint8` of a float32 image. +#[pyfunction] +fn normalize_to_uint8<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = img.shape().to_vec(); + let data = slice(&img)?; + let out = empty::(py, &shape, "uint8")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| analysis::normalize_to_uint8(data, dst)); + } + Ok(out) +} + +/// `_detection_luma` of a float32 luminance plane. +#[pyfunction] +fn detection_luma<'py>( + py: Python<'py>, + lum: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = lum.shape().to_vec(); + let data = slice(&lum)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| analysis::detection_luma(data, dst)) + .ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") + })?; + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(luminance, m)?)?; + m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; + m.add_function(wrap_pyfunction!(detection_luma, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index c591f475d..8c9a0a6f4 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,3 +1,4 @@ +pub mod analysis; pub mod contours; pub mod filter; pub mod gray; diff --git a/scripts/core_manifest.py b/scripts/core_manifest.py index cd83aa877..f51fc8c85 100644 --- a/scripts/core_manifest.py +++ b/scripts/core_manifest.py @@ -14,6 +14,8 @@ # cv2 calls the plan ports last, or may never port: each needs a decision of its own. LAST_CV2 = {"SIFT_create", "BFMatcher", "detail_DpSeamFinder", "createLineSegmentDetector", "inpaint", "phaseCorrelate", "UMat"} STATUSES = ("todo", "ported", "stays") +# Where a pure-Python caller of ported code is itself ported; orchestration elsewhere stays. +CHAIN_PREFIXES = ("features/", "kernel/image/") # First match wins. Entries keep a hand-set phase; this only seeds new ones. PHASE_BY_PATH = [ @@ -72,8 +74,8 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: names.add(f.id) if jit: calls.add("numba") - if calls: - self.found[f"{self.rel}::{'.'.join(self.stack)}"] = {"calls": sorted(calls), "names": names} + # Every function is kept for now; scan() drops those outside the closure. + self.found[f"{self.rel}::{'.'.join(self.stack)}"] = {"calls": sorted(calls), "names": names} self.generic_visit(node) self.stack.pop() @@ -86,6 +88,16 @@ def scan() -> dict[str, dict]: scanner = _Scanner(rel) scanner.visit(ast.parse(path.read_text(encoding="utf-8"))) found.update(scanner.found) + # In scope: a function with a hot call, or one that calls an in-scope function by name (the + # pure-Python links of a chain such as measure_base_log), to a fixed point. + scope = {k for k, v in found.items() if v["calls"]} + while True: + names_in = {k.split("::")[1].split(".")[-1] for k in scope} + grown = {k for k, v in found.items() if k not in scope and k.startswith(CHAIN_PREFIXES) and v["names"] & names_in} + if not grown: + break + scope |= grown + found = {k: v for k, v in found.items() if k in scope} by_module: dict[str, set[str]] = {} for key in found: module, qual = key.split("::") diff --git a/tests/test_numba_nogil.py b/tests/test_numba_nogil.py index 27af7e979..531f88df5 100644 --- a/tests/test_numba_nogil.py +++ b/tests/test_numba_nogil.py @@ -5,7 +5,6 @@ _KERNELS = [ ("negpy.kernel.image.logic", name) for name in ( - "_get_luminance_jit", "_to_uint16_jit", "_to_uint8_jit", "uint8_to_float32", From d1a1e066b3354239b1462621b6133ba2fe6e8298 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:54:31 +0200 Subject: [PATCH 019/133] perf(core): _base_blocks block statistics in Rust negpy_core.flat_block_means computes what _base_blocks needs from the 8x8 blocks (means, max, std, band coverage) in NumPy's order: each block sums its 8 rows outer, from +0, divided by float32 64; the channel means and the flat test in float32; np.max's NaN rule. The float32 log10 and the argsort grouping stay in NumPy: on an AVX-512 CPU NumPy's log10 is SVML's, and argsort's tie order is its SIMD sort's, so neither can be copied bit for bit. The replay workload keeps stage caching on, as the preview does, so measure_base_log is recorded now. Replay: 58/58 phase 2 functions identical, _base_blocks 14/14, measure_base_log 25/25. One thread against the recorded Python (which already used the Rust primitives): _base_blocks 0.165x, measure_base_log 0.782x. --- negpy/features/exposure/normalization.py | 18 ++----- negpy_core/manifest.toml | 2 +- negpy_core/src/analysis.rs | 65 ++++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 36 ++++++++++++- scripts/replay.py | 5 +- 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index ac1c760cf..664597ebc 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -426,12 +426,9 @@ def measure_neutral_axis( #: way negdensity.py reads it. The film box comes from the crop's detector at its own resolution, #: whose edge walk is calibrated there. _BASE_MARGIN = 0.15 # the band inside the film box, per side, that holds rebate, perforations and picture edge -_BASE_BLOCK = 8 # block size at detection resolution -_FLAT_CV = 0.03 # a block whose std over mean is under this is flat film, not edge print or a gradient _GROUP_GAP = 0.04 # decades between neighbouring blocks that split two brightness groups _OPEN_LIGHT_GAP = 0.08 # decades a brighter, smaller group must clear the base to be bare light _MIN_BASE_BLOCKS = 5 -_CLIP_LEVEL = 0.98 # a block with a pixel at sensor white is light the scan clipped, never film def _margin_band(shape: Tuple[int, int], film_roi: ROI) -> np.ndarray: @@ -450,17 +447,12 @@ def _base_blocks(det: np.ndarray, band: np.ndarray) -> Optional[np.ndarray]: Flat blocks are grouped by brightness; the largest group is the base. A smaller group well above it is bare light through a perforation or past the film's edge. A flat picture region in the band that outnumbers the base is taken for it.""" - b = _BASE_BLOCK - hb, wb = det.shape[0] // b, det.shape[1] // b - blocks = det[: hb * b, : wb * b].reshape(hb, b, wb, b, 3) - mean = blocks.mean(axis=(1, 3)) - lum = mean.mean(axis=2) - inside = band[: hb * b, : wb * b].reshape(hb, b, wb, b).mean(axis=(1, 3)) >= 0.9 - unclipped = blocks.max(axis=(1, 3, 4)) < _CLIP_LEVEL - flat = inside & unclipped & (lum > 1e-4) & (blocks.std(axis=(1, 3)).mean(axis=2) / np.maximum(lum, 1e-6) < _FLAT_CV) - if int(flat.sum()) < _MIN_BASE_BLOCKS: + # The block statistics (8x8 blocks, flat under 3% std over mean, unclipped under 0.98) run + # in negpy_core; log10 and the grouping stay in NumPy, whose + # float32 log10 and argsort differ by CPU (SVML, SIMD sort) and cannot be copied bit for bit. + cand = negpy_core.flat_block_means(np.ascontiguousarray(det, dtype=np.float32), np.ascontiguousarray(band, dtype=bool)) + if cand is None: return None - cand = mean[flat] log_lum = np.log10(np.maximum(cand.mean(axis=1), 1e-6)) order = np.argsort(log_lum)[::-1] # brightest first groups: list[np.ndarray] = [] diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index c000701bb..e7eeb0e8c 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -155,7 +155,7 @@ depends = [] ["features/exposure/normalization.py::_base_blocks"] phase = 2 status = "todo" -calls = ["np.argsort"] +calls = ["negpy_core.flat_block_means", "np.argsort"] depends = [] ["features/exposure/normalization.py::_block_median_grid"] diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 8079ca055..a2f4ad877 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -65,6 +65,71 @@ pub fn detection_luma(lum: &[f32], dst: &mut [f32]) -> Option<()> { Some(()) } +const BASE_BLOCK: usize = 8; +const CLIP_LEVEL: f32 = 0.98; +const FLAT_CV: f32 = 0.03; +const MIN_BASE_BLOCKS: usize = 5; + +/// The first half of `_base_blocks`: per-channel means of the 8x8 blocks of `det` (HxWx3) that +/// lie in `band`, hold no clipped pixel and are flat, in block order; None with fewer than +/// five. NumPy's order: each block sums row by row, the 8 rows outer, from +0. +pub fn flat_block_means(det: &[f32], band: &[u8], h: usize, w: usize) -> Option> { + let b = BASE_BLOCK; + let (hb, wb) = (h / b, w / b); + let rows: Vec> = (0..hb) + .into_par_iter() + .map(|by| { + let mut out = Vec::new(); + for bx in 0..wb { + let px = |i1: usize, i3: usize| { + let o = ((by * b + i1) * w + bx * b + i3) * 3; + [det[o], det[o + 1], det[o + 2]] + }; + let mut sum = [0f32; 3]; + let mut top = f32::NEG_INFINITY; + let mut count = 0u32; + for i1 in 0..b { + for i3 in 0..b { + let p = px(i1, i3); + for c in 0..3 { + sum[c] += p[c]; + // np.max propagates NaN, so a block with one is never unclipped. + top = if p[c].is_nan() || top.is_nan() { + f32::NAN + } else { + top.max(p[c]) + }; + } + count += (band[(by * b + i1) * w + bx * b + i3] != 0) as u32; + } + } + let n = (b * b) as f32; + let mean = sum.map(|v| v / n); + let mut sq = [0f32; 3]; + for i1 in 0..b { + for i3 in 0..b { + let p = px(i1, i3); + for c in 0..3 { + let d = p[c] - mean[c]; + sq[c] += d * d; + } + } + } + let std = sq.map(|v| (v / n).sqrt()); + let lum = ((mean[0] + mean[1]) + mean[2]) / 3.0; + let cv = ((std[0] + std[1]) + std[2]) / 3.0 / lum.max(1e-6); + let inside = count as f64 / (b * b) as f64 >= 0.9; + if inside && top < CLIP_LEVEL && lum > 1e-4 && cv < FLAT_CV { + out.push(mean); + } + } + out + }) + .collect(); + let flat: Vec<[f32; 3]> = rows.into_iter().flatten().collect(); + (flat.len() >= MIN_BASE_BLOCKS).then_some(flat) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 0da303ab0..c5fb1fb39 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -84,8 +84,42 @@ fn detection_luma<'py>( Ok(out) } +/// The flat base-candidate blocks of `_base_blocks`: an Nx3 float32 array of block means, or +/// None with too few. +#[pyfunction] +fn flat_block_means<'py>( + py: Python<'py>, + det: PyReadonlyArrayDyn<'py, f32>, + band: PyReadonlyArrayDyn<'py, bool>, +) -> PyResult>>> { + let shape = det.shape().to_vec(); + let [h, w, 3] = shape[..] else { + return Err(PyValueError::new_err( + "flat_block_means needs an HxWx3 array", + )); + }; + if band.shape() != [h, w] { + return Err(PyValueError::new_err("band must be HxW")); + } + let data = slice(&det)?; + let mask: Vec = band + .as_slice() + .map_err(|_| PyValueError::new_err("band must be contiguous"))? + .iter() + .map(|&v| v as u8) + .collect(); + let found = py.detach(|| analysis::flat_block_means(data, &mask, h, w)); + found + .map(|rows| { + let flat: Vec> = rows.iter().map(|r| r.to_vec()).collect(); + numpy::PyArray2::from_vec2(py, &flat).map_err(|e| PyValueError::new_err(e.to_string())) + }) + .transpose() +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; - m.add_function(wrap_pyfunction!(detection_luma, m)?) + m.add_function(wrap_pyfunction!(detection_luma, m)?)?; + m.add_function(wrap_pyfunction!(flat_block_means, m)?) } diff --git a/scripts/replay.py b/scripts/replay.py index 0a176219f..f56713400 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -136,9 +136,8 @@ def _run_frame(path: str, repo=None) -> None: buffer, _, _ = PreviewManager().load_linear_preview(path) saved = repo.load_file_settings(file_hashes(path)[0]) if repo else None config = with_auto_crop(saved or WorkspaceConfig(), True) - ImageProcessor(use_gpu=False).run_pipeline( - buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False, cache_stages=False - ) + # Stage caching on, as the preview runs: the base read (measure_base_log) happens only then. + ImageProcessor(use_gpu=False).run_pipeline(buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False) def _edits_repo(edits_db: str | None): From 76d37758d956916f4d387ee50d3b2018df03e932 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 22 Oct 2026 23:58:24 +0200 Subject: [PATCH 020/133] perf(core): _base_blocks fully in Rust; tolerance ledger After Marcin's decision that transcendental functions come from the pure-Rust libm crate, the log10 and the grouping of _base_blocks move to Rust too; the Python function is one call. Blocks of equal brightness sort by block index, where NumPy's SIMD argsort has its own tie order. negpy_core/LEDGER.md lists the ports that differ from Python (one version bump at release covers them), and the manifest's compare field tells replay how to check them: rows in any order for _base_blocks. Replay: 58/58 phase 2 functions; _base_blocks 8 identical and 6 the same rows in another order; measure_base_log 25/25 identical. --- CLAUDE.md | 2 +- NOTICE.md | 3 +- negpy/features/exposure/normalization.py | 26 +++------------- negpy_core/Cargo.lock | 7 +++++ negpy_core/Cargo.toml | 1 + negpy_core/LEDGER.md | 13 ++++++++ negpy_core/manifest.toml | 9 ++++-- negpy_core/src/analysis.rs | 34 ++++++++++++++++++++- negpy_core/src/py/analysis.rs | 9 +++--- scripts/core_manifest.py | 7 +++-- scripts/replay.py | 38 +++++++++++++++++++----- tests/test_replay_same.py | 10 +++++++ 12 files changed, 116 insertions(+), 43 deletions(-) create mode 100644 negpy_core/LEDGER.md diff --git a/CLAUDE.md b/CLAUDE.md index 55ae5897d..4b89108c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. -- A port that cannot be bit-exact needs the user's agreement, then the `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` bumps. +- Transcendental functions (`log10`, `exp`, `pow`, ...) come from the `libm` crate: NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. ### Pipeline diff --git a/NOTICE.md b/NOTICE.md index ef39d03c2..d89ac4e95 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -63,7 +63,8 @@ The negpy_core extension module links these Rust crates: PyO3 and rust-numpy (MIT OR Apache-2.0; rust-numpy BSD-2-Clause), rayon and crossbeam (MIT OR Apache-2.0), ndarray, num-traits, num-complex, num-integer, matrixmultiply and rawpointer (MIT OR Apache-2.0), libc, once_cell, either, portable-atomic and -rustc-hash (MIT OR Apache-2.0), multiversion and target-features (MIT OR Apache-2.0). +rustc-hash (MIT OR Apache-2.0), multiversion and target-features (MIT OR Apache-2.0), libm (MIT, a port of +musl's math library). negpy_core/Cargo.lock lists the exact versions. negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 664597ebc..d32fe88d4 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -426,9 +426,7 @@ def measure_neutral_axis( #: way negdensity.py reads it. The film box comes from the crop's detector at its own resolution, #: whose edge walk is calibrated there. _BASE_MARGIN = 0.15 # the band inside the film box, per side, that holds rebate, perforations and picture edge -_GROUP_GAP = 0.04 # decades between neighbouring blocks that split two brightness groups _OPEN_LIGHT_GAP = 0.08 # decades a brighter, smaller group must clear the base to be bare light -_MIN_BASE_BLOCKS = 5 def _margin_band(shape: Tuple[int, int], film_roi: ROI) -> np.ndarray: @@ -446,26 +444,10 @@ def _base_blocks(det: np.ndarray, band: np.ndarray) -> Optional[np.ndarray]: Flat blocks are grouped by brightness; the largest group is the base. A smaller group well above it is bare light through a perforation or past the film's edge. A flat - picture region in the band that outnumbers the base is taken for it.""" - # The block statistics (8x8 blocks, flat under 3% std over mean, unclipped under 0.98) run - # in negpy_core; log10 and the grouping stay in NumPy, whose - # float32 log10 and argsort differ by CPU (SVML, SIMD sort) and cannot be copied bit for bit. - cand = negpy_core.flat_block_means(np.ascontiguousarray(det, dtype=np.float32), np.ascontiguousarray(band, dtype=bool)) - if cand is None: - return None - log_lum = np.log10(np.maximum(cand.mean(axis=1), 1e-6)) - order = np.argsort(log_lum)[::-1] # brightest first - groups: list[np.ndarray] = [] - start = 0 - for i in range(1, len(order) + 1): - if i == len(order) or log_lum[order[i - 1]] - log_lum[order[i]] > _GROUP_GAP: - groups.append(order[start:i]) - start = i - min_n = max(_MIN_BASE_BLOCKS, int(0.02 * len(order))) - big = [g for g in groups if len(g) >= min_n] - if not big: - return None - return cand[max(big, key=len)] + picture region in the band that outnumbers the base is taken for it. negpy_core holds the + rules: 8x8 blocks, flat under 3% std over mean, unclipped under 0.98, groups split at 0.04 + decades, at least max(5, 2%) blocks.""" + return negpy_core.base_blocks(np.ascontiguousarray(det, dtype=np.float32), np.ascontiguousarray(band, dtype=bool)) def measure_base_log(image: ImageBuffer) -> Optional[Tuple[float, float, float]]: diff --git a/negpy_core/Cargo.lock b/negpy_core/Cargo.lock index c84223e8b..aeac42dd5 100644 --- a/negpy_core/Cargo.lock +++ b/negpy_core/Cargo.lock @@ -51,6 +51,12 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "matrixmultiply" version = "0.3.11" @@ -102,6 +108,7 @@ dependencies = [ name = "negpy_core" version = "0.1.0" dependencies = [ + "libm", "multiversion", "numpy", "pyo3", diff --git a/negpy_core/Cargo.toml b/negpy_core/Cargo.toml index 49c1b08de..7a92aa428 100644 --- a/negpy_core/Cargo.toml +++ b/negpy_core/Cargo.toml @@ -17,3 +17,4 @@ rayon = "1.12" pyo3 = { version = "0.30", features = ["abi3-py313"], optional = true } numpy = { version = "0.30", optional = true } multiversion = "0.8" +libm = "0.2" diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md new file mode 100644 index 000000000..4c45c4ed0 --- /dev/null +++ b/negpy_core/LEDGER.md @@ -0,0 +1,13 @@ +# Version-bump ledger + +A port whose output cannot match the Python version bit for bit is listed here. The release +that ships negpy_core bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for all +entries, so users render their thumbnails again only once. + +Transcendental functions use the pure-Rust `libm` crate, the same bits on every platform. +NumPy computes them through SVML on an AVX-512 CPU and through libm elsewhere, so the Python +results already differ by CPU. Replay accepts the tolerance listed for each entry. + +| Function | Differs because | Tolerance | +|---|---|---| +| `features/exposure/normalization.py::_base_blocks` | Blocks of equal log brightness come out in block order, not in NumPy's SIMD argsort order. log10 is libm's, not SVML's, so a block on a group gap can change group in rare cases. | rows in any order | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index e7eeb0e8c..befec056d 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1,5 +1,6 @@ # One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes -# calls and depends; phase, status (todo, ported, stays), owner and note are kept by hand. +# calls and depends; phase, status (todo, ported, stays), owner, compare (replay: exact, rows, +# ulp=N; see negpy_core/LEDGER.md) and note are kept by hand. ["kernel/image/logic.py::percentiles"] phase = 1 @@ -154,9 +155,11 @@ depends = [] ["features/exposure/normalization.py::_base_blocks"] phase = 2 -status = "todo" -calls = ["negpy_core.flat_block_means", "np.argsort"] +status = "ported" +compare = "rows" +calls = ["negpy_core.base_blocks"] depends = [] +note = "negpy_core::analysis::base_blocks" ["features/exposure/normalization.py::_block_median_grid"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index a2f4ad877..fd7056ff1 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -73,7 +73,7 @@ const MIN_BASE_BLOCKS: usize = 5; /// The first half of `_base_blocks`: per-channel means of the 8x8 blocks of `det` (HxWx3) that /// lie in `band`, hold no clipped pixel and are flat, in block order; None with fewer than /// five. NumPy's order: each block sums row by row, the 8 rows outer, from +0. -pub fn flat_block_means(det: &[f32], band: &[u8], h: usize, w: usize) -> Option> { +fn flat_block_means(det: &[f32], band: &[u8], h: usize, w: usize) -> Option> { let b = BASE_BLOCK; let (hb, wb) = (h / b, w / b); let rows: Vec> = (0..hb) @@ -130,6 +130,38 @@ pub fn flat_block_means(det: &[f32], band: &[u8], h: usize, w: usize) -> Option< (flat.len() >= MIN_BASE_BLOCKS).then_some(flat) } +const GROUP_GAP: f32 = 0.04; + +/// `_base_blocks`: the flat blocks grouped by log brightness, brightest first, split where two +/// neighbours differ by more than `GROUP_GAP` decades; the largest group of at least +/// max(5, 2%) blocks. log10 is libm's (see LEDGER.md); ties sort by block index. +pub fn base_blocks(det: &[f32], band: &[u8], h: usize, w: usize) -> Option> { + let cand = flat_block_means(det, band, h, w)?; + let log_lum: Vec = cand + .iter() + .map(|m| libm::log10f((((m[0] + m[1]) + m[2]) / 3.0).max(1e-6))) + .collect(); + let mut order: Vec = (0..cand.len()).collect(); + order.sort_by(|&a, &b| log_lum[a].total_cmp(&log_lum[b])); + order.reverse(); + let mut groups: Vec<&[usize]> = Vec::new(); + let mut start = 0; + for i in 1..=order.len() { + if i == order.len() || log_lum[order[i - 1]] - log_lum[order[i]] > GROUP_GAP { + groups.push(&order[start..i]); + start = i; + } + } + let min_n = MIN_BASE_BLOCKS.max((0.02 * order.len() as f64) as usize); + let mut best: Option<&[usize]> = None; + for g in groups.into_iter().filter(|g| g.len() >= min_n) { + if best.is_none_or(|b| g.len() > b.len()) { + best = Some(g); + } + } + best.map(|g| g.iter().map(|&i| cand[i]).collect()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index c5fb1fb39..3467b32c8 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -84,10 +84,9 @@ fn detection_luma<'py>( Ok(out) } -/// The flat base-candidate blocks of `_base_blocks`: an Nx3 float32 array of block means, or -/// None with too few. +/// `_base_blocks(det, band)`: an Nx3 float32 array of the base blocks' means, or None. #[pyfunction] -fn flat_block_means<'py>( +fn base_blocks<'py>( py: Python<'py>, det: PyReadonlyArrayDyn<'py, f32>, band: PyReadonlyArrayDyn<'py, bool>, @@ -108,7 +107,7 @@ fn flat_block_means<'py>( .iter() .map(|&v| v as u8) .collect(); - let found = py.detach(|| analysis::flat_block_means(data, &mask, h, w)); + let found = py.detach(|| analysis::base_blocks(data, &mask, h, w)); found .map(|rows| { let flat: Vec> = rows.iter().map(|r| r.to_vec()).collect(); @@ -121,5 +120,5 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; - m.add_function(wrap_pyfunction!(flat_block_means, m)?) + m.add_function(wrap_pyfunction!(base_blocks, m)?) } diff --git a/scripts/core_manifest.py b/scripts/core_manifest.py index f51fc8c85..3d289298e 100644 --- a/scripts/core_manifest.py +++ b/scripts/core_manifest.py @@ -124,7 +124,7 @@ def merge(current: dict[str, dict], found: dict[str, dict]) -> dict[str, dict]: "status": old.get("status", "todo"), "calls": entry["calls"], "depends": entry["depends"], - **{k: old[k] for k in ("owner", "note") if k in old}, + **{k: old[k] for k in ("owner", "compare", "note") if k in old}, } for key, old in current.items(): if key not in merged and old.get("status") == "ported": @@ -143,12 +143,13 @@ def _toml_value(value) -> str: def dump(manifest: dict[str, dict]) -> str: lines = [ "# One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes", - "# calls and depends; phase, status (todo, ported, stays), owner and note are kept by hand.", + "# calls and depends; phase, status (todo, ported, stays), owner, compare (replay: exact, rows,", + "# ulp=N; see negpy_core/LEDGER.md) and note are kept by hand.", ] for key in sorted(manifest, key=lambda k: (manifest[k]["phase"], k)): lines.append("") lines.append(f"[{_toml_value(key)}]") - for field in ("phase", "status", "owner", "calls", "depends", "note"): + for field in ("phase", "status", "owner", "compare", "calls", "depends", "note"): if field in manifest[key]: lines.append(f"{field} = {_toml_value(manifest[key][field])}") return "\n".join(lines) + "\n" diff --git a/scripts/replay.py b/scripts/replay.py index f56713400..4ef707411 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -173,20 +173,42 @@ def record(files: list[str], phases: list[int] | None, edits_db: str | None) -> print(f"{recorder.saved[key]:4d} {key}") -def same(a, b) -> bool: - """Bit-for-bit equality, NaN payloads and the sign of zero included.""" +def _ulps(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Distance in units in the last place between two float arrays of one dtype.""" + ints = {np.dtype(np.float32): np.int32, np.dtype(np.float64): np.int64}[a.dtype] + ia, ib = (np.ascontiguousarray(x).view(ints).astype(np.int64) for x in (a, b)) + # Map the sign-magnitude float bits onto one ordered integer line. + ia = np.where(ia < 0, np.iinfo(ints).min - ia, ia) + ib = np.where(ib < 0, np.iinfo(ints).min - ib, ib) + return np.abs(ia - ib) + + +def same(a, b, compare: str = "exact") -> bool: + """Equality for replay. "exact": bit for bit, NaN payloads and the sign of zero included. + "rows": a 2-D array's rows in any order. "ulp=N": floats within N units in the last place.""" if type(a) is not type(b): return False if isinstance(a, np.ndarray): - return a.dtype == b.dtype and a.shape == b.shape and np.ascontiguousarray(a).tobytes() == np.ascontiguousarray(b).tobytes() + if a.dtype != b.dtype or a.shape != b.shape: + return False + if compare == "rows" and a.ndim == 2: + rows = f"V{a.shape[1] * a.dtype.itemsize}" + a, b = (np.sort(np.ascontiguousarray(x).view(rows).ravel()) for x in (a, b)) + return a.tobytes() == b.tobytes() + if compare.startswith("ulp=") and a.dtype.kind == "f": + nan = np.isnan(a) + return bool(np.array_equal(nan, np.isnan(b)) and np.all(_ulps(a[~nan], b[~nan]) <= int(compare[4:]))) + return np.ascontiguousarray(a).tobytes() == np.ascontiguousarray(b).tobytes() if isinstance(a, (np.generic, float)): + if compare.startswith("ulp="): + return same(np.atleast_1d(np.asarray(a)), np.atleast_1d(np.asarray(b)), compare) return np.asarray(a).tobytes() == np.asarray(b).tobytes() if isinstance(a, (list, tuple)): - return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b)) + return len(a) == len(b) and all(same(x, y, compare) for x, y in zip(a, b)) if isinstance(a, dict): - return a.keys() == b.keys() and all(same(a[k], b[k]) for k in a) + return a.keys() == b.keys() and all(same(a[k], b[k], compare) for k in a) if dataclasses.is_dataclass(a) or hasattr(a, "__dict__"): - return same(vars(a), vars(b)) + return same(vars(a), vars(b), compare) return a == b @@ -201,11 +223,13 @@ def _calls(keys: list[str]): def replay(phases: list[int] | None) -> int: failed = 0 + manifest = load_manifest() for key, fn, files in _calls(_targets(phases, ("todo", "ported"))): + compare = manifest[key].get("compare", "exact") bad = [] for f in files: rec = pickle.loads(f.read_bytes()) - if not same(fn(*rec["args"], **rec["kwargs"]), rec["result"]): + if not same(fn(*rec["args"], **rec["kwargs"]), rec["result"], compare): bad.append(f"{f.name} ({rec['frame']})") failed += bool(bad) print(f"{'DIFF' if bad else 'same'} {len(files) - len(bad):3d}/{len(files):<3d} {key} {' '.join(bad[:5])}") diff --git a/tests/test_replay_same.py b/tests/test_replay_same.py index 72fd2f2aa..1a41ea4ef 100644 --- a/tests/test_replay_same.py +++ b/tests/test_replay_same.py @@ -21,3 +21,13 @@ def test_same_walks_containers(): assert same((1, [np.uint8(3)], {"k": None}), (1, [np.uint8(3)], {"k": None})) assert not same((1, 2), [1, 2]) assert not same({"k": np.int32(1)}, {"k": np.int64(1)}) + + +def test_same_rows_and_ulp_modes(): + a = np.array([[1, 2, 3], [4, 5, 6]], np.float32) + assert same(a, a[::-1].copy(), "rows") + assert not same(a, a[::-1].copy()) + b = np.nextafter(a, np.float32(10)) + assert same(a, b, "ulp=1") + assert not same(a, b, "ulp=0") + assert same(np.array([np.nan, -0.0], np.float32), np.array([np.nan, 0.0], np.float32), "ulp=1") From b40ce3d218b4e8da5ef81f32ae6fabd99a0141d8 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:04:14 +0200 Subject: [PATCH 021/133] perf(core): port _find_rebate_level negpy_core.find_rebate_level reads the four border strips of the film box as the Python did: the bed cut joins the float32 comparison as float32 (NEP 50), Python's round() for the ring width, NegPy's percentile helper on the strips, and np.percentile's NaN rule for the bed and the box median (np_percentiles). The first of equal levels wins, in top, bottom, left, right order. Replay: _find_rebate_level 60/60, phase 2 58/58. One thread against the recorded Python: 0.566x. --- negpy/features/geometry/logic.py | 42 +--------------- negpy_core/manifest.toml | 5 +- negpy_core/src/analysis.rs | 85 ++++++++++++++++++++++++++++++-- negpy_core/src/py/analysis.rs | 19 ++++++- negpy_core/src/stats.rs | 10 ++++ 5 files changed, 114 insertions(+), 47 deletions(-) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 582faa178..806b0852f 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -15,7 +15,7 @@ AutocropMode, GeometryConfig, ) -from negpy.kernel.image.logic import get_luminance, percentiles +from negpy.kernel.image.logic import get_luminance from negpy.kernel.image.validation import ensure_image AUTOCROP_DETECT_RES = 1800 @@ -1320,45 +1320,7 @@ def _find_rebate_level(lum: np.ndarray, film_roi: ROI, bed: Optional[float] = No qualifying plateau wins. Returns (rebate_level, spread) or None. `bed` is the 99th percentile of `lum`, for a caller that already has it. """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - if bh < 16 or bw < 16: - return None - if bed is None: - bed = float(np.percentile(lum, 99)) - box_median = float(np.percentile(box, 50)) - ring_w = max(3, round(0.04 * min(bh, bw))) - sides = { - "top": box[:ring_w, :], - "bottom": box[-ring_w:, :], - "left": box[:, :ring_w], - "right": box[:, -ring_w:], - } - - qualifying: dict[str, Tuple[float, float]] = {} - for name, strip in sides.items(): - vals = strip[strip < bed - 0.05] # exclude sprocket holes / bed slop in the box - if vals.size < 0.25 * strip.size: - continue - p80, p20, p60 = percentiles(vals, (80, 20, 60)) - spread = float(p80 - p20) - if spread > 0.10: - continue # textured strip = image content reaches this film edge - p60 = float(p60) - if p60 < box_median + 0.10: - continue # rebate is the lowest-density tier: must sit clearly above the - # image interior; a plateau near the median is bright image content - # reaching the film edge (full-bleed) or holder slop, not film base - qualifying[name] = (p60, spread) - - # A genuine film rebate borders the frame, so it shows up as an opposite pair. A lone - # qualifying side is almost always a uniform bright scene region in a full-bleed frame, - # and trusting it carves the picture down to a dark subject. Require a pair. - has_pair = ("top" in qualifying and "bottom" in qualifying) or ("left" in qualifying and "right" in qualifying) - if not has_pair: - return None - return max(qualifying.values(), key=lambda t: t[0]) + return negpy_core.find_rebate_level(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi), bed) def _estimate_tier_levels(lum: np.ndarray, film_roi: ROI) -> Optional[_TierLevels]: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index befec056d..b0b06da44 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -548,9 +548,10 @@ depends = ["_detect_film_bounds", "_refine_roi_to_image"] ["features/geometry/logic.py::_find_rebate_level"] phase = 2 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.find_rebate_level"] depends = [] +note = "negpy_core::analysis::find_rebate_level" ["features/geometry/logic.py::_get_threshold_autocrop_coords"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index fd7056ff1..ded3960b3 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -3,7 +3,7 @@ use multiversion::multiversion; use rayon::prelude::*; -use crate::stats::percentiles; +use crate::stats::{np_percentiles, percentiles}; const LUMA_R: f64 = 0.2126; const LUMA_G: f64 = 0.7152; @@ -52,9 +52,9 @@ pub fn normalize_to_uint8(img: &[f32], dst: &mut [u8]) { /// `_detection_luma` of a float32 luminance plane: divided by its 99.5th percentile and /// clipped to 0..2, in float32. pub fn detection_luma(lum: &[f32], dst: &mut [f32]) -> Option<()> { - // np.percentile is NaN when any value is, and Python's max(nan, 1e-6) keeps the NaN. - let p = percentiles(lum, &[99.5])?[0] as f64; - let anchor = if lum.iter().any(|v| v.is_nan()) { + // Python's max(nan, 1e-6) keeps the NaN. + let p = np_percentiles(lum, &[99.5])?[0] as f64; + let anchor = if p.is_nan() { f32::NAN } else { p.max(1e-6) as f32 @@ -162,6 +162,83 @@ pub fn base_blocks(det: &[f32], band: &[u8], h: usize, w: usize) -> Option Vec { + (y1..y2) + .flat_map(|y| img[y * w + x1..y * w + x2].iter().copied()) + .collect() +} + +/// Python's round() of a float: half to even. +fn py_round(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +/// `_find_rebate_level(lum, film_roi, bed)`: the brightest uniform rebate plateau on a side of +/// the film box, (level, spread), when two opposite sides show one. +pub fn find_rebate_level( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), + bed: Option, +) -> Option<(f64, f64)> { + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2.saturating_sub(y1), x2.saturating_sub(x1)); + if bh < 16 || bw < 16 { + return None; + } + let bed = match bed { + Some(b) => b, + None => np_percentiles(lum, &[99.0])?[0] as f64, + }; + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let box_median = np_percentiles(&boxed, &[50.0])?[0] as f64; + let ring = py_round(0.04 * bh.min(bw) as f64).max(3) as usize; + let ring_h = ring.min(bh); + let ring_w = ring.min(bw); + // top, bottom, left, right: NumPy slices of the box, in its row-major order. + let sides = [ + crop(&boxed, bw, (0, ring_h, 0, bw)), + crop(&boxed, bw, (bh - ring_h, bh, 0, bw)), + crop(&boxed, bw, (0, bh, 0, ring_w)), + crop(&boxed, bw, (0, bh, bw - ring_w, bw)), + ]; + // NEP 50: the Python float threshold joins the float32 comparison as float32. + let cut = (bed - 0.05) as f32; + let mut qualifying: [Option<(f64, f64)>; 4] = [None; 4]; + for (slot, strip) in qualifying.iter_mut().zip(&sides) { + let vals: Vec = strip.iter().copied().filter(|&v| v < cut).collect(); + if (vals.len() as f64) < 0.25 * strip.len() as f64 { + continue; + } + let Some(p) = percentiles(&vals, &[80.0, 20.0, 60.0]) else { + continue; + }; + let spread = (p[0] - p[1]) as f64; + if spread > 0.10 { + continue; + } + let p60 = p[2] as f64; + if p60 < box_median + 0.10 { + continue; + } + *slot = Some((p60, spread)); + } + let [top, bottom, left, right] = qualifying; + if !((top.is_some() && bottom.is_some()) || (left.is_some() && right.is_some())) { + return None; + } + // max() keeps the first of equal levels, in the order the sides were checked. + qualifying + .into_iter() + .flatten() + .fold(None, |best: Option<(f64, f64)>, q| match best { + Some(b) if q.0 <= b.0 => Some(b), + _ => Some(q), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 3467b32c8..65d710713 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -116,9 +116,26 @@ fn base_blocks<'py>( .transpose() } +/// `_find_rebate_level(lum, film_roi, bed)` for a 2-D float32 plane: (level, spread) or None. +#[pyfunction] +#[pyo3(signature = (lum, film_roi, bed=None))] +fn find_rebate_level( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (usize, usize, usize, usize), + bed: Option, +) -> PyResult> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err("find_rebate_level needs a 2-D array")); + }; + let data = slice(&lum)?; + Ok(py.detach(|| analysis::find_rebate_level(data, h, w, film_roi, bed))) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; - m.add_function(wrap_pyfunction!(base_blocks, m)?) + m.add_function(wrap_pyfunction!(base_blocks, m)?)?; + m.add_function(wrap_pyfunction!(find_rebate_level, m)?) } diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index 87b351863..e9fd92669 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -152,6 +152,16 @@ pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { ) } +/// `np.percentile` itself, which is NaN for every q when any value is NaN; `percentiles` +/// matches NegPy's partition helper, which callers feed only finite values. +pub fn np_percentiles(values: &[f32], qs: &[f64]) -> Option> { + let out = percentiles(values, qs)?; + if values.iter().any(|v| v.is_nan()) { + return Some(vec![f32::NAN; qs.len()]); + } + Some(out) +} + #[cfg(test)] mod tests { use super::*; From 9ac157d3254d64b62469c65a2332c049fdb33406 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:08:44 +0200 Subject: [PATCH 022/133] perf(core): port _walk_border_depth negpy_core.walk_border_depth runs the border walk per line as the NumPy version did: np.median's middle-pair float32 mean and NaN rule, the float32 margins (NEP 50), the bridge and the early stop, the masked span peak and its first near-peak index, and int64 out. The constants and their reasoning move with it; _EDGE_REBATE_STEP stays in Python too while other functions use it. Replay: _walk_border_depth 60/60, phase 2 58/58. One thread against the recorded Python: 0.414x. --- negpy/features/geometry/logic.py | 102 ++-------------------- negpy_core/manifest.toml | 5 +- negpy_core/src/analysis.rs | 142 +++++++++++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 22 ++++- 4 files changed, 172 insertions(+), 99 deletions(-) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 806b0852f..d26b1a1a7 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1044,30 +1044,14 @@ def _roi_from_measured_border(lum: np.ndarray, film_roi: ROI) -> ROI | None: return roi -# Camera-scan holder mask against a bed normalized to 1.0. Dense negative bottoms out near -# 0.05, so the cut sits below that. The mask is not always this clean, which is why the walk -# bridges instead of classifying every pixel. -_EDGE_MASK_LEVEL = 0.03 -# Clear film base / bare bed. _detection_luma anchors P99.5 at 1.0, so the bed is a stable -# landmark; picture only reaches here when the frame carries no base at all. -_EDGE_BASE_LEVEL = 0.95 # Gap the walk carries across, covering the mid-gray ramp between mask and base. _EDGE_BRIDGE = 8 _EDGE_TRIM_CAP = 0.03 _EDGE_CORE = 0.70 _EDGE_QUANTILE = 80 _EDGE_MIN_SUPPORT = 0.45 -# Borders that no absolute level finds, read against the picture on the same line. A rebate -# on a thin negative sits above the picture but below the bed, because the frame's own -# highlights set the P99.5 anchor. A holder edge sits below the picture but above the mask -# cut, shading from near-black to mid-grey along the side. Both must end in a step, which is -# what separates them from a bright subject or a deep shadow touching the frame edge. -_EDGE_REBATE_MARGIN = 0.10 -# The dark side runs a wider margin: dense negative crowds the bottom of the range, so a -# shadow sits nearer the picture median than a highlight does. -_EDGE_SHADE_MARGIN = 0.16 +# Mirrored in negpy_core/src/analysis.rs (EDGE_REBATE_STEP) until its other users move. _EDGE_REBATE_STEP = 0.18 -_EDGE_REBATE_REF = 60 # Second, deeper walk. _EDGE_TRIM_CAP is the depth a border may reach on trust alone. Past # it a border must prove itself, because that much of a side is worth real picture. A wide @@ -1084,11 +1068,8 @@ def _roi_from_measured_border(lum: np.ndarray, film_roi: ROI) -> ROI | None: # Proof two: the walk stopped on its own. Reaching the allowance means no boundary was # found inside it, and the depth is then an artefact of where the search was cut off. _EDGE_DEEP_FULL = 0.92 -# Proof three: the step carries most of the drop from the outer level to the picture. A -# boundary does that and a subject shading toward the edge does not. Replaces the fixed -# _EDGE_REBATE_STEP at depth, where a dense frame compresses every level. -_EDGE_STEP_SHARE = 0.45 -_EDGE_STEP_FLOOR = 0.06 +# Proof three: the step carries most of the drop from the outer level to the picture (the +# walk's EDGE_STEP_SHARE in negpy_core). # Proof four: the outermost pixels are one material. Mask, bed and film base each hold their # level along the whole side, so the outer level barely moves from line to line, while # picture reaching the frame edge moves with the subject. @@ -1114,80 +1095,9 @@ def _walk_border_depth(lines: np.ndarray, limit: int, bridge: int, relative_step first picture-like pixel. relative_step scales the step a soft-ended run must show by that run's own drop, - instead of the fixed _EDGE_REBATE_STEP; see _EDGE_STEP_SHARE. - """ - head = lines[: limit + 1] - # Picture level per line, taken past the deepest cut allowed so a thick border - # cannot raise its own reference and hide itself. - picture = np.median(lines[limit : limit + _EDGE_REBATE_REF], axis=0) - - firm = (head < _EDGE_MASK_LEVEL) | (head > _EDGE_BASE_LEVEL) - # Tiers no absolute level reaches, read against the picture on the same line: a rebate - # brighter than the picture but under the bed, and a holder edge darker than the picture - # but over the mask cut. A holder edge shades from near-black to mid-grey along the side, - # so the absolute cut catches only the dark end. - reference = picture[None, :] - border = firm | (head > reference + _EDGE_REBATE_MARGIN) | (head < reference - _EDGE_SHADE_MARGIN) - - depth = np.zeros(head.shape[1], dtype=np.int32) - last = np.full(head.shape[1], -1, dtype=np.int32) - soft = np.zeros(head.shape[1], dtype=bool) - alive = np.ones(head.shape[1], dtype=bool) - # Tracked alongside: how deep the mask and bed alone reach. Firm evidence is its own - # proof, so wherever it runs the border runs and the gradient below never arbitrates. It - # matters when the border stacks two firm tiers, mask outside and film base inside: the - # outer boundary is the stronger step, and a gradient picks that and leaves the base - # inside the frame. - firm_depth = np.zeros(head.shape[1], dtype=np.int32) - firm_last = np.full(head.shape[1], -1, dtype=np.int32) - firm_alive = np.ones(head.shape[1], dtype=bool) - for i in range(head.shape[0]): - # Evidence must chain back to the frame edge: the border tiers are the outermost - # things on the film, never something found well inside the picture. - alive &= (i - last) <= bridge - firm_alive &= (i - firm_last) <= bridge - if not alive.any() and not firm_alive.any(): - break - hit = alive & border[i] - last = np.where(hit, i, last) - depth = np.where(hit, i + 1, depth) - soft = np.where(hit, ~firm[i], soft) - - firm_hit = firm_alive & firm[i] - firm_last = np.where(firm_hit, i, firm_last) - firm_depth = np.where(firm_hit, i + 1, firm_depth) - - # Where the walk ended on a relative test alone, the border edge comes from the strongest - # gradient inside the run, not from where the level test gave out. A level test only - # nominates candidates: against a bright picture, "darker than the reference" stays true - # well into the picture's own shadows and the run ends mid-ramp. The gradient is the - # boundary. - # - # That peak must also be a real step. A border stops at the frame edge, a hard boundary, - # while a bright subject or deep shadow running to the picture edge shades off, and - # reading that as a border carves the frame down. Magnitude, not sign: a rebate is - # brighter than the picture it ends against and a holder edge is darker. Mask and bed - # need no such proof, since nothing else on the film reaches those levels. - span = np.abs(lines[3 : limit + 3] - lines[:limit]) - reach = np.arange(span.shape[0])[:, None] < depth[None, :] - masked = np.where(reach, span, -1.0) - peak = masked.max(axis=0) - # The outermost span that nearly matches the peak, not the peak itself. Where the picture - # keeps rising past the boundary, a span straddling the step plus some picture outscores - # the step alone and drags the cut inward. Ties go outward, keeping picture. - peak_at = np.argmax(masked >= (0.9 * peak)[None, :], axis=0) - # span[j] compares j against j+3, so a step between j+2 and j+3 first peaks at j: the - # boundary is the far end of the winning span, not its middle. - resolved = np.clip(peak_at + 3, 0, limit) - if relative_step: - # Scaled by the run's own drop, not fixed. A dense frame compresses every level toward - # black, so its borders step by less while still stepping by nearly all there is. - outer = np.median(head[:3], axis=0) - gate = np.maximum(_EDGE_STEP_FLOOR, _EDGE_STEP_SHARE * np.abs(outer - picture)) - else: - gate = np.full(peak.shape, _EDGE_REBATE_STEP, dtype=peak.dtype) - depth = np.where(soft, np.where(peak >= gate, resolved, 0), depth) - return np.maximum(depth, firm_depth) + instead of the fixed _EDGE_REBATE_STEP; negpy_core's walk_border_depth holds the rule. + """ + return negpy_core.walk_border_depth(np.ascontiguousarray(lines, dtype=np.float32), int(limit), int(bridge), bool(relative_step)) def _pool_border_depth(depth: np.ndarray, limit: int) -> int: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index b0b06da44..bf57bb36f 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -694,9 +694,10 @@ depends = [] ["features/geometry/logic.py::_walk_border_depth"] phase = 2 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.walk_border_depth"] depends = [] +note = "negpy_core::analysis::walk_border_depth" ["features/geometry/logic.py::_weighted_median"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index ded3960b3..a65867c56 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -239,6 +239,148 @@ pub fn find_rebate_level( }) } +// Camera-scan holder mask against a bed normalized to 1.0. Dense negative bottoms out near +// 0.05, so the cut sits below that. The mask is not always this clean, which is why the walk +// bridges instead of classifying every pixel. +const EDGE_MASK_LEVEL: f32 = 0.03; +// Clear film base / bare bed. _detection_luma anchors P99.5 at 1.0, so the bed is a stable +// landmark; picture only reaches here when the frame carries no base at all. +const EDGE_BASE_LEVEL: f32 = 0.95; +// Borders that no absolute level finds, read against the picture on the same line. A rebate +// on a thin negative sits above the picture but below the bed, because the frame's own +// highlights set the P99.5 anchor. A holder edge sits below the picture but above the mask +// cut, shading from near-black to mid-grey along the side. Both must end in a step, which is +// what separates them from a bright subject or a deep shadow touching the frame edge. +const EDGE_REBATE_MARGIN: f32 = 0.10; +// The dark side runs a wider margin: dense negative crowds the bottom of the range, so a +// shadow sits nearer the picture median than a highlight does. +const EDGE_SHADE_MARGIN: f32 = 0.16; +// Mirrors geometry/logic.py's _EDGE_REBATE_STEP while it has Python users. +const EDGE_REBATE_STEP: f32 = 0.18; +const EDGE_REBATE_REF: usize = 60; +// The step carries most of the drop from the outer level to the picture. A boundary does that +// and a subject shading toward the edge does not. Replaces the fixed EDGE_REBATE_STEP at +// depth, where a dense frame compresses every level. +const EDGE_STEP_SHARE: f32 = 0.45; +const EDGE_STEP_FLOOR: f32 = 0.06; + +/// `np.median` of a column of float32 values: the middle one, or the float32 mean of the two +/// middle ones; NaN when any value is NaN. +fn np_median(col: &mut [f32]) -> f32 { + if col.iter().any(|v| v.is_nan()) { + return f32::NAN; + } + let n = col.len(); + let (_, &mut hi, _) = col.select_nth_unstable_by(n / 2, f32::total_cmp); + if n % 2 == 1 { + return hi; + } + let lo = col[..n / 2] + .iter() + .copied() + .fold(f32::NEG_INFINITY, f32::max); + (lo + hi) / 2.0 +} + +/// np.max with NaN propagation. +fn np_max(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.max(b) + } +} + +/// `_walk_border_depth(lines, limit, bridge, relative_step)`: `lines` is L x N (steps inward x +/// lines), the result one depth per line. +pub fn walk_border_depth( + lines: &[f32], + rows: usize, + n: usize, + limit: usize, + bridge: i64, + relative_step: bool, +) -> Option> { + if rows < limit + 3 || lines.len() != rows * n { + return None; + } + let at = |r: usize, c: usize| lines[r * n + c]; + let ref_end = (limit + EDGE_REBATE_REF).min(rows); + let picture: Vec = (0..n) + .map(|c| np_median(&mut (limit..ref_end).map(|r| at(r, c)).collect::>())) + .collect(); + + let mut depth = vec![0i64; n]; + let mut last = vec![-1i64; n]; + let mut soft = vec![false; n]; + let mut alive = vec![true; n]; + let mut firm_depth = vec![0i64; n]; + let mut firm_last = vec![-1i64; n]; + let mut firm_alive = vec![true; n]; + for i in 0..=limit { + let ii = i as i64; + for c in 0..n { + alive[c] &= ii - last[c] <= bridge; + firm_alive[c] &= ii - firm_last[c] <= bridge; + } + if !alive.iter().any(|&a| a) && !firm_alive.iter().any(|&a| a) { + break; + } + for c in 0..n { + let v = at(i, c); + let firm = v < EDGE_MASK_LEVEL || v > EDGE_BASE_LEVEL; + let border = + firm || v > picture[c] + EDGE_REBATE_MARGIN || v < picture[c] - EDGE_SHADE_MARGIN; + if alive[c] && border { + last[c] = ii; + depth[c] = ii + 1; + soft[c] = !firm; + } + if firm_alive[c] && firm { + firm_last[c] = ii; + firm_depth[c] = ii + 1; + } + } + } + + let out = (0..n) + .map(|c| { + // span[j] = |lines[j + 3] - lines[j]|, masked to -1 past the walk's depth. + let masked: Vec = (0..limit) + .map(|j| { + if (j as i64) < depth[c] { + (at(j + 3, c) - at(j, c)).abs() + } else { + -1.0 + } + }) + .collect(); + let peak = masked.iter().copied().fold(masked[0], np_max); + let near = 0.9f32 * peak; + let peak_at = masked.iter().position(|&m| m >= near).unwrap_or(0) as i64; + let resolved = (peak_at + 3).clamp(0, limit as i64); + let gate = if relative_step { + let outer = np_median(&mut [at(0, c), at(1, c), at(2, c)]); + let g = EDGE_STEP_SHARE * (outer - picture[c]).abs(); + if g.is_nan() { + g + } else { + g.max(EDGE_STEP_FLOOR) + } + } else { + EDGE_REBATE_STEP + }; + let d = if soft[c] { + if peak >= gate { resolved } else { 0 } + } else { + depth[c] + }; + d.max(firm_depth[c]) + }) + .collect(); + Some(out) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 65d710713..e61d0c0d7 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -132,10 +132,30 @@ fn find_rebate_level( Ok(py.detach(|| analysis::find_rebate_level(data, h, w, film_roi, bed))) } +/// `_walk_border_depth(lines, limit, bridge, relative_step)` for an L x N float32 array. +#[pyfunction] +fn walk_border_depth<'py>( + py: Python<'py>, + lines: PyReadonlyArrayDyn<'py, f32>, + limit: usize, + bridge: i64, + relative_step: bool, +) -> PyResult>> { + let [rows, n] = lines.shape()[..] else { + return Err(PyValueError::new_err("walk_border_depth needs a 2-D array")); + }; + let data = slice(&lines)?; + let out = py + .detach(|| analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step)) + .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; + Ok(numpy::PyArray1::from_vec(py, out)) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; m.add_function(wrap_pyfunction!(base_blocks, m)?)?; - m.add_function(wrap_pyfunction!(find_rebate_level, m)?) + m.add_function(wrap_pyfunction!(find_rebate_level, m)?)?; + m.add_function(wrap_pyfunction!(walk_border_depth, m)?) } From eec900ab49da5a4fa45658984315c536e88c062e Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:13:46 +0200 Subject: [PATCH 023/133] perf(core): port measure_film_edges and _trim_film_edges The edge walk's whole subtree moves to Rust: the per-side orientation and core-column crop, the near and deep walks, _pool_border_depth (np.percentile's float64 lerp on int depths), _border_edge_scatter (Theil-Sen with float64 medians), the four deep-walk proofs, and the trim with Python's round(). The Python helpers and constants are deleted; their reasoning moves onto the Rust items. Replay skips a ported helper whose Python is gone, since its callers cover it. Replay: measure_film_edges 60/60, _trim_film_edges 60/60, phase 2 54/54. --- negpy/features/geometry/logic.py | 160 +----------------- negpy_core/manifest.toml | 23 ++- negpy_core/src/analysis.rs | 275 ++++++++++++++++++++++++++++++- negpy_core/src/py/analysis.rs | 35 +++- scripts/replay.py | 5 +- 5 files changed, 327 insertions(+), 171 deletions(-) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index d26b1a1a7..56c444d03 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1044,130 +1044,6 @@ def _roi_from_measured_border(lum: np.ndarray, film_roi: ROI) -> ROI | None: return roi -# Gap the walk carries across, covering the mid-gray ramp between mask and base. -_EDGE_BRIDGE = 8 -_EDGE_TRIM_CAP = 0.03 -_EDGE_CORE = 0.70 -_EDGE_QUANTILE = 80 -_EDGE_MIN_SUPPORT = 0.45 -# Mirrored in negpy_core/src/analysis.rs (EDGE_REBATE_STEP) until its other users move. -_EDGE_REBATE_STEP = 0.18 - -# Second, deeper walk. _EDGE_TRIM_CAP is the depth a border may reach on trust alone. Past -# it a border must prove itself, because that much of a side is worth real picture. A wide -# holder margin, a sprocket rail or a neighbouring frame all run several times deeper than -# the cap, and clamping there leaves a band the eye reads as an uncropped edge. -_EDGE_DEEP_CAP = 0.10 -# Wider than _EDGE_BRIDGE: a border this thick is layered (mask, ramp, base, sometimes a -# second mask segment) and the mid-gray runs between the tiers are longer at this depth. -_EDGE_DEEP_BRIDGE = 12 -# Proof one: the step ends a straight edge. A film boundary is a cut line, so the per-line -# depths fit a sloped line to within a fraction of the allowance. Picture content resolved -# by the same rule scatters several times wider. -_EDGE_DEEP_STRAIGHT = 0.025 -# Proof two: the walk stopped on its own. Reaching the allowance means no boundary was -# found inside it, and the depth is then an artefact of where the search was cut off. -_EDGE_DEEP_FULL = 0.92 -# Proof three: the step carries most of the drop from the outer level to the picture (the -# walk's EDGE_STEP_SHARE in negpy_core). -# Proof four: the outermost pixels are one material. Mask, bed and film base each hold their -# level along the whole side, so the outer level barely moves from line to line, while -# picture reaching the frame edge moves with the subject. -_EDGE_DEEP_OUTER_SPREAD = 0.15 - -_EDGE_ORIENT = { - "top": lambda box: box, - "bottom": lambda box: box[::-1], - "left": lambda box: box.T, - "right": lambda box: box.T[::-1], -} - - -def _walk_border_depth(lines: np.ndarray, limit: int, bridge: int, relative_step: bool) -> np.ndarray: - """ - Per-line depth of the non-image border, walking inward from the frame edge. - - A camera scan puts two tiers outside the picture, either of which may be absent: an - opaque holder mask at the very edge, then a clear film-base sliver. Neither is - separable by level alone — the mask overlaps dense negative around 0.05 and the ramp - between the tiers sits squarely in picture range — but the tiers are adjacent, so the - walk carries across a short gap and stops at the last border evidence instead of the - first picture-like pixel. - - relative_step scales the step a soft-ended run must show by that run's own drop, - instead of the fixed _EDGE_REBATE_STEP; negpy_core's walk_border_depth holds the rule. - """ - return negpy_core.walk_border_depth(np.ascontiguousarray(lines, dtype=np.float32), int(limit), int(bridge), bool(relative_step)) - - -def _pool_border_depth(depth: np.ndarray, limit: int) -> int: - if float((depth > 0).mean()) < _EDGE_MIN_SUPPORT: - return 0 # a minority of lines: incidental highlights, not a border - return int(min(limit, np.percentile(depth, _EDGE_QUANTILE))) - - -def _border_edge_scatter(depth: np.ndarray) -> float: - """ - How far the resolved depths sit off a straight line, in pixels (median residual). - - A film boundary is a cut line, so a robust fit through the per-line depths leaves - almost nothing; the slope absorbs the tilt every frame has. Picture content resolved - by the same rule follows the subject instead and scatters. - """ - live = depth > 0 - if int(live.sum()) < 8: - return float("inf") - index = np.nonzero(live)[0].astype(np.float64) - value = depth[live].astype(np.float64) - step = max(1, index.size // 48) - a, b = index[::step], value[::step] - # Theil-Sen over a coarse subsample: the median pairwise slope ignores the runs that - # follow a subject, which a least-squares fit would let tilt the line. - da, db = a[:, None] - a[None, :], b[:, None] - b[None, :] - upper = np.triu(np.ones_like(da, dtype=bool), 1) & (da != 0) - slope = float(np.median(db[upper] / da[upper])) if upper.any() else 0.0 - intercept = float(np.median(value - slope * index)) - return float(np.median(np.abs(value - (slope * index + intercept)))) - - -def _edge_border_depth(box: np.ndarray, side: str, limit: int, deep_limit: int = 0) -> int: - """ - Depth of the non-image border on one side of a film box, in pixels. - - Walks each line inward on its own and pools the depths — per line rather than per row - because these frames are slightly tilted, and one row crosses both border tiers and the - picture at once, which smears every per-row statistic together. - - Two passes. The first reaches `limit` and is believed as it stands. The second reaches - `deep_limit`, and is adopted only where it goes deeper and proves itself: it must stop - inside its allowance, leave a straight edge, and end on a step that carries most of the - run's drop. Past `limit` a wrong answer costs real picture, so the burden is on the - deeper reading. - """ - lines = _EDGE_ORIENT[side](box) - n = lines.shape[1] - margin = int(round((1.0 - _EDGE_CORE) * 0.5 * n)) - if n - 2 * margin >= 8: - lines = lines[:, margin : n - margin] - if limit <= 0 or lines.shape[0] < limit + 8 or lines.shape[1] < 8: - return 0 - - near = _pool_border_depth(_walk_border_depth(lines, limit, _EDGE_BRIDGE, False), limit) - if deep_limit <= limit or lines.shape[0] < deep_limit + 8: - return near - - deep = _walk_border_depth(lines, deep_limit, _EDGE_DEEP_BRIDGE, True) - far = _pool_border_depth(deep, deep_limit) - if far <= near or far >= _EDGE_DEEP_FULL * deep_limit: - return near - if _border_edge_scatter(deep) > _EDGE_DEEP_STRAIGHT * deep_limit: - return near - outer = np.median(lines[:3], axis=0)[deep > 0] - if float(np.median(np.abs(outer - np.median(outer)))) > _EDGE_DEEP_OUTER_SPREAD: - return near - return far - - def measure_film_edges(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: """ Per-side holder-mask + film-base thickness inside a film box, as a fraction of that @@ -1178,22 +1054,8 @@ def measure_film_edges(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: few pixels, so that ring straddles both tiers plus the picture and abstains; this walks the edge itself instead. """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - if bh < 16 or bw < 16: - return {name: 0.0 for name in BORDER_SIDES} - - ly = int(round(_EDGE_TRIM_CAP * bh)) - lx = int(round(_EDGE_TRIM_CAP * bw)) - dy = int(round(_EDGE_DEEP_CAP * bh)) - dx = int(round(_EDGE_DEEP_CAP * bw)) - return { - "top": _edge_border_depth(box, "top", ly, dy) / bh, - "bottom": _edge_border_depth(box, "bottom", ly, dy) / bh, - "left": _edge_border_depth(box, "left", lx, dx) / bw, - "right": _edge_border_depth(box, "right", lx, dx) / bw, - } + sides = negpy_core.measure_film_edges(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) + return dict(zip(BORDER_SIDES, sides)) def _trim_film_edges(lum: np.ndarray, film_roi: ROI) -> ROI: @@ -1203,22 +1065,10 @@ def _trim_film_edges(lum: np.ndarray, film_roi: ROI) -> ROI: Sides are independent: a frame sitting off-centre in the holder shows a border on some sides and none on others, so requiring an opposite pair (as _roi_from_measured_border does) would leave the border on. The caps are what bound the damage instead — picture - running to the frame edge is trimmed by at most _EDGE_TRIM_CAP of the side, or - _EDGE_DEEP_CAP where the deeper walk proved a border. + running to the frame edge is trimmed by at most 3% of the side, or 10% where the + deeper walk proved a border (negpy_core holds the walk and its constants). """ - y1, y2, x1, x2 = film_roi - height, width = y2 - y1, x2 - x1 - measured = measure_film_edges(lum, film_roi) - - roi = ( - y1 + round(measured["top"] * height), - y2 - round(measured["bottom"] * height), - x1 + round(measured["left"] * width), - x2 - round(measured["right"] * width), - ) - if roi[1] - roi[0] < 0.5 * height or roi[3] - roi[2] < 0.5 * width: - return film_roi - return roi + return negpy_core.trim_film_edges(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) def _find_rebate_level(lum: np.ndarray, film_roi: ROI, bed: Optional[float] = None) -> Optional[Tuple[float, float]]: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index bf57bb36f..60e591b06 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -481,9 +481,10 @@ depends = [] ["features/geometry/logic.py::_border_edge_scatter"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median"] depends = [] +note = "negpy_core::analysis::border_edge_scatter (Python deleted)" ["features/geometry/logic.py::_candidate_from_contour"] phase = 2 @@ -518,9 +519,10 @@ note = "negpy_core::analysis::detection_luma" ["features/geometry/logic.py::_edge_border_depth"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median"] depends = ["_border_edge_scatter", "_pool_border_depth", "_walk_border_depth"] +note = "negpy_core::analysis::edge_border_depth (Python deleted)" ["features/geometry/logic.py::_enforce_ratio_by_occupancy"] phase = 2 @@ -610,9 +612,10 @@ depends = [] ["features/geometry/logic.py::_pool_border_depth"] phase = 2 -status = "todo" +status = "ported" calls = ["np.percentile"] depends = [] +note = "negpy_core::analysis::pool_border_depth (Python deleted)" ["features/geometry/logic.py::_pre_keystone_point"] phase = 2 @@ -682,9 +685,10 @@ depends = [] ["features/geometry/logic.py::_trim_film_edges"] phase = 2 -status = "todo" -calls = [] -depends = ["measure_film_edges"] +status = "ported" +calls = ["negpy_core.trim_film_edges"] +depends = [] +note = "negpy_core::analysis::trim_film_edges" ["features/geometry/logic.py::_vertical_edge_profile"] phase = 2 @@ -791,9 +795,10 @@ depends = [] ["features/geometry/logic.py::measure_film_edges"] phase = 2 -status = "todo" -calls = [] -depends = ["_edge_border_depth"] +status = "ported" +calls = ["negpy_core.measure_film_edges"] +depends = [] +note = "negpy_core::analysis::measure_film_edges" ["features/geometry/logic.py::resolve_autocrop_rect"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index a65867c56..9a6e86da3 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -255,7 +255,6 @@ const EDGE_REBATE_MARGIN: f32 = 0.10; // The dark side runs a wider margin: dense negative crowds the bottom of the range, so a // shadow sits nearer the picture median than a highlight does. const EDGE_SHADE_MARGIN: f32 = 0.16; -// Mirrors geometry/logic.py's _EDGE_REBATE_STEP while it has Python users. const EDGE_REBATE_STEP: f32 = 0.18; const EDGE_REBATE_REF: usize = 60; // The step carries most of the drop from the outer level to the picture. A boundary does that @@ -267,7 +266,7 @@ const EDGE_STEP_FLOOR: f32 = 0.06; /// `np.median` of a column of float32 values: the middle one, or the float32 mean of the two /// middle ones; NaN when any value is NaN. fn np_median(col: &mut [f32]) -> f32 { - if col.iter().any(|v| v.is_nan()) { + if col.is_empty() || col.iter().any(|v| v.is_nan()) { return f32::NAN; } let n = col.len(); @@ -291,8 +290,13 @@ fn np_max(a: f32, b: f32) -> f32 { } } -/// `_walk_border_depth(lines, limit, bridge, relative_step)`: `lines` is L x N (steps inward x -/// lines), the result one depth per line. +/// `_walk_border_depth(lines, limit, bridge, relative_step)`: per line, the depth of the +/// non-image border walking inward from the frame edge. `lines` is L x N (steps inward x +/// lines). A camera scan puts two tiers outside the picture, either of which may be absent: a +/// holder mask at the very edge, then a film-base sliver. Neither is separable by level alone, +/// but the tiers are adjacent, so the walk carries across a short gap and stops at the last +/// border evidence. A run that ended on a relative test takes its boundary from the strongest +/// step inside it, and that step must clear a gate (fixed, or a share of the run's own drop). pub fn walk_border_depth( lines: &[f32], rows: usize, @@ -381,6 +385,269 @@ pub fn walk_border_depth( Some(out) } +// Gap the walk carries across, covering the mid-gray ramp between mask and base. +const EDGE_BRIDGE: i64 = 8; +const EDGE_TRIM_CAP: f64 = 0.03; +const EDGE_CORE: f64 = 0.70; +const EDGE_QUANTILE: f64 = 80.0; +const EDGE_MIN_SUPPORT: f64 = 0.45; +// Second, deeper walk. EDGE_TRIM_CAP is the depth a border may reach on trust alone. Past it +// a border must prove itself, because that much of a side is worth real picture. A wide +// holder margin, a sprocket rail or a neighbouring frame all run several times deeper than +// the cap, and clamping there leaves a band the eye reads as an uncropped edge. +const EDGE_DEEP_CAP: f64 = 0.10; +// Wider than EDGE_BRIDGE: a border this thick is layered (mask, ramp, base, sometimes a second +// mask segment) and the mid-gray runs between the tiers are longer at this depth. +const EDGE_DEEP_BRIDGE: i64 = 12; +// Proof one: the step ends a straight edge. A film boundary is a cut line, so the per-line +// depths fit a sloped line to within a fraction of the allowance. Picture content resolved by +// the same rule scatters several times wider. +const EDGE_DEEP_STRAIGHT: f64 = 0.025; +// Proof two: the walk stopped on its own. Reaching the allowance means no boundary was found +// inside it, and the depth is then an artefact of where the search was cut off. +const EDGE_DEEP_FULL: f64 = 0.92; +// Proof three is EDGE_STEP_SHARE. Proof four: the outermost pixels are one material. Mask, +// bed and film base each hold their level along the whole side, so the outer level barely +// moves from line to line, while picture reaching the frame edge moves with the subject. +const EDGE_DEEP_OUTER_SPREAD: f64 = 0.15; + +/// `np.percentile` of float64 data (or integers, which NumPy interpolates in float64). +fn np_percentile_f64(values: &[f64], q: f64) -> f64 { + let mut v = values.to_vec(); + if v.iter().any(|x| x.is_nan()) { + return f64::NAN; + } + let n = v.len(); + let virtual_index = (n - 1) as f64 * (q / 100.0); + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let hi = (lo + 1).min(n - 1); + let t = virtual_index - lo as f64; + v.sort_by(f64::total_cmp); + let (a, b) = (v[lo], v[hi]); + let diff = b - a; + if t >= 0.5 { + b - diff * (1.0 - t) + } else { + a + diff * t + } +} + +/// `np.median` of float64 data. +fn np_median_f64(values: &[f64]) -> f64 { + if values.is_empty() || values.iter().any(|x| x.is_nan()) { + return f64::NAN; + } + let mut v = values.to_vec(); + v.sort_by(f64::total_cmp); + let n = v.len(); + if n % 2 == 1 { + v[n / 2] + } else { + (v[n / 2 - 1] + v[n / 2]) / 2.0 + } +} + +/// `_pool_border_depth`: the 80th percentile of the line depths, capped at `limit`, or 0 when +/// fewer than 45% of the lines show a border. +fn pool_border_depth(depth: &[i64], limit: i64) -> i64 { + let live = depth.iter().filter(|&&d| d > 0).count(); + if (live as f64 / depth.len() as f64) < EDGE_MIN_SUPPORT { + return 0; + } + let as_f: Vec = depth.iter().map(|&d| d as f64).collect(); + (limit as f64).min(np_percentile_f64(&as_f, EDGE_QUANTILE)) as i64 +} + +/// `_border_edge_scatter`: the median residual of the live depths around a Theil-Sen line. +fn border_edge_scatter(depth: &[i64]) -> f64 { + let index: Vec = (0..depth.len()) + .filter(|&i| depth[i] > 0) + .map(|i| i as f64) + .collect(); + if index.len() < 8 { + return f64::INFINITY; + } + let value: Vec = depth + .iter() + .filter(|&&d| d > 0) + .map(|&d| d as f64) + .collect(); + let step = (index.len() / 48).max(1); + let a: Vec = index.iter().step_by(step).copied().collect(); + let b: Vec = value.iter().step_by(step).copied().collect(); + let mut slopes = Vec::new(); + for i in 0..a.len() { + for j in i + 1..a.len() { + let da = a[i] - a[j]; + if da != 0.0 { + slopes.push((b[i] - b[j]) / da); + } + } + } + let slope = if slopes.is_empty() { + 0.0 + } else { + np_median_f64(&slopes) + }; + let offsets: Vec = value + .iter() + .zip(&index) + .map(|(v, i)| v - slope * i) + .collect(); + let intercept = np_median_f64(&offsets); + let residuals: Vec = value + .iter() + .zip(&index) + .map(|(v, i)| (v - (slope * i + intercept)).abs()) + .collect(); + np_median_f64(&residuals) +} + +#[derive(Clone, Copy)] +enum Side { + Top, + Bottom, + Left, + Right, +} + +/// `_EDGE_ORIENT[side](box)`, cropped to the core columns: lines run inward from the edge +/// (rows), one per position along it (columns). +fn edge_lines(boxed: &[f32], bh: usize, bw: usize, side: Side) -> (Vec, usize, usize) { + let (rows, n) = match side { + Side::Top | Side::Bottom => (bh, bw), + Side::Left | Side::Right => (bw, bh), + }; + let at = |r: usize, c: usize| match side { + Side::Top => boxed[r * bw + c], + Side::Bottom => boxed[(bh - 1 - r) * bw + c], + Side::Left => boxed[c * bw + r], + Side::Right => boxed[c * bw + (bw - 1 - r)], + }; + let margin = py_round((1.0 - EDGE_CORE) * 0.5 * n as f64).max(0) as usize; + let (c0, c1) = if n >= 2 * margin + 8 { + (margin, n - margin) + } else { + (0, n) + }; + let cols = c1 - c0; + let mut out = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in c0..c1 { + out.push(at(r, c)); + } + } + (out, rows, cols) +} + +/// `_edge_border_depth(box, side, limit, deep_limit)`: the border depth on one side, per line +/// (these frames are slightly tilted) and pooled. A first walk to `limit` is believed; a second +/// to `deep_limit` is adopted only where it goes deeper and passes all four proofs. +fn edge_border_depth( + boxed: &[f32], + bh: usize, + bw: usize, + side: Side, + limit: i64, + deep_limit: i64, +) -> i64 { + let (lines, rows, cols) = edge_lines(boxed, bh, bw, side); + if limit <= 0 || (rows as i64) < limit + 8 || cols < 8 { + return 0; + } + let Some(walk) = walk_border_depth(&lines, rows, cols, limit as usize, EDGE_BRIDGE, false) + else { + return 0; + }; + let near = pool_border_depth(&walk, limit); + if deep_limit <= limit || (rows as i64) < deep_limit + 8 { + return near; + } + let Some(deep) = walk_border_depth( + &lines, + rows, + cols, + deep_limit as usize, + EDGE_DEEP_BRIDGE, + true, + ) else { + return near; + }; + let far = pool_border_depth(&deep, deep_limit); + if far <= near || far as f64 >= EDGE_DEEP_FULL * deep_limit as f64 { + return near; + } + if border_edge_scatter(&deep) > EDGE_DEEP_STRAIGHT * deep_limit as f64 { + return near; + } + let mut outer: Vec = (0..cols) + .filter(|&c| deep[c] > 0) + .map(|c| np_median(&mut [lines[c], lines[cols + c], lines[2 * cols + c]])) + .collect(); + // np.median of nothing is NaN, and a NaN spread passes no comparison. + if outer.is_empty() { + return far; + } + let mid = np_median(&mut outer.clone()); + let mut spread: Vec = outer.iter_mut().map(|v| (*v - mid).abs()).collect(); + if np_median(&mut spread) as f64 > EDGE_DEEP_OUTER_SPREAD { + return near; + } + far +} + +/// `measure_film_edges(lum, film_roi)`: per side (top, bottom, left, right) the border depth as +/// a fraction of that side. +pub fn measure_film_edges( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> [f64; 4] { + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2.saturating_sub(y1), x2.saturating_sub(x1)); + if bh < 16 || bw < 16 { + return [0.0; 4]; + } + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let ly = py_round(EDGE_TRIM_CAP * bh as f64); + let lx = py_round(EDGE_TRIM_CAP * bw as f64); + let dy = py_round(EDGE_DEEP_CAP * bh as f64); + let dx = py_round(EDGE_DEEP_CAP * bw as f64); + [ + edge_border_depth(&boxed, bh, bw, Side::Top, ly, dy) as f64 / bh as f64, + edge_border_depth(&boxed, bh, bw, Side::Bottom, ly, dy) as f64 / bh as f64, + edge_border_depth(&boxed, bh, bw, Side::Left, lx, dx) as f64 / bw as f64, + edge_border_depth(&boxed, bh, bw, Side::Right, lx, dx) as f64 / bw as f64, + ] +} + +/// `_trim_film_edges(lum, film_roi)`: the box inset past the border on each side, or the box +/// itself when that would take half a side. +pub fn trim_film_edges( + lum: &[f32], + h: usize, + w: usize, + roi: (i64, i64, i64, i64), +) -> (i64, i64, i64, i64) { + let (y1, y2, x1, x2) = roi; + let (height, width) = (y2 - y1, x2 - x1); + let clamp = |v: i64| v.max(0) as usize; + let m = measure_film_edges(lum, h, w, (clamp(y1), clamp(y2), clamp(x1), clamp(x2))); + let out = ( + y1 + py_round(m[0] * height as f64), + y2 - py_round(m[1] * height as f64), + x1 + py_round(m[2] * width as f64), + x2 - py_round(m[3] * width as f64), + ); + if ((out.1 - out.0) as f64) < 0.5 * height as f64 + || ((out.3 - out.2) as f64) < 0.5 * width as f64 + { + return roi; + } + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index e61d0c0d7..f34ab3af1 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -151,11 +151,44 @@ fn walk_border_depth<'py>( Ok(numpy::PyArray1::from_vec(py, out)) } +/// `measure_film_edges(lum, film_roi)`: (top, bottom, left, right) border fractions. +#[pyfunction] +fn measure_film_edges( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (usize, usize, usize, usize), +) -> PyResult<(f64, f64, f64, f64)> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "measure_film_edges needs a 2-D array", + )); + }; + let data = slice(&lum)?; + let [t, b, l, r] = py.detach(|| analysis::measure_film_edges(data, h, w, film_roi)); + Ok((t, b, l, r)) +} + +/// `_trim_film_edges(lum, film_roi)`. +#[pyfunction] +fn trim_film_edges( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (i64, i64, i64, i64), +) -> PyResult<(i64, i64, i64, i64)> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err("trim_film_edges needs a 2-D array")); + }; + let data = slice(&lum)?; + Ok(py.detach(|| analysis::trim_film_edges(data, h, w, film_roi))) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; m.add_function(wrap_pyfunction!(base_blocks, m)?)?; m.add_function(wrap_pyfunction!(find_rebate_level, m)?)?; - m.add_function(wrap_pyfunction!(walk_border_depth, m)?) + m.add_function(wrap_pyfunction!(walk_border_depth, m)?)?; + m.add_function(wrap_pyfunction!(measure_film_edges, m)?)?; + m.add_function(wrap_pyfunction!(trim_film_edges, m)?) } diff --git a/scripts/replay.py b/scripts/replay.py index 4ef707411..63add3155 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -41,12 +41,13 @@ def _resolve(key: str): - """(owner, attribute name, function) for a manifest key, or None for a nested function.""" + """(owner, attribute name, function) for a manifest key, or None for a nested or deleted function.""" rel, qual = key.split("::") module = importlib.import_module("negpy." + rel.removesuffix(".py").replace("/", ".")) owner, *rest = qual.split(".") if not rest: - return module, owner, getattr(module, owner) + # A ported helper whose Python is deleted: its callers' replays cover it. + return (module, owner, getattr(module, owner)) if hasattr(module, owner) else None if len(rest) == 1 and isinstance(getattr(module, owner, None), type): cls = getattr(module, owner) return cls, rest[0], cls.__dict__[rest[0]] From e959d1cc6f32c6db08cd885549d9c10bd2e563ef Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:18:14 +0200 Subject: [PATCH 024/133] perf(core): port measure_film_border and _roi_from_measured_border The ring-border measurement moves to Rust: the row profile as NumPy's pairwise sum per row, the column profile summed down the columns, the 40th and 90th percentiles with np.percentile's NaN rule, the float32 threshold comparisons (NEP 50), np.argmax's first-max and first-NaN rule, and the opposite-side cap with Python's round(). stats gains pairwise_sum_f32, NumPy's contiguous float32 sum. No recorded corpus reaches these (they run on fallback paths and in batch autocrop), so they were checked against the old Python on the recorded luma planes of three neighbouring functions, with the box as given and shifted 7 px: 720/720 identical. Phase 2 replay 54/54. --- negpy/features/geometry/logic.py | 76 +------------------ negpy_core/manifest.toml | 12 +-- negpy_core/src/analysis.rs | 121 ++++++++++++++++++++++++++++++- negpy_core/src/py/analysis.rs | 37 +++++++++- negpy_core/src/stats.rs | 40 ++++++++++ 5 files changed, 206 insertions(+), 80 deletions(-) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 56c444d03..c3dae1d44 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -949,12 +949,6 @@ def _detection_luma(img: np.ndarray) -> np.ndarray: BORDER_SIDES: Tuple[str, ...] = ("top", "bottom", "left", "right") -# Walk cap per side, as a fraction of that side. Real rebate plus bed slop stays well under -# it, so reaching the cap means the profile never came back down and bright picture content -# touches that edge. Those sides report NaN and pooling ignores them. -_BORDER_WALK_CAP = 0.06 -_BORDER_MIN_SEPARATION = 0.15 - def measure_film_border(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: """ @@ -965,42 +959,8 @@ def measure_film_border(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: bright sky reaching the frame edge. Callers pool these across a roll and take the median. A side with no clean border->image transition reports NaN. """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - if bh < 16 or bw < 16: - return {name: float("nan") for name in BORDER_SIDES} - - image_level = float(np.percentile(box, 40)) - row_profile = box.mean(axis=1) - col_profile = box.mean(axis=0) - - result: dict[str, float] = {} - for name in BORDER_SIDES: - profile, length = (row_profile, bh) if name in ("top", "bottom") else (col_profile, bw) - walk = profile[::-1] if name in ("bottom", "right") else profile - # A 1-2 px border is too few samples for a median; use a high percentile. - outer = walk[: max(2, round(0.03 * length))] - base = float(np.percentile(outer, 90)) - if base - image_level < _BORDER_MIN_SEPARATION: - result[name] = 0.0 # no bright border on this side (full-bleed edge) - continue - threshold = 0.5 * (base + image_level) - cap = max(3, int(_BORDER_WALK_CAP * length)) - head = walk[:cap] - # From the brightest sample, not index 0. The box edge usually carries a sliver of bed - # that ramps up into the rebate, and a walk starting there is already under the - # threshold and reports zero on a side that has a border. - peak = int(np.argmax(head)) - if head[peak] <= threshold: - result[name] = 0.0 # no bright border on this side (full-bleed edge) - continue - below = np.flatnonzero(head[peak:] <= threshold) - result[name] = float("nan") if below.size == 0 else float(peak + int(below[0])) / length - return result - - -_OPPOSITE_SIDE = {"top": "bottom", "bottom": "top", "left": "right", "right": "left"} + sides = negpy_core.measure_film_border(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) + return dict(zip(BORDER_SIDES, sides)) def _roi_from_measured_border(lum: np.ndarray, film_roi: ROI) -> ROI | None: @@ -1011,37 +971,7 @@ def _roi_from_measured_border(lum: np.ndarray, film_roi: ROI) -> ROI | None: bright side is usually a uniform scene region, and trusting it carves the picture down to a dark subject. """ - measured = measure_film_border(lum, film_roi) - y1, y2, x1, x2 = film_roi - height, width = y2 - y1, x2 - x1 - - # Cap against the opposite side, so one over-long walk cannot cut deep against a much - # smaller reading across the same gate. - insets: dict[str, float] = {} - for name in BORDER_SIDES: - own, opposite = measured[name], measured[_OPPOSITE_SIDE[name]] - if not np.isfinite(own): - own = opposite - if not np.isfinite(own): - insets[name] = 0.0 - continue - if own > 0.0 and np.isfinite(opposite) and opposite > 0.0: - own = min(own, 2.0 * opposite) - insets[name] = float(own) - - paired = any(insets[first] > 0.0 and insets[second] > 0.0 for first, second in (("top", "bottom"), ("left", "right"))) - if not paired: - return None - - roi = ( - y1 + round(insets["top"] * height), - y2 - round(insets["bottom"] * height), - x1 + round(insets["left"] * width), - x2 - round(insets["right"] * width), - ) - if roi[1] <= roi[0] or roi[3] <= roi[2]: - return None - return roi + return negpy_core.roi_from_measured_border(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) def measure_film_edges(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 60e591b06..6678016b6 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -649,9 +649,10 @@ depends = [] ["features/geometry/logic.py::_roi_from_measured_border"] phase = 2 -status = "todo" -calls = [] -depends = ["measure_film_border"] +status = "ported" +calls = ["negpy_core.roi_from_measured_border"] +depends = [] +note = "negpy_core::analysis::roi_from_measured_border" ["features/geometry/logic.py::_score_contour"] phase = 2 @@ -789,9 +790,10 @@ depends = ["keystone_matrix"] ["features/geometry/logic.py::measure_film_border"] phase = 2 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.measure_film_border"] depends = [] +note = "negpy_core::analysis::measure_film_border" ["features/geometry/logic.py::measure_film_edges"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 9a6e86da3..fcdca5191 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -3,7 +3,7 @@ use multiversion::multiversion; use rayon::prelude::*; -use crate::stats::{np_percentiles, percentiles}; +use crate::stats::{np_percentiles, pairwise_sum_f32, percentiles}; const LUMA_R: f64 = 0.2126; const LUMA_G: f64 = 0.7152; @@ -648,6 +648,125 @@ pub fn trim_film_edges( out } +// Walk cap per side, as a fraction of that side. Real rebate plus bed slop stays well under +// it, so reaching the cap means the profile never came back down and bright picture content +// touches that edge. Those sides report NaN and pooling ignores them. +const BORDER_WALK_CAP: f64 = 0.06; +const BORDER_MIN_SEPARATION: f64 = 0.15; + +/// `measure_film_border(lum, film_roi)`: per side (top, bottom, left, right) the bright +/// border's thickness as a fraction of that side; 0.0 for none, NaN for no clean transition. +pub fn measure_film_border( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> [f64; 4] { + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2.saturating_sub(y1), x2.saturating_sub(x1)); + if bh < 16 || bw < 16 { + return [f64::NAN; 4]; + } + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let Some(level) = np_percentiles(&boxed, &[40.0]) else { + return [f64::NAN; 4]; + }; + let image_level = level[0] as f64; + // mean(axis=1): a pairwise sum per row; mean(axis=0): sequential down each column. + let rows: Vec = boxed + .chunks_exact(bw) + .map(|r| pairwise_sum_f32(r) / bw as f32) + .collect(); + let mut cols = vec![0f32; bw]; + for r in boxed.chunks_exact(bw) { + for (c, &v) in cols.iter_mut().zip(r) { + *c += v; + } + } + cols.iter_mut().for_each(|c| *c /= bh as f32); + + let side = |profile: &[f32], reversed: bool| -> f64 { + let length = profile.len(); + let walk: Vec = if reversed { + profile.iter().rev().copied().collect() + } else { + profile.to_vec() + }; + let outer_n = py_round(0.03 * length as f64).max(2) as usize; + let Some(b) = np_percentiles(&walk[..outer_n.min(length)], &[90.0]) else { + return f64::NAN; + }; + let base = b[0] as f64; + if base - image_level < BORDER_MIN_SEPARATION { + return 0.0; + } + // NEP 50: the Python float threshold joins float32 comparisons as float32. + let threshold = (0.5 * (base + image_level)) as f32; + let cap = ((BORDER_WALK_CAP * length as f64) as usize) + .max(3) + .min(length); + let head = &walk[..cap]; + // np.argmax: the first maximum, or the first NaN. + let peak = head.iter().position(|v| v.is_nan()).unwrap_or_else(|| { + head.iter() + .enumerate() + .fold(0, |best, (i, &v)| if v > head[best] { i } else { best }) + }); + if head[peak] <= threshold { + return 0.0; + } + match head[peak..].iter().position(|&v| v <= threshold) { + Some(k) => (peak + k) as f64 / length as f64, + None => f64::NAN, + } + }; + [ + side(&rows, false), + side(&rows, true), + side(&cols, false), + side(&cols, true), + ] +} + +/// `_roi_from_measured_border(lum, film_roi)`: the box inset by its measured border, when an +/// opposite pair agrees. +pub fn roi_from_measured_border( + lum: &[f32], + h: usize, + w: usize, + roi: (i64, i64, i64, i64), +) -> Option<(i64, i64, i64, i64)> { + let (y1, y2, x1, x2) = roi; + let clamp = |v: i64| v.max(0) as usize; + let m = measure_film_border(lum, h, w, (clamp(y1), clamp(y2), clamp(x1), clamp(x2))); + let (height, width) = ((y2 - y1) as f64, (x2 - x1) as f64); + let opposite = [1, 0, 3, 2]; + let mut insets = [0f64; 4]; + for i in 0..4 { + let (mut own, other) = (m[i], m[opposite[i]]); + if !own.is_finite() { + own = other; + } + if !own.is_finite() { + continue; + } + if own > 0.0 && other.is_finite() && other > 0.0 { + own = own.min(2.0 * other); + } + insets[i] = own; + } + if !((insets[0] > 0.0 && insets[1] > 0.0) || (insets[2] > 0.0 && insets[3] > 0.0)) { + return None; + } + let out = ( + y1 + py_round(insets[0] * height), + y2 - py_round(insets[1] * height), + x1 + py_round(insets[2] * width), + x2 - py_round(insets[3] * width), + ); + (out.1 > out.0 && out.3 > out.2).then_some(out) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index f34ab3af1..cec7b5d66 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -182,6 +182,39 @@ fn trim_film_edges( Ok(py.detach(|| analysis::trim_film_edges(data, h, w, film_roi))) } +/// `measure_film_border(lum, film_roi)`: (top, bottom, left, right) border fractions. +#[pyfunction] +fn measure_film_border( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (usize, usize, usize, usize), +) -> PyResult<(f64, f64, f64, f64)> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "measure_film_border needs a 2-D array", + )); + }; + let data = slice(&lum)?; + let [t, b, l, r] = py.detach(|| analysis::measure_film_border(data, h, w, film_roi)); + Ok((t, b, l, r)) +} + +/// `_roi_from_measured_border(lum, film_roi)`. +#[pyfunction] +fn roi_from_measured_border( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (i64, i64, i64, i64), +) -> PyResult> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "roi_from_measured_border needs a 2-D array", + )); + }; + let data = slice(&lum)?; + Ok(py.detach(|| analysis::roi_from_measured_border(data, h, w, film_roi))) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; @@ -190,5 +223,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(find_rebate_level, m)?)?; m.add_function(wrap_pyfunction!(walk_border_depth, m)?)?; m.add_function(wrap_pyfunction!(measure_film_edges, m)?)?; - m.add_function(wrap_pyfunction!(trim_film_edges, m)?) + m.add_function(wrap_pyfunction!(trim_film_edges, m)?)?; + m.add_function(wrap_pyfunction!(measure_film_border, m)?)?; + m.add_function(wrap_pyfunction!(roi_from_measured_border, m)?) } diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index e9fd92669..7f9e923bc 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -152,6 +152,34 @@ pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { ) } +/// NumPy's float32 sum of a contiguous run: one pairwise sum (blocks of 128, eight +/// accumulators), the order `np.sum` and `mean(axis=-1)` use. +pub fn pairwise_sum_f32(a: &[f32]) -> f32 { + let n = a.len(); + if n < 8 { + return a.iter().fold(-0.0f32, |s, &v| s + v); + } + if n <= 128 { + let mut r = [0f32; 8]; + r.copy_from_slice(&a[..8]); + let mut i = 8; + while i < n - n % 8 { + for j in 0..8 { + r[j] += a[i + j]; + } + i += 8; + } + let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7])); + for &v in &a[i..] { + res += v; + } + return res; + } + let mut n2 = n / 2; + n2 -= n2 % 8; + pairwise_sum_f32(&a[..n2]) + pairwise_sum_f32(&a[n2..]) +} + /// `np.percentile` itself, which is NaN for every q when any value is NaN; `percentiles` /// matches NegPy's partition helper, which callers feed only finite values. pub fn np_percentiles(values: &[f32], qs: &[f64]) -> Option> { @@ -185,6 +213,18 @@ mod tests { .collect() } + #[test] + fn pairwise_sum_regions() { + let v: Vec = (0..1000).map(|i| (i as f32).sqrt()).collect(); + for n in [0, 1, 7, 8, 9, 128, 129, 1000] { + let exact: f64 = v[..n].iter().map(|&x| x as f64).sum(); + assert!( + (pairwise_sum_f32(&v[..n]) as f64 - exact).abs() < 1e-2, + "n={n}" + ); + } + } + #[test] fn empty_is_none() { assert!(percentiles(&[], &[50.0]).is_none()); From 863c1c9a061f7fd455e1402c76804020d2962d00 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:24:43 +0200 Subject: [PATCH 025/133] perf(core): port the tier refinement and the edge snap _refine_film_roi_by_tiers with _estimate_tier_levels and _longest_run_above, _snap_edge_to_gradient and _smooth_signal move to Rust. np.convolve's float32 arithmetic was probed: a full-overlap output of a window up to 11 is NumPy's small_correlate (float32 sums), every other output BLAS sdot (float32 products summed in float64); windows of 32 and up take OpenBLAS's vector path and are refused, as no caller uses them. np.isclose's tolerance joins the comparison as float32. Checked against the corpus before the swap: snap 60/60, tiers 57/57, smooth_signal against np.convolve for windows 1-31. Replay: phase 2 53/53 (the deleted _estimate_tier_levels drops out). --- negpy/features/geometry/logic.py | 147 +------------------ negpy_core/manifest.toml | 23 ++- negpy_core/src/analysis.rs | 242 +++++++++++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 75 +++++++++- 4 files changed, 338 insertions(+), 149 deletions(-) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index c3dae1d44..c05875c8f 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1,7 +1,7 @@ import math from dataclasses import dataclass, field, replace from functools import lru_cache -from typing import Callable, Dict, List, NamedTuple, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple import cv2 import negpy_core @@ -137,11 +137,7 @@ def _ensure_color(image: np.ndarray) -> np.ndarray: def _smooth_signal(signal: np.ndarray, window: int) -> np.ndarray: - if window <= 1 or signal.size == 0: - return signal.astype(np.float32) - - kernel = np.ones(window, dtype=np.float32) / window - return np.convolve(signal.astype(np.float32), kernel, mode="same") + return negpy_core.smooth_signal(np.ascontiguousarray(signal, dtype=np.float32), int(window)) def _boundary_candidates(signal: np.ndarray, *, from_start: bool) -> tuple[int, float, int, float]: @@ -927,18 +923,6 @@ def _snap_film_bounds_to_bed_gradient(roi: ROI, lum: np.ndarray) -> ROI: return max(0, ny1 - 2), min(h, ny2 + 2), max(0, nx1 - 2), min(w, nx2 + 2) -class _TierLevels(NamedTuple): - bed: float - rebate: float - image: float - ring_spread: float - - -# Smallest fraction of a film-box side the tier refinement may keep. A rebate never takes -# half a side, so anything below this is a broken occupancy run, not a frame. -_TIER_MIN_KEEP = 0.5 - - def _detection_luma(img: np.ndarray) -> np.ndarray: """ Luminance normalized so the light bed sits near 1.0 (anchored at P99.5). @@ -1013,45 +997,6 @@ def _find_rebate_level(lum: np.ndarray, film_roi: ROI, bed: Optional[float] = No return negpy_core.find_rebate_level(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi), bed) -def _estimate_tier_levels(lum: np.ndarray, film_roi: ROI) -> Optional[_TierLevels]: - """ - Estimates the three luminance tiers (bed, rebate, exposed image) for a film box. - Returns None when the tiers are not reliably separable. - """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - - bed = float(np.percentile(lum, 99)) - found = _find_rebate_level(lum, film_roi, bed) - if found is None: - return None - rebate, ring_spread = found - - dark = box[box < rebate - 0.02] - if dark.size < 0.05 * box.size: - return None - image_level = float(np.percentile(dark, 30)) - - separation = rebate - image_level - if separation < max(0.04, 3.0 * ring_spread): - return None - return _TierLevels(bed, rebate, image_level, ring_spread) - - -def _longest_run_above(profile: np.ndarray, threshold: float) -> Optional[Tuple[int, int]]: - """ - Longest contiguous half-open index run with profile >= threshold. - """ - idx = np.where(profile >= threshold)[0] - if idx.size == 0: - return None - breaks = np.where(np.diff(idx) > 1)[0] - starts = np.concatenate(([0], breaks + 1)) - ends = np.concatenate((breaks, [idx.size - 1])) - k = int(np.argmax(idx[ends] - idx[starts])) - return int(idx[starts[k]]), int(idx[ends[k]]) + 1 - - def _snap_edge_to_gradient( profile: np.ndarray, idx: int, @@ -1067,30 +1012,9 @@ def _snap_edge_to_gradient( frame area when bright content touching the edge suppresses occupancy. Keeps idx unless the peak clearly dominates the window (>= 2x median). """ - n = profile.size - if n < 8: - return idx - sm = _smooth_signal(profile, max(3, round(0.01 * n))) - grad = np.abs(np.diff(sm)) - out_px = max(min_window_px, round(window_out * n)) - in_px = max(min_window_px, round(window_in * n)) - if is_start: - lo, hi = max(0, idx - out_px), min(n - 1, idx + in_px) - else: - lo, hi = max(0, idx - in_px), min(n - 1, idx + out_px) - window = grad[lo:hi] - if window.size == 0: - return idx - peak = float(np.max(window)) - if peak >= min_dominance * float(np.median(window)) + 1e-6: - # Box smoothing turns a sharp step into a nearly flat plateau, so picking the first - # raw maximum lets sub-ULP OpenCV differences move the snapped edge by several pixels - # across resolutions and platforms. Treat equivalent maxima as one peak and keep the - # transition nearest the coarse contour edge. - near_peak = np.flatnonzero(np.isclose(window, peak, rtol=1e-5, atol=1e-7)) - transitions = lo + near_peak + 1 - return int(transitions[np.argmin(np.abs(transitions - idx))]) - return idx + return negpy_core.snap_edge_to_gradient( + np.ascontiguousarray(profile, dtype=np.float32), int(idx), bool(is_start), window_out, window_in, min_dominance, int(min_window_px) + ) def _refine_film_roi_by_tiers(lum: np.ndarray, film_roi: ROI) -> Optional[Tuple[ROI, np.ndarray, np.ndarray]]: @@ -1101,66 +1025,7 @@ def _refine_film_roi_by_tiers(lum: np.ndarray, film_roi: ROI) -> Optional[Tuple[ detection-image coords (profiles padded to full image length), or None when tiers are not separable (caller falls back to the Sobel path). """ - levels = _estimate_tier_levels(lum, film_roi) - if levels is None: - return None - - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - # Image pixels are anything meaningfully darker than the rebate plateau, since any - # exposure adds density over base+fog. This keeps thin, bright frame regions classified as - # image. The midpoint is the floor for tightly separated tiers. - threshold = max( - 0.5 * (levels.rebate + levels.image), - levels.rebate - max(0.04, 3.0 * levels.ring_spread), - ) - mask = box < threshold - - row_occ = mask.mean(axis=1) - vrun = _longest_run_above(row_occ, 0.55) - if vrun is None: - return None - vt, vb = vrun - # Restrict to the vertical run so rebate rows don't dilute column occupancy. - col_occ = mask[vt:vb].mean(axis=0) - hrun = _longest_run_above(col_occ, 0.55) - if hrun is None: - return None - hl, hr = hrun - - # Single-frame sanity. Multi-frame strip scans fail here and fall back to Sobel. - # The floor is half a side: no rebate eats more than that, so a shorter run means the - # occupancy broke on bright picture content inside a full-bleed frame and the "rebate" - # tier was really the picture reaching the film edge. That splits the frame into a - # sliver, and the sliver is what the caller would crop to. - if (vb - vt) < _TIER_MIN_KEEP * bh or (hr - hl) < _TIER_MIN_KEEP * bw: - return None - area_ratio = ((vb - vt) * (hr - hl)) / float(bh * bw) - if not (0.25 <= area_ratio <= 0.95): - return None - - col_profile = box[vt:vb, :].mean(axis=0) - row_profile = box[:, hl:hr].mean(axis=1) - hl = _snap_edge_to_gradient(col_profile, hl, is_start=True) - hr = _snap_edge_to_gradient(col_profile, hr, is_start=False) - vt = _snap_edge_to_gradient(row_profile, vt, is_start=True) - vb = _snap_edge_to_gradient(row_profile, vb, is_start=False) - - pad_y = max(2, round(0.004 * bh)) - pad_x = max(2, round(0.004 * bw)) - vt, vb = max(0, vt - pad_y), min(bh, vb + pad_y) - hl, hr = max(0, hl - pad_x), min(bw, hr + pad_x) - if vb - vt <= 0 or hr - hl <= 0: - return None - - h_det, w_det = lum.shape[:2] - row_occ_full = np.zeros(h_det, dtype=np.float32) - row_occ_full[y1:y2] = row_occ - col_occ_full = np.zeros(w_det, dtype=np.float32) - col_occ_full[x1:x2] = col_occ - - return (y1 + vt, y1 + vb, x1 + hl, x1 + hr), row_occ_full, col_occ_full + return negpy_core.refine_film_roi_by_tiers(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) def film_and_picture(det: np.ndarray) -> Tuple[Optional[ROI], ROI]: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 6678016b6..ad69af19c 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -532,9 +532,10 @@ depends = ["_place_window_by_occupancy"] ["features/geometry/logic.py::_estimate_tier_levels"] phase = 2 -status = "todo" +status = "ported" calls = ["np.percentile"] depends = ["_find_rebate_level"] +note = "negpy_core::analysis::estimate_tier_levels (Python deleted)" ["features/geometry/logic.py::_film_surround_is_plausible"] phase = 2 @@ -625,15 +626,16 @@ depends = [] ["features/geometry/logic.py::_refine_film_roi_by_tiers"] phase = 2 -status = "todo" -calls = [] -depends = ["_estimate_tier_levels", "_snap_edge_to_gradient"] +status = "ported" +calls = ["negpy_core.refine_film_roi_by_tiers"] +depends = [] +note = "negpy_core::analysis::refine_film_roi_by_tiers" ["features/geometry/logic.py::_refine_frame_bounds"] phase = 2 status = "todo" calls = ["negpy_core.bgr_to_gray", "negpy_core.sobel", "np.percentile"] -depends = ["_dark_region_bounds", "_normalize_to_uint8"] +depends = ["_dark_region_bounds", "_normalize_to_uint8", "_smooth_signal"] ["features/geometry/logic.py::_refine_roi_to_image"] phase = 2 @@ -666,11 +668,18 @@ status = "todo" calls = [] depends = ["_weighted_median"] -["features/geometry/logic.py::_snap_edge_to_gradient"] +["features/geometry/logic.py::_smooth_signal"] phase = 2 status = "todo" -calls = ["np.median"] +calls = ["negpy_core.smooth_signal"] +depends = [] + +["features/geometry/logic.py::_snap_edge_to_gradient"] +phase = 2 +status = "ported" +calls = ["negpy_core.snap_edge_to_gradient"] depends = [] +note = "negpy_core::analysis::snap_edge_to_gradient" ["features/geometry/logic.py::_snap_film_bounds_to_bed_gradient"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index fcdca5191..874c131c6 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -767,6 +767,248 @@ pub fn roi_from_measured_border( (out.1 > out.0 && out.3 > out.2).then_some(out) } +// Smallest fraction of a film-box side the tier refinement may keep. A rebate never takes half +// a side, so anything below this is a broken occupancy run, not a frame. +const TIER_MIN_KEEP: f64 = 0.5; + +/// `_smooth_signal(signal, window)`: `np.convolve(signal, ones(window) / window, "same")` in +/// NumPy's float32 arithmetic. A full-overlap output of a window up to 11 goes through NumPy's +/// small_correlate (float32 sums); every other output through BLAS sdot (float32 products, a +/// float64 sum). None for a window over 31 (BLAS's vector path) or longer than the signal +/// (np.convolve swaps the arrays): no caller uses either. +pub fn smooth_signal(signal: &[f32], window: usize) -> Option> { + let n = signal.len(); + if window <= 1 || n == 0 { + return Some(signal.to_vec()); + } + if window > 31 || window > n { + return None; + } + let k = 1.0f32 / window as f32; + let left = window / 2; + (0..n) + .map(|o| { + let lo = left.saturating_sub(o); + let hi = window.min(n + left - o); + let terms = (lo..hi).map(|j| signal[o + j - left] * k); + if lo == 0 && hi == window && window <= 11 { + terms.fold(0f32, |s, p| s + p) + } else { + terms.fold(0f64, |s, p| s + p as f64) as f32 + } + }) + .collect::>() + .into() +} + +/// `_snap_edge_to_gradient`'s windows: outward and inward fractions of the profile, the +/// dominance over the window median, and the smallest window in pixels. +#[derive(Clone, Copy)] +pub struct Snap { + pub window_out: f64, + pub window_in: f64, + pub min_dominance: f64, + pub min_window_px: i64, +} + +impl Default for Snap { + fn default() -> Self { + Snap { + window_out: 0.06, + window_in: 0.02, + min_dominance: 2.0, + min_window_px: 3, + } + } +} + +/// `_snap_edge_to_gradient(profile, idx, is_start, ...)`: the edge moved to the strongest step +/// of the smoothed profile in a window biased outward, when that step dominates the window. +pub fn snap_edge_to_gradient(profile: &[f32], idx: i64, is_start: bool, snap: Snap) -> i64 { + let n = profile.len(); + if n < 8 { + return idx; + } + let Some(sm) = smooth_signal(profile, py_round(0.01 * n as f64).max(3) as usize) else { + return idx; + }; + let grad: Vec = sm.windows(2).map(|w| (w[1] - w[0]).abs()).collect(); + let out_px = py_round(snap.window_out * n as f64).max(snap.min_window_px); + let in_px = py_round(snap.window_in * n as f64).max(snap.min_window_px); + let (lo, hi) = if is_start { + ((idx - out_px).max(0), (idx + in_px).min(n as i64 - 1)) + } else { + ((idx - in_px).max(0), (idx + out_px).min(n as i64 - 1)) + }; + if hi <= lo { + return idx; + } + let window = &grad[lo as usize..hi as usize]; + let peak = window.iter().copied().fold(window[0], np_max); + let median = np_median(&mut window.to_vec()); + if (peak as f64) < snap.min_dominance * median as f64 + 1e-6 || peak.is_nan() || median.is_nan() + { + return idx; + } + // np.isclose(window, peak, rtol=1e-5, atol=1e-7): the difference in float32, the tolerance + // a Python float joining the comparison as float32 (NEP 50). + let tol = (1e-7 + 1e-5 * (peak as f64).abs()) as f32; + let mut best: Option<(i64, i64)> = None; + for (i, &v) in window.iter().enumerate() { + if (v - peak).abs() <= tol || v == peak { + let t = lo + i as i64 + 1; + let d = (t - idx).abs(); + if best.is_none_or(|(_, bd)| d < bd) { + best = Some((t, d)); + } + } + } + best.map_or(idx, |(t, _)| t) +} + +/// `_longest_run_above(profile, threshold)`: the longest half-open run with profile >= threshold, +/// the first of equal length. +fn longest_run_above(profile: &[f64], threshold: f64) -> Option<(i64, i64)> { + let idx: Vec = (0..profile.len()) + .filter(|&i| profile[i] >= threshold) + .map(|i| i as i64) + .collect(); + if idx.is_empty() { + return None; + } + let mut best = (idx[0], idx[0]); + let mut start = idx[0]; + for w in 0..idx.len() { + let end_here = w + 1 == idx.len() || idx[w + 1] - idx[w] > 1; + if end_here { + if idx[w] - start > best.1 - best.0 { + best = (start, idx[w]); + } + if w + 1 < idx.len() { + start = idx[w + 1]; + } + } + } + Some((best.0, best.1 + 1)) +} + +/// `_estimate_tier_levels(lum, film_roi)`: (bed, rebate, image, ring spread), or None. +fn estimate_tier_levels( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> Option<(f64, f64, f64, f64)> { + let bed = np_percentiles(lum, &[99.0])?[0] as f64; + let (rebate, ring_spread) = find_rebate_level(lum, h, w, roi, Some(bed))?; + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let cut = (rebate - 0.02) as f32; + let dark: Vec = boxed.iter().copied().filter(|&v| v < cut).collect(); + if (dark.len() as f64) < 0.05 * boxed.len() as f64 { + return None; + } + let image = np_percentiles(&dark, &[30.0])?[0] as f64; + if rebate - image < 0.04f64.max(3.0 * ring_spread) { + return None; + } + Some((bed, rebate, image, ring_spread)) +} + +pub struct TierRefinement { + pub roi: (i64, i64, i64, i64), + pub rows: Vec, + pub cols: Vec, +} + +/// `_refine_film_roi_by_tiers(lum, film_roi)`: the picture box from rebate/image occupancy runs +/// snapped to the strongest gradients, with the full-length occupancy profiles. +pub fn refine_film_roi_by_tiers( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> Option { + let (_, rebate, image, ring_spread) = estimate_tier_levels(lum, h, w, roi)?; + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2 - y1, x2 - x1); + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let threshold = (0.5 * (rebate + image)).max(rebate - 0.04f64.max(3.0 * ring_spread)) as f32; + let mask: Vec = boxed.iter().map(|&v| v < threshold).collect(); + let count = |r0: usize, r1: usize, c0: usize, c1: usize, by_row: bool| -> Vec { + if by_row { + (r0..r1) + .map(|r| { + mask[r * bw + c0..r * bw + c1] + .iter() + .filter(|&&m| m) + .count() as f64 + / (c1 - c0) as f64 + }) + .collect() + } else { + (c0..c1) + .map(|c| (r0..r1).filter(|&r| mask[r * bw + c]).count() as f64 / (r1 - r0) as f64) + .collect() + } + }; + let row_occ = count(0, bh, 0, bw, true); + let (vt, vb) = longest_run_above(&row_occ, 0.55)?; + let col_occ = count(vt as usize, vb as usize, 0, bw, false); + let (hl, hr) = longest_run_above(&col_occ, 0.55)?; + if ((vb - vt) as f64) < TIER_MIN_KEEP * bh as f64 + || ((hr - hl) as f64) < TIER_MIN_KEEP * bw as f64 + { + return None; + } + let area = ((vb - vt) * (hr - hl)) as f64 / (bh * bw) as f64; + if !(0.25..=0.95).contains(&area) { + return None; + } + // box[vt:vb, :].mean(axis=0) sums down the columns; box[:, hl:hr].mean(axis=1) is a pairwise + // sum per row. + let (vtu, vbu, hlu, hru) = (vt as usize, vb as usize, hl as usize, hr as usize); + let mut col_profile = vec![0f32; bw]; + for r in vtu..vbu { + for (c, p) in col_profile.iter_mut().enumerate() { + *p += boxed[r * bw + c]; + } + } + col_profile + .iter_mut() + .for_each(|p| *p /= (vbu - vtu) as f32); + let row_profile: Vec = (0..bh) + .map(|r| pairwise_sum_f32(&boxed[r * bw + hlu..r * bw + hru]) / (hru - hlu) as f32) + .collect(); + let hl = snap_edge_to_gradient(&col_profile, hl, true, Snap::default()); + let hr = snap_edge_to_gradient(&col_profile, hr, false, Snap::default()); + let vt = snap_edge_to_gradient(&row_profile, vt, true, Snap::default()); + let vb = snap_edge_to_gradient(&row_profile, vb, false, Snap::default()); + let pad_y = py_round(0.004 * bh as f64).max(2); + let pad_x = py_round(0.004 * bw as f64).max(2); + let (vt, vb) = ((vt - pad_y).max(0), (vb + pad_y).min(bh as i64)); + let (hl, hr) = ((hl - pad_x).max(0), (hr + pad_x).min(bw as i64)); + if vb - vt <= 0 || hr - hl <= 0 { + return None; + } + let mut rows = vec![0f32; h]; + rows[y1..y2] + .iter_mut() + .zip(&row_occ) + .for_each(|(d, &v)| *d = v as f32); + let mut cols = vec![0f32; w]; + cols[x1..x2] + .iter_mut() + .zip(&col_occ) + .for_each(|(d, &v)| *d = v as f32); + let (y1, x1) = (y1 as i64, x1 as i64); + Some(TierRefinement { + roi: (y1 + vt, y1 + vb, x1 + hl, x1 + hr), + rows, + cols, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index cec7b5d66..4a5f01d86 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -215,6 +215,76 @@ fn roi_from_measured_border( Ok(py.detach(|| analysis::roi_from_measured_border(data, h, w, film_roi))) } +/// `_smooth_signal(signal, window)` for a 1-D float32 signal. +#[pyfunction] +fn smooth_signal<'py>( + py: Python<'py>, + signal: PyReadonlyArrayDyn<'py, f32>, + window: usize, +) -> PyResult>> { + let data = slice(&signal)?.to_vec(); + let out = py + .detach(|| analysis::smooth_signal(&data, window)) + .ok_or_else(|| { + PyValueError::new_err( + "smooth_signal covers a window of at most 31, no longer than the signal", + ) + })?; + Ok(numpy::PyArray1::from_vec(py, out)) +} + +/// `_snap_edge_to_gradient(profile, idx, is_start, window_out, window_in, min_dominance, min_window_px)`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn snap_edge_to_gradient( + py: Python<'_>, + profile: PyReadonlyArrayDyn<'_, f32>, + idx: i64, + is_start: bool, + window_out: f64, + window_in: f64, + min_dominance: f64, + min_window_px: i64, +) -> PyResult { + let data = slice(&profile)?; + let snap = analysis::Snap { + window_out, + window_in, + min_dominance, + min_window_px, + }; + Ok(py.detach(|| analysis::snap_edge_to_gradient(data, idx, is_start, snap))) +} + +type Refined<'py> = ( + (i64, i64, i64, i64), + Bound<'py, numpy::PyArray1>, + Bound<'py, numpy::PyArray1>, +); + +/// `_refine_film_roi_by_tiers(lum, film_roi)`: (roi, row occupancy, column occupancy) or None. +#[pyfunction] +fn refine_film_roi_by_tiers<'py>( + py: Python<'py>, + lum: PyReadonlyArrayDyn<'py, f32>, + film_roi: (usize, usize, usize, usize), +) -> PyResult>> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "refine_film_roi_by_tiers needs a 2-D array", + )); + }; + let data = slice(&lum)?; + let found = py.detach(|| analysis::refine_film_roi_by_tiers(data, h, w, film_roi)); + Ok(found.map(|r| { + ( + r.roi, + numpy::PyArray1::from_vec(py, r.rows), + numpy::PyArray1::from_vec(py, r.cols), + ) + })) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; @@ -225,5 +295,8 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(measure_film_edges, m)?)?; m.add_function(wrap_pyfunction!(trim_film_edges, m)?)?; m.add_function(wrap_pyfunction!(measure_film_border, m)?)?; - m.add_function(wrap_pyfunction!(roi_from_measured_border, m)?) + m.add_function(wrap_pyfunction!(roi_from_measured_border, m)?)?; + m.add_function(wrap_pyfunction!(smooth_signal, m)?)?; + m.add_function(wrap_pyfunction!(snap_edge_to_gradient, m)?)?; + m.add_function(wrap_pyfunction!(refine_film_roi_by_tiers, m)?) } From 5ce88bfd6a706ac2c1a33d5b378028883eb893e7 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:30:01 +0200 Subject: [PATCH 026/133] perf(core): port _refine_frame_bounds and _refine_roi_to_image The Sobel fallback (_refine_frame_bounds with _boundary_candidates and _dark_region_bounds) and the picture-gate orchestration move to Rust, on the already ported primitives. contourArea and boundingRect for integer points join contours.rs. NumPy rules copied: np.percentile on uint8 interpolates in float64; uint8 <= float compares in float64; np.argmax's first-max and first-NaN; max(..., key=contourArea) keeps the first of the largest. No corpus reaches the Sobel fallback, so it was checked against the old Python on the recorded images (whole, the film box, and 5% inside it): 171/171. Replay: _refine_roi_to_image 57/57, film_and_picture 25/25, phase 2 53/53. --- negpy/features/geometry/logic.py | 188 +------------------ negpy_core/manifest.toml | 17 +- negpy_core/src/analysis.rs | 297 +++++++++++++++++++++++++++++++ negpy_core/src/contours.rs | 28 +++ negpy_core/src/py/analysis.rs | 65 ++++++- 5 files changed, 403 insertions(+), 192 deletions(-) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index c05875c8f..e2e1c5bc4 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -140,160 +140,10 @@ def _smooth_signal(signal: np.ndarray, window: int) -> np.ndarray: return negpy_core.smooth_signal(np.ascontiguousarray(signal, dtype=np.float32), int(window)) -def _boundary_candidates(signal: np.ndarray, *, from_start: bool) -> tuple[int, float, int, float]: - length = signal.size - if length == 0: - return 0, 0.0, 0, 0.0 - - edge_window = max(int(round(length * 0.08)), 32) - edge_window = min(edge_window, max(length - 1, 1)) - search_end = max(int(round(length * 0.45)), edge_window + 1) - search_end = min(search_end, length) - search_start = min(int(round(length * 0.55)), max(length - edge_window - 1, 0)) - - if from_start: - edge_slice = signal[:edge_window] - search_slice = signal[edge_window:search_end] - edge_idx = int(np.argmax(edge_slice)) if edge_slice.size else 0 - edge_value = float(edge_slice[edge_idx]) if edge_slice.size else 0.0 - if search_slice.size == 0: - return edge_idx, edge_value, edge_idx, edge_value - inner_offset = int(np.argmax(search_slice)) - inner_idx = edge_window + inner_offset - inner_value = float(search_slice[inner_offset]) - return edge_idx, edge_value, inner_idx, inner_value - - edge_slice = signal[length - edge_window :] - search_slice = signal[search_start : length - edge_window] - edge_offset = int(np.argmax(edge_slice)) if edge_slice.size else 0 - edge_idx = length - edge_window + edge_offset - edge_value = float(edge_slice[edge_offset]) if edge_slice.size else 0.0 - if search_slice.size == 0: - return edge_idx, edge_value, edge_idx, edge_value - inner_offset = int(np.argmax(search_slice)) - inner_idx = search_start + inner_offset - inner_value = float(search_slice[inner_offset]) - return edge_idx, edge_value, inner_idx, inner_value - - -def _dark_region_bounds(image: np.ndarray) -> tuple[int, int, int, int] | None: - preview = _normalize_to_uint8(_ensure_color(image)) - gray = negpy_core.bgr_to_gray(preview) - - threshold = float(np.percentile(gray, 55)) - mask = (gray <= threshold).astype(np.uint8) * 255 - open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)) - mask = negpy_core.morphology_ex(mask, cv2.MORPH_OPEN, open_kernel) - mask = negpy_core.morphology_ex(mask, cv2.MORPH_CLOSE, close_kernel, iterations=2) - - contours, _ = negpy_core.find_external_contours(mask) - if not contours: - return None - - contour = max(contours, key=cv2.contourArea) - x, y, box_w, box_h = cv2.boundingRect(contour) - image_area = float(gray.shape[0] * gray.shape[1]) - area_ratio = float(cv2.contourArea(contour)) / max(image_area, 1.0) - if area_ratio < 0.15 or area_ratio > 0.85: - return None - - min_width = int(round(image.shape[1] * 0.25)) - min_height = int(round(image.shape[0] * 0.25)) - if box_w < min_width or box_h < min_height: - return None - - pad_x = max(int(round(image.shape[1] * 0.004)), 4) - pad_y = max(int(round(image.shape[0] * 0.004)), 4) - left = max(x - pad_x, 0) - top = max(y - pad_y, 0) - right = min(x + box_w + pad_x, image.shape[1]) - bottom = min(y + box_h + pad_y, image.shape[0]) - - min_inset_x = int(round(image.shape[1] * 0.03)) - min_inset_y = int(round(image.shape[0] * 0.03)) - if left < min_inset_x or top < min_inset_y or (image.shape[1] - right) < min_inset_x or (image.shape[0] - bottom) < min_inset_y: - return None - - return left, top, right, bottom - - def _refine_frame_bounds(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, int]]: - preview = _normalize_to_uint8(_ensure_color(image)) - gray = negpy_core.bgr_to_gray(preview) - - grad_x = np.minimum(np.abs(negpy_core.sobel(gray.astype(np.float32), 1, 0)), 255).astype(np.uint8) - grad_y = np.minimum(np.abs(negpy_core.sobel(gray.astype(np.float32), 0, 1)), 255).astype(np.uint8) - col_signal = _smooth_signal(np.percentile(grad_x, 95, axis=0), 31) - row_signal = _smooth_signal(np.percentile(grad_y, 95, axis=1), 31) - - left_edge, left_edge_value, left_inner, left_inner_value = _boundary_candidates(col_signal, from_start=True) - right_edge, right_edge_value, right_inner, right_inner_value = _boundary_candidates(col_signal, from_start=False) - top_edge, top_edge_value, top_inner, top_inner_value = _boundary_candidates(row_signal, from_start=True) - bottom_edge, bottom_edge_value, bottom_inner, bottom_inner_value = _boundary_candidates(row_signal, from_start=False) - - col_noise_floor = float(np.percentile(col_signal, 75)) - row_noise_floor = float(np.percentile(row_signal, 75)) - - left = left_edge - right = right_edge + 1 - top = top_edge - bottom = bottom_edge + 1 - - use_inner_pair_x = ( - left_inner >= int(round(image.shape[1] * 0.12)) - and right_inner <= int(round(image.shape[1] * 0.88)) - and left_inner_value >= col_noise_floor * 4.0 - and right_inner_value >= col_noise_floor * 4.0 - and (right_inner - left_inner) >= int(round(image.shape[1] * 0.5)) - ) - if use_inner_pair_x: - left = left_inner - right = right_inner + 1 - else: - if left_inner >= int(round(image.shape[1] * 0.12)) and left_inner_value >= col_noise_floor * 5.0: - left = left_inner - if right_inner <= int(round(image.shape[1] * 0.88)) and right_inner_value >= col_noise_floor * 5.0: - right = right_inner + 1 - - use_inner_pair_y = ( - top_inner > top_edge + 20 - and bottom_inner < bottom_edge - 20 - and top_inner_value > max(top_edge_value * 1.2, row_noise_floor + 25.0) - and bottom_inner_value > max(bottom_edge_value * 1.2, row_noise_floor + 25.0) - and (bottom_inner - top_inner) >= int(round(image.shape[0] * 0.5)) - ) - if use_inner_pair_y: - top = top_inner - bottom = bottom_inner + 1 - else: - if top_inner > top_edge + 20 and top_inner_value > max(top_edge_value * 1.45, row_noise_floor + 35.0): - top = top_inner - if bottom_inner < bottom_edge - 20 and bottom_inner_value > max(bottom_edge_value * 1.45, row_noise_floor + 35.0): - bottom = bottom_inner + 1 - - pad_x = max(int(round(image.shape[1] * 0.004)), 4) - pad_y = max(int(round(image.shape[0] * 0.004)), 4) - left = max(left - pad_x, 0) - right = min(right + pad_x, image.shape[1]) - top = max(top - pad_y, 0) - bottom = min(bottom + pad_y, image.shape[0]) - - min_width = max(int(round(image.shape[1] * 0.5)), 1) - min_height = max(int(round(image.shape[0] * 0.5)), 1) - if right - left < min_width: - left, right = 0, image.shape[1] - if bottom - top < min_height: - top, bottom = 0, image.shape[0] - - refined_area_ratio = ((right - left) * (bottom - top)) / max(float(image.shape[0] * image.shape[1]), 1.0) - dark_bounds = _dark_region_bounds(image) - if dark_bounds is not None and refined_area_ratio > 0.8: - dark_left, dark_top, dark_right, dark_bottom = dark_bounds - dark_area_ratio = ((dark_right - dark_left) * (dark_bottom - dark_top)) / max(float(image.shape[0] * image.shape[1]), 1.0) - if 0.15 <= dark_area_ratio <= 0.85: - left, top, right, bottom = dark_left, dark_top, dark_right, dark_bottom - + """The picture inside a film box by Sobel profiles of its gray preview, and its box + (left, top, right, bottom); negpy_core holds the rules.""" + left, top, right, bottom = negpy_core.refine_frame_bounds(np.ascontiguousarray(image, dtype=np.float32)) return image[top:bottom, left:right], (left, top, right, bottom) @@ -1059,37 +909,7 @@ def _refine_roi_to_image(img: ImageBuffer, film_roi: ROI) -> Tuple[ROI, Optional measurement, then Sobel. Returns (roi, row_occupancy | None, col_occupancy | None). """ - lum = _detection_luma(img) - refined = _refine_film_roi_by_tiers(lum, film_roi) - if refined is not None: - return refined - - # Ahead of the border measurement, which reads one bright tier through a narrow ring. A - # camera scan that fills the sensor compresses mask and base into a few pixels, so that - # ring straddles both plus the picture and abstains. - walked = _trim_film_edges(lum, film_roi) - if walked != film_roi: - return walked, None, None - - measured = _roi_from_measured_border(lum, film_roi) - if measured is not None: - return measured, None, None - - if _find_rebate_level(lum, film_roi) is None: - # No uniform rebate plateau on any side means image content runs to the film edge. - # There is nothing to refine away, and Sobel would cut into the picture. - return film_roi, None, None - - y1, y2, x1, x2 = film_roi - _, (ref_left, ref_top, ref_right, ref_bottom) = _refine_frame_bounds(img[y1:y2, x1:x2]) - roi = (y1 + ref_top, y1 + ref_bottom, x1 + ref_left, x1 + ref_right) - - # Rebate is a small fraction of the film area, so a Sobel cut removing more than a - # quarter of the box latched onto image content. - film_area = max(1, (y2 - y1) * (x2 - x1)) - if (roi[1] - roi[0]) * (roi[3] - roi[2]) < 0.75 * film_area: - return film_roi, None, None - return roi, None, None + return negpy_core.refine_roi_to_image(np.ascontiguousarray(img, dtype=np.float32), tuple(int(v) for v in film_roi)) def _find_autocrop_roi_from_contours(img: ImageBuffer) -> ROI | None: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index ad69af19c..6d0cef594 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -500,9 +500,10 @@ depends = ["_candidate_from_contour"] ["features/geometry/logic.py::_dark_region_bounds"] phase = 2 -status = "todo" +status = "ported" calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.getStructuringElement", "negpy_core.bgr_to_gray", "negpy_core.find_external_contours", "negpy_core.morphology_ex", "np.percentile"] depends = ["_normalize_to_uint8"] +note = "negpy_core::analysis::dark_region_bounds (Python deleted)" ["features/geometry/logic.py::_detect_film_bounds"] phase = 2 @@ -633,15 +634,17 @@ note = "negpy_core::analysis::refine_film_roi_by_tiers" ["features/geometry/logic.py::_refine_frame_bounds"] phase = 2 -status = "todo" -calls = ["negpy_core.bgr_to_gray", "negpy_core.sobel", "np.percentile"] -depends = ["_dark_region_bounds", "_normalize_to_uint8", "_smooth_signal"] +status = "ported" +calls = ["negpy_core.refine_frame_bounds"] +depends = [] +note = "negpy_core::analysis::refine_frame_bounds" ["features/geometry/logic.py::_refine_roi_to_image"] phase = 2 -status = "todo" -calls = [] -depends = ["_detection_luma", "_find_rebate_level", "_refine_film_roi_by_tiers", "_refine_frame_bounds", "_roi_from_measured_border", "_trim_film_edges"] +status = "ported" +calls = ["negpy_core.refine_roi_to_image"] +depends = [] +note = "negpy_core::analysis::refine_roi_to_image" ["features/geometry/logic.py::_remap_tiled"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 874c131c6..cdf53805f 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -3,6 +3,7 @@ use multiversion::multiversion; use rayon::prelude::*; +use crate::filter::Border; use crate::stats::{np_percentiles, pairwise_sum_f32, percentiles}; const LUMA_R: f64 = 0.2126; @@ -1009,6 +1010,302 @@ pub fn refine_film_roi_by_tiers( }) } +/// The uint8 preview and its gray plane, as `_refine_frame_bounds` and `_dark_region_bounds` +/// build them from an HxW or HxWx3 float32 image. +fn preview_gray(img: &[f32], h: usize, w: usize, cn: usize) -> Vec { + let color: Vec = if cn == 1 { + img.iter().flat_map(|&v| [v, v, v]).collect() + } else { + img.to_vec() + }; + let mut preview = vec![0u8; h * w * 3]; + normalize_to_uint8(&color, &mut preview); + let mut gray = vec![0u8; h * w]; + crate::gray::bgr_to_gray(&preview, &mut gray); + gray +} + +/// `_boundary_candidates(signal, from_start)`: (edge index, value, inner index, value). +fn boundary_candidates(signal: &[f32], from_start: bool) -> (i64, f64, i64, f64) { + let length = signal.len() as i64; + if length == 0 { + return (0, 0.0, 0, 0.0); + } + let edge_window = (py_round(length as f64 * 0.08)) + .max(32) + .min((length - 1).max(1)); + let search_end = py_round(length as f64 * 0.45) + .max(edge_window + 1) + .min(length); + let search_start = py_round(length as f64 * 0.55).min((length - edge_window - 1).max(0)); + // np.argmax: the first maximum, or the first NaN. + let argmax = |s: &[f32]| -> usize { + s.iter().position(|v| v.is_nan()).unwrap_or_else(|| { + s.iter() + .enumerate() + .fold(0, |b, (i, &v)| if v > s[b] { i } else { b }) + }) + }; + let at = |a: i64, b: i64| { + &signal[a.clamp(0, length) as usize..b.clamp(0, length).max(a.clamp(0, length)) as usize] + }; + let (edge, edge_base, search, search_base) = if from_start { + ( + at(0, edge_window), + 0, + at(edge_window, search_end), + edge_window, + ) + } else { + ( + at(length - edge_window, length), + length - edge_window, + at(search_start, length - edge_window), + search_start, + ) + }; + let (edge_idx, edge_value) = if edge.is_empty() { + (edge_base, 0.0) + } else { + let k = argmax(edge); + (edge_base + k as i64, edge[k] as f64) + }; + if search.is_empty() { + return (edge_idx, edge_value, edge_idx, edge_value); + } + let k = argmax(search); + ( + edge_idx, + edge_value, + search_base + k as i64, + search[k] as f64, + ) +} + +/// `_dark_region_bounds(image)`: (left, top, right, bottom) of the largest dark region, or None. +fn dark_region_bounds(gray: &[u8], h: usize, w: usize) -> Option<(i64, i64, i64, i64)> { + let as_f: Vec = gray.iter().map(|&v| v as f64).collect(); + let threshold = np_percentile_f64(&as_f, 55.0); + let mask: Vec = gray + .iter() + .map(|&v| if (v as f64) <= threshold { 255 } else { 0 }) + .collect(); + let mut opened = vec![0u8; h * w]; + crate::morph::morphology( + &mask, + &mut opened, + (h, w, 1), + crate::morph::Op::Open, + (9, 9), + 1, + Border::Constant, + )?; + let mut closed = vec![0u8; h * w]; + crate::morph::morphology( + &opened, + &mut closed, + (h, w, 1), + crate::morph::Op::Close, + (31, 31), + 2, + Border::Constant, + )?; + let found = crate::contours::find_external_contours(&closed, h, w); + // max(contours, key=cv2.contourArea): the first of the largest. + let contour = found + .iter() + .fold(None::<&Vec<(i32, i32)>>, |best, c| match best { + Some(b) if crate::contours::contour_area(c) <= crate::contours::contour_area(b) => { + Some(b) + } + _ => Some(c), + })?; + let (x, y, bw, bh) = crate::contours::bounding_rect(contour); + let (x, y, bw, bh) = (x as i64, y as i64, bw as i64, bh as i64); + let area_ratio = crate::contours::contour_area(contour) / ((h * w) as f64).max(1.0); + if !(0.15..=0.85).contains(&area_ratio) { + return None; + } + let (hf, wf) = (h as f64, w as f64); + if bw < py_round(wf * 0.25) || bh < py_round(hf * 0.25) { + return None; + } + let pad_x = py_round(wf * 0.004).max(4); + let pad_y = py_round(hf * 0.004).max(4); + let (left, top) = ((x - pad_x).max(0), (y - pad_y).max(0)); + let (right, bottom) = ( + (x + bw + pad_x).min(w as i64), + (y + bh + pad_y).min(h as i64), + ); + let (inset_x, inset_y) = (py_round(wf * 0.03), py_round(hf * 0.03)); + if left < inset_x + || top < inset_y + || (w as i64 - right) < inset_x + || (h as i64 - bottom) < inset_y + { + return None; + } + Some((left, top, right, bottom)) +} + +/// `_refine_frame_bounds(image)`'s box (left, top, right, bottom) for an HxW or HxWx3 float32 +/// image: Sobel profiles of the gray preview, edge and inner candidates, and the dark-region +/// fallback. None for an image narrower or shorter than the 31-tap smoothing. +pub fn refine_frame_bounds( + img: &[f32], + h: usize, + w: usize, + cn: usize, +) -> Option<(i64, i64, i64, i64)> { + let gray = preview_gray(img, h, w, cn); + let gray_f: Vec = gray.iter().map(|&v| v as f32).collect(); + let grad = |dx: bool| -> Option> { + let (kx, ky): (&[f32], &[f32]) = if dx { + (&[-1.0, 0.0, 1.0], &[1.0, 2.0, 1.0]) + } else { + (&[1.0, 2.0, 1.0], &[-1.0, 0.0, 1.0]) + }; + let mut out = vec![0f32; h * w]; + crate::filter::sep_filter2d(&gray_f, &mut out, (h, w, 1), (kx, ky), Border::Reflect101)?; + Some( + out.iter() + .map(|&v| v.abs().min(255.0) as u8 as f64) + .collect(), + ) + }; + let (gx, gy) = (grad(true)?, grad(false)?); + let col: Vec = (0..w) + .map(|c| np_percentile_f64(&(0..h).map(|r| gx[r * w + c]).collect::>(), 95.0) as f32) + .collect(); + let row: Vec = (0..h) + .map(|r| np_percentile_f64(&gy[r * w..(r + 1) * w], 95.0) as f32) + .collect(); + let col_signal = smooth_signal(&col, 31)?; + let row_signal = smooth_signal(&row, 31)?; + + let (left_edge, _, left_inner, left_inner_value) = boundary_candidates(&col_signal, true); + let (right_edge, _, right_inner, right_inner_value) = boundary_candidates(&col_signal, false); + let (top_edge, top_edge_value, top_inner, top_inner_value) = + boundary_candidates(&row_signal, true); + let (bottom_edge, bottom_edge_value, bottom_inner, bottom_inner_value) = + boundary_candidates(&row_signal, false); + let col_floor = np_percentiles(&col_signal, &[75.0])?[0] as f64; + let row_floor = np_percentiles(&row_signal, &[75.0])?[0] as f64; + let (wf, hf) = (w as f64, h as f64); + + let (mut left, mut right, mut top, mut bottom) = + (left_edge, right_edge + 1, top_edge, bottom_edge + 1); + let pair_x = left_inner >= py_round(wf * 0.12) + && right_inner <= py_round(wf * 0.88) + && left_inner_value >= col_floor * 4.0 + && right_inner_value >= col_floor * 4.0 + && (right_inner - left_inner) >= py_round(wf * 0.5); + if pair_x { + left = left_inner; + right = right_inner + 1; + } else { + if left_inner >= py_round(wf * 0.12) && left_inner_value >= col_floor * 5.0 { + left = left_inner; + } + if right_inner <= py_round(wf * 0.88) && right_inner_value >= col_floor * 5.0 { + right = right_inner + 1; + } + } + let pair_y = top_inner > top_edge + 20 + && bottom_inner < bottom_edge - 20 + && top_inner_value > (top_edge_value * 1.2).max(row_floor + 25.0) + && bottom_inner_value > (bottom_edge_value * 1.2).max(row_floor + 25.0) + && (bottom_inner - top_inner) >= py_round(hf * 0.5); + if pair_y { + top = top_inner; + bottom = bottom_inner + 1; + } else { + if top_inner > top_edge + 20 + && top_inner_value > (top_edge_value * 1.45).max(row_floor + 35.0) + { + top = top_inner; + } + if bottom_inner < bottom_edge - 20 + && bottom_inner_value > (bottom_edge_value * 1.45).max(row_floor + 35.0) + { + bottom = bottom_inner + 1; + } + } + let pad_x = py_round(wf * 0.004).max(4); + let pad_y = py_round(hf * 0.004).max(4); + left = (left - pad_x).max(0); + right = (right + pad_x).min(w as i64); + top = (top - pad_y).max(0); + bottom = (bottom + pad_y).min(h as i64); + if right - left < py_round(wf * 0.5).max(1) { + (left, right) = (0, w as i64); + } + if bottom - top < py_round(hf * 0.5).max(1) { + (top, bottom) = (0, h as i64); + } + let area = ((right - left) * (bottom - top)) as f64 / (hf * wf).max(1.0); + if area > 0.8 + && let Some((dl, dt, dr, db)) = dark_region_bounds(&gray, h, w) + { + let dark_area = ((dr - dl) * (db - dt)) as f64 / (hf * wf).max(1.0); + if (0.15..=0.85).contains(&dark_area) { + return Some((dl, dt, dr, db)); + } + } + Some((left, top, right, bottom)) +} + +pub type Roi = (i64, i64, i64, i64); + +/// `_refine_roi_to_image(img, film_roi)`: the exposed picture inside the film box, by tiers, +/// then the edge walk, then the border measurement, then Sobel; with the occupancy profiles +/// when the tiers found it. +pub fn refine_roi_to_image( + img: &[f32], + h: usize, + w: usize, + cn: usize, + roi: Roi, +) -> Option<(Roi, Option)> { + let color: Vec = if cn == 1 { + img.iter().flat_map(|&v| [v, v, v]).collect() + } else { + img.to_vec() + }; + let mut lum = vec![0f32; h * w]; + luminance(&color, &mut lum); + let mut det = vec![0f32; h * w]; + detection_luma(&lum, &mut det)?; + let clamp = |v: i64| v.max(0) as usize; + let uroi = (clamp(roi.0), clamp(roi.1), clamp(roi.2), clamp(roi.3)); + if let Some(r) = refine_film_roi_by_tiers(&det, h, w, uroi) { + return Some((r.roi, Some(r))); + } + let walked = trim_film_edges(&det, h, w, roi); + if walked != roi { + return Some((walked, None)); + } + if let Some(m) = roi_from_measured_border(&det, h, w, roi) { + return Some((m, None)); + } + if find_rebate_level(&det, h, w, uroi, None).is_none() { + return Some((roi, None)); + } + let (y1, y2, x1, x2) = (uroi.0.min(h), uroi.1.min(h), uroi.2.min(w), uroi.3.min(w)); + let crop_w = x2.saturating_sub(x1); + let crop: Vec = (y1..y2) + .flat_map(|y| color[(y * w + x1) * 3..(y * w + x2) * 3].iter().copied()) + .collect(); + let (left, top, right, bottom) = refine_frame_bounds(&crop, y2.saturating_sub(y1), crop_w, 3)?; + let out = (roi.0 + top, roi.0 + bottom, roi.2 + left, roi.2 + right); + // A Sobel cut removing more than a quarter of the box latched onto picture content. + let film_area = ((roi.1 - roi.0) * (roi.3 - roi.2)).max(1) as f64; + if (((out.1 - out.0) * (out.3 - out.2)) as f64) < 0.75 * film_area { + return Some((roi, None)); + } + Some((out, None)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/contours.rs b/negpy_core/src/contours.rs index 7090fe338..173e175f8 100644 --- a/negpy_core/src/contours.rs +++ b/negpy_core/src/contours.rs @@ -137,6 +137,34 @@ fn trace( pts } +/// `cv2.contourArea` of integer points: the shoelace sum in double, unsigned. +pub fn contour_area(pts: &[(i32, i32)]) -> f64 { + let Some(&last) = pts.last() else { return 0.0 }; + let mut prev = (last.0 as f32, last.1 as f32); + let mut a = 0f64; + for &(x, y) in pts { + let p = (x as f32, y as f32); + a += prev.0 as f64 * p.1 as f64 - prev.1 as f64 * p.0 as f64; + prev = p; + } + (a * 0.5).abs() +} + +/// `cv2.boundingRect` of integer points: (x, y, width, height). +pub fn bounding_rect(pts: &[(i32, i32)]) -> (i32, i32, i32, i32) { + if pts.is_empty() { + return (0, 0, 0, 0); + } + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + for &(x, y) in pts { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + (x0, y0, x1 - x0 + 1, y1 - y0 + 1) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 4a5f01d86..8303657f5 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -285,6 +285,67 @@ fn refine_film_roi_by_tiers<'py>( })) } +/// `_refine_frame_bounds(image)`'s (left, top, right, bottom) for an HxW or HxWx3 float32 image. +#[pyfunction] +fn refine_frame_bounds( + py: Python<'_>, + image: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<(i64, i64, i64, i64)> { + let (h, w, cn) = match image.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "refine_frame_bounds needs an HxW or HxWx3 array", + )); + } + }; + let data = slice(&image)?; + py.detach(|| analysis::refine_frame_bounds(data, h, w, cn)) + .ok_or_else(|| { + PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") + }) +} + +type Gated<'py> = ( + analysis::Roi, + Option>>, + Option>>, +); + +/// `_refine_roi_to_image(img, film_roi)` for an HxW or HxWx3 float32 image: (roi, row +/// occupancy or None, column occupancy or None). +#[pyfunction] +fn refine_roi_to_image<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + film_roi: analysis::Roi, +) -> PyResult> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "refine_roi_to_image needs an HxW or HxWx3 array", + )); + } + }; + let data = slice(&img)?; + let (roi, tiers) = py + .detach(|| analysis::refine_roi_to_image(data, h, w, cn, film_roi)) + .ok_or_else(|| { + PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") + })?; + Ok(match tiers { + Some(t) => ( + roi, + Some(numpy::PyArray1::from_vec(py, t.rows)), + Some(numpy::PyArray1::from_vec(py, t.cols)), + ), + None => (roi, None, None), + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; @@ -298,5 +359,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(roi_from_measured_border, m)?)?; m.add_function(wrap_pyfunction!(smooth_signal, m)?)?; m.add_function(wrap_pyfunction!(snap_edge_to_gradient, m)?)?; - m.add_function(wrap_pyfunction!(refine_film_roi_by_tiers, m)?) + m.add_function(wrap_pyfunction!(refine_film_roi_by_tiers, m)?)?; + m.add_function(wrap_pyfunction!(refine_frame_bounds, m)?)?; + m.add_function(wrap_pyfunction!(refine_roi_to_image, m)?) } From 6435459bdad18992659d34c97b77efbd74b6b70e Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:35:36 +0200 Subject: [PATCH 027/133] feat(core): convexHull, minAreaRect, boxPoints, fillConvexPoly in Rust shapes.rs ports OpenCV's Sklansky hull (sorted by x, y, then original index, with its cyclic rotation toward ascending indices), the rotating calipers of minAreaRect, RotatedRect::points, boundingRect of float points and fillConvexPoly (8-connected Bresenham outline with clipLine, then the two-edge scanline fill). Those OpenCV files build without FMA on x86-64, so plain float arithmetic matches. atan2, cos and sin are libm's; on 389 contours (recorded masks and random sets) hull, rect and box are identical to cv2, and the fill on 778 masks. --- .github/workflows/ci.yml | 2 +- negpy_core/src/lib.rs | 1 + negpy_core/src/py/contours.rs | 66 +++- negpy_core/src/shapes.rs | 603 ++++++++++++++++++++++++++++++++++ tests/test_core_shapes.py | 33 ++ 5 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 negpy_core/src/shapes.rs create mode 100644 tests/test_core_shapes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 957a8479d..17580e587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 60c4eb184..dca0e75e9 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -4,6 +4,7 @@ pub mod filter; pub mod gray; pub mod morph; pub mod resize; +pub mod shapes; pub mod stats; #[cfg(feature = "python")] diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index eeeeab98d..0ccd5c445 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -1,10 +1,12 @@ -use numpy::{PyArray3, PyReadonlyArray2}; +use numpy::{PyArray3, PyArrayMethods, PyReadonlyArray2}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyTuple; use crate::contours; +type PyRotatedRect = ((f32, f32), (f32, f32), f32); + /// `cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)` for a uint8 image: /// (contours as Nx1x2 int32 arrays, hierarchy as 1xKx4 int32), in OpenCV's order. #[pyfunction] @@ -46,6 +48,66 @@ fn find_external_contours<'py>( Ok((PyTuple::new(py, arrays)?, hier)) } +fn int_points(pts: &numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult> { + let flat = pts + .as_slice() + .map_err(|_| PyValueError::new_err("points must be contiguous"))?; + if flat.len() % 2 != 0 { + return Err(PyValueError::new_err("points must be pairs")); + } + Ok(flat + .as_chunks::<2>() + .0 + .iter() + .map(|p| (p[0], p[1])) + .collect()) +} + +/// `cv2.minAreaRect(points)` of int32 points: ((cx, cy), (w, h), angle). +#[pyfunction] +fn min_area_rect(pts: numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult { + let r = crate::shapes::min_area_rect(&int_points(&pts)?); + Ok((r.center, r.size, r.angle)) +} + +/// `cv2.boxPoints(rect)`: four (x, y) float32 corners. +#[pyfunction] +fn box_points(rect: PyRotatedRect) -> Vec<(f32, f32)> { + let r = crate::shapes::RotatedRect { + center: rect.0, + size: rect.1, + angle: rect.2, + }; + crate::shapes::box_points(&r).to_vec() +} + +/// `cv2.convexHull(points)` of int32 points, as a list of (x, y). +#[pyfunction] +fn convex_hull(pts: numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult> { + Ok(crate::shapes::convex_hull(&int_points(&pts)?)) +} + +/// `cv2.fillConvexPoly(zeros((h, w), uint8), points, 1)` as a new mask. +#[pyfunction] +fn fill_convex_poly<'py>( + py: Python<'py>, + shape: (usize, usize), + pts: numpy::PyReadonlyArrayDyn<'_, i32>, +) -> PyResult>> { + let (h, w) = shape; + let v: Vec<(i64, i64)> = int_points(&pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + let mut mask = vec![0u8; h * w]; + crate::shapes::fill_convex_poly(&mut mask, w, h, &v, 1); + numpy::PyArray1::from_vec(py, mask).reshape([h, w]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(find_external_contours, m)?) + m.add_function(wrap_pyfunction!(find_external_contours, m)?)?; + m.add_function(wrap_pyfunction!(min_area_rect, m)?)?; + m.add_function(wrap_pyfunction!(box_points, m)?)?; + m.add_function(wrap_pyfunction!(convex_hull, m)?)?; + m.add_function(wrap_pyfunction!(fill_convex_poly, m)?) } diff --git a/negpy_core/src/shapes.rs b/negpy_core/src/shapes.rs new file mode 100644 index 000000000..7738e2d76 --- /dev/null +++ b/negpy_core/src/shapes.rs @@ -0,0 +1,603 @@ +//! OpenCV's shape helpers for integer contours: convexHull, minAreaRect (rotating calipers), +//! boxPoints, boundingRect of float points, and fillConvexPoly. Ported from convhull.cpp, +//! rotcalipers.cpp, geometry.cpp, types.cpp and drawing.cpp, which build without FMA on +//! x86-64, so plain float arithmetic matches them. + +/// `convexHull(points, clockwise=false, returnPoints=true)` of integer points. +pub fn convex_hull(points: &[(i32, i32)]) -> Vec<(i32, i32)> { + let total = points.len(); + if total == 0 { + return Vec::new(); + } + // Sorted by x, then y, then original position (the comparator's pointer fallback). + let mut order: Vec = (0..total).collect(); + order.sort_by(|&a, &b| { + points[a] + .0 + .cmp(&points[b].0) + .then(points[a].1.cmp(&points[b].1)) + .then(a.cmp(&b)) + }); + let pt = |i: usize| points[order[i]]; + let (mut miny, mut maxy) = (0usize, 0usize); + for i in 1..total { + let y = pt(i).1; + if pt(miny).1 > y { + miny = i; + } + if pt(maxy).1 < y { + maxy = i; + } + } + let mut hull: Vec = Vec::new(); + if pt(0) == pt(total - 1) { + hull.push(0); + } else { + let mut stack = vec![0usize; total + 2]; + let tl_count = sklansky(&pt, 0, maxy as isize, &mut stack[..], -1, 1); + let tl: Vec = stack[..tl_count].to_vec(); + let tr_count = sklansky( + &pt, + total as isize - 1, + maxy as isize, + &mut stack[..], + -1, + -1, + ); + let tr: Vec = stack[..tr_count].to_vec(); + // Counter-clockwise: the right chain first. + let (tl, tr) = (tr, tl); + for &i in &tl[..tl.len().saturating_sub(1)] { + hull.push(i); + } + for i in (1..tr.len()).rev() { + hull.push(tr[i]); + } + let stop_idx: isize = if tr.len() > 2 { + tr[1] as isize + } else if tl.len() > 2 { + tl[tl.len() - 2] as isize + } else { + -1 + }; + let bl_count = sklansky(&pt, 0, miny as isize, &mut stack[..], 1, -1); + let bl: Vec = stack[..bl_count].to_vec(); + let br_count = sklansky(&pt, total as isize - 1, miny as isize, &mut stack[..], 1, 1); + let br: Vec = stack[..br_count].to_vec(); + let (mut blc, mut brc) = (bl.len(), br.len()); + if stop_idx >= 0 { + let check_idx: isize = if blc > 2 { + bl[1] as isize + } else if blc + brc > 2 { + br[2 - blc] as isize + } else { + -1 + }; + if check_idx == stop_idx + || (check_idx >= 0 && pt(check_idx as usize) == pt(stop_idx as usize)) + { + blc = blc.min(2); + brc = brc.min(2); + } + } + for &i in &bl[..blc.saturating_sub(1)] { + hull.push(i); + } + for i in (1..brc).rev() { + hull.push(br[i]); + } + } + let mut idx: Vec = hull.iter().map(|&i| order[i]).collect(); + // Rotate toward an ascending or descending index sequence, as OpenCV does. + let nout = idx.len(); + if nout >= 3 { + let (mut min_idx, mut max_idx, mut lt) = (0usize, 0usize, 0usize); + for i in 1..nout { + let v = idx[i]; + lt += (idx[i - 1] < v) as usize; + if lt > 1 && lt + 2 <= i { + break; + } + if v < idx[min_idx] { + min_idx = i; + } + if v > idx[max_idx] { + max_idx = i; + } + } + let mmdist = max_idx.abs_diff(min_idx); + if (mmdist == 1 || mmdist == nout - 1) && (lt <= 1 || lt + 2 >= nout) { + let ascending = (max_idx + 1) % nout == min_idx; + let i0 = if ascending { min_idx } else { max_idx }; + if i0 > 0 { + let mut out = Vec::with_capacity(nout); + let mut j = i0; + let mut complete = true; + for i in 0..nout { + let curr = idx[j]; + out.push(curr); + let next_j = if j + 1 < nout { j + 1 } else { 0 }; + if i < nout - 1 && (ascending != (curr < idx[next_j])) { + complete = false; + break; + } + j = next_j; + } + if complete { + idx = out; + } + } + } + } + idx.iter().map(|&i| points[i]).collect() +} + +/// `Sklansky_`: one monotone chain between `start` and `end` (sorted indices). +fn sklansky( + pt: &dyn Fn(usize) -> (i32, i32), + start: isize, + end: isize, + stack: &mut [usize], + nsign: i32, + sign2: i32, +) -> usize { + let incr: isize = if end > start { 1 } else { -1 }; + let (mut pprev, mut pcur) = (start, start + incr); + let mut pnext = pcur + incr; + let mut size = 3usize; + let p = |i: isize| pt(i as usize); + if start == end || p(start) == p(end) { + stack[0] = start as usize; + return 1; + } + stack[0] = pprev as usize; + stack[1] = pcur as usize; + stack[2] = pnext as usize; + let end = end + incr; + while pnext != end { + let (cury, nexty) = (p(pcur).1, p(pnext).1); + let by = nexty - cury; + if by.signum() != nsign { + let a = (p(pcur).0 - p(pprev).0, cury - p(pprev).1); + let b = (p(pnext).0 - p(pcur).0, by); + let convexity = a.1 as i64 * b.0 as i64 - a.0 as i64 * b.1 as i64; + if convexity.signum() as i32 == sign2 && (a.0 != 0 || a.1 != 0) { + pprev = pcur; + pcur = pnext; + pnext += incr; + stack[size] = pnext as usize; + size += 1; + } else if pprev == start { + pcur = pnext; + stack[1] = pcur as usize; + pnext += incr; + stack[2] = pnext as usize; + } else { + stack[size - 2] = pnext as usize; + pcur = pprev; + pprev = stack[size - 4] as isize; + size -= 1; + } + } else { + pnext += incr; + stack[size - 1] = pnext as usize; + } + } + size - 1 +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RotatedRect { + pub center: (f32, f32), + pub size: (f32, f32), + pub angle: f32, +} + +fn right_of(v1: (f32, f32), v2: (f32, f32)) -> bool { + // rotate90CW(v1) . v2 < 0 + v1.1 * v2.0 + (-v1.0) * v2.1 < 0.0 +} + +/// `cv2.minAreaRect` of integer points. +pub fn min_area_rect(points: &[(i32, i32)]) -> RotatedRect { + let hull: Vec<(f32, f32)> = convex_hull(points) + .iter() + .map(|&(x, y)| (x as f32, y as f32)) + .collect(); + let n = hull.len(); + let mut angle = -std::f64::consts::PI / 2.0; + let mut rect = RotatedRect { + center: (0.0, 0.0), + size: (0.0, 0.0), + angle: 0.0, + }; + if n > 2 { + let out = rotating_calipers(&hull); + rect.center = ( + out[0].0 + (out[1].0 + out[2].0) * 0.5, + out[0].1 + (out[1].1 + out[2].1) * 0.5, + ); + rect.size.0 = + ((out[2].0 as f64 * out[2].0 as f64 + out[2].1 as f64 * out[2].1 as f64).sqrt()) as f32; + rect.size.1 = + ((out[1].0 as f64 * out[1].0 as f64 + out[1].1 as f64 * out[1].1 as f64).sqrt()) as f32; + if out[1].0 == 0.0 && out[1].1 > 0.0 { + rect.size = (rect.size.1, rect.size.0); + } else { + angle = -libm::atan2(out[1].0 as f64, out[1].1 as f64); + } + } else if n == 2 { + rect.center = ((hull[0].0 + hull[1].0) * 0.5, (hull[0].1 + hull[1].1) * 0.5); + let dx = hull[0].0 as f64 - hull[1].0 as f64; + let dy = hull[0].1 as f64 - hull[1].1 as f64; + rect.size = (0.0, (dx * dx + dy * dy).sqrt() as f32); + if dx == 0.0 { + rect.size = (rect.size.1, rect.size.0); + } else if dy < 0.0 { + angle = libm::atan2(dy, dx); + rect.size = (rect.size.1, rect.size.0); + } else if dy > 0.0 { + angle = -libm::atan2(dx, dy); + } + } else if n == 1 { + rect.center = hull[0]; + } + rect.angle = (angle * 180.0 / std::f64::consts::PI) as f32; + rect +} + +/// `rotatingCalipers(..., CALIPERS_MINAREARECT)` for a counter-clockwise hull: the corner point +/// and the two side vectors. +fn rotating_calipers(points: &[(f32, f32)]) -> [(f32, f32); 3] { + let n = points.len(); + let mut vect = vec![(0f32, 0f32); n]; + let mut inv_len = vec![0f32; n]; + let (mut left, mut bottom, mut right, mut top) = (0usize, 0usize, 0usize, 0usize); + let mut pt0 = points[0]; + let (mut left_x, mut right_x, mut top_y, mut bottom_y) = (pt0.0, pt0.0, pt0.1, pt0.1); + for i in 0..n { + if pt0.0 < left_x { + left_x = pt0.0; + left = i; + } + if pt0.0 > right_x { + right_x = pt0.0; + right = i; + } + if pt0.1 > top_y { + top_y = pt0.1; + top = i; + } + if pt0.1 < bottom_y { + bottom_y = pt0.1; + bottom = i; + } + let pt = points[if i + 1 < n { i + 1 } else { 0 }]; + let dx = pt.0 as f64 - pt0.0 as f64; + let dy = pt.1 as f64 - pt0.1 as f64; + vect[i] = (dx as f32, dy as f32); + inv_len[i] = (1.0 / (dx * dx + dy * dy).sqrt()) as f32; + pt0 = pt; + } + let mut seq = [bottom, right, top, left]; + let mut minarea = f32::MAX; + let mut best_left = 0usize; + let mut best_bottom = 0usize; + let (mut ba, mut bw, mut bb, mut bh) = (0f32, 0f32, 0f32, 0f32); + for _ in 0..n { + let rot = [ + vect[seq[0]], + (vect[seq[1]].1, -vect[seq[1]].0), + (-vect[seq[2]].0, -vect[seq[2]].1), + (-vect[seq[3]].1, vect[seq[3]].0), + ]; + let mut main = 0usize; + for i in 1..4 { + if right_of(rot[i], rot[main]) { + main = i; + } + } + let pindex = seq[main]; + let lead_x = vect[pindex].0 * inv_len[pindex]; + let lead_y = vect[pindex].1 * inv_len[pindex]; + let (base_a, base_b) = match main { + 0 => (lead_x, lead_y), + 1 => (lead_y, -lead_x), + 2 => (-lead_x, -lead_y), + _ => (-lead_y, lead_x), + }; + seq[main] = if seq[main] + 1 == n { 0 } else { seq[main] + 1 }; + let dx = points[seq[1]].0 - points[seq[3]].0; + let dy = points[seq[1]].1 - points[seq[3]].1; + let width = dx * base_a + dy * base_b; + let dx = points[seq[2]].0 - points[seq[0]].0; + let dy = points[seq[2]].1 - points[seq[0]].1; + let height = -dx * base_b + dy * base_a; + let area = width * height; + if area <= minarea { + minarea = area; + best_left = seq[3]; + ba = base_a; + bw = width; + bb = base_b; + bh = height; + best_bottom = seq[0]; + } + } + let (a1, b1) = (ba, bb); + let (a2, b2) = (-bb, ba); + let c1 = a1 * points[best_left].0 + points[best_left].1 * b1; + let c2 = a2 * points[best_bottom].0 + points[best_bottom].1 * b2; + let idet = 1.0f32 / (a1 * b2 - a2 * b1); + let px = (c1 * b2 - c2 * b1) * idet; + let py = (a1 * c2 - a2 * c1) * idet; + [(px, py), (a1 * bw, b1 * bw), (a2 * bh, b2 * bh)] +} + +/// `cv2.boxPoints(rect)` (`RotatedRect::points`); cos and sin are libm's (see LEDGER.md). +pub fn box_points(r: &RotatedRect) -> [(f32, f32); 4] { + let a_rad = r.angle as f64 * std::f64::consts::PI / 180.0; + let b = libm::cos(a_rad) as f32 * 0.5; + let a = libm::sin(a_rad) as f32 * 0.5; + let (cx, cy) = r.center; + let (w, h) = r.size; + let p0 = (cx - a * h - b * w, cy + b * h - a * w); + let p1 = (cx + a * h - b * w, cy - b * h - a * w); + let p2 = (2.0 * cx - p0.0, 2.0 * cy - p0.1); + let p3 = (2.0 * cx - p1.0, 2.0 * cy - p1.1); + [p0, p1, p2, p3] +} + +/// `cv2.boundingRect` of float points: floors of the extremes, (x, y, width, height). +pub fn bounding_rect_f32(pts: &[(f32, f32)]) -> (i32, i32, i32, i32) { + if pts.is_empty() { + return (0, 0, 0, 0); + } + let floor = |v: f32| v.floor() as i32; + let (mut x0, mut y0, mut x1, mut y1) = ( + floor(pts[0].0), + floor(pts[0].1), + floor(pts[0].0), + floor(pts[0].1), + ); + for &(x, y) in pts { + x0 = x0.min(floor(x)); + y0 = y0.min(floor(y)); + x1 = x1.max(floor(x)); + y1 = y1.max(floor(y)); + } + (x0, y0, x1 - x0 + 1, y1 - y0 + 1) +} + +const XY_SHIFT: i64 = 16; +const XY_ONE: i64 = 1 << XY_SHIFT; + +/// `clipLine(Size2l, pt1, pt2)`: false when the segment misses the image. +fn clip_line(w: i64, h: i64, p1: &mut (i64, i64), p2: &mut (i64, i64)) -> bool { + let (right, bottom) = (w - 1, h - 1); + if w <= 0 || h <= 0 { + return false; + } + let code = |p: &(i64, i64)| { + (p.0 < 0) as i32 + + (p.0 > right) as i32 * 2 + + (p.1 < 0) as i32 * 4 + + (p.1 > bottom) as i32 * 8 + }; + let (mut c1, mut c2) = (code(p1), code(p2)); + if (c1 & c2) == 0 && (c1 | c2) != 0 { + if c1 & 12 != 0 { + let a = if c1 < 8 { 0 } else { bottom }; + p1.0 += ((a - p1.1) as f64 * (p2.0 - p1.0) as f64 / (p2.1 - p1.1) as f64) as i64; + p1.1 = a; + c1 = (p1.0 < 0) as i32 + (p1.0 > right) as i32 * 2; + } + if c2 & 12 != 0 { + let a = if c2 < 8 { 0 } else { bottom }; + p2.0 += ((a - p2.1) as f64 * (p2.0 - p1.0) as f64 / (p2.1 - p1.1) as f64) as i64; + p2.1 = a; + c2 = (p2.0 < 0) as i32 + (p2.0 > right) as i32 * 2; + } + if (c1 & c2) == 0 && (c1 | c2) != 0 { + if c1 != 0 { + let a = if c1 == 1 { 0 } else { right }; + p1.1 += ((a - p1.0) as f64 * (p2.1 - p1.1) as f64 / (p2.0 - p1.0) as f64) as i64; + p1.0 = a; + c1 = 0; + } + if c2 != 0 { + let a = if c2 == 1 { 0 } else { right }; + p2.1 += ((a - p2.0) as f64 * (p2.1 - p1.1) as f64 / (p2.0 - p1.0) as f64) as i64; + p2.0 = a; + c2 = 0; + } + } + } + (c1 | c2) == 0 +} + +/// `Line(img, pt1, pt2, color, 8)`: an 8-connected line drawn left to right. +fn draw_line(mask: &mut [u8], w: usize, h: usize, pt1: (i64, i64), pt2: (i64, i64), color: u8) { + let (mut p1, mut p2) = (pt1, pt2); + let outside = |p: &(i64, i64)| p.0 < 0 || p.0 >= w as i64 || p.1 < 0 || p.1 >= h as i64; + if (outside(&p1) || outside(&p2)) && !clip_line(w as i64, h as i64, &mut p1, &mut p2) { + return; + } + let (mut dx, mut dy) = (p2.0 - p1.0, p2.1 - p1.1); + let (mut delta_x, mut delta_y) = (1i64, 1i64); + if dx < 0 { + dx = -dx; + dy = -dy; + p1 = p2; + } + if dy < 0 { + dy = -dy; + delta_y = -1; + } + let vert = dy > dx; + if vert { + std::mem::swap(&mut dx, &mut dy); + std::mem::swap(&mut delta_x, &mut delta_y); + } + let mut err = dx - (dy + dy); + let plus_delta = dx + dx; + let minus_delta = -(dy + dy); + // (x, y) moves. OpenCV swaps step and shift for a steep line, after swapping the deltas. + let (minus, plus) = if vert { + ((0, delta_x), (delta_y, 0)) + } else { + ((delta_x, 0), (0, delta_y)) + }; + let (mut x, mut y) = p1; + for _ in 0..=dx { + if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { + mask[y as usize * w + x as usize] = color; + } + let m = err < 0; + err += minus_delta + if m { plus_delta } else { 0 }; + x += minus.0 + if m { plus.0 } else { 0 }; + y += minus.1 + if m { plus.1 } else { 0 }; + } +} + +/// `cv2.fillConvexPoly(mask, pts, color)` (8-connected outline, shift 0) on a w x h uint8 mask. +pub fn fill_convex_poly(mask: &mut [u8], w: usize, h: usize, v: &[(i64, i64)], color: u8) { + let npts = v.len(); + if npts == 0 { + return; + } + let delta1 = XY_ONE >> 1; + let delta2 = XY_ONE >> 1; + let mut p0 = (v[npts - 1].0 << XY_SHIFT, v[npts - 1].1 << XY_SHIFT); + let (mut xmin, mut xmax, mut ymin, mut ymax) = (v[0].0, v[0].0, v[0].1, v[0].1); + let mut imin = 0usize; + for (i, &q) in v.iter().enumerate() { + if q.1 < ymin { + ymin = q.1; + imin = i; + } + ymax = ymax.max(q.1); + xmax = xmax.max(q.0); + xmin = xmin.min(q.0); + let p = (q.0 << XY_SHIFT, q.1 << XY_SHIFT); + draw_line( + mask, + w, + h, + (p0.0 >> XY_SHIFT, p0.1 >> XY_SHIFT), + (p.0 >> XY_SHIFT, p.1 >> XY_SHIFT), + color, + ); + p0 = p; + } + if npts < 3 || xmax < 0 || ymax < 0 || xmin >= w as i64 || ymin >= h as i64 { + return; + } + let ymax = ymax.min(h as i64 - 1); + #[derive(Clone, Copy)] + struct Edge { + idx: usize, + di: usize, + x: i64, + dx: i64, + ye: i64, + } + let mut edge = [ + Edge { + idx: imin, + di: 1, + x: -XY_ONE, + dx: 0, + ye: ymin, + }, + Edge { + idx: imin, + di: npts - 1, + x: -XY_ONE, + dx: 0, + ye: ymin, + }, + ]; + let mut edges = npts as i64; + let mut y = ymin; + loop { + for e in edge.iter_mut() { + if y >= e.ye { + let mut idx0 = e.idx; + let mut idx = (idx0 + e.di) % npts; + loop { + let go = edges > 0; + edges -= 1; + if !go { + break; + } + let ty = v[idx].1; + if ty > y { + let xs = v[idx0].0 << XY_SHIFT; + let xe = v[idx].0 << XY_SHIFT; + e.ye = ty; + e.dx = ((xe - xs) * 2 + (ty - y)) / (2 * (ty - y)); + e.x = xs; + e.idx = idx; + break; + } + idx0 = idx; + idx = (idx + e.di) % npts; + } + } + } + if edges < 0 { + break; + } + if y >= 0 { + let (left, right) = if edge[0].x > edge[1].x { + (1, 0) + } else { + (0, 1) + }; + let mut xx1 = (edge[left].x + delta1) >> XY_SHIFT; + let mut xx2 = (edge[right].x + delta2) >> XY_SHIFT; + if xx2 >= 0 && xx1 < w as i64 { + xx1 = xx1.max(0); + xx2 = xx2.min(w as i64 - 1); + let row = y as usize * w; + mask[row + xx1 as usize..=row + xx2 as usize].fill(color); + } + } + edge[0].x += edge[0].dx; + edge[1].x += edge[1].dx; + y += 1; + if y > ymax { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hull_of_a_square_with_inner_points() { + let pts = [(0, 0), (5, 5), (10, 0), (10, 10), (0, 10), (3, 4)]; + let hull = convex_hull(&pts); + assert_eq!(hull.len(), 4); + } + + #[test] + fn axis_aligned_rect() { + let pts = [(2, 3), (12, 3), (12, 8), (2, 8)]; + let r = min_area_rect(&pts); + assert_eq!(r.center, (7.0, 5.5)); + let mut s = [r.size.0, r.size.1]; + s.sort_by(f32::total_cmp); + assert_eq!(s, [5.0, 10.0]); + } + + #[test] + fn filled_square() { + let mut mask = vec![0u8; 10 * 10]; + fill_convex_poly(&mut mask, 10, 10, &[(2, 2), (6, 2), (6, 6), (2, 6)], 1); + assert_eq!(mask.iter().filter(|&&v| v == 1).count(), 25); + } +} diff --git a/tests/test_core_shapes.py b/tests/test_core_shapes.py new file mode 100644 index 000000000..59aef7961 --- /dev/null +++ b/tests/test_core_shapes.py @@ -0,0 +1,33 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(15) +_POINT_SETS = [_RNG.integers(-50, 500, (n, 2)).astype(np.int32) for n in (1, 2, 3, 4, 7, 12, 39)] + [ + np.array([[0, 0], [10, 0], [10, 10], [0, 10], [5, 5]], np.int32), + np.array([[0, 0], [5, 0], [10, 0]], np.int32), + np.array([[3, 3], [3, 3], [3, 3]], np.int32), +] + + +@pytest.mark.parametrize("index", range(len(_POINT_SETS))) +def test_hull_rect_and_box_match_cv2(index): + pts = _POINT_SETS[index] + assert np.array_equal(np.array(negpy_core.convex_hull(pts), np.int32).reshape(-1, 2), cv2.convexHull(pts).reshape(-1, 2)) + want = cv2.minAreaRect(pts) + got = negpy_core.min_area_rect(pts) + assert np.array_equal(np.float32([*want[0], *want[1], want[2]]), np.float32([*got[0], *got[1], got[2]])) + assert np.array_equal(np.array(negpy_core.box_points(want), np.float32), cv2.boxPoints(want)) + + +@pytest.mark.parametrize("index", range(len(_POINT_SETS))) +@pytest.mark.parametrize("shape", [(400, 500), (60, 70)]) +def test_fill_convex_poly_matches_cv2(index, shape): + quad = np.rint(cv2.boxPoints(cv2.minAreaRect(_POINT_SETS[index]))).astype(np.int32) + want = np.zeros(shape, np.uint8) + cv2.fillConvexPoly(want, quad, 1) + assert np.array_equal(negpy_core.fill_convex_poly(shape, quad), want) From 218d0a271fa6596f30ad605659cd654f31b0d1d1 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:59:16 +0200 Subject: [PATCH 028/133] perf(core): port detect_film_bounds_with_confidence The whole outer-film detector runs in negpy_core: adaptive and structural masks, candidate scoring, consensus, surround gates and the bed snap. The trials run in parallel; candidates join in Python's order. The Python subtree is deleted; tests call negpy_core.select_consensus, film_box_covers_enough and film_surround_is_plausible directly. Replay: 60/60 detect_film_bounds_with_confidence calls bit-identical. One-thread bench against the recorded cv2 Python: 1.77x before the primitive fixes below, 0.78x after. - percentile with a sequence of q: radix select, not a full sort. - uint8 GaussianBlur: kernel-major sums, u16 rows, AVX2 dispatch. - Canny: padded i16 Sobel, no per-pixel clamps. - findContours scan: skips runs eight bytes at a time. - par_zip: rayon blocks instead of a task per element, and a move closure. A capture read through a reference made threshold_binary 4.5 ms instead of 0.1 ms on 1.8 MP. --- negpy/features/geometry/logic.py | 595 +--------------- negpy/features/geometry/skew.py | 2 +- negpy_core/manifest.toml | 34 +- negpy_core/src/analysis.rs | 4 +- negpy_core/src/contours.rs | 32 +- negpy_core/src/detect.rs | 1121 ++++++++++++++++++++++++++++++ negpy_core/src/gray.rs | 152 ++-- negpy_core/src/lib.rs | 12 + negpy_core/src/morph.rs | 12 +- negpy_core/src/py/analysis.rs | 145 +++- negpy_core/src/stats.rs | 45 +- tests/test_autocrop_consensus.py | 34 +- tests/test_geometry_logic.py | 4 +- 13 files changed, 1485 insertions(+), 707 deletions(-) create mode 100644 negpy_core/src/detect.rs diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index e2e1c5bc4..b03cf4ea6 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -19,15 +19,6 @@ from negpy.kernel.image.validation import ensure_image AUTOCROP_DETECT_RES = 1800 -_ADAPTIVE_THRESHOLD_COUNT = 7 -_SIDE_NAMES = ("top", "right", "bottom", "left") -_CORNER_SIDES = { - "top_left": frozenset({"top", "left"}), - "top_right": frozenset({"top", "right"}), - "bottom_right": frozenset({"bottom", "right"}), - "bottom_left": frozenset({"bottom", "left"}), -} -_EVIDENCE_SOURCE_ORDER = ("adaptive-dark", "adaptive-bright", "inverse-threshold", "blackhat", "edges") @dataclass(frozen=True) @@ -69,32 +60,6 @@ def __post_init__(self) -> None: object.__setattr__(self, "vertical_edge_profile", profile) -@dataclass(frozen=True) -class _FilmCandidate: - roi: ROI - correction_angle: float - source: str - threshold_index: int | None - polarity: str - boundary_score: float - geometry_score: float - supported_sides: frozenset[str] - supported_corners: frozenset[str] - - -@dataclass(frozen=True) -class _FilmConsensus: - roi: ROI - correction_angle: float - confidence: float - supported_sides: frozenset[str] - supported_corners: frozenset[str] - evidence_sources: tuple[str, ...] - geometry_score: float - dispersion: float - polarity: str - - def _normalize_detection_input(img: ImageBuffer, detect_res: int) -> tuple[np.ndarray, float]: """ Downsamples to detect_res longest edge (INTER_AREA). Never upscales. @@ -147,33 +112,6 @@ def _refine_frame_bounds(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int, return image[top:bottom, left:right], (left, top, right, bottom) -def _mask_from_blackhat(gray: np.ndarray) -> np.ndarray: - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)) - blackhat = negpy_core.morphology_ex(gray, cv2.MORPH_BLACKHAT, kernel) - blackhat = negpy_core.gaussian_blur_u8(blackhat, (5, 5), 0) - _, thresh = negpy_core.threshold_otsu(blackhat) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 21)) - return negpy_core.morphology_ex(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) - - -def _mask_from_inverse_threshold(gray: np.ndarray) -> np.ndarray: - blurred = negpy_core.gaussian_blur_u8(gray, (7, 7), 0) - _, thresh = negpy_core.threshold_otsu(blurred, 255, True) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17)) - open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) - cleaned = negpy_core.morphology_ex(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) - return negpy_core.morphology_ex(cleaned, cv2.MORPH_OPEN, open_kernel, iterations=1) - - -def _mask_from_edges(gray: np.ndarray) -> np.ndarray: - blurred = negpy_core.gaussian_blur_u8(gray, (5, 5), 0) - edges = negpy_core.canny(blurred, 40, 160) - dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) - dilated = negpy_core.morphology_ex(edges, cv2.MORPH_DILATE, dilate_kernel, iterations=2) - return negpy_core.morphology_ex(dilated, cv2.MORPH_CLOSE, close_kernel, iterations=2) - - def _score_contour(contour: np.ndarray, image_area: float) -> tuple[float, np.ndarray] | None: rect = cv2.minAreaRect(contour) width, height = rect[1] @@ -207,146 +145,6 @@ def _odd_kernel_size(value: float, lower: int, upper: int) -> int: return max(size, 1) -def _outer_ring_values(lum: np.ndarray) -> np.ndarray: - # 2% not 5%: a tightly framed scan leaves only 1-3% of bed around the film, so a - # wider ring reads mostly film, fails the ring_delta gate in - # _oriented_boundary_evidence, and abstains on an otherwise perfect box. - h, w = lum.shape[:2] - ring_width = max(2, round(0.02 * min(h, w))) - ring_width = min(ring_width, max(1, min(h, w) // 2)) - parts = [lum[:ring_width, :].reshape(-1), lum[-ring_width:, :].reshape(-1)] - if h > 2 * ring_width: - parts.extend( - [ - lum[ring_width:-ring_width, :ring_width].reshape(-1), - lum[ring_width:-ring_width, -ring_width:].reshape(-1), - ] - ) - return np.concatenate(parts) - - -def _threshold_polarities(lum: np.ndarray, robust_span: float, ring_median: float) -> tuple[str, ...]: - h, w = lum.shape[:2] - y1, y2 = round(0.2 * h), round(0.8 * h) - x1, x2 = round(0.2 * w), round(0.8 * w) - center = lum[y1 : max(y2, y1 + 1), x1 : max(x2, x1 + 1)] - center_median = float(np.median(center)) - direction_gate = max(0.025, 0.05 * robust_span) - if ring_median - center_median > direction_gate: - return ("dark",) - if center_median - ring_median > direction_gate: - return ("bright",) - return ("dark", "bright") - - -def _adaptive_threshold_levels(lum: np.ndarray, polarity: str) -> tuple[float, ...]: - lo, hi = (float(value) for value in np.percentile(lum, (2.0, 98.0))) - if hi - lo < 0.04: - return () - if polarity == "dark": - # Bias toward the bright surround. On a three-tier negative this gives the - # outer film/rebate edge at least as many trials as the inner image edge. - fractions = (0.35, 0.50, 0.62, 0.72, 0.80, 0.87, 0.93) - else: - # Mirror the sampling pressure toward a dark holder for positive polarity. - fractions = (0.07, 0.13, 0.20, 0.28, 0.38, 0.50, 0.65) - return tuple(lo + fraction * (hi - lo) for fraction in fractions) - - -def _adaptive_threshold_mask(blurred_lum: np.ndarray, threshold: float, polarity: str) -> np.ndarray: - selected = blurred_lum <= threshold if polarity == "dark" else blurred_lum >= threshold - mask = selected.astype(np.uint8) * 255 - min_dim = min(mask.shape[:2]) - close_size = _odd_kernel_size(0.010 * min_dim, 3, 21) - open_size = _odd_kernel_size(0.003 * min_dim, 3, 9) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (close_size, close_size)) - open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (open_size, open_size)) - mask = negpy_core.morphology_ex(mask, cv2.MORPH_CLOSE, close_kernel, iterations=1) - return negpy_core.morphology_ex(mask, cv2.MORPH_OPEN, open_kernel, iterations=1) - - -def _order_quad(quad: np.ndarray) -> np.ndarray: - points = np.asarray(quad, dtype=np.float32).reshape(4, 2) - ordered = np.empty((4, 2), dtype=np.float32) - sums = points.sum(axis=1) - diffs = np.diff(points, axis=1).reshape(-1) - ordered[0] = points[int(np.argmin(sums))] # top-left - ordered[1] = points[int(np.argmin(diffs))] # top-right - ordered[2] = points[int(np.argmax(sums))] # bottom-right - ordered[3] = points[int(np.argmax(diffs))] # bottom-left - return ordered - - -def _polygon_values(lum: np.ndarray, polygon: np.ndarray) -> np.ndarray: - h, w = lum.shape[:2] - points = np.asarray(polygon, dtype=np.float32) - left = max(0, int(math.floor(float(np.min(points[:, 0]))))) - right = min(w, int(math.ceil(float(np.max(points[:, 0])))) + 1) - top = max(0, int(math.floor(float(np.min(points[:, 1]))))) - bottom = min(h, int(math.ceil(float(np.max(points[:, 1])))) + 1) - if right <= left or bottom <= top: - return np.empty(0, dtype=np.float32) - local_points = np.rint(points - np.array([left, top], dtype=np.float32)).astype(np.int32) - mask = np.zeros((bottom - top, right - left), dtype=np.uint8) - cv2.fillConvexPoly(mask, local_points, 1) - values = lum[top:bottom, left:right][mask.astype(bool)] - return values[np.isfinite(values)].astype(np.float32, copy=False) - - -def _oriented_boundary_evidence( - lum: np.ndarray, - quad: np.ndarray, - polarity: str, - ring_median: float, - robust_span: float, -) -> tuple[float, frozenset[str], frozenset[str]]: - ordered = _order_quad(quad) - edge_lengths = np.linalg.norm(np.roll(ordered, -1, axis=0) - ordered, axis=1) - band = float(max(2, min(24, round(0.012 * max(1.0, float(np.min(edge_lengths))))))) - contrast_gate = max(0.02, 0.04 * robust_span) - contrast_scale = max(0.06, 0.20 * robust_span) - ring_tolerance = max(0.06, 0.16 * robust_span) - - supported: set[str] = set() - side_scores: list[float] = [] - for index, name in enumerate(_SIDE_NAMES): - start = ordered[index] - end = ordered[(index + 1) % 4] - vector = end - start - length = float(np.linalg.norm(vector)) - if length < 4.0: - continue - # Ignore corners, where interpolation and sprocket holes mix the two populations. - # Ordered points run clockwise in image coordinates, so (-dy, dx) is the inward normal. - inner_start = start + 0.08 * vector - inner_end = end - 0.08 * vector - inward = np.array([-vector[1], vector[0]], dtype=np.float32) / length - inside_polygon = np.array([inner_start, inner_end, inner_end + inward * band, inner_start + inward * band]) - outside_polygon = np.array([inner_start, inner_end, inner_end - inward * band, inner_start - inward * band]) - inside = _polygon_values(lum, inside_polygon) - outside = _polygon_values(lum, outside_polygon) - expected = max(8.0, 0.20 * 0.84 * length * band) - if inside.size < expected or outside.size < expected: - continue - - inside_median = float(np.median(inside)) - outside_median = float(np.median(outside)) - contrast = outside_median - inside_median if polarity == "dark" else inside_median - outside_median - ring_delta = abs(outside_median - ring_median) - if contrast < contrast_gate or ring_delta > ring_tolerance: - continue - - contrast_score = float(np.clip(contrast / contrast_scale, 0.0, 1.0)) - ring_score = float(np.clip(1.0 - ring_delta / ring_tolerance, 0.0, 1.0)) - supported.add(name) - side_scores.append(0.75 * contrast_score + 0.25 * ring_score) - - supported_sides = frozenset(supported) - supported_corners = frozenset(name for name, sides in _CORNER_SIDES.items() if sides <= supported_sides) - boundary_score = float(sum(side_scores) / 4.0) - return boundary_score, supported_sides, supported_corners - - def _correction_angle_from_quad(quad: np.ndarray, aspect_ratio: float) -> float: if aspect_ratio < 1.12: return 0.0 # Near-square boxes do not have a stable orientation axis. @@ -367,240 +165,6 @@ def _correction_angle_from_quad(quad: np.ndarray, aspect_ratio: float) -> float: return float(np.clip(angle, -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT)) -def _candidate_from_contour( - contour: np.ndarray, - image_shape: tuple[int, int], - lum: np.ndarray, - ring_median: float, - robust_span: float, - source: str, - threshold_index: int | None, - polarity: str | None, -) -> _FilmCandidate | None: - h, w = image_shape - image_area = float(h * w) - rect = cv2.minAreaRect(contour) - rect_width, rect_height = (float(value) for value in rect[1]) - rect_area = rect_width * rect_height - if rect_area <= 0.0: - return None - contour_area = float(cv2.contourArea(contour)) - area_ratio = rect_area / max(image_area, 1.0) - short_side = min(rect_width, rect_height) - long_side = max(rect_width, rect_height) - aspect_ratio = long_side / max(short_side, 1.0) - rectangularity = contour_area / rect_area - if area_ratio < 0.08 or area_ratio > 1.02: - return None - if short_side < max(8.0, 0.04 * min(h, w)) or aspect_ratio > 8.0: - return None - if rectangularity < 0.30: - return None - - quad = cv2.boxPoints(rect).astype(np.float32) - x, y, box_width, box_height = cv2.boundingRect(quad) - roi = (max(0, y), min(h, y + box_height), max(0, x), min(w, x + box_width)) - if roi[1] <= roi[0] or roi[3] <= roi[2]: - return None - - best: _FilmCandidate | None = None - for candidate_polarity in (polarity,) if polarity is not None else ("dark", "bright"): - boundary_score, supported_sides, supported_corners = _oriented_boundary_evidence( - lum, quad, candidate_polarity, ring_median, robust_span - ) - if len(supported_sides) < 2: - continue - rectangularity_score = float(np.clip((rectangularity - 0.30) / 0.65, 0.0, 1.0)) - geometry_score = 0.70 * rectangularity_score + 0.30 * (len(supported_sides) / 4.0) - candidate = _FilmCandidate( - roi=roi, - correction_angle=_correction_angle_from_quad(quad, aspect_ratio), - source=source, - threshold_index=threshold_index, - polarity=candidate_polarity, - boundary_score=boundary_score, - geometry_score=float(np.clip(geometry_score, 0.0, 1.0)), - supported_sides=supported_sides, - supported_corners=supported_corners, - ) - if best is None or (candidate.boundary_score, candidate.geometry_score) > (best.boundary_score, best.geometry_score): - best = candidate - return best - - -def _candidates_from_mask( - mask: np.ndarray, - image_shape: tuple[int, int], - lum: np.ndarray, - ring_median: float, - robust_span: float, - source: str, - threshold_index: int | None = None, - polarity: str | None = None, -) -> list[_FilmCandidate]: - contours, _ = negpy_core.find_external_contours(mask) - candidates: list[_FilmCandidate] = [] - for contour in sorted(contours, key=cv2.contourArea, reverse=True)[:12]: - candidate = _candidate_from_contour( - contour, - image_shape, - lum, - ring_median, - robust_span, - source, - threshold_index, - polarity, - ) - if candidate is not None: - candidates.append(candidate) - return candidates - - -def _roi_iou(first: ROI, second: ROI) -> float: - y1 = max(first[0], second[0]) - y2 = min(first[1], second[1]) - x1 = max(first[2], second[2]) - x2 = min(first[3], second[3]) - intersection = max(0, y2 - y1) * max(0, x2 - x1) - first_area = max(0, first[1] - first[0]) * max(0, first[3] - first[2]) - second_area = max(0, second[1] - second[0]) * max(0, second[3] - second[2]) - return intersection / max(float(first_area + second_area - intersection), 1.0) - - -def _normalized_edge_distance(first: ROI, second: ROI, image_shape: tuple[int, int]) -> float: - h, w = image_shape - scales = (max(h, 1), max(h, 1), max(w, 1), max(w, 1)) - return max(abs(a - b) / scale for a, b, scale in zip(first, second, scales)) - - -def _cluster_film_candidates(candidates: list[_FilmCandidate], image_shape: tuple[int, int]) -> list[list[_FilmCandidate]]: - count = len(candidates) - parents = list(range(count)) - - def find(index: int) -> int: - while parents[index] != index: - parents[index] = parents[parents[index]] - index = parents[index] - return index - - def union(first: int, second: int) -> None: - first_root, second_root = find(first), find(second) - if first_root != second_root: - parents[second_root] = first_root - - for first in range(count): - for second in range(first + 1, count): - if _roi_iou(candidates[first].roi, candidates[second].roi) < 0.78: - continue - if _normalized_edge_distance(candidates[first].roi, candidates[second].roi, image_shape) <= 0.035: - union(first, second) - - grouped: dict[int, list[_FilmCandidate]] = {} - for index, candidate in enumerate(candidates): - grouped.setdefault(find(index), []).append(candidate) - return list(grouped.values()) - - -def _weighted_median(values: list[float], weights: list[float]) -> float: - order = np.argsort(np.asarray(values, dtype=np.float64)) - ordered_values = np.asarray(values, dtype=np.float64)[order] - ordered_weights = np.asarray(weights, dtype=np.float64)[order] - cutoff = 0.5 * float(np.sum(ordered_weights)) - index = int(np.searchsorted(np.cumsum(ordered_weights), cutoff, side="left")) - return float(ordered_values[min(index, ordered_values.size - 1)]) - - -def _dedupe_cluster(cluster: list[_FilmCandidate]) -> list[_FilmCandidate]: - by_trial: dict[tuple[str, int | None, str], _FilmCandidate] = {} - for candidate in cluster: - key = (candidate.source, candidate.threshold_index, candidate.polarity) - current = by_trial.get(key) - if current is None or (candidate.boundary_score, candidate.geometry_score) > (current.boundary_score, current.geometry_score): - by_trial[key] = candidate - return list(by_trial.values()) - - -def _source_tuple(cluster: list[_FilmCandidate]) -> tuple[str, ...]: - present = {candidate.source for candidate in cluster} - ordered = [source for source in _EVIDENCE_SOURCE_ORDER if source in present] - ordered.extend(sorted(present - set(ordered))) - return tuple(ordered) - - -def _supported_by_majority(cluster: list[_FilmCandidate], attribute: str) -> frozenset[str]: - values = {value for candidate in cluster for value in getattr(candidate, attribute)} - needed = max(1, math.ceil(0.5 * len(cluster))) - return frozenset(value for value in values if sum(value in getattr(candidate, attribute) for candidate in cluster) >= needed) - - -def _select_consensus_cluster(candidates: list[_FilmCandidate], image_shape: tuple[int, int]) -> _FilmConsensus | None: - h, w = image_shape - accepted: list[_FilmConsensus] = [] - for raw_cluster in _cluster_film_candidates(candidates, image_shape): - cluster = _dedupe_cluster(raw_cluster) - adaptive_trials = { - (candidate.polarity, candidate.threshold_index) - for candidate in cluster - if candidate.source.startswith("adaptive-") and candidate.threshold_index is not None - } - structural_sources = {candidate.source for candidate in cluster if not candidate.source.startswith("adaptive-")} - if len(adaptive_trials) < 3 and not (len(adaptive_trials) >= 2 and len(structural_sources) >= 2): - continue - - weights = [max(0.05, candidate.boundary_score * candidate.geometry_score) for candidate in cluster] - roi_values = list(zip(*(candidate.roi for candidate in cluster))) - roi = tuple(int(round(_weighted_median(list(values), weights))) for values in roi_values) - if roi[1] <= roi[0] or roi[3] <= roi[2]: - continue - - scales = (max(h, 1), max(h, 1), max(w, 1), max(w, 1)) - edge_mads = [] - for edge_index, values in enumerate(roi_values): - center = _weighted_median(list(values), weights) - edge_mads.append(_weighted_median([abs(value - center) / scales[edge_index] for value in values], weights)) - dispersion = float(max(edge_mads, default=1.0)) - stability = float(np.clip(1.0 - dispersion / 0.03, 0.0, 1.0)) - - angles = [candidate.correction_angle for candidate in cluster] - correction_angle = _weighted_median(angles, weights) - angle_mad = _weighted_median([abs(angle - correction_angle) for angle in angles], weights) - angle_stability = float(np.clip(1.0 - angle_mad / 3.0, 0.0, 1.0)) - median_geometry = _weighted_median([candidate.geometry_score for candidate in cluster], weights) - geometry_score = float(np.clip(0.55 * median_geometry + 0.25 * stability + 0.20 * angle_stability, 0.0, 1.0)) - boundary_score = _weighted_median([candidate.boundary_score for candidate in cluster], weights) - support = min(1.0, len(adaptive_trials) / _ADAPTIVE_THRESHOLD_COUNT) - corroboration = min(1.0, len(structural_sources) / 3.0) - confidence = float( - np.clip(0.40 * support + 0.25 * stability + 0.20 * boundary_score + 0.10 * geometry_score + 0.05 * corroboration, 0.0, 1.0) - ) - if confidence < 0.55: - continue - - polarity_weights = { - polarity: sum(weight for candidate, weight in zip(cluster, weights) if candidate.polarity == polarity) - for polarity in ("dark", "bright") - } - accepted.append( - _FilmConsensus( - roi=roi, - correction_angle=correction_angle, - confidence=confidence, - supported_sides=_supported_by_majority(cluster, "supported_sides"), - supported_corners=_supported_by_majority(cluster, "supported_corners"), - evidence_sources=_source_tuple(cluster), - geometry_score=geometry_score, - dispersion=dispersion, - polarity=max(polarity_weights, key=polarity_weights.get), - ) - ) - - if not accepted: - return None - # Confidence is primary. A small area preference breaks equivalent inner/outer clusters - # toward the film extent instead of picture content. - return max(accepted, key=lambda result: (result.confidence, (result.roi[1] - result.roi[0]) * (result.roi[3] - result.roi[2]))) - - def _vertical_edge_profile(lum: np.ndarray) -> tuple[np.ndarray, float]: if lum.size == 0: return np.empty(0, dtype=np.float32), 0.0 @@ -636,73 +200,24 @@ def _empty_autocrop_detection( def detect_film_bounds_with_confidence(img: ImageBuffer) -> AutocropDetection: - """Detect the outer film box by agreement across adaptive thresholds. - - Adaptive intensity trials provide the required consensus. The previous - inverse-threshold, black-hat, and edge masks are retained as independent - corroborating evidence, but no structural mask can win by itself. - """ + """Detect the outer film box by agreement across adaptive thresholds, corroborated by + structural masks that cannot win alone; negpy_core holds the rules.""" if img.ndim < 2 or img.shape[0] < 2 or img.shape[1] < 2: return _empty_autocrop_detection(np.empty(0, dtype=np.float32)) - - lum = _detection_luma(img) - vertical_edge_profile, vertical_edge_contrast = _vertical_edge_profile(lum) - lo, hi = (float(value) for value in np.percentile(lum, (2.0, 98.0))) - robust_span = hi - lo - if robust_span < 0.04: - return _empty_autocrop_detection(vertical_edge_profile, vertical_edge_contrast) - ring_values = _outer_ring_values(lum) - ring_median = float(np.median(ring_values)) - polarities = _threshold_polarities(lum, robust_span, ring_median) - - min_dim = min(lum.shape[:2]) - blur_size = _odd_kernel_size(0.004 * min_dim, 3, 9) - blurred_lum = negpy_core.gaussian_blur(lum.astype(np.float32), (blur_size, blur_size), 0) - image_shape = lum.shape[:2] - candidates: list[_FilmCandidate] = [] - for polarity in polarities: - source = f"adaptive-{polarity}" - for threshold_index, threshold in enumerate(_adaptive_threshold_levels(lum, polarity)): - mask = _adaptive_threshold_mask(blurred_lum, threshold, polarity) - candidates.extend( - _candidates_from_mask( - mask, - image_shape, - lum, - ring_median, - robust_span, - source, - threshold_index=threshold_index, - polarity=polarity, - ) - ) - - color = _ensure_color(img) - preview = _normalize_to_uint8(color) - gray = negpy_core.bgr_to_gray(preview) - structural_masks = ( - ("inverse-threshold", _mask_from_inverse_threshold(gray)), - ("blackhat", _mask_from_blackhat(gray)), - ("edges", _mask_from_edges(gray)), - ) - for source, mask in structural_masks: - candidates.extend(_candidates_from_mask(mask, image_shape, lum, ring_median, robust_span, source)) - - consensus = _select_consensus_cluster(candidates, image_shape) - if consensus is None or not _film_box_covers_enough(lum, consensus.roi) or not _film_surround_is_plausible(lum, consensus.roi): - return _empty_autocrop_detection(vertical_edge_profile, vertical_edge_contrast) - - roi = _snap_film_bounds_to_bed_gradient(consensus.roi, lum) + consensus, profile, contrast = negpy_core.detect_film_bounds(np.ascontiguousarray(img, dtype=np.float32)) + if consensus is None: + return _empty_autocrop_detection(profile, contrast) + roi, angle, confidence, sides, corners, sources, geometry = consensus return AutocropDetection( roi=roi, - correction_angle=consensus.correction_angle, - confidence=consensus.confidence, - supported_sides=consensus.supported_sides, - supported_corners=consensus.supported_corners, - evidence_sources=consensus.evidence_sources, - geometry_score=consensus.geometry_score, - vertical_edge_contrast=vertical_edge_contrast, - vertical_edge_profile=vertical_edge_profile, + correction_angle=angle, + confidence=confidence, + supported_sides=frozenset(sides), + supported_corners=frozenset(corners), + evidence_sources=tuple(sources), + geometry_score=geometry, + vertical_edge_contrast=contrast, + vertical_edge_profile=profile, ) @@ -711,68 +226,6 @@ def _detect_film_bounds(img: ImageBuffer) -> ROI | None: return detect_film_bounds_with_confidence(img).roi -# How much of the frame a detected film box must cover to be read as film. Film fills most -# of a scan under every holder we have samples for, and on a positive whose bed clips to -# white a dark subject can otherwise satisfy every other check on its own geometry. The -# smallest legitimate box in the corpus covers 62.5%, so this sits clear of real geometry. -_MIN_FILM_BOX_COVERAGE = 0.25 - - -def _film_box_covers_enough(lum: np.ndarray, roi: ROI) -> bool: - h, w = lum.shape[:2] - y1, y2, x1, x2 = roi - return h > 0 and w > 0 and (y2 - y1) * (x2 - x1) >= _MIN_FILM_BOX_COVERAGE * h * w - - -def _film_surround_is_plausible(lum: np.ndarray, roi: ROI) -> bool: - """ - A real film box sits on a light bed (uniform, near-clipping surround) or in a - dark holder. A mid-tone textured surround means the contour latched onto image - content in a borderless scan — reject so detection falls back to full frame. - """ - h, w = lum.shape[:2] - y1, y2, x1, x2 = roi - out_mask = np.ones((h, w), dtype=bool) - out_mask[y1:y2, x1:x2] = False - out = lum[out_mask] - if out.size < 0.005 * lum.size: - return True # box covers nearly the whole scan; no surround evidence either way - out_med = float(np.median(out)) - out_high = float(np.percentile(out, 90)) - box_med = float(np.median(lum[y1:y2, x1:x2])) - # Near-clipping, not merely bright. On a thin negative the picture itself clears 0.85, - # so a median alone accepts a blown region as bed and cuts the frame down to whatever is - # darker beside it. The bed is the light source and sits at the top of the histogram, - # where _detection_luma's anchor puts it. A highlight does not. - bed_like = out_med >= 0.85 and out_high >= 0.98 - holder_like = out_med <= 0.30 and out_med <= box_med - 0.15 - return bed_like or holder_like - - -def _snap_film_bounds_to_bed_gradient(roi: ROI, lum: np.ndarray) -> ROI: - """ - Refines contour film bounds to the strongest bed->film luminance gradient within - a +/-2% window per edge (contour morphology kernels inflate/deflate bounds by - ~10-20px). Each edge moves only on a dominant gradient; otherwise it is kept. - """ - h, w = lum.shape[:2] - y1, y2, x1, x2 = roi - col_profile = lum[y1:y2, :].mean(axis=0) - row_profile = lum[:, x1:x2].mean(axis=1) - # 16px floor: contour morphology kernels are fixed-pixel (21-31px), so their - # inflation doesn't shrink with image size the way the 2% window does. - snap = dict(window_out=0.02, window_in=0.02, min_dominance=3.0, min_window_px=16) - nx1 = _snap_edge_to_gradient(col_profile, x1, is_start=True, **snap) - nx2 = _snap_edge_to_gradient(col_profile, x2, is_start=False, **snap) - ny1 = _snap_edge_to_gradient(row_profile, y1, is_start=True, **snap) - ny2 = _snap_edge_to_gradient(row_profile, y2, is_start=False, **snap) - if ny2 - ny1 <= 0 or nx2 - nx1 <= 0: - return roi - # Keep the bed-to-film transition rows inside the box: downstream refinement needs them, - # and the (2+offset) crop margin re-tightens afterwards. - return max(0, ny1 - 2), min(h, ny2 + 2), max(0, nx1 - 2), min(w, nx2 + 2) - - def _detection_luma(img: np.ndarray) -> np.ndarray: """ Luminance normalized so the light bed sits near 1.0 (anchored at P99.5). @@ -847,26 +300,6 @@ def _find_rebate_level(lum: np.ndarray, film_roi: ROI, bed: Optional[float] = No return negpy_core.find_rebate_level(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi), bed) -def _snap_edge_to_gradient( - profile: np.ndarray, - idx: int, - is_start: bool, - window_out: float = 0.06, - window_in: float = 0.02, - min_dominance: float = 2.0, - min_window_px: int = 3, -) -> int: - """ - Snaps a coarse edge index to the strongest |gradient| of the smoothed profile - within an asymmetric window biased outward (toward the film border) — recovers - frame area when bright content touching the edge suppresses occupancy. - Keeps idx unless the peak clearly dominates the window (>= 2x median). - """ - return negpy_core.snap_edge_to_gradient( - np.ascontiguousarray(profile, dtype=np.float32), int(idx), bool(is_start), window_out, window_in, min_dominance, int(min_window_px) - ) - - def _refine_film_roi_by_tiers(lum: np.ndarray, film_roi: ROI) -> Optional[Tuple[ROI, np.ndarray, np.ndarray]]: """ Tier-based image-area refinement: classify pixels inside the film box against the diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 94fe26b59..094e7ae63 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -44,7 +44,7 @@ _MAX_LINE_RESIDUAL = 0.15 # Opposite edges this close together cannot separate keystone from noise. _MIN_PAIR_SEPARATION = 0.3 -# A box bounds its edge to within the film detector's snap window (`_snap_film_bounds_to_bed_gradient`). +# A box bounds its edge to within the film detector's snap window (`snap_film_bounds` in negpy_core). _BOX_TOLERANCE = 0.02 _BOX_TOLERANCE_PX = 16 # A tight scan's border lines sit within this fraction of the cross extent from the canvas edge. diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 6d0cef594..f9405f3b7 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -469,13 +469,13 @@ depends = ["_map_rect_between_rotations", "_rect_from_edge_profile", "build_roll ["features/geometry/logic.py::_adaptive_threshold_levels"] phase = 2 -status = "todo" +status = "ported" calls = ["np.percentile"] depends = [] ["features/geometry/logic.py::_adaptive_threshold_mask"] phase = 2 -status = "todo" +status = "ported" calls = ["cv2.getStructuringElement", "negpy_core.morphology_ex"] depends = [] @@ -488,13 +488,13 @@ note = "negpy_core::analysis::border_edge_scatter (Python deleted)" ["features/geometry/logic.py::_candidate_from_contour"] phase = 2 -status = "todo" +status = "ported" calls = ["cv2.boundingRect", "cv2.boxPoints", "cv2.contourArea", "cv2.minAreaRect"] depends = ["_oriented_boundary_evidence"] ["features/geometry/logic.py::_candidates_from_mask"] phase = 2 -status = "todo" +status = "ported" calls = ["negpy_core.find_external_contours"] depends = ["_candidate_from_contour"] @@ -540,7 +540,7 @@ note = "negpy_core::analysis::estimate_tier_levels (Python deleted)" ["features/geometry/logic.py::_film_surround_is_plausible"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median", "np.percentile"] depends = [] @@ -565,19 +565,19 @@ depends = [] ["features/geometry/logic.py::_mask_from_blackhat"] phase = 2 -status = "todo" +status = "ported" calls = ["cv2.getStructuringElement", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex", "negpy_core.threshold_otsu"] depends = [] ["features/geometry/logic.py::_mask_from_edges"] phase = 2 -status = "todo" +status = "ported" calls = ["cv2.getStructuringElement", "negpy_core.canny", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex"] depends = [] ["features/geometry/logic.py::_mask_from_inverse_threshold"] phase = 2 -status = "todo" +status = "ported" calls = ["cv2.getStructuringElement", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex", "negpy_core.threshold_otsu"] depends = [] @@ -596,7 +596,7 @@ note = "negpy_core::analysis::normalize_to_uint8" ["features/geometry/logic.py::_oriented_boundary_evidence"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median"] depends = ["_polygon_values"] @@ -608,7 +608,7 @@ depends = [] ["features/geometry/logic.py::_polygon_values"] phase = 2 -status = "todo" +status = "ported" calls = ["cv2.fillConvexPoly"] depends = [] @@ -667,7 +667,7 @@ depends = [] ["features/geometry/logic.py::_select_consensus_cluster"] phase = 2 -status = "todo" +status = "ported" calls = [] depends = ["_weighted_median"] @@ -686,13 +686,13 @@ note = "negpy_core::analysis::snap_edge_to_gradient" ["features/geometry/logic.py::_snap_film_bounds_to_bed_gradient"] phase = 2 -status = "todo" +status = "ported" calls = [] depends = ["_snap_edge_to_gradient"] ["features/geometry/logic.py::_threshold_polarities"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median"] depends = [] @@ -718,7 +718,7 @@ note = "negpy_core::analysis::walk_border_depth" ["features/geometry/logic.py::_weighted_median"] phase = 2 -status = "todo" +status = "ported" calls = ["np.argsort", "np.cumsum", "np.sum"] depends = [] @@ -754,9 +754,9 @@ depends = ["_find_autocrop_roi_from_contours", "_get_threshold_autocrop_coords", ["features/geometry/logic.py::detect_film_bounds_with_confidence"] phase = 2 -status = "todo" -calls = ["negpy_core.bgr_to_gray", "negpy_core.gaussian_blur", "np.median", "np.percentile"] -depends = ["_adaptive_threshold_levels", "_adaptive_threshold_mask", "_candidates_from_mask", "_detection_luma", "_film_surround_is_plausible", "_mask_from_blackhat", "_mask_from_edges", "_mask_from_inverse_threshold", "_normalize_to_uint8", "_select_consensus_cluster", "_snap_film_bounds_to_bed_gradient", "_threshold_polarities", "_vertical_edge_profile"] +status = "ported" +calls = ["negpy_core.detect_film_bounds"] +depends = [] ["features/geometry/logic.py::film_and_picture"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index cdf53805f..8cdd94ee2 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -43,7 +43,7 @@ pub fn normalize_to_uint8(img: &[f32], dst: &mut [u8]) { p[1] as f64 }; let (low32, scale32) = (low as f32, (255.0 / (high - low)) as f32); - dst.par_iter_mut().zip(img.par_iter()).for_each(|(d, &v)| { + crate::par_zip(dst, img, move |d, &v| { let s = ((v - low32) * scale32).clamp(0.0, 255.0); // NumPy's float32 to uint8 cast truncates, NaN to 0 on x86; Rust's `as` does the same. *d = s as u8; @@ -60,7 +60,7 @@ pub fn detection_luma(lum: &[f32], dst: &mut [f32]) -> Option<()> { } else { p.max(1e-6) as f32 }; - dst.par_iter_mut().zip(lum.par_iter()).for_each(|(d, &v)| { + crate::par_zip(dst, lum, move |d, &v| { *d = (v / anchor).clamp(0.0, 2.0); }); Some(()) diff --git a/negpy_core/src/contours.rs b/negpy_core/src/contours.rs index 173e175f8..fd022dfa6 100644 --- a/negpy_core/src/contours.rs +++ b/negpy_core/src/contours.rs @@ -22,9 +22,9 @@ pub fn find_external_contours(src: &[u8], h: usize, w: usize) -> Vec 0) as i8; + for (line, row) in img.chunks_exact_mut(pw).skip(1).zip(src.chunks_exact(w)) { + for (d, &v) in line[1..=w].iter_mut().zip(row) { + *d = (v > 0) as i8; } } let delta = |s: usize| DELTAS[s % 8].0 + DELTAS[s % 8].1 * pw as isize; @@ -36,14 +36,12 @@ pub fn find_external_contours(src: &[u8], h: usize, w: usize) -> Vec= width { - break; + let line = &img[y * pw..y * pw + width]; + match first_other(&line[x..], prev) { + Some(skip) => x += skip, + None => break, } + let p = img[y * pw + x]; // contourScan for RETR_EXTERNAL: start only at an outer border whose left // neighbour is not inside an already traced contour. let mut start = false; @@ -77,6 +75,20 @@ pub fn find_external_contours(src: &[u8], h: usize, w: usize) -> Vec Option { + let run = u64::from_ne_bytes([v as u8; 8]); + let (words, _) = line.as_chunks::<8>(); + let mut i = 0; + for word in words { + if u64::from_ne_bytes(word.map(|b| b as u8)) != run { + break; + } + i += 8; + } + line[i..].iter().position(|&b| b != v).map(|p| i + p) +} + /// `icvFetchContourEx` for an outer border from `start` (an index into the padded image), /// with CHAIN_APPROX_SIMPLE: a point wherever the chain direction changes. fn trace( diff --git a/negpy_core/src/detect.rs b/negpy_core/src/detect.rs new file mode 100644 index 000000000..bd0dbf479 --- /dev/null +++ b/negpy_core/src/detect.rs @@ -0,0 +1,1121 @@ +//! `detect_film_bounds_with_confidence`: the outer film box by agreement across adaptive +//! thresholds, corroborated by structural masks (geometry/logic.py). + +use rayon::prelude::*; + +use crate::analysis::{Snap, detection_luma, luminance, snap_edge_to_gradient}; +use crate::contours::{contour_area, find_external_contours}; +use crate::filter::Border; +use crate::gray; +use crate::morph::{self, Op}; +use crate::shapes::{bounding_rect_f32, box_points, fill_convex_poly, min_area_rect}; +use crate::stats::{np_percentiles, np_percentiles_f64, pairwise_sum_f32}; + +pub type Roi = (i64, i64, i64, i64); + +const FINE_ROTATION_LIMIT: f64 = 45.0; +const ADAPTIVE_THRESHOLD_COUNT: f64 = 7.0; +// Film fills most of a scan under every known holder; on a positive whose bed clips to white, a +// dark subject can pass every other check on its own geometry. +const MIN_FILM_BOX_COVERAGE: f64 = 0.25; +pub const SIDE_NAMES: [&str; 4] = ["top", "right", "bottom", "left"]; +pub const CORNER_SIDES: [(&str, [usize; 2]); 4] = [ + ("top_left", [0, 3]), + ("top_right", [0, 1]), + ("bottom_right", [2, 1]), + ("bottom_left", [2, 3]), +]; +pub const EVIDENCE_SOURCE_ORDER: [&str; 5] = [ + "adaptive-dark", + "adaptive-bright", + "inverse-threshold", + "blackhat", + "edges", +]; + +fn py_round(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +/// `np.median` of float32 values (NaN when any is NaN). +fn median_f32(values: &[f32]) -> f32 { + if values.is_empty() || values.iter().any(|v| v.is_nan()) { + return f32::NAN; + } + let mut v = values.to_vec(); + let n = v.len(); + let (_, &mut hi, _) = v.select_nth_unstable_by(n / 2, f32::total_cmp); + if n % 2 == 1 { + return hi; + } + let lo = v[..n / 2].iter().copied().fold(f32::NEG_INFINITY, f32::max); + (lo + hi) / 2.0 +} + +fn clip(v: f64, lo: f64, hi: f64) -> f64 { + v.clamp(lo, hi) +} + +/// `_odd_kernel_size(value, lower, upper)`. +fn odd_kernel_size(value: f64, lower: i64, upper: i64) -> i64 { + let mut size = py_round(value).clamp(lower, upper); + if size % 2 == 0 { + size = if size < upper { size + 1 } else { size - 1 }; + } + size.max(1) +} + +/// `_vertical_edge_profile(lum)`: per column, the normalized mean |Sobel x|, and its contrast. +fn vertical_edge_profile(lum: &[f32], h: usize, w: usize) -> (Vec, f64) { + if lum.is_empty() { + return (Vec::new(), 0.0); + } + let mut grad = vec![0f32; h * w]; + if crate::filter::sep_filter2d( + lum, + &mut grad, + (h, w, 1), + (&[-1.0, 0.0, 1.0], &[1.0, 2.0, 1.0]), + Border::Reflect101, + ) + .is_none() + { + return (vec![0.0; w], 0.0); + } + // np.mean(axis=0): summed down each column. + let mut profile = vec![0f32; w]; + for r in grad.chunks_exact(w) { + for (p, &g) in profile.iter_mut().zip(r) { + *p += g.abs(); + } + } + profile.iter_mut().for_each(|p| *p /= h as f32); + let window = odd_kernel_size(0.006 * w as f64, 3, 15) as usize; + let mut blurred = vec![0f32; w]; + if crate::filter::gaussian_blur( + &profile, + &mut blurred, + (1, w, 1), + (window, 1), + (0.0, 0.0), + Border::Reflect101, + ) + .is_none() + { + blurred = profile; + } + let p = np_percentiles(&blurred, &[50.0, 99.5]).unwrap_or(vec![0.0, 0.0]); + let (baseline, normalizer) = (p[0] as f64, p[1] as f64); + let contrast = (normalizer - baseline).max(0.0); + if normalizer > 1e-8 { + let n32 = normalizer as f32; + blurred + .iter_mut() + .for_each(|v| *v = (*v / n32).clamp(0.0, 1.0)); + } else { + blurred.fill(0.0); + } + (blurred, contrast) +} + +/// `_outer_ring_values(lum)`: the frame's outer ring, top, bottom, left, right. +/// 2% wide: a tightly framed scan leaves 1-3% of bed, so a wider ring reads film and fails the +/// ring gate in `oriented_boundary_evidence` on a good box. +fn outer_ring_values(lum: &[f32], h: usize, w: usize) -> Vec { + let m = h.min(w); + let ring = (py_round(0.02 * m as f64).max(2) as usize).min((m / 2).max(1)); + let mut out = Vec::new(); + out.extend_from_slice(&lum[..ring.min(h) * w]); + out.extend_from_slice(&lum[h.saturating_sub(ring) * w..]); + if h > 2 * ring { + for y in ring..h - ring { + out.extend_from_slice(&lum[y * w..y * w + ring.min(w)]); + } + for y in ring..h - ring { + out.extend_from_slice(&lum[y * w + w.saturating_sub(ring)..(y + 1) * w]); + } + } + out +} + +fn crop(img: &[f32], w: usize, (y1, y2, x1, x2): (usize, usize, usize, usize)) -> Vec { + (y1..y2) + .flat_map(|y| img[y * w + x1..y * w + x2].iter().copied()) + .collect() +} + +/// `_threshold_polarities`: which polarities the trials sample. +fn threshold_polarities( + lum: &[f32], + h: usize, + w: usize, + span: f64, + ring_median: f64, +) -> Vec<&'static str> { + let (y1, y2) = ( + py_round(0.2 * h as f64) as usize, + py_round(0.8 * h as f64) as usize, + ); + let (x1, x2) = ( + py_round(0.2 * w as f64) as usize, + py_round(0.8 * w as f64) as usize, + ); + let (y2, x2) = (y2.max(y1 + 1).min(h), x2.max(x1 + 1).min(w)); + let center = crop(lum, w, (y1.min(h), y2, x1.min(w), x2)); + let center_median = median_f32(¢er) as f64; + let gate = 0.025f64.max(0.05 * span); + if ring_median - center_median > gate { + vec!["dark"] + } else if center_median - ring_median > gate { + vec!["bright"] + } else { + vec!["dark", "bright"] + } +} + +fn rect_close_open( + mask: &[u8], + h: usize, + w: usize, + close: (usize, usize, usize), + open: (usize, usize, usize), +) -> Vec { + let mut a = vec![0u8; h * w]; + let mut b = vec![0u8; h * w]; + let _ = morph::morphology( + mask, + &mut a, + (h, w, 1), + Op::Close, + (close.0, close.1), + close.2, + Border::Constant, + ); + let _ = morph::morphology( + &a, + &mut b, + (h, w, 1), + Op::Open, + (open.0, open.1), + open.2, + Border::Constant, + ); + b +} + +/// `_adaptive_threshold_mask(blurred_lum, threshold, polarity)`. +fn adaptive_threshold_mask( + blurred: &[f32], + h: usize, + w: usize, + threshold: f64, + dark: bool, +) -> Vec { + let t = threshold as f32; + let mask: Vec = blurred + .iter() + .map(|&v| { + if (dark && v <= t) || (!dark && v >= t) { + 255 + } else { + 0 + } + }) + .collect(); + let m = h.min(w) as f64; + let c = odd_kernel_size(0.010 * m, 3, 21) as usize; + let o = odd_kernel_size(0.003 * m, 3, 9) as usize; + rect_close_open(&mask, h, w, (c, c, 1), (o, o, 1)) +} + +fn mask_from_inverse_threshold(gray_img: &[u8], h: usize, w: usize) -> Vec { + let mut blurred = vec![0u8; h * w]; + let _ = gray::gaussian_blur_u8( + gray_img, + &mut blurred, + (h, w, 1), + (7, 7), + (0.0, 0.0), + Border::Reflect101, + ); + let mut thresh = vec![0u8; h * w]; + let t = gray::otsu_threshold(&blurred); + gray::threshold_binary(&blurred, &mut thresh, t, 255, true); + let mut closed = vec![0u8; h * w]; + let _ = morph::morphology( + &thresh, + &mut closed, + (h, w, 1), + Op::Close, + (17, 17), + 2, + Border::Constant, + ); + let mut out = vec![0u8; h * w]; + let _ = morph::morphology( + &closed, + &mut out, + (h, w, 1), + Op::Open, + (5, 5), + 1, + Border::Constant, + ); + out +} + +fn mask_from_blackhat(gray_img: &[u8], h: usize, w: usize) -> Vec { + let mut bh = vec![0u8; h * w]; + let _ = morph::morphology( + gray_img, + &mut bh, + (h, w, 1), + Op::BlackHat, + (31, 31), + 1, + Border::Constant, + ); + let mut blurred = vec![0u8; h * w]; + let _ = gray::gaussian_blur_u8( + &bh, + &mut blurred, + (h, w, 1), + (5, 5), + (0.0, 0.0), + Border::Reflect101, + ); + let mut thresh = vec![0u8; h * w]; + let t = gray::otsu_threshold(&blurred); + gray::threshold_binary(&blurred, &mut thresh, t, 255, false); + let mut out = vec![0u8; h * w]; + let _ = morph::morphology( + &thresh, + &mut out, + (h, w, 1), + Op::Close, + (21, 21), + 2, + Border::Constant, + ); + out +} + +fn mask_from_edges(gray_img: &[u8], h: usize, w: usize) -> Vec { + let mut blurred = vec![0u8; h * w]; + let _ = gray::gaussian_blur_u8( + gray_img, + &mut blurred, + (h, w, 1), + (5, 5), + (0.0, 0.0), + Border::Reflect101, + ); + let mut edges = vec![0u8; h * w]; + let _ = gray::canny(&blurred, &mut edges, h, w, 40.0, 160.0); + let mut dilated = vec![0u8; h * w]; + let _ = morph::morphology( + &edges, + &mut dilated, + (h, w, 1), + Op::Dilate, + (7, 7), + 2, + Border::Constant, + ); + let mut out = vec![0u8; h * w]; + let _ = morph::morphology( + &dilated, + &mut out, + (h, w, 1), + Op::Close, + (15, 15), + 2, + Border::Constant, + ); + out +} + +#[derive(Clone, Debug)] +pub struct Candidate { + pub roi: Roi, + pub correction_angle: f64, + pub source: &'static str, + pub threshold_index: Option, + pub polarity: &'static str, + pub boundary_score: f64, + pub geometry_score: f64, + pub sides: [bool; 4], + pub corners: [bool; 4], +} + +/// np.linalg.norm of a float32 2-vector: sqrt((-0 + x*x) + y*y) in float32. +fn norm2(x: f32, y: f32) -> f32 { + ((-0.0f32 + x * x) + y * y).sqrt() +} + +/// `_order_quad`: top-left, top-right, bottom-right, bottom-left. +fn order_quad(q: &[(f32, f32); 4]) -> [(f32, f32); 4] { + let sums: Vec = q.iter().map(|p| (-0.0f32 + p.0) + p.1).collect(); + let diffs: Vec = q.iter().map(|p| p.1 - p.0).collect(); + let argmin = |v: &[f32]| { + v.iter() + .enumerate() + .fold(0, |b, (i, &x)| if x < v[b] { i } else { b }) + }; + let argmax = |v: &[f32]| { + v.iter() + .enumerate() + .fold(0, |b, (i, &x)| if x > v[b] { i } else { b }) + }; + [ + q[argmin(&sums)], + q[argmin(&diffs)], + q[argmax(&sums)], + q[argmax(&diffs)], + ] +} + +/// `_polygon_values(lum, polygon)`: the finite luma values under a filled convex polygon. +fn polygon_values(lum: &[f32], h: usize, w: usize, poly: &[(f32, f32); 4]) -> Vec { + let minx = poly.iter().map(|p| p.0).fold(f32::INFINITY, f32::min); + let maxx = poly.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max); + let miny = poly.iter().map(|p| p.1).fold(f32::INFINITY, f32::min); + let maxy = poly.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max); + let left = (minx as f64).floor().max(0.0) as i64; + let right = ((maxx as f64).ceil() as i64 + 1).min(w as i64); + let top = (miny as f64).floor().max(0.0) as i64; + let bottom = ((maxy as f64).ceil() as i64 + 1).min(h as i64); + if right <= left || bottom <= top { + return Vec::new(); + } + let (mw, mh) = ((right - left) as usize, (bottom - top) as usize); + let local: Vec<(i64, i64)> = poly + .iter() + .map(|p| { + ( + (p.0 - left as f32).round_ties_even() as i64, + (p.1 - top as f32).round_ties_even() as i64, + ) + }) + .collect(); + let mut mask = vec![0u8; mw * mh]; + fill_convex_poly(&mut mask, mw, mh, &local, 1); + let mut out = Vec::new(); + for y in 0..mh { + for x in 0..mw { + if mask[y * mw + x] != 0 { + let v = lum[(top as usize + y) * w + left as usize + x]; + if v.is_finite() { + out.push(v); + } + } + } + } + out +} + +/// `_oriented_boundary_evidence`: (boundary score, supported sides, supported corners). +fn oriented_boundary_evidence( + lum: &[f32], + h: usize, + w: usize, + quad: &[(f32, f32); 4], + dark: bool, + ring_median: f64, + span: f64, +) -> (f64, [bool; 4], [bool; 4]) { + let ordered = order_quad(quad); + let min_edge = (0..4) + .map(|i| { + norm2( + ordered[(i + 1) % 4].0 - ordered[i].0, + ordered[(i + 1) % 4].1 - ordered[i].1, + ) + }) + .fold(f32::INFINITY, f32::min); + let band = py_round(0.012 * 1.0f64.max(min_edge as f64)).clamp(2, 24) as f64; + let contrast_gate = 0.02f64.max(0.04 * span); + let contrast_scale = 0.06f64.max(0.20 * span); + let ring_tolerance = 0.06f64.max(0.16 * span); + let mut supported = [false; 4]; + let mut scores = Vec::new(); + for i in 0..4 { + let (start, end) = (ordered[i], ordered[(i + 1) % 4]); + let vector = (end.0 - start.0, end.1 - start.1); + let length = norm2(vector.0, vector.1) as f64; + if length < 4.0 { + continue; + } + // Skip the corners, where interpolation and sprocket holes mix the two populations. + // The ordered points run clockwise in image coordinates, so (-dy, dx) points inward. + let k = 0.08f32; + let inner_start = (start.0 + k * vector.0, start.1 + k * vector.1); + let inner_end = (end.0 - k * vector.0, end.1 - k * vector.1); + let l32 = length as f32; + let inward = (-vector.1 / l32, vector.0 / l32); + let b32 = band as f32; + let off = (inward.0 * b32, inward.1 * b32); + let inside_poly = [ + inner_start, + inner_end, + (inner_end.0 + off.0, inner_end.1 + off.1), + (inner_start.0 + off.0, inner_start.1 + off.1), + ]; + let outside_poly = [ + inner_start, + inner_end, + (inner_end.0 - off.0, inner_end.1 - off.1), + (inner_start.0 - off.0, inner_start.1 - off.1), + ]; + let inside = polygon_values(lum, h, w, &inside_poly); + let outside = polygon_values(lum, h, w, &outside_poly); + let expected = 8.0f64.max(0.20 * 0.84 * length * band); + if (inside.len() as f64) < expected || (outside.len() as f64) < expected { + continue; + } + let (im, om) = (median_f32(&inside) as f64, median_f32(&outside) as f64); + let contrast = if dark { om - im } else { im - om }; + let ring_delta = (om - ring_median).abs(); + if contrast < contrast_gate + || ring_delta > ring_tolerance + || contrast.is_nan() + || ring_delta.is_nan() + { + continue; + } + let cs = clip(contrast / contrast_scale, 0.0, 1.0); + let rs = clip(1.0 - ring_delta / ring_tolerance, 0.0, 1.0); + supported[i] = true; + scores.push(0.75 * cs + 0.25 * rs); + } + let mut corners = [false; 4]; + for (c, (_, sides)) in CORNER_SIDES.iter().enumerate() { + corners[c] = supported[sides[0]] && supported[sides[1]]; + } + let boundary = scores.iter().fold(0.0f64, |s, &v| s + v) / 4.0; + (boundary, supported, corners) +} + +/// `_correction_angle_from_quad(quad, aspect_ratio)`: the residual deskew in degrees. +fn correction_angle_from_quad(quad: &[(f32, f32); 4], aspect: f64) -> f64 { + // A near-square box has no stable orientation axis. + if aspect < 1.12 { + return 0.0; + } + let edges: Vec<(f32, f32)> = (0..4) + .map(|i| { + ( + quad[(i + 1) % 4].0 - quad[i].0, + quad[(i + 1) % 4].1 - quad[i].1, + ) + }) + .collect(); + let norms: Vec = edges.iter().map(|e| norm2(e.0, e.1)).collect(); + let k = norms + .iter() + .enumerate() + .fold(0, |b, (i, &n)| if n > norms[b] { i } else { b }); + // Python's math.degrees multiplies by 180 / pi. + let mut angle = + libm::atan2(edges[k].1 as f64, edges[k].0 as f64) * (180.0 / std::f64::consts::PI); + while angle >= 90.0 { + angle -= 180.0; + } + while angle < -90.0 { + angle += 180.0; + } + if angle > 45.0 { + angle -= 90.0; + } else if angle < -45.0 { + angle += 90.0; + } + // OpenCV's positive fine rotation turns a horizontal edge to a negative image angle, so the + // measured residual is already the additive fix. + clip(angle, -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT) +} + +struct Scene<'a> { + lum: &'a [f32], + h: usize, + w: usize, + ring_median: f64, + span: f64, +} + +/// `_candidate_from_contour`. +fn candidate_from_contour( + contour: &[(i32, i32)], + s: &Scene, + source: &'static str, + threshold_index: Option, + polarity: Option<&'static str>, +) -> Option { + let (h, w) = (s.h, s.w); + let image_area = (h * w) as f64; + let rect = min_area_rect(contour); + let (rw, rh) = (rect.size.0 as f64, rect.size.1 as f64); + let rect_area = rw * rh; + if rect_area <= 0.0 { + return None; + } + let area = contour_area(contour); + let area_ratio = rect_area / image_area.max(1.0); + let (short, long) = (rw.min(rh), rw.max(rh)); + let aspect = long / short.max(1.0); + let rectangularity = area / rect_area; + if !(0.08..=1.02).contains(&area_ratio) { + return None; + } + if short < 8.0f64.max(0.04 * h.min(w) as f64) || aspect > 8.0 || rectangularity < 0.30 { + return None; + } + let quad = box_points(&rect); + let (x, y, bw, bh) = bounding_rect_f32(&quad); + let (x, y, bw, bh) = (x as i64, y as i64, bw as i64, bh as i64); + let roi = ( + y.max(0), + (y + bh).min(h as i64), + x.max(0), + (x + bw).min(w as i64), + ); + if roi.1 <= roi.0 || roi.3 <= roi.2 { + return None; + } + let polarities: Vec<&'static str> = match polarity { + Some(p) => vec![p], + None => vec!["dark", "bright"], + }; + let mut best: Option = None; + for p in polarities { + let (boundary, sides, corners) = + oriented_boundary_evidence(s.lum, h, w, &quad, p == "dark", s.ring_median, s.span); + let n_sides = sides.iter().filter(|&&b| b).count(); + if n_sides < 2 { + continue; + } + let rs = clip((rectangularity - 0.30) / 0.65, 0.0, 1.0); + let geometry = 0.70 * rs + 0.30 * (n_sides as f64 / 4.0); + let c = Candidate { + roi, + correction_angle: correction_angle_from_quad(&quad, aspect), + source, + threshold_index, + polarity: p, + boundary_score: boundary, + geometry_score: clip(geometry, 0.0, 1.0), + sides, + corners, + }; + let better = match &best { + None => true, + Some(b) => (c.boundary_score, c.geometry_score) > (b.boundary_score, b.geometry_score), + }; + if better { + best = Some(c); + } + } + best +} + +/// `_candidates_from_mask`: the twelve largest contours (stable by area, largest first) scored. +fn candidates_from_mask( + mask: &[u8], + s: &Scene, + source: &'static str, + threshold_index: Option, + polarity: Option<&'static str>, +) -> Vec { + let mut contours = find_external_contours(mask, s.h, s.w); + let areas: Vec = contours.iter().map(|c| contour_area(c)).collect(); + let mut order: Vec = (0..contours.len()).collect(); + order.sort_by(|&a, &b| areas[b].total_cmp(&areas[a])); + let picked: Vec> = order + .iter() + .take(12) + .map(|&i| std::mem::take(&mut contours[i])) + .collect(); + picked + .iter() + .filter_map(|c| candidate_from_contour(c, s, source, threshold_index, polarity)) + .collect() +} + +fn roi_iou(a: &Roi, b: &Roi) -> f64 { + let (y1, y2, x1, x2) = (a.0.max(b.0), a.1.min(b.1), a.2.max(b.2), a.3.min(b.3)); + let inter = (y2 - y1).max(0) * (x2 - x1).max(0); + let area = |r: &Roi| (r.1 - r.0).max(0) * (r.3 - r.2).max(0); + inter as f64 / ((area(a) + area(b) - inter) as f64).max(1.0) +} + +fn normalized_edge_distance(a: &Roi, b: &Roi, h: usize, w: usize) -> f64 { + let scales = [ + h.max(1) as f64, + h.max(1) as f64, + w.max(1) as f64, + w.max(1) as f64, + ]; + let (av, bv) = ([a.0, a.1, a.2, a.3], [b.0, b.1, b.2, b.3]); + (0..4) + .map(|i| (av[i] - bv[i]).abs() as f64 / scales[i]) + .fold(f64::NEG_INFINITY, f64::max) +} + +/// `_cluster_film_candidates`: union-find on overlapping boxes, groups in first-seen order. +fn cluster_candidates(c: &[Candidate], h: usize, w: usize) -> Vec> { + let n = c.len(); + let mut parents: Vec = (0..n).collect(); + fn find(p: &mut [usize], mut i: usize) -> usize { + while p[i] != i { + p[i] = p[p[i]]; + i = p[i]; + } + i + } + for a in 0..n { + for b in a + 1..n { + if roi_iou(&c[a].roi, &c[b].roi) < 0.78 { + continue; + } + if normalized_edge_distance(&c[a].roi, &c[b].roi, h, w) <= 0.035 { + let (ra, rb) = (find(&mut parents, a), find(&mut parents, b)); + if ra != rb { + parents[rb] = ra; + } + } + } + } + let mut roots: Vec = Vec::new(); + let mut groups: Vec> = Vec::new(); + for i in 0..n { + let r = find(&mut parents, i); + match roots.iter().position(|&x| x == r) { + Some(g) => groups[g].push(i), + None => { + roots.push(r); + groups.push(vec![i]); + } + } + } + groups +} + +/// The geometry module's `_weighted_median`: values sorted (ties by position), the first whose +/// cumulative weight reaches half the total. +fn weighted_median(values: &[f64], weights: &[f64]) -> f64 { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|&a, &b| values[a].total_cmp(&values[b])); + let ow: Vec = order.iter().map(|&i| weights[i]).collect(); + let total = if ow.len() < 8 { + ow.iter().fold(-0.0f64, |s, &v| s + v) + } else { + pairwise_sum_f64(&ow) + }; + let cutoff = 0.5 * total; + let mut cum = 0.0f64; + let mut index = ow.len(); + for (i, &v) in ow.iter().enumerate() { + cum += v; + if cum >= cutoff { + index = i; + break; + } + } + values[order[index.min(order.len() - 1)]] +} + +/// NumPy's float64 pairwise sum of a contiguous run. +fn pairwise_sum_f64(a: &[f64]) -> f64 { + let n = a.len(); + if n < 8 { + return a.iter().fold(-0.0, |s, &v| s + v); + } + if n <= 128 { + let mut r = [0f64; 8]; + r.copy_from_slice(&a[..8]); + let mut i = 8; + while i < n - n % 8 { + for j in 0..8 { + r[j] += a[i + j]; + } + i += 8; + } + let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7])); + for &v in &a[i..] { + res += v; + } + return res; + } + let mut n2 = n / 2; + n2 -= n2 % 8; + pairwise_sum_f64(&a[..n2]) + pairwise_sum_f64(&a[n2..]) +} + +pub struct Consensus { + pub roi: Roi, + pub correction_angle: f64, + pub confidence: f64, + pub sides: Vec<&'static str>, + pub corners: Vec<&'static str>, + pub sources: Vec<&'static str>, + pub geometry_score: f64, +} + +/// `_select_consensus_cluster`. +pub fn select_consensus(cands: &[Candidate], h: usize, w: usize) -> Option { + let mut accepted: Vec = Vec::new(); + for raw in cluster_candidates(cands, h, w) { + // _dedupe_cluster: the best candidate per trial, in first-seen trial order. + let mut keys: Vec<(&str, Option, &str)> = Vec::new(); + let mut cluster: Vec<&Candidate> = Vec::new(); + for &i in &raw { + let c = &cands[i]; + let key = (c.source, c.threshold_index, c.polarity); + match keys.iter().position(|k| *k == key) { + Some(k) => { + if (c.boundary_score, c.geometry_score) + > (cluster[k].boundary_score, cluster[k].geometry_score) + { + cluster[k] = c; + } + } + None => { + keys.push(key); + cluster.push(c); + } + } + } + let mut adaptive: Vec<(&str, usize)> = Vec::new(); + let mut structural: Vec<&str> = Vec::new(); + for c in &cluster { + if c.source.starts_with("adaptive-") { + if let Some(t) = c.threshold_index + && !adaptive.contains(&(c.polarity, t)) + { + adaptive.push((c.polarity, t)); + } + } else if !structural.contains(&c.source) { + structural.push(c.source); + } + } + if adaptive.len() < 3 && !(adaptive.len() >= 2 && structural.len() >= 2) { + continue; + } + let weights: Vec = cluster + .iter() + .map(|c| 0.05f64.max(c.boundary_score * c.geometry_score)) + .collect(); + let edge = |e: usize, c: &Candidate| [c.roi.0, c.roi.1, c.roi.2, c.roi.3][e] as f64; + let roi_values: Vec> = (0..4) + .map(|e| cluster.iter().map(|c| edge(e, c)).collect()) + .collect(); + let r: Vec = roi_values + .iter() + .map(|v| py_round(weighted_median(v, &weights))) + .collect(); + let roi = (r[0], r[1], r[2], r[3]); + if roi.1 <= roi.0 || roi.3 <= roi.2 { + continue; + } + let scales = [ + h.max(1) as f64, + h.max(1) as f64, + w.max(1) as f64, + w.max(1) as f64, + ]; + let mads: Vec = (0..4) + .map(|e| { + let center = weighted_median(&roi_values[e], &weights); + let dev: Vec = roi_values[e] + .iter() + .map(|v| (v - center).abs() / scales[e]) + .collect(); + weighted_median(&dev, &weights) + }) + .collect(); + let dispersion = mads.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let stability = clip(1.0 - dispersion / 0.03, 0.0, 1.0); + let angles: Vec = cluster.iter().map(|c| c.correction_angle).collect(); + let correction_angle = weighted_median(&angles, &weights); + let angle_dev: Vec = angles + .iter() + .map(|a| (a - correction_angle).abs()) + .collect(); + let angle_stability = clip(1.0 - weighted_median(&angle_dev, &weights) / 3.0, 0.0, 1.0); + let geo: Vec = cluster.iter().map(|c| c.geometry_score).collect(); + let geometry_score = clip( + 0.55 * weighted_median(&geo, &weights) + 0.25 * stability + 0.20 * angle_stability, + 0.0, + 1.0, + ); + let bnd: Vec = cluster.iter().map(|c| c.boundary_score).collect(); + let boundary = weighted_median(&bnd, &weights); + let support = 1.0f64.min(adaptive.len() as f64 / ADAPTIVE_THRESHOLD_COUNT); + let corroboration = 1.0f64.min(structural.len() as f64 / 3.0); + let confidence = clip( + 0.40 * support + + 0.25 * stability + + 0.20 * boundary + + 0.10 * geometry_score + + 0.05 * corroboration, + 0.0, + 1.0, + ); + if confidence < 0.55 { + continue; + } + // _supported_by_majority: values present in at least half the cluster. + let needed = ((0.5 * cluster.len() as f64).ceil() as usize).max(1); + let sides: Vec<&'static str> = (0..4) + .filter(|&i| cluster.iter().filter(|c| c.sides[i]).count() >= needed) + .map(|i| SIDE_NAMES[i]) + .collect(); + let corners: Vec<&'static str> = (0..4) + .filter(|&i| cluster.iter().filter(|c| c.corners[i]).count() >= needed) + .map(|i| CORNER_SIDES[i].0) + .collect(); + let mut sources: Vec<&'static str> = EVIDENCE_SOURCE_ORDER + .iter() + .copied() + .filter(|s| cluster.iter().any(|c| c.source == *s)) + .collect(); + let mut rest: Vec<&'static str> = cluster + .iter() + .map(|c| c.source) + .filter(|s| !sources.contains(s)) + .collect(); + rest.sort(); + rest.dedup(); + sources.extend(rest); + accepted.push(Consensus { + roi, + correction_angle, + confidence, + sides, + corners, + sources, + geometry_score, + }); + } + // Confidence first; area breaks a tie between inner and outer clusters toward the film. + let area = |c: &Consensus| (c.roi.1 - c.roi.0) * (c.roi.3 - c.roi.2); + let mut best: Option = None; + for c in accepted { + let better = match &best { + None => true, + Some(b) => (c.confidence, area(&c)) > (b.confidence, area(b)), + }; + if better { + best = Some(c); + } + } + best +} + +pub fn film_box_covers_enough(h: usize, w: usize, roi: &Roi) -> bool { + h > 0 + && w > 0 + && ((roi.1 - roi.0) * (roi.3 - roi.2)) as f64 >= MIN_FILM_BOX_COVERAGE * (h * w) as f64 +} + +/// `_film_surround_is_plausible`: a light bed or a dark holder around the box. +pub fn film_surround_is_plausible(lum: &[f32], h: usize, w: usize, roi: &Roi) -> bool { + let clampy = |v: i64| v.clamp(0, h as i64) as usize; + let clampx = |v: i64| v.clamp(0, w as i64) as usize; + let (y1, y2, x1, x2) = (clampy(roi.0), clampy(roi.1), clampx(roi.2), clampx(roi.3)); + let mut out = Vec::with_capacity(h * w); + for y in 0..h { + for x in 0..w { + if !(y >= y1 && y < y2 && x >= x1 && x < x2) { + out.push(lum[y * w + x]); + } + } + } + if (out.len() as f64) < 0.005 * lum.len() as f64 { + return true; + } + let out_med = median_f32(&out) as f64; + let out_high = np_percentiles(&out, &[90.0]).map_or(f64::NAN, |p| p[0] as f64); + let box_med = median_f32(&crop(lum, w, (y1, y2.max(y1), x1, x2.max(x1)))) as f64; + // Near-clipping, not only bright: a thin negative's picture clears 0.85, but the bed is the + // light source at the top of the histogram, where detection_luma anchors it. + let bed_like = out_med >= 0.85 && out_high >= 0.98; + let holder_like = out_med <= 0.30 && out_med <= box_med - 0.15; + bed_like || holder_like +} + +/// `_snap_film_bounds_to_bed_gradient(roi, lum)`. +fn snap_film_bounds(roi: Roi, lum: &[f32], h: usize, w: usize) -> Roi { + let (y1, y2, x1, x2) = roi; + let (yu1, yu2, xu1, xu2) = ( + y1.max(0) as usize, + (y2.max(0) as usize).min(h), + x1.max(0) as usize, + (x2.max(0) as usize).min(w), + ); + let mut col = vec![0f32; w]; + for y in yu1..yu2 { + for (c, p) in col.iter_mut().enumerate() { + *p += lum[y * w + c]; + } + } + col.iter_mut().for_each(|p| *p /= (yu2 - yu1) as f32); + let row: Vec = (0..h) + .map(|y| pairwise_sum_f32(&lum[y * w + xu1..y * w + xu2]) / (xu2 - xu1) as f32) + .collect(); + // The 16 px floor: the mask morphology kernels are fixed-size, so their inflation does not + // shrink with the image the way the 2% window does. + let snap = Snap { + window_out: 0.02, + window_in: 0.02, + min_dominance: 3.0, + min_window_px: 16, + }; + let nx1 = snap_edge_to_gradient(&col, x1, true, snap); + let nx2 = snap_edge_to_gradient(&col, x2, false, snap); + let ny1 = snap_edge_to_gradient(&row, y1, true, snap); + let ny2 = snap_edge_to_gradient(&row, y2, false, snap); + if ny2 - ny1 <= 0 || nx2 - nx1 <= 0 { + return roi; + } + // Keep the bed-to-film rows inside the box: the refinement after this needs them. + ( + (ny1 - 2).max(0), + (ny2 + 2).min(h as i64), + (nx1 - 2).max(0), + (nx2 + 2).min(w as i64), + ) +} + +/// One adaptive threshold trial. +struct Trial { + source: &'static str, + index: usize, + polarity: &'static str, + threshold: f64, +} + +pub struct Detection { + pub consensus: Option, + pub profile: Vec, + pub contrast: f64, +} + +/// `detect_film_bounds_with_confidence(img)` for an HxW or HxWx3 float32 image. +pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detection { + let empty = |profile: Vec, contrast: f64| Detection { + consensus: None, + profile, + contrast, + }; + if h < 2 || w < 2 { + return empty(Vec::new(), 0.0); + } + let color: Vec = if cn == 1 { + img.iter().flat_map(|&v| [v, v, v]).collect() + } else { + img.to_vec() + }; + let mut raw = vec![0f32; h * w]; + luminance(&color, &mut raw); + let mut lum = vec![0f32; h * w]; + if detection_luma(&raw, &mut lum).is_none() { + return empty(Vec::new(), 0.0); + } + let (profile, contrast) = vertical_edge_profile(&lum, h, w); + let lohi = np_percentiles_f64(&lum, &[2.0, 98.0]); + let span = lohi[1] - lohi[0]; + if span < 0.04 || span.is_nan() { + return empty(profile, contrast); + } + let ring_median = median_f32(&outer_ring_values(&lum, h, w)) as f64; + let polarities = threshold_polarities(&lum, h, w, span, ring_median); + let blur = odd_kernel_size(0.004 * h.min(w) as f64, 3, 9) as usize; + let mut blurred = vec![0f32; h * w]; + let _ = crate::filter::gaussian_blur( + &lum, + &mut blurred, + (h, w, 1), + (blur, blur), + (0.0, 0.0), + Border::Reflect101, + ); + let scene = Scene { + lum: &lum, + h, + w, + ring_median, + span, + }; + + // Trials in Python's order: each polarity's thresholds, then the three structural masks. + let mut trials: Vec = Vec::new(); + for &p in &polarities { + // Biased toward the surround, so the outer film edge of a three-tier negative gets at + // least as many trials as the inner picture edge. + let fractions: [f64; 7] = if p == "dark" { + [0.35, 0.50, 0.62, 0.72, 0.80, 0.87, 0.93] + } else { + [0.07, 0.13, 0.20, 0.28, 0.38, 0.50, 0.65] + }; + let (lo, hi) = (lohi[0], lohi[1]); + if hi - lo < 0.04 { + continue; + } + let source = if p == "dark" { + "adaptive-dark" + } else { + "adaptive-bright" + }; + for (i, f) in fractions.iter().enumerate() { + trials.push(Trial { + source, + index: i, + polarity: p, + threshold: lo + f * (hi - lo), + }); + } + } + let structural = ["inverse-threshold", "blackhat", "edges"]; + let gray_img = { + let mut preview = vec![0u8; h * w * 3]; + crate::analysis::normalize_to_uint8(&color, &mut preview); + let mut g = vec![0u8; h * w]; + gray::bgr_to_gray(&preview, &mut g); + g + }; + let mut results: Vec> = trials + .par_iter() + .map(|t| { + let mask = adaptive_threshold_mask(&blurred, h, w, t.threshold, t.polarity == "dark"); + candidates_from_mask(&mask, &scene, t.source, Some(t.index), Some(t.polarity)) + }) + .collect(); + let structural_results: Vec> = structural + .par_iter() + .map(|&source| { + let mask = match source { + "inverse-threshold" => mask_from_inverse_threshold(&gray_img, h, w), + "blackhat" => mask_from_blackhat(&gray_img, h, w), + _ => mask_from_edges(&gray_img, h, w), + }; + candidates_from_mask(&mask, &scene, source, None, None) + }) + .collect(); + results.extend(structural_results); + let candidates: Vec = results.into_iter().flatten().collect(); + + let Some(consensus) = select_consensus(&candidates, h, w) else { + return empty(profile, contrast); + }; + if !film_box_covers_enough(h, w, &consensus.roi) + || !film_surround_is_plausible(&lum, h, w, &consensus.roi) + { + return empty(profile, contrast); + } + let roi = snap_film_bounds(consensus.roi, &lum, h, w); + Detection { + consensus: Some(Consensus { roi, ..consensus }), + profile, + contrast, + } +} diff --git a/negpy_core/src/gray.rs b/negpy_core/src/gray.rs index b10f36de2..4bc67424d 100644 --- a/negpy_core/src/gray.rs +++ b/negpy_core/src/gray.rs @@ -1,18 +1,17 @@ //! OpenCV's uint8 grayscale operations, all integer arithmetic: BGR to gray, the fixed-point //! GaussianBlur, Otsu's threshold and Canny. +use multiversion::multiversion; use rayon::prelude::*; use crate::filter::{Border, border_interpolate, gaussian_kernel_f64}; /// `cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)`: 15-bit fixed-point weights, rounded. pub fn bgr_to_gray(src: &[u8], dst: &mut [u8]) { - dst.par_iter_mut() - .zip(src.par_chunks_exact(3)) - .for_each(|(d, p)| { - *d = ((p[0] as u32 * 3735 + p[1] as u32 * 19235 + p[2] as u32 * 9798 + (1 << 14)) >> 15) - as u8; - }); + crate::par_zip(dst, src.as_chunks::<3>().0, move |d, p| { + *d = ((p[0] as u32 * 3735 + p[1] as u32 * 19235 + p[2] as u32 * 9798 + (1 << 14)) >> 15) + as u8; + }); } /// `getGaussianKernelFixedPoint_ED`: the bit-exact kernel in 8 fractional bits, rounding errors @@ -83,35 +82,65 @@ pub fn gaussian_blur_u8( .map(|px| border_interpolate(px as isize - rx as isize, w, border)) .collect(); - let mut rows = vec![0u32; h * n]; + // Kernel-major sums over a padded row: integer, so the order leaves the bits alone. A row sum + // is at most 255 * 256, so rows fit u16, as in OpenCV. + let mut rows = vec![0u16; h * n]; rows.par_chunks_mut(n).enumerate().for_each(|(y, out)| { let row = &src[y * n..(y + 1) * n]; - let px = |p: usize, c: usize| xmap[p].map_or(0, |sx| row[sx * cn + c] as u32); - for x in 0..w { - for c in 0..cn { - out[x * cn + c] = (0..kw).map(|j| kx[j] * px(x + j, c)).sum(); + let mut pad = vec![0u16; (w + 2 * rx) * cn]; + for (p, &v) in pad[rx * cn..(rx + w) * cn].iter_mut().zip(row) { + *p = v as u16; + } + for p in (0..rx).chain(rx + w..w + 2 * rx) { + if let Some(sx) = xmap[p] { + for c in 0..cn { + pad[p * cn + c] = row[sx * cn + c] as u16; + } } } + for (j, &k) in kx.iter().enumerate() { + mac_u16(out, &pad[j * cn..j * cn + n], k as u16); + } }); dst.par_chunks_mut(n).enumerate().for_each(|(y, out)| { - let lines: Vec> = (0..kh) - .map(|j| { - border_interpolate(y as isize + j as isize - ry as isize, h, border) - .map(|sy| &rows[sy * n..(sy + 1) * n]) - }) - .collect(); - for (i, o) in out.iter_mut().enumerate() { - let v: u32 = lines - .iter() - .zip(&ky) - .map(|(l, &k)| l.map_or(0, |l| k * l[i])) - .sum(); + let mut acc = vec![0u32; n]; + for (j, &k) in ky.iter().enumerate() { + if let Some(sy) = border_interpolate(y as isize + j as isize - ry as isize, h, border) { + mac_u32(&mut acc, &rows[sy * n..(sy + 1) * n], k); + } + } + for (o, &v) in out.iter_mut().zip(&acc) { *o = ((v + (1 << 15)) >> 16).min(255) as u8; } }); Some(()) } +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn mac_u16(out: &mut [u16], src: &[u16], k: u16) { + for (o, &v) in out.iter_mut().zip(src) { + *o += k * v; + } +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn mac_u32(out: &mut [u32], src: &[u16], k: u32) { + for (o, &v) in out.iter_mut().zip(src) { + *o += k * v as u32; + } +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn sobel_row(a: &[i16], b: &[i16], c: &[i16], rx: &mut [i16], ry: &mut [i16], rm: &mut [i16]) { + for x in 0..rx.len() { + let gx = (a[x + 2] + 2 * b[x + 2] + c[x + 2]) - (a[x] + 2 * b[x] + c[x]); + let gy = (c[x] + 2 * c[x + 1] + c[x + 2]) - (a[x] + 2 * a[x + 1] + a[x + 2]); + rx[x] = gx; + ry[x] = gy; + rm[x] = gx.abs() + gy.abs(); + } +} + /// Otsu's threshold for uint8 (`getThreshVal_Otsu`), in its double arithmetic and first-max rule. pub fn otsu_threshold(src: &[u8]) -> f64 { let mut hist = [0u64; 256]; @@ -149,7 +178,7 @@ pub fn otsu_threshold(src: &[u8]) -> f64 { pub fn threshold_binary(src: &[u8], dst: &mut [u8], thresh: f64, maxval: u8, invert: bool) { // OpenCV compares against cvFloor(thresh) for uint8. let t = thresh.floor().clamp(-1.0, 255.0) as i32; - dst.par_iter_mut().zip(src.par_iter()).for_each(|(d, &s)| { + crate::par_zip(dst, src, move |d, &s| { *d = if (s as i32 > t) != invert { maxval } else { 0 }; }); } @@ -161,68 +190,75 @@ pub fn canny(src: &[u8], dst: &mut [u8], h: usize, w: usize, low: f64, high: f64 } let (low, high) = if low > high { (high, low) } else { (low, high) }; let (low, high) = (low.floor() as i32, high.floor() as i32); - let at = |y: isize, x: isize| { - src[y.clamp(0, h as isize - 1) as usize * w + x.clamp(0, w as isize - 1) as usize] as i32 - }; - - // Sobel 3x3 in 16-bit integers with a replicated border, then |dx| + |dy|. - let mut dx = vec![0i32; h * w]; - let mut dy = vec![0i32; h * w]; + // Sobel 3x3 over a replicated one-pixel border, then |dx| + |dy| in a frame of zeros. + let pw = w + 2; + let mut pad = vec![0i16; (h + 2) * pw]; + for py in 0..h + 2 { + let sy = py.saturating_sub(1).min(h - 1); + let line = &mut pad[py * pw..(py + 1) * pw]; + for (px, v) in line.iter_mut().enumerate() { + *v = src[sy * w + px.saturating_sub(1).min(w - 1)] as i16; + } + } + let mut dx = vec![0i16; h * w]; + let mut dy = vec![0i16; h * w]; + let mut mag = vec![0i16; (h + 2) * pw]; dx.par_chunks_mut(w) .zip(dy.par_chunks_mut(w)) + .zip(mag.par_chunks_mut(pw).skip(1)) .enumerate() - .for_each(|(y, (rx, ry))| { - let y = y as isize; - for x in 0..w { - let xi = x as isize; - let col = |c: isize| at(y - 1, c) + 2 * at(y, c) + at(y + 1, c); - let row = |r: isize| at(r, xi - 1) + 2 * at(r, xi) + at(r, xi + 1); - rx[x] = col(xi + 1) - col(xi - 1); - ry[x] = row(y + 1) - row(y - 1); - } + .for_each(|(y, ((rx, ry), rm))| { + sobel_row( + &pad[y * pw..(y + 1) * pw], + &pad[(y + 1) * pw..(y + 2) * pw], + &pad[(y + 2) * pw..(y + 3) * pw], + rx, + ry, + &mut rm[1..w + 1], + ); }); - let mag = |y: isize, x: isize| -> i32 { - if y < 0 || y >= h as isize || x < 0 || x >= w as isize { - 0 - } else { - let i = y as usize * w + x as usize; - dx[i].abs() + dy[i].abs() - } - }; // Map: 0 may be an edge, 1 cannot, 2 is one. A one-pixel frame of 1 surrounds the image. const TG22: i64 = 13573; - let mw = w + 2; + let mw = pw; let mut map = vec![1u8; (h + 2) * mw]; map.par_chunks_mut(mw) .enumerate() .skip(1) .take(h) .for_each(|(my, line)| { - let y = my as isize - 1; + let y = my - 1; + let (up, mid, down) = ( + &mag[(my - 1) * pw..my * pw], + &mag[my * pw..(my + 1) * pw], + &mag[(my + 1) * pw..(my + 2) * pw], + ); for x in 0..w { - let xi = x as isize; - let m = mag(y, xi); + let px = x + 1; + let m = mid[px] as i32; let mut v = 1u8; if m > low { - let i = y as usize * w + x; + let i = y * w + x; let (xs, ys) = (dx[i], dy[i]); let ax = xs.abs() as i64; let ay = (ys.abs() as i64) << 15; let tg22x = ax * TG22; + let m = m as i16; let peak = if ay < tg22x { - m > mag(y, xi - 1) && m >= mag(y, xi + 1) + m > mid[px - 1] && m >= mid[px + 1] } else if ay > tg22x + (ax << 16) { - m > mag(y - 1, xi) && m >= mag(y + 1, xi) + m > up[px] && m >= down[px] + } else if (xs ^ ys) < 0 { + m > up[px + 1] && m > down[px - 1] } else { - let s: isize = if (xs ^ ys) < 0 { -1 } else { 1 }; - m > mag(y - 1, xi - s) && m > mag(y + 1, xi + s) + m > up[px - 1] && m > down[px + 1] }; + let m = m as i32; if peak { v = if m > high { 2 } else { 0 }; } } - line[x + 1] = v; + line[px] = v; } }); diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index dca0e75e9..ca9b18aa0 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,5 +1,6 @@ pub mod analysis; pub mod contours; +pub mod detect; pub mod filter; pub mod gray; pub mod morph; @@ -7,6 +8,17 @@ pub mod resize; pub mod shapes; pub mod stats; +/// `f(dst[i], src[i])` for every i, in parallel blocks: a rayon task per element costs more than +/// the work. Pass a `move` closure: a capture read through a reference may alias `dst`, so the +/// loop reloads it per element and does not vectorize. +pub(crate) fn par_zip(dst: &mut [T], src: &[U], f: F) { + use rayon::prelude::*; + const BLOCK: usize = 1 << 14; + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| d.iter_mut().zip(s).for_each(|(d, s)| f(d, s))); +} + #[cfg(feature = "python")] mod py; diff --git a/negpy_core/src/morph.rs b/negpy_core/src/morph.rs index 2d10767ba..475b43071 100644 --- a/negpy_core/src/morph.rs +++ b/negpy_core/src/morph.rs @@ -240,23 +240,17 @@ pub fn morphology( Op::Gradient => { ed(src, &mut tmp, false); ed(src, dst, true); - dst.par_iter_mut() - .zip(tmp.par_iter()) - .for_each(|(d, &t)| *d = t.sub(*d)); + crate::par_zip(dst, &tmp, move |d, &t| *d = t.sub(*d)); } Op::TopHat => { ed(src, &mut tmp, true); ed(&tmp, dst, false); - dst.par_iter_mut() - .zip(src.par_iter()) - .for_each(|(d, &s)| *d = s.sub(*d)); + crate::par_zip(dst, src, move |d, &s| *d = s.sub(*d)); } Op::BlackHat => { ed(src, &mut tmp, false); ed(&tmp, dst, true); - dst.par_iter_mut() - .zip(src.par_iter()) - .for_each(|(d, &s)| *d = d.sub(s)); + crate::par_zip(dst, src, move |d, &s| *d = d.sub(s)); } } Some(()) diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 8303657f5..17588f59f 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -346,6 +346,145 @@ fn refine_roi_to_image<'py>( }) } +type DetectionFields<'py> = ( + Option, + Bound<'py, numpy::PyArray1>, + f64, +); + +/// `detect_film_bounds_with_confidence(img)` for an HxW or HxWx3 float32 image: +/// ((roi, angle, confidence, sides, corners, sources, geometry) or None, profile, contrast). +#[pyfunction] +fn detect_film_bounds<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "detect_film_bounds needs an HxW or HxWx3 array", + )); + } + }; + let data = slice(&img)?; + let d = py.detach(|| crate::detect::detect_film_bounds(data, h, w, cn)); + Ok(( + d.consensus.map(consensus_fields), + numpy::PyArray1::from_vec(py, d.profile), + d.contrast, + )) +} + +type ConsensusFields = ( + analysis::Roi, + f64, + f64, + Vec<&'static str>, + Vec<&'static str>, + Vec<&'static str>, + f64, +); + +fn consensus_fields(c: crate::detect::Consensus) -> ConsensusFields { + ( + c.roi, + c.correction_angle, + c.confidence, + c.sides, + c.corners, + c.sources, + c.geometry_score, + ) +} + +fn known(value: &str, names: &[&'static str]) -> PyResult<&'static str> { + names + .iter() + .find(|n| **n == value) + .copied() + .ok_or_else(|| PyValueError::new_err(format!("unknown name {value:?}"))) +} + +fn flags(values: &[String], names: &[&'static str]) -> PyResult<[bool; 4]> { + let mut out = [false; 4]; + for v in values { + out[names + .iter() + .position(|n| n == v) + .ok_or_else(|| PyValueError::new_err(format!("unknown name {v:?}")))?] = true; + } + Ok(out) +} + +type CandidateFields = ( + analysis::Roi, + f64, + String, + Option, + String, + f64, + f64, + Vec, + Vec, +); + +/// `_select_consensus_cluster` over (roi, angle, source, threshold index, polarity, boundary, +/// geometry, sides, corners) candidates in an HxW image. +#[pyfunction] +fn select_consensus( + candidates: Vec, + h: usize, + w: usize, +) -> PyResult> { + use crate::detect::{CORNER_SIDES, Candidate, EVIDENCE_SOURCE_ORDER, SIDE_NAMES}; + let corners: Vec<&'static str> = CORNER_SIDES.iter().map(|c| c.0).collect(); + let cands = candidates + .into_iter() + .map( + |(roi, angle, source, index, polarity, boundary, geometry, s, c)| { + Ok(Candidate { + roi, + correction_angle: angle, + source: known(&source, &EVIDENCE_SOURCE_ORDER)?, + threshold_index: index, + polarity: known(&polarity, &["dark", "bright"])?, + boundary_score: boundary, + geometry_score: geometry, + sides: flags(&s, &SIDE_NAMES)?, + corners: flags(&c, &corners)?, + }) + }, + ) + .collect::>>()?; + Ok(crate::detect::select_consensus(&cands, h, w).map(consensus_fields)) +} + +fn lum_dims<'a>(lum: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], usize, usize)> { + match lum.shape()[..] { + [h, w] => Ok((slice(lum)?, h, w)), + _ => Err(PyValueError::new_err("needs an HxW array")), + } +} + +/// `_film_box_covers_enough(lum, roi)`. +#[pyfunction] +fn film_box_covers_enough(lum: PyReadonlyArrayDyn<'_, f32>, roi: analysis::Roi) -> PyResult { + let (_, h, w) = lum_dims(&lum)?; + Ok(crate::detect::film_box_covers_enough(h, w, &roi)) +} + +/// `_film_surround_is_plausible(lum, roi)`. +#[pyfunction] +fn film_surround_is_plausible( + lum: PyReadonlyArrayDyn<'_, f32>, + roi: analysis::Roi, +) -> PyResult { + let (data, h, w) = lum_dims(&lum)?; + Ok(crate::detect::film_surround_is_plausible(data, h, w, &roi)) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; @@ -361,5 +500,9 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(snap_edge_to_gradient, m)?)?; m.add_function(wrap_pyfunction!(refine_film_roi_by_tiers, m)?)?; m.add_function(wrap_pyfunction!(refine_frame_bounds, m)?)?; - m.add_function(wrap_pyfunction!(refine_roi_to_image, m)?) + m.add_function(wrap_pyfunction!(refine_roi_to_image, m)?)?; + m.add_function(wrap_pyfunction!(detect_film_bounds, m)?)?; + m.add_function(wrap_pyfunction!(select_consensus, m)?)?; + m.add_function(wrap_pyfunction!(film_box_covers_enough, m)?)?; + m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?) } diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index 7f9e923bc..7e7ecea91 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -125,13 +125,9 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { .collect() } -/// `np.percentile(values, q)` (method "linear") for each q, bit for bit, except that -0.0 and -/// 0.0 may swap: `np.partition` ranks them equal and returns either. `None` for an empty input. -pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { +/// The two samples around each q's virtual rank and the weight between them. +fn rank_pairs(values: &[f32], qs: &[f64]) -> Vec<(f32, f32, f64)> { let n = values.len(); - if n == 0 { - return None; - } let spans: Vec<(usize, usize, f64)> = qs .iter() .map(|&q| { @@ -144,14 +140,45 @@ pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { ranks.dedup(); let picked = select_ranks(values, &ranks); let at = |r: usize| picked[ranks.binary_search(&r).unwrap_or_default()]; + spans + .iter() + .map(|&(lo, hi, t)| (at(lo), at(hi), t)) + .collect() +} + +/// `np.percentile(values, q)` (method "linear") for each q, bit for bit, except that -0.0 and +/// 0.0 may swap: `np.partition` ranks them equal and returns either. `None` for an empty input. +pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { + if values.is_empty() { + return None; + } Some( - spans - .iter() - .map(|&(lo, hi, t)| interpolate(at(lo), at(hi), t)) + rank_pairs(values, qs) + .into_iter() + .map(|(a, b, t)| interpolate(a, b, t)) .collect(), ) } +/// `np.percentile(values, (a, b, ...))`: a sequence of q interpolates in float64. NaN for every +/// q when any value is NaN or the input is empty. +pub fn np_percentiles_f64(values: &[f32], qs: &[f64]) -> Vec { + if values.is_empty() || values.iter().any(|v| v.is_nan()) { + return vec![f64::NAN; qs.len()]; + } + rank_pairs(values, qs) + .into_iter() + .map(|(a, b, t)| { + let (a, b) = (a as f64, b as f64); + if t >= 0.5 { + b - (b - a) * (1.0 - t) + } else { + a + (b - a) * t + } + }) + .collect() +} + /// NumPy's float32 sum of a contiguous run: one pairwise sum (blocks of 128, eight /// accumulators), the order `np.sum` and `mean(axis=-1)` use. pub fn pairwise_sum_f32(a: &[f32]) -> f32 { diff --git a/tests/test_autocrop_consensus.py b/tests/test_autocrop_consensus.py index 0d913a88a..45207ab19 100644 --- a/tests/test_autocrop_consensus.py +++ b/tests/test_autocrop_consensus.py @@ -1,11 +1,10 @@ from dataclasses import FrozenInstanceError +import negpy_core import numpy as np import pytest from negpy.features.geometry.logic import ( - _FilmCandidate, - _select_consensus_cluster, apply_fine_rotation, detect_film_bounds_with_confidence, ) @@ -39,16 +38,16 @@ def _candidate( geometry_score=0.9, boundary_score=0.9, ): - return _FilmCandidate( - roi=roi, - correction_angle=0.0, - source=source, - threshold_index=threshold_index, - polarity="dark", - boundary_score=boundary_score, - geometry_score=geometry_score, - supported_sides=frozenset({"top", "bottom", "left", "right"}), - supported_corners=frozenset({"top_left", "top_right", "bottom_left", "bottom_right"}), + return ( + roi, + 0.0, + source, + threshold_index, + "dark", + boundary_score, + geometry_score, + ["top", "bottom", "left", "right"], + ["top_left", "top_right", "bottom_left", "bottom_right"], ) @@ -68,17 +67,18 @@ def test_consensus_rejects_a_high_scoring_single_source_outlier(): boundary_score=1.0, ) - consensus = _select_consensus_cluster([outlier, *stable], (360, 540)) + consensus = negpy_core.select_consensus([outlier, *stable], 360, 540) assert consensus is not None - assert all(abs(actual - expected) <= 1 for actual, expected in zip(consensus.roi, (80, 300, 100, 440))) - assert "adaptive-dark" in consensus.evidence_sources - assert "edges" not in consensus.evidence_sources + roi, *_, sources, _ = consensus + assert all(abs(actual - expected) <= 1 for actual, expected in zip(roi, (80, 300, 100, 440))) + assert "adaptive-dark" in sources + assert "edges" not in sources def test_consensus_abstains_on_a_single_structural_mask(): candidate = _candidate((80, 300, 100, 440), threshold_index=None, source="edges") - assert _select_consensus_cluster([candidate], (360, 540)) is None + assert negpy_core.select_consensus([candidate], 360, 540) is None def test_detects_dark_film_on_light_bed_and_returns_immutable_evidence(): diff --git a/tests/test_geometry_logic.py b/tests/test_geometry_logic.py index dadc03a4c..73e9bb476 100644 --- a/tests/test_geometry_logic.py +++ b/tests/test_geometry_logic.py @@ -1659,7 +1659,7 @@ def test_edge_walk_reads_a_wide_scanner_rebate_the_same_as_the_ring(): def test_film_surround_must_be_near_clipping_to_read_as_bed(): - from negpy.features.geometry.logic import _film_surround_is_plausible + from negpy_core import film_surround_is_plausible as _film_surround_is_plausible # A blown region on a thin negative clears the brightness bar but not the histogram # top, where the light bed sits. Accepting it cropped a frame to a third of itself. @@ -1673,7 +1673,7 @@ def test_film_surround_must_be_near_clipping_to_read_as_bed(): def test_a_film_box_must_cover_a_quarter_of_the_frame(): - from negpy.features.geometry.logic import _film_box_covers_enough + from negpy_core import film_box_covers_enough as _film_box_covers_enough # A positive whose bed clips to white leaves a dark subject satisfying every other # check on its own geometry. Film fills most of a scan, so a box this small is not it. From 274c93a45b07332f1cdc4b188a6c06243ca3a6c8 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 00:59:51 +0200 Subject: [PATCH 029/133] docs(core): par_zip and move closures in hot loops --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4b89108c3..64e1713df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `pkg-config`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. - `mul_add` is one instruction only on a CPU with FMA; the release baseline has none, so a loop that uses it takes `#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))]`. `mul_add` is exactly fused on every path, so the bits never depend on the CPU. +- Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. From 1f150176b8bd53fb06651374f4e6cb87dcde2648 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:06:32 +0200 Subject: [PATCH 030/133] perf(core): port normalize_log_image and _block_median_grid normalize_log_image drops its Numba kernel. Numba types the span float64 there; a float32 division of the difference rounds the same, so the Rust divides in float64 and casts, as the kernel did. _block_median_grid keeps the 2x2 (sum - min - max) / 2 rule with the sum in float64, and np.median for larger blocks; rayon replaces the strip thread pool. np_median moves to stats.rs and replaces the copy in detect.rs. Replay: _block_median_grid 60/60, normalize_log_image 60/60, and every recorded caller identical. The corpus only reaches 2x2 blocks; larger blocks match the old Python on 27 upscaled and random inputs. Bench (one thread): 0.447x and 0.311x. --- negpy/features/exposure/normalization.py | 71 ++---------------- negpy_core/manifest.toml | 16 ++-- negpy_core/src/analysis.rs | 93 +++++++++++++++++++----- negpy_core/src/detect.rs | 35 +++------ negpy_core/src/py/analysis.rs | 48 ++++++++++++ negpy_core/src/stats.rs | 18 +++++ tests/test_numba_nogil.py | 1 - 7 files changed, 165 insertions(+), 117 deletions(-) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index d32fe88d4..815d53971 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -1,11 +1,8 @@ -import os -from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, Optional, TYPE_CHECKING, Tuple import cv2 import negpy_core import numpy as np -from numba import njit # type: ignore from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ROI, ImageBuffer from negpy.features.geometry.logic import AUTOCROP_DETECT_RES, _normalize_detection_input, film_and_picture @@ -20,9 +17,6 @@ # per-frame bounds from an older version are dropped at startup (migrations/bounds.py). BOUNDS_VERSION = 1 -# Above this size the block-median is threaded over row strips (np.median frees the GIL). -_BLOCK_MEDIAN_PARALLEL_MIN_PIXELS = 2_000_000 - # Contrast-mask spacer, per-cent of the analysis grid's short side. Of the grid, not of # the render, so preview and export mask alike. Under the minimum the mask stops being # unsharp and collapses into a plain (1-g) reduction, which is Grade. @@ -34,35 +28,6 @@ DIFFUSION_SPREAD = 2.0 -@njit(cache=True, nogil=True, fastmath=True) -def _normalize_log_image_jit(img_log: np.ndarray, floors: np.ndarray, ceils: np.ndarray) -> np.ndarray: - """ - Log -> ~0.0-1.0 (Linear stretch, unclamped: out-of-bounds densities are - rolled off by the downstream characteristic curve). - Supports both f < c (Negative) and f > c (Positive) mapping. - """ - h, w, c = img_log.shape - res = np.empty_like(img_log) - epsilon = 1e-6 - - for y in range(h): - for x in range(w): - for ch in range(3): - f = floors[ch] - c_val = ceils[ch] - delta = c_val - f - - denom = delta - if abs(delta) < epsilon: - if delta >= 0: - denom = epsilon - else: - denom = -epsilon - - res[y, x, ch] = (img_log[y, x, ch] - f) / denom - return res - - class LogNegativeBounds: """ D-min / D-max container. @@ -134,32 +99,8 @@ def _block_median_grid(img_log: ImageBuffer) -> ImageBuffer: b = int(np.ceil(max(h, w) / grid)) if b <= 1 or h < b or w < b: return img_log - hb, wb = (h // b) * b, (w // b) * b - arr = img_log[:hb, :wb] - grid_rows, c = hb // b, arr.shape[2] - - def _median(rows: np.ndarray) -> np.ndarray: - v = rows.reshape(rows.shape[0] // b, b, wb // b, b, c) - if b == 2: - # Median of 4 = (sum - min - max) / 2; strided views, no partition. - p00, p01, p10, p11 = v[:, 0, :, 0], v[:, 0, :, 1], v[:, 1, :, 0], v[:, 1, :, 1] - s = p00.astype(np.float64) + p01 + p10 + p11 - mn = np.minimum(np.minimum(p00, p01), np.minimum(p10, p11)) - mx = np.maximum(np.maximum(p00, p01), np.maximum(p10, p11)) - return ((s - mn - mx) * 0.5).astype(rows.dtype, copy=False) - return np.median(v, axis=(1, 3)) - - workers = min(os.cpu_count() or 1, grid_rows) - if workers < 2 or hb * wb < _BLOCK_MEDIAN_PARALLEL_MIN_PIXELS: - return _median(arr) - - # Block-aligned strips -> per-cell median identical to the single pass. - rows_per = -(-grid_rows // workers) - strips = [arr[i * b : min(grid_rows, i + rows_per) * b] for i in range(0, grid_rows, rows_per)] - with ThreadPoolExecutor(max_workers=workers) as ex: - parts = list(ex.map(_median, strips)) - return np.concatenate(parts, axis=0) + return negpy_core.block_median_grid(np.ascontiguousarray(img_log[:hb, :wb], dtype=np.float32), b) def prefilter_log_grid( @@ -835,12 +776,12 @@ def diffusion_plane(grid: np.ndarray, bounds: LogNegativeBounds, panchromatic: b def normalize_log_image(img_log: ImageBuffer, bounds: LogNegativeBounds) -> ImageBuffer: """ - Stretches log-data to fit [0, 1]. + Log -> ~0.0-1.0 (linear stretch, unclamped: out-of-bounds densities are rolled off by + the downstream characteristic curve). Supports both f < c (negative) and f > c (positive). """ - floors = np.ascontiguousarray(np.array(bounds.floors, dtype=np.float32)) - ceils = np.ascontiguousarray(np.array(bounds.ceils, dtype=np.float32)) - - return ensure_image(_normalize_log_image_jit(np.ascontiguousarray(img_log.astype(np.float32)), floors, ceils)) + floors = [float(v) for v in np.asarray(bounds.floors, dtype=np.float32)] + ceils = [float(v) for v in np.asarray(bounds.ceils, dtype=np.float32)] + return ensure_image(negpy_core.normalize_log_image(np.ascontiguousarray(img_log, dtype=np.float32), floors, ceils)) def _sample_log_bounds( diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index f9405f3b7..d3fb3bc98 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -163,19 +163,19 @@ note = "negpy_core::analysis::base_blocks" ["features/exposure/normalization.py::_block_median_grid"] phase = 2 -status = "todo" -calls = ["np.median"] -depends = ["_median"] +status = "ported" +calls = ["negpy_core.block_median_grid"] +depends = [] ["features/exposure/normalization.py::_block_median_grid._median"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median"] depends = [] ["features/exposure/normalization.py::_normalize_log_image_jit"] phase = 2 -status = "todo" +status = "ported" calls = ["numba"] depends = [] @@ -337,9 +337,9 @@ depends = ["_block_median_grid"] ["features/exposure/normalization.py::normalize_log_image"] phase = 2 -status = "todo" -calls = [] -depends = ["_normalize_log_image_jit"] +status = "ported" +calls = ["negpy_core.normalize_log_image"] +depends = [] ["features/exposure/normalization.py::pool_frame_bounds"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 8cdd94ee2..61a82afe4 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -4,7 +4,7 @@ use multiversion::multiversion; use rayon::prelude::*; use crate::filter::Border; -use crate::stats::{np_percentiles, pairwise_sum_f32, percentiles}; +use crate::stats::{np_median, np_percentiles, pairwise_sum_f32, percentiles}; const LUMA_R: f64 = 0.2126; const LUMA_G: f64 = 0.7152; @@ -66,6 +66,79 @@ pub fn detection_luma(lum: &[f32], dst: &mut [f32]) -> Option<()> { Some(()) } +/// `normalize_log_image`: `(v - floor) / (ceil - floor)` per channel of an HxWx3 log image, +/// unclamped. A span under 1e-6 divides by +-1e-6, by the span's sign. +pub fn normalize_log_image(img: &[f32], floors: [f32; 3], ceils: [f32; 3], dst: &mut [f32]) { + let denom = std::array::from_fn::(|c| { + let d = ceils[c] - floors[c]; + // Numba types the span float64 here; float32 division of the difference rounds the same. + if (d.abs() as f64) < 1e-6 { + if d >= 0.0 { 1e-6 } else { -1e-6 } + } else { + d + } + }); + crate::par_zip( + dst.as_chunks_mut::<3>().0, + img.as_chunks::<3>().0, + move |d, p| { + for c in 0..3 { + d[c] = ((p[c] - floors[c]) as f64 / denom[c] as f64) as f32; + } + }, + ); +} + +/// `_block_median_grid` for block size `b` over the whole `b` x `b` blocks of an HxWxC image: +/// the median of each block per channel, (H/b)x(W/b)xC. A block of 2x2 is (sum - min - max) / 2, +/// the sum in float64. +pub fn block_median_grid(img: &[f32], (h, w, c): (usize, usize, usize), b: usize) -> Vec { + let (gh, gw) = (h / b, w / b); + let mut out = vec![0f32; gh * gw * c]; + if gh == 0 || gw == 0 { + return out; + } + let at = |y: usize, x: usize, ch: usize| img[(y * w + x) * c + ch]; + out.par_chunks_mut(gw * c) + .enumerate() + .for_each(|(gy, line)| { + let mut block = Vec::with_capacity(b * b); + for gx in 0..gw { + for ch in 0..c { + let (y, x) = (gy * b, gx * b); + line[gx * c + ch] = if b == 2 { + let (p00, p01, p10, p11) = ( + at(y, x, ch), + at(y, x + 1, ch), + at(y + 1, x, ch), + at(y + 1, x + 1, ch), + ); + let s = p00 as f64 + p01 as f64 + p10 as f64 + p11 as f64; + let mn = np_min(np_min(p00, p01), np_min(p10, p11)); + let mx = np_max(np_max(p00, p01), np_max(p10, p11)); + ((s - mn as f64 - mx as f64) * 0.5) as f32 + } else { + block.clear(); + for yy in y..y + b { + block.extend((x..x + b).map(|xx| at(yy, xx, ch))); + } + np_median(&mut block) + }; + } + } + }); + out +} + +/// np.minimum with NaN propagation. +fn np_min(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.min(b) + } +} + const BASE_BLOCK: usize = 8; const CLIP_LEVEL: f32 = 0.98; const FLAT_CV: f32 = 0.03; @@ -264,24 +337,6 @@ const EDGE_REBATE_REF: usize = 60; const EDGE_STEP_SHARE: f32 = 0.45; const EDGE_STEP_FLOOR: f32 = 0.06; -/// `np.median` of a column of float32 values: the middle one, or the float32 mean of the two -/// middle ones; NaN when any value is NaN. -fn np_median(col: &mut [f32]) -> f32 { - if col.is_empty() || col.iter().any(|v| v.is_nan()) { - return f32::NAN; - } - let n = col.len(); - let (_, &mut hi, _) = col.select_nth_unstable_by(n / 2, f32::total_cmp); - if n % 2 == 1 { - return hi; - } - let lo = col[..n / 2] - .iter() - .copied() - .fold(f32::NEG_INFINITY, f32::max); - (lo + hi) / 2.0 -} - /// np.max with NaN propagation. fn np_max(a: f32, b: f32) -> f32 { if a.is_nan() || b.is_nan() { diff --git a/negpy_core/src/detect.rs b/negpy_core/src/detect.rs index bd0dbf479..8b0a88896 100644 --- a/negpy_core/src/detect.rs +++ b/negpy_core/src/detect.rs @@ -9,7 +9,7 @@ use crate::filter::Border; use crate::gray; use crate::morph::{self, Op}; use crate::shapes::{bounding_rect_f32, box_points, fill_convex_poly, min_area_rect}; -use crate::stats::{np_percentiles, np_percentiles_f64, pairwise_sum_f32}; +use crate::stats::{np_median, np_percentiles, np_percentiles_f64, pairwise_sum_f32}; pub type Roi = (i64, i64, i64, i64); @@ -37,21 +37,6 @@ fn py_round(v: f64) -> i64 { v.round_ties_even() as i64 } -/// `np.median` of float32 values (NaN when any is NaN). -fn median_f32(values: &[f32]) -> f32 { - if values.is_empty() || values.iter().any(|v| v.is_nan()) { - return f32::NAN; - } - let mut v = values.to_vec(); - let n = v.len(); - let (_, &mut hi, _) = v.select_nth_unstable_by(n / 2, f32::total_cmp); - if n % 2 == 1 { - return hi; - } - let lo = v[..n / 2].iter().copied().fold(f32::NEG_INFINITY, f32::max); - (lo + hi) / 2.0 -} - fn clip(v: f64, lo: f64, hi: f64) -> f64 { v.clamp(lo, hi) } @@ -161,8 +146,7 @@ fn threshold_polarities( py_round(0.8 * w as f64) as usize, ); let (y2, x2) = (y2.max(y1 + 1).min(h), x2.max(x1 + 1).min(w)); - let center = crop(lum, w, (y1.min(h), y2, x1.min(w), x2)); - let center_median = median_f32(¢er) as f64; + let center_median = np_median(&mut crop(lum, w, (y1.min(h), y2, x1.min(w), x2))) as f64; let gate = 0.025f64.max(0.05 * span); if ring_median - center_median > gate { vec!["dark"] @@ -467,13 +451,16 @@ fn oriented_boundary_evidence( (inner_end.0 - off.0, inner_end.1 - off.1), (inner_start.0 - off.0, inner_start.1 - off.1), ]; - let inside = polygon_values(lum, h, w, &inside_poly); - let outside = polygon_values(lum, h, w, &outside_poly); + let mut inside = polygon_values(lum, h, w, &inside_poly); + let mut outside = polygon_values(lum, h, w, &outside_poly); let expected = 8.0f64.max(0.20 * 0.84 * length * band); if (inside.len() as f64) < expected || (outside.len() as f64) < expected { continue; } - let (im, om) = (median_f32(&inside) as f64, median_f32(&outside) as f64); + let (im, om) = ( + np_median(&mut inside) as f64, + np_median(&mut outside) as f64, + ); let contrast = if dark { om - im } else { im - om }; let ring_delta = (om - ring_median).abs(); if contrast < contrast_gate @@ -933,9 +920,9 @@ pub fn film_surround_is_plausible(lum: &[f32], h: usize, w: usize, roi: &Roi) -> if (out.len() as f64) < 0.005 * lum.len() as f64 { return true; } - let out_med = median_f32(&out) as f64; + let out_med = np_median(&mut out) as f64; let out_high = np_percentiles(&out, &[90.0]).map_or(f64::NAN, |p| p[0] as f64); - let box_med = median_f32(&crop(lum, w, (y1, y2.max(y1), x1, x2.max(x1)))) as f64; + let box_med = np_median(&mut crop(lum, w, (y1, y2.max(y1), x1, x2.max(x1)))) as f64; // Near-clipping, not only bright: a thin negative's picture clears 0.85, but the bed is the // light source at the top of the histogram, where detection_luma anchors it. let bed_like = out_med >= 0.85 && out_high >= 0.98; @@ -1027,7 +1014,7 @@ pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detecti if span < 0.04 || span.is_nan() { return empty(profile, contrast); } - let ring_median = median_f32(&outer_ring_values(&lum, h, w)) as f64; + let ring_median = np_median(&mut outer_ring_values(&lum, h, w)) as f64; let polarities = threshold_polarities(&lum, h, w, span, ring_median); let blur = odd_kernel_size(0.004 * h.min(w) as f64, 3, 9) as usize; let mut blurred = vec![0f32; h * w]; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 17588f59f..942d7fef1 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -377,6 +377,52 @@ fn detect_film_bounds<'py>( )) } +/// `normalize_log_image(img_log, bounds)` of an HxWx3 float32 log image. +#[pyfunction] +fn normalize_log_image<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + floors: [f32; 3], + ceils: [f32; 3], +) -> PyResult>> { + let shape = img.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err( + "normalize_log_image needs an HxWx3 array", + )); + } + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| analysis::normalize_log_image(data, floors, ceils, dst)); + } + Ok(out) +} + +/// `_block_median_grid` of an HxWxC float32 image in `b` x `b` blocks. +#[pyfunction] +fn block_median_grid<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + b: usize, +) -> PyResult>> { + let [h, w, c] = img.shape()[..] else { + return Err(PyValueError::new_err( + "block_median_grid needs an HxWxC array", + )); + }; + if b == 0 { + return Err(PyValueError::new_err("block size must be positive")); + } + let data = slice(&img)?; + let grid = py.detach(|| analysis::block_median_grid(data, (h, w, c), b)); + numpy::PyArray1::from_vec(py, grid).reshape(vec![h / b, w / b, c]) +} + type ConsensusFields = ( analysis::Roi, f64, @@ -503,6 +549,8 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(refine_roi_to_image, m)?)?; m.add_function(wrap_pyfunction!(detect_film_bounds, m)?)?; m.add_function(wrap_pyfunction!(select_consensus, m)?)?; + m.add_function(wrap_pyfunction!(normalize_log_image, m)?)?; + m.add_function(wrap_pyfunction!(block_median_grid, m)?)?; m.add_function(wrap_pyfunction!(film_box_covers_enough, m)?)?; m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?) } diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index 7e7ecea91..da7001c25 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -179,6 +179,24 @@ pub fn np_percentiles_f64(values: &[f32], qs: &[f64]) -> Vec { .collect() } +/// `np.median` of float32 values: the middle one, or the float32 mean of the two middle ones; +/// NaN when any value is NaN or there are none. Reorders `values`. +pub fn np_median(values: &mut [f32]) -> f32 { + if values.is_empty() || values.iter().any(|v| v.is_nan()) { + return f32::NAN; + } + let n = values.len(); + let (_, &mut hi, _) = values.select_nth_unstable_by(n / 2, f32::total_cmp); + if n % 2 == 1 { + return hi; + } + let lo = values[..n / 2] + .iter() + .copied() + .fold(f32::NEG_INFINITY, f32::max); + (lo + hi) / 2.0 +} + /// NumPy's float32 sum of a contiguous run: one pairwise sum (blocks of 128, eight /// accumulators), the order `np.sum` and `mean(axis=-1)` use. pub fn pairwise_sum_f32(a: &[f32]) -> f32 { diff --git a/tests/test_numba_nogil.py b/tests/test_numba_nogil.py index 531f88df5..129732183 100644 --- a/tests/test_numba_nogil.py +++ b/tests/test_numba_nogil.py @@ -15,7 +15,6 @@ "_float_to_uint16_luma_jit", ) ] + [ - ("negpy.features.exposure.normalization", "_normalize_log_image_jit"), ("negpy.features.exposure.logic", "_apply_print_curve_kernel"), ("negpy.infrastructure.display.icc_lut", "_apply_lut_u16_jit"), ("negpy.infrastructure.display.icc_lut", "_apply_lut_f32_jit"), From 13b2666104579ebf72eff83ddc810c906d9b74ef Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:14:41 +0200 Subject: [PATCH 031/133] perf(core): faster radix percentile and normalize_to_uint8 select_ranks builds one histogram per thread-sized block, in four interleaved u32 lanes: on a smooth image neighbours share a bucket, and one counter serializes on its own increments. The gather tests each block of 64 values for a wanted bucket with a plain vector compare first. normalize_to_uint8 copies only when a value is not finite. np.percentile(q=(1, 99)) of a 1600x1059x3 frame, one pinned core: 10.1 ms to 4.1 ms. normalize_to_uint8: 17.4 ms to 6.7 ms. --- negpy_core/src/analysis.rs | 7 ++++- negpy_core/src/stats.rs | 64 ++++++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 61a82afe4..3ddc662ab 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -31,7 +31,12 @@ fn luminance_chunk(rgb: &[f32], dst: &mut [f32]) { /// `_normalize_to_uint8` for a float32 image: the 1st and 99th percentiles of its finite /// values stretched to 0..255, NumPy's float32 arithmetic (NEP 50) and truncating cast. pub fn normalize_to_uint8(img: &[f32], dst: &mut [u8]) { - let valid: Vec = img.iter().copied().filter(|v| v.is_finite()).collect(); + let finite = img.iter().fold(true, |all, v| all & v.is_finite()); + let valid: std::borrow::Cow<[f32]> = if finite { + img.into() + } else { + img.iter().copied().filter(|v| v.is_finite()).collect() + }; let Some(p) = percentiles(&valid, &[1.0, 99.0]) else { dst.fill(0); return; diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index da7001c25..0fa3a5ae6 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -54,17 +54,34 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { .collect(); } + // One histogram per thread-sized block: each costs a megabyte to clear and merge. Four + // interleaved u32 lanes, because on a smooth image neighbours share a bucket and one counter + // would serialize on its own increments. + let block = values + .len() + .div_ceil(rayon::current_num_threads()) + .max(CHUNK); let hist = values - .par_chunks(CHUNK) - .fold( - || vec![0usize; BUCKETS], - |mut h, chunk| { - for &v in chunk { - h[(order_key(v) >> 16) as usize] += 1; + .par_chunks(block) + .map(|chunk| { + let mut h = vec![0u32; 4 * BUCKETS]; + let (quads, rest) = chunk.as_chunks::<4>(); + for q in quads { + for (j, &v) in q.iter().enumerate() { + h[j * BUCKETS + (order_key(v) >> 16) as usize] += 1; } - h - }, - ) + } + for &v in rest { + h[(order_key(v) >> 16) as usize] += 1; + } + let mut sum = vec![0usize; BUCKETS]; + for lane in h.as_chunks::().0 { + sum.iter_mut() + .zip(lane) + .for_each(|(s, &c)| *s += c as usize); + } + sum + }) .reduce( || vec![0usize; BUCKETS], |mut a, b| { @@ -85,23 +102,30 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { located.push((bucket, r - below)); } - let mut slot_of = vec![u32::MAX; BUCKETS]; - let mut wanted: Vec = located.iter().map(|&(b, _)| b).collect(); + let mut wanted: Vec = located.iter().map(|&(b, _)| b as u32).collect(); wanted.dedup(); - for (slot, &b) in wanted.iter().enumerate() { - slot_of[b] = slot as u32; - } + let slot = |key: u32| wanted.iter().position(|&b| b == key >> 16); + // Matches are rare: a vectorized test per block skips the blocks with none. let gathered = values .par_chunks(CHUNK) .fold( || vec![Vec::new(); wanted.len()], |mut g: Vec>, chunk| { - for &v in chunk { - let key = order_key(v); - let slot = slot_of[(key >> 16) as usize]; - if slot != u32::MAX { - g[slot as usize].push(key); + for block in chunk.chunks(64) { + // One pass per wanted bucket keeps the test a plain vector compare. + let hit = wanted.iter().fold(false, |any, &b| { + any | block + .iter() + .fold(false, |a, &v| a | (order_key(v) >> 16 == b)) + }); + if hit { + for &v in block { + let key = order_key(v); + if let Some(i) = slot(key) { + g[i].push(key); + } + } } } g @@ -119,7 +143,7 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { located .iter() .map(|&(b, r)| { - let keys = &mut gathered[slot_of[b] as usize]; + let keys = &mut gathered[wanted.iter().position(|&w| w as usize == b).unwrap_or(0)]; from_key(*keys.select_nth_unstable(r).1) }) .collect() From 27a0e75ac16fa36a7b75a9e989f987ff428c4462 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:22:12 +0200 Subject: [PATCH 032/133] perf(core): port density_histogram and density_parade Both scopes count in one Rust pass over the stride-subsampled crop, with no copy when the crop rows are already packed RGB. The bin of each value is computed branch-free so it vectorizes: NumPy's float32 histogram index, corrected against the float32 linspace edges recomputed in float64; the parade's scan-density axis in float64. Counting goes to four interleaved lanes. Replay: density_histogram 60/60, density_parade 25/25. Bench (one pinned core): 0.457x and 0.285x. --- negpy/features/exposure/analysis.py | 41 +++--- negpy_core/manifest.toml | 8 +- negpy_core/src/analysis.rs | 2 +- negpy_core/src/histogram.rs | 218 ++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/histogram.rs | 63 ++++++++ negpy_core/src/py/mod.rs | 1 + 7 files changed, 309 insertions(+), 26 deletions(-) create mode 100644 negpy_core/src/histogram.rs create mode 100644 negpy_core/src/py/histogram.rs diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index d1fcf7ad1..92d596de5 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -643,19 +643,10 @@ def loupe_acutance(patch: Any) -> float: def density_histogram(normalized_log: np.ndarray, roi: Optional[Tuple[int, int, int, int]] = None) -> np.ndarray: """(4, DENSITY_HIST_BINS) [R, G, B, L] val-domain occupancy; out-of-range mass lands in the edge bins. `roi` is the crop rect (y1, y2, x1, x2) in the same frame as `normalized_log`.""" - img = normalized_log - if roi is not None: - y1, y2, x1, x2 = roi - img = img[y1:y2, x1:x2] - step = max(1, round(np.sqrt(img.shape[0] * img.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - img = img[::step, ::step] - img = np.ascontiguousarray(img) - lum = get_luminance(img) - lo, hi = DENSITY_HIST_RANGE - rows = [np.histogram(np.clip(img[..., c], lo, hi), bins=DENSITY_HIST_BINS, range=DENSITY_HIST_RANGE)[0] for c in range(3)] - rows.append(np.histogram(np.clip(lum, lo, hi), bins=DENSITY_HIST_BINS, range=DENSITY_HIST_RANGE)[0]) - return np.stack(rows).astype(np.float64) + crop = _sample_crop(normalized_log.shape, roi) + if crop is None: + return np.zeros((4, DENSITY_HIST_BINS)) + return negpy_core.density_histogram(np.ascontiguousarray(normalized_log, dtype=np.float32), *crop) def scan_density_axis(density: Any) -> Any: @@ -671,6 +662,19 @@ def scan_density_of_val(val: Any, bounds: Any) -> np.ndarray: return -(floors + np.asarray(val, dtype=np.float64)[:3] * (ceils - floors)) +def _sample_crop(shape: Tuple[int, ...], roi: Optional[Tuple[int, int, int, int]]) -> Optional[Tuple[Tuple[int, int, int, int], int]]: + """The crop (y1, y2, x1, x2) as numpy slicing clamps it, and the stride that keeps it near + _MAX_HIST_SAMPLES samples; None for an empty crop.""" + h, w = shape[:2] + y1, y2, x1, x2 = roi if roi is not None else (0, h, 0, w) + y1, y2, _ = slice(y1, y2).indices(h) + x1, x2, _ = slice(x1, x2).indices(w) + if y2 <= y1 or x2 <= x1: + return None + step = max(1, round(np.sqrt((y2 - y1) * (x2 - x1) / _MAX_HIST_SAMPLES))) + return (y1, y2, x1, x2), step + + def _parade_samples(img: np.ndarray, roi: Optional[Tuple[int, int, int, int]]) -> Optional[Tuple[np.ndarray, np.ndarray]]: """The crop's RGB, stride-subsampled, and each sample column's parade column.""" if roi is not None: @@ -694,15 +698,10 @@ def density_parade(img_log: Any, roi: Optional[Tuple[int, int, int, int]] = None img = np.asarray(img_log) if img.ndim != 3 or img.shape[-1] < 3: return None - sampled = _parade_samples(img, roi) - if sampled is None: + crop = _sample_crop(img.shape, roi) + if crop is None: return None - img, cols = sampled - pos = scan_density_axis(-np.nan_to_num(img.astype(np.float32))) - bins = np.clip(np.floor(pos * WAVEFORM_BINS), 0, WAVEFORM_BINS - 1).astype(np.int64) - flat_cols = np.broadcast_to(cols[None, :], bins.shape[:2]).ravel() - counts = [np.bincount(bins[..., c].ravel() * WAVEFORM_COLS + flat_cols, minlength=WAVEFORM_BINS * WAVEFORM_COLS) for c in range(3)] - return np.stack(counts).reshape(3, WAVEFORM_BINS, WAVEFORM_COLS).astype(float) + return negpy_core.density_parade(np.ascontiguousarray(img, dtype=np.float32), *crop) def scan_full_scale_columns(image: Any, roi: Optional[Tuple[int, int, int, int]] = None) -> Optional[np.ndarray]: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index d3fb3bc98..b088b6fe8 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -35,14 +35,14 @@ depends = [] ["features/exposure/analysis.py::density_histogram"] phase = 2 -status = "todo" -calls = ["np.histogram"] +status = "ported" +calls = ["negpy_core.density_histogram"] depends = [] ["features/exposure/analysis.py::density_parade"] phase = 2 -status = "todo" -calls = ["np.bincount"] +status = "ported" +calls = ["negpy_core.density_parade"] depends = [] ["features/exposure/analysis.py::encoded_of_zone"] diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 3ddc662ab..bf8c3d7e1 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -20,7 +20,7 @@ pub fn luminance(rgb: &[f32], dst: &mut [f32]) { /// `mul_add` is one instruction only where the CPU has FMA, so this compiles twice. #[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] -fn luminance_chunk(rgb: &[f32], dst: &mut [f32]) { +pub(crate) fn luminance_chunk(rgb: &[f32], dst: &mut [f32]) { for (d, p) in dst.iter_mut().zip(rgb.as_chunks::<3>().0) { let t = LUMA_R * p[0] as f64; let t = LUMA_G.mul_add(p[1] as f64, t); diff --git a/negpy_core/src/histogram.rs b/negpy_core/src/histogram.rs new file mode 100644 index 000000000..0e912c321 --- /dev/null +++ b/negpy_core/src/histogram.rs @@ -0,0 +1,218 @@ +//! The scope counts of negpy/features/exposure/analysis.py: integer counts, so only the bin each +//! sample lands in must match NumPy. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::analysis::luminance_chunk; + +pub const DENSITY_HIST_BINS: usize = 120; +const DENSITY_HIST_RANGE: (f64, f64) = (-0.1, 1.1); +pub const WAVEFORM_BINS: usize = 128; +pub const WAVEFORM_COLS: usize = 256; +const SCAN_DENSITY_RANGE: (f64, f64) = (0.0, 3.0); + +/// A stride-subsampled crop of an HxWxC float32 image: rows `y1..y2` and columns `x1..x2`, every +/// `step`-th from the first, as `img[y1:y2:step, x1:x2:step]` reads them. +#[derive(Clone, Copy)] +pub struct Samples<'a> { + pub img: &'a [f32], + pub w: usize, + pub cn: usize, + pub rows: (usize, usize), + pub cols: (usize, usize), + pub step: usize, +} + +impl Samples<'_> { + fn row_starts(&self) -> Vec { + (self.rows.0..self.rows.1).step_by(self.step).collect() + } + + /// The first three channels of row `y`'s samples, packed: a slice of the image when they + /// already are, else a copy in `buf`. + fn rgb_row<'b>(&'b self, y: usize, buf: &'b mut Vec) -> &'b [f32] { + if self.step == 1 && self.cn == 3 { + return &self.img[(y * self.w + self.cols.0) * 3..(y * self.w + self.cols.1) * 3]; + } + buf.clear(); + for x in (self.cols.0..self.cols.1).step_by(self.step) { + let p = (y * self.w + x) * self.cn; + buf.extend_from_slice(&self.img[p..p + 3]); + } + buf + } +} + +/// `np.histogram(np.clip(values, lo, hi), bins, range=(lo, hi))` for float32 values with Python +/// float edges: NumPy bins in float32 (NEP 50), then corrects each index against the float32 +/// `np.linspace` edges, `f32(i * step + lo)`. NaN is dropped. +#[derive(Clone, Copy)] +struct Binner { + lo: f64, + hi: f64, + bins: usize, +} + +const NO_BIN: u8 = u8::MAX; + +impl Binner { + /// Each value's bin, or NO_BIN for NaN; branch-free, so it vectorizes. + fn bin_into(self, vals: &[f32], out: &mut [u8]) { + bin_into(self, vals, out) + } +} + +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn bin_into(b: Binner, vals: &[f32], out: &mut [u8]) { + let (lo, hi, n) = (b.lo as f32, b.hi as f32, b.bins as i32); + let (denom, step) = ((b.hi - b.lo) as f32, (b.hi - b.lo) / b.bins as f64); + let edge = |i: i32| (i as f64 * step + b.lo) as f32; + for (o, &v) in out.iter_mut().zip(vals) { + let c = v.max(lo).min(hi); + let i = ((((c - lo) / denom) * n as f32) as i32).min(n - 1); + let down = c < edge(i); + let up = !down & (i != n - 1) & (c >= edge(i + 1)); + let i = i - down as i32 + up as i32; + *o = if v.is_nan() { NO_BIN } else { i as u8 }; + } +} + +/// The parade bin of each value: np.nan_to_num and the negation in float32, the scan density +/// axis in float64, floored and clipped. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn parade_bins(vals: &[f32], out: &mut [u8]) { + let (lo, hi) = SCAN_DENSITY_RANGE; + for (o, &v) in out.iter_mut().zip(vals) { + let v = if v.is_nan() { + 0.0 + } else { + v.clamp(f32::MIN, f32::MAX) + }; + let pos = (hi - (-v) as f64) / (hi - lo); + *o = (pos * WAVEFORM_BINS as f64) + .floor() + .clamp(0.0, (WAVEFORM_BINS - 1) as f64) as u8; + } +} + +/// Neighbouring samples often share a bin, and one counter serializes on its own increments, +/// so each sample counts into lane `i % LANES` (u32: a row never overflows one). +const LANES: usize = 4; + +fn fold_lanes(lanes: &[u32]) -> Vec { + let n = lanes.len() / LANES; + let mut out = vec![0u64; n]; + for lane in lanes.chunks_exact(n) { + out.iter_mut().zip(lane).for_each(|(o, &c)| *o += c as u64); + } + out +} + +/// `density_histogram`: (4, DENSITY_HIST_BINS) counts of R, G, B and luminance. +pub fn density_histogram(s: Samples) -> Vec { + let binner = Binner { + lo: DENSITY_HIST_RANGE.0, + hi: DENSITY_HIST_RANGE.1, + bins: DENSITY_HIST_BINS, + }; + let counts = s + .row_starts() + .par_iter() + .fold( + || { + let counts = vec![0u32; LANES * 4 * DENSITY_HIST_BINS]; + (counts, Vec::new(), Vec::new(), Vec::new(), Vec::new()) + }, + |(mut counts, mut buf, mut lum, mut bins, mut lbins), &y| { + let rgb = s.rgb_row(y, &mut buf); + let px = rgb.len() / 3; + lum.resize(px, 0.0); + luminance_chunk(rgb, &mut lum); + bins.resize(3 * px, 0); + lbins.resize(px, 0); + binner.bin_into(rgb, &mut bins); + binner.bin_into(&lum, &mut lbins); + for (i, (p, &l)) in bins.as_chunks::<3>().0.iter().zip(&lbins).enumerate() { + let lane = (i % LANES) * 4 * DENSITY_HIST_BINS; + for (c, &b) in p.iter().chain([&l]).enumerate() { + if b != NO_BIN { + counts[lane + c * DENSITY_HIST_BINS + b as usize] += 1; + } + } + } + (counts, buf, lum, bins, lbins) + }, + ) + .map(|(counts, ..)| fold_lanes(&counts)) + .reduce( + || vec![0u64; 4 * DENSITY_HIST_BINS], + |mut a, b| { + a.iter_mut().zip(&b).for_each(|(x, y)| *x += y); + a + }, + ); + counts.iter().map(|&c| c as f64).collect() +} + +/// `density_parade`: (3, WAVEFORM_BINS, WAVEFORM_COLS) counts of each channel's scan density +/// (the negated log) per parade column, the column of the sample's x in the crop. +pub fn density_parade(s: Samples) -> Vec { + let full_w = s.cols.1 - s.cols.0; + let cols: Vec = (0..full_w) + .step_by(s.step) + .map(|x| (x * WAVEFORM_COLS / full_w).min(WAVEFORM_COLS - 1)) + .collect(); + let plane = WAVEFORM_BINS * WAVEFORM_COLS; + let counts = s + .row_starts() + .par_iter() + .fold( + || (vec![0u32; LANES * 3 * plane], Vec::new(), Vec::new()), + |(mut counts, mut buf, mut bins), &y| { + let rgb = s.rgb_row(y, &mut buf); + bins.resize(rgb.len(), 0); + parade_bins(rgb, &mut bins); + for (i, (p, &col)) in bins.as_chunks::<3>().0.iter().zip(&cols).enumerate() { + let lane = (i % LANES) * 3 * plane; + for (c, &b) in p.iter().enumerate() { + counts[lane + c * plane + b as usize * WAVEFORM_COLS + col] += 1; + } + } + (counts, buf, bins) + }, + ) + .map(|(counts, ..)| fold_lanes(&counts)) + .reduce( + || vec![0u64; 3 * plane], + |mut a, b| { + a.iter_mut().zip(&b).for_each(|(x, y)| *x += y); + a + }, + ); + counts.iter().map(|&c| c as f64).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edges_and_extremes_land_in_the_edge_bins() { + let b = Binner { + lo: DENSITY_HIST_RANGE.0, + hi: DENSITY_HIST_RANGE.1, + bins: DENSITY_HIST_BINS, + }; + let step = (b.hi - b.lo) / b.bins as f64; + let mut vals = vec![-5.0, 5.0, 1.1, f32::NAN]; + vals.extend((0..DENSITY_HIST_BINS).map(|i| (i as f64 * step + b.lo) as f32)); + let mut out = vec![0u8; vals.len()]; + b.bin_into(&vals, &mut out); + let last = (DENSITY_HIST_BINS - 1) as u8; + assert_eq!(out[..4], [0, last, last, NO_BIN]); + for (i, &o) in out[4..].iter().enumerate() { + assert_eq!(o as usize, i, "edge {i}"); + } + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index ca9b18aa0..ee6e50d27 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -3,6 +3,7 @@ pub mod contours; pub mod detect; pub mod filter; pub mod gray; +pub mod histogram; pub mod morph; pub mod resize; pub mod shapes; @@ -31,5 +32,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::morph::register(m)?; py::gray::register(m)?; py::contours::register(m)?; + py::histogram::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/py/histogram.rs b/negpy_core/src/py/histogram.rs new file mode 100644 index 000000000..e79016abc --- /dev/null +++ b/negpy_core/src/py/histogram.rs @@ -0,0 +1,63 @@ +use numpy::{PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::histogram::{self, DENSITY_HIST_BINS, Samples, WAVEFORM_BINS, WAVEFORM_COLS}; + +type Counts<'py> = Bound<'py, numpy::PyArrayDyn>; + +/// The samples `img[y1:y2:step, x1:x2:step, :3]` of an HxWxC float32 image, C >= 3. +fn samples<'a>( + img: &'a PyReadonlyArrayDyn<'_, f32>, + (y1, y2, x1, x2): (usize, usize, usize, usize), + step: usize, +) -> PyResult> { + let [h, w, cn] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC array")); + }; + if cn < 3 || step == 0 || y2 > h || x2 > w || y1 >= y2 || x1 >= x2 { + return Err(PyValueError::new_err("invalid channels, crop or step")); + } + let img = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok(Samples { + img, + w, + cn, + rows: (y1, y2), + cols: (x1, x2), + step, + }) +} + +/// `density_histogram` of a crop (y1, y2, x1, x2) sampled every `step`: (4, bins) counts. +#[pyfunction] +fn density_histogram<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + roi: (usize, usize, usize, usize), + step: usize, +) -> PyResult> { + let s = samples(&img, roi, step)?; + let counts = py.detach(|| histogram::density_histogram(s)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![4, DENSITY_HIST_BINS]) +} + +/// `density_parade` of a crop (y1, y2, x1, x2) sampled every `step`: (3, bins, cols) counts. +#[pyfunction] +fn density_parade<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + roi: (usize, usize, usize, usize), + step: usize, +) -> PyResult> { + let s = samples(&img, roi, step)?; + let counts = py.detach(|| histogram::density_parade(s)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_BINS, WAVEFORM_COLS]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(density_histogram, m)?)?; + m.add_function(wrap_pyfunction!(density_parade, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 8c9a0a6f4..b5fe8eefc 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -2,6 +2,7 @@ pub mod analysis; pub mod contours; pub mod filter; pub mod gray; +pub mod histogram; pub mod morph; pub mod resize; pub mod stats; From bb71c9e1c5a0d12ab46e019079ea72d4831523ce Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:26:52 +0200 Subject: [PATCH 033/133] perf(core): port the CPU scopes (output, color, waveforms, full scale) output_histogram (float32 and uint8), color_histogram, waveform, waveform_color, waveform_parade and scan_full_scale_columns count in one Rust pass each, sharing count_rows (rayon rows, four u32 lanes per cell). NumPy's x86 float-to-int32 cast is copied: NaN and out-of-range values become INT_MIN, then clip to bin 0. density_histogram and density_parade move onto count_rows too. Replay: scan_full_scale_columns 25/25. The others have no corpus; they match the old Python on 22 buffers each (recorded frames, NaN, +-inf, huge values, exact edges, uint8, 4 channels, 1x1, strided sizes). One pinned core, 1600x1059: the CPU render's scopes take 42 ms instead of 94 ms (0.24x to 0.73x each). --- negpy/features/exposure/analysis.py | 102 +++-------- negpy_core/manifest.toml | 38 ++-- negpy_core/src/histogram.rs | 259 +++++++++++++++++++++------- negpy_core/src/py/histogram.rs | 95 +++++++++- 4 files changed, 336 insertions(+), 158 deletions(-) diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index 92d596de5..31517503c 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -45,32 +45,24 @@ _MAX_HIST_SAMPLES = 2_000_000 +def _frame_step(shape: Tuple[int, ...], max_samples: int) -> int: + """The stride that keeps a whole frame near `max_samples` samples.""" + return max(1, round(np.sqrt(shape[0] * shape[1] / max_samples))) + + def output_histogram(buffer: Any) -> Optional[np.ndarray]: - """(4, 256) [R, G, B, L] counts from a (4, 256) bin array or an H×W×3 encoded buffer.""" + """(4, 256) [R, G, B, L] counts from a (4, 256) bin array or an H×W×3 encoded buffer. An + 8-bit frame (the camera live view) bins by value: the same bins as the float path's + floor(v / 255 * 256).""" if buffer is None: return None buffer = np.asarray(buffer) if buffer.ndim == 2 and buffer.shape == (4, OUTPUT_HIST_BINS): return buffer.astype(float) - if buffer.ndim != 3 or buffer.shape[-1] != 3: + if buffer.ndim != 3 or buffer.shape[-1] != 3 or min(buffer.shape[:2]) < 1: return None - step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - if buffer.dtype == np.uint8: - # An 8-bit frame (the camera live view) bins by value: the same bins as the float - # path's floor(v / 255 * 256). - rows = [np.bincount(buffer[..., c].ravel(), minlength=OUTPUT_HIST_BINS) for c in range(3)] - lum = get_luminance(np.ascontiguousarray(buffer.astype(np.float32) / 255.0)) - rows.append( - np.bincount(np.minimum((lum * OUTPUT_HIST_BINS).astype(np.int64), OUTPUT_HIST_BINS - 1).ravel(), minlength=OUTPUT_HIST_BINS) - ) - return np.stack(rows).astype(float) - buffer = np.ascontiguousarray(buffer.astype(np.float32, copy=False)) - lum = get_luminance(buffer) - rows = [np.histogram(buffer[..., c], bins=OUTPUT_HIST_BINS, range=(0, 1))[0] for c in range(3)] - rows.append(np.histogram(lum, bins=OUTPUT_HIST_BINS, range=(0, 1))[0]) - return np.stack(rows).astype(float) + dtype = np.uint8 if buffer.dtype == np.uint8 else np.float32 + return negpy_core.output_histogram(np.ascontiguousarray(buffer, dtype=dtype), _frame_step(buffer.shape, _MAX_HIST_SAMPLES)) def color_histogram(buffer: Any) -> Optional[np.ndarray]: @@ -81,15 +73,9 @@ def color_histogram(buffer: Any) -> Optional[np.ndarray]: buffer = np.asarray(buffer) if buffer.ndim == 3 and buffer.shape == (COLOR_HIST_BINS,) * 3: return buffer.astype(float) - if buffer.ndim != 3 or buffer.shape[-1] != 3: + if buffer.ndim != 3 or buffer.shape[-1] != 3 or min(buffer.shape[:2]) < 1: return None - step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - q = np.clip((np.asarray(buffer, dtype=np.float32) * COLOR_HIST_BINS).astype(np.int32), 0, COLOR_HIST_BINS - 1) - flat = (q[..., 0] * COLOR_HIST_BINS + q[..., 1]) * COLOR_HIST_BINS + q[..., 2] - counts = np.bincount(flat.ravel(), minlength=COLOR_HIST_BINS**3) - return counts.reshape((COLOR_HIST_BINS,) * 3).astype(float) + return negpy_core.color_histogram(np.ascontiguousarray(buffer, dtype=np.float32), _frame_step(buffer.shape, _MAX_HIST_SAMPLES)) def gamut_fraction(color_hist: Any, out_of_gamut: Any) -> Optional[float]: @@ -109,56 +95,32 @@ def gamut_fraction(color_hist: Any, out_of_gamut: Any) -> Optional[float]: return float(hist[mask].sum() / total) if total > 0 else None -def _waveform_cells(buffer: Any) -> Optional[Tuple[np.ndarray, np.ndarray]]: - """(sampled H×W×3 float32 buffer, flat bin * WAVEFORM_COLS + col index per pixel).""" +def _waveform(buffer: Any, kind: str) -> Optional[np.ndarray]: + """The waveform counts or sums of an H×W×3 encoded buffer, binned by float32 luma or channel.""" if buffer is None: return None buffer = np.asarray(buffer) if buffer.ndim != 3 or buffer.shape[-1] < 3 or min(buffer.shape[:2]) < 1: return None - step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - rgb = np.ascontiguousarray(buffer[..., :3], dtype=np.float32) - lum = get_luminance(rgb) - w = lum.shape[1] - cols = np.minimum(np.arange(w) * WAVEFORM_COLS // w, WAVEFORM_COLS - 1) - bins = np.clip((np.nan_to_num(lum) * WAVEFORM_BINS).astype(np.int32), 0, WAVEFORM_BINS - 1) - return rgb, (bins * WAVEFORM_COLS + cols[None, :]).ravel() + return negpy_core.waveform(np.ascontiguousarray(buffer, dtype=np.float32), _frame_step(buffer.shape, _MAX_HIST_SAMPLES), kind) def waveform(buffer: Any) -> Optional[np.ndarray]: """(WAVEFORM_BINS, WAVEFORM_COLS) luma counts per image column of an H×W×3 encoded buffer. CPU counterpart of waveform.wgsl.""" - cells = _waveform_cells(buffer) - if cells is None: - return None - return np.bincount(cells[1], minlength=WAVEFORM_BINS * WAVEFORM_COLS).reshape(WAVEFORM_BINS, WAVEFORM_COLS).astype(float) + return _waveform(buffer, "luma") def waveform_color(buffer: Any) -> Optional[np.ndarray]: """(3, WAVEFORM_BINS, WAVEFORM_COLS) fixed-point sums of the encoded R, G, B in each waveform cell. CPU counterpart of the sums in waveform.wgsl.""" - cells = _waveform_cells(buffer) - if cells is None: - return None - rgb, flat = cells - fixed = np.round(np.clip(np.nan_to_num(rgb.reshape(-1, 3)), 0.0, 1.0) * WAVEFORM_COLOR_SCALE) - sums = [np.bincount(flat, weights=fixed[:, c], minlength=WAVEFORM_BINS * WAVEFORM_COLS) for c in range(3)] - return np.stack(sums).reshape(3, WAVEFORM_BINS, WAVEFORM_COLS) + return _waveform(buffer, "color") def waveform_parade(buffer: Any) -> Optional[np.ndarray]: """(3, WAVEFORM_BINS, WAVEFORM_COLS) counts of each encoded channel per image column: the RGB parade. CPU counterpart of the parade counts in waveform.wgsl.""" - cells = _waveform_cells(buffer) - if cells is None: - return None - rgb, flat = cells - cols = flat % WAVEFORM_COLS - bins = np.clip((np.nan_to_num(rgb.reshape(-1, 3)) * WAVEFORM_BINS).astype(np.int32), 0, WAVEFORM_BINS - 1) - counts = [np.bincount(bins[:, c] * WAVEFORM_COLS + cols, minlength=WAVEFORM_BINS * WAVEFORM_COLS) for c in range(3)] - return np.stack(counts).reshape(3, WAVEFORM_BINS, WAVEFORM_COLS).astype(float) + return _waveform(buffer, "parade") def scope_radius(chroma: Any, log: bool) -> Any: @@ -671,21 +633,7 @@ def _sample_crop(shape: Tuple[int, ...], roi: Optional[Tuple[int, int, int, int] x1, x2, _ = slice(x1, x2).indices(w) if y2 <= y1 or x2 <= x1: return None - step = max(1, round(np.sqrt((y2 - y1) * (x2 - x1) / _MAX_HIST_SAMPLES))) - return (y1, y2, x1, x2), step - - -def _parade_samples(img: np.ndarray, roi: Optional[Tuple[int, int, int, int]]) -> Optional[Tuple[np.ndarray, np.ndarray]]: - """The crop's RGB, stride-subsampled, and each sample column's parade column.""" - if roi is not None: - y1, y2, x1, x2 = roi - img = img[y1:y2, x1:x2] - if min(img.shape[:2]) < 1: - return None - full_w = img.shape[1] - step = max(1, round(np.sqrt(img.shape[0] * full_w / _MAX_HIST_SAMPLES))) - cols = np.minimum(np.arange(0, full_w, step) * WAVEFORM_COLS // full_w, WAVEFORM_COLS - 1) - return img[::step, ::step, :3], cols + return (y1, y2, x1, x2), _frame_step((y2 - y1, x2 - x1), _MAX_HIST_SAMPLES) def density_parade(img_log: Any, roi: Optional[Tuple[int, int, int, int]] = None) -> Optional[np.ndarray]: @@ -715,10 +663,8 @@ def scan_full_scale_columns(image: Any, roi: Optional[Tuple[int, int, int, int]] img = np.asarray(image) if img.ndim != 3 or img.shape[-1] < 3: return None - sampled = _parade_samples(img, roi) - if sampled is None: + crop = _sample_crop(img.shape, roi) + if crop is None: return None - img, cols = sampled - clipped = img >= float(EXPOSURE_CONSTANTS["scan_clip_level"]) - flat_cols = np.broadcast_to(cols[None, :], clipped.shape[:2]).ravel() - return np.stack([np.bincount(flat_cols, weights=clipped[..., c].ravel().astype(np.float64), minlength=WAVEFORM_COLS) for c in range(3)]) + level = float(EXPOSURE_CONSTANTS["scan_clip_level"]) + return negpy_core.scan_full_scale_columns(np.ascontiguousarray(img, dtype=np.float32), *crop, level) diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index b088b6fe8..66d7b261d 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -21,16 +21,22 @@ status = "todo" calls = ["np.bincount"] depends = [] -["features/exposure/analysis.py::_waveform_cells"] +["features/exposure/analysis.py::_waveform"] phase = 2 status = "todo" +calls = ["negpy_core.waveform"] +depends = [] + +["features/exposure/analysis.py::_waveform_cells"] +phase = 2 +status = "ported" calls = [] depends = [] ["features/exposure/analysis.py::color_histogram"] phase = 2 -status = "todo" -calls = ["np.bincount"] +status = "ported" +calls = ["negpy_core.color_histogram"] depends = [] ["features/exposure/analysis.py::density_histogram"] @@ -59,8 +65,8 @@ depends = [] ["features/exposure/analysis.py::output_histogram"] phase = 2 -status = "todo" -calls = ["np.bincount", "np.histogram"] +status = "ported" +calls = ["negpy_core.output_histogram"] depends = [] ["features/exposure/analysis.py::paper_grid_mosaic"] @@ -71,8 +77,8 @@ depends = [] ["features/exposure/analysis.py::scan_full_scale_columns"] phase = 2 -status = "todo" -calls = ["np.bincount"] +status = "ported" +calls = ["negpy_core.scan_full_scale_columns"] depends = [] ["features/exposure/analysis.py::scope_point"] @@ -95,21 +101,21 @@ depends = [] ["features/exposure/analysis.py::waveform"] phase = 2 -status = "todo" -calls = ["np.bincount"] -depends = ["_waveform_cells"] +status = "ported" +calls = [] +depends = ["_waveform"] ["features/exposure/analysis.py::waveform_color"] phase = 2 -status = "todo" -calls = ["np.bincount"] -depends = ["_waveform_cells"] +status = "ported" +calls = [] +depends = ["_waveform"] ["features/exposure/analysis.py::waveform_parade"] phase = 2 -status = "todo" -calls = ["np.bincount"] -depends = ["_waveform_cells"] +status = "ported" +calls = [] +depends = ["_waveform"] ["features/exposure/analysis.py::zone_anchors"] phase = 2 diff --git a/negpy_core/src/histogram.rs b/negpy_core/src/histogram.rs index 0e912c321..8c0ecdd63 100644 --- a/negpy_core/src/histogram.rs +++ b/negpy_core/src/histogram.rs @@ -10,13 +10,16 @@ pub const DENSITY_HIST_BINS: usize = 120; const DENSITY_HIST_RANGE: (f64, f64) = (-0.1, 1.1); pub const WAVEFORM_BINS: usize = 128; pub const WAVEFORM_COLS: usize = 256; +pub const OUTPUT_HIST_BINS: usize = 256; +pub const COLOR_HIST_BINS: usize = 32; +const WAVEFORM_COLOR_SCALE: f32 = 1023.0; const SCAN_DENSITY_RANGE: (f64, f64) = (0.0, 3.0); /// A stride-subsampled crop of an HxWxC float32 image: rows `y1..y2` and columns `x1..x2`, every /// `step`-th from the first, as `img[y1:y2:step, x1:x2:step]` reads them. #[derive(Clone, Copy)] -pub struct Samples<'a> { - pub img: &'a [f32], +pub struct Samples<'a, T = f32> { + pub img: &'a [T], pub w: usize, pub cn: usize, pub rows: (usize, usize), @@ -24,14 +27,14 @@ pub struct Samples<'a> { pub step: usize, } -impl Samples<'_> { +impl Samples<'_, T> { fn row_starts(&self) -> Vec { (self.rows.0..self.rows.1).step_by(self.step).collect() } /// The first three channels of row `y`'s samples, packed: a slice of the image when they /// already are, else a copy in `buf`. - fn rgb_row<'b>(&'b self, y: usize, buf: &'b mut Vec) -> &'b [f32] { + fn rgb_row<'b>(&'b self, y: usize, buf: &'b mut Vec) -> &'b [T] { if self.step == 1 && self.cn == 3 { return &self.img[(y * self.w + self.cols.0) * 3..(y * self.w + self.cols.1) * 3]; } @@ -97,7 +100,7 @@ fn parade_bins(vals: &[f32], out: &mut [u8]) { } /// Neighbouring samples often share a bin, and one counter serializes on its own increments, -/// so each sample counts into lane `i % LANES` (u32: a row never overflows one). +/// so each sample counts into lane `i % LANES` (u32: a cell holds one column's samples at most). const LANES: usize = 4; fn fold_lanes(lanes: &[u32]) -> Vec { @@ -116,43 +119,22 @@ pub fn density_histogram(s: Samples) -> Vec { hi: DENSITY_HIST_RANGE.1, bins: DENSITY_HIST_BINS, }; - let counts = s - .row_starts() - .par_iter() - .fold( - || { - let counts = vec![0u32; LANES * 4 * DENSITY_HIST_BINS]; - (counts, Vec::new(), Vec::new(), Vec::new(), Vec::new()) - }, - |(mut counts, mut buf, mut lum, mut bins, mut lbins), &y| { - let rgb = s.rgb_row(y, &mut buf); - let px = rgb.len() / 3; - lum.resize(px, 0.0); - luminance_chunk(rgb, &mut lum); - bins.resize(3 * px, 0); - lbins.resize(px, 0); - binner.bin_into(rgb, &mut bins); - binner.bin_into(&lum, &mut lbins); - for (i, (p, &l)) in bins.as_chunks::<3>().0.iter().zip(&lbins).enumerate() { - let lane = (i % LANES) * 4 * DENSITY_HIST_BINS; - for (c, &b) in p.iter().chain([&l]).enumerate() { - if b != NO_BIN { - counts[lane + c * DENSITY_HIST_BINS + b as usize] += 1; - } - } + count_rows(s, 4 * DENSITY_HIST_BINS, |rgb, counts| { + let px = rgb.len() / 3; + let mut lum = vec![0f32; px]; + luminance_chunk(rgb, &mut lum); + let (mut bins, mut lbins) = (vec![0u8; 3 * px], vec![0u8; px]); + binner.bin_into(rgb, &mut bins); + binner.bin_into(&lum, &mut lbins); + for (i, (p, &l)) in bins.as_chunks::<3>().0.iter().zip(&lbins).enumerate() { + let lane = (i % LANES) * 4 * DENSITY_HIST_BINS; + for (c, &b) in p.iter().chain([&l]).enumerate() { + if b != NO_BIN { + counts[lane + c * DENSITY_HIST_BINS + b as usize] += 1; } - (counts, buf, lum, bins, lbins) - }, - ) - .map(|(counts, ..)| fold_lanes(&counts)) - .reduce( - || vec![0u64; 4 * DENSITY_HIST_BINS], - |mut a, b| { - a.iter_mut().zip(&b).for_each(|(x, y)| *x += y); - a - }, - ); - counts.iter().map(|&c| c as f64).collect() + } + } + }) } /// `density_parade`: (3, WAVEFORM_BINS, WAVEFORM_COLS) counts of each channel's scan density @@ -164,33 +146,192 @@ pub fn density_parade(s: Samples) -> Vec { .map(|x| (x * WAVEFORM_COLS / full_w).min(WAVEFORM_COLS - 1)) .collect(); let plane = WAVEFORM_BINS * WAVEFORM_COLS; - let counts = s - .row_starts() + count_rows(s, 3 * plane, |rgb, counts| { + let mut bins = vec![0u8; rgb.len()]; + parade_bins(rgb, &mut bins); + for (i, (p, &col)) in bins.as_chunks::<3>().0.iter().zip(&cols).enumerate() { + let lane = (i % LANES) * 3 * plane; + for (c, &b) in p.iter().enumerate() { + counts[lane + c * plane + b as usize * WAVEFORM_COLS + col] += 1; + } + } + }) +} + +/// NumPy's float32 to int32 cast on x86: truncation, and INT_MIN for NaN or out of range. +fn np_i32(f: f32) -> i32 { + if (-2147483648.0..2147483648.0).contains(&f) { + f as i32 + } else { + i32::MIN + } +} + +/// np.nan_to_num for float32. +fn nan_to_num(v: f32) -> f32 { + if v.is_nan() { + 0.0 + } else { + v.clamp(f32::MIN, f32::MAX) + } +} + +/// `clip((v * bins).astype(int32), 0, bins - 1)` in float32. +fn quantize(v: f32, bins: usize) -> usize { + np_i32(v * bins as f32).clamp(0, bins as i32 - 1) as usize +} + +/// Counts over the rows of `s` in parallel: `count(row, counts)` adds one row into `n` lanes' +/// worth of u32 counters, which fold into one u64 count per cell. +fn count_rows( + s: Samples<'_, T>, + n: usize, + count: impl Fn(&[T], &mut [u32]) + Sync, +) -> Vec { + s.row_starts() .par_iter() .fold( - || (vec![0u32; LANES * 3 * plane], Vec::new(), Vec::new()), - |(mut counts, mut buf, mut bins), &y| { - let rgb = s.rgb_row(y, &mut buf); - bins.resize(rgb.len(), 0); - parade_bins(rgb, &mut bins); - for (i, (p, &col)) in bins.as_chunks::<3>().0.iter().zip(&cols).enumerate() { - let lane = (i % LANES) * 3 * plane; - for (c, &b) in p.iter().enumerate() { - counts[lane + c * plane + b as usize * WAVEFORM_COLS + col] += 1; - } - } - (counts, buf, bins) + || (vec![0u32; LANES * n], Vec::new()), + |(mut counts, mut buf), &y| { + count(s.rgb_row(y, &mut buf), &mut counts); + (counts, buf) }, ) - .map(|(counts, ..)| fold_lanes(&counts)) + .map(|(counts, _)| fold_lanes(&counts)) .reduce( - || vec![0u64; 3 * plane], + || vec![0u64; n], |mut a, b| { a.iter_mut().zip(&b).for_each(|(x, y)| *x += y); a }, - ); - counts.iter().map(|&c| c as f64).collect() + ) + .iter() + .map(|&c| c as f64) + .collect() +} + +/// `output_histogram` of float32 samples: (4, 256) counts of R, G, B and luminance on 0..1. +/// `np.histogram(range=(0, 1))` divides by a uint64 span, so it bins in float64, where the +/// edges i / 256 are exact; values outside 0..1 and NaN are dropped. +pub fn output_histogram(s: Samples) -> Vec { + let bin = |v: f32| { + (0.0..=1.0) + .contains(&v) + .then(|| ((v as f64 * OUTPUT_HIST_BINS as f64) as usize).min(OUTPUT_HIST_BINS - 1)) + }; + count_rows(s, 4 * OUTPUT_HIST_BINS, |rgb, counts| { + let mut lum = vec![0f32; rgb.len() / 3]; + luminance_chunk(rgb, &mut lum); + for (i, (p, &l)) in rgb.as_chunks::<3>().0.iter().zip(&lum).enumerate() { + let lane = (i % LANES) * 4 * OUTPUT_HIST_BINS; + for (c, &v) in p.iter().chain([&l]).enumerate() { + if let Some(b) = bin(v) { + counts[lane + c * OUTPUT_HIST_BINS + b] += 1; + } + } + } + }) +} + +/// `output_histogram` of uint8 samples: each channel by value, luminance of value / 255. +pub fn output_histogram_u8(s: Samples<'_, u8>) -> Vec { + count_rows(s, 4 * OUTPUT_HIST_BINS, |rgb, counts| { + let scaled: Vec = rgb.iter().map(|&v| v as f32 / 255.0).collect(); + let mut lum = vec![0f32; rgb.len() / 3]; + luminance_chunk(&scaled, &mut lum); + for (i, (p, &l)) in rgb.as_chunks::<3>().0.iter().zip(&lum).enumerate() { + let lane = (i % LANES) * 4 * OUTPUT_HIST_BINS; + for (c, &v) in p.iter().enumerate() { + counts[lane + c * OUTPUT_HIST_BINS + v as usize] += 1; + } + let b = ((l * OUTPUT_HIST_BINS as f32) as i64).min(OUTPUT_HIST_BINS as i64 - 1); + counts[lane + 3 * OUTPUT_HIST_BINS + b as usize] += 1; + } + }) +} + +/// `color_histogram`: (32, 32, 32) joint RGB counts. +pub fn color_histogram(s: Samples) -> Vec { + let n = COLOR_HIST_BINS; + count_rows(s, n * n * n, |rgb, counts| { + for (i, p) in rgb.as_chunks::<3>().0.iter().enumerate() { + let lane = (i % LANES) * n * n * n; + let [r, g, b] = p.map(|v| quantize(v, n)); + counts[lane + (r * n + g) * n + b] += 1; + } + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Waveform { + /// (BINS, COLS) luma counts. + Luma, + /// (3, BINS, COLS) sums of R, G, B rounded to 0..1023, in each luma cell. + Color, + /// (3, BINS, COLS) counts of each channel. + Parade, +} + +/// `waveform`, `waveform_color` and `waveform_parade`: the column of a sample is its x in the +/// sampled frame, its luma cell from the float32 luminance. +pub fn waveform(s: Samples, kind: Waveform) -> Vec { + let w = (s.cols.1 - s.cols.0).div_ceil(s.step); + let cols: Vec = (0..w) + .map(|x| (x * WAVEFORM_COLS / w).min(WAVEFORM_COLS - 1)) + .collect(); + let plane = WAVEFORM_BINS * WAVEFORM_COLS; + let n = if kind == Waveform::Luma { + plane + } else { + 3 * plane + }; + count_rows(s, n, |rgb, counts| { + let mut lum = vec![0f32; rgb.len() / 3]; + if kind != Waveform::Parade { + luminance_chunk(rgb, &mut lum); + } + for (i, (p, &col)) in rgb.as_chunks::<3>().0.iter().zip(&cols).enumerate() { + let lane = (i % LANES) * n; + match kind { + Waveform::Luma => { + let b = quantize(nan_to_num(lum[i]), WAVEFORM_BINS); + counts[lane + b * WAVEFORM_COLS + col] += 1; + } + Waveform::Color => { + let b = quantize(nan_to_num(lum[i]), WAVEFORM_BINS); + for (c, &v) in p.iter().enumerate() { + let fixed = (nan_to_num(v).clamp(0.0, 1.0) * WAVEFORM_COLOR_SCALE) + .round_ties_even(); + counts[lane + c * plane + b * WAVEFORM_COLS + col] += fixed as u32; + } + } + Waveform::Parade => { + for (c, &v) in p.iter().enumerate() { + let b = quantize(nan_to_num(v), WAVEFORM_BINS); + counts[lane + c * plane + b * WAVEFORM_COLS + col] += 1; + } + } + } + } + }) +} + +/// `scan_full_scale_columns`: (3, WAVEFORM_COLS) counts of samples at or above `level`, per +/// parade column of the crop. +pub fn scan_full_scale_columns(s: Samples, level: f32) -> Vec { + let full_w = s.cols.1 - s.cols.0; + let cols: Vec = (0..full_w) + .step_by(s.step) + .map(|x| (x * WAVEFORM_COLS / full_w).min(WAVEFORM_COLS - 1)) + .collect(); + count_rows(s, 3 * WAVEFORM_COLS, |rgb, counts| { + for (i, (p, &col)) in rgb.as_chunks::<3>().0.iter().zip(&cols).enumerate() { + let lane = (i % LANES) * 3 * WAVEFORM_COLS; + for (c, &v) in p.iter().enumerate() { + counts[lane + c * WAVEFORM_COLS + col] += (v >= level) as u32; + } + } + }) } #[cfg(test)] diff --git a/negpy_core/src/py/histogram.rs b/negpy_core/src/py/histogram.rs index e79016abc..056e13d71 100644 --- a/negpy_core/src/py/histogram.rs +++ b/negpy_core/src/py/histogram.rs @@ -2,16 +2,19 @@ use numpy::{PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use crate::histogram::{self, DENSITY_HIST_BINS, Samples, WAVEFORM_BINS, WAVEFORM_COLS}; +use crate::histogram::{ + self, COLOR_HIST_BINS, DENSITY_HIST_BINS, OUTPUT_HIST_BINS, Samples, WAVEFORM_BINS, + WAVEFORM_COLS, Waveform, +}; type Counts<'py> = Bound<'py, numpy::PyArrayDyn>; /// The samples `img[y1:y2:step, x1:x2:step, :3]` of an HxWxC float32 image, C >= 3. -fn samples<'a>( - img: &'a PyReadonlyArrayDyn<'_, f32>, +fn samples<'a, T: numpy::Element>( + img: &'a PyReadonlyArrayDyn<'_, T>, (y1, y2, x1, x2): (usize, usize, usize, usize), step: usize, -) -> PyResult> { +) -> PyResult> { let [h, w, cn] = img.shape()[..] else { return Err(PyValueError::new_err("needs an HxWxC array")); }; @@ -57,7 +60,89 @@ fn density_parade<'py>( numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_BINS, WAVEFORM_COLS]) } +/// The whole frame of an HxWxC image, sampled every `step`. +fn frame<'a, T: numpy::Element>( + img: &'a PyReadonlyArrayDyn<'_, T>, + step: usize, +) -> PyResult> { + let (h, w) = (img.shape()[0], img.shape().get(1).copied().unwrap_or(0)); + samples(img, (0, h, 0, w), step) +} + +/// `output_histogram` of an HxWx3 float32 or uint8 frame sampled every `step`: (4, 256) counts. +#[pyfunction] +fn output_histogram<'py>( + py: Python<'py>, + img: &Bound<'py, PyAny>, + step: usize, +) -> PyResult> { + let counts = if let Ok(a) = img.extract::>() { + let s = frame(&a, step)?; + py.detach(|| histogram::output_histogram_u8(s)) + } else { + let a = img.extract::>()?; + let s = frame(&a, step)?; + py.detach(|| histogram::output_histogram(s)) + }; + numpy::PyArray1::from_vec(py, counts).reshape(vec![4, OUTPUT_HIST_BINS]) +} + +/// `color_histogram` of an HxWx3 float32 frame sampled every `step`: (32, 32, 32) counts. +#[pyfunction] +fn color_histogram<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + step: usize, +) -> PyResult> { + let s = frame(&img, step)?; + let counts = py.detach(|| histogram::color_histogram(s)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![COLOR_HIST_BINS; 3]) +} + +/// `waveform` ("luma"), `waveform_color` ("color") or `waveform_parade` ("parade") of an +/// HxWxC float32 frame sampled every `step`. +#[pyfunction] +fn waveform<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + step: usize, + kind: &str, +) -> PyResult> { + let kind = match kind { + "luma" => Waveform::Luma, + "color" => Waveform::Color, + "parade" => Waveform::Parade, + _ => return Err(PyValueError::new_err(format!("unknown waveform {kind:?}"))), + }; + let s = frame(&img, step)?; + let counts = py.detach(|| histogram::waveform(s, kind)); + let shape = if kind == Waveform::Luma { + vec![WAVEFORM_BINS, WAVEFORM_COLS] + } else { + vec![3, WAVEFORM_BINS, WAVEFORM_COLS] + }; + numpy::PyArray1::from_vec(py, counts).reshape(shape) +} + +/// `scan_full_scale_columns` of a crop (y1, y2, x1, x2) sampled every `step`: (3, cols) counts. +#[pyfunction] +fn scan_full_scale_columns<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + roi: (usize, usize, usize, usize), + step: usize, + level: f32, +) -> PyResult> { + let s = samples(&img, roi, step)?; + let counts = py.detach(|| histogram::scan_full_scale_columns(s, level)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_COLS]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(density_histogram, m)?)?; - m.add_function(wrap_pyfunction!(density_parade, m)?) + m.add_function(wrap_pyfunction!(density_parade, m)?)?; + m.add_function(wrap_pyfunction!(output_histogram, m)?)?; + m.add_function(wrap_pyfunction!(color_histogram, m)?)?; + m.add_function(wrap_pyfunction!(waveform, m)?)?; + m.add_function(wrap_pyfunction!(scan_full_scale_columns, m)?) } From cf79e647be73658b5ac615233a5e9256c3817c8f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:31:38 +0200 Subject: [PATCH 034/133] perf(core): port the textured-cell meters measure_anchor_from_log, measure_shadow_point_from_log, measure_highlight_point_from_log and measure_textural_range_from_log read the textured luma and its percentiles in Rust (meter.rs). The block mean follows NumPy: each block row summed pairwise, the rows added in order. NumPy normalizes in float64 when the bounds hold NumPy float64 scalars and in float32 for Python floats; the wrapper passes that per channel. Replay: 60/60 each. With float64, float32 and mixed scalar bounds, crops and buffers: 660/660 against the old Python. Bench (one pinned core): 0.52x to 0.70x. --- negpy/features/exposure/normalization.py | 71 +++++------ negpy_core/manifest.toml | 16 +-- negpy_core/src/lib.rs | 2 + negpy_core/src/meter.rs | 152 +++++++++++++++++++++++ negpy_core/src/py/meter.rs | 87 +++++++++++++ negpy_core/src/py/mod.rs | 1 + 6 files changed, 278 insertions(+), 51 deletions(-) create mode 100644 negpy_core/src/meter.rs create mode 100644 negpy_core/src/py/meter.rs diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 815d53971..83f8a7eeb 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -462,43 +462,31 @@ def luminance_density_range(bounds: LogNegativeBounds) -> float: return float(LUMA_R * rr + LUMA_G * rg + LUMA_B * rb) -def _textured_cells(lum: np.ndarray) -> np.ndarray: - """ - Cells of the prefiltered luma grid that carry detail. The grid is tiled into - sectors of 2x2 blocks of activity_block cells; a sector votes when its four block - means span more than activity_gate_density (Boyack & Juenger, US 5,724,456). Block - means average grain out, and a flat sector beside a textured one stays out because - only its own blocks are compared. Falls back to every cell when too few pass. - """ +def _activity_gate() -> Tuple[int, float, float]: + """The activity gate shared by the anchor and textural-range meters: the grid is tiled into + sectors of 2x2 blocks of activity_block cells, and a sector votes when its four block means + span more than activity_gate_density (Boyack & Juenger, US 5,724,456). Block means average + grain out, and a flat sector beside a textured one stays out because only its own blocks are + compared. Every cell votes when fewer than activity_min_fraction of the sectors pass.""" from negpy.features.exposure.models import EXPOSURE_CONSTANTS c = EXPOSURE_CONSTANTS - b = int(c["activity_block"]) - h, w = lum.shape - hs, ws = (h // (2 * b)) * 2 * b, (w // (2 * b)) * 2 * b - if hs == 0 or ws == 0: - return lum.ravel() - core = lum[:hs, :ws] - blocks = core.reshape(hs // b, b, ws // b, b).mean(axis=(1, 3)) - sectors = blocks.reshape(hs // (2 * b), 2, ws // (2 * b), 2) - active = (sectors.max(axis=(1, 3)) - sectors.min(axis=(1, 3))) > float(c["activity_gate_density"]) - if active.mean() < float(c["activity_min_fraction"]): - return lum.ravel() - mask = np.repeat(np.repeat(active, 2 * b, axis=0), 2 * b, axis=1) - return core[mask] - - -def _textured_norm_luma(grid: ImageBuffer, bounds: LogNegativeBounds) -> np.ndarray: - """Luma of the grid normalized against `bounds`, textured cells only.""" + return int(c["activity_block"]), float(c["activity_gate_density"]), float(c["activity_min_fraction"]) + + +def _meter_norm(bounds: LogNegativeBounds) -> list: + """Per channel (floor, span, and whether each is a float64 operand): NumPy normalizes in + float64 when the bounds hold NumPy float64 scalars, in float32 for Python floats.""" epsilon = 1e-6 - norm = np.empty_like(grid) + out = [] for ch in range(3): f = bounds.floors[ch] denom = bounds.ceils[ch] - f if abs(denom) < epsilon: denom = epsilon if denom >= 0 else -epsilon - norm[:, :, ch] = (grid[:, :, ch] - f) / denom - return _textured_cells(LUMA_R * norm[:, :, 0] + LUMA_G * norm[:, :, 1] + LUMA_B * norm[:, :, 2]) + wide = (np.result_type(np.float32, f) == np.float64, np.result_type(np.float32, denom) == np.float64) + out.append((float(f), bool(wide[0]), float(denom), bool(wide[1]))) + return out def measure_shadow_point_from_log( @@ -516,8 +504,10 @@ def measure_shadow_point_from_log( img_log = img_log[y1:y2, x1:x2] if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - lum = _textured_norm_luma(_block_median_grid(img_log), bounds) - return float(np.percentile(lum, float(EXPOSURE_CONSTANTS["shadow_reach_percentile"]))) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + return float( + negpy_core.textured_percentile(grid, _meter_norm(bounds), _activity_gate(), float(EXPOSURE_CONSTANTS["shadow_reach_percentile"])) + ) def measure_highlight_point_from_log( @@ -535,8 +525,10 @@ def measure_highlight_point_from_log( img_log = img_log[y1:y2, x1:x2] if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - lum = _textured_norm_luma(_block_median_grid(img_log), bounds) - return float(np.percentile(lum, float(EXPOSURE_CONSTANTS["highlight_hold_percentile"]))) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + return float( + negpy_core.textured_percentile(grid, _meter_norm(bounds), _activity_gate(), float(EXPOSURE_CONSTANTS["highlight_hold_percentile"])) + ) def measure_anchor_from_log( @@ -573,11 +565,8 @@ def measure_anchor_from_log( if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - lum = _textured_norm_luma(_block_median_grid(img_log), bounds) - clip = float(EXPOSURE_CONSTANTS["anchor_trim_clip"]) - lo, hi = np.percentile(lum, [clip, 100.0 - clip]) - inner = lum[(lum >= lo) & (lum <= hi)] - measured = 0.5 * (float(inner.mean()) + 0.5 * (float(lo) + float(hi))) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + measured = negpy_core.textured_anchor(grid, _meter_norm(bounds), _activity_gate(), float(EXPOSURE_CONSTANTS["anchor_trim_clip"])) a = float(EXPOSURE_CONSTANTS["assumed_anchor"]) if assumed is None else float(assumed) strength = float(EXPOSURE_CONSTANTS["anchor_meter_strength"]) @@ -619,12 +608,8 @@ def measure_textural_range_from_log( if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - img_log = _block_median_grid(img_log) - - lum = _textured_cells(LUMA_R * img_log[:, :, 0] + LUMA_G * img_log[:, :, 1] + LUMA_B * img_log[:, :, 2]) - clip = float(EXPOSURE_CONSTANTS["textural_range_clip"]) - lo, hi = np.percentile(lum, [clip, 100.0 - clip]) - return float(abs(hi - lo)) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + return negpy_core.textured_range(grid, _activity_gate(), float(EXPOSURE_CONSTANTS["textural_range_clip"])) def measure_textural_range( diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 66d7b261d..61bae24bb 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -283,8 +283,8 @@ depends = ["measure_anchor_from_log"] ["features/exposure/normalization.py::measure_anchor_from_log"] phase = 2 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.textured_anchor"] depends = ["_block_median_grid"] ["features/exposure/normalization.py::measure_base_log"] @@ -301,8 +301,8 @@ depends = [] ["features/exposure/normalization.py::measure_highlight_point_from_log"] phase = 2 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.textured_percentile"] depends = ["_block_median_grid"] ["features/exposure/normalization.py::measure_neutral_axis"] @@ -325,8 +325,8 @@ depends = [] ["features/exposure/normalization.py::measure_shadow_point_from_log"] phase = 2 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.textured_percentile"] depends = ["_block_median_grid"] ["features/exposure/normalization.py::measure_textural_range"] @@ -337,8 +337,8 @@ depends = ["measure_textural_range_from_log"] ["features/exposure/normalization.py::measure_textural_range_from_log"] phase = 2 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.textured_range"] depends = ["_block_median_grid"] ["features/exposure/normalization.py::normalize_log_image"] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index ee6e50d27..ebfa3e990 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -4,6 +4,7 @@ pub mod detect; pub mod filter; pub mod gray; pub mod histogram; +pub mod meter; pub mod morph; pub mod resize; pub mod shapes; @@ -33,5 +34,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::gray::register(m)?; py::contours::register(m)?; py::histogram::register(m)?; + py::meter::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/meter.rs b/negpy_core/src/meter.rs new file mode 100644 index 000000000..79f0db342 --- /dev/null +++ b/negpy_core/src/meter.rs @@ -0,0 +1,152 @@ +//! The textured-cell meters of negpy/features/exposure/normalization.py: anchor, textural +//! range, shadow reach and highlight hold, read off the block-median grid. + +use crate::stats::{np_percentiles, np_percentiles_f64, pairwise_sum_f32}; + +const LUMA_R: f32 = 0.2126; +const LUMA_G: f32 = 0.7152; +const LUMA_B: f32 = 0.0722; + +/// One channel of `(grid - floor) / span`. A float64 operand (a NumPy float64 scalar) makes +/// NumPy compute that step in float64; a Python float joins the float32 math (NEP 50). +#[derive(Clone, Copy)] +pub struct ChannelNorm { + pub floor: f64, + pub floor_wide: bool, + pub span: f64, + pub span_wide: bool, +} + +impl ChannelNorm { + fn apply(&self, g: f32) -> f32 { + if self.floor_wide || self.span_wide { + let d = if self.floor_wide { + g as f64 - self.floor + } else { + (g - self.floor as f32) as f64 + }; + (d / self.span) as f32 + } else { + (g - self.floor as f32) / self.span as f32 + } + } +} + +/// The float32 luma of an HxWx3 grid, each channel normalized first when `norm` is given. +pub fn luma(grid: &[f32], norm: Option<&[ChannelNorm; 3]>) -> Vec { + grid.as_chunks::<3>() + .0 + .iter() + .map(|p| { + let [r, g, b] = match norm { + Some(n) => [n[0].apply(p[0]), n[1].apply(p[1]), n[2].apply(p[2])], + None => *p, + }; + LUMA_R * r + LUMA_G * g + LUMA_B * b + }) + .collect() +} + +/// The activity gate: sectors of 2x2 blocks of `block` x `block` cells vote when their four +/// block means span more than `gate`. +#[derive(Clone, Copy)] +pub struct Activity { + pub block: usize, + pub gate: f32, + pub min_fraction: f64, +} + +/// `_textured_cells(lum)` of an HxW luma plane: the cells of the voting sectors in row order, +/// or every cell when fewer than `min_fraction` of the sectors vote. +pub fn textured_cells(lum: Vec, h: usize, w: usize, a: Activity) -> Vec { + let b = a.block; + let (hs, ws) = ((h / (2 * b)) * 2 * b, (w / (2 * b)) * 2 * b); + if hs == 0 || ws == 0 { + return lum; + } + let (bh, bw) = (hs / b, ws / b); + // NumPy's block mean: each row of the block summed pairwise, the rows added in order. + let mut means = vec![0f32; bh * bw]; + for (i, m) in means.iter_mut().enumerate() { + let (by, bx) = (i / bw, i % bw); + let mut sum = 0f32; + for y in by * b..(by + 1) * b { + sum += pairwise_sum_f32(&lum[y * w + bx * b..y * w + (bx + 1) * b]); + } + *m = sum / (b * b) as f32; + } + let (sh, sw) = (bh / 2, bw / 2); + let active: Vec = (0..sh * sw) + .map(|i| { + let (sy, sx) = (i / sw, i % sw); + let quad = [ + means[2 * sy * bw + 2 * sx], + means[2 * sy * bw + 2 * sx + 1], + means[(2 * sy + 1) * bw + 2 * sx], + means[(2 * sy + 1) * bw + 2 * sx + 1], + ]; + let (lo, hi) = quad + .iter() + .fold((f32::INFINITY, f32::NEG_INFINITY), |(l, h), &v| { + (np_min(l, v), np_max(h, v)) + }); + hi - lo > a.gate + }) + .collect(); + let voting = active.iter().filter(|&&v| v).count(); + if (voting as f64 / active.len() as f64) < a.min_fraction { + return lum; + } + let mut out = Vec::with_capacity(voting * 4 * b * b); + for y in 0..hs { + let sy = y / (2 * b); + for x in 0..ws { + if active[sy * sw + x / (2 * b)] { + out.push(lum[y * w + x]); + } + } + } + out +} + +/// np.min / np.max with NaN propagation. +fn np_min(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.min(b) + } +} + +fn np_max(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.max(b) + } +} + +/// `np.percentile(lum, q)` for a Python float q: float32 interpolation. +pub fn percentile(lum: &[f32], q: f64) -> f32 { + np_percentiles(lum, &[q]).map_or(f32::NAN, |p| p[0]) +} + +/// The anchor meter's measurement: the mean of the cells inside the `clip` percentile trim +/// (float32) averaged with the trim's midpoint. +pub fn anchor_measure(lum: &[f32], clip: f64) -> f64 { + let p = np_percentiles_f64(lum, &[clip, 100.0 - clip]); + let (lo, hi) = (p[0], p[1]); + let inner: Vec = lum + .iter() + .copied() + .filter(|&v| v as f64 >= lo && v as f64 <= hi) + .collect(); + let mean = pairwise_sum_f32(&inner) / inner.len() as f32; + 0.5 * (mean as f64 + 0.5 * (lo + hi)) +} + +/// The textural range: the spread between the `clip` and `100 - clip` percentiles. +pub fn textural_range(lum: &[f32], clip: f64) -> f64 { + let p = np_percentiles_f64(lum, &[clip, 100.0 - clip]); + (p[1] - p[0]).abs() +} diff --git a/negpy_core/src/py/meter.rs b/negpy_core/src/py/meter.rs new file mode 100644 index 000000000..7f54acafa --- /dev/null +++ b/negpy_core/src/py/meter.rs @@ -0,0 +1,87 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::meter::{self, Activity, ChannelNorm}; + +/// Per channel (floor, floor is float64, span, span is float64), as the caller's types decide. +type Norm = [(f64, bool, f64, bool); 3]; + +/// The data and size of an HxWx3 float32 grid. +fn grid_of<'a>(grid: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], usize, usize)> { + let [h, w, 3] = grid.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 array")); + }; + let data = grid + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok((data, h, w)) +} + +fn activity((block, gate, min_fraction): (usize, f32, f64)) -> PyResult { + if block == 0 { + return Err(PyValueError::new_err("block must be positive")); + } + Ok(Activity { + block, + gate, + min_fraction, + }) +} + +/// The textured luma of a grid, normalized when `norm` is given. +fn textured((data, h, w): (&[f32], usize, usize), norm: Option, a: Activity) -> Vec { + let norm = norm.map(|n| { + n.map(|(floor, floor_wide, span, span_wide)| ChannelNorm { + floor, + floor_wide, + span, + span_wide, + }) + }); + meter::textured_cells(meter::luma(data, norm.as_ref()), h, w, a) +} + +/// `np.percentile` (float32 q) of the grid's textured normalized luma: shadow reach, highlight hold. +#[pyfunction] +fn textured_percentile( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + norm: Norm, + gate: (usize, f32, f64), + q: f64, +) -> PyResult { + let (g, a) = (grid_of(&grid)?, activity(gate)?); + Ok(py.detach(|| meter::percentile(&textured(g, Some(norm), a), q))) +} + +/// The anchor meter's measurement over the grid's textured normalized luma. +#[pyfunction] +fn textured_anchor( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + norm: Norm, + gate: (usize, f32, f64), + clip: f64, +) -> PyResult { + let (g, a) = (grid_of(&grid)?, activity(gate)?); + Ok(py.detach(|| meter::anchor_measure(&textured(g, Some(norm), a), clip))) +} + +/// The textural range of the grid's textured raw-log luma. +#[pyfunction] +fn textured_range( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + gate: (usize, f32, f64), + clip: f64, +) -> PyResult { + let (g, a) = (grid_of(&grid)?, activity(gate)?); + Ok(py.detach(|| meter::textural_range(&textured(g, None, a), clip))) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(textured_percentile, m)?)?; + m.add_function(wrap_pyfunction!(textured_anchor, m)?)?; + m.add_function(wrap_pyfunction!(textured_range, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index b5fe8eefc..1a8ec3b59 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -3,6 +3,7 @@ pub mod contours; pub mod filter; pub mod gray; pub mod histogram; +pub mod meter; pub mod morph; pub mod resize; pub mod stats; From 0acce3cc9359751a8b745fa0c8b32ac771f41b1e Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:34:54 +0200 Subject: [PATCH 035/133] perf(core): port the bounds sampling and the same-pixel color pass _sample_log_bounds reads its per-channel percentiles from negpy_core (float64 interpolation, as for an np.float64 q); the sorted-grid path stays a lookup in Python. _same_pixel_color_floor_refs runs in Rust (bounds.rs): float64 normalization and luma, NumPy's linear percentile and quantile, the two chroma-gated passes and their column medians. Replay: _sample_log_bounds 60/60, _same_pixel_color_floor_refs 60/60, analyze_log_exposure_bounds_from_log 58/58. Clip variants: 260/260 against the old Python, both outcomes covered. Bench (one pinned core): 0.296x, 0.451x; analyze_log_exposure_bounds_from_log 0.319x. --- negpy/features/exposure/normalization.py | 77 +++------- negpy_core/manifest.toml | 16 +-- negpy_core/src/bounds.rs | 174 +++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/bounds.rs | 55 +++++++ negpy_core/src/py/mod.rs | 1 + 6 files changed, 263 insertions(+), 62 deletions(-) create mode 100644 negpy_core/src/bounds.rs create mode 100644 negpy_core/src/py/bounds.rs diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 83f8a7eeb..b209fd9fb 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -790,13 +790,13 @@ def _sample_log_bounds( margin = -percentile_clip p_low, p_high = np.float64(clip), np.float64(100.0 - clip) - def _pct(p) -> list: - if sorted_grid is not None: - return [float(v) for v in percentile_from_sorted(sorted_grid, p)] - return [float(np.percentile(img_log[:, :, ch], p)) for ch in range(3)] - - floors = _pct(p_low) - ceils = _pct(p_high) + if sorted_grid is not None: + floors = [float(v) for v in percentile_from_sorted(sorted_grid, p_low)] + ceils = [float(v) for v in percentile_from_sorted(sorted_grid, p_high)] + else: + per_channel = negpy_core.channel_percentiles(np.ascontiguousarray(img_log, dtype=np.float32), [float(p_low), float(p_high)]) + floors = [lo for lo, _ in per_channel] + ceils = [hi for _, hi in per_channel] if margin > 0.0: # Expand outward; per-channel sign handles both f < c and f > c. @@ -832,53 +832,22 @@ def _same_pixel_color_floor_refs( from negpy.features.exposure.models import EXPOSURE_CONSTANTS c = EXPOSURE_CONSTANTS - q = float(c["neutral_axis_chroma_quantile"]) - cap = float(c["neutral_axis_chroma_cap"]) - min_px = int(c["neutral_axis_min_pixels"]) - width = float(c["color_bounds_band_width"]) - epsilon = 1e-6 - - flat = img_log.reshape(-1, 3).astype(np.float64) - base = np.asarray(base_refs, dtype=np.float64) - norm = np.empty_like(flat) - for ch in range(3): - denom = luma_ceils[ch] - luma_floors[ch] - if abs(denom) < epsilon: - denom = epsilon if denom >= 0 else -epsilon - norm[:, ch] = (flat[:, ch] - luma_floors[ch]) / denom - luma = LUMA_R * norm[:, 0] + LUMA_G * norm[:, 1] + LUMA_B * norm[:, 2] - - clip = max(0.00001, min(50.0 - width, float(color_clip))) - lo, hi = np.percentile(luma, [clip, clip + width]) - band = (luma >= lo) & (luma <= hi) - if int(band.sum()) < min_px: - return None - d = flat[band] - base[None, :] - - def _select(gamma: np.ndarray) -> Optional[Tuple[np.ndarray, float]]: - g = gamma.copy() - g[np.abs(g) < epsilon] = epsilon - chroma = _rms_chroma(d / g[None, :]) - thr = float(np.quantile(chroma, q)) - sel = chroma <= thr - if int(sel.sum()) < min_px: - return None - return sel, float(np.median(chroma[sel])) - - spans = np.array([luma_floors[ch] - base_refs[ch] for ch in range(3)], dtype=np.float64) - first = _select(spans) - # Pass-1 loose cap: a homogeneous colored cluster would otherwise be self-normalized to - # zero chroma by pass 2 and read as neutral. - if first is None or first[1] > float(c["neutral_axis_first_pass_cap"]): - return None - provisional = np.median(d[first[0]], axis=0) - if np.any(np.abs(provisional) < epsilon): - return None - second = _select(provisional) - if second is None or second[1] > cap: - return None - refs = base + np.median(d[second[0]], axis=0) - return (float(refs[0]), float(refs[1]), float(refs[2])) + constants = ( + float(c["neutral_axis_chroma_quantile"]), + float(c["neutral_axis_chroma_cap"]), + float(c["neutral_axis_first_pass_cap"]), + int(c["neutral_axis_min_pixels"]), + float(c["color_bounds_band_width"]), + ) + refs = negpy_core.same_pixel_color_floor_refs( + np.ascontiguousarray(img_log, dtype=np.float32), + [float(v) for v in luma_floors], + [float(v) for v in luma_ceils], + [float(v) for v in base_refs], + float(color_clip), + constants, + ) + return None if refs is None else (refs[0], refs[1], refs[2]) def analyze_log_exposure_bounds( diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 61bae24bb..b5f3275cc 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -187,25 +187,25 @@ depends = [] ["features/exposure/normalization.py::_same_pixel_color_floor_refs"] phase = 2 -status = "todo" -calls = ["np.median", "np.percentile", "np.quantile"] -depends = ["_select"] +status = "ported" +calls = ["negpy_core.same_pixel_color_floor_refs"] +depends = [] ["features/exposure/normalization.py::_same_pixel_color_floor_refs._select"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median", "np.quantile"] depends = [] ["features/exposure/normalization.py::_sample_log_bounds"] phase = 2 -status = "todo" -calls = ["np.percentile"] -depends = ["_pct"] +status = "ported" +calls = ["negpy_core.channel_percentiles"] +depends = [] ["features/exposure/normalization.py::_sample_log_bounds._pct"] phase = 2 -status = "todo" +status = "ported" calls = ["np.percentile"] depends = [] diff --git a/negpy_core/src/bounds.rs b/negpy_core/src/bounds.rs new file mode 100644 index 000000000..a73f38a88 --- /dev/null +++ b/negpy_core/src/bounds.rs @@ -0,0 +1,174 @@ +//! The density bounds sampling of negpy/features/exposure/normalization.py. + +use crate::stats::np_percentiles_f64; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; +const EPSILON: f64 = 1e-6; + +/// `np.percentile(grid[:, :, ch], qs)` for each channel of an HxWx3 float32 grid, with float64 +/// interpolation (an `np.float64` or sequence q). +pub fn channel_percentiles(grid: &[f32], qs: &[f64]) -> [Vec; 3] { + std::array::from_fn(|ch| { + let plane: Vec = grid.iter().skip(ch).step_by(3).copied().collect(); + np_percentiles_f64(&plane, qs) + }) +} + +/// NumPy's linear quantile of float64 values at a virtual index of `(n - 1) * q`, the far half +/// counted back from the upper sample; NaN when any value is NaN. Reorders `values`. +fn quantile_at(values: &mut [f64], virtual_index: f64) -> f64 { + let n = values.len(); + if n == 0 || values.iter().any(|v| v.is_nan()) { + return f64::NAN; + } + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let t = virtual_index - lo as f64; + let a = *values.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = if lo + 1 < n { + values[lo + 1..] + .iter() + .copied() + .fold(f64::INFINITY, f64::min) + } else { + a + }; + let diff = b - a; + if t >= 0.5 { + b - diff * (1.0 - t) + } else { + a + diff * t + } +} + +fn np_quantile(values: &mut [f64], q: f64) -> f64 { + let n = values.len(); + quantile_at(values, n.saturating_sub(1) as f64 * q) +} + +fn np_percentile(values: &mut [f64], q: f64) -> f64 { + let n = values.len(); + quantile_at(values, n.saturating_sub(1) as f64 * (q / 100.0)) +} + +/// `np.median` of float64 values: the middle one, or the mean of the two middle ones. +fn np_median(values: &mut [f64]) -> f64 { + let n = values.len(); + if n == 0 || values.iter().any(|v| v.is_nan()) { + return f64::NAN; + } + let hi = *values.select_nth_unstable_by(n / 2, f64::total_cmp).1; + if n % 2 == 1 { + return hi; + } + let lo = values[..n / 2] + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + (lo + hi) / 2.0 +} + +/// `_rms_chroma` of one triplet. +fn rms_chroma([r, g, b]: [f64; 3]) -> f64 { + (((r - g) * (r - g) + (g - b) * (g - b) + (r - b) * (r - b)) / 3.0).sqrt() +} + +/// The constants of the same-pixel color pass. +#[derive(Clone, Copy)] +pub struct ColorPass { + pub chroma_quantile: f64, + pub chroma_cap: f64, + pub first_pass_cap: f64, + pub min_pixels: usize, + pub band_width: f64, +} + +/// `_same_pixel_color_floor_refs`: the dense-end color refs from the luma band's lowest-chroma +/// pixels, or None when that set is too small or too chromatic. +pub fn same_pixel_color_floor_refs( + grid: &[f32], + luma_floors: [f64; 3], + luma_ceils: [f64; 3], + base: [f64; 3], + color_clip: f64, + c: ColorPass, +) -> Option<[f64; 3]> { + let flat: Vec<[f64; 3]> = grid + .as_chunks::<3>() + .0 + .iter() + .map(|p| p.map(|v| v as f64)) + .collect(); + let denom: [f64; 3] = std::array::from_fn(|ch| { + let d = luma_ceils[ch] - luma_floors[ch]; + if d.abs() < EPSILON { + if d >= 0.0 { EPSILON } else { -EPSILON } + } else { + d + } + }); + let luma: Vec = flat + .iter() + .map(|p| { + let n: [f64; 3] = std::array::from_fn(|ch| (p[ch] - luma_floors[ch]) / denom[ch]); + LUMA_R * n[0] + LUMA_G * n[1] + LUMA_B * n[2] + }) + .collect(); + + let clip = 0.00001f64.max((50.0 - c.band_width).min(color_clip)); + let (lo, hi) = { + let mut v = luma.clone(); + ( + np_percentile(&mut v, clip), + np_percentile(&mut v, clip + c.band_width), + ) + }; + let d: Vec<[f64; 3]> = flat + .iter() + .zip(&luma) + .filter(|&(_, &l)| l >= lo && l <= hi) + .map(|(p, _)| std::array::from_fn(|ch| p[ch] - base[ch])) + .collect(); + if d.len() < c.min_pixels { + return None; + } + + // The lowest-chroma quantile of the band, chroma read with `gamma` as each channel's span: + // the selected rows and their median chroma. + let select = |gamma: [f64; 3]| -> Option<(Vec, f64)> { + let g = gamma.map(|v| if v.abs() < EPSILON { EPSILON } else { v }); + let chroma: Vec = d + .iter() + .map(|p| rms_chroma(std::array::from_fn(|ch| p[ch] / g[ch]))) + .collect(); + let thr = np_quantile(&mut chroma.clone(), c.chroma_quantile); + let sel: Vec = (0..chroma.len()).filter(|&i| chroma[i] <= thr).collect(); + if sel.len() < c.min_pixels { + return None; + } + let mut picked: Vec = sel.iter().map(|&i| chroma[i]).collect(); + Some((sel, np_median(&mut picked))) + }; + let column_medians = |rows: &[usize]| -> [f64; 3] { + std::array::from_fn(|ch| np_median(&mut rows.iter().map(|&i| d[i][ch]).collect::>())) + }; + + let spans: [f64; 3] = std::array::from_fn(|ch| luma_floors[ch] - base[ch]); + let (first, first_chroma) = select(spans)?; + // A loose first cap: pass 2 would self-normalize a homogeneous colored cluster to zero + // chroma and read it as neutral. + if first_chroma > c.first_pass_cap { + return None; + } + let provisional = column_medians(&first); + if provisional.iter().any(|v| v.abs() < EPSILON) { + return None; + } + let (second, second_chroma) = select(provisional)?; + if second_chroma > c.chroma_cap { + return None; + } + let m = column_medians(&second); + Some(std::array::from_fn(|ch| base[ch] + m[ch])) +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index ebfa3e990..5e835813b 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,4 +1,5 @@ pub mod analysis; +pub mod bounds; pub mod contours; pub mod detect; pub mod filter; @@ -35,5 +36,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::contours::register(m)?; py::histogram::register(m)?; py::meter::register(m)?; + py::bounds::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/py/bounds.rs b/negpy_core/src/py/bounds.rs new file mode 100644 index 000000000..f82cea685 --- /dev/null +++ b/negpy_core/src/py/bounds.rs @@ -0,0 +1,55 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::bounds::{self, ColorPass}; + +fn grid_of<'a>(grid: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + if grid.ndim() != 3 || grid.shape()[2] != 3 { + return Err(PyValueError::new_err("needs an HxWx3 array")); + } + grid.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `np.percentile(grid[:, :, ch], qs)` per channel of an HxWx3 float32 grid, float64 interpolation. +#[pyfunction] +fn channel_percentiles( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + qs: Vec, +) -> PyResult<[Vec; 3]> { + let g = grid_of(&grid)?; + Ok(py.detach(|| bounds::channel_percentiles(g, &qs))) +} + +/// `_same_pixel_color_floor_refs` with its constants (chroma quantile, chroma cap, first-pass +/// cap, minimum pixels, band width). +#[pyfunction] +fn same_pixel_color_floor_refs( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + luma_floors: [f64; 3], + luma_ceils: [f64; 3], + base: [f64; 3], + color_clip: f64, + constants: (f64, f64, f64, usize, f64), +) -> PyResult> { + let g = grid_of(&grid)?; + let (chroma_quantile, chroma_cap, first_pass_cap, min_pixels, band_width) = constants; + let c = ColorPass { + chroma_quantile, + chroma_cap, + first_pass_cap, + min_pixels, + band_width, + }; + Ok(py.detach(|| { + bounds::same_pixel_color_floor_refs(g, luma_floors, luma_ceils, base, color_clip, c) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(channel_percentiles, m)?)?; + m.add_function(wrap_pyfunction!(same_pixel_color_floor_refs, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 1a8ec3b59..516706cbb 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,4 +1,5 @@ pub mod analysis; +pub mod bounds; pub mod contours; pub mod filter; pub mod gray; From 0802ecebd91a6abc8e11ae5bcbe731b611a18341 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:37:32 +0200 Subject: [PATCH 036/133] refactor(core): drop the detector helpers the Rust port orphaned; record batch autocrop _vertical_edge_profile, _correction_angle_from_quad and _odd_kernel_size in geometry/logic.py lost their last caller with the detector port. scripts/replay.py record now also runs Square Before Crop and the Auto-crop All Frames evidence per frame, then resolves the files as one roll, so batch_autocrop and skew get a corpus. --- negpy/features/geometry/batch_autocrop.py | 2 +- negpy/features/geometry/logic.py | 45 ----------------------- negpy_core/manifest.toml | 2 +- scripts/replay.py | 32 ++++++++++++++-- 4 files changed, 31 insertions(+), 50 deletions(-) diff --git a/negpy/features/geometry/batch_autocrop.py b/negpy/features/geometry/batch_autocrop.py index 5f4bfd2a3..6204c18a0 100644 --- a/negpy/features/geometry/batch_autocrop.py +++ b/negpy/features/geometry/batch_autocrop.py @@ -173,7 +173,7 @@ def _vertical_edge_profile(img: ImageBuffer) -> tuple[np.ndarray, float]: def _top_edge_slope(lum: np.ndarray, roi: ROI) -> float | None: """Residual tilt of the film box's top edge, in degrees, or None when unreadable. - Sign matches _correction_angle_from_quad: the measured image-coordinate residual is + Sign matches the film detector's quad angle: the measured image-coordinate residual is already the additive fix. """ y1, _, x1, x2 = roi diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index b03cf4ea6..528ec4671 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -137,51 +137,6 @@ def _score_contour(contour: np.ndarray, image_area: float) -> tuple[float, np.nd return score, cv2.boxPoints(rect) -def _odd_kernel_size(value: float, lower: int, upper: int) -> int: - size = int(round(value)) - size = min(max(size, lower), upper) - if size % 2 == 0: - size = size + 1 if size < upper else size - 1 - return max(size, 1) - - -def _correction_angle_from_quad(quad: np.ndarray, aspect_ratio: float) -> float: - if aspect_ratio < 1.12: - return 0.0 # Near-square boxes do not have a stable orientation axis. - points = np.asarray(quad, dtype=np.float32) - edges = np.roll(points, -1, axis=0) - points - edge = edges[int(np.argmax(np.linalg.norm(edges, axis=1)))] - angle = math.degrees(math.atan2(float(edge[1]), float(edge[0]))) - while angle >= 90.0: - angle -= 180.0 - while angle < -90.0: - angle += 180.0 - if angle > 45.0: - angle -= 90.0 - elif angle < -45.0: - angle += 90.0 - # OpenCV's positive fine rotation moves a horizontal edge toward a negative - # image-coordinate angle, so this measured residual is already the additive fix. - return float(np.clip(angle, -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT)) - - -def _vertical_edge_profile(lum: np.ndarray) -> tuple[np.ndarray, float]: - if lum.size == 0: - return np.empty(0, dtype=np.float32), 0.0 - gradient = np.abs(negpy_core.sobel(lum.astype(np.float32), 1, 0)) - profile = np.mean(gradient, axis=0).astype(np.float32) - window = _odd_kernel_size(0.006 * profile.size, 3, 15) - profile = negpy_core.gaussian_blur(profile.reshape(1, -1), (window, 1), 0).ravel() - baseline = float(np.percentile(profile, 50.0)) if profile.size else 0.0 - normalizer = float(np.percentile(profile, 99.5)) if profile.size else 0.0 - contrast = max(0.0, normalizer - baseline) - if normalizer > 1e-8: - profile = np.clip(profile / normalizer, 0.0, 1.0) - else: - profile.fill(0.0) - return profile, contrast - - def _empty_autocrop_detection( vertical_edge_profile: np.ndarray, vertical_edge_contrast: float = 0.0, diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index b5f3275cc..da7fd7294 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -711,7 +711,7 @@ note = "negpy_core::analysis::trim_film_edges" ["features/geometry/logic.py::_vertical_edge_profile"] phase = 2 -status = "todo" +status = "ported" calls = ["negpy_core.gaussian_blur", "negpy_core.sobel", "np.mean", "np.percentile"] depends = [] diff --git a/scripts/replay.py b/scripts/replay.py index 63add3155..5e825a08d 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -1,7 +1,8 @@ """Record real calls of the functions in negpy_core/manifest.toml, then replay them through the current code. - record FILE... run each frame through the preview load and the CPU pipeline, and save every call - of a recorded function: inputs, result and its time on one thread, called alone. + record FILE... run each frame through the preview load, the CPU pipeline, Square Before Crop and + the Auto-crop All Frames evidence, then resolve the files as one roll; save every + call of a recorded function: inputs, result and its time on one thread, called alone. replay call the current code with each saved input and compare the result bit for bit. bench time the current code on one thread against the saved time. @@ -139,6 +140,25 @@ def _run_frame(path: str, repo=None) -> None: config = with_auto_crop(saved or WorkspaceConfig(), True) # Stage caching on, as the preview runs: the base read (measure_base_log) happens only then. ImageProcessor(use_gpu=False).run_pipeline(buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False) + return buffer, config + + +def _crop_evidence(key: str, buffer, config): + """One frame of Auto-crop All Frames and Square Before Crop, as the batch worker runs them + (without the flat field).""" + from dataclasses import replace + + from negpy.domain.interfaces import PipelineContext + from negpy.features.geometry.batch_autocrop import detect_crop_candidate + from negpy.features.geometry.processor import GeometryProcessor + from negpy.features.geometry.skew import squared_geometry + + geometry = config.geometry + squared_geometry(buffer, geometry, config.process.process_mode) + detection = replace(geometry, crop_rect=None, crop_from_auto=False, autocrop_offset=0) + context = PipelineContext(original_size=(buffer.shape[1], buffer.shape[0]), scale_factor=1.0, process_mode=config.process.process_mode) + transformed = GeometryProcessor(detection).process(buffer, context) + return detect_crop_candidate(key, transformed, target_ratio=geometry.autocrop_ratio, rebate_trim=geometry.autocrop_rebate_trim) def _edits_repo(edits_db: str | None): @@ -161,12 +181,18 @@ def record(files: list[str], phases: list[int] | None, edits_db: str | None) -> import negpy.services.rendering.image_processor # noqa: F401 (load the pipeline before wrapping) import negpy.services.rendering.preview_manager # noqa: F401 + from negpy.features.geometry.batch_autocrop import resolve_roll_crops + recorder = Recorder(keys) try: + evidence = [] for path in files: recorder.frame, recorder.per_frame = Path(path).name, {} - _run_frame(path, repo) + buffer, config = _run_frame(path, repo) + evidence.append(_crop_evidence(str(len(evidence)), buffer, config)) print(f"recorded {path}") + recorder.frame, recorder.per_frame = "roll", {} + resolve_roll_crops(evidence) finally: recorder.restore() for key in keys: From cb86b13f284a58e3b5af9ea23db7245599b85920 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:43:43 +0200 Subject: [PATCH 037/133] perf(core): port _get_threshold_autocrop_coords; resolve_autocrop_rect resizes in Rust The threshold box reads its row and column means in negpy_core (row means pairwise, column means summed down in order, as NumPy does). resolve_autocrop_rect downsamples through _normalize_detection_input, the same size and INTER_AREA bits as its cv2.resize call. Replay: _get_threshold_autocrop_coords 53/53, resolve_autocrop_rect 60/60, get_autocrop_coords 60/60. Also fixes _run_frame's return annotation. --- negpy/features/geometry/logic.py | 16 +++------------- negpy_core/manifest.toml | 6 +++--- negpy_core/src/analysis.rs | 25 +++++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 15 +++++++++++++++ scripts/replay.py | 2 +- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 528ec4671..2ec2e1438 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -323,13 +323,8 @@ def _get_threshold_autocrop_coords( if assist_luma is not None: threshold = float(np.clip(assist_luma - 0.02, 0.5, 0.98)) - rows_det = np.where(np.mean(lum, axis=1) < threshold)[0] - cols_det = np.where(np.mean(lum, axis=0) < threshold)[0] - - if len(rows_det) < 10 or len(cols_det) < 10: - return 0, h, 0, w - - return int(rows_det[0]), int(rows_det[-1]), int(cols_det[0]), int(cols_det[-1]) + box = negpy_core.threshold_box(np.ascontiguousarray(lum, dtype=np.float32), threshold) + return box if box is not None else (0, h, 0, w) def _trim_opaque_border( @@ -1014,12 +1009,7 @@ def resolve_autocrop_rect( the same at any resolution. Runs at AUTOCROP_DETECT_RES in GeometryProcessor order (rot90 -> flips -> fine rotation -> distortion -> keystone). Excludes Crop Offset, which get_manual_rect_coords adds per render; None when too small, or with `require_film_box` when no film box stands out from a holder or bed.""" - h, w = img.shape[:2] - det_s = min(1.0, AUTOCROP_DETECT_RES / max(h, w)) - if det_s < 1.0: - tmp = cv2.resize(img, (max(1, round(w * det_s)), max(1, round(h * det_s))), interpolation=cv2.INTER_AREA) - else: - tmp = img + tmp, _ = _normalize_detection_input(img, AUTOCROP_DETECT_RES) if geometry.rotation != 0: tmp = np.rot90(tmp, k=geometry.rotation) if geometry.flip_horizontal: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index da7fd7294..5d2b3797f 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -565,8 +565,8 @@ note = "negpy_core::analysis::find_rebate_level" ["features/geometry/logic.py::_get_threshold_autocrop_coords"] phase = 2 -status = "todo" -calls = ["np.mean"] +status = "ported" +calls = ["negpy_core.threshold_box"] depends = [] ["features/geometry/logic.py::_mask_from_blackhat"] @@ -823,7 +823,7 @@ note = "negpy_core::analysis::measure_film_edges" ["features/geometry/logic.py::resolve_autocrop_rect"] phase = 2 status = "todo" -calls = ["cv2.resize"] +calls = [] depends = ["_detect_film_bounds", "_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion", "get_autocrop_coords"] ["features/geometry/logic.py::resolve_meter_rect"] diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index bf8c3d7e1..1b030478d 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -144,6 +144,31 @@ fn np_min(a: f32, b: f32) -> f32 { } } +/// `_get_threshold_autocrop_coords` on an HxW luma plane: the first and last rows and columns +/// whose float32 mean is under `threshold` (row means pairwise, column means summed down in +/// order, as NumPy sums them), or None with fewer than ten of either. +pub fn threshold_box( + lum: &[f32], + h: usize, + w: usize, + threshold: f32, +) -> Option<(usize, usize, usize, usize)> { + let rows: Vec = (0..h) + .filter(|&y| pairwise_sum_f32(&lum[y * w..(y + 1) * w]) / (w as f32) < threshold) + .collect(); + let mut sums = vec![0f32; w]; + for row in lum.chunks_exact(w) { + sums.iter_mut().zip(row).for_each(|(s, &v)| *s += v); + } + let cols: Vec = (0..w) + .filter(|&x| sums[x] / (h as f32) < threshold) + .collect(); + if rows.len() < 10 || cols.len() < 10 { + return None; + } + Some((rows[0], rows[rows.len() - 1], cols[0], cols[cols.len() - 1])) +} + const BASE_BLOCK: usize = 8; const CLIP_LEVEL: f32 = 0.98; const FLAT_CV: f32 = 0.03; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 942d7fef1..b53d59fdb 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -423,6 +423,20 @@ fn block_median_grid<'py>( numpy::PyArray1::from_vec(py, grid).reshape(vec![h / b, w / b, c]) } +/// `_get_threshold_autocrop_coords` of an HxW float32 luma plane: (y1, y2, x1, x2) or None. +#[pyfunction] +fn threshold_box( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + threshold: f32, +) -> PyResult> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err("threshold_box needs a 2-D array")); + }; + let data = slice(&lum)?; + Ok(py.detach(|| analysis::threshold_box(data, h, w, threshold))) +} + type ConsensusFields = ( analysis::Roi, f64, @@ -551,6 +565,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(select_consensus, m)?)?; m.add_function(wrap_pyfunction!(normalize_log_image, m)?)?; m.add_function(wrap_pyfunction!(block_median_grid, m)?)?; + m.add_function(wrap_pyfunction!(threshold_box, m)?)?; m.add_function(wrap_pyfunction!(film_box_covers_enough, m)?)?; m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?) } diff --git a/scripts/replay.py b/scripts/replay.py index 5e825a08d..26311e430 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -127,7 +127,7 @@ def restore(self) -> None: setattr(owner, name, fn) -def _run_frame(path: str, repo=None) -> None: +def _run_frame(path: str, repo=None) -> tuple: """The frame's saved edit (or the defaults), with Auto Crop armed so detection runs.""" from negpy.domain.models import WorkspaceConfig from negpy.features.geometry.models import with_auto_crop From 709de4b7025b8eb35ab334b0c4f5543830e69946 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:49:02 +0200 Subject: [PATCH 038/133] perf(core): port batch autocrop's _vertical_edge_profile The roll fallback's edge profile runs in negpy_core: the left-associative channel mean, the float64 2..98 percentile stretch, |Sobel x|, each column's 80th percentile (float32, as for an integer q), the blur and the 99.5 - 50 percentile contrast. Replay: 34/34, and every recorded batch_autocrop function identical. Bench (one pinned core): 0.223x; detect_crop_candidate 0.802x. --- negpy/features/geometry/batch_autocrop.py | 28 ++--------- negpy_core/manifest.toml | 4 +- negpy_core/src/analysis.rs | 58 +++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 22 +++++++++ 4 files changed, 86 insertions(+), 26 deletions(-) diff --git a/negpy/features/geometry/batch_autocrop.py b/negpy/features/geometry/batch_autocrop.py index 6204c18a0..dec5a85b0 100644 --- a/negpy/features/geometry/batch_autocrop.py +++ b/negpy/features/geometry/batch_autocrop.py @@ -144,30 +144,10 @@ class ResolvedCrop: def _vertical_edge_profile(img: ImageBuffer) -> tuple[np.ndarray, float]: - """Return a contrast-normalized vertical-edge profile for template fallback.""" - arr = np.asarray(img, dtype=np.float32) - if arr.ndim == 3: - gray = arr[..., :3].mean(axis=2) - else: - gray = arr - finite = gray[np.isfinite(gray)] - if finite.size == 0 or gray.shape[1] < 2: - return np.zeros(gray.shape[1], dtype=np.float32), 0.0 - - low, high = np.percentile(finite, (2.0, 98.0)) - span = max(float(high - low), 1e-6) - normalized = np.clip((gray - low) / span, 0.0, 1.0).astype(np.float32) - grad = np.abs(negpy_core.sobel(normalized, 1, 0)) - profile = np.percentile(grad, 80, axis=0).astype(np.float32) - window = max(5, int(round(profile.size * 0.012))) - if window % 2 == 0: - window += 1 - profile = negpy_core.gaussian_blur(profile.reshape(1, -1), (window, 1), 0).ravel() - peak = float(np.max(profile)) if profile.size else 0.0 - contrast = max(0.0, float(np.percentile(profile, 99.5)) - float(np.percentile(profile, 50.0))) if profile.size else 0.0 - if peak > 0.0: - profile /= peak - return profile.astype(np.float32), contrast + """Return a contrast-normalized vertical-edge profile for template fallback; negpy_core + holds the rules.""" + profile, contrast = negpy_core.batch_edge_profile(np.ascontiguousarray(img, dtype=np.float32)) + return profile, contrast def _top_edge_slope(lum: np.ndarray, roi: ROI) -> float | None: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 5d2b3797f..fd013c4c7 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -451,8 +451,8 @@ depends = [] ["features/geometry/batch_autocrop.py::_vertical_edge_profile"] phase = 2 -status = "todo" -calls = ["negpy_core.gaussian_blur", "negpy_core.sobel", "np.percentile"] +status = "ported" +calls = ["negpy_core.batch_edge_profile"] depends = [] ["features/geometry/batch_autocrop.py::build_roll_template"] diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 1b030478d..2ec40063b 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -169,6 +169,64 @@ pub fn threshold_box( Some((rows[0], rows[rows.len() - 1], cols[0], cols[cols.len() - 1])) } +/// `_vertical_edge_profile` of batch autocrop for an HxW or HxWxC float32 image: the 80th +/// percentile of each column's |Sobel x| on the 2..98 percentile stretch of the channel mean, +/// blurred along the row and scaled to its peak, and its 99.5 - 50 percentile contrast. +pub fn batch_edge_profile(img: &[f32], h: usize, w: usize, cn: usize) -> (Vec, f64) { + let gray: Vec = if cn == 1 { + img.to_vec() + } else { + img.chunks_exact(cn) + .map(|p| (p[0] + p[1] + p[2]) / 3.0) + .collect() + }; + let finite: Vec = gray.iter().copied().filter(|v| v.is_finite()).collect(); + if finite.is_empty() || w < 2 { + return (vec![0.0; w], 0.0); + } + let p = crate::stats::np_percentiles_f64(&finite, &[2.0, 98.0]); + let (low, span) = (p[0], (p[1] - p[0]).max(1e-6)); + let normalized: Vec = gray + .iter() + .map(|&v| ((v as f64 - low) / span).clamp(0.0, 1.0) as f32) + .collect(); + let mut grad = vec![0f32; h * w]; + let _ = crate::filter::sep_filter2d( + &normalized, + &mut grad, + (h, w, 1), + (&[-1.0, 0.0, 1.0], &[1.0, 2.0, 1.0]), + Border::Reflect101, + ); + let columns: Vec = (0..w) + .into_par_iter() + .map(|x| { + let col: Vec = (0..h).map(|y| grad[y * w + x].abs()).collect(); + np_percentiles(&col, &[80.0]).map_or(f32::NAN, |p| p[0]) + }) + .collect(); + let window = { + let k = 5.max((w as f64 * 0.012).round_ties_even() as usize); + if k % 2 == 0 { k + 1 } else { k } + }; + let mut profile = vec![0f32; w]; + let _ = crate::filter::gaussian_blur( + &columns, + &mut profile, + (1, w, 1), + (window, 1), + (0.0, 0.0), + Border::Reflect101, + ); + let peak = profile.iter().copied().fold(f32::NEG_INFINITY, np_max); + let pct = |q: f64| np_percentiles(&profile, &[q]).map_or(f32::NAN, |p| p[0]) as f64; + let contrast = 0f64.max(pct(99.5) - pct(50.0)); + if peak > 0.0 { + profile.iter_mut().for_each(|v| *v /= peak); + } + (profile, contrast) +} + const BASE_BLOCK: usize = 8; const CLIP_LEVEL: f32 = 0.98; const FLAT_CV: f32 = 0.03; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index b53d59fdb..d245e1aaa 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -437,6 +437,27 @@ fn threshold_box( Ok(py.detach(|| analysis::threshold_box(data, h, w, threshold))) } +/// Batch autocrop's `_vertical_edge_profile` of an HxW or HxWxC (C >= 3) float32 image: +/// (profile, contrast). +#[pyfunction] +fn batch_edge_profile<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult<(Bound<'py, numpy::PyArray1>, f64)> { + let (h, w, cn) = match img.shape()[..] { + [h, w] | [h, w, 1] => (h, w, 1), + [h, w, c] if c >= 3 => (h, w, c), + _ => { + return Err(PyValueError::new_err( + "batch_edge_profile needs an HxW, HxWx1 or HxWxC array, C >= 3", + )); + } + }; + let data = slice(&img)?; + let (profile, contrast) = py.detach(|| analysis::batch_edge_profile(data, h, w, cn)); + Ok((numpy::PyArray1::from_vec(py, profile), contrast)) +} + type ConsensusFields = ( analysis::Roi, f64, @@ -566,6 +587,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(normalize_log_image, m)?)?; m.add_function(wrap_pyfunction!(block_median_grid, m)?)?; m.add_function(wrap_pyfunction!(threshold_box, m)?)?; + m.add_function(wrap_pyfunction!(batch_edge_profile, m)?)?; m.add_function(wrap_pyfunction!(film_box_covers_enough, m)?)?; m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?) } From 404e99eb321b2e031a7f8b8a6021b471c8eb369f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 01:56:53 +0200 Subject: [PATCH 039/133] perf(core): measure_base_log and film_and_picture are single Rust calls film_and_picture runs the film detector and the picture gate in one negpy_core call. measure_base_log adds the margin band, the flat base blocks and the base-versus-picture test (float32 pixel means summed left to right, NumPy medians) and takes log10 through libm. _margin_band and _base_blocks lose their Python wrappers; their tests call negpy_core.base_blocks. Replay: measure_base_log 59/59 (21 with a base, 38 without), film_and_picture 59/59. Bench (one pinned core): 0.368x and 0.450x. --- negpy/features/exposure/normalization.py | 48 +++----------- negpy/features/geometry/logic.py | 7 +-- negpy_core/manifest.toml | 12 ++-- negpy_core/src/analysis.rs | 80 ++++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 35 +++++++++++ tests/test_density_above_base.py | 6 +- 6 files changed, 132 insertions(+), 56 deletions(-) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index b209fd9fb..281101b77 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -4,8 +4,8 @@ import negpy_core import numpy as np -from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ROI, ImageBuffer -from negpy.features.geometry.logic import AUTOCROP_DETECT_RES, _normalize_detection_input, film_and_picture +from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ImageBuffer +from negpy.features.geometry.logic import AUTOCROP_DETECT_RES, _normalize_detection_input from negpy.features.process.models import ProcessMode from negpy.kernel.image.logic import percentile_from_sorted from negpy.kernel.image.validation import ensure_image @@ -363,53 +363,19 @@ def measure_neutral_axis( return measure_neutral_axis_from_log(img_log, bounds, roi, analysis_buffer) -#: The clear base is read from flat blocks in the film box's margin, grouped by brightness, the -#: way negdensity.py reads it. The film box comes from the crop's detector at its own resolution, -#: whose edge walk is calibrated there. -_BASE_MARGIN = 0.15 # the band inside the film box, per side, that holds rebate, perforations and picture edge _OPEN_LIGHT_GAP = 0.08 # decades a brighter, smaller group must clear the base to be bare light -def _margin_band(shape: Tuple[int, int], film_roi: ROI) -> np.ndarray: - """Inside the film box, outside its central core: where the rebate and the perforations lie.""" - fy1, fy2, fx1, fx2 = film_roi - band = np.zeros(shape, bool) - band[fy1:fy2, fx1:fx2] = True - my, mx = int((fy2 - fy1) * _BASE_MARGIN), int((fx2 - fx1) * _BASE_MARGIN) - band[fy1 + my : fy2 - my, fx1 + mx : fx2 - mx] = False - return band - - -def _base_blocks(det: np.ndarray, band: np.ndarray) -> Optional[np.ndarray]: - """Per-channel means of the band's flat blocks that are clear base, or None without enough. - - Flat blocks are grouped by brightness; the largest group is the base. A smaller group - well above it is bare light through a perforation or past the film's edge. A flat - picture region in the band that outnumbers the base is taken for it. negpy_core holds the - rules: 8x8 blocks, flat under 3% std over mean, unclipped under 0.98, groups split at 0.04 - decades, at least max(5, 2%) blocks.""" - return negpy_core.base_blocks(np.ascontiguousarray(det, dtype=np.float32), np.ascontiguousarray(band, dtype=bool)) - - def measure_base_log(image: ImageBuffer) -> Optional[Tuple[float, float, float]]: """Per-channel log10 transmittance of the clear base: the median of the flat base blocks in the film box's margin on the uncropped frame. None when the scan shows no film box, the margin holds too few flat blocks, or the base is darker than the picture, because a - negative's base is its thinnest film.""" + negative's base is its thinnest film. The blocks group by brightness, the way negdensity.py + reads the base; the film box comes from the crop's detector at its own resolution, whose + edge walk is calibrated there.""" det, _ = _normalize_detection_input(image, AUTOCROP_DETECT_RES) - det = np.ascontiguousarray(det, dtype=np.float32) - film_roi, gate = film_and_picture(det) - if film_roi is None: - return None - base = _base_blocks(det, _margin_band(det.shape[:2], film_roi)) - if base is None: - return None - gy1, gy2, gx1, gx2 = gate - picture = det[gy1:gy2, gx1:gx2].reshape(-1, 3) - if np.median(base.mean(axis=1)) <= np.median(picture.mean(axis=1)): - return None - r, g, b = (float(np.log10(max(float(v), 1e-6))) for v in np.median(base, axis=0)) - return (r, g, b) + base = negpy_core.measure_base_log(np.ascontiguousarray(det, dtype=np.float32)) + return None if base is None else (base[0], base[1], base[2]) def log_from_val(val_rgb: Tuple[float, float, float], bounds: LogNegativeBounds) -> Tuple[float, float, float]: diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 2ec2e1438..39cad537a 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -270,12 +270,7 @@ def film_and_picture(det: np.ndarray) -> Tuple[Optional[ROI], ROI]: """The film box (None when the scan shows no film edge) and the picture gate inside it, at detection resolution. With no film box the gate is refined over the whole frame; a degenerate gate (a blank or dark frame) falls back to the whole frame.""" - h, w = det.shape[:2] - film = detect_film_bounds_with_confidence(det) - gate, _, _ = _refine_roi_to_image(det, film.roi if film.roi is not None else (0, h, 0, w)) - if gate[1] <= gate[0] or gate[3] <= gate[2]: - gate = (0, h, 0, w) - return film.roi, gate + return negpy_core.film_and_picture(np.ascontiguousarray(det, dtype=np.float32)) def rebate_mask(shape: Tuple[int, int], film_roi: Optional[ROI], gate: ROI) -> np.ndarray: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index fd013c4c7..b2d2d3eb7 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -289,9 +289,9 @@ depends = ["_block_median_grid"] ["features/exposure/normalization.py::measure_base_log"] phase = 2 -status = "todo" -calls = ["np.median"] -depends = ["_base_blocks"] +status = "ported" +calls = ["negpy_core.measure_base_log"] +depends = [] ["features/exposure/normalization.py::measure_clip_fractions"] phase = 2 @@ -766,9 +766,9 @@ depends = [] ["features/geometry/logic.py::film_and_picture"] phase = 2 -status = "todo" -calls = [] -depends = ["_refine_roi_to_image", "detect_film_bounds_with_confidence"] +status = "ported" +calls = ["negpy_core.film_and_picture"] +depends = [] ["features/geometry/logic.py::get_autocrop_coords"] phase = 2 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 2ec40063b..4c8f9b12e 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -227,6 +227,86 @@ pub fn batch_edge_profile(img: &[f32], h: usize, w: usize, cn: usize) -> (Vec Option<[f64; 3]> { + let mean = |p: &[f32]| (p[0] + p[1] + p[2]) / 3.0; + let mut picture: Vec = (gy1..gy2) + .flat_map(|y| (gx1..gx2).map(move |x| (y * w + x) * 3)) + .map(|i| mean(&det[i..i + 3])) + .collect(); + let mut base_means: Vec = base.iter().map(|p| mean(p)).collect(); + // `<=` is false for NaN, so a NaN median keeps the base. + if np_median(&mut base_means) <= np_median(&mut picture) { + return None; + } + Some(std::array::from_fn(|c| { + let mut col: Vec = base.iter().map(|p| p[c]).collect(); + libm::log10((np_median(&mut col) as f64).max(1e-6)) + })) +} + +/// `film_and_picture(det)` for an HxWx3 detection image: the film box (None when the scan +/// shows no film edge) and the picture gate inside it, or over the whole frame without a box; a +/// degenerate gate falls back to the whole frame. None when the frame is too small to refine. +pub fn film_and_picture(det: &[f32], h: usize, w: usize) -> Option<(Option, Roi)> { + let film = crate::detect::detect_film_bounds(det, h, w, 3) + .consensus + .map(|c| c.roi); + let whole = (0, h as i64, 0, w as i64); + let (gate, _) = refine_roi_to_image(det, h, w, 3, film.unwrap_or(whole))?; + let gate = if gate.1 <= gate.0 || gate.3 <= gate.2 { + whole + } else { + gate + }; + Some((film, gate)) +} + +// The band inside the film box, per side, that holds rebate, perforations and picture edge. +const BASE_MARGIN: f64 = 0.15; + +/// `measure_base_log(image)` on its HxWx3 detection image: per-channel log10 transmittance of +/// the clear base, the median of the flat blocks in the film box's margin band. None without a +/// film box, with too few flat blocks, or when the base is darker than the picture, because a +/// negative's base is its thinnest film. +pub fn measure_base_log(det: &[f32], h: usize, w: usize) -> Option<[f64; 3]> { + let (film, gate) = film_and_picture(det, h, w)?; + let (fy1, fy2, fx1, fx2) = film?; + let (my, mx) = ( + ((fy2 - fy1) as f64 * BASE_MARGIN) as i64, + ((fx2 - fx1) as f64 * BASE_MARGIN) as i64, + ); + let clamp = |v: i64, n: usize| v.clamp(0, n as i64) as usize; + let mut band = vec![0u8; h * w]; + for y in clamp(fy1, h)..clamp(fy2, h) { + band[y * w + clamp(fx1, w)..y * w + clamp(fx2, w).max(clamp(fx1, w))].fill(1); + } + let (cy1, cy2, cx1, cx2) = ( + clamp(fy1 + my, h), + clamp(fy2 - my, h), + clamp(fx1 + mx, w), + clamp(fx2 - mx, w), + ); + for y in cy1..cy2.max(cy1) { + band[y * w + cx1..y * w + cx2.max(cx1)].fill(0); + } + let base = base_blocks(det, &band, h, w)?; + let gate = ( + clamp(gate.0, h), + clamp(gate.1, h), + clamp(gate.2, w), + clamp(gate.3, w), + ); + base_log_from_blocks(det, w, gate, &base) +} + const BASE_BLOCK: usize = 8; const CLIP_LEVEL: f32 = 0.98; const FLAT_CV: f32 = 0.03; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index d245e1aaa..291c5c637 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -458,6 +458,39 @@ fn batch_edge_profile<'py>( Ok((numpy::PyArray1::from_vec(py, profile), contrast)) } +/// The HxW of an HxWx3 detection image. +fn det_dims(det: &PyReadonlyArrayDyn<'_, f32>) -> PyResult<(usize, usize)> { + match det.shape()[..] { + [h, w, 3] => Ok((h, w)), + _ => Err(PyValueError::new_err("needs an HxWx3 array")), + } +} + +/// `film_and_picture(det)` of an HxWx3 float32 detection image: (film roi or None, gate). +#[pyfunction] +fn film_and_picture( + py: Python<'_>, + det: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<(Option, analysis::Roi)> { + let (h, w) = det_dims(&det)?; + let data = slice(&det)?; + py.detach(|| analysis::film_and_picture(data, h, w)) + .ok_or_else(|| { + PyValueError::new_err("film_and_picture: image too small for the Sobel path") + }) +} + +/// `measure_base_log` of an HxWx3 float32 detection image: (r, g, b) log10 or None. +#[pyfunction] +fn measure_base_log( + py: Python<'_>, + det: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult> { + let (h, w) = det_dims(&det)?; + let data = slice(&det)?; + Ok(py.detach(|| analysis::measure_base_log(data, h, w))) +} + type ConsensusFields = ( analysis::Roi, f64, @@ -588,6 +621,8 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(block_median_grid, m)?)?; m.add_function(wrap_pyfunction!(threshold_box, m)?)?; m.add_function(wrap_pyfunction!(batch_edge_profile, m)?)?; + m.add_function(wrap_pyfunction!(film_and_picture, m)?)?; + m.add_function(wrap_pyfunction!(measure_base_log, m)?)?; m.add_function(wrap_pyfunction!(film_box_covers_enough, m)?)?; m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?) } diff --git a/tests/test_density_above_base.py b/tests/test_density_above_base.py index b3c1d91cb..fcda54066 100644 --- a/tests/test_density_above_base.py +++ b/tests/test_density_above_base.py @@ -47,7 +47,7 @@ def _band_frame(fill: list[tuple[float, float, float]]) -> tuple[np.ndarray, np. def test_the_largest_flat_group_is_the_base(): """A flat sliver of bare light, brighter than the rebate, is the smaller group.""" - from negpy.features.exposure.normalization import _base_blocks + from negpy_core import base_blocks as _base_blocks det, band = _band_frame([(0.84, 0.002, 0.30), (0.47, 0.004, 0.70)]) @@ -58,7 +58,7 @@ def test_the_largest_flat_group_is_the_base(): def test_clipped_light_is_never_the_base(): - from negpy.features.exposure.normalization import _base_blocks + from negpy_core import base_blocks as _base_blocks det, band = _band_frame([(0.995, 0.001, 0.70), (0.60, 0.004, 0.30)]) # holes at sensor white @@ -69,7 +69,7 @@ def test_clipped_light_is_never_the_base(): def test_a_band_with_no_flat_film_reads_nothing(): - from negpy.features.exposure.normalization import _base_blocks + from negpy_core import base_blocks as _base_blocks det, band = _band_frame([(0.5, 0.2, 1.0)]) # texture everywhere, no flat block From 9a017153c2715a09f994e6d2e920de9e1564d201 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:13:34 +0200 Subject: [PATCH 040/133] perf(core): port the pixel-format Numba kernels _to_uint8_jit, _to_uint16_jit, uint8_to_float32, uint16_to_float32, the two luminance-to-uint kernels, rgb_to_rgba_into and rgba_to_rgb_into run in negpy_core (convert.rs); their Numba code is deleted. Truncation adds 2^52 and reads the low mantissa bits: Rust's saturating `as` cast kept LLVM from vectorizing these loops. The luminance kernels fuse as Numba's fastmath did. AVX-512 builds of the two quantize loops keep pace with Numba, which compiles for the host CPU. Replay: uint16_to_float32 25/25 (Phase 3 corpus recorded today). Every kernel matches Numba on random, NaN, +-inf and rounding-edge inputs. 24 MP, one pinned core, against Numba: to_uint16 0.98x, to_uint8 0.99x, uint16/uint8_to_float32 0.44x/0.49x, luma 0.55x, rgb_to_rgba 0.99x, rgba_to_rgb 0.54x. --- negpy/kernel/image/logic.py | 178 ++++---------------------------- negpy_core/manifest.toml | 38 +++---- negpy_core/src/convert.rs | 155 ++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/convert.rs | 190 +++++++++++++++++++++++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_numba_nogil.py | 12 --- 7 files changed, 387 insertions(+), 189 deletions(-) create mode 100644 negpy_core/src/convert.rs create mode 100644 negpy_core/src/py/convert.rs diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index f13e18118..4cb547f9d 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -11,112 +11,48 @@ logger = get_logger(__name__) -@njit(cache=True, nogil=True, fastmath=True) def _to_uint16_jit(img: np.ndarray) -> np.ndarray: """ - Scale to uint16 (rounds to nearest, clips & handles NaNs). + Scale a float32 buffer to uint16: rounds to nearest, clips, NaN to 0. The +0.5 rounds. A bare cast truncates toward zero, which biases every sample down by half a level and doubles the quantisation error: the error distribution runs [-1, 0] - instead of [-0.5, +0.5]. The luma kernels below have always rounded; these did not. + instead of [-0.5, +0.5]. """ - res = np.empty_like(img, dtype=np.uint16) - img_flat = img.reshape(-1) - res_flat = res.reshape(-1) - - for i in range(len(img_flat)): - val = img_flat[i] - if np.isnan(val): - v = 0.0 - else: - v = val * 65535.0 + 0.5 - - if v < 0.0: - v = 0.0 - elif v > 65535.0: - v = 65535.0 - - res_flat[i] = np.uint16(v) - return res + return negpy_core.to_uint16(np.ascontiguousarray(img, dtype=np.float32)) -@njit(cache=True, nogil=True, fastmath=True) def _to_uint8_jit(img: np.ndarray) -> np.ndarray: """ - Scale to uint8 (rounds to nearest, clips & handles NaNs). See _to_uint16_jit. + Scale a float32 buffer to uint8: rounds to nearest, clips, NaN to 0. See _to_uint16_jit. """ - res = np.empty_like(img, dtype=np.uint8) - img_flat = img.reshape(-1) - res_flat = res.reshape(-1) - - for i in range(len(img_flat)): - val = img_flat[i] - if np.isnan(val): - v = 0.0 - else: - v = val * 255.0 + 0.5 + return negpy_core.to_uint8(np.ascontiguousarray(img, dtype=np.float32)) - if v < 0.0: - v = 0.0 - elif v > 255.0: - v = 255.0 - res_flat[i] = np.uint8(v) - return res - - -@njit(cache=True, nogil=True, fastmath=True) def uint8_to_float32(img: np.ndarray) -> np.ndarray: """ - Fast JIT conversion from uint8 to float32 [0.0, 1.0]. + uint8 to float32 [0.0, 1.0]. """ - h, w, c = img.shape - res = np.empty((h, w, c), dtype=np.float32) - inv_255 = 1.0 / 255.0 - for y in range(h): - for x in range(w): - for ch in range(c): - res[y, x, ch] = np.float32(img[y, x, ch]) * inv_255 - return res + return negpy_core.uint8_to_float32(np.ascontiguousarray(img, dtype=np.uint8)) -@njit(cache=True, nogil=True) def rgb_to_rgba_into(src: np.ndarray, dst: np.ndarray) -> None: - """Copies the three colour planes of ``src`` into the RGBA ``dst``; alpha is left as is. - numpy cannot vectorise a 3-of-4 strided copy, so a texture upload spent longer here than - on the GPU.""" - h, w = src.shape[0], src.shape[1] - for y in range(h): - for x in range(w): - dst[y, x, 0] = src[y, x, 0] - dst[y, x, 1] = src[y, x, 1] - dst[y, x, 2] = src[y, x, 2] - - -@njit(cache=True, nogil=True) + """Copies the three colour planes of float32 ``src`` into the RGBA ``dst``; alpha is left + as is. numpy cannot vectorise a 3-of-4 strided copy, so a texture upload spent longer here + than on the GPU.""" + negpy_core.rgb_to_rgba_into(np.asarray(src, dtype=np.float32), dst) + + def rgba_to_rgb_into(src: np.ndarray, dst: np.ndarray, oy: int, ox: int) -> None: - """Writes the RGB of ``src[oy:oy+h, ox:ox+w]`` into the (h, w, 3) ``dst``.""" - h, w = dst.shape[0], dst.shape[1] - for y in range(h): - for x in range(w): - dst[y, x, 0] = src[oy + y, ox + x, 0] - dst[y, x, 1] = src[oy + y, ox + x, 1] - dst[y, x, 2] = src[oy + y, ox + x, 2] + """Writes the RGB of float32 ``src[oy:oy+h, ox:ox+w]`` into the (h, w, 3) ``dst``.""" + negpy_core.rgba_to_rgb_into(np.asarray(src, dtype=np.float32), dst, int(oy), int(ox)) -@njit(cache=True, nogil=True, fastmath=True) def uint16_to_float32(img: np.ndarray) -> np.ndarray: """ - Fast JIT conversion from uint16 to float32 [0.0, 1.0]. + uint16 to float32 [0.0, 1.0]. """ - h, w, c = img.shape - res = np.empty((h, w, c), dtype=np.float32) - inv_65535 = 1.0 / 65535.0 - for y in range(h): - for x in range(w): - for ch in range(c): - res[y, x, ch] = np.float32(img[y, x, ch]) * inv_65535 - return res + return negpy_core.uint16_to_float32(np.ascontiguousarray(img, dtype=np.uint16)) def srgb_to_linear(img: np.ndarray) -> np.ndarray: @@ -485,86 +421,12 @@ def lab_to_rgb_working(lab: np.ndarray) -> np.ndarray: return out.reshape(arr.shape) -@njit(cache=True, nogil=True, fastmath=True) -def _float_to_uint8_luma_jit(img: np.ndarray) -> np.ndarray: - """ - Luminance -> uint8. - """ - scale = 255.0 - dtype = np.uint8 - - if img.ndim == 2: - h, w = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - v = img[y, x] * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - else: - h, w, c = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - lum = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - v = lum * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - - -@njit(cache=True, nogil=True, fastmath=True) -def _float_to_uint16_luma_jit(img: np.ndarray) -> np.ndarray: - """ - Luminance -> uint16. - """ - scale = 65535.0 - dtype = np.uint16 - - if img.ndim == 2: - h, w = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - v = img[y, x] * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - else: - h, w, c = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - lum = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - v = lum * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - - def float_to_uint_luma(img: np.ndarray, bit_depth: int = 8) -> np.ndarray: """ - Fuses luminance calculation and bit-depth conversion. - Dispatches to specialized JIT kernels based on bit_depth. + Fuses luminance calculation and bit-depth conversion: an HxWx3 float32 buffer (or an + HxW one, taken as luminance) to HxW uint8, or uint16 for ``bit_depth`` 16. """ - if bit_depth == 16: - res_16: np.ndarray = _float_to_uint16_luma_jit(img) - return res_16 - res_8: np.ndarray = _float_to_uint8_luma_jit(img) - return res_8 + return negpy_core.luma_to_uint(np.ascontiguousarray(img, dtype=np.float32), 16 if bit_depth == 16 else 8) def float_to_uint16(img: np.ndarray) -> np.ndarray: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index b2d2d3eb7..d2a41b8eb 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -972,13 +972,13 @@ depends = [] ["kernel/image/logic.py::_float_to_uint16_luma_jit"] phase = 3 -status = "todo" +status = "ported" calls = ["numba"] depends = [] ["kernel/image/logic.py::_float_to_uint8_luma_jit"] phase = 3 -status = "todo" +status = "ported" calls = ["numba"] depends = [] @@ -1044,14 +1044,14 @@ depends = [] ["kernel/image/logic.py::_to_uint16_jit"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.to_uint16"] depends = [] ["kernel/image/logic.py::_to_uint8_jit"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.to_uint8"] depends = [] ["kernel/image/logic.py::apply_primaries_transform"] @@ -1062,21 +1062,21 @@ depends = ["_matmul_3x3_kernel", "working_oetf_decode", "working_oetf_encode"] ["kernel/image/logic.py::float_to_uint16"] phase = 3 -status = "todo" +status = "ported" calls = [] depends = ["_to_uint16_jit"] ["kernel/image/logic.py::float_to_uint8"] phase = 3 -status = "todo" +status = "ported" calls = [] depends = ["_to_uint8_jit"] ["kernel/image/logic.py::float_to_uint_luma"] phase = 3 -status = "todo" -calls = [] -depends = ["_float_to_uint16_luma_jit", "_float_to_uint8_luma_jit"] +status = "ported" +calls = ["negpy_core.luma_to_uint"] +depends = [] ["kernel/image/logic.py::gamut_aware_chroma_scale"] phase = 3 @@ -1105,14 +1105,14 @@ depends = ["_rgb_to_lab_kernel"] ["kernel/image/logic.py::rgb_to_rgba_into"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.rgb_to_rgba_into"] depends = [] ["kernel/image/logic.py::rgba_to_rgb_into"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.rgba_to_rgb_into"] depends = [] ["kernel/image/logic.py::skin_chroma_rein"] @@ -1123,14 +1123,14 @@ depends = ["_skin_chroma_rein_kernel"] ["kernel/image/logic.py::uint16_to_float32"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.uint16_to_float32"] depends = [] ["kernel/image/logic.py::uint8_to_float32"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.uint8_to_float32"] depends = [] ["kernel/image/logic.py::working_oetf_decode"] diff --git a/negpy_core/src/convert.rs b/negpy_core/src/convert.rs new file mode 100644 index 000000000..d7ebd036d --- /dev/null +++ b/negpy_core/src/convert.rs @@ -0,0 +1,155 @@ +//! The pixel-format kernels of negpy/kernel/image/logic.py: float to uint8/uint16 and back, +//! luminance to uint, and the RGB/RGBA copies of the texture paths. + +use multiversion::multiversion; +use rayon::prelude::*; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; +const BLOCK: usize = 1 << 14; + +/// `v * scale + 0.5` clipped to 0..scale, NaN to 0, then truncated: the rounding of +/// `_to_uint8_jit` and `_to_uint16_jit`. A float32 times the scale is exact in float64. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn quantize_block(src: &[f32], dst: &mut [T]) { + for (d, &v) in dst.iter_mut().zip(src) { + // max and min drop NaN, so NaN becomes 0. + *d = T::truncate((v as f64 * T::SCALE + 0.5).max(0.0).min(T::SCALE)); + } +} + +pub fn to_uint(src: &[f32], dst: &mut [T]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| quantize_block(s, d)); +} + +/// `uint8_to_float32`: the float32 value times 1/255 in float64, rounded to float32. +pub fn uint8_to_float32(src: &[u8], dst: &mut [f32]) { + let inv = 1.0 / 255.0; + crate::par_zip(dst, src, move |d, &v| *d = (v as f32 as f64 * inv) as f32); +} + +/// `uint16_to_float32`: the float32 value times 1/65535 in float64, rounded to float32. +pub fn uint16_to_float32(src: &[u16], dst: &mut [f32]) { + let inv = 1.0 / 65535.0; + crate::par_zip(dst, src, move |d, &v| *d = (v as f32 as f64 * inv) as f32); +} + +/// Numba's fastmath luminance in float64: two fused multiply-adds. +fn luma(p: &[f32]) -> f64 { + LUMA_B.mul_add( + p[2] as f64, + LUMA_G.mul_add(p[1] as f64, LUMA_R * p[0] as f64), + ) +} + +pub trait Level: Copy + Send { + const SCALE: f64; + fn truncate(v: f64) -> Self; +} + +/// The integer part of `v` in 0..2^31: added to 2^52, it fills the low mantissa bits. The +/// saturating `as` cast keeps LLVM from vectorizing the loops; this rounds and adds instead. +fn trunc_bits(v: f64) -> u64 { + (v.trunc() + 4503599627370496.0).to_bits() +} + +impl Level for u8 { + const SCALE: f64 = 255.0; + fn truncate(v: f64) -> Self { + trunc_bits(v) as u8 + } +} + +impl Level for u16 { + const SCALE: f64 = 65535.0; + fn truncate(v: f64) -> Self { + trunc_bits(v) as u16 + } +} + +/// `_float_to_uint8_luma_jit` / `_float_to_uint16_luma_jit` over HxWx3 pixels. The +/// scale-and-round fuses too under fastmath. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn luma_block(src: &[f32], cn: usize, dst: &mut [T]) { + let scale = T::SCALE; + // NaN becomes 0, as Numba's cast of it does. + let clip = |v: f64| v.max(0.0).min(scale); + match cn { + 1 => { + for (d, &v) in dst.iter_mut().zip(src) { + *d = T::truncate(clip(v as f64 * scale + 0.5)); + } + } + 3 => { + for (d, p) in dst.iter_mut().zip(src.as_chunks::<3>().0) { + *d = T::truncate(clip(luma(p).mul_add(scale, 0.5))); + } + } + _ => { + for (d, p) in dst.iter_mut().zip(src.chunks_exact(cn)) { + *d = T::truncate(clip(luma(p).mul_add(scale, 0.5))); + } + } + } +} + +pub fn luma_to_uint(src: &[f32], cn: usize, dst: &mut [T]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK * cn)) + .for_each(|(d, s)| luma_block(s, cn, d)); +} + +/// The first three channels of each `cn`-channel pixel of `src` into the RGBA `dst`. +pub fn rgb_to_rgba(src: &[f32], cn: usize, dst: &mut [f32]) { + dst.par_chunks_mut(4 * BLOCK) + .zip(src.par_chunks(cn * BLOCK)) + .for_each(|(d, s)| { + for (dp, sp) in d.as_chunks_mut::<4>().0.iter_mut().zip(s.chunks_exact(cn)) { + dp[..3].copy_from_slice(&sp[..3]); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quantize_rounds_clips_and_zeroes_nan() { + let src = [ + 0.0, + 1.0, + 0.5 / 255.0, + 0.499 / 255.0, + -1.0, + 2.0, + f32::NAN, + f32::INFINITY, + ]; + let mut dst = [0u8; 8]; + to_uint(&src, &mut dst); + assert_eq!(dst, [0, 255, 1, 0, 0, 255, 0, 255]); + let mut wide = [0u16; 8]; + to_uint(&src, &mut wide); + assert_eq!(wide[1], 65535); + assert_eq!(wide[6], 0); + } + + #[test] + fn trunc_bits_is_the_integer_part() { + for v in [0.0, 0.999, 1.0, 254.9, 65535.0, 2_147_483_647.0] { + assert_eq!(trunc_bits(v) & 0xffff_ffff, v.trunc() as u64, "{v}"); + } + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 5e835813b..c8aedf30b 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,6 +1,7 @@ pub mod analysis; pub mod bounds; pub mod contours; +pub mod convert; pub mod detect; pub mod filter; pub mod gray; @@ -34,6 +35,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::morph::register(m)?; py::gray::register(m)?; py::contours::register(m)?; + py::convert::register(m)?; py::histogram::register(m)?; py::meter::register(m)?; py::bounds::register(m)?; diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs new file mode 100644 index 000000000..9b23bcd3b --- /dev/null +++ b/negpy_core/src/py/convert.rs @@ -0,0 +1,190 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray3, PyReadonlyArrayDyn, PyReadwriteArray3, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::convert::{self, Level}; + +fn contiguous<'a, T: numpy::Element>(a: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +fn empty<'py, T: numpy::Element>( + py: Python<'py>, + shape: &[usize], + dtype: &str, +) -> PyResult>> { + Ok(py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), dtype))? + .cast_into::>()?) +} + +/// Fills a new array of `shape` and `dtype` from `fill(dst)` with the GIL released. +fn filled<'py, T: numpy::Element>( + py: Python<'py>, + shape: &[usize], + dtype: &str, + fill: impl FnOnce(&mut [T]) + Send, +) -> PyResult>> { + let out = empty::(py, shape, dtype)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| fill(dst)); + } + Ok(out) +} + +/// `_to_uint8_jit` of a contiguous float32 array. +#[pyfunction] +fn to_uint8<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "uint8", |d| convert::to_uint(src, d)) +} + +/// `_to_uint16_jit` of a contiguous float32 array. +#[pyfunction] +fn to_uint16<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "uint16", |d| convert::to_uint(src, d)) +} + +/// `uint8_to_float32` of a contiguous uint8 array. +#[pyfunction] +fn uint8_to_float32<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| { + convert::uint8_to_float32(src, d) + }) +} + +/// `uint16_to_float32` of a contiguous uint16 array. +#[pyfunction] +fn uint16_to_float32<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u16>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| { + convert::uint16_to_float32(src, d) + }) +} + +fn luma_to<'py, T: Level + numpy::Element>( + py: Python<'py>, + img: &PyReadonlyArrayDyn<'py, f32>, + dtype: &str, +) -> PyResult>> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, c] if c >= 3 => (h, w, c), + _ => { + return Err(PyValueError::new_err("needs an HxW or HxWxC array, C >= 3")); + } + }; + let src = contiguous(img)?; + filled(py, &[h, w], dtype, |d| convert::luma_to_uint(src, cn, d)) +} + +/// `float_to_uint_luma(img, bit_depth)` of a contiguous float32 HxW or HxWxC array. +#[pyfunction] +fn luma_to_uint<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + bit_depth: u8, +) -> PyResult> { + Ok(if bit_depth == 16 { + luma_to::(py, &img, "uint16")?.into_any() + } else { + luma_to::(py, &img, "uint8")?.into_any() + }) +} + +/// `rgb_to_rgba_into(src, dst)`: the first three channels of `src` into the HxWx4 `dst`. +#[pyfunction] +fn rgb_to_rgba_into( + py: Python<'_>, + src: PyReadonlyArray3<'_, f32>, + mut dst: PyReadwriteArray3<'_, f32>, +) -> PyResult<()> { + let (s, mut d) = (src.as_array(), dst.as_array_mut()); + if s.dim().0 != d.dim().0 || s.dim().1 != d.dim().1 || s.dim().2 < 3 || d.dim().2 != 4 { + return Err(PyValueError::new_err("needs HxWx3 into HxWx4")); + } + let cn = s.dim().2; + if let (Some(src), Some(dst)) = (s.as_slice(), d.as_slice_mut()) { + py.detach(|| convert::rgb_to_rgba(src, cn, dst)); + return Ok(()); + } + py.detach(|| { + for (srow, mut drow) in s.outer_iter().zip(d.outer_iter_mut()) { + for (sp, mut dp) in srow.outer_iter().zip(drow.outer_iter_mut()) { + for c in 0..3 { + dp[c] = sp[c]; + } + } + } + }); + Ok(()) +} + +/// `rgba_to_rgb_into(src, dst, oy, ox)`: the RGB of `src[oy:oy+h, ox:ox+w]` into `dst`. +#[pyfunction] +fn rgba_to_rgb_into( + py: Python<'_>, + src: PyReadonlyArray3<'_, f32>, + mut dst: PyReadwriteArray3<'_, f32>, + oy: usize, + ox: usize, +) -> PyResult<()> { + let (s, mut d) = (src.as_array(), dst.as_array_mut()); + let (h, w, dc) = d.dim(); + if oy + h > s.dim().0 || ox + w > s.dim().1 || s.dim().2 < 3 || dc < 3 { + return Err(PyValueError::new_err("region outside the source")); + } + py.detach(|| { + for (y, mut drow) in d.outer_iter_mut().enumerate() { + let srow = s.index_axis(numpy::ndarray::Axis(0), oy + y); + // A padded readback has whole RGBA rows: copy them as slices when it does. + if let (Some(sr), Some(dr)) = (srow.as_slice(), drow.as_slice_mut()) { + let sc = srow.dim().1; + for (dp, sp) in dr.chunks_exact_mut(dc).zip(sr[ox * sc..].chunks_exact(sc)) { + dp[..3].copy_from_slice(&sp[..3]); + } + continue; + } + for (x, mut dp) in drow.outer_iter_mut().enumerate() { + for c in 0..3 { + dp[c] = srow[[ox + x, c]]; + } + } + } + }); + Ok(()) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(to_uint8, m)?)?; + m.add_function(wrap_pyfunction!(to_uint16, m)?)?; + m.add_function(wrap_pyfunction!(uint8_to_float32, m)?)?; + m.add_function(wrap_pyfunction!(uint16_to_float32, m)?)?; + m.add_function(wrap_pyfunction!(luma_to_uint, m)?)?; + m.add_function(wrap_pyfunction!(rgb_to_rgba_into, m)?)?; + m.add_function(wrap_pyfunction!(rgba_to_rgb_into, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 516706cbb..3145df988 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,6 +1,7 @@ pub mod analysis; pub mod bounds; pub mod contours; +pub mod convert; pub mod filter; pub mod gray; pub mod histogram; diff --git a/tests/test_numba_nogil.py b/tests/test_numba_nogil.py index 129732183..646bb809c 100644 --- a/tests/test_numba_nogil.py +++ b/tests/test_numba_nogil.py @@ -3,18 +3,6 @@ import pytest _KERNELS = [ - ("negpy.kernel.image.logic", name) - for name in ( - "_to_uint16_jit", - "_to_uint8_jit", - "uint8_to_float32", - "rgb_to_rgba_into", - "rgba_to_rgb_into", - "uint16_to_float32", - "_float_to_uint8_luma_jit", - "_float_to_uint16_luma_jit", - ) -] + [ ("negpy.features.exposure.logic", "_apply_print_curve_kernel"), ("negpy.infrastructure.display.icc_lut", "_apply_lut_u16_jit"), ("negpy.infrastructure.display.icc_lut", "_apply_lut_f32_jit"), From c17cb5211f7de4f8bf339d47eb8f81ab8ba964d9 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:13:46 +0200 Subject: [PATCH 041/133] docs(core): saturating casts keep loops scalar --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 64e1713df..a07e1df68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. - `mul_add` is one instruction only on a CPU with FMA; the release baseline has none, so a loop that uses it takes `#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))]`. `mul_add` is exactly fused on every path, so the bits never depend on the CPU. - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. +- A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. From 46b18c8f26384f4f520f48919a6e30929dd7bf54 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:27:52 +0200 Subject: [PATCH 042/133] feat(core): box filter (cv2.boxFilter / cv2.blur) for float32 Phase 4 primitive. OpenCV's two float paths (blockSum for kernels up to 5x5, the filter engine past that) share one column recurrence: add the new row sum, write, subtract the row sum from kh - 1 rows back. Rows sum afresh up to five wide and keep a running float64 sum past that. Bands of rows compute their row sums in parallel; the column recurrence runs in parallel column strips. 672 oracle cases bit-identical to cv2 (all borders, 1/3/4 channels, normalized or not), on one thread and on many. Speed debt: on one pinned core it takes 1.6x (3x3) and 1.3x (9x9, 31x31) of cv2's single-thread time on 12 MP; more threads do not help yet, because ring rows cross cores. It has no caller yet; the Phase 4 port that adopts it carries the speed gate. --- negpy_core/src/filter.rs | 207 ++++++++++++++++++++++++++++++++++++ negpy_core/src/py/filter.rs | 18 ++++ tests/test_core_filter.py | 11 ++ 3 files changed, 236 insertions(+) diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs index b0e365405..56bb511ed 100644 --- a/negpy_core/src/filter.rs +++ b/negpy_core/src/filter.rs @@ -603,6 +603,213 @@ pub fn gaussian_blur( sep_filter2d(src, dst, (h, w, cn), (&kx, &ky), border) } +/// `cv2.boxFilter(src, -1, (kw, kh), normalize=..., borderType=border)` (and `cv2.blur`) for +/// float32, anchor at the center. Row sums in float64: a fresh sum per output up to five wide, +/// a running sum (`s += a - b`) past that. Column sums keep a running total: add the new row +/// sum, write, subtract the row sum from `kh - 1` rows back. Both OpenCV paths (`blockSum` and +/// the filter engine) run that recurrence. +pub fn box_filter( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + (kw, kh): (usize, usize), + normalize: bool, + border: Border, +) -> Option<()> { + if h == 0 + || w == 0 + || cn == 0 + || kw == 0 + || kh == 0 + || src.len() != h * w * cn + || dst.len() != src.len() + { + return None; + } + let (ax, ay) = (kw / 2, kh / 2); + let n = w * cn; + let scale = if normalize { + 1.0 / (kw * kh) as f64 + } else { + 1.0 + }; + let xmap: Vec> = (0..w + kw - 1) + .map(|p| border_interpolate(p as isize - ax as isize, w, border)) + .collect(); + + let ymap: Vec> = (0..h + kh - 1) + .map(|r| border_interpolate(r as isize - ay as isize, h, border)) + .collect(); + let row_sums = |y: Option, pad: &mut Vec, out: &mut [f64]| { + let Some(y) = y else { + out.fill(0.0); + return; + }; + let row = &src[y * n..(y + 1) * n]; + let tap = |p: usize, c: usize| xmap[p].map_or(0.0, |sx| row[sx * cn + c] as f64); + if kw <= 5 { + // Each output sums its taps left to right from zero. Outputs whose taps all lie + // inside the row run kernel-major straight off the source. + let lo = ax.min(w); + let hi = (w + ax + 1).saturating_sub(kw).max(lo); + for x in (0..lo).chain(hi..w) { + for c in 0..cn { + out[x * cn + c] = (0..kw).fold(0.0, |s, k| s + tap(x + k, c)); + } + } + if hi == lo { + return; + } + let inner = &mut out[lo * cn..hi * cn]; + let tap_row = |k: usize| &row[(lo + k - ax) * cn..]; + let f = |v: f32| v as f64; + match kw { + 1 => inner + .iter_mut() + .zip(tap_row(0)) + .for_each(|(o, &a)| *o = 0.0 + f(a)), + 2 => inner + .iter_mut() + .zip(tap_row(0).iter().zip(tap_row(1))) + .for_each(|(o, (&a, &b))| *o = (0.0 + f(a)) + f(b)), + 3 => inner + .iter_mut() + .zip(tap_row(0).iter().zip(tap_row(1)).zip(tap_row(2))) + .for_each(|(o, ((&a, &b), &c))| *o = ((0.0 + f(a)) + f(b)) + f(c)), + 4 => inner + .iter_mut() + .zip( + tap_row(0) + .iter() + .zip(tap_row(1)) + .zip(tap_row(2)) + .zip(tap_row(3)), + ) + .for_each(|(o, (((&a, &b), &c), &d))| { + *o = (((0.0 + f(a)) + f(b)) + f(c)) + f(d) + }), + _ => inner + .iter_mut() + .zip( + tap_row(0) + .iter() + .zip(tap_row(1)) + .zip(tap_row(2)) + .zip(tap_row(3)) + .zip(tap_row(4)), + ) + .for_each(|(o, ((((&a, &b), &c), &d), &e))| { + *o = ((((0.0 + f(a)) + f(b)) + f(c)) + f(d)) + f(e) + }), + } + return; + } + pad.clear(); + pad.resize((w + kw - 1) * cn, 0.0); + for (p, &v) in pad[ax * cn..ax * cn + n].iter_mut().zip(row) { + *p = v as f64; + } + for p in (0..ax).chain(ax + w..w + kw - 1) { + for c in 0..cn { + pad[p * cn + c] = tap(p, c); + } + } + for c in 0..cn { + let mut s = (0..kw).fold(0.0, |s, k| s + pad[k * cn + c]); + out[c] = s; + for x in 1..w { + s += pad[(x + kw - 1) * cn + c] - pad[(x - 1) * cn + c]; + out[x * cn + c] = s; + } + } + }; + + // Bands of rows: their row sums in parallel, then the column recurrence (sequential down a + // column, independent across columns) in parallel strips, each into its own tile. The ring + // keeps the row sums a later row still subtracts. + const BAND: usize = 128; + const STRIP: usize = 512; + let slots = kh + BAND; + let mut ring: Vec> = vec![vec![0f64; n]; slots]; + let mut sum = vec![0f64; n]; + let zeros = vec![0f64; n]; + let total = h + kh - 1; + let mut rc0 = 0; + while rc0 < total { + let rc1 = (rc0 + BAND).min(total); + let band: Vec<(usize, &mut Vec)> = ring + .iter_mut() + .enumerate() + .filter_map(|(slot, row)| { + // The band row in this slot, if any: rc0 + ((slot - rc0) mod slots). + let rc = rc0 + (slot + slots - rc0 % slots) % slots; + (rc < rc1).then_some((rc, row)) + }) + .collect(); + band.into_par_iter() + .for_each_init(Vec::new, |pad, (rc, out)| row_sums(ymap[rc], pad, out)); + let ring = ˚ + let line = |rc: usize| &ring[rc % slots][..]; + // Output rows this band writes: extended row rc writes image row rc - (kh - 1). + let y0 = rc0.max(kh - 1) - (kh - 1); + let y1 = rc1.saturating_sub(kh - 1).max(y0); + // One strip per thread; when one strip spans the row it writes straight into dst. + let threads = rayon::current_num_threads(); + let strip = n.div_ceil(threads).max(STRIP); + let column = |x0: usize, s: &mut [f64], tile: &mut [f32], tile_w: usize, tile_x0: usize| { + let sw = s.len(); + for rc in rc0..rc1 { + let sp = &line(rc)[x0..x0 + sw]; + // Before kh - 1 rows have passed, nothing leaves the window yet. + let old = if rc + 1 >= kh { + &line(rc + 1 - kh)[x0..x0 + sw] + } else { + &zeros[..sw] + }; + if let Some(y) = rc.checked_sub(kh - 1) { + let at = (y - y0) * tile_w + tile_x0; + let o = &mut tile[at..at + sw]; + for i in 0..sw { + let s0 = s[i] + sp[i]; + o[i] = (s0 * scale) as f32; + s[i] = s0 - old[i]; + } + } else { + for i in 0..sw { + s[i] = (s[i] + sp[i]) - old[i]; + } + } + } + }; + if strip >= n { + column(0, &mut sum, &mut dst[y0 * n..y1 * n], n, 0); + } else { + let tiles: Vec> = sum + .par_chunks_mut(strip) + .enumerate() + .map(|(si, s)| { + let mut tile = vec![0f32; (y1 - y0) * s.len()]; + let sw = s.len(); + column(si * strip, s, &mut tile, sw, 0); + tile + }) + .collect(); + dst[y0 * n..y1 * n] + .par_chunks_mut(n) + .enumerate() + .for_each(|(j, out)| { + for (si, tile) in tiles.iter().enumerate() { + let sw = tile.len() / (y1 - y0); + out[si * strip..si * strip + sw] + .copy_from_slice(&tile[j * sw..(j + 1) * sw]); + } + }); + } + rc0 = rc1; + } + Some(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs index 762a4a652..75e7631ac 100644 --- a/negpy_core/src/py/filter.rs +++ b/negpy_core/src/py/filter.rs @@ -124,6 +124,23 @@ fn sobel<'py>( }) } +/// `cv2.boxFilter(src, -1, ksize, normalize=normalize, borderType=border_type)` for float32. +#[pyfunction] +#[pyo3(signature = (src, ksize, normalize=true, border_type=4))] +fn box_filter<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + ksize: (usize, usize), + normalize: bool, + border_type: i32, +) -> PyResult>> { + let img = image(&src)?; + let b = border(border_type)?; + filled(py, &img.shape, |dst| { + filter::box_filter(img.data, dst, img.dims, ksize, normalize, b) + }) +} + /// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)` as a flat list. #[pyfunction] fn gaussian_kernel(n: usize, sigma: f64) -> Vec { @@ -131,6 +148,7 @@ fn gaussian_kernel(n: usize, sigma: f64) -> Vec { } pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(box_filter, m)?)?; m.add_function(wrap_pyfunction!(gaussian_blur, m)?)?; m.add_function(wrap_pyfunction!(sep_filter2d, m)?)?; m.add_function(wrap_pyfunction!(sobel, m)?)?; diff --git a/tests/test_core_filter.py b/tests/test_core_filter.py index a220fda9e..5b35f0ae0 100644 --- a/tests/test_core_filter.py +++ b/tests/test_core_filter.py @@ -65,3 +65,14 @@ def test_sep_filter2d_matches_cv2_bit_for_bit(shape, border, name): def test_sep_filter2d_rejects_a_kernel_it_does_not_cover(): with pytest.raises(ValueError): negpy_core.sep_filter2d(_image(9, 9, 1), np.array([0.2, 0.5, 0.3], np.float32), np.array([1], np.float32)) + + +@pytest.mark.parametrize("shape", _SHAPES + [(9, 700, 1)]) +@pytest.mark.parametrize("border", _BORDERS) +@pytest.mark.parametrize("ksize", [(1, 1), (3, 3), (5, 5), (2, 4), (4, 2), (7, 7), (3, 9), (9, 3), (15, 15), (6, 11), (31, 1), (1, 31)]) +@pytest.mark.parametrize("normalize", [True, False]) +def test_box_filter_matches_cv2_bit_for_bit(shape, border, ksize, normalize): + img = (_image(*shape) * _RNG.choice([1.0, 1e3])).astype(np.float32) + want = cv2.boxFilter(img, -1, ksize, normalize=normalize, borderType=border) + got = negpy_core.box_filter(img, ksize, normalize, border) + assert got.tobytes() == want.tobytes() From f0ad4d88026a3aeebdb0d4f4ca35d88612183599 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:28:45 +0200 Subject: [PATCH 043/133] chore(core): replay record --set overrides one config field for every frame The real edits leave dust and IR repair off on the corpus files; a Phase 4 recording switches them on with --set retouch.dust_remove=true. --- scripts/replay.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/replay.py b/scripts/replay.py index 26311e430..103c6801d 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -15,6 +15,7 @@ import dataclasses import fcntl import importlib +import json import os import pickle import statistics @@ -127,7 +128,7 @@ def restore(self) -> None: setattr(owner, name, fn) -def _run_frame(path: str, repo=None) -> tuple: +def _run_frame(path: str, repo=None, overrides: dict | None = None) -> tuple: """The frame's saved edit (or the defaults), with Auto Crop armed so detection runs.""" from negpy.domain.models import WorkspaceConfig from negpy.features.geometry.models import with_auto_crop @@ -138,6 +139,8 @@ def _run_frame(path: str, repo=None) -> tuple: buffer, _, _ = PreviewManager().load_linear_preview(path) saved = repo.load_file_settings(file_hashes(path)[0]) if repo else None config = with_auto_crop(saved or WorkspaceConfig(), True) + for section, fields in (overrides or {}).items(): + config = dataclasses.replace(config, **{section: dataclasses.replace(getattr(config, section), **fields)}) # Stage caching on, as the preview runs: the base read (measure_base_log) happens only then. ImageProcessor(use_gpu=False).run_pipeline(buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False) return buffer, config @@ -175,7 +178,7 @@ def _edits_repo(edits_db: str | None): return StorageRepository(str(folder / "edits.db"), str(folder / "settings.db")) -def record(files: list[str], phases: list[int] | None, edits_db: str | None) -> None: +def record(files: list[str], phases: list[int] | None, edits_db: str | None, overrides: dict | None = None) -> None: keys = _targets(phases, ("todo",)) repo = _edits_repo(edits_db) import negpy.services.rendering.image_processor # noqa: F401 (load the pipeline before wrapping) @@ -188,7 +191,7 @@ def record(files: list[str], phases: list[int] | None, edits_db: str | None) -> evidence = [] for path in files: recorder.frame, recorder.per_frame = Path(path).name, {} - buffer, config = _run_frame(path, repo) + buffer, config = _run_frame(path, repo, overrides) evidence.append(_crop_evidence(str(len(evidence)), buffer, config)) print(f"recorded {path}") recorder.frame, recorder.per_frame = "roll", {} @@ -285,11 +288,23 @@ def main() -> None: parser.add_argument("files", nargs="*") parser.add_argument("--phase", type=int, action="append", help="only functions of this phase (repeatable)") parser.add_argument("--edits", help="record each frame with its saved edit from this edits.db (read from a copy)") + parser.add_argument( + "--set", + action="append", + default=[], + metavar="SECTION.FIELD=JSON", + help="override one config field for every frame, e.g. retouch.dust_remove=true", + ) args = parser.parse_args() if args.mode == "record": if not args.files: parser.error("record needs at least one frame") - record(args.files, args.phase, args.edits) + overrides: dict = {} + for item in args.set: + key, value = item.split("=", 1) + section, name = key.split(".", 1) + overrides.setdefault(section, {})[name] = json.loads(value) + record(args.files, args.phase, args.edits, overrides) else: sys.exit((replay if args.mode == "replay" else bench)(args.phase)) From d1d4f5faeef67d83e9ecd1149510d1efc64db279 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:31:02 +0200 Subject: [PATCH 044/133] feat(core): connected components with stats (cv2.connectedComponentsWithStats) Phase 4 primitive. Union-find over the uint8 mask, numbered as OpenCV's Spaghetti labelling numbers them: 4-connected components in raster order of their first pixel, 8-connected ones in raster order of their first 2x2 block. That numbering does not change with OpenCV's thread count. Stats and centroids follow CCStatsOp, the empty label included. 240 random cases and 70 oracle tests bit-identical to cv2. --- .github/workflows/ci.yml | 2 +- negpy_core/src/components.rs | 143 ++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/components.rs | 47 +++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_components.py | 26 ++++++ 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 negpy_core/src/components.rs create mode 100644 negpy_core/src/py/components.rs create mode 100644 tests/test_core_components.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17580e587..ad856c4a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py diff --git a/negpy_core/src/components.rs b/negpy_core/src/components.rs new file mode 100644 index 000000000..41ce3f991 --- /dev/null +++ b/negpy_core/src/components.rs @@ -0,0 +1,143 @@ +//! `cv2.connectedComponents(WithStats)` for a uint8 mask, with OpenCV's labels: its Spaghetti +//! labelling numbers 4-connected components in raster order of their first pixel, and +//! 8-connected ones in raster order of their first 2x2 block. The numbering does not depend on +//! OpenCV's thread count. + +pub struct Components { + pub count: usize, + pub labels: Vec, + /// Per label: left, top, width, height, area. + pub stats: Vec<[i32; 5]>, + pub centroids: Vec<[f64; 2]>, +} + +fn find(parent: &mut [u32], mut i: u32) -> u32 { + while parent[i as usize] != i { + let p = parent[i as usize]; + parent[i as usize] = parent[p as usize]; + i = p; + } + i +} + +fn union(parent: &mut [u32], a: u32, b: u32) -> u32 { + let (ra, rb) = (find(parent, a), find(parent, b)); + let (lo, hi) = (ra.min(rb), ra.max(rb)); + parent[hi as usize] = lo; + lo +} + +/// Components of the nonzero pixels of an `h` x `w` mask, `connectivity` 4 or 8. +pub fn connected_components(mask: &[u8], h: usize, w: usize, connectivity: u8) -> Components { + let mut prov = vec![0u32; h * w]; + let mut parent: Vec = vec![0]; + for y in 0..h { + for x in 0..w { + if mask[y * w + x] == 0 { + continue; + } + let mut neighbours = [0u32; 4]; + let mut k = 0; + let mut push = |l: u32| { + if l != 0 { + neighbours[k] = l; + k += 1; + } + }; + if x > 0 { + push(prov[y * w + x - 1]); + } + if y > 0 { + push(prov[(y - 1) * w + x]); + if connectivity == 8 { + if x > 0 { + push(prov[(y - 1) * w + x - 1]); + } + if x + 1 < w { + push(prov[(y - 1) * w + x + 1]); + } + } + } + prov[y * w + x] = if k == 0 { + parent.push(parent.len() as u32); + (parent.len() - 1) as u32 + } else { + let mut l = neighbours[0]; + for &n in &neighbours[1..k] { + l = union(&mut parent, l, n); + } + find(&mut parent, l) + }; + } + } + + // Number the roots by the first pixel (4-connected) or 2x2 block (8-connected) of their + // component in raster order. + let blocks_w = w.div_ceil(2); + let mut first = vec![usize::MAX; parent.len()]; + for y in 0..h { + for x in 0..w { + let l = prov[y * w + x]; + if l != 0 { + let root = find(&mut parent, l) as usize; + let key = if connectivity == 8 { + (y / 2) * blocks_w + x / 2 + } else { + y * w + x + }; + first[root] = first[root].min(key); + } + } + } + let mut roots: Vec = (1..parent.len()) + .filter(|&r| first[r] != usize::MAX) + .collect(); + roots.sort_by_key(|&r| first[r]); + let mut final_label = vec![0i32; parent.len()]; + for (i, &r) in roots.iter().enumerate() { + final_label[r] = i as i32 + 1; + } + + let count = roots.len() + 1; + let mut labels = vec![0i32; h * w]; + let mut lo = vec![[i32::MAX, i32::MAX, i32::MIN, i32::MIN, 0i32]; count]; + let mut integrals = vec![[0u64; 2]; count]; + for y in 0..h { + for x in 0..w { + let p = prov[y * w + x]; + let l = if p == 0 { + 0 + } else { + final_label[find(&mut parent, p) as usize] + }; + labels[y * w + x] = l; + let s = &mut lo[l as usize]; + let (xi, yi) = (x as i32, y as i32); + s[0] = s[0].min(xi); + s[1] = s[1].min(yi); + s[2] = s[2].max(xi); + s[3] = s[3].max(yi); + s[4] += 1; + integrals[l as usize][0] += x as u64; + integrals[l as usize][1] += y as u64; + } + } + let mut stats = Vec::with_capacity(count); + let mut centroids = Vec::with_capacity(count); + for (s, i) in lo.iter().zip(&integrals) { + let area = s[4] as f64; + if area > 0.0 { + stats.push([s[0], s[1], s[2] - s[0] + 1, s[3] - s[1] + 1, s[4]]); + centroids.push([i[0] as f64 / area, i[1] as f64 / area]); + } else { + stats.push([-1, s[1], 0, 0, 0]); + centroids.push([f64::NAN, f64::NAN]); + } + } + Components { + count, + labels, + stats, + centroids, + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index c8aedf30b..ed666be1f 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,5 +1,6 @@ pub mod analysis; pub mod bounds; +pub mod components; pub mod contours; pub mod convert; pub mod detect; @@ -35,6 +36,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::morph::register(m)?; py::gray::register(m)?; py::contours::register(m)?; + py::components::register(m)?; py::convert::register(m)?; py::histogram::register(m)?; py::meter::register(m)?; diff --git a/negpy_core/src/py/components.rs b/negpy_core/src/py/components.rs new file mode 100644 index 000000000..6da888977 --- /dev/null +++ b/negpy_core/src/py/components.rs @@ -0,0 +1,47 @@ +use numpy::{PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::components; + +type Labelled<'py> = ( + usize, + Bound<'py, numpy::PyArrayDyn>, + Bound<'py, numpy::PyArray2>, + Bound<'py, numpy::PyArray2>, +); + +/// `cv2.connectedComponentsWithStats(mask, connectivity=...)` of an HxW uint8 mask: +/// (count, labels, stats, centroids). +#[pyfunction] +#[pyo3(signature = (mask, connectivity=8))] +fn connected_components_with_stats<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, + connectivity: u8, +) -> PyResult> { + let [h, w] = mask.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW array")); + }; + if connectivity != 4 && connectivity != 8 { + return Err(PyValueError::new_err("connectivity must be 4 or 8")); + } + let data = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let c = py.detach(|| components::connected_components(data, h, w, connectivity)); + let labels = numpy::PyArray1::from_vec(py, c.labels).reshape(vec![h, w])?; + let stats = + numpy::PyArray2::from_vec2(py, &c.stats.iter().map(|s| s.to_vec()).collect::>()) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let centroids = numpy::PyArray2::from_vec2( + py, + &c.centroids.iter().map(|s| s.to_vec()).collect::>(), + ) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok((c.count, labels, stats, centroids)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(connected_components_with_stats, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 3145df988..234b95598 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,5 +1,6 @@ pub mod analysis; pub mod bounds; +pub mod components; pub mod contours; pub mod convert; pub mod filter; diff --git a/tests/test_core_components.py b/tests/test_core_components.py new file mode 100644 index 000000000..c82a8e09b --- /dev/null +++ b/tests/test_core_components.py @@ -0,0 +1,26 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(4) + + +def _mask(h, w, density): + return ((_RNG.random((h, w)) < density) * 255).astype(np.uint8) + + +@pytest.mark.parametrize("connectivity", [4, 8]) +@pytest.mark.parametrize("shape", [(1, 1), (1, 57), (57, 1), (2, 2), (31, 47), (128, 200), (257, 129)]) +@pytest.mark.parametrize("density", [0.0, 0.1, 0.4, 0.7, 1.0]) +def test_connected_components_with_stats_match_cv2(connectivity, shape, density): + mask = _mask(*shape, density) + want = cv2.connectedComponentsWithStats(mask, connectivity=connectivity) + count, labels, stats, centroids = negpy_core.connected_components_with_stats(mask, connectivity) + assert count == want[0] + assert np.array_equal(labels, want[1]) + assert np.array_equal(stats, want[2]) + assert centroids.tobytes() == want[3].tobytes() From 9623364cb89f8594b12bab8f7c9b80f001393d51 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:38:26 +0200 Subject: [PATCH 045/133] feat(core): morphology with any structuring element Phase 4 primitive (elliptical erode in downsample_ir and the dust detector's opening). Each kernel row splits into runs; a run is a sliding min or max along the padded row, and the output takes the extreme over the runs. Iterations repeat the pass, as OpenCV does for a kernel that is not a full rectangle. morphology_ex keeps the rectangle path for kernels of all ones. 1176 random cases and 420 oracle tests bit-identical to cv2 (ellipse, cross, every op, iterations, three borders, uint8 and float32). --- negpy_core/src/morph.rs | 133 ++++++++++++++++++++++++++++++++++++- negpy_core/src/py/morph.rs | 31 +++++---- tests/test_core_morph.py | 26 +++++++- 3 files changed, 173 insertions(+), 17 deletions(-) diff --git a/negpy_core/src/morph.rs b/negpy_core/src/morph.rs index 475b43071..2e3c16f41 100644 --- a/negpy_core/src/morph.rs +++ b/negpy_core/src/morph.rs @@ -222,9 +222,14 @@ pub fn morphology( { return None; } - let ed = |s: &[T], d: &mut [T], erode: bool| { + compose(src, dst, op, |s: &[T], d: &mut [T], erode: bool| { erode_dilate(s, d, dims, ksize, iterations, erode, border) - }; + }); + Some(()) +} + +/// An operation built from erode (`true`) and dilate passes. +fn compose(src: &[T], dst: &mut [T], op: Op, ed: impl Fn(&[T], &mut [T], bool)) { let mut tmp = vec![src[0]; src.len()]; match op { Op::Erode => ed(src, dst, true), @@ -253,6 +258,130 @@ pub fn morphology( crate::par_zip(dst, src, move |d, &s| *d = d.sub(s)); } } +} + +/// One erode or dilate pass with an arbitrary `kw` x `kh` kernel mask (nonzero = in), anchor at +/// the center: each kernel row splits into runs, a run is a sliding extreme along the row, and +/// the output takes the extreme over the runs. +fn mask_pass( + src: &[T], + dst: &mut [T], + (h, w, cn): (usize, usize, usize), + mask: &[u8], + (kw, kh): (usize, usize), + border: Border, +) { + let (ax, ay) = (kw / 2, kh / 2); + let identity = if ERODE { T::HIGH } else { T::LOW }; + let n = w * cn; + let pw = (w + kw - 1) * cn; + // (kernel row, run start, run length) + let mut runs: Vec<(usize, usize, usize)> = Vec::new(); + for j in 0..kh { + let mut x = 0; + while x < kw { + if mask[j * kw + x] != 0 { + let start = x; + while x < kw && mask[j * kw + x] != 0 { + x += 1; + } + runs.push((j, start, x - start)); + } else { + x += 1; + } + } + } + if runs.is_empty() { + dst.fill(identity); + return; + } + let mut lengths: Vec = runs.iter().map(|r| r.2).collect(); + lengths.sort_unstable(); + lengths.dedup(); + let xmap: Vec> = (0..w + kw - 1) + .map(|px| border_interpolate(px as isize - ax as isize, w, border)) + .collect(); + // Per padded source row and run length: the sliding extreme of that length. + let padded_rows = h + kh - 1; + let mut slides: Vec> = vec![vec![identity; padded_rows * pw]; lengths.len()]; + let row_of = |r: usize| border_interpolate(r as isize - ay as isize, h, border); + for (li, &len) in lengths.iter().enumerate() { + slides[li].par_chunks_mut(pw).enumerate().for_each_init( + || (Vec::new(), Vec::new()), + |(padded, scratch): &mut (Vec, Vec), (r, out)| { + let Some(sy) = row_of(r) else { return }; + let row = &src[sy * n..(sy + 1) * n]; + padded.clear(); + padded.resize(pw, identity); + for (px, m) in xmap.iter().enumerate() { + if let Some(sx) = m { + padded[px * cn..(px + 1) * cn] + .copy_from_slice(&row[sx * cn..(sx + 1) * cn]); + } + } + let valid = pw - (len - 1) * cn; + window::(padded, scratch, &mut out[..valid], len, cn); + }, + ); + } + dst.par_chunks_mut(n).enumerate().for_each(|(y, out)| { + out.fill(identity); + for &(j, start, len) in &runs { + let li = lengths.binary_search(&len).unwrap_or(0); + let line = &slides[li][(y + j) * pw + start * cn..(y + j) * pw + start * cn + n]; + for (o, &v) in out.iter_mut().zip(line) { + *o = pick::(*o, v); + } + } + }); +} + +/// A structuring element: `h` rows of `w` cells, nonzero = in. +#[derive(Clone, Copy)] +pub struct Kernel<'a> { + pub mask: &'a [u8], + pub w: usize, + pub h: usize, +} + +/// `cv2.morphologyEx(src, op, kernel, iterations=..., borderType=...)` for an arbitrary kernel, +/// OpenCV's default anchor and border value. Iterations repeat the pass, as OpenCV does for a +/// kernel that is not a full rectangle. +pub fn morphology_mask( + src: &[T], + dst: &mut [T], + dims: (usize, usize, usize), + op: Op, + kernel: Kernel, + iterations: usize, + border: Border, +) -> Option<()> { + let (h, w, cn) = dims; + let (mask, kw, kh) = (kernel.mask, kernel.w, kernel.h); + let ksize = (kw, kh); + if h == 0 + || w == 0 + || cn == 0 + || kw == 0 + || kh == 0 + || mask.len() != kw * kh + || src.len() != h * w * cn + || dst.len() != src.len() + { + return None; + } + compose(src, dst, op, |s: &[T], d: &mut [T], erode: bool| { + d.copy_from_slice(s); + let mut cur = s.to_vec(); + for _ in 0..iterations { + if erode { + mask_pass::(&cur, d, dims, mask, ksize, border); + } else { + mask_pass::(&cur, d, dims, mask, ksize, border); + } + cur.copy_from_slice(d); + } + }); Some(()) } diff --git a/negpy_core/src/py/morph.rs b/negpy_core/src/py/morph.rs index fb9b6ee1a..c51fe1b02 100644 --- a/negpy_core/src/py/morph.rs +++ b/negpy_core/src/py/morph.rs @@ -12,7 +12,7 @@ fn run<'py, T: Pixel + Element>( py: Python<'py>, src: &Bound<'py, PyAny>, op: Op, - ksize: (usize, usize), + kernel: morph::Kernel, iterations: usize, border: Border, dtype: &str, @@ -37,14 +37,21 @@ fn run<'py, T: Pixel + Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| morph::morphology(data, dst, dims, op, ksize, iterations, border)) - .ok_or_else(|| PyValueError::new_err("invalid morphology input"))?; + py.detach(|| { + if kernel.mask.iter().all(|&v| v != 0) { + let ksize = (kernel.w, kernel.h); + morph::morphology(data, dst, dims, op, ksize, iterations, border) + } else { + morph::morphology_mask(data, dst, dims, op, kernel, iterations, border) + } + }) + .ok_or_else(|| PyValueError::new_err("invalid morphology input"))?; } Ok(out.into_any()) } /// `cv2.morphologyEx(src, op, kernel, iterations=..., borderType=...)` for a uint8 or float32 -/// image and a kernel of all ones, with OpenCV's default anchor and border value. +/// image and any kernel, with OpenCV's default anchor and border value. #[pyfunction] #[pyo3(signature = (src, op, kernel, iterations=1, border_type=0))] fn morphology_ex<'py>( @@ -60,16 +67,16 @@ fn morphology_ex<'py>( let border = Border::from_cv(border_type) .ok_or_else(|| PyValueError::new_err(format!("unsupported border type {border_type}")))?; let k = kernel.as_array(); - if k.iter().any(|&v| v == 0) { - return Err(PyValueError::new_err( - "negpy_core morphology covers rectangular kernels only", - )); - } - let ksize = (k.ncols(), k.nrows()); + let cells: Vec = k.iter().copied().collect(); + let kernel = morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }; if src.extract::>().is_ok() { - run::(py, src, op, ksize, iterations, border, "uint8") + run::(py, src, op, kernel, iterations, border, "uint8") } else if src.extract::>().is_ok() { - run::(py, src, op, ksize, iterations, border, "float32") + run::(py, src, op, kernel, iterations, border, "float32") } else { Err(PyTypeError::new_err( "morphology needs a uint8 or float32 array", diff --git a/tests/test_core_morph.py b/tests/test_core_morph.py index 544379067..6ec6f188b 100644 --- a/tests/test_core_morph.py +++ b/tests/test_core_morph.py @@ -40,6 +40,26 @@ def test_erode_dilate_borders_match_cv2(border, op): assert negpy_core.morphology_ex(img, op, kernel, 2, border).tobytes() == want.tobytes() -def test_morphology_rejects_a_non_rectangular_kernel(): - with pytest.raises(ValueError): - negpy_core.morphology_ex(_image(np.uint8, 9, 9, 1), cv2.MORPH_ERODE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) +@pytest.mark.parametrize( + "shape_code, ksize", + [ + (cv2.MORPH_ELLIPSE, (5, 5)), + (cv2.MORPH_ELLIPSE, (11, 11)), + (cv2.MORPH_ELLIPSE, (7, 3)), + (cv2.MORPH_CROSS, (3, 3)), + (cv2.MORPH_CROSS, (5, 7)), + ], +) +@pytest.mark.parametrize("op", range(7)) +@pytest.mark.parametrize("iterations", [1, 2]) +@pytest.mark.parametrize("border", [cv2.BORDER_CONSTANT, cv2.BORDER_REPLICATE, cv2.BORDER_REFLECT_101]) +@pytest.mark.parametrize("dtype", [np.uint8, np.float32]) +def test_shaped_kernels_match_cv2(shape_code, ksize, op, iterations, border, dtype): + rng = np.random.default_rng(ksize[0] * 31 + op) + img = rng.integers(0, 256, (37, 53, 3)).astype(dtype) + if dtype == np.float32: + img = (img / 255.0 - 0.3).astype(np.float32) + kernel = cv2.getStructuringElement(shape_code, ksize) + want = cv2.morphologyEx(img, op, kernel, iterations=iterations, borderType=border) + got = negpy_core.morphology_ex(img, op, kernel, iterations, border) + assert got.tobytes() == want.tobytes() From 6285849d1e9939e8c04608e9609763d48471f727 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:53:00 +0200 Subject: [PATCH 046/133] perf(core): downsample_ir erodes and resizes in Rust; faster shaped morphology downsample_ir and _erode_resize_bounded call negpy_core's elliptical erode and INTER_AREA resize instead of cv2. An HxWx1 plane is read as HxW before the erode, the shape cv2 returned. Shaped kernels up to 64 cells take one vector min or max per cell, all taps per 64-value chunk in registers, interior columns straight from the source rows; morphology no longer allocates a temporary for a single erode or dilate. Float32 5x5 ellipse on 22 MP, one core: 4.9x to 1.15x of cv2. scripts/replay.py pins cv2 to one thread when recording and benching; the corpus recorded before this timed cv2 on all threads, so its ratios for cv2-heavy functions are conservative. Replay: downsample_ir 60/60, _erode_resize_bounded 58/58. Against the old Python, both on one thread: 1.004x. --- negpy/features/retouch/logic.py | 14 ++- negpy_core/manifest.toml | 8 +- negpy_core/src/morph.rs | 170 ++++++++++++++++++++++++-------- scripts/replay.py | 5 + tests/test_ir_dust.py | 8 +- 5 files changed, 152 insertions(+), 53 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 9414879e7..7b6211dca 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -3,6 +3,7 @@ from typing import List, Optional, Tuple import cv2 +import negpy_core import numpy as np from numba import prange # type: ignore @@ -798,9 +799,9 @@ def _erode_resize_bounded(plane: np.ndarray, dims: Tuple[int, int], kernel: np.n read_top = max(0, source_top - radius) read_bottom = min(h, source_bottom + radius) source = np.ascontiguousarray(plane[read_top:read_bottom], dtype=np.float32) - eroded = cv2.erode(source, kernel) - core = eroded[source_top - read_top : source_bottom - read_top] - horizontal = cv2.resize(core, (dw, len(core)), interpolation=cv2.INTER_AREA) + eroded = negpy_core.morphology_ex(source, cv2.MORPH_ERODE, kernel) + core = np.ascontiguousarray(eroded[source_top - read_top : source_bottom - read_top]) + horizontal = negpy_core.resize_area(core, (dw, len(core))) # Keep the full-image sampling grid across fractional block boundaries. for row in range(top, bottom): start = row * scale_y @@ -837,14 +838,17 @@ def downsample_ir(plane: np.ndarray, target_long_edge: int, dims: Optional[Tuple dims = (max(1, int(round(w * s))), max(1, int(round(h * s)))) if dims == (w, h): return np.ascontiguousarray(plane) + # cv2 read an HxWx1 plane as single-channel and returned HxW; keep that shape. + if plane.ndim == 3 and plane.shape[2] == 1: + plane = plane[:, :, 0] # Erode by the resample footprint: a 1.25x downsample must not fatten by a 4.5x kernel. k = max(1, int(round(long_edge / max(dims))) | 1) if k > 1: kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) if plane.nbytes > _IR_DOWNSAMPLE_WORK_BYTES: return _erode_resize_bounded(plane, dims, kernel) - plane = cv2.erode(np.ascontiguousarray(plane), kernel) - return cv2.resize(plane, dims, interpolation=cv2.INTER_AREA).astype(np.float32) + plane = negpy_core.morphology_ex(np.ascontiguousarray(plane), cv2.MORPH_ERODE, kernel) + return negpy_core.resize_area(np.ascontiguousarray(plane), dims) def normalize_ir(plane: np.ndarray, glare: Optional[np.ndarray] = None) -> np.ndarray: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index d2a41b8eb..4fd2876df 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1453,8 +1453,8 @@ depends = [] ["features/retouch/logic.py::_erode_resize_bounded"] phase = 4 -status = "todo" -calls = ["cv2.erode", "cv2.resize", "np.sum"] +status = "ported" +calls = ["negpy_core.morphology_ex", "negpy_core.resize_area", "np.sum"] depends = [] ["features/retouch/logic.py::_fill_rung"] @@ -1567,8 +1567,8 @@ depends = ["_box_mean_std", "_is_hair", "_mask_to_score", "compute_dust_stats", ["features/retouch/logic.py::downsample_ir"] phase = 4 -status = "todo" -calls = ["cv2.erode", "cv2.getStructuringElement", "cv2.resize"] +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.morphology_ex", "negpy_core.resize_area"] depends = ["_erode_resize_bounded"] ["features/retouch/logic.py::drop_exclusions"] diff --git a/negpy_core/src/morph.rs b/negpy_core/src/morph.rs index 2e3c16f41..ce85a7be6 100644 --- a/negpy_core/src/morph.rs +++ b/negpy_core/src/morph.rs @@ -1,6 +1,7 @@ //! OpenCV's erode, dilate and morphologyEx for rectangular kernels. A min or max has no //! rounding, so a separable row-then-column pass gives OpenCV's bits. +use multiversion::multiversion; use rayon::prelude::*; use crate::filter::{Border, border_interpolate}; @@ -75,6 +76,39 @@ fn pick(a: T, b: T) -> T { if ERODE { a.lo(b) } else { a.hi(b) } } +/// `out[i] = extreme(out[i], line[i])`; AVX2 doubles the lanes of the baseline build. +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn extreme_into(out: &mut [T], line: &[T]) { + for (o, &v) in out.iter_mut().zip(line) { + *o = pick::(*o, v); + } +} + +/// `out[i]` = the extreme of `out[i]` and every `line[i]`, a register-sized chunk at a time +/// so the accumulator never leaves the registers between taps. +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn taps_into(out: &mut [T], lines: &[&[T]]) { + const CHUNK: usize = 64; + let n = out.len(); + let mut x = 0; + while x + CHUNK <= n { + let mut acc: [T; CHUNK] = std::array::from_fn(|i| out[x + i]); + for line in lines { + let l = &line[x..x + CHUNK]; + for i in 0..CHUNK { + acc[i] = pick::(acc[i], l[i]); + } + } + out[x..x + CHUNK].copy_from_slice(&acc); + x += CHUNK; + } + for line in lines { + for (o, &v) in out[x..].iter_mut().zip(&line[x..]) { + *o = pick::(*o, v); + } + } +} + /// `out[i] = min (or max) of src[i + j * stride]` for `j < len`, from overlapping power-of-two /// windows: a min or max is idempotent, so the overlap changes nothing and a window of /// `len` costs log2(len) vector passes. @@ -230,10 +264,14 @@ pub fn morphology( /// An operation built from erode (`true`) and dilate passes. fn compose(src: &[T], dst: &mut [T], op: Op, ed: impl Fn(&[T], &mut [T], bool)) { + match op { + Op::Erode => return ed(src, dst, true), + Op::Dilate => return ed(src, dst, false), + _ => {} + } let mut tmp = vec![src[0]; src.len()]; match op { - Op::Erode => ed(src, dst, true), - Op::Dilate => ed(src, dst, false), + Op::Erode | Op::Dilate => {} Op::Open => { ed(src, &mut tmp, true); ed(&tmp, dst, false); @@ -295,45 +333,88 @@ fn mask_pass( dst.fill(identity); return; } - let mut lengths: Vec = runs.iter().map(|r| r.2).collect(); - lengths.sort_unstable(); - lengths.dedup(); let xmap: Vec> = (0..w + kw - 1) .map(|px| border_interpolate(px as isize - ax as isize, w, border)) .collect(); - // Per padded source row and run length: the sliding extreme of that length. - let padded_rows = h + kh - 1; - let mut slides: Vec> = vec![vec![identity; padded_rows * pw]; lengths.len()]; - let row_of = |r: usize| border_interpolate(r as isize - ay as isize, h, border); - for (li, &len) in lengths.iter().enumerate() { - slides[li].par_chunks_mut(pw).enumerate().for_each_init( - || (Vec::new(), Vec::new()), - |(padded, scratch): &mut (Vec, Vec), (r, out)| { - let Some(sy) = row_of(r) else { return }; - let row = &src[sy * n..(sy + 1) * n]; - padded.clear(); - padded.resize(pw, identity); - for (px, m) in xmap.iter().enumerate() { - if let Some(sx) = m { - padded[px * cn..(px + 1) * cn] - .copy_from_slice(&row[sx * cn..(sx + 1) * cn]); + let pad_row = |row: &[T], padded: &mut [T]| { + padded[ax * cn..ax * cn + n].copy_from_slice(row); + for px in (0..ax).chain(ax + w..w + kw - 1) { + if let Some(sx) = xmap[px] { + padded[px * cn..(px + 1) * cn].copy_from_slice(&row[sx * cn..(sx + 1) * cn]); + } + } + }; + let cells: usize = runs.iter().map(|r| r.2).sum(); + if cells <= 64 { + // Small kernels: one vector min or max per kernel cell, as OpenCV. Interior columns + // read the source rows directly; the few columns whose taps cross the border go + // through the border map. + let taps: Vec<(usize, usize)> = runs + .iter() + .flat_map(|&(j, start, len)| (start..start + len).map(move |x| (j, x))) + .collect(); + let (left, right) = (ax.min(w), (kw - 1 - ax).min(w - ax.min(w))); + dst.par_chunks_mut(n).enumerate().for_each(|(y, out)| { + out.fill(identity); + let rows: Vec> = (0..kh) + .map(|j| border_interpolate((y + j) as isize - ay as isize, h, border)) + .collect(); + let inner = left * cn..n - right * cn; + if inner.start < inner.end { + let lines: Vec<&[T]> = taps + .iter() + .filter_map(|&(j, x)| { + rows[j].map(|sy| { + let at = sy * n + inner.start + x * cn - ax * cn; + &src[at..at + inner.len()] + }) + }) + .collect(); + taps_into::(&mut out[inner.clone()], &lines); + } + for px in (0..left).chain(w - right..w) { + for &(j, x) in &taps { + let (Some(sy), Some(sx)) = (rows[j], xmap[px + x]) else { + continue; + }; + for c in 0..cn { + let o = &mut out[px * cn + c]; + *o = pick::(*o, src[sy * n + sx * cn + c]); } } - let valid = pw - (len - 1) * cn; - window::(padded, scratch, &mut out[..valid], len, cn); - }, - ); + } + }); + return; } - dst.par_chunks_mut(n).enumerate().for_each(|(y, out)| { - out.fill(identity); - for &(j, start, len) in &runs { - let li = lengths.binary_search(&len).unwrap_or(0); - let line = &slides[li][(y + j) * pw + start * cn..(y + j) * pw + start * cn + n]; - for (o, &v) in out.iter_mut().zip(line) { - *o = pick::(*o, v); + // Large kernels: each output row recomputes its runs' sliding extremes from the padded + // source rows, a log-time window per run and no full-image intermediate. + dst.par_chunks_mut(n).enumerate().for_each_init( + || (Vec::new(), Vec::new(), Vec::new(), Vec::new(), usize::MAX), + |(padded, cur, scratch, slid, padded_row): &mut (Vec, Vec, Vec, Vec, usize), + (y, out)| { + out.fill(identity); + for &(j, start, len) in &runs { + let Some(sy) = border_interpolate((y + j) as isize - ay as isize, h, border) else { + // A constant border row holds the identity, which changes nothing. + continue; + }; + if *padded_row != sy { + padded.clear(); + padded.resize(pw, identity); + pad_row(&src[sy * n..(sy + 1) * n], padded); + *padded_row = sy; + } + cur.clear(); + cur.extend_from_slice(&padded[start * cn..start * cn + n + (len - 1) * cn]); + slid.clear(); + slid.resize(n, identity); + window::(cur, scratch, slid, len, cn); + for (o, &v) in out.iter_mut().zip(slid.iter()) { + *o = pick::(*o, v); + } } - } - }); + }, + ); } /// A structuring element: `h` rows of `w` cells, nonzero = in. @@ -371,15 +452,24 @@ pub fn morphology_mask( return None; } compose(src, dst, op, |s: &[T], d: &mut [T], erode: bool| { - d.copy_from_slice(s); - let mut cur = s.to_vec(); - for _ in 0..iterations { + let pass = |from: &[T], to: &mut [T]| { if erode { - mask_pass::(&cur, d, dims, mask, ksize, border); + mask_pass::(from, to, dims, mask, ksize, border); } else { - mask_pass::(&cur, d, dims, mask, ksize, border); + mask_pass::(from, to, dims, mask, ksize, border); + } + }; + if iterations == 0 { + d.copy_from_slice(s); + return; + } + pass(s, d); + if iterations > 1 { + let mut cur = d.to_vec(); + for _ in 1..iterations { + pass(&cur, d); + cur.copy_from_slice(d); } - cur.copy_from_slice(d); } }); Some(()) diff --git a/scripts/replay.py b/scripts/replay.py index 103c6801d..07ab6e6cf 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -30,6 +30,11 @@ import numpy as np # noqa: E402 +if len(sys.argv) > 1 and sys.argv[1] in ("record", "bench"): + import cv2 # noqa: E402 + + cv2.setNumThreads(1) + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from core_manifest import load as load_manifest # noqa: E402 diff --git a/tests/test_ir_dust.py b/tests/test_ir_dust.py index 20fa2093f..ac7b120cc 100644 --- a/tests/test_ir_dust.py +++ b/tests/test_ir_dust.py @@ -1043,14 +1043,14 @@ def test_downsample_ir_bounds_large_rgb_work_blocks(monkeypatch): plane[100:104, 40:320] = 0.2 source_bytes = plane.nbytes seen = [] - erode = cv2.erode + morphology = logic.negpy_core.morphology_ex - def tracked(source, kernel): + def tracked(source, op, kernel, *args): seen.append(source.nbytes) - return erode(source, kernel) + return morphology(source, op, kernel, *args) monkeypatch.setattr(logic, "_IR_DOWNSAMPLE_WORK_BYTES", 32 * 1024) - monkeypatch.setattr(logic.cv2, "erode", tracked) + monkeypatch.setattr(logic.negpy_core, "morphology_ex", tracked) result = logic.downsample_ir(plane, 90) assert result.shape == (60, 90, 3) From 969d9f8f91b4337325408e92888cc7f58118eb7b Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 02:57:52 +0200 Subject: [PATCH 047/133] feat(core): median blur for uint8 (cv2.medianBlur) Phase 4 primitive (the dust detector's background and MAD). Perreault and Hebert's constant-time median: column histograms slid down the rows, a 16-bin coarse window histogram slid along each row, and a 16-bin fine segment brought up to date only when the search enters it. Edges replicate, as OpenCV's. A median of integers is exact, so the result does not depend on the method. 192 random cases and 126 oracle tests bit-identical to cv2. Speed debt: on one pinned core it takes 1.6x to 1.9x of cv2's time for 25 and 33 px windows on 1.8 MP. compute_dust_stats, its caller, waits on the log10 decision. --- negpy_core/src/lib.rs | 1 + negpy_core/src/median.rs | 108 ++++++++++++++++++++++++++++++++++++++ negpy_core/src/py/gray.rs | 30 +++++++++++ tests/test_core_gray.py | 13 +++++ 4 files changed, 152 insertions(+) create mode 100644 negpy_core/src/median.rs diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index ed666be1f..57463c4fd 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -7,6 +7,7 @@ pub mod detect; pub mod filter; pub mod gray; pub mod histogram; +pub mod median; pub mod meter; pub mod morph; pub mod resize; diff --git a/negpy_core/src/median.rs b/negpy_core/src/median.rs new file mode 100644 index 000000000..fa1d13f05 --- /dev/null +++ b/negpy_core/src/median.rs @@ -0,0 +1,108 @@ +//! `cv2.medianBlur` for single-channel uint8: the median of a `k` x `k` window, edges +//! replicated as OpenCV does. A median of integers is exact, so only the border rule matters. + +use rayon::prelude::*; + +fn bump(hist: &mut [u16; N], bin: usize, up: bool) { + hist[bin] = if up { hist[bin] + 1 } else { hist[bin] - 1 }; +} + +/// Perreault and Hebert's constant-time median: column histograms slid down the rows, a 16-bin +/// coarse window histogram slid along each row, and each 16-bin fine segment brought up to date +/// only when the search enters it. +pub fn median_blur_u8(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) -> Option<()> { + if h == 0 + || w == 0 + || k == 0 + || k.is_multiple_of(2) + || src.len() != h * w + || dst.len() != src.len() + { + return None; + } + if k == 1 { + dst.copy_from_slice(src); + return Some(()); + } + let r = (k / 2) as isize; + let rank = (k * k / 2) as u32; + let cy = |v: isize| v.clamp(0, h as isize - 1) as usize; + let cx = |v: isize| v.clamp(0, w as isize - 1) as usize; + const BAND: usize = 256; + dst.par_chunks_mut(BAND * w) + .enumerate() + .for_each(|(bi, band)| { + let y0 = bi * BAND; + // Column histograms over rows y0 - r ..= y0 + r: fine (256 bins) and coarse (16). + let mut fine_col = vec![[0u16; 256]; w]; + let mut coarse_col = vec![[0u16; 16]; w]; + for dy in -r..=r { + for (x, &v) in src[cy(y0 as isize + dy) * w..][..w].iter().enumerate() { + bump(&mut fine_col[x], v as usize, true); + bump(&mut coarse_col[x], (v >> 4) as usize, true); + } + } + for (dy, out) in band.chunks_mut(w).enumerate() { + let y = (y0 + dy) as isize; + if dy > 0 { + let gone = &src[cy(y - r - 1) * w..][..w]; + let new = &src[cy(y + r) * w..][..w]; + for x in 0..w { + bump(&mut fine_col[x], gone[x] as usize, false); + bump(&mut coarse_col[x], (gone[x] >> 4) as usize, false); + bump(&mut fine_col[x], new[x] as usize, true); + bump(&mut coarse_col[x], (new[x] >> 4) as usize, true); + } + } + let mut coarse = [0u32; 16]; + for dx in -r..=r { + for (c, &v) in coarse.iter_mut().zip(&coarse_col[cx(dx)]) { + *c += v as u32; + } + } + let mut fine = [[0u32; 16]; 16]; + // The output column each fine segment was last summed for. + let mut synced = [isize::MIN; 16]; + for (x, o) in out.iter_mut().enumerate() { + let xi = x as isize; + let mut seen = 0u32; + let mut b = 0; + while seen + coarse[b] <= rank { + seen += coarse[b]; + b += 1; + } + let seg = &mut fine[b]; + let lo = b << 4; + if synced[b] < xi - 2 * r { + *seg = [0; 16]; + for dx in -r..=r { + for (f, &v) in seg.iter_mut().zip(&fine_col[cx(xi + dx)][lo..lo + 16]) { + *f += v as u32; + } + } + } else { + for j in synced[b] + 1..=xi { + let (gone, new) = (&fine_col[cx(j - r - 1)], &fine_col[cx(j + r)]); + for i in 0..16 { + seg[i] = (seg[i] + new[lo + i] as u32) - gone[lo + i] as u32; + } + } + } + synced[b] = xi; + let mut v = 0; + while seen + seg[v] <= rank { + seen += seg[v]; + v += 1; + } + *o = (lo + v) as u8; + if x + 1 < w { + let (gone, new) = (&coarse_col[cx(xi - r)], &coarse_col[cx(xi + r + 1)]); + for i in 0..16 { + coarse[i] = (coarse[i] + new[i] as u32) - gone[i] as u32; + } + } + } + } + }); + Some(()) +} diff --git a/negpy_core/src/py/gray.rs b/negpy_core/src/py/gray.rs index c7c3c4d52..e72fdac95 100644 --- a/negpy_core/src/py/gray.rs +++ b/negpy_core/src/py/gray.rs @@ -126,7 +126,37 @@ fn canny<'py>( Ok(out) } +/// `cv2.medianBlur(src, ksize)` for a single-channel uint8 image. +#[pyfunction] +fn median_blur<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + ksize: usize, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w) = match shape.as_slice() { + [h, w] | [h, w, 1] => (*h, *w), + _ => { + return Err(PyValueError::new_err( + "median_blur needs a single-channel array", + )); + } + }; + let data = contiguous(&src)?; + let out = empty_u8(py, &shape)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| crate::median::median_blur_u8(data, dst, h, w, ksize)) + .ok_or_else(|| PyValueError::new_err("ksize must be odd"))?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(median_blur, m)?)?; m.add_function(wrap_pyfunction!(bgr_to_gray, m)?)?; m.add_function(wrap_pyfunction!(gaussian_blur_u8, m)?)?; m.add_function(wrap_pyfunction!(threshold_otsu, m)?)?; diff --git a/tests/test_core_gray.py b/tests/test_core_gray.py index f0ec78eba..60e99870c 100644 --- a/tests/test_core_gray.py +++ b/tests/test_core_gray.py @@ -66,3 +66,16 @@ def test_threshold_otsu_matches_cv2(shape, invert): def test_canny_matches_cv2(shape, low, high): img = _gray(shape) assert np.array_equal(negpy_core.canny(img, low, high), cv2.Canny(img, low, high)) + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 30), (30, 1), (5, 5), (37, 53), (130, 140)]) +@pytest.mark.parametrize("ksize", [1, 3, 5, 7, 9, 25, 33]) +@pytest.mark.parametrize("kind", ["noise", "smooth", "levels"]) +def test_median_blur_matches_cv2(shape, ksize, kind): + rng = np.random.default_rng(ksize * 7 + shape[0]) + img = rng.integers(0, 256, shape, dtype=np.uint8) + if kind == "smooth": + img = cv2.GaussianBlur(img, (5, 5), 0) + elif kind == "levels": + img = (img // 64 * 64).astype(np.uint8) + assert negpy_core.median_blur(img, ksize).tobytes() == cv2.medianBlur(img, ksize).tobytes() From 724cce0053fc85783221488c5ffdfa28f8580303 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 03:02:35 +0200 Subject: [PATCH 048/133] feat(core): distance transform, with and without labels Phase 4 primitive. Without labels opencv-python runs IPP's float32 chamfer: a float32 two-pass model matches it bit for bit for DIST_C and DIST_L2 mask 5. For DIST_L2 mask 3 IPP's internal order picks a path one float32 unit longer at rare pixels (38/40 random masks identical, never more than 1 ULP); LEDGER.md lists it for the port that adopts it. OpenCV without IPP (the Apple Silicon wheels) returns its own fixed-point values, so cv2 itself already differs by platform here. With labels (DIST_LABEL_PIXEL), OpenCV's own 5x5 fixed-point pass runs on every platform; the port matches it bit for bit, labels included. --- negpy_core/LEDGER.md | 1 + negpy_core/src/distance.rs | 149 ++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 1 + negpy_core/src/py/components.rs | 57 +++++++++++- tests/test_core_components.py | 33 +++++++ 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 negpy_core/src/distance.rs diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 4c45c4ed0..56aa8f437 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -11,3 +11,4 @@ results already differ by CPU. Replay accepts the tolerance listed for each entr | Function | Differs because | Tolerance | |---|---|---| | `features/exposure/normalization.py::_base_blocks` | Blocks of equal log brightness come out in block order, not in NumPy's SIMD argsort order. log10 is libm's, not SVML's, so a block on a group gap can change group in rare cases. | rows in any order | +| `negpy_core.distance_transform` (DIST_L2, mask 3), once a port calls it | opencv-python runs IPP's float32 chamfer, whose 3x3 pass picks a path one float32 unit longer than the two-pass order at rare pixels; OpenCV without IPP (Apple Silicon wheels) returns its own fixed-point values. | ulp=1 | diff --git a/negpy_core/src/distance.rs b/negpy_core/src/distance.rs new file mode 100644 index 000000000..b2e4b27bb --- /dev/null +++ b/negpy_core/src/distance.rs @@ -0,0 +1,149 @@ +//! `cv2.distanceTransform` for a uint8 mask: chamfer distances to the nearest zero pixel. +//! Without labels, opencv-python runs IPP's float32 two-pass chamfer; with labels, OpenCV's own +//! 5x5 pass in 16-bit fixed point. IPP's 3x3 pass picks a path one float32 unit longer at rare +//! pixels than this two-pass order does (negpy_core/LEDGER.md). + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Metric { + /// DIST_C, 3x3: steps of 1. + Chessboard, + /// DIST_L2, 3x3: 0.955 and 1.3693. + L2Mask3, + /// DIST_L2, 5x5: 1, 1.4 and 2.1969. + L2Mask5, +} + +/// (row offset, column offset, step index) of the forward pass; the backward pass mirrors it. +fn forward(metric: Metric) -> &'static [(isize, isize, usize)] { + match metric { + Metric::Chessboard | Metric::L2Mask3 => &[(-1, -1, 1), (-1, 0, 0), (-1, 1, 1), (0, -1, 0)], + Metric::L2Mask5 => &[ + (-2, -1, 2), + (-2, 1, 2), + (-1, -2, 2), + (-1, -1, 1), + (-1, 0, 0), + (-1, 1, 1), + (-1, 2, 2), + (0, -1, 0), + ], + } +} + +fn steps(metric: Metric) -> [f32; 3] { + match metric { + Metric::Chessboard => [1.0, 1.0, 0.0], + Metric::L2Mask3 => [0.955, 1.3693, 0.0], + Metric::L2Mask5 => [1.0, 1.4, 2.1969], + } +} + +/// Distances in float32: a forward and a backward pass over a frame two pixels wide. +pub fn distance_transform(src: &[u8], h: usize, w: usize, metric: Metric) -> Vec { + const B: usize = 2; + let (pw, ph) = (w + 2 * B, h + 2 * B); + let far = f32::MAX; + let mut t = vec![far; pw * ph]; + let step = steps(metric); + let fwd = forward(metric); + let at = |y: usize, x: usize, dy: isize, dx: isize| { + ((y as isize + dy) as usize) * pw + (x as isize + dx) as usize + }; + for y in B..h + B { + for x in B..w + B { + if src[(y - B) * w + x - B] == 0 { + t[y * pw + x] = 0.0; + continue; + } + let mut v = far; + for &(dy, dx, s) in fwd { + v = v.min(t[at(y, x, dy, dx)] + step[s]); + } + t[y * pw + x] = v; + } + } + let mut out = vec![0f32; h * w]; + for y in (B..h + B).rev() { + for x in (B..w + B).rev() { + let mut v = t[y * pw + x]; + if v > step[0] { + for &(dy, dx, s) in fwd { + v = v.min(t[at(y, x, -dy, -dx)] + step[s]); + } + t[y * pw + x] = v; + } + out[(y - B) * w + x - B] = v; + } + } + out +} + +/// `cv2.distanceTransformWithLabels(src, DIST_L2, ..., labelType=DIST_LABEL_PIXEL)`: OpenCV's +/// 5x5 fixed-point pass, each pixel labelled with its nearest zero pixel, zeros numbered from 1 +/// in raster order. +pub fn distance_transform_labels(src: &[u8], h: usize, w: usize) -> (Vec, Vec) { + const B: usize = 2; + const SHIFT: i32 = 16; + let fix = |v: f32| ((v as f64) * (1 << SHIFT) as f64).round_ties_even() as u32; + let [hv, diag, long] = steps(Metric::L2Mask5).map(fix); + let cost = [hv, diag, long]; + let far = u32::MAX - long; + let scale = 1.0f32 / (1 << SHIFT) as f32; + let (pw, ph) = (w + 2 * B, h + 2 * B); + let mut t = vec![far; pw * ph]; + let mut lab = vec![0i32; pw * ph]; + let mut next = 1; + for y in 0..h { + for x in 0..w { + if src[y * w + x] == 0 { + lab[(y + B) * pw + x + B] = next; + next += 1; + } + } + } + let fwd = forward(Metric::L2Mask5); + let at = |y: usize, x: usize, dy: isize, dx: isize| { + ((y as isize + dy) as usize) * pw + (x as isize + dx) as usize + }; + for y in B..h + B { + for x in B..w + B { + if src[(y - B) * w + x - B] == 0 { + t[y * pw + x] = 0; + continue; + } + let (mut v, mut l) = (far, 0); + for &(dy, dx, s) in fwd { + let i = at(y, x, dy, dx); + let c = t[i].wrapping_add(cost[s]); + if v > c { + v = c; + l = lab[i]; + } + } + t[y * pw + x] = v; + lab[y * pw + x] = l; + } + } + let mut dist = vec![0f32; h * w]; + let mut labels = vec![0i32; h * w]; + for y in (B..h + B).rev() { + for x in (B..w + B).rev() { + let (mut v, mut l) = (t[y * pw + x], lab[y * pw + x]); + if v > hv { + for &(dy, dx, s) in fwd { + let i = at(y, x, -dy, -dx); + let c = t[i].wrapping_add(cost[s]); + if v > c { + v = c; + l = lab[i]; + } + } + t[y * pw + x] = v; + lab[y * pw + x] = l; + } + dist[(y - B) * w + x - B] = v as f32 * scale; + labels[(y - B) * w + x - B] = l; + } + } + (dist, labels) +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 57463c4fd..b9266a89f 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -4,6 +4,7 @@ pub mod components; pub mod contours; pub mod convert; pub mod detect; +pub mod distance; pub mod filter; pub mod gray; pub mod histogram; diff --git a/negpy_core/src/py/components.rs b/negpy_core/src/py/components.rs index 6da888977..f691fa270 100644 --- a/negpy_core/src/py/components.rs +++ b/negpy_core/src/py/components.rs @@ -42,6 +42,61 @@ fn connected_components_with_stats<'py>( Ok((c.count, labels, stats, centroids)) } +fn mask_of<'a>(mask: &'a PyReadonlyArrayDyn<'_, u8>) -> PyResult<(&'a [u8], usize, usize)> { + let [h, w] = mask.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW array")); + }; + let data = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok((data, h, w)) +} + +/// `cv2.distanceTransform(mask, dist_type, mask_size)` for DIST_C (3) and DIST_L2 (3 or 5). +#[pyfunction] +fn distance_transform<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, + dist_type: i32, + mask_size: i32, +) -> PyResult>> { + use crate::distance::{self, Metric}; + let metric = match (dist_type, mask_size) { + (3, _) => Metric::Chessboard, + (2, 3) => Metric::L2Mask3, + (2, 5) => Metric::L2Mask5, + _ => { + return Err(PyValueError::new_err( + "covers DIST_C and DIST_L2 with mask 3 or 5", + )); + } + }; + let (data, h, w) = mask_of(&mask)?; + let d = py.detach(|| distance::distance_transform(data, h, w, metric)); + numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) +} + +type Distances<'py> = ( + Bound<'py, numpy::PyArrayDyn>, + Bound<'py, numpy::PyArrayDyn>, +); + +/// `cv2.distanceTransformWithLabels(mask, DIST_L2, _, labelType=DIST_LABEL_PIXEL)`: (dist, labels). +#[pyfunction] +fn distance_transform_labels<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, +) -> PyResult> { + let (data, h, w) = mask_of(&mask)?; + let (d, l) = py.detach(|| crate::distance::distance_transform_labels(data, h, w)); + Ok(( + numpy::PyArray1::from_vec(py, d).reshape(vec![h, w])?, + numpy::PyArray1::from_vec(py, l).reshape(vec![h, w])?, + )) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(connected_components_with_stats, m)?) + m.add_function(wrap_pyfunction!(connected_components_with_stats, m)?)?; + m.add_function(wrap_pyfunction!(distance_transform, m)?)?; + m.add_function(wrap_pyfunction!(distance_transform_labels, m)?) } diff --git a/tests/test_core_components.py b/tests/test_core_components.py index c82a8e09b..ff2c27168 100644 --- a/tests/test_core_components.py +++ b/tests/test_core_components.py @@ -24,3 +24,36 @@ def test_connected_components_with_stats_match_cv2(connectivity, shape, density) assert np.array_equal(labels, want[1]) assert np.array_equal(stats, want[2]) assert centroids.tobytes() == want[3].tobytes() + + +def _sparse(h, w, density): + return (_RNG.random((h, w)) >= density).astype(np.uint8) + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 40), (40, 1), (37, 53), (150, 90)]) +@pytest.mark.parametrize("density", [0.0, 0.005, 0.05, 0.5]) +@pytest.mark.parametrize("dist_type, mask_size", [(cv2.DIST_C, 3), (cv2.DIST_L2, 5)]) +def test_distance_transform_matches_cv2(shape, density, dist_type, mask_size): + mask = _sparse(*shape, density) + want = cv2.distanceTransform(mask, dist_type, mask_size) + assert negpy_core.distance_transform(mask, dist_type, mask_size).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("shape", [(1, 1), (37, 53), (150, 90)]) +@pytest.mark.parametrize("density", [0.0, 0.005, 0.05, 0.5]) +def test_distance_transform_l2_mask3_within_one_ulp(shape, density): + mask = _sparse(*shape, density) + want = cv2.distanceTransform(mask, cv2.DIST_L2, 3).view(np.int32).astype(np.int64) + got = negpy_core.distance_transform(mask, cv2.DIST_L2, 3).view(np.int32).astype(np.int64) + assert np.abs(got - want).max(initial=0) <= 1 + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 40), (37, 53), (150, 90)]) +@pytest.mark.parametrize("density", [0.005, 0.05, 0.5]) +@pytest.mark.parametrize("mask_size", [3, 5]) +def test_distance_transform_with_labels_matches_cv2(shape, density, mask_size): + mask = _sparse(*shape, density) + want_d, want_l = cv2.distanceTransformWithLabels(mask, cv2.DIST_L2, mask_size, labelType=cv2.DIST_LABEL_PIXEL) + got_d, got_l = negpy_core.distance_transform_labels(mask) + assert got_d.tobytes() == want_d.tobytes() + assert np.array_equal(got_l, want_l) From 6c9c8855a84bb689d26f99f6194e8074da735341 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 03:13:16 +0200 Subject: [PATCH 049/133] perf(core): dust detection uses the Rust primitives; _mask_to_score in Rust detect_luma_score, split_hairs and _is_hair call negpy_core's elliptical dilate, connected components and 5x5 distance transform instead of cv2. _mask_to_score is one Rust call: the 3x3 chamfer distance and the smoothstep, in float32 as NumPy computed it. The distance passes take the rows above (below) in a vector loop, then carry the left (right) neighbour sequentially, with a plain compare for the minimum: 14 ms to 7.5 ms on 1.8 MP, one core. Replay: detect_luma_score 36/36, split_hairs 33/33, _is_hair 60/60, _mask_to_score 32/32, repair_components 32/32. Against the old Python, both on one thread: detect_luma_score 0.886x, _mask_to_score 0.737x, _is_hair 0.995x. --- negpy/features/retouch/logic.py | 13 ++-- negpy_core/manifest.toml | 12 ++-- negpy_core/src/distance.rs | 110 +++++++++++++++++++++++++------- negpy_core/src/py/components.rs | 14 ++++ 4 files changed, 112 insertions(+), 37 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 7b6211dca..7d4669574 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -244,7 +244,7 @@ def _is_hair(labels_sub: np.ndarray, area: int) -> bool: if area < _HAIR_MIN_AREA: return False # Pad, or a component touching the sub-image border reads as thin along that edge. - dist = cv2.distanceTransform(np.pad(labels_sub.astype(np.uint8), 1), cv2.DIST_L2, 5) + dist = negpy_core.distance_transform(np.pad(labels_sub.astype(np.uint8), 1), cv2.DIST_L2, 5) thickness = 2.0 * float(dist.max()) return area / max(thickness * thickness, 1e-6) >= _HAIR_MIN_ELONG @@ -266,16 +266,14 @@ def _mask_to_score(mask: np.ndarray, pad_px: float) -> np.ndarray: """Binary defect mask → the continuous score every repair consumes: at the floor on the defect, ramping to clean over ``pad_px``. The ramp is the skirt allowance a hard mask edge would leave behind as a halo.""" - d = cv2.distanceTransform((np.asarray(mask) == 0).astype(np.uint8), cv2.DIST_L2, 3) - t = np.clip(d / max(pad_px, 1e-3), 0.0, 1.0) - return (_IR_SCORE_FLOOR + (1.0 - _IR_SCORE_FLOOR) * (t * t * (3.0 - 2.0 * t))).astype(np.float32) + return negpy_core.mask_to_score((np.asarray(mask) != 0).astype(np.uint8), float(pad_px), _IR_SCORE_FLOOR) def split_hairs(mask: np.ndarray) -> Tuple[np.ndarray, Optional[np.ndarray]]: """Defect mask → ``(compact, hairs)``. Compact defects go to the weighted fill; long twisted ones to structure-following inpaint, which keeps detail the fill would average across. ``hairs`` is None when nothing is thin enough.""" - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(np.ascontiguousarray(mask, dtype=np.uint8), connectivity=8) + n_lbl, labels, stats, _ = negpy_core.connected_components_with_stats(np.ascontiguousarray(mask, dtype=np.uint8), 8) compact = np.zeros(mask.shape[:2], dtype=np.uint8) hairs: Optional[np.ndarray] = None for i in range(1, n_lbl): @@ -354,8 +352,9 @@ def detect_luma_score( scale = film_scale(proxy.shape) lo = max(_DETECT_Z_GROW, min(hi, hi_hair) * _DETECT_Z_GROW_FRAC) reach = int(round(_DETECT_GROW_REACH * max(1, dust_size) * scale)) - near = cv2.dilate(seeds.astype(np.uint8), cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * reach + 1,) * 2)) > 0 - n_lbl, lab, stats_cc, _ = cv2.connectedComponentsWithStats((near & (z >= lo)).astype(np.uint8), connectivity=8) + disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * reach + 1,) * 2) + near = negpy_core.morphology_ex(seeds.astype(np.uint8), cv2.MORPH_DILATE, disk) > 0 + n_lbl, lab, stats_cc, _ = negpy_core.connected_components_with_stats((near & (z >= lo)).astype(np.uint8), 8) bar = hi * (1.0 + _DETECT_TEXTURE_GAIN * np.maximum(texture - _DETECT_TEXTURE_KNEE, 0.0)) strong = np.zeros(n_lbl, dtype=bool) strong[np.unique(lab[seeds & (z >= bar)])] = True diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 4fd2876df..530675332 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1507,14 +1507,14 @@ depends = [] ["features/retouch/logic.py::_is_hair"] phase = 4 -status = "todo" -calls = ["cv2.distanceTransform"] +status = "ported" +calls = ["negpy_core.distance_transform"] depends = [] ["features/retouch/logic.py::_mask_to_score"] phase = 4 -status = "todo" -calls = ["cv2.distanceTransform"] +status = "ported" +calls = ["negpy_core.mask_to_score"] depends = [] ["features/retouch/logic.py::_proxy_norm"] @@ -1562,7 +1562,7 @@ depends = ["_box_mean_std", "_density", "_proxy_norm"] ["features/retouch/logic.py::detect_luma_score"] phase = 4 status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.getStructuringElement", "np.median", "np.unique"] +calls = ["cv2.getStructuringElement", "negpy_core.connected_components_with_stats", "negpy_core.morphology_ex", "np.median", "np.unique"] depends = ["_box_mean_std", "_is_hair", "_mask_to_score", "compute_dust_stats", "split_hairs"] ["features/retouch/logic.py::downsample_ir"] @@ -1628,7 +1628,7 @@ depends = ["_fill_rung"] ["features/retouch/logic.py::split_hairs"] phase = 4 status = "todo" -calls = ["cv2.connectedComponentsWithStats"] +calls = ["negpy_core.connected_components_with_stats"] depends = ["_is_hair"] ["features/retouch/logic.py::strokes_to_score"] diff --git a/negpy_core/src/distance.rs b/negpy_core/src/distance.rs index b2e4b27bb..bac74435e 100644 --- a/negpy_core/src/distance.rs +++ b/negpy_core/src/distance.rs @@ -38,46 +38,95 @@ fn steps(metric: Metric) -> [f32; 3] { } } -/// Distances in float32: a forward and a backward pass over a frame two pixels wide. +/// The smaller of two finite distances: a plain compare, with no NaN handling in the chain. +fn lo(x: f32, y: f32) -> f32 { + if y < x { y } else { x } +} + +/// Distances in float32: a forward and a backward pass over a frame two pixels wide. Each row +/// first takes the minimum over the rows above (below) in a vector loop, then carries the +/// left (right) neighbour in a short sequential loop: the same sums and an exact minimum, so +/// the same bits as one combined pass. pub fn distance_transform(src: &[u8], h: usize, w: usize, metric: Metric) -> Vec { const B: usize = 2; - let (pw, ph) = (w + 2 * B, h + 2 * B); + let pw = w + 2 * B; let far = f32::MAX; - let mut t = vec![far; pw * ph]; - let step = steps(metric); - let fwd = forward(metric); - let at = |y: usize, x: usize, dy: isize, dx: isize| { - ((y as isize + dy) as usize) * pw + (x as isize + dx) as usize - }; + let mut t = vec![far; pw * (h + 2 * B)]; + let [a, b, c] = steps(metric); + let long = metric == Metric::L2Mask5; + let mut cand = vec![far; w]; for y in B..h + B { + let (above, rest) = t.split_at_mut(y * pw); + let (up2, up1) = above[(y - 2) * pw..].split_at(pw); + let row = &mut rest[..pw]; + let s = &src[(y - B) * w..(y - B + 1) * w]; + vertical(&mut cand, up1, up2, (a, b, c), long); for x in B..w + B { - if src[(y - B) * w + x - B] == 0 { - t[y * pw + x] = 0.0; - continue; - } - let mut v = far; - for &(dy, dx, s) in fwd { - v = v.min(t[at(y, x, dy, dx)] + step[s]); - } - t[y * pw + x] = v; + row[x] = if s[x - B] == 0 { + 0.0 + } else { + lo(cand[x - B], row[x - 1] + a) + }; } } let mut out = vec![0f32; h * w]; for y in (B..h + B).rev() { + let (head, below) = t.split_at_mut((y + 1) * pw); + let row = &mut head[y * pw..]; + let (dn1, dn2) = below.split_at(pw); + vertical_back(&mut cand, dn1, dn2, (a, b, c), long); + let o = &mut out[(y - B) * w..(y - B + 1) * w]; for x in (B..w + B).rev() { - let mut v = t[y * pw + x]; - if v > step[0] { - for &(dy, dx, s) in fwd { - v = v.min(t[at(y, x, -dy, -dx)] + step[s]); - } - t[y * pw + x] = v; + let v0 = row[x]; + if v0 > a { + row[x] = lo(lo(v0, cand[x - B]), row[x + 1] + a); } - out[(y - B) * w + x - B] = v; + o[x - B] = row[x]; } } out } +/// The forward candidates from the two rows above, for the row's `w` pixels. +fn vertical(cand: &mut [f32], up1: &[f32], up2: &[f32], (a, b, c): (f32, f32, f32), long: bool) { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo(lo(up1[x - 1] + b, up1[x] + a), up1[x + 1] + b); + } + if long { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo( + lo(lo(lo(*v, up2[x - 1] + c), up2[x + 1] + c), up1[x - 2] + c), + up1[x + 2] + c, + ); + } + } +} + +/// The backward candidates from the two rows below. +fn vertical_back( + cand: &mut [f32], + dn1: &[f32], + dn2: &[f32], + (a, b, c): (f32, f32, f32), + long: bool, +) { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo(lo(dn1[x + 1] + b, dn1[x] + a), dn1[x - 1] + b); + } + if long { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo( + lo(lo(lo(*v, dn2[x + 1] + c), dn2[x - 1] + c), dn1[x + 2] + c), + dn1[x - 2] + c, + ); + } + } +} + /// `cv2.distanceTransformWithLabels(src, DIST_L2, ..., labelType=DIST_LABEL_PIXEL)`: OpenCV's /// 5x5 fixed-point pass, each pixel labelled with its nearest zero pixel, zeros numbered from 1 /// in raster order. @@ -147,3 +196,16 @@ pub fn distance_transform_labels(src: &[u8], h: usize, w: usize) -> (Vec, V } (dist, labels) } + +/// `_mask_to_score(mask, pad_px)` of retouch: the floor on the defect (nonzero), rising to 1 over +/// `pad` pixels of 3x3 chamfer distance along a smoothstep, in float32. +pub fn mask_to_score(mask: &[u8], h: usize, w: usize, pad: f64, floor: f64) -> Vec { + let clean: Vec = mask.iter().map(|&m| (m == 0) as u8).collect(); + let mut d = distance_transform(&clean, h, w, Metric::L2Mask3); + let (pad, floor, rise) = (pad.max(1e-3) as f32, floor as f32, (1.0 - floor) as f32); + for v in d.iter_mut() { + let t = (*v / pad).clamp(0.0, 1.0); + *v = floor + rise * (t * t * (3.0 - 2.0 * t)); + } + d +} diff --git a/negpy_core/src/py/components.rs b/negpy_core/src/py/components.rs index f691fa270..f1ee29be5 100644 --- a/negpy_core/src/py/components.rs +++ b/negpy_core/src/py/components.rs @@ -95,7 +95,21 @@ fn distance_transform_labels<'py>( )) } +/// Retouch's `_mask_to_score(mask, pad_px)` with its score floor. +#[pyfunction] +fn mask_to_score<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, + pad: f64, + floor: f64, +) -> PyResult>> { + let (data, h, w) = mask_of(&mask)?; + let d = py.detach(|| crate::distance::mask_to_score(data, h, w, pad, floor)); + numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(mask_to_score, m)?)?; m.add_function(wrap_pyfunction!(connected_components_with_stats, m)?)?; m.add_function(wrap_pyfunction!(distance_transform, m)?)?; m.add_function(wrap_pyfunction!(distance_transform_labels, m)?) From 1fd28f179dcb1f6151d30a08b094c58042ca512f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 03:22:51 +0200 Subject: [PATCH 050/133] perf(core): port the score-fill Numba kernels _fill_rung, _blend_fill and _add_floor_deficit run in negpy_core (retouch.rs) in the kernels' float32 operation order and Python's max/min tie rules; their Numba code is deleted. Blocks of 8192 pixels with plain AVX2-dispatched loops. They match the old Numba kernels on 72 random and edge cases (zeros of both signs, out-of-range scores); replay of their callers is identical (apply_score_repair 60/60, score_weighted_fill 60/60, repair_components 32/32). 6 MP, one core, against Numba: 0.28x, 0.63x, 0.83x. --- negpy/features/retouch/logic.py | 47 +---------- negpy_core/manifest.toml | 12 +-- negpy_core/src/lib.rs | 2 + negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/retouch.rs | 95 ++++++++++++++++++++++ negpy_core/src/retouch.rs | 134 ++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+), 49 deletions(-) create mode 100644 negpy_core/src/py/retouch.rs create mode 100644 negpy_core/src/retouch.rs diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 7d4669574..cabaaa0f8 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -913,28 +913,10 @@ def score_weighted_fill( return fill -@parallel_njit(cache=True) def _fill_rung(num, den, fill, first, reject_floor_mass): """One rung of the fill ladder in place: the score-normalized candidate, blended over the - coarser result by the rung's clean fraction. Same float32 operation order as the array - form, one pass over the frame.""" - f32 = np.float32 - h, w = den.shape - floor = f32(_IR_SCORE_FLOOR) - inv_span = f32(1.0) - floor - tau = f32(_IR_FILL_TAU) - for y in prange(h): - for x in range(w): - d = max(den[y, x], f32(1e-6)) - if first: - for c in range(3): - fill[y, x, c] = num[y, x, c] / d - else: - mass = (den[y, x] - floor) / inv_span if reject_floor_mass else den[y, x] - conf = min(max(mass / tau, f32(0.0)), f32(1.0)) - keep = f32(1.0) - conf - for c in range(3): - fill[y, x, c] = fill[y, x, c] * keep + (num[y, x, c] / d) * conf + coarser result by the rung's clean fraction, in the array form's float32 operation order.""" + negpy_core.fill_rung(num, den, fill, bool(first), bool(reject_floor_mass), (_IR_SCORE_FLOOR, _IR_FILL_TAU)) def _fill_supports(buffer_long_edge: int, factor: float) -> Tuple[int, ...]: @@ -1026,36 +1008,15 @@ def apply_score_repair( return ensure_image(out) -@parallel_njit(cache=True) def _blend_fill(src, score, fill, a_out): """``fill`` becomes ``src * (1 - a) + fill * a`` under the smoothstep write ramp of the score; the ramp is kept in ``a_out`` for the grain and floor passes.""" - f32 = np.float32 - h, w = score.shape - hi = f32(_IR_WRITE_HI) - span = f32(_IR_WRITE_HI - _IR_WRITE_LO) - for y in prange(h): - for x in range(w): - a = min(max((hi - score[y, x]) / span, f32(0.0)), f32(1.0)) - a = a * a * (f32(3.0) - f32(2.0) * a) - a_out[y, x] = a - keep = f32(1.0) - a - for c in range(3): - fill[y, x, c] = src[y, x, c] * keep + fill[y, x, c] * a + negpy_core.blend_fill(src, score, fill, a_out, (_IR_WRITE_HI, _IR_WRITE_HI - _IR_WRITE_LO)) -@parallel_njit(cache=True) def _add_floor_deficit(out, a, blur_src, blur_out): """``out += a * max(blur_src - blur_out, 0)``, then clamps at zero, in place.""" - f32 = np.float32 - h, w = a.shape - for y in prange(h): - for x in range(w): - av = a[y, x] - for c in range(3): - deficit = blur_src[y, x, c] - blur_out[y, x, c] - v = out[y, x, c] + av * max(deficit, f32(0.0)) - out[y, x, c] = max(v, f32(0.0)) + negpy_core.add_floor_deficit(out, a, blur_src, blur_out) def film_scale(shape: Tuple[int, int]) -> float: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 530675332..2c07ffe5a 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1423,14 +1423,14 @@ depends = ["apply_clone_stroke"] ["features/retouch/logic.py::_add_floor_deficit"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.add_floor_deficit"] depends = [] ["features/retouch/logic.py::_blend_fill"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.blend_fill"] depends = [] ["features/retouch/logic.py::_borrow_clean_grain"] @@ -1459,8 +1459,8 @@ depends = [] ["features/retouch/logic.py::_fill_rung"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.fill_rung"] depends = [] ["features/retouch/logic.py::_fit_refraction_gammas"] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index b9266a89f..3c0c2bad2 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -12,6 +12,7 @@ pub mod median; pub mod meter; pub mod morph; pub mod resize; +pub mod retouch; pub mod shapes; pub mod stats; @@ -42,6 +43,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::convert::register(m)?; py::histogram::register(m)?; py::meter::register(m)?; + py::retouch::register(m)?; py::bounds::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 234b95598..ed3cf4b70 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -9,4 +9,5 @@ pub mod histogram; pub mod meter; pub mod morph; pub mod resize; +pub mod retouch; pub mod stats; diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs new file mode 100644 index 000000000..30e72cb55 --- /dev/null +++ b/negpy_core/src/py/retouch.rs @@ -0,0 +1,95 @@ +use numpy::{PyReadonlyArrayDyn, PyReadwriteArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::retouch; + +fn plane<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>, n: usize) -> PyResult<&'a [f32]> { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + if s.len() != n { + return Err(PyValueError::new_err("array sizes disagree")); + } + Ok(s) +} + +fn pixels(a: &PyReadwriteArrayDyn<'_, f32>) -> PyResult { + match a.shape()[..] { + [h, w, 3] => Ok(h * w), + _ => Err(PyValueError::new_err("needs an HxWx3 array")), + } +} + +/// `_fill_rung(num, den, fill, first, reject_floor_mass)` in place, with (score floor, tau). +#[pyfunction] +fn fill_rung( + py: Python<'_>, + num: PyReadonlyArrayDyn<'_, f32>, + den: PyReadonlyArrayDyn<'_, f32>, + mut fill: PyReadwriteArrayDyn<'_, f32>, + first: bool, + reject_floor_mass: bool, + constants: (f32, f32), +) -> PyResult<()> { + let n = pixels(&fill)?; + let (num, den) = (plane(&num, 3 * n)?, plane(&den, n)?); + let fill = fill + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + py.detach(|| retouch::fill_rung(num, den, fill, first, reject_floor_mass, constants)); + Ok(()) +} + +/// `_blend_fill(src, score, fill, a_out)` in place, with (write ramp top, ramp span). +#[pyfunction] +fn blend_fill( + py: Python<'_>, + src: PyReadonlyArrayDyn<'_, f32>, + score: PyReadonlyArrayDyn<'_, f32>, + mut fill: PyReadwriteArrayDyn<'_, f32>, + mut a_out: PyReadwriteArrayDyn<'_, f32>, + ramp: (f32, f32), +) -> PyResult<()> { + let n = pixels(&fill)?; + let (src, score) = (plane(&src, 3 * n)?, plane(&score, n)?); + let fill = fill + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + let a_out = a_out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + if a_out.len() != n { + return Err(PyValueError::new_err("array sizes disagree")); + } + py.detach(|| retouch::blend_fill(src, score, fill, a_out, ramp)); + Ok(()) +} + +/// `_add_floor_deficit(out, a, blur_src, blur_out)` in place. +#[pyfunction] +fn add_floor_deficit( + py: Python<'_>, + mut out: PyReadwriteArrayDyn<'_, f32>, + a: PyReadonlyArrayDyn<'_, f32>, + blur_src: PyReadonlyArrayDyn<'_, f32>, + blur_out: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<()> { + let n = pixels(&out)?; + let (a, bs, bo) = ( + plane(&a, n)?, + plane(&blur_src, 3 * n)?, + plane(&blur_out, 3 * n)?, + ); + let out = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + py.detach(|| retouch::add_floor_deficit(out, a, bs, bo)); + Ok(()) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(fill_rung, m)?)?; + m.add_function(wrap_pyfunction!(blend_fill, m)?)?; + m.add_function(wrap_pyfunction!(add_floor_deficit, m)?) +} diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs new file mode 100644 index 000000000..249783a06 --- /dev/null +++ b/negpy_core/src/retouch.rs @@ -0,0 +1,134 @@ +//! The per-pixel passes of the retouch score fill (negpy/features/retouch/logic.py), in its +//! float32 operation order. + +use multiversion::multiversion; +use rayon::prelude::*; + +const BLOCK: usize = 1 << 13; + +/// Python's `max(a, b)`, which Numba keeps: `a` unless `b` is greater. +fn py_max(a: f32, b: f32) -> f32 { + if b > a { b } else { a } +} + +/// Python's `min(a, b)`: `a` unless `b` is smaller. +fn py_min(a: f32, b: f32) -> f32 { + if b < a { b } else { a } +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn fill_rung_block( + num: &[f32], + den: &[f32], + fill: &mut [f32], + first: bool, + reject: bool, + (floor, tau): (f32, f32), +) { + let inv_span = 1.0 - floor; + for ((f, n), &dv) in fill + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(num.as_chunks::<3>().0) + .zip(den) + { + let d = py_max(dv, 1e-6); + if first { + for c in 0..3 { + f[c] = n[c] / d; + } + } else { + let mass = if reject { (dv - floor) / inv_span } else { dv }; + let conf = py_min(py_max(mass / tau, 0.0), 1.0); + let keep = 1.0 - conf; + for c in 0..3 { + f[c] = f[c] * keep + (n[c] / d) * conf; + } + } + } +} + +/// `_fill_rung`: one rung of the fill ladder in place over HxW pixels of three channels. +pub fn fill_rung( + num: &[f32], + den: &[f32], + fill: &mut [f32], + first: bool, + reject_floor_mass: bool, + constants: (f32, f32), +) { + fill.par_chunks_mut(3 * BLOCK) + .zip(num.par_chunks(3 * BLOCK).zip(den.par_chunks(BLOCK))) + .for_each(|(f, (n, d))| fill_rung_block(n, d, f, first, reject_floor_mass, constants)); +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn blend_block( + src: &[f32], + score: &[f32], + fill: &mut [f32], + a_out: &mut [f32], + (hi, span): (f32, f32), +) { + for (((f, s), &sc), ao) in fill + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + .zip(score) + .zip(a_out.iter_mut()) + { + let a = py_min(py_max((hi - sc) / span, 0.0), 1.0); + let a = a * a * (3.0 - 2.0 * a); + *ao = a; + let keep = 1.0 - a; + for c in 0..3 { + f[c] = s[c] * keep + f[c] * a; + } + } +} + +/// `_blend_fill`: `fill` becomes `src * (1 - a) + fill * a` under the smoothstep write ramp of +/// the score, the ramp kept in `a_out`. +pub fn blend_fill( + src: &[f32], + score: &[f32], + fill: &mut [f32], + a_out: &mut [f32], + ramp: (f32, f32), +) { + fill.par_chunks_mut(3 * BLOCK) + .zip(a_out.par_chunks_mut(BLOCK)) + .zip(src.par_chunks(3 * BLOCK).zip(score.par_chunks(BLOCK))) + .for_each(|((f, ao), (s, sc))| blend_block(s, sc, f, ao, ramp)); +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn deficit_block(out: &mut [f32], a: &[f32], blur_src: &[f32], blur_out: &[f32]) { + for (((o, &av), bs), bo) in out + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(a) + .zip(blur_src.as_chunks::<3>().0) + .zip(blur_out.as_chunks::<3>().0) + { + for c in 0..3 { + let v = o[c] + av * py_max(bs[c] - bo[c], 0.0); + o[c] = py_max(v, 0.0); + } + } +} + +/// `_add_floor_deficit`: `out += a * max(blur_src - blur_out, 0)`, then clamped at zero. +pub fn add_floor_deficit(out: &mut [f32], a: &[f32], blur_src: &[f32], blur_out: &[f32]) { + out.par_chunks_mut(3 * BLOCK) + .zip(a.par_chunks(BLOCK)) + .zip( + blur_src + .par_chunks(3 * BLOCK) + .zip(blur_out.par_chunks(3 * BLOCK)), + ) + .for_each(|((o, av), (bs, bo))| deficit_block(o, av, bs, bo)); +} From b7120ed4e918163709180016bd1448e343b83a18 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 03:26:17 +0200 Subject: [PATCH 051/133] perf(core): box filter inner loops take the AVX2 build The narrow row sums and the column recurrence move into multiversion functions; the baseline build ran them two float64 lanes wide. One core, 12 MP: 3x3 from 1.6x to 1.3x of cv2's single-thread time; wider kernels stay near 1.35x, bound by the running row sum. --- negpy_core/src/filter.rs | 114 +++++++++++++++++++++------------------ 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs index 56bb511ed..da66d16f0 100644 --- a/negpy_core/src/filter.rs +++ b/negpy_core/src/filter.rs @@ -2,6 +2,7 @@ //! opencv-python's AVX2 build: an 8-lane vector region with fused multiply-adds, then the scalar //! loops in the arithmetic GCC compiled them to. +use multiversion::multiversion; use rayon::prelude::*; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -603,6 +604,65 @@ pub fn gaussian_blur( sep_filter2d(src, dst, (h, w, cn), (&kx, &ky), border) } +/// Fresh row sums of a narrow box (up to five taps) over a row slice whose taps all lie inside +/// it: each output sums its taps left to right from zero, in float64. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn narrow_rows(out: &mut [f64], row: &[f32], cn: usize, kw: usize) { + let tap = |k: usize| &row[k * cn..]; + let f = |v: f32| v as f64; + match kw { + 1 => out + .iter_mut() + .zip(tap(0)) + .for_each(|(o, &a)| *o = 0.0 + f(a)), + 2 => out + .iter_mut() + .zip(tap(0).iter().zip(tap(1))) + .for_each(|(o, (&a, &b))| *o = (0.0 + f(a)) + f(b)), + 3 => out + .iter_mut() + .zip(tap(0).iter().zip(tap(1)).zip(tap(2))) + .for_each(|(o, ((&a, &b), &c))| *o = ((0.0 + f(a)) + f(b)) + f(c)), + 4 => out + .iter_mut() + .zip(tap(0).iter().zip(tap(1)).zip(tap(2)).zip(tap(3))) + .for_each(|(o, (((&a, &b), &c), &d))| *o = (((0.0 + f(a)) + f(b)) + f(c)) + f(d)), + _ => out + .iter_mut() + .zip( + tap(0) + .iter() + .zip(tap(1)) + .zip(tap(2)) + .zip(tap(3)) + .zip(tap(4)), + ) + .for_each(|(o, ((((&a, &b), &c), &d), &e))| { + *o = ((((0.0 + f(a)) + f(b)) + f(c)) + f(d)) + f(e) + }), + } +} + +/// One row of the column recurrence: add the new row sum, write the scaled total when the window +/// is full, subtract the row sum leaving it. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn column_step(sum: &mut [f64], sp: &[f64], old: &[f64], out: Option<&mut [f32]>, scale: f64) { + match out { + Some(o) => { + for (((s, &p), &q), o) in sum.iter_mut().zip(sp).zip(old).zip(o.iter_mut()) { + let s0 = *s + p; + *o = (s0 * scale) as f32; + *s = s0 - q; + } + } + None => { + for ((s, &p), &q) in sum.iter_mut().zip(sp).zip(old) { + *s = (*s + p) - q; + } + } + } +} + /// `cv2.boxFilter(src, -1, (kw, kh), normalize=..., borderType=border)` (and `cv2.blur`) for /// float32, anchor at the center. Row sums in float64: a fresh sum per output up to five wide, /// a running sum (`s += a - b`) past that. Column sums keep a running total: add the new row @@ -660,48 +720,7 @@ pub fn box_filter( if hi == lo { return; } - let inner = &mut out[lo * cn..hi * cn]; - let tap_row = |k: usize| &row[(lo + k - ax) * cn..]; - let f = |v: f32| v as f64; - match kw { - 1 => inner - .iter_mut() - .zip(tap_row(0)) - .for_each(|(o, &a)| *o = 0.0 + f(a)), - 2 => inner - .iter_mut() - .zip(tap_row(0).iter().zip(tap_row(1))) - .for_each(|(o, (&a, &b))| *o = (0.0 + f(a)) + f(b)), - 3 => inner - .iter_mut() - .zip(tap_row(0).iter().zip(tap_row(1)).zip(tap_row(2))) - .for_each(|(o, ((&a, &b), &c))| *o = ((0.0 + f(a)) + f(b)) + f(c)), - 4 => inner - .iter_mut() - .zip( - tap_row(0) - .iter() - .zip(tap_row(1)) - .zip(tap_row(2)) - .zip(tap_row(3)), - ) - .for_each(|(o, (((&a, &b), &c), &d))| { - *o = (((0.0 + f(a)) + f(b)) + f(c)) + f(d) - }), - _ => inner - .iter_mut() - .zip( - tap_row(0) - .iter() - .zip(tap_row(1)) - .zip(tap_row(2)) - .zip(tap_row(3)) - .zip(tap_row(4)), - ) - .for_each(|(o, ((((&a, &b), &c), &d), &e))| { - *o = ((((0.0 + f(a)) + f(b)) + f(c)) + f(d)) + f(e) - }), - } + narrow_rows(&mut out[lo * cn..hi * cn], &row[(lo - ax) * cn..], cn, kw); return; } pad.clear(); @@ -768,16 +787,9 @@ pub fn box_filter( }; if let Some(y) = rc.checked_sub(kh - 1) { let at = (y - y0) * tile_w + tile_x0; - let o = &mut tile[at..at + sw]; - for i in 0..sw { - let s0 = s[i] + sp[i]; - o[i] = (s0 * scale) as f32; - s[i] = s0 - old[i]; - } + column_step(s, sp, old, Some(&mut tile[at..at + sw]), scale); } else { - for i in 0..sw { - s[i] = (s[i] + sp[i]) - old[i]; - } + column_step(s, sp, old, None, scale); } } }; From d6cc5d97630a7dc904621e378aef7aac38f351a7 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 03:33:51 +0200 Subject: [PATCH 052/133] fix(core): trunc_bits returns the integer, not the raw float bits It handed back the exponent bits with the mantissa; the uint8 and uint16 casts dropped them, a wider integer would not have. --- negpy_core/src/convert.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/negpy_core/src/convert.rs b/negpy_core/src/convert.rs index d7ebd036d..492ec296f 100644 --- a/negpy_core/src/convert.rs +++ b/negpy_core/src/convert.rs @@ -56,8 +56,8 @@ pub trait Level: Copy + Send { /// The integer part of `v` in 0..2^31: added to 2^52, it fills the low mantissa bits. The /// saturating `as` cast keeps LLVM from vectorizing the loops; this rounds and adds instead. -fn trunc_bits(v: f64) -> u64 { - (v.trunc() + 4503599627370496.0).to_bits() +pub(crate) fn trunc_bits(v: f64) -> u64 { + (v.trunc() + 4503599627370496.0).to_bits() & ((1 << 52) - 1) } impl Level for u8 { From f67711a90c8f6d26d0cb8f9e91a05e0690bdc00d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 07:21:52 +0200 Subject: [PATCH 053/133] perf(core): own vectorized log10f; to_log_density in Rust mathf::log10f is branch-free (bit split, float64 atanh series, one rounding) and correctly rounded on every positive normal float32 (2.13 B inputs checked against float64 log10). libm::log10f was 14x slower than NumPy's SVML and broke the speed gate. NumPy's float32 log10 is up to 3 ULP off on about half the inputs, so prefilter_log_grid replays with ulp=3 (ledger entry). Replay 60/60, all other Phase 2 recordings unchanged; bench 0.476x. 24 MP frame on one core: 64 ms against NumPy's 77 ms. replay.py takes --key to pick functions by manifest key. --- CLAUDE.md | 2 +- negpy/features/exposure/normalization.py | 10 +-- negpy_core/LEDGER.md | 8 ++- negpy_core/manifest.toml | 19 ++++-- negpy_core/src/analysis.rs | 20 ++++++ negpy_core/src/lib.rs | 1 + negpy_core/src/mathf.rs | 77 ++++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 20 ++++++ scripts/replay.py | 8 ++- tests/test_core_same_bits.py | 2 + tests/test_meter_fast_paths.py | 23 +++---- 11 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 negpy_core/src/mathf.rs diff --git a/CLAUDE.md b/CLAUDE.md index a07e1df68..49a4acfa2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. -- Transcendental functions (`log10`, `exp`, `pow`, ...) come from the `libm` crate: NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. +- Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. ### Pipeline diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 281101b77..f8a013ceb 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -201,14 +201,8 @@ def sorted_channel_grid(img_log: ImageBuffer) -> np.ndarray: def to_log_density(image: ImageBuffer) -> ImageBuffer: - """ - Linear scan -> log10 density, the domain every meter reads. - - fmin/fmax rather than clip: they drop a NaN in favour of the bound, so one clamp covers - the NaN and infinity fixup as well. - """ - epsilon = 1e-6 - return np.log10(np.fmin(np.fmax(image, epsilon), 1.0)) + """Linear scan -> log10 density clamped to 1e-6..1 (NaN to 1e-6), the domain every meter reads.""" + return negpy_core.to_log_density(np.ascontiguousarray(image, dtype=np.float32)) def _rms_chroma(triplets: np.ndarray) -> np.ndarray: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 56aa8f437..4484fda96 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -4,11 +4,13 @@ A port whose output cannot match the Python version bit for bit is listed here. that ships negpy_core bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for all entries, so users render their thumbnails again only once. -Transcendental functions use the pure-Rust `libm` crate, the same bits on every platform. -NumPy computes them through SVML on an AVX-512 CPU and through libm elsewhere, so the Python -results already differ by CPU. Replay accepts the tolerance listed for each entry. +Transcendental functions in array loops come from `src/mathf.rs`, scalar ones from the pure-Rust +`libm` crate: the same bits on every platform. NumPy computes them through SVML on an AVX-512 CPU +and through libm elsewhere, so the Python results already differ by CPU. Replay accepts the +tolerance listed for each entry. | Function | Differs because | Tolerance | |---|---|---| | `features/exposure/normalization.py::_base_blocks` | Blocks of equal log brightness come out in block order, not in NumPy's SIMD argsort order. log10 is libm's, not SVML's, so a block on a group gap can change group in rare cases. | rows in any order | | `negpy_core.distance_transform` (DIST_L2, mask 3), once a port calls it | opencv-python runs IPP's float32 chamfer, whose 3x3 pass picks a path one float32 unit longer than the two-pass order at rare pixels; OpenCV without IPP (Apple Silicon wheels) returns its own fixed-point values. | ulp=1 | +| `features/exposure/normalization.py::to_log_density` (through `prefilter_log_grid`) | log10 is correctly rounded (`mathf::log10f`); NumPy's float32 log10 (SVML) is up to 3 ULP off on about half the inputs. | ulp=3 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 2c07ffe5a..2c7f544be 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -231,7 +231,7 @@ depends = [] phase = 2 status = "todo" calls = [] -depends = ["analyze_log_exposure_bounds_from_log"] +depends = ["analyze_log_exposure_bounds_from_log", "to_log_density"] ["features/exposure/normalization.py::analyze_log_exposure_bounds_from_log"] phase = 2 @@ -279,7 +279,7 @@ depends = ["resolve_crosstalk_matrix"] phase = 2 status = "todo" calls = [] -depends = ["measure_anchor_from_log"] +depends = ["measure_anchor_from_log", "to_log_density"] ["features/exposure/normalization.py::measure_anchor_from_log"] phase = 2 @@ -309,7 +309,7 @@ depends = ["_block_median_grid"] phase = 2 status = "todo" calls = [] -depends = ["measure_neutral_axis_from_log"] +depends = ["measure_neutral_axis_from_log", "to_log_density"] ["features/exposure/normalization.py::measure_neutral_axis_from_log"] phase = 2 @@ -333,7 +333,7 @@ depends = ["_block_median_grid"] phase = 2 status = "todo" calls = [] -depends = ["measure_textural_range_from_log"] +depends = ["measure_textural_range_from_log", "to_log_density"] ["features/exposure/normalization.py::measure_textural_range_from_log"] phase = 2 @@ -367,9 +367,10 @@ depends = ["_weighted_median"] ["features/exposure/normalization.py::prefilter_log_grid"] phase = 2 -status = "todo" +status = "ported" +compare = "ulp=3" calls = [] -depends = ["_block_median_grid"] +depends = ["_block_median_grid", "to_log_density"] ["features/exposure/normalization.py::resolve_crosstalk_matrix"] phase = 2 @@ -383,6 +384,12 @@ status = "todo" calls = ["np.sort"] depends = [] +["features/exposure/normalization.py::to_log_density"] +phase = 2 +status = "todo" +calls = ["negpy_core.to_log_density"] +depends = [] + ["features/geometry/batch_autocrop.py::_border_without_a_film_box"] phase = 2 status = "todo" diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 4c8f9b12e..c4987e2ff 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -71,6 +71,26 @@ pub fn detection_luma(lum: &[f32], dst: &mut [f32]) -> Option<()> { Some(()) } +/// `to_log_density`: log10 of the value clamped to 1e-6..1, NaN to 1e-6. +pub fn to_log_density(img: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(1 << 14) + .zip(img.par_chunks(1 << 14)) + .for_each(|(d, s)| log_density_block(s, d)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +#[allow(clippy::manual_clamp)] +fn log_density_block(src: &[f32], dst: &mut [f32]) { + for (d, &v) in dst.iter_mut().zip(src) { + // max drops NaN, as np.fmax does; clamp would keep it. + *d = crate::mathf::log10f(v.max(1e-6).min(1.0)); + } +} + /// `normalize_log_image`: `(v - floor) / (ceil - floor)` per channel of an HxWx3 log image, /// unclamped. A span under 1e-6 divides by +-1e-6, by the span's sign. pub fn normalize_log_image(img: &[f32], floors: [f32; 3], ceils: [f32; 3], dst: &mut [f32]) { diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 3c0c2bad2..4b0b19efb 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -8,6 +8,7 @@ pub mod distance; pub mod filter; pub mod gray; pub mod histogram; +pub mod mathf; pub mod median; pub mod meter; pub mod morph; diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs new file mode 100644 index 000000000..408df9eab --- /dev/null +++ b/negpy_core/src/mathf.rs @@ -0,0 +1,77 @@ +//! Transcendental functions for hot array loops: branch-free, so LLVM vectorizes them, and pure +//! float arithmetic, so every platform gets the same bits. Scalar calls may use `libm`. + +/// log10 of a positive normal f32, correctly rounded (`exhaustive` checks every input). +/// x = m * 2^e with m in [sqrt(1/2), sqrt(2)); ln(m) from the atanh series in +/// f64, rounded once to f32. +#[inline(always)] +pub fn log10f(x: f32) -> f32 { + let bits = x.to_bits(); + // Subtracting the bits of sqrt(1/2) puts the exponent split at sqrt(2). + let e = (bits.wrapping_sub(0x3f35_04f3) as i32) >> 23; + let m = f32::from_bits(bits.wrapping_sub((e as u32) << 23)) as f64; + let s = (m - 1.0) / (m + 1.0); + let s2 = s * s; + // 2 s (1 + s^2/3 + s^4/5 + ...), |s| <= 0.1716: these terms reach f64 accuracy. + let p = 1.0 + + s2 * (1.0 / 3.0 + + s2 * (1.0 / 5.0 + + s2 * (1.0 / 7.0 + + s2 * (1.0 / 9.0 + + s2 * (1.0 / 11.0 + + s2 * (1.0 / 13.0 + s2 * (1.0 / 15.0 + s2 * (1.0 / 17.0)))))))); + // log10(2) in two parts: HI has 20 trailing zero bits, so e * HI is exact for any exponent. + const HI: f64 = 0.301_029_995_607_677_85; + const LO: f64 = std::f64::consts::LOG10_2 - HI; + let e = e as f64; + (e * HI + (e * LO + 2.0 * s * p * std::f64::consts::LOG10_E)) as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log10f_is_correctly_rounded_on_a_sample() { + let mut bits = 1e-6f32.to_bits(); + while bits <= 1f32.to_bits() { + let x = f32::from_bits(bits); + assert_eq!(log10f(x), libm::log10(x as f64) as f32, "{x:e}"); + bits += 997; + } + for x in [ + 1.0f32, + 0.1, + 0.01, + 1e-6, + 0.5, + 2.0, + 10.0, + 1e30, + f32::MIN_POSITIVE, + ] { + assert_eq!(log10f(x), libm::log10(x as f64) as f32, "{x:e}"); + } + } +} + +#[cfg(test)] +mod exhaustive { + /// `cargo test --release -- --ignored`. + #[test] + #[ignore] + fn all() { + let (lo, hi) = (f32::MIN_POSITIVE.to_bits(), f32::MAX.to_bits()); + let mut bad = 0u64; + for b in lo..=hi { + let x = f32::from_bits(b); + if super::log10f(x) != libm::log10(x as f64) as f32 { + bad += 1; + if bad < 20 { + eprintln!("{b:#x} {x:e}"); + } + } + } + eprintln!("checked {} bad {bad}", hi - lo + 1); + } +} diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 291c5c637..b651e25c5 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -43,6 +43,25 @@ fn luminance<'py>( Ok(out) } +/// `to_log_density` of a float32 image, any shape. +#[pyfunction] +fn to_log_density<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = img.shape().to_vec(); + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| analysis::to_log_density(data, dst)); + } + Ok(out) +} + /// `_normalize_to_uint8` of a float32 image. #[pyfunction] fn normalize_to_uint8<'py>( @@ -601,6 +620,7 @@ fn film_surround_is_plausible( pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; + m.add_function(wrap_pyfunction!(to_log_density, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; m.add_function(wrap_pyfunction!(base_blocks, m)?)?; diff --git a/scripts/replay.py b/scripts/replay.py index 07ab6e6cf..80ced20d5 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -247,8 +247,11 @@ def same(a, b, compare: str = "exact") -> bool: return a == b +KEY_FILTER = "" + + def _calls(keys: list[str]): - for key in keys: + for key in [k for k in keys if KEY_FILTER in k]: resolved = _resolve(key) files = sorted(_folder(key).glob("*.pkl")) if resolved is None or not files: @@ -292,6 +295,7 @@ def main() -> None: parser.add_argument("mode", choices=("record", "replay", "bench")) parser.add_argument("files", nargs="*") parser.add_argument("--phase", type=int, action="append", help="only functions of this phase (repeatable)") + parser.add_argument("--key", default="", help="only functions whose manifest key holds this text") parser.add_argument("--edits", help="record each frame with its saved edit from this edits.db (read from a copy)") parser.add_argument( "--set", @@ -301,6 +305,8 @@ def main() -> None: help="override one config field for every frame, e.g. retouch.dust_remove=true", ) args = parser.parse_args() + global KEY_FILTER + KEY_FILTER = args.key if args.mode == "record": if not args.files: parser.error("record needs at least one frame") diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 7d7a8fc42..905795664 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -28,6 +28,7 @@ [c.ravel() for c in negpy_core.find_external_contours((_IMG1 > 0.5).astype(np.uint8) * np.uint8(255))[0]] ), "morph_close_u8": lambda: negpy_core.morphology_ex((_IMG1 > 0.5).astype(np.uint8) * 255, 3, np.ones((9, 7), np.uint8), 2), + "to_log_density": lambda: negpy_core.to_log_density(np.ascontiguousarray(_IMG1 * _IMG1)), } _HASHES = { @@ -44,6 +45,7 @@ "canny": "1e3125ae47a5dd1719f97531ffebffc79a6461ba1fae024d26d4d7a85b89b968", "find_external_contours": "ebb4302dd2bc7226a7ed24fbb5e16e14f407d5693ce47128a649cee9e4a098be", "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", + "to_log_density": "cc4d2086edf051826619f024a4f5d28c80d28528291d9df4610c1d3854144038", } diff --git a/tests/test_meter_fast_paths.py b/tests/test_meter_fast_paths.py index 371978a99..173148fd3 100644 --- a/tests/test_meter_fast_paths.py +++ b/tests/test_meter_fast_paths.py @@ -64,22 +64,15 @@ def test_sorted_grid_is_ignored_when_a_roi_still_has_to_be_applied(self): class TestToLogDensity(unittest.TestCase): - def test_matches_the_nan_to_num_clip_it_replaces(self): + def test_is_the_correctly_rounded_log_of_the_clamped_value(self): eps = 1e-6 - for dtype in (np.float32, np.float64): - x = (np.random.default_rng(1).random((97, 89, 3)) * 1.4 - 0.2).astype(dtype) - x[0, 0] = np.nan - x[1, 1] = np.inf - x[2, 2] = -np.inf - x[3, 3] = 0.0 - x[4, 4] = 1.0 - x[5, 5] = eps - x[6, 6] = -5.0 - x[7, 7] = 1e30 - expected = np.log10(np.clip(np.nan_to_num(x, nan=eps, posinf=1.0, neginf=eps), eps, 1.0)) - got = to_log_density(x) - self.assertEqual(expected.dtype, got.dtype) - self.assertTrue(np.array_equal(expected, got), msg=f"{dtype.__name__} differs") + x = (np.random.default_rng(1).random((97, 89, 3)) * 1.4 - 0.2).astype(np.float32) + x[:8, :8] = np.array([np.nan, np.inf, -np.inf, 0.0, 1.0, eps, -5.0, 1e30], dtype=np.float32)[:, None, None] + clamped = np.clip(np.nan_to_num(x, nan=eps, posinf=1.0, neginf=eps), np.float32(eps), 1.0) + expected = np.log10(clamped.astype(np.float64)).astype(np.float32) + got = to_log_density(x) + self.assertEqual(got.dtype, np.float32) + self.assertTrue(np.array_equal(expected, got)) if __name__ == "__main__": From 442a820de526afb2cc5b32caa42c393e768b3bd6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 07:28:39 +0200 Subject: [PATCH 054/133] perf(core): neutral-axis meter in Rust; own vectorized atan2f measure_neutral_axis_from_log runs as one Rust call after its crop and block-median grid: subject sectors, both chroma passes, band refs and the confidence. NumPy rules copied: float32 compares with a Python float, np.quantile at (n - 1) * q with float32 interpolation, np.degrees as x * (f32(180) / f32(pi)), np.hypot rounded once. mathf::atan2f (float64 series, one rounding) is within 1 ULP of the float64 reference; NumPy's float32 arctan2 is up to 3 ULP off. Replay 60/60 bit-identical; bench 0.457x. --- negpy/features/exposure/logic.py | 2 +- negpy/features/exposure/normalization.py | 135 +++-------------- negpy_core/manifest.toml | 8 +- negpy_core/src/mathf.rs | 76 ++++++++++ negpy_core/src/meter.rs | 182 ++++++++++++++++++++++- negpy_core/src/py/meter.rs | 89 ++++++++++- negpy_core/src/stats.rs | 37 +++++ 7 files changed, 404 insertions(+), 125 deletions(-) diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index ad1d3a824..cf0e34c3a 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -394,7 +394,7 @@ def _apply_print_curve_kernel( # Chroma-selective dye separation, in place of the frame-wide saturation # matrix, which then carries the paper's coupling only and keeps # compose_density_matrices' separation-outermost order. Chroma mirrors - # _rms_chroma in normalization.py. + # rms_chroma in negpy_core/src/meter.rs. s0 = dens[0] - d_min_rgb[0] s1 = dens[1] - d_min_rgb[1] s2 = dens[2] - d_min_rgb[2] diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index f8a013ceb..a0efffd11 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -205,33 +205,6 @@ def to_log_density(image: ImageBuffer) -> ImageBuffer: return negpy_core.to_log_density(np.ascontiguousarray(image, dtype=np.float32)) -def _rms_chroma(triplets: np.ndarray) -> np.ndarray: - """Distance from the neutral axis in normalized-density space (pairwise RMS): - rotation-symmetric around grey, so the near-neutral ranking is hue-uniform - (max-min scores an opposed R/B split double a same-side deviation).""" - r, g, b = triplets[..., 0], triplets[..., 1], triplets[..., 2] - return np.sqrt(((r - g) ** 2 + (g - b) ** 2 + (r - b) ** 2) / 3.0) - - -def _subject_cells(norm_f: np.ndarray) -> np.ndarray: - """Cells in the foliage or the sky sector. In a frame with no grays the lowest-chroma cells are - desaturated leaves or pale sky, and neutralizing them prints magenta or yellow (a printer's - subject failure).""" - from negpy.features.exposure.models import EXPOSURE_CONSTANTS - - c = EXPOSURE_CONSTANTS - dr = norm_f[:, 0] - norm_f[:, 1] - db = norm_f[:, 2] - norm_f[:, 1] - t, s = (dr + db) / 2.0, (dr - db) / 2.0 - hue = np.degrees(np.arctan2(-s, t)) - chroma = np.hypot(t, s) - cells = np.zeros(chroma.shape, dtype=bool) - for name in ("foliage", "sky"): - lo, hi = c[f"neutral_axis_{name}_hue"] - cells |= (chroma > float(c[f"neutral_axis_{name}_chroma"])) & (hue > float(lo)) & (hue < float(hi)) - return cells - - def measure_neutral_axis_from_log( img_log: ImageBuffer, bounds: "LogNegativeBounds", @@ -253,97 +226,25 @@ def measure_neutral_axis_from_log( if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - img_log = _block_median_grid(img_log) - norm = normalize_log_image(img_log, bounds) - luma = LUMA_R * norm[:, :, 0] + LUMA_G * norm[:, :, 1] + LUMA_B * norm[:, :, 2] - - flat_log = img_log.reshape(-1, 3) - norm_f = norm.reshape(-1, 3) - luma_f = luma.reshape(-1) - if exclude_subjects: - keep = ~_subject_cells(norm_f) - flat_log, norm_f, luma_f = flat_log[keep], norm_f[keep], luma_f[keep] - chroma_f = _rms_chroma(norm_f) - c = EXPOSURE_CONSTANTS - q = float(c["neutral_axis_chroma_quantile"]) - cap = float(c["neutral_axis_chroma_cap"]) - pass1_cap = float(c["neutral_axis_first_pass_cap"]) - min_px = int(c["neutral_axis_min_pixels"]) - epsilon = 1e-6 - - def _band_refs( - lo: float, hi: float, chroma_vals: np.ndarray, cap_val: float - ) -> Optional[Tuple[Tuple[float, float, float], float, int, float]]: - band = (luma_f >= lo) & (luma_f <= hi) - if int(band.sum()) < min_px: - return None - band_chroma = chroma_vals[band] - thr = float(np.quantile(band_chroma, q)) - keep = band_chroma <= thr - idx = np.nonzero(band)[0][keep] - near_neutral_chroma = float(np.median(band_chroma[keep])) if idx.size else cap_val - if idx.size < min_px or near_neutral_chroma > cap_val: - return None - # One gather for all three channels: the per-channel fancy index is the cost here. - sel = flat_log[idx] - # R and B are green plus the set's median offset from green. Independent channel - # medians can each fall in a different cluster of a set that holds two colors. - green = float(np.median(sel[:, 1])) - refs = (green + float(np.median(sel[:, 0] - sel[:, 1])), green, green + float(np.median(sel[:, 2] - sel[:, 1]))) - offsets = (norm_f[idx][:, (0, 2)] - norm_f[idx][:, 1:2]).astype(np.float64) - p10, p90 = np.percentile(offsets, (10.0, 90.0), axis=0) - return (refs, near_neutral_chroma, int(idx.size), float(np.max(p90 - p10))) - - def _norm_ref(refs: Tuple[float, float, float]) -> Tuple[float, float, float]: - out = [] - for ch in range(3): - denom = bounds.ceils[ch] - bounds.floors[ch] - if abs(denom) < epsilon: - denom = epsilon if denom >= 0 else -epsilon - out.append((refs[ch] - bounds.floors[ch]) / denom) - return (out[0], out[1], out[2]) - - hb = c["neutral_axis_highlight_band"] - mb = c["neutral_axis_mid_band"] - sb = c["neutral_axis_shadow_band"] - mid1 = _band_refs(float(mb[0]), float(mb[1]), chroma_f, pass1_cap) - sh1 = _band_refs(float(sb[0]), float(sb[1]), chroma_f, pass1_cap) - if mid1 is None or sh1 is None: - return None - - nm, ns = _norm_ref(mid1[0]), _norm_ref(sh1[0]) - corrected = norm_f.copy() - for ch in (0, 2): - du = nm[ch] - ns[ch] - if abs(du) < epsilon: - a, b = 1.0, nm[1] - nm[ch] - else: - a = (nm[1] - ns[1]) / du - b = nm[1] - a * nm[ch] - corrected[:, ch] = a * norm_f[:, ch] + b - chroma2_f = _rms_chroma(corrected) - - mid = _band_refs(float(mb[0]), float(mb[1]), chroma2_f, cap) - shadow = _band_refs(float(sb[0]), float(sb[1]), chroma2_f, cap) - if mid is None or shadow is None: - return None - highlight = _band_refs(float(hb[0]), float(hb[1]), chroma2_f, cap) - - # Confidence: the corrected tightness of the grey sets, times the midtone sample size, - # times the mid/shadow deviation agreement, where a dead zone passes plausible crossover. - n0 = float(c["neutral_axis_confidence_n0"]) - dead = float(c["neutral_axis_agreement_deadzone"]) - scale = float(c["neutral_axis_agreement_scale"]) - tight = float(np.clip(1.0 - max(mid[1], shadow[1]) / cap, 0.0, 1.0)) - size_term = mid[2] / (mid[2] + n0) - dm, ds = _norm_ref(mid[0]), _norm_ref(shadow[0]) - spread = max(abs((dm[ch] - dm[1]) - (ds[ch] - ds[1])) for ch in (0, 2)) - agree = 1.0 - min(max(spread - dead, 0.0) / scale, 1.0) - width = max(mid[3], shadow[3]) - single = 1.0 - min(max(width - float(c["neutral_axis_width_deadzone"]), 0.0) / float(c["neutral_axis_width_scale"]), 1.0) - confidence = float(np.clip(tight * size_term * agree * single, 0.0, 1.0)) - return (mid[0], shadow[0], highlight[0] if highlight is not None else None, confidence) + consts = ( + float(c["neutral_axis_chroma_quantile"]), + float(c["neutral_axis_chroma_cap"]), + float(c["neutral_axis_first_pass_cap"]), + int(c["neutral_axis_min_pixels"]), + tuple(float(v) for v in c["neutral_axis_highlight_band"]), + tuple(float(v) for v in c["neutral_axis_mid_band"]), + tuple(float(v) for v in c["neutral_axis_shadow_band"]), + (float(c["neutral_axis_foliage_chroma"]), *(float(v) for v in c["neutral_axis_foliage_hue"])), + (float(c["neutral_axis_sky_chroma"]), *(float(v) for v in c["neutral_axis_sky_hue"])), + float(c["neutral_axis_confidence_n0"]), + (float(c["neutral_axis_agreement_deadzone"]), float(c["neutral_axis_agreement_scale"])), + (float(c["neutral_axis_width_deadzone"]), float(c["neutral_axis_width_scale"])), + ) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + floors = tuple(float(v) for v in bounds.floors) + ceils = tuple(float(v) for v in bounds.ceils) + return negpy_core.neutral_axis(grid, floors, ceils, exclude_subjects, consts) def measure_neutral_axis( diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 2c7f544be..69e82b2aa 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -313,13 +313,13 @@ depends = ["measure_neutral_axis_from_log", "to_log_density"] ["features/exposure/normalization.py::measure_neutral_axis_from_log"] phase = 2 -status = "todo" -calls = ["np.median", "np.percentile", "np.quantile"] -depends = ["_band_refs", "_block_median_grid", "normalize_log_image"] +status = "ported" +calls = ["negpy_core.neutral_axis"] +depends = ["_block_median_grid"] ["features/exposure/normalization.py::measure_neutral_axis_from_log._band_refs"] phase = 2 -status = "todo" +status = "ported" calls = ["np.median", "np.percentile", "np.quantile"] depends = [] diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index 408df9eab..6d9d16479 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -27,6 +27,46 @@ pub fn log10f(x: f32) -> f32 { (e * HI + (e * LO + 2.0 * s * p * std::f64::consts::LOG10_E)) as f32 } +/// atan2 of finite f32 in f64, rounded once: |y/x| folded to [0, 1], then to |u| <= tan(pi/8) +/// by atan(r) = pi/4 + atan((r - 1) / (r + 1)), where the Taylor series reaches f64 accuracy. +/// Signed zeros follow C's atan2. +#[inline(always)] +pub fn atan2f(y: f32, x: f32) -> f32 { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + let (ay, ax) = ((y as f64).abs(), (x as f64).abs()); + let swap = ay > ax; + let (n, d) = if swap { (ax, ay) } else { (ay, ax) }; + let r = if d == 0.0 { 0.0 } else { n / d }; + let big = r > std::f64::consts::SQRT_2 - 1.0; + let u = if big { (r - 1.0) / (r + 1.0) } else { r }; + let u2 = u * u; + // (-1)^k / (2k + 1): the series to u^25, whose next term is under 2^-37 of atan(u). + const C: [f64; 13] = [ + 1.0, + -1.0 / 3.0, + 1.0 / 5.0, + -1.0 / 7.0, + 1.0 / 9.0, + -1.0 / 11.0, + 1.0 / 13.0, + -1.0 / 15.0, + 1.0 / 17.0, + -1.0 / 19.0, + 1.0 / 21.0, + -1.0 / 23.0, + 1.0 / 25.0, + ]; + let mut p = C[12]; + for &c in C[..12].iter().rev() { + p = c + u2 * p; + } + let mut a = u * p; + a = if big { FRAC_PI_4 + a } else { a }; + a = if swap { FRAC_PI_2 - a } else { a }; + a = if x.is_sign_negative() { PI - a } else { a }; + (a as f32).copysign(y) +} + #[cfg(test)] mod tests { use super::*; @@ -55,6 +95,42 @@ mod tests { } } +#[cfg(test)] +mod atan2_tests { + use super::atan2f; + + #[test] + fn atan2f_is_within_one_ulp_and_keeps_c_zeros() { + let mut state = 0x2545_f491_4f6c_dd1du64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + ((state >> 40) as f32 / (1u64 << 24) as f32) * 4.0 - 2.0 + }; + let mut off = 0; + for _ in 0..1_000_000 { + let (y, x) = (next(), next()); + let want = libm::atan2(y as f64, x as f64) as f32; + let got = atan2f(y, x); + let ulp = (got.to_bits() as i64 - want.to_bits() as i64).abs(); + assert!(ulp <= 1, "{y} {x}"); + off += (ulp > 0) as usize; + } + assert!(off < 10, "{off}"); + let pi = std::f32::consts::PI; + for (y, x, want) in [ + (0.0, 0.0, 0.0), + (-0.0, 0.0, -0.0), + (0.0, -0.0, pi), + (-0.0, -0.0, -pi), + (1.0, 0.0, pi / 2.0), + ] { + assert_eq!(atan2f(y, x).to_bits(), want.to_bits(), "{y} {x}"); + } + } +} + #[cfg(test)] mod exhaustive { /// `cargo test --release -- --ignored`. diff --git a/negpy_core/src/meter.rs b/negpy_core/src/meter.rs index 79f0db342..47c7d5661 100644 --- a/negpy_core/src/meter.rs +++ b/negpy_core/src/meter.rs @@ -1,7 +1,7 @@ //! The textured-cell meters of negpy/features/exposure/normalization.py: anchor, textural //! range, shadow reach and highlight hold, read off the block-median grid. -use crate::stats::{np_percentiles, np_percentiles_f64, pairwise_sum_f32}; +use crate::stats::{np_median, np_percentiles, np_percentiles_f64, np_quantile, pairwise_sum_f32}; const LUMA_R: f32 = 0.2126; const LUMA_G: f32 = 0.7152; @@ -150,3 +150,183 @@ pub fn textural_range(lum: &[f32], clip: f64) -> f64 { let p = np_percentiles_f64(lum, &[clip, 100.0 - clip]); (p[1] - p[0]).abs() } + +/// The constants of `measure_neutral_axis_from_log`. Bands and subject sectors compare in +/// float32, as NumPy compares a float32 array with a Python float. +pub struct NeutralConsts { + pub quantile: f64, + pub cap: f64, + pub first_pass_cap: f64, + pub min_pixels: usize, + pub highlight: (f32, f32), + pub mid: (f32, f32), + pub shadow: (f32, f32), + /// Foliage and sky: (chroma floor, hue low, hue high) in degrees. + pub subjects: [(f32, f32, f32); 2], + pub n0: f64, + /// (dead zone, scale) of the mid/shadow agreement and of the offset width. + pub agreement: (f64, f64), + pub width: (f64, f64), +} + +pub type Refs = [f64; 3]; + +pub struct NeutralAxis { + pub mid: Refs, + pub shadow: Refs, + pub highlight: Option, + pub confidence: f64, +} + +struct BandRefs { + refs: Refs, + chroma: f64, + size: usize, + width: f64, +} + +/// `_rms_chroma` of one normalized triplet, in float32. +fn rms_chroma(n: [f32; 3]) -> f32 { + let [r, g, b] = n; + ((((r - g) * (r - g) + (g - b) * (g - b)) + (r - b) * (r - b)) / 3.0).sqrt() +} + +/// `_subject_cells` for one normalized triplet: foliage or sky by hue and chroma. +fn is_subject(n: [f32; 3], subjects: &[(f32, f32, f32); 2]) -> bool { + let (dr, db) = (n[0] - n[1], n[2] - n[1]); + let (t, s) = ((dr + db) / 2.0, (dr - db) / 2.0); + // np.hypot of float32 rounds the exact hypotenuse once. + let chroma = ((t as f64) * (t as f64) + (s as f64) * (s as f64)).sqrt() as f32; + if !subjects.iter().any(|&(c, _, _)| chroma > c) { + return false; + } + let hue = crate::mathf::atan2f(-s, t) * (180.0f32 / std::f32::consts::PI); + subjects + .iter() + .any(|&(c, lo, hi)| chroma > c && hue > lo && hue < hi) +} + +/// `measure_neutral_axis_from_log` over a block-median grid (HxWx3 raw log), after its crop. +/// `floors32`/`ceils32` are the bounds as `normalize_log_image` reads them, `floors`/`ceils` +/// as Python floats. +pub fn neutral_axis( + grid: &[f32], + (floors32, ceils32): ([f32; 3], [f32; 3]), + (floors, ceils): (Refs, Refs), + exclude_subjects: bool, + c: &NeutralConsts, +) -> Option { + let mut norm_all = vec![0f32; grid.len()]; + crate::analysis::normalize_log_image(grid, floors32, ceils32, &mut norm_all); + let (norm_all, log_all) = (norm_all.as_chunks::<3>().0, grid.as_chunks::<3>().0); + let keep: Vec = (0..norm_all.len()) + .filter(|&i| !exclude_subjects || !is_subject(norm_all[i], &c.subjects)) + .collect(); + let norm: Vec<[f32; 3]> = keep.iter().map(|&i| norm_all[i]).collect(); + let log: Vec<[f32; 3]> = keep.iter().map(|&i| log_all[i]).collect(); + let lum: Vec = norm + .iter() + .map(|n| LUMA_R * n[0] + LUMA_G * n[1] + LUMA_B * n[2]) + .collect(); + + let band_refs = |(lo, hi): (f32, f32), chroma: &[f32], cap: f64| -> Option { + let band: Vec = (0..lum.len()) + .filter(|&i| lum[i] >= lo && lum[i] <= hi) + .collect(); + if band.len() < c.min_pixels { + return None; + } + let band_chroma: Vec = band.iter().map(|&i| chroma[i]).collect(); + let thr = np_quantile(&band_chroma, c.quantile)?; + let idx: Vec = band.into_iter().filter(|&i| chroma[i] <= thr).collect(); + let near_neutral = if idx.is_empty() { + cap + } else { + np_median(&mut idx.iter().map(|&i| chroma[i]).collect::>()) as f64 + }; + if idx.len() < c.min_pixels || near_neutral > cap { + return None; + } + let median_of = |f: &dyn Fn(usize) -> f32| { + np_median(&mut idx.iter().map(|&i| f(i)).collect::>()) as f64 + }; + let green = median_of(&|i| log[i][1]); + let refs = [ + green + median_of(&|i| log[i][0] - log[i][1]), + green, + green + median_of(&|i| log[i][2] - log[i][1]), + ]; + let width_of = |ch: usize| { + let off: Vec = idx.iter().map(|&i| norm[i][ch] - norm[i][1]).collect(); + let p = np_percentiles_f64(&off, &[10.0, 90.0]); + p[1] - p[0] + }; + let (w0, w2) = (width_of(0), width_of(2)); + Some(BandRefs { + refs, + chroma: near_neutral, + size: idx.len(), + // np.max propagates NaN. + width: if w0.is_nan() || w2.is_nan() { + f64::NAN + } else { + w0.max(w2) + }, + }) + }; + let norm_ref = |refs: Refs| -> Refs { + std::array::from_fn(|ch| { + let mut denom = ceils[ch] - floors[ch]; + if denom.abs() < 1e-6 { + denom = if denom >= 0.0 { 1e-6 } else { -1e-6 }; + } + (refs[ch] - floors[ch]) / denom + }) + }; + + let chroma1: Vec = norm.iter().map(|&n| rms_chroma(n)).collect(); + let mid1 = band_refs(c.mid, &chroma1, c.first_pass_cap)?; + let sh1 = band_refs(c.shadow, &chroma1, c.first_pass_cap)?; + let (nm, ns) = (norm_ref(mid1.refs), norm_ref(sh1.refs)); + let mut gain = [(1.0f32, 0.0f32); 3]; + for ch in [0, 2] { + let du = nm[ch] - ns[ch]; + let (a, b) = if du.abs() < 1e-6 { + (1.0, nm[1] - nm[ch]) + } else { + let a = (nm[1] - ns[1]) / du; + (a, nm[1] - a * nm[ch]) + }; + gain[ch] = (a as f32, b as f32); + } + let chroma2: Vec = norm + .iter() + .map(|n| { + rms_chroma([ + gain[0].0 * n[0] + gain[0].1, + n[1], + gain[2].0 * n[2] + gain[2].1, + ]) + }) + .collect(); + let mid = band_refs(c.mid, &chroma2, c.cap)?; + let shadow = band_refs(c.shadow, &chroma2, c.cap)?; + let highlight = band_refs(c.highlight, &chroma2, c.cap); + + let tight = (1.0 - mid.chroma.max(shadow.chroma) / c.cap).clamp(0.0, 1.0); + let size_term = mid.size as f64 / (mid.size as f64 + c.n0); + let (dm, ds) = (norm_ref(mid.refs), norm_ref(shadow.refs)); + let spread = [0, 2] + .map(|ch| ((dm[ch] - dm[1]) - (ds[ch] - ds[1])).abs()) + .into_iter() + .fold(f64::NEG_INFINITY, f64::max); + let agree = 1.0 - ((spread - c.agreement.0).max(0.0) / c.agreement.1).min(1.0); + let width = mid.width.max(shadow.width); + let single = 1.0 - ((width - c.width.0).max(0.0) / c.width.1).min(1.0); + Some(NeutralAxis { + mid: mid.refs, + shadow: shadow.refs, + highlight: highlight.map(|h| h.refs), + confidence: (tight * size_term * agree * single).clamp(0.0, 1.0), + }) +} diff --git a/negpy_core/src/py/meter.rs b/negpy_core/src/py/meter.rs index 7f54acafa..4710dc5c9 100644 --- a/negpy_core/src/py/meter.rs +++ b/negpy_core/src/py/meter.rs @@ -2,7 +2,7 @@ use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use crate::meter::{self, Activity, ChannelNorm}; +use crate::meter::{self, Activity, ChannelNorm, NeutralConsts}; /// Per channel (floor, floor is float64, span, span is float64), as the caller's types decide. type Norm = [(f64, bool, f64, bool); 3]; @@ -80,8 +80,93 @@ fn textured_range( Ok(py.detach(|| meter::textural_range(&textured(g, None, a), clip))) } +type Band = (f32, f32); +type Sector = (f32, f32, f32); +type Triplet = (f64, f64, f64); +/// quantile, cap, first-pass cap, min pixels, highlight/mid/shadow bands, foliage and sky +/// sectors, n0, agreement (dead zone, scale), width (dead zone, scale). +type Consts = ( + f64, + f64, + f64, + usize, + Band, + Band, + Band, + Sector, + Sector, + f64, + (f64, f64), + (f64, f64), +); + +fn triplet(r: [f64; 3]) -> Triplet { + (r[0], r[1], r[2]) +} + +/// `measure_neutral_axis_from_log` of a cropped block-median grid: (midtone, shadow, +/// highlight or None, confidence), or None. +#[pyfunction] +fn neutral_axis( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + floors: Triplet, + ceils: Triplet, + exclude_subjects: bool, + consts: Consts, +) -> PyResult, f64)>> { + let (data, _, _) = grid_of(&grid)?; + let ( + quantile, + cap, + first_pass_cap, + min_pixels, + highlight, + mid, + shadow, + foliage, + sky, + n0, + agreement, + width, + ) = consts; + let c = NeutralConsts { + quantile, + cap, + first_pass_cap, + min_pixels, + highlight, + mid, + shadow, + subjects: [foliage, sky], + n0, + agreement, + width, + }; + let f = [floors.0, floors.1, floors.2]; + let e = [ceils.0, ceils.1, ceils.2]; + let axis = py.detach(|| { + meter::neutral_axis( + data, + (f.map(|v| v as f32), e.map(|v| v as f32)), + (f, e), + exclude_subjects, + &c, + ) + }); + Ok(axis.map(|a| { + ( + triplet(a.mid), + triplet(a.shadow), + a.highlight.map(triplet), + a.confidence, + ) + })) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(textured_percentile, m)?)?; m.add_function(wrap_pyfunction!(textured_anchor, m)?)?; - m.add_function(wrap_pyfunction!(textured_range, m)?) + m.add_function(wrap_pyfunction!(textured_range, m)?)?; + m.add_function(wrap_pyfunction!(neutral_axis, m)?) } diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index 0fa3a5ae6..ac8519803 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -249,6 +249,29 @@ pub fn pairwise_sum_f32(a: &[f32]) -> f32 { pairwise_sum_f32(&a[..n2]) + pairwise_sum_f32(&a[n2..]) } +/// `np.quantile(values, q)` for a Python-number q in 0..1: the virtual rank is `(n - 1) * q`, +/// not `percentile`'s `(n - 1) * (q / 100)`. NaN when any value is NaN; `None` when empty. +pub fn np_quantile(values: &[f32], q: f64) -> Option { + if values.is_empty() { + return None; + } + if values.iter().any(|v| v.is_nan()) { + return Some(f32::NAN); + } + let n = values.len(); + let virtual_index = (n - 1) as f64 * q; + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let hi = (lo + 1).min(n - 1); + let mut ranks = vec![lo, hi]; + ranks.dedup(); + let picked = select_ranks(values, &ranks); + Some(interpolate( + picked[0], + picked[ranks.len() - 1], + virtual_index - lo as f64, + )) +} + /// `np.percentile` itself, which is NaN for every q when any value is NaN; `percentiles` /// matches NegPy's partition helper, which callers feed only finite values. pub fn np_percentiles(values: &[f32], qs: &[f64]) -> Option> { @@ -282,6 +305,20 @@ mod tests { .collect() } + #[test] + fn quantile_ranks_at_n_minus_one_times_q() { + for n in [1, 2, 7, 64, 1000, 70_000] { + let v = lcg(n, n as u64); + let mut s = v.clone(); + s.sort_by(f32::total_cmp); + let vi = (n - 1) as f64 * 0.3; + let lo = vi.floor() as usize; + let want = interpolate(s[lo], s[(lo + 1).min(n - 1)], vi - lo as f64); + assert_eq!(np_quantile(&v, 0.3), Some(want), "{n}"); + } + assert_eq!(np_quantile(&[], 0.3), None); + } + #[test] fn pairwise_sum_regions() { let v: Vec = (0..1000).map(|i| (i as f32).sqrt()).collect(); From 88a9f03be34a121d93843bdb16f51a5150e72b11 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 07:42:48 +0200 Subject: [PATCH 055/133] perf(core): working TRC and CIELAB kernels in Rust; own float64 pow _oetf_encode_flat, _oetf_decode_flat, _rgb_to_lab_kernel and _lab_to_rgb_kernel call Rust; their Numba code is deleted. Numba's fastmath build runs them in float64 with FMA contraction and folds the Lab divisions into reciprocals; the Rust kernels copy that order. mathf::pow_f64 (series ln and exp in float64) replaces glibc's scalar pow and rounds to the same float32 on 1 M samples per exponent. Replay: OETF encode/decode bit-identical (165 M values); Lab to RGB differs in 1 of 170 M values by 1 ULP; RGB to Lab in 518 of 170 M, all a*/b* near zero, at most 4.8e-7 (ledger, replay abs=1e-6). Bench: encode 0.37x, decode 0.84x, RGB to Lab 0.45x, Lab to RGB 0.37x. The color bindings allocate through PyArray::zeros: numpy.empty through Python cost 400 ns of a 700 ns call. replay.py gains abs=X. --- .github/workflows/ci.yml | 2 +- negpy/kernel/image/logic.py | 75 ++------------- negpy_core/LEDGER.md | 2 + negpy_core/manifest.toml | 28 +++--- negpy_core/src/color.rs | 177 +++++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/mathf.rs | 80 ++++++++++++++++ negpy_core/src/py/color.rs | 112 ++++++++++++++++++++++ negpy_core/src/py/mod.rs | 1 + scripts/replay.py | 8 +- tests/test_core_color.py | 60 ++++++++++++ tests/test_core_same_bits.py | 12 +++ 12 files changed, 476 insertions(+), 83 deletions(-) create mode 100644 negpy_core/src/color.rs create mode 100644 negpy_core/src/py/color.rs create mode 100644 tests/test_core_color.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad856c4a9..da11c03b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index 4cb547f9d..8dd4388bc 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -66,32 +66,14 @@ def srgb_to_linear(img: np.ndarray) -> np.ndarray: _WORKING_GAMMA = 563.0 / 256.0 # 2.19921875 -@parallel_njit(cache=True, fastmath=True) def _oetf_encode_flat(flat: np.ndarray, inv_gamma: float) -> np.ndarray: - """Row-parallel working-space encode over a flattened buffer (shape-agnostic).""" - n = flat.shape[0] - out = np.empty(n, dtype=np.float32) - for i in prange(n): - x = flat[i] - if x < 0.0: - x = 0.0 - elif x > 1.0: - x = 1.0 - out[i] = x**inv_gamma - return out + """Working-space encode of a flat buffer: clamped to 0..1, raised to `inv_gamma`.""" + return negpy_core.oetf_encode(np.ascontiguousarray(flat, dtype=np.float32), inv_gamma) -@parallel_njit(cache=True, fastmath=True) def _oetf_decode_flat(flat: np.ndarray, gamma: float) -> np.ndarray: """Inverse of _oetf_encode_flat.""" - n = flat.shape[0] - out = np.empty(n, dtype=np.float32) - for i in prange(n): - e = flat[i] - if e < 0.0: - e = 0.0 - out[i] = e**gamma - return out + return negpy_core.oetf_decode(np.ascontiguousarray(flat, dtype=np.float32), gamma) def working_oetf_encode(img: np.ndarray) -> np.ndarray: @@ -160,57 +142,14 @@ def apply_primaries_transform(img: np.ndarray, src_to_xyz: np.ndarray) -> np.nda return working_oetf_encode(out) -@parallel_njit(cache=True, fastmath=True) def _rgb_to_lab_kernel(px: np.ndarray, m: np.ndarray, white: np.ndarray, eps: float, kappa: float) -> np.ndarray: - """Row-parallel linear working RGB -> CIELAB (D65) over an (N, 3) pixel list.""" - n = px.shape[0] - out = np.empty((n, 3), dtype=np.float32) - c = np.float32(16.0 / 116.0) - for i in prange(n): - r = px[i, 0] - g = px[i, 1] - b = px[i, 2] - if r < 0.0: - r = 0.0 - if g < 0.0: - g = 0.0 - if b < 0.0: - b = 0.0 - xr = (m[0, 0] * r + m[0, 1] * g + m[0, 2] * b) / white[0] - yr = (m[1, 0] * r + m[1, 1] * g + m[1, 2] * b) / white[1] - zr = (m[2, 0] * r + m[2, 1] * g + m[2, 2] * b) / white[2] - fx = xr ** (1.0 / 3.0) if xr > eps else kappa * xr + c - fy = yr ** (1.0 / 3.0) if yr > eps else kappa * yr + c - fz = zr ** (1.0 / 3.0) if zr > eps else kappa * zr + c - out[i, 0] = 116.0 * fy - 16.0 - out[i, 1] = 500.0 * (fx - fy) - out[i, 2] = 200.0 * (fy - fz) - return out + """Linear working RGB -> CIELAB (D65) over an (N, 3) pixel list.""" + return negpy_core.rgb_to_lab(np.ascontiguousarray(px, dtype=np.float32), m, white, eps, kappa) -@parallel_njit(cache=True, fastmath=True) def _lab_to_rgb_kernel(lab: np.ndarray, m: np.ndarray, white: np.ndarray, eps: float, kappa: float) -> np.ndarray: - """Row-parallel inverse: CIELAB (D65) -> linear working RGB over an (N, 3) pixel list.""" - n = lab.shape[0] - out = np.empty((n, 3), dtype=np.float32) - c = np.float32(16.0 / 116.0) - for i in prange(n): - fy = (lab[i, 0] + 16.0) / 116.0 - fx = lab[i, 1] / 500.0 + fy - fz = fy - lab[i, 2] / 200.0 - fx3 = fx * fx * fx - fy3 = fy * fy * fy - fz3 = fz * fz * fz - xr = (fx3 if fx3 > eps else (fx - c) / kappa) * white[0] - yr = (fy3 if fy3 > eps else (fy - c) / kappa) * white[1] - zr = (fz3 if fz3 > eps else (fz - c) / kappa) * white[2] - r = m[0, 0] * xr + m[0, 1] * yr + m[0, 2] * zr - g = m[1, 0] * xr + m[1, 1] * yr + m[1, 2] * zr - b = m[2, 0] * xr + m[2, 1] * yr + m[2, 2] * zr - out[i, 0] = r if r > 0.0 else 0.0 - out[i, 1] = g if g > 0.0 else 0.0 - out[i, 2] = b if b > 0.0 else 0.0 - return out + """Inverse: CIELAB (D65) -> linear working RGB over an (N, 3) pixel list.""" + return negpy_core.lab_to_rgb(np.ascontiguousarray(lab, dtype=np.float32), m, white, eps, kappa) @njit(inline="always") diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 4484fda96..abae70ae5 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -14,3 +14,5 @@ tolerance listed for each entry. | `features/exposure/normalization.py::_base_blocks` | Blocks of equal log brightness come out in block order, not in NumPy's SIMD argsort order. log10 is libm's, not SVML's, so a block on a group gap can change group in rare cases. | rows in any order | | `negpy_core.distance_transform` (DIST_L2, mask 3), once a port calls it | opencv-python runs IPP's float32 chamfer, whose 3x3 pass picks a path one float32 unit longer than the two-pass order at rare pixels; OpenCV without IPP (Apple Silicon wheels) returns its own fixed-point values. | ulp=1 | | `features/exposure/normalization.py::to_log_density` (through `prefilter_log_grid`) | log10 is correctly rounded (`mathf::log10f`); NumPy's float32 log10 (SVML) is up to 3 ULP off on about half the inputs. | ulp=3 | +| `kernel/image/logic.py::_rgb_to_lab_kernel` (and `rgb_to_lab_working`) | The cube root is `mathf::pow_f64`, not glibc's correctly rounded pow; a* and b* near zero take the float64 difference in a few ULP. | abs=1e-6 | +| `kernel/image/logic.py::_lab_to_rgb_kernel` (and `lab_to_rgb_working`) | One value in the corpus rounds the other way; the float64 operation order copies Numba's fastmath build. | ulp=1 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 69e82b2aa..f6b5e5827 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1003,8 +1003,9 @@ depends = [] ["kernel/image/logic.py::_lab_to_rgb_kernel"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.lab_to_rgb"] depends = [] ["kernel/image/logic.py::_matmul_3x3_kernel"] @@ -1015,20 +1016,21 @@ depends = [] ["kernel/image/logic.py::_oetf_decode_flat"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.oetf_decode"] depends = [] ["kernel/image/logic.py::_oetf_encode_flat"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.oetf_encode"] depends = [] ["kernel/image/logic.py::_rgb_to_lab_kernel"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.rgb_to_lab"] depends = [] ["kernel/image/logic.py::_skin_chroma_rein_kernel"] @@ -1100,13 +1102,15 @@ note = "negpy_core::analysis::luminance" ["kernel/image/logic.py::lab_to_rgb_working"] phase = 3 -status = "todo" +status = "ported" +compare = "ulp=1" calls = [] depends = ["_lab_to_rgb_kernel"] ["kernel/image/logic.py::rgb_to_lab_working"] phase = 3 -status = "todo" +status = "ported" +compare = "abs=1e-6" calls = [] depends = ["_rgb_to_lab_kernel"] @@ -1142,13 +1146,13 @@ depends = [] ["kernel/image/logic.py::working_oetf_decode"] phase = 3 -status = "todo" +status = "ported" calls = [] depends = ["_oetf_decode_flat"] ["kernel/image/logic.py::working_oetf_encode"] phase = 3 -status = "todo" +status = "ported" calls = [] depends = ["_oetf_encode_flat"] diff --git a/negpy_core/src/color.rs b/negpy_core/src/color.rs new file mode 100644 index 000000000..74d9bef62 --- /dev/null +++ b/negpy_core/src/color.rs @@ -0,0 +1,177 @@ +//! The working-space colour kernels of negpy/kernel/image/logic.py: the Adobe RGB TRC and +//! CIELAB. Numba's fastmath build computes them in float64 with FMA contraction; these copy its +//! operation order, and `mathf::pow_f64` stands in for glibc's pow. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::pow_f64; + +const BLOCK: usize = 1 << 14; + +/// `_oetf_encode_flat`: clamped to 0..1 (NaN stays), raised to `inv_gamma`. +pub fn oetf_encode(src: &[f32], dst: &mut [f32], inv_gamma: f32) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| encode_block(s, d, inv_gamma as f64)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn encode_block(src: &[f32], dst: &mut [f32], y: f64) { + for (d, &v) in dst.iter_mut().zip(src) { + // clamp keeps NaN, as the kernel did. + let x = (v as f64).clamp(0.0, 1.0); + *d = pow_f64(x, y) as f32; + } +} + +/// `_oetf_decode_flat`: negatives to 0 (NaN stays), raised to `gamma`. +pub fn oetf_decode(src: &[f32], dst: &mut [f32], gamma: f32) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| decode_block(s, d, gamma as f64)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn decode_block(src: &[f32], dst: &mut [f32], y: f64) { + for (d, &v) in dst.iter_mut().zip(src) { + let x = v as f64; + *d = pow_f64(if x < 0.0 { 0.0 } else { x }, y) as f32; + } +} + +/// The constants of the Lab kernels, as float64. +#[derive(Clone, Copy)] +pub struct LabSpace { + pub m: [[f64; 3]; 3], + pub white: [f64; 3], + pub eps: f64, + pub kappa: f64, +} + +impl LabSpace { + pub fn new(m: [[f32; 3]; 3], white: [f32; 3], eps: f32, kappa: f32) -> Self { + Self { + m: m.map(|r| r.map(|v| v as f64)), + white: white.map(|v| v as f64), + eps: eps as f64, + kappa: kappa as f64, + } + } +} + +/// `np.float32(16/116)` as float64. +const C: f64 = (16.0f32 / 116.0f32) as f64; + +/// One row of the 3x3 product as Numba contracts it. +#[inline(always)] +fn row(m: [f64; 3], x: f64, y: f64, z: f64) -> f64 { + z.mul_add(m[2], y.mul_add(m[1], x * m[0])) +} + +/// `_rgb_to_lab_kernel` over N x 3 linear RGB. +pub fn rgb_to_lab(src: &[f32], dst: &mut [f32], s: LabSpace) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| rgb_to_lab_block(p, d, s)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn rgb_to_lab_block(src: &[f32], dst: &mut [f32], s: LabSpace) { + // Three passes over short runs: the cube root vectorizes only as a flat loop. + const RUN: usize = 3 * 512; + let mut t = [0f64; RUN]; + for (d, p) in dst.chunks_mut(RUN).zip(src.chunks(RUN)) { + let t = &mut t[..p.len()]; + for (t, p) in t + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(p.as_chunks::<3>().0) + { + let [r, g, b] = p.map(|v| { + let v = v as f64; + if v < 0.0 { 0.0 } else { v } + }); + *t = std::array::from_fn(|c| row(s.m[c], r, g, b) / s.white[c]); + } + for v in t.iter_mut() { + let root = pow_f64(*v, 1.0 / 3.0); + let lin = s.kappa.mul_add(*v, C); + *v = if *v > s.eps { root } else { lin }; + } + for (d, f) in d + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(t.as_chunks::<3>().0) + { + let [fx, fy, fz] = *f; + d[0] = fy.mul_add(116.0, -16.0) as f32; + d[1] = ((fx - fy) * 500.0) as f32; + d[2] = ((fy - fz) * 200.0) as f32; + } + } +} + +/// `_lab_to_rgb_kernel` over N x 3 Lab: Numba folds the divisions into reciprocal products +/// and `fy - c` into `L / 116 + (16/116 - c)`. +pub fn lab_to_rgb(src: &[f32], dst: &mut [f32], s: LabSpace) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| lab_to_rgb_block(p, d, s)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lab_to_rgb_block(src: &[f32], dst: &mut [f32], s: LabSpace) { + let inv_kappa = 1.0 / s.kappa; + let fy_gap = 16.0 / 116.0 - C; + for (d, p) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + let [l, a, b] = p.map(|v| v as f64); + let fy_l = l * (1.0 / 116.0); + let fy = fy_l + 16.0 / 116.0; + let fx = a.mul_add(0.002, fy); + let fz = (-b).mul_add(0.005, fy); + let (fx3, fy3, fz3) = (fx * fx * fx, fy * fy * fy, fz * fz * fz); + let xr = if fx3 > s.eps { + fx3 * s.white[0] + } else { + ((fx - C) * s.white[0]) * inv_kappa + }; + let yr = if fy3 > s.eps { + fy3 + } else { + (fy_l + fy_gap) * inv_kappa + } * s.white[1]; + let zr = if fz3 > s.eps { + fz3 + } else { + (fz - C) * inv_kappa + } * s.white[2]; + *d = std::array::from_fn(|c| { + let v = row(s.m[c], xr, yr, zr); + if v > 0.0 { v as f32 } else { 0.0 } + }); + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 4b0b19efb..7331a8f55 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,5 +1,6 @@ pub mod analysis; pub mod bounds; +pub mod color; pub mod components; pub mod contours; pub mod convert; @@ -46,5 +47,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::meter::register(m)?; py::retouch::register(m)?; py::bounds::register(m)?; + py::color::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index 6d9d16479..bba70d2cd 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -67,6 +67,59 @@ pub fn atan2f(y: f32, x: f32) -> f32 { (a as f32).copysign(y) } +/// ln of a positive normal f64 to about 1 ULP: x = m * 2^e with m in [sqrt(1/2), sqrt(2)), +/// ln(m) from the atanh series. +#[inline(always)] +fn ln_f64(x: f64) -> f64 { + let bits = x.to_bits(); + let e = (bits.wrapping_sub(0x3fe6_a09e_667f_3bcd) as i64) >> 52; + let m = f64::from_bits(bits.wrapping_sub((e as u64) << 52)); + let s = (m - 1.0) / (m + 1.0); + let s2 = s * s; + // 2 s (1 + s^2/3 + ... + s^20/21), |s| <= 0.1716: the next term is under 2^-56 of the sum. + let mut p = 1.0 / 21.0; + for k in (0..10).rev() { + p = 1.0 / (2 * k + 1) as f64 + s2 * p; + } + const LN2_HI: f64 = 0.693_147_180_369_123_8; + const LN2_LO: f64 = std::f64::consts::LN_2 - LN2_HI; + let e = e as f64; + e * LN2_HI + (e * LN2_LO + 2.0 * s * p) +} + +/// e^t for t in -700..700 to about 1 ULP: t = k ln2 + r with |r| <= ln2/2, e^r by its Taylor +/// series, times 2^k through the exponent bits. +#[inline(always)] +fn exp_f64(t: f64) -> f64 { + const LN2_HI: f64 = 0.693_147_180_369_123_8; + const LN2_LO: f64 = std::f64::consts::LN_2 - LN2_HI; + let k = (t * std::f64::consts::LOG2_E).round_ties_even(); + let r = (t - k * LN2_HI) - k * LN2_LO; + // 1/n! to n = 13: the next term is under 2^-57 at |r| = ln2/2. + let mut p = 1.0 / 6_227_020_800.0; + let mut f = 6_227_020_800.0; + for n in (1..13).rev() { + f /= (n + 1) as f64; + p = 1.0 / f + r * p; + } + let y = 1.0 + r * p; + y * f64::from_bits(((k as i64 + 1023) as u64) << 52) +} + +/// x^y for x >= 0 (or NaN) and y > 0: e^(y ln x), 0 at x = 0. The relative error grows with +/// |y ln x|, so the result is for rounding to f32, where it is almost always correct. +#[inline(always)] +pub fn pow_f64(x: f64, y: f64) -> f64 { + let v = exp_f64(y * ln_f64(x)); + if x == 0.0 { + 0.0 + } else if x.is_nan() || x == f64::INFINITY { + x + } else { + v + } +} + #[cfg(test)] mod tests { use super::*; @@ -131,6 +184,33 @@ mod atan2_tests { } } +#[cfg(test)] +mod pow_tests { + use super::pow_f64; + + #[test] + fn pow_f64_rounds_to_the_same_f32() { + let mut state = 0x9e37_79b9_7f4a_7c15u64; + let mut off = 0; + for _ in 0..1_000_000 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let x = f32::from_bits((state >> 33) as u32 % 0x3f80_0001) as f64; + for y in [563.0 / 256.0, 256.0 / 563.0, 1.0 / 3.0] { + let (got, want) = (pow_f64(x, y) as f32, libm::pow(x, y) as f32); + let ulp = (got.to_bits() as i64 - want.to_bits() as i64).abs(); + assert!(ulp <= 1, "{x} {y}"); + off += ulp as usize; + } + } + assert!(off <= 3, "{off}"); + assert_eq!(pow_f64(0.0, 2.2), 0.0); + assert!(pow_f64(f64::NAN, 2.2).is_nan()); + assert_eq!(pow_f64(1.0, 2.2), 1.0); + } +} + #[cfg(test)] mod exhaustive { /// `cargo test --release -- --ignored`. diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs new file mode 100644 index 000000000..5aebf9330 --- /dev/null +++ b/negpy_core/src/py/color.rs @@ -0,0 +1,112 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::color::{self, LabSpace}; + +fn filled<'py>( + py: Python<'py>, + src: &PyReadonlyArrayDyn<'py, f32>, + fill: impl FnOnce(&[f32], &mut [f32]) + Send, +) -> PyResult>> { + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = PyArrayDyn::::zeros(py, src.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| fill(data, dst)); + } + Ok(out) +} + +/// `_oetf_encode_flat` of a contiguous float32 array. +#[pyfunction] +fn oetf_encode<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + inv_gamma: f32, +) -> PyResult>> { + filled(py, &img, |s, d| color::oetf_encode(s, d, inv_gamma)) +} + +/// `_oetf_decode_flat` of a contiguous float32 array. +#[pyfunction] +fn oetf_decode<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + gamma: f32, +) -> PyResult>> { + filled(py, &img, |s, d| color::oetf_decode(s, d, gamma)) +} + +fn space( + m: &PyReadonlyArray2<'_, f32>, + white: &PyReadonlyArray1<'_, f32>, + eps: f32, + kappa: f32, +) -> PyResult { + let m = m.as_array(); + let white = white.as_array(); + if m.shape() != [3, 3] || white.len() != 3 { + return Err(PyValueError::new_err( + "needs a 3x3 matrix and a 3-vector white", + )); + } + Ok(LabSpace::new( + std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])), + std::array::from_fn(|c| white[c]), + eps, + kappa, + )) +} + +fn triplets(img: &PyReadonlyArrayDyn<'_, f32>) -> PyResult<()> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs a last axis of 3")); + } + Ok(()) +} + +/// `_rgb_to_lab_kernel` of an (..., 3) float32 array. +#[pyfunction] +fn rgb_to_lab<'py>( + py: Python<'py>, + px: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + triplets(&px)?; + let s = space(&m, &white, eps, kappa)?; + filled(py, &px, |src, d| color::rgb_to_lab(src, d, s)) +} + +/// `_lab_to_rgb_kernel` of an (..., 3) float32 array. +#[pyfunction] +fn lab_to_rgb<'py>( + py: Python<'py>, + lab: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + triplets(&lab)?; + let s = space(&m, &white, eps, kappa)?; + filled(py, &lab, |src, d| color::lab_to_rgb(src, d, s)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(oetf_encode, m)?)?; + m.add_function(wrap_pyfunction!(oetf_decode, m)?)?; + m.add_function(wrap_pyfunction!(rgb_to_lab, m)?)?; + m.add_function(wrap_pyfunction!(lab_to_rgb, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index ed3cf4b70..868d99e97 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,5 +1,6 @@ pub mod analysis; pub mod bounds; +pub mod color; pub mod components; pub mod contours; pub mod convert; diff --git a/scripts/replay.py b/scripts/replay.py index 80ced20d5..ecd93f7cb 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -220,7 +220,8 @@ def _ulps(a: np.ndarray, b: np.ndarray) -> np.ndarray: def same(a, b, compare: str = "exact") -> bool: """Equality for replay. "exact": bit for bit, NaN payloads and the sign of zero included. - "rows": a 2-D array's rows in any order. "ulp=N": floats within N units in the last place.""" + "rows": a 2-D array's rows in any order. "ulp=N": floats within N units in the last place. + "abs=X": floats within X of each other.""" if type(a) is not type(b): return False if isinstance(a, np.ndarray): @@ -233,9 +234,12 @@ def same(a, b, compare: str = "exact") -> bool: if compare.startswith("ulp=") and a.dtype.kind == "f": nan = np.isnan(a) return bool(np.array_equal(nan, np.isnan(b)) and np.all(_ulps(a[~nan], b[~nan]) <= int(compare[4:]))) + if compare.startswith("abs=") and a.dtype.kind == "f": + nan = np.isnan(a) + return bool(np.array_equal(nan, np.isnan(b)) and np.all(np.abs(a[~nan] - b[~nan]) <= float(compare[4:]))) return np.ascontiguousarray(a).tobytes() == np.ascontiguousarray(b).tobytes() if isinstance(a, (np.generic, float)): - if compare.startswith("ulp="): + if compare.startswith(("ulp=", "abs=")): return same(np.atleast_1d(np.asarray(a)), np.atleast_1d(np.asarray(b)), compare) return np.asarray(a).tobytes() == np.asarray(b).tobytes() if isinstance(a, (list, tuple)): diff --git a/tests/test_core_color.py b/tests/test_core_color.py new file mode 100644 index 000000000..3e40b10de --- /dev/null +++ b/tests/test_core_color.py @@ -0,0 +1,60 @@ +import numpy as np +import pytest + +from negpy.kernel.image.logic import ( + _WORKING_GAMMA, + _WORKING_TO_XYZ, + _WORKING_WHITE, + lab_to_rgb_working, + rgb_to_lab_working, + working_oetf_decode, + working_oetf_encode, +) + +_RNG = np.random.default_rng(11) + + +def _f32_ulps(a, b): + ia, ib = a.view(np.int32).astype(np.int64), b.view(np.int32).astype(np.int64) + return np.abs(ia - ib) + + +def test_oetf_is_the_float64_power_rounded_once(): + x = _RNG.random(200_000, dtype=np.float32) * 1.2 - 0.1 + x[:4] = [0.0, 1.0, -0.5, 1.5] + clamped = np.clip(x, 0.0, 1.0).astype(np.float64) + want = (clamped ** np.float64(np.float32(1.0 / _WORKING_GAMMA))).astype(np.float32) + got = working_oetf_encode(x) + assert np.max(_f32_ulps(got, want)) <= 1 + assert np.mean(got != want) < 1e-5 + back = working_oetf_decode(got) + want = (np.maximum(got, 0.0).astype(np.float64) ** np.float64(np.float32(_WORKING_GAMMA))).astype(np.float32) + assert np.max(_f32_ulps(back, want)) <= 1 + + +def test_oetf_keeps_nan(): + assert np.isnan(working_oetf_encode(np.array([np.nan], np.float32))[0]) + assert np.isnan(working_oetf_decode(np.array([np.nan], np.float32))[0]) + + +def _lab_reference(rgb): + lin = np.maximum(rgb.astype(np.float64), 0.0) + t = (lin @ _WORKING_TO_XYZ.astype(np.float64).T) / _WORKING_WHITE.astype(np.float64) + eps, kappa, c = float(np.float32(0.008856)), float(np.float32(7.787)), float(np.float32(16.0 / 116.0)) + f = np.where(t > eps, np.cbrt(t), kappa * t + c) + return np.stack([116.0 * f[:, 1] - 16.0, 500.0 * (f[:, 0] - f[:, 1]), 200.0 * (f[:, 1] - f[:, 2])], axis=1) + + +@pytest.mark.parametrize("scale", [1.0, 0.01]) +def test_lab_matches_a_float64_reference_and_round_trips(scale): + rgb = (_RNG.random((50_000, 3), dtype=np.float32) * scale).astype(np.float32) + lab = rgb_to_lab_working(rgb) + assert np.max(np.abs(lab - _lab_reference(rgb))) < 1e-4 + back = lab_to_rgb_working(lab) + assert np.max(np.abs(back - rgb)) < 1e-5 * max(scale, 0.1) + + +def test_lab_keeps_the_shape(): + img = _RNG.random((7, 5, 3), dtype=np.float32) + assert rgb_to_lab_working(img).shape == (7, 5, 3) + assert lab_to_rgb_working(rgb_to_lab_working(img)).shape == (7, 5, 3) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 905795664..6ae80f115 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -11,6 +11,8 @@ _IMG3 = _RNG.random((97, 131, 3), dtype=np.float32) _IMG1 = (_RNG.random((83, 61), dtype=np.float32) - 0.4) * 5 _GRAY = negpy_core.bgr_to_gray((_IMG3 * 255).astype(np.uint8)) +_M = np.array([[0.58, 0.19, 0.19], [0.30, 0.63, 0.08], [0.03, 0.07, 0.99]], np.float32) +_LAB = (_IMG3 * np.array([100.0, 160.0, 160.0], np.float32) - np.array([0.0, 80.0, 80.0], np.float32)).astype(np.float32) _CASES = { "percentiles": lambda: np.array(negpy_core.percentiles(_IMG1.ravel(), [0.5, 1.0, 37.0, 50.0, 99.0]), np.float32), @@ -29,6 +31,13 @@ ), "morph_close_u8": lambda: negpy_core.morphology_ex((_IMG1 > 0.5).astype(np.uint8) * 255, 3, np.ones((9, 7), np.uint8), 2), "to_log_density": lambda: negpy_core.to_log_density(np.ascontiguousarray(_IMG1 * _IMG1)), + "oetf_encode": lambda: negpy_core.oetf_encode(_IMG3, np.float32(256.0 / 563.0)), + "rgb_to_lab": lambda: negpy_core.rgb_to_lab( + _IMG3, _M, np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787) + ), + "lab_to_rgb": lambda: negpy_core.lab_to_rgb( + _LAB, _M.T.copy(), np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787) + ), } _HASHES = { @@ -46,6 +55,9 @@ "find_external_contours": "ebb4302dd2bc7226a7ed24fbb5e16e14f407d5693ce47128a649cee9e4a098be", "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", "to_log_density": "cc4d2086edf051826619f024a4f5d28c80d28528291d9df4610c1d3854144038", + "oetf_encode": "44508d28673c678fc0ec7b5262a06d0053ea6b4bed4bead7bc49556bc60eb070", + "rgb_to_lab": "02c583bf1f5fe9723e2cb0a3e1f34759d6b0778d3644e8e2cff2251e756b68f2", + "lab_to_rgb": "34016e7f9aebcfdf780b67b96b128041c6ba1ac2a427f4e87b5413dcc7345eef", } From c476689ce293801570fa31e873551da50f3d18b0 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 07:53:41 +0200 Subject: [PATCH 056/133] perf(core): skin chroma rein in Rust _skin_chroma_rein_kernel and _skin_weight call Rust; the Numba kernel, its smoothstep helper and the skin constants move there. The order copies the parallel Numba build's machine code; Numba's serial and parallel builds of the kernel differ from each other in the last bit. Skin candidates are gathered per run, so their weights vectorize. mathf::exp_f64 builds 2^k from the low bits of k + 1023 + 2^52: the saturating cast kept every loop that called it scalar. Replay 60/60 at ulp=1 (186 k of 158 M values differ by 1 ULP, ledger); bench 0.59x. --- negpy/kernel/image/logic.py | 77 ++----------------------- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 20 +++---- negpy_core/src/color.rs | 109 +++++++++++++++++++++++++++++++++++ negpy_core/src/mathf.rs | 8 ++- negpy_core/src/py/color.rs | 21 ++++++- tests/test_core_same_bits.py | 2 + 7 files changed, 149 insertions(+), 89 deletions(-) diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index 8dd4388bc..c57eb9f09 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -172,83 +172,14 @@ def _in_gamut_lab(l_val: float, a: float, b: float, m: np.ndarray, white: np.nda return r >= -tol and r <= one + tol and g >= -tol and g <= one + tol and bl >= -tol and bl <= one + tol -# Skin-tone mask for skin_chroma_rein: an axis-aligned reduction of the CIELAB skin locus. -# Hue is the axis that stays put across the whole tonal range, chroma is bounded, and -# lightness is free apart from the two ends where the hue angle turns to noise. -# -# The chroma window is the discriminator, and it is the measured skin locus, not a gamut -# bound: sunset, terracotta and brick all sit inside the hue band. It cuts both ways. Skin -# above the window keeps only partial weight, and warm objects at skin's own chroma (wood, -# tan leather, sand) are the same color as skin. Neither is separable per pixel. -_SKIN_HUE_CENTER_DEG = np.float32(52.0) -_SKIN_HUE_WIDTH_DEG = np.float32(20.0) -_SKIN_CHROMA_FULL = np.float32(35.0) -_SKIN_CHROMA_ZERO = np.float32(60.0) -_SKIN_L_LO = np.float32(15.0) -_SKIN_L_HI = np.float32(95.0) - -# Chroma ceiling the rein pulls toward: _SKIN_CEIL_AT_FULL / strength, so the ceiling runs -# past any reachable chroma as strength approaches 0 and the control fades out continuously. -# A lerp to a finite "off" ceiling would step the moment the slider left 0. The knee starts -# at a fraction of the ceiling, so ordinary skin below it passes through untouched. -_SKIN_CEIL_AT_FULL = np.float32(22.0) -_SKIN_KNEE_START_FRAC = np.float32(0.6) - - -@njit(inline="always") -def _smoothstep_scalar(e0: float, e1: float, x: float) -> float: - t = (x - e0) / (e1 - e0) - if t < 0.0: - t = 0.0 - elif t > 1.0: - t = 1.0 - return float(t * t * (np.float32(3.0) - np.float32(2.0) * t)) - - -@njit(inline="always") def _skin_weight(l_val: float, a: float, b: float) -> float: - """0 (not skin) to 1 (dead centre of the skin locus). Near-neutral pixels - have an undefined hue angle, so gate on chroma before trusting atan2.""" - chroma = np.sqrt(a * a + b * b) - if chroma < np.float32(2.0): - return 0.0 - hue_deg = np.degrees(np.arctan2(b, a)) - dist = hue_deg - _SKIN_HUE_CENTER_DEG - # Wrap to [-180, 180], so 179 against -179 reads as 2 degrees apart, not 358. - dist = dist - np.float32(360.0) * np.round(dist / np.float32(360.0)) - x = dist / _SKIN_HUE_WIDTH_DEG - w_hue = np.exp(np.float32(-0.5) * x * x) - w_chroma = np.float32(1.0) - _smoothstep_scalar(_SKIN_CHROMA_FULL, _SKIN_CHROMA_ZERO, chroma) - w_light = _smoothstep_scalar(np.float32(0.0), _SKIN_L_LO, l_val) * ( - np.float32(1.0) - _smoothstep_scalar(_SKIN_L_HI, np.float32(100.0), l_val) - ) - return float(w_hue * w_chroma * w_light) + """0 (not skin) to 1 (dead centre of the skin locus); 0 below a chroma of 2, where the hue is noise.""" + return negpy_core.skin_weight(l_val, a, b) -@parallel_njit(cache=True, fastmath=True) def _skin_chroma_rein_kernel(lab: np.ndarray, strength: float) -> np.ndarray: - """Soft chroma ceiling inside the skin mask -- see skin_chroma_rein.""" - n = lab.shape[0] - out = np.empty((n, 3), dtype=np.float32) - one = np.float32(1.0) - ceiling = _SKIN_CEIL_AT_FULL / strength - start = _SKIN_KNEE_START_FRAC * ceiling - span = ceiling - start - for i in prange(n): - l_val = lab[i, 0] - a = lab[i, 1] - b = lab[i, 2] - chroma = np.sqrt(a * a + b * b) - scale = one - if chroma > start: - w = _skin_weight(l_val, a, b) - if w > 0.0: - knee = start + span * (one - np.exp(-(chroma - start) / span)) - scale = (chroma + w * (knee - chroma)) / chroma - out[i, 0] = l_val - out[i, 1] = a * scale - out[i, 2] = b * scale - return out + """Soft chroma ceiling inside the skin mask over an (N, 3) Lab list -- see skin_chroma_rein.""" + return negpy_core.skin_chroma_rein(np.ascontiguousarray(lab, dtype=np.float32), strength) def skin_chroma_rein(lab: np.ndarray, strength: float) -> np.ndarray: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index abae70ae5..4aea6ae4f 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -16,3 +16,4 @@ tolerance listed for each entry. | `features/exposure/normalization.py::to_log_density` (through `prefilter_log_grid`) | log10 is correctly rounded (`mathf::log10f`); NumPy's float32 log10 (SVML) is up to 3 ULP off on about half the inputs. | ulp=3 | | `kernel/image/logic.py::_rgb_to_lab_kernel` (and `rgb_to_lab_working`) | The cube root is `mathf::pow_f64`, not glibc's correctly rounded pow; a* and b* near zero take the float64 difference in a few ULP. | abs=1e-6 | | `kernel/image/logic.py::_lab_to_rgb_kernel` (and `lab_to_rgb_working`) | One value in the corpus rounds the other way; the float64 operation order copies Numba's fastmath build. | ulp=1 | +| `kernel/image/logic.py::_skin_chroma_rein_kernel` (and `skin_chroma_rein`) | `atan2f` and `exp` are `mathf`'s, not glibc's; Numba's own serial and parallel builds of the kernel already differ in the last bit. | ulp=1 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index f6b5e5827..3befd1120 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1035,20 +1035,15 @@ depends = [] ["kernel/image/logic.py::_skin_chroma_rein_kernel"] phase = 3 -status = "todo" -calls = ["numba"] -depends = ["_skin_weight"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.skin_chroma_rein"] +depends = [] ["kernel/image/logic.py::_skin_weight"] phase = 3 -status = "todo" -calls = ["numba"] -depends = ["_smoothstep_scalar"] - -["kernel/image/logic.py::_smoothstep_scalar"] -phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.skin_weight"] depends = [] ["kernel/image/logic.py::_to_uint16_jit"] @@ -1128,7 +1123,8 @@ depends = [] ["kernel/image/logic.py::skin_chroma_rein"] phase = 3 -status = "todo" +status = "ported" +compare = "ulp=1" calls = [] depends = ["_skin_chroma_rein_kernel"] diff --git a/negpy_core/src/color.rs b/negpy_core/src/color.rs index 74d9bef62..323efc50e 100644 --- a/negpy_core/src/color.rs +++ b/negpy_core/src/color.rs @@ -175,3 +175,112 @@ fn lab_to_rgb_block(src: &[f32], dst: &mut [f32], s: LabSpace) { }); } } + +// Skin-tone mask: an axis-aligned reduction of the CIELAB skin locus. Hue stays put across the +// tonal range, chroma is bounded, and lightness is free apart from the two ends where the hue +// turns to noise. The chroma window is the measured skin locus, not a gamut bound: sunset, +// terracotta and brick sit inside the hue band, and wood, tan leather and sand at skin's own +// chroma are skin's colour. Neither is separable per pixel. +const HUE_CENTER: f32 = 52.0; +const HUE_WIDTH: f64 = 20.0; +const CHROMA_FULL: f32 = 35.0; +const CHROMA_ZERO: f32 = 60.0; +const L_LO: f32 = 15.0; +const L_HI: f32 = 95.0; +// The ceiling is CEIL_AT_FULL / strength, so it runs past any reachable chroma as strength nears +// 0 and the control fades out without a step. The knee starts at a fraction of the ceiling, so +// ordinary skin below it passes untouched. +const CEIL_AT_FULL: f32 = 22.0; +const KNEE_START: f32 = 0.6; + +/// `smoothstep` of a clamped `t`, as float64: Numba returns `float(...)`. +#[inline(always)] +fn clamp01(t: f32) -> f64 { + if t < 0.0 { + 0.0 + } else if t > 1.0 { + 1.0 + } else { + t as f64 + } +} + +/// `_skin_chroma_rein_kernel` over N x 3 Lab. Numba's fastmath build folds the smoothstep +/// divisions into reciprocal constants and regroups the weight's products; this copies its +/// machine code. +pub fn skin_chroma_rein(src: &[f32], dst: &mut [f32], strength: f32) { + let ceiling = CEIL_AT_FULL / strength; + let start = ceiling * KNEE_START; + let span = ceiling * (1.0 - KNEE_START); + let inv_span = 1.0f32 / span; + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| rein_block(p, d, (start, span, inv_span))); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn rein_block(src: &[f32], dst: &mut [f32], (start, span, inv_span): (f32, f32, f32)) { + // The skin candidates of each run are gathered first, so their weights compute in one + // vectorized loop; every other pixel keeps its values (a scale of 1 is exact). + const RUN: usize = 512; + let mut idx = [0usize; RUN]; + let mut cand = [[0f32; 4]; RUN]; + let mut scale = [1f64; RUN]; + dst.copy_from_slice(src); + for (d, p) in dst.chunks_mut(3 * RUN).zip(src.chunks(3 * RUN)) { + let mut n = 0; + for (i, &[l, a, b]) in p.as_chunks::<3>().0.iter().enumerate() { + let chroma = b.mul_add(b, a * a).sqrt(); + idx[n] = i; + cand[n] = [l, a, b, chroma]; + // `chroma <= start` is unordered-or-less: a NaN chroma keeps the pixel. + n += !(chroma <= start || chroma < 2.0) as usize; + } + for (s, &[l, a, b, chroma]) in scale[..n].iter_mut().zip(&cand[..n]) { + let w = skin_weight(l, a, b, chroma); + let gap = start - chroma; + let e = crate::mathf::exp_f64((gap * inv_span) as f64) as f32; + let knee_gap = (1.0 - e).mul_add(span, gap); + let c = chroma as f64; + let rein = w.mul_add(knee_gap as f64, c) / c; + *s = if w > 0.0 { rein } else { 1.0 }; + } + let out = d.as_chunks_mut::<3>().0; + for ((&i, &[_, a, b, _]), &s) in idx[..n].iter().zip(&cand[..n]).zip(&scale[..n]) { + out[i][1] = (s * a as f64) as f32; + out[i][2] = (s * b as f64) as f32; + } + } +} + +/// `_skin_weight`: 0 below a chroma of 2, where the hue angle is noise. +pub fn skin_weight_of(l: f32, a: f32, b: f32) -> f64 { + let chroma = b.mul_add(b, a * a).sqrt(); + if chroma < 2.0 { + 0.0 + } else { + skin_weight(l, a, b, chroma) + } +} + +/// `_skin_weight` for a chroma above 2. +#[inline(always)] +fn skin_weight(l: f32, a: f32, b: f32, chroma: f32) -> f64 { + let deg = 180.0f32 / std::f32::consts::PI; + let dist = crate::mathf::atan2f(b, a).mul_add(deg, -HUE_CENTER); + let turns = (dist * (1.0f32 / 360.0)).round_ties_even(); + let wrapped = (-(turns as f64)).mul_add(360.0, dist as f64); + let w_hue = crate::mathf::exp_f64(wrapped * wrapped * (-0.5 / (HUE_WIDTH * HUE_WIDTH))); + let window = CHROMA_ZERO - CHROMA_FULL; + let tc = clamp01(chroma.mul_add(1.0 / window, -CHROMA_FULL / window)); + let w_chroma = (-(tc * tc)).mul_add((-tc).mul_add(2.0, 3.0), 1.0); + let t_lo = clamp01(l * (1.0 / L_LO)); + let t_hi = clamp01(l.mul_add(1.0 / (100.0 - L_HI), -L_HI / (100.0 - L_HI))); + let rise = (w_hue * w_chroma) * ((t_lo * t_lo) * (-t_lo).mul_add(2.0, 3.0)); + let fall = (t_hi * t_hi) * (-t_hi).mul_add(2.0, 3.0); + (-fall).mul_add(rise, rise) +} diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index bba70d2cd..55f147674 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -87,12 +87,13 @@ fn ln_f64(x: f64) -> f64 { e * LN2_HI + (e * LN2_LO + 2.0 * s * p) } -/// e^t for t in -700..700 to about 1 ULP: t = k ln2 + r with |r| <= ln2/2, e^r by its Taylor +/// e^t to about 1 ULP for t in -708..709 (clamped there): t = k ln2 + r with |r| <= ln2/2, e^r by its Taylor /// series, times 2^k through the exponent bits. #[inline(always)] -fn exp_f64(t: f64) -> f64 { +pub fn exp_f64(t: f64) -> f64 { const LN2_HI: f64 = 0.693_147_180_369_123_8; const LN2_LO: f64 = std::f64::consts::LN_2 - LN2_HI; + let t = t.clamp(-708.0, 709.0); let k = (t * std::f64::consts::LOG2_E).round_ties_even(); let r = (t - k * LN2_HI) - k * LN2_LO; // 1/n! to n = 13: the next term is under 2^-57 at |r| = ln2/2. @@ -103,7 +104,8 @@ fn exp_f64(t: f64) -> f64 { p = 1.0 / f + r * p; } let y = 1.0 + r * p; - y * f64::from_bits(((k as i64 + 1023) as u64) << 52) + // 2^k from the low bits of k + 1023 + 2^52: an `as` cast saturates and keeps the loop scalar. + y * f64::from_bits((k + (1023.0 + 4_503_599_627_370_496.0)).to_bits() << 52) } /// x^y for x >= 0 (or NaN) and y > 0: e^(y ln x), 0 at x = 0. The relative error grows with diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs index 5aebf9330..e4e28be1e 100644 --- a/negpy_core/src/py/color.rs +++ b/negpy_core/src/py/color.rs @@ -104,9 +104,28 @@ fn lab_to_rgb<'py>( filled(py, &lab, |src, d| color::lab_to_rgb(src, d, s)) } +/// `_skin_chroma_rein_kernel` of an (..., 3) float32 Lab array. +#[pyfunction] +fn skin_chroma_rein<'py>( + py: Python<'py>, + lab: PyReadonlyArrayDyn<'py, f32>, + strength: f32, +) -> PyResult>> { + triplets(&lab)?; + filled(py, &lab, |src, d| color::skin_chroma_rein(src, d, strength)) +} + +/// `_skin_weight(l, a, b)`. +#[pyfunction] +fn skin_weight(l: f32, a: f32, b: f32) -> f64 { + color::skin_weight_of(l, a, b) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(oetf_encode, m)?)?; m.add_function(wrap_pyfunction!(oetf_decode, m)?)?; m.add_function(wrap_pyfunction!(rgb_to_lab, m)?)?; - m.add_function(wrap_pyfunction!(lab_to_rgb, m)?) + m.add_function(wrap_pyfunction!(lab_to_rgb, m)?)?; + m.add_function(wrap_pyfunction!(skin_chroma_rein, m)?)?; + m.add_function(wrap_pyfunction!(skin_weight, m)?) } diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 6ae80f115..67fd6d3ac 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -38,6 +38,7 @@ "lab_to_rgb": lambda: negpy_core.lab_to_rgb( _LAB, _M.T.copy(), np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787) ), + "skin_chroma_rein": lambda: negpy_core.skin_chroma_rein(_LAB, np.float32(0.6)), } _HASHES = { @@ -58,6 +59,7 @@ "oetf_encode": "44508d28673c678fc0ec7b5262a06d0053ea6b4bed4bead7bc49556bc60eb070", "rgb_to_lab": "02c583bf1f5fe9723e2cb0a3e1f34759d6b0778d3644e8e2cff2251e756b68f2", "lab_to_rgb": "34016e7f9aebcfdf780b67b96b128041c6ba1ac2a427f4e87b5413dcc7345eef", + "skin_chroma_rein": "6def89b9f456f16abf406c351745bbb791f15bc69e5b03f2e478ed7391839b01", } From 813a896c68c93527a5861769fcf9e8a9b6dcc4a6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 08:02:19 +0200 Subject: [PATCH 057/133] perf(core): gamut-aware chroma scale and 3x3 matrix in Rust _gamut_aware_chroma_scale_kernel and _matmul_3x3_kernel call Rust; kernel/image/logic.py has no Numba left. The gamut check shares the Lab decode of _lab_to_rgb_kernel, with Numba's float32 bounds (1 + 1e-4 summed in float32). Pixels whose full push clips are gathered per run and bisected in lockstep, so each step vectorizes. No recordings exist (saturation above 1 is rare in edits); against the old Numba kernels on 12 recorded frames at saturations 0.7 to 2.5 both are bit-identical (170 M values each). One thread: gamut 0.22x to 0.47x when bisecting, 0.9x to 1.0x for a plain desaturation (memory bound); matrix 0.6x. --- negpy/kernel/image/logic.py | 74 +--------------- negpy_core/manifest.toml | 18 ++-- negpy_core/src/color.rs | 165 ++++++++++++++++++++++++++++++----- negpy_core/src/py/color.rs | 40 ++++++++- tests/test_core_same_bits.py | 6 ++ 5 files changed, 195 insertions(+), 108 deletions(-) diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index c57eb9f09..8e6106b13 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -3,8 +3,6 @@ from typing import Any, Optional, Sequence import negpy_core import numpy as np -from numba import njit, prange # type: ignore -from negpy.kernel.system.parallel import parallel_njit from negpy.domain.types import LUMA_R, LUMA_G, LUMA_B from negpy.kernel.system.logging import get_logger @@ -111,17 +109,9 @@ def working_oetf_decode(img: np.ndarray) -> np.ndarray: _LAB_KAPPA = 7.787 -@parallel_njit(cache=True, fastmath=True) def _matmul_3x3_kernel(px: np.ndarray, m: np.ndarray) -> np.ndarray: - """Row-parallel 3x3 matrix multiply over an (N, 3) pixel array.""" - n = px.shape[0] - out = np.empty((n, 3), dtype=np.float32) - for i in prange(n): - r, g, b = px[i, 0], px[i, 1], px[i, 2] - out[i, 0] = m[0, 0] * r + m[0, 1] * g + m[0, 2] * b - out[i, 1] = m[1, 0] * r + m[1, 1] * g + m[1, 2] * b - out[i, 2] = m[2, 0] * r + m[2, 1] * g + m[2, 2] * b - return out + """3x3 matrix multiply over an (N, 3) pixel array.""" + return negpy_core.matmul_3x3(np.ascontiguousarray(px, dtype=np.float32), m) def apply_primaries_transform(img: np.ndarray, src_to_xyz: np.ndarray) -> np.ndarray: @@ -152,26 +142,6 @@ def _lab_to_rgb_kernel(lab: np.ndarray, m: np.ndarray, white: np.ndarray, eps: f return negpy_core.lab_to_rgb(np.ascontiguousarray(lab, dtype=np.float32), m, white, eps, kappa) -@njit(inline="always") -def _in_gamut_lab(l_val: float, a: float, b: float, m: np.ndarray, white: np.ndarray, eps: float, kappa: float, c: float) -> bool: - """Whether (L, a, b) decodes to linear working RGB within [0,1] on all channels.""" - fy = (l_val + 16.0) / 116.0 - fx = a / 500.0 + fy - fz = fy - b / 200.0 - fx3 = fx * fx * fx - fy3 = fy * fy * fy - fz3 = fz * fz * fz - xr = (fx3 if fx3 > eps else (fx - c) / kappa) * white[0] - yr = (fy3 if fy3 > eps else (fy - c) / kappa) * white[1] - zr = (fz3 if fz3 > eps else (fz - c) / kappa) * white[2] - r = m[0, 0] * xr + m[0, 1] * yr + m[0, 2] * zr - g = m[1, 0] * xr + m[1, 1] * yr + m[1, 2] * zr - bl = m[2, 0] * xr + m[2, 1] * yr + m[2, 2] * zr - tol = np.float32(1e-4) - one = np.float32(1.0) - return r >= -tol and r <= one + tol and g >= -tol and g <= one + tol and bl >= -tol and bl <= one + tol - - def _skin_weight(l_val: float, a: float, b: float) -> float: """0 (not skin) to 1 (dead centre of the skin locus); 0 below a chroma of 2, where the hue is noise.""" return negpy_core.skin_weight(l_val, a, b) @@ -205,7 +175,6 @@ def skin_chroma_rein(lab: np.ndarray, strength: float) -> np.ndarray: return out.reshape(arr.shape) -@parallel_njit(cache=True, fastmath=True) def _gamut_aware_chroma_scale_kernel( lab: np.ndarray, saturation: float, m: np.ndarray, white: np.ndarray, eps: float, kappa: float, iters: int ) -> np.ndarray: @@ -225,44 +194,7 @@ def _gamut_aware_chroma_scale_kernel( changes the R:G:B ratio the eye actually sees. This sidesteps that by never overshooting in the first place. """ - n = lab.shape[0] - out = np.empty((n, 3), dtype=np.float32) - c = np.float32(16.0 / 116.0) - one = np.float32(1.0) - for i in prange(n): - l_val = lab[i, 0] - a = lab[i, 1] - b = lab[i, 2] - if saturation <= one: - # Desaturation never overshoots the gamut: moving toward the achromatic axis - # cannot push a channel further out of range. - eff = saturation - else: - if _in_gamut_lab(l_val, a * saturation, b * saturation, m, white, eps, kappa, c): - # The full push already lands in gamut, so use it directly. Without this, - # bisecting within [1, saturation] converges lo toward saturation itself, which - # is the search's own upper bound and not a real constraint, and the knee - # formula below then throttles pixels that were never going to clip. - eff = saturation - else: - lo = one - hi = saturation - still_ok = _in_gamut_lab(l_val, a, b, m, white, eps, kappa, c) - for _ in range(iters): - mid = (lo + hi) / np.float32(2.0) - if still_ok and _in_gamut_lab(l_val, a * mid, b * mid, m, white, eps, kappa, c): - lo = mid - else: - hi = mid - s_max = lo - if s_max < one + np.float32(1e-4): - s_max = one + np.float32(1e-4) - knee = s_max - one - eff = one + knee * (one - np.exp(-(saturation - one) / knee)) - out[i, 0] = l_val - out[i, 1] = a * eff - out[i, 2] = b * eff - return out + return negpy_core.gamut_aware_chroma_scale(np.ascontiguousarray(lab, dtype=np.float32), saturation, m, white, eps, kappa, iters) def gamut_aware_chroma_scale(lab: np.ndarray, saturation: float, iters: int = 10) -> np.ndarray: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 3befd1120..5e1e51df3 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -991,14 +991,8 @@ depends = [] ["kernel/image/logic.py::_gamut_aware_chroma_scale_kernel"] phase = 3 -status = "todo" -calls = ["numba"] -depends = ["_in_gamut_lab"] - -["kernel/image/logic.py::_in_gamut_lab"] -phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.gamut_aware_chroma_scale"] depends = [] ["kernel/image/logic.py::_lab_to_rgb_kernel"] @@ -1010,8 +1004,8 @@ depends = [] ["kernel/image/logic.py::_matmul_3x3_kernel"] phase = 3 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.matmul_3x3"] depends = [] ["kernel/image/logic.py::_oetf_decode_flat"] @@ -1060,7 +1054,7 @@ depends = [] ["kernel/image/logic.py::apply_primaries_transform"] phase = 3 -status = "todo" +status = "ported" calls = [] depends = ["_matmul_3x3_kernel", "working_oetf_decode", "working_oetf_encode"] @@ -1084,7 +1078,7 @@ depends = [] ["kernel/image/logic.py::gamut_aware_chroma_scale"] phase = 3 -status = "todo" +status = "ported" calls = [] depends = ["_gamut_aware_chroma_scale_kernel"] diff --git a/negpy_core/src/color.rs b/negpy_core/src/color.rs index 323efc50e..a4229145d 100644 --- a/negpy_core/src/color.rs +++ b/negpy_core/src/color.rs @@ -55,6 +55,7 @@ pub struct LabSpace { pub white: [f64; 3], pub eps: f64, pub kappa: f64, + pub inv_kappa: f64, } impl LabSpace { @@ -64,6 +65,7 @@ impl LabSpace { white: white.map(|v| v as f64), eps: eps as f64, kappa: kappa as f64, + inv_kappa: 1.0 / kappa as f64, } } } @@ -140,35 +142,13 @@ pub fn lab_to_rgb(src: &[f32], dst: &mut [f32], s: LabSpace) { "aarch64+neon" ))] fn lab_to_rgb_block(src: &[f32], dst: &mut [f32], s: LabSpace) { - let inv_kappa = 1.0 / s.kappa; - let fy_gap = 16.0 / 116.0 - C; - for (d, p) in dst + for (d, &[l, a, b]) in dst .as_chunks_mut::<3>() .0 .iter_mut() .zip(src.as_chunks::<3>().0) { - let [l, a, b] = p.map(|v| v as f64); - let fy_l = l * (1.0 / 116.0); - let fy = fy_l + 16.0 / 116.0; - let fx = a.mul_add(0.002, fy); - let fz = (-b).mul_add(0.005, fy); - let (fx3, fy3, fz3) = (fx * fx * fx, fy * fy * fy, fz * fz * fz); - let xr = if fx3 > s.eps { - fx3 * s.white[0] - } else { - ((fx - C) * s.white[0]) * inv_kappa - }; - let yr = if fy3 > s.eps { - fy3 - } else { - (fy_l + fy_gap) * inv_kappa - } * s.white[1]; - let zr = if fz3 > s.eps { - fz3 - } else { - (fz - C) * inv_kappa - } * s.white[2]; + let [xr, yr, zr] = lab_to_xyz(l, a, b, &s); *d = std::array::from_fn(|c| { let v = row(s.m[c], xr, yr, zr); if v > 0.0 { v as f32 } else { 0.0 } @@ -176,6 +156,34 @@ fn lab_to_rgb_block(src: &[f32], dst: &mut [f32], s: LabSpace) { } } +/// XYZ of one Lab triplet in float64, in the Numba build's order. +#[inline(always)] +fn lab_to_xyz(l: f32, a: f32, b: f32, s: &LabSpace) -> [f64; 3] { + let inv_kappa = s.inv_kappa; + let (l, a, b) = (l as f64, a as f64, b as f64); + let fy_l = l * (1.0 / 116.0); + let fy = fy_l + 16.0 / 116.0; + let fx = a.mul_add(0.002, fy); + let fz = (-b).mul_add(0.005, fy); + let (fx3, fy3, fz3) = (fx * fx * fx, fy * fy * fy, fz * fz * fz); + let xr = if fx3 > s.eps { + fx3 * s.white[0] + } else { + ((fx - C) * s.white[0]) * inv_kappa + }; + let yr = if fy3 > s.eps { + fy3 + } else { + (fy_l + (16.0 / 116.0 - C)) * inv_kappa + } * s.white[1]; + let zr = if fz3 > s.eps { + fz3 + } else { + (fz - C) * inv_kappa + } * s.white[2]; + [xr, yr, zr] +} + // Skin-tone mask: an axis-aligned reduction of the CIELAB skin locus. Hue stays put across the // tonal range, chroma is bounded, and lightness is free apart from the two ends where the hue // turns to noise. The chroma window is the measured skin locus, not a gamut bound: sunset, @@ -284,3 +292,112 @@ fn skin_weight(l: f32, a: f32, b: f32, chroma: f32) -> f64 { let fall = (t_hi * t_hi) * (-t_hi).mul_add(2.0, 3.0); (-fall).mul_add(rise, rise) } + +/// `_matmul_3x3_kernel` over N x 3 float32 pixels, contracted as Numba does. +pub fn matmul_3x3(src: &[f32], dst: &mut [f32], m: [[f32; 3]; 3]) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| matmul_block(p, d, m)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn matmul_block(src: &[f32], dst: &mut [f32], m: [[f32; 3]; 3]) { + for (d, &[r, g, b]) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + *d = m.map(|row| b.mul_add(row[2], g.mul_add(row[1], row[0] * r))); + } +} + +/// `_in_gamut_lab`: (L, a, b) decodes to linear RGB within 0..1, 1e-4 either side; the bounds +/// are float32 sums, compared in float64. +#[inline(always)] +fn in_gamut(l: f32, a: f32, b: f32, s: &LabSpace) -> bool { + let [xr, yr, zr] = lab_to_xyz(l, a, b, s); + let (lo, hi) = (-1e-4f32 as f64, (1.0f32 + 1e-4f32) as f64); + s.m.iter().fold(true, |ok, &m| { + let v = row(m, xr, yr, zr); + ok & (v >= lo) & (v <= hi) + }) +} + +/// `_gamut_aware_chroma_scale_kernel` over N x 3 Lab: the flat `saturation` where its full push +/// stays in gamut, else a softplus knee toward the pixel's own headroom, found by bisection. +pub fn gamut_aware_chroma_scale( + src: &[f32], + dst: &mut [f32], + saturation: f32, + iters: usize, + s: LabSpace, +) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| gamut_block(p, d, saturation, iters, &s)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn gamut_block(src: &[f32], dst: &mut [f32], saturation: f32, iters: usize, s: &LabSpace) { + if saturation <= 1.0 { + // L times 1 is exact; a flat pattern of 48 lanes vectorizes where triplets do not. + let pattern: [f32; 48] = std::array::from_fn(|i| if i % 3 == 0 { 1.0 } else { saturation }); + for (d, p) in dst.chunks_mut(48).zip(src.chunks(48)) { + for ((d, &p), &k) in d.iter_mut().zip(p).zip(&pattern) { + *d = p * k; + } + } + return; + } + // Pixels whose full push clips are gathered per run and bisected in lockstep, so each step + // vectorizes across them. + const RUN: usize = 512; + let mut idx = [0usize; RUN]; + let mut cand = [[0f32; 3]; RUN]; + let mut ok = [false; RUN]; + let mut full = [false; RUN]; + let (mut lo, mut hi) = ([1f32; RUN], [saturation; RUN]); + for (d, sp) in dst.chunks_mut(3 * RUN).zip(src.chunks(3 * RUN)) { + let (px, src_px) = (d.as_chunks_mut::<3>().0, sp.as_chunks::<3>().0); + for (f, &[l, a, b]) in full.iter_mut().zip(src_px) { + *f = in_gamut(l, a * saturation, b * saturation, s); + } + let mut n = 0; + for (i, ((p, &q), &f)) in px.iter_mut().zip(src_px).zip(&full).enumerate() { + idx[n] = i; + cand[n] = q; + n += !f as usize; + *p = [q[0], q[1] * saturation, q[2] * saturation]; + } + for (k, &[l, a, b]) in ok[..n].iter_mut().zip(&cand[..n]) { + *k = in_gamut(l, a, b, s); + } + lo[..n].fill(1.0); + hi[..n].fill(saturation); + for _ in 0..iters { + for j in 0..n { + let [l, a, b] = cand[j]; + let mid = (lo[j] + hi[j]) / 2.0; + let fits = ok[j] & in_gamut(l, a * mid, b * mid, s); + lo[j] = if fits { mid } else { lo[j] }; + hi[j] = if fits { hi[j] } else { mid }; + } + } + for j in 0..n { + let [l, a, b] = cand[j]; + let knee = lo[j].max(1.0f32 + 1e-4f32) - 1.0; + let e = crate::mathf::exp_f64(((1.0 - saturation) / knee) as f64) as f32; + let eff = (1.0 - e).mul_add(knee, 1.0); + px[idx[j]] = [l, a * eff, b * eff]; + } + } +} diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs index e4e28be1e..29cb9b835 100644 --- a/negpy_core/src/py/color.rs +++ b/negpy_core/src/py/color.rs @@ -121,11 +121,49 @@ fn skin_weight(l: f32, a: f32, b: f32) -> f64 { color::skin_weight_of(l, a, b) } +/// `_matmul_3x3_kernel` of an (..., 3) float32 array. +#[pyfunction] +fn matmul_3x3<'py>( + py: Python<'py>, + px: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + triplets(&px)?; + let m = m.as_array(); + if m.shape() != [3, 3] { + return Err(PyValueError::new_err("needs a 3x3 matrix")); + } + let m = std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])); + filled(py, &px, |src, d| color::matmul_3x3(src, d, m)) +} + +/// `_gamut_aware_chroma_scale_kernel` of an (..., 3) float32 Lab array. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn gamut_aware_chroma_scale<'py>( + py: Python<'py>, + lab: PyReadonlyArrayDyn<'py, f32>, + saturation: f32, + m: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, + iters: usize, +) -> PyResult>> { + triplets(&lab)?; + let s = space(&m, &white, eps, kappa)?; + filled(py, &lab, |src, d| { + color::gamut_aware_chroma_scale(src, d, saturation, iters, s) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(oetf_encode, m)?)?; m.add_function(wrap_pyfunction!(oetf_decode, m)?)?; m.add_function(wrap_pyfunction!(rgb_to_lab, m)?)?; m.add_function(wrap_pyfunction!(lab_to_rgb, m)?)?; m.add_function(wrap_pyfunction!(skin_chroma_rein, m)?)?; - m.add_function(wrap_pyfunction!(skin_weight, m)?) + m.add_function(wrap_pyfunction!(skin_weight, m)?)?; + m.add_function(wrap_pyfunction!(matmul_3x3, m)?)?; + m.add_function(wrap_pyfunction!(gamut_aware_chroma_scale, m)?) } diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 67fd6d3ac..1e11d7be2 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -39,6 +39,10 @@ _LAB, _M.T.copy(), np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787) ), "skin_chroma_rein": lambda: negpy_core.skin_chroma_rein(_LAB, np.float32(0.6)), + "matmul_3x3": lambda: negpy_core.matmul_3x3(_IMG3, _M), + "gamut_aware_chroma_scale": lambda: negpy_core.gamut_aware_chroma_scale( + _LAB, np.float32(1.8), _M.T.copy(), np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787), 10 + ), } _HASHES = { @@ -60,6 +64,8 @@ "rgb_to_lab": "02c583bf1f5fe9723e2cb0a3e1f34759d6b0778d3644e8e2cff2251e756b68f2", "lab_to_rgb": "34016e7f9aebcfdf780b67b96b128041c6ba1ac2a427f4e87b5413dcc7345eef", "skin_chroma_rein": "6def89b9f456f16abf406c351745bbb791f15bc69e5b03f2e478ed7391839b01", + "matmul_3x3": "d80c524d23c1bc6b7d9592b638a5fb84d4b51691efac9b71b40fbf529e67c019", + "gamut_aware_chroma_scale": "ef1a9474817c0bca19e3c847a4667ba47ba75829722a1c7939a77e135d8b7da4", } From 1573fe941ce536b248f8952dede22f03d57894da Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 08:03:17 +0200 Subject: [PATCH 058/133] fix(core): name the neutral-axis result type for clippy clippy runs with --all-features in CI; the python bindings failed type_complexity. --- negpy_core/src/py/meter.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/negpy_core/src/py/meter.rs b/negpy_core/src/py/meter.rs index 4710dc5c9..d55c2ca27 100644 --- a/negpy_core/src/py/meter.rs +++ b/negpy_core/src/py/meter.rs @@ -83,6 +83,8 @@ fn textured_range( type Band = (f32, f32); type Sector = (f32, f32, f32); type Triplet = (f64, f64, f64); +/// (midtone, shadow, highlight or None, confidence). +type Axis = (Triplet, Triplet, Option, f64); /// quantile, cap, first-pass cap, min pixels, highlight/mid/shadow bands, foliage and sky /// sectors, n0, agreement (dead zone, scale), width (dead zone, scale). type Consts = ( @@ -114,7 +116,7 @@ fn neutral_axis( ceils: Triplet, exclude_subjects: bool, consts: Consts, -) -> PyResult, f64)>> { +) -> PyResult> { let (data, _, _) = grid_of(&grid)?; let ( quantile, From f458cb9f35ec09b83d73f5c456571e95e68d263e Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 08:07:21 +0200 Subject: [PATCH 059/133] perf(core): vectorscope histogram in Rust vectorscope_histogram decodes, converts to Lab and bins in one Rust call. The bins take mathf's float64 atan2 and log1p instead of NumPy's SVML, so a sample on a bin edge may move (ledger, abs=2). Against the Python version on 39 recorded encoded frames the counts are identical; one thread 0.56x of the Python time, which already ran the Rust Lab kernels. --- negpy/features/exposure/analysis.py | 28 ++++++++---- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 4 +- negpy_core/src/histogram.rs | 69 +++++++++++++++++++++++++++++ negpy_core/src/mathf.rs | 17 ++++--- negpy_core/src/py/histogram.rs | 37 +++++++++++++++- tests/test_vectorscope.py | 12 +++++ 7 files changed, 150 insertions(+), 18 deletions(-) diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index 31517503c..bac251260 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -7,7 +7,17 @@ import negpy_core import numpy as np -from negpy.kernel.image.logic import get_luminance, rgb_to_lab_working, working_oetf_decode, working_oetf_encode +from negpy.kernel.image.logic import ( + _LAB_EPS, + _LAB_KAPPA, + _WORKING_GAMMA, + _WORKING_TO_XYZ, + _WORKING_WHITE, + get_luminance, + rgb_to_lab_working, + working_oetf_decode, + working_oetf_encode, +) # Mirrored by the chart's x-range (PhotometricCurveWidget._X_MIN/_X_MAX) and the WGSL # literals in shaders/density_hist.wgsl. Keep them in lock step. @@ -155,13 +165,15 @@ def vectorscope_histogram(buffer: Any) -> Optional[np.ndarray]: if buffer.ndim != 3 or buffer.shape[-1] < 3 or min(buffer.shape[:2]) < 1: return None step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _SCOPE_MAX_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - encoded = np.clip(np.nan_to_num(np.asarray(buffer[..., :3], dtype=np.float32)), 0.0, 1.0) - lab = rgb_to_lab_working(np.asarray(working_oetf_decode(encoded))) - flat = _scope_bins(lab[..., 1].ravel().astype(np.float64), lab[..., 2].ravel().astype(np.float64)) - counts = np.bincount(flat, minlength=SCOPE_HUE_BINS * SCOPE_CHROMA_BINS) - return counts.reshape(SCOPE_HUE_BINS, SCOPE_CHROMA_BINS).astype(float) + return negpy_core.vectorscope_histogram( + np.ascontiguousarray(buffer, dtype=np.float32), + step, + _WORKING_TO_XYZ, + _WORKING_WHITE, + np.float32(_LAB_EPS), + np.float32(_LAB_KAPPA), + np.float32(_WORKING_GAMMA), + ) def scope_point(rgb: Sequence[float]) -> Tuple[float, float, float]: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 4aea6ae4f..d3baa1ff0 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -17,3 +17,4 @@ tolerance listed for each entry. | `kernel/image/logic.py::_rgb_to_lab_kernel` (and `rgb_to_lab_working`) | The cube root is `mathf::pow_f64`, not glibc's correctly rounded pow; a* and b* near zero take the float64 difference in a few ULP. | abs=1e-6 | | `kernel/image/logic.py::_lab_to_rgb_kernel` (and `lab_to_rgb_working`) | One value in the corpus rounds the other way; the float64 operation order copies Numba's fastmath build. | ulp=1 | | `kernel/image/logic.py::_skin_chroma_rein_kernel` (and `skin_chroma_rein`) | `atan2f` and `exp` are `mathf`'s, not glibc's; Numba's own serial and parallel builds of the kernel already differ in the last bit. | ulp=1 | +| `features/exposure/analysis.py::vectorscope_histogram` | atan2 and log1p are `mathf`'s, not SVML's; a sample on a bin edge can change bin (none did in 39 recorded frames). | abs=2 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 5e1e51df3..e57f96d74 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -95,8 +95,8 @@ depends = ["_scope_lookup"] ["features/exposure/analysis.py::vectorscope_histogram"] phase = 2 -status = "todo" -calls = ["np.bincount"] +status = "ported" +calls = ["negpy_core.vectorscope_histogram"] depends = [] ["features/exposure/analysis.py::waveform"] diff --git a/negpy_core/src/histogram.rs b/negpy_core/src/histogram.rs index 8c0ecdd63..4be798f23 100644 --- a/negpy_core/src/histogram.rs +++ b/negpy_core/src/histogram.rs @@ -5,6 +5,7 @@ use multiversion::multiversion; use rayon::prelude::*; use crate::analysis::luminance_chunk; +use crate::color::{self, LabSpace}; pub const DENSITY_HIST_BINS: usize = 120; const DENSITY_HIST_RANGE: (f64, f64) = (-0.1, 1.1); @@ -357,3 +358,71 @@ mod tests { } } } + +pub const SCOPE_HUE_BINS: usize = 180; +pub const SCOPE_CHROMA_BINS: usize = 64; +const SCOPE_MAX_CHROMA: f64 = 60.0; +const SCOPE_LOG_KNEE: f64 = 4.0; + +/// `vectorscope_histogram`: a*b* counts of the sampled pixels, (hue, chroma) bins in float64 +/// as `_scope_bins` computes them. The encoded values are clipped to 0..1 (NaN to 0), decoded +/// with `gamma` and converted to Lab by the working-space kernels. +pub fn vectorscope_histogram(s: Samples, space: LabSpace, gamma: f32) -> Vec { + let mut rgb = Vec::new(); + let mut buf = Vec::new(); + for y in s.row_starts() { + rgb.extend_from_slice(s.rgb_row(y, &mut buf)); + } + for v in rgb.iter_mut() { + *v = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; + } + let mut lin = vec![0f32; rgb.len()]; + color::oetf_decode(&rgb, &mut lin, gamma); + color::rgb_to_lab(&lin, &mut rgb, space); + let denom = libm::log1p(SCOPE_MAX_CHROMA / SCOPE_LOG_KNEE); + let bins = SCOPE_HUE_BINS * SCOPE_CHROMA_BINS; + rgb.par_chunks(3 * 4096) + .fold( + || vec![0u32; bins], + |mut acc, chunk| { + let mut idx = [0u16; 4096]; + scope_bins(chunk, &mut idx, denom); + for &i in &idx[..chunk.len() / 3] { + acc[i as usize] += 1; + } + acc + }, + ) + .reduce( + || vec![0u32; bins], + |mut a, b| { + a.iter_mut().zip(b).for_each(|(a, b)| *a += b); + a + }, + ) + .into_iter() + .map(f64::from) + .collect() +} + +/// `_scope_bins` of Lab triplets: the hue bin of atan2(b, a) and the chroma bin of the log +/// radius, flat as hue * SCOPE_CHROMA_BINS + chroma. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn scope_bins(lab: &[f32], idx: &mut [u16], denom: f64) { + use std::f64::consts::PI; + for (i, p) in idx.iter_mut().zip(lab.as_chunks::<3>().0) { + let (a, b) = (p[1] as f64, p[2] as f64); + let turn = (crate::mathf::atan2_f64(b, a) + PI) / (2.0 * PI) * SCOPE_HUE_BINS as f64; + let hue = crate::convert::trunc_bits(turn.floor()) as usize % SCOPE_HUE_BINS; + let chroma = (a * a + b * b).sqrt(); + let radius = crate::mathf::ln_f64(1.0 + chroma / SCOPE_LOG_KNEE) / denom; + let c = (radius * SCOPE_CHROMA_BINS as f64) + .floor() + .clamp(0.0, (SCOPE_CHROMA_BINS - 1) as f64); + *i = (hue * SCOPE_CHROMA_BINS + crate::convert::trunc_bits(c) as usize) as u16; + } +} diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index 55f147674..dda46763e 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -27,13 +27,18 @@ pub fn log10f(x: f32) -> f32 { (e * HI + (e * LO + 2.0 * s * p * std::f64::consts::LOG10_E)) as f32 } -/// atan2 of finite f32 in f64, rounded once: |y/x| folded to [0, 1], then to |u| <= tan(pi/8) -/// by atan(r) = pi/4 + atan((r - 1) / (r + 1)), where the Taylor series reaches f64 accuracy. -/// Signed zeros follow C's atan2. +/// atan2 of finite f32 in f64, rounded once. Signed zeros follow C's atan2. #[inline(always)] pub fn atan2f(y: f32, x: f32) -> f32 { + atan2_f64(y as f64, x as f64) as f32 +} + +/// atan2 of finite values within 2^-37 relative: |y/x| folded to [0, 1], then to +/// |u| <= tan(pi/8) by atan(r) = pi/4 + atan((r - 1) / (r + 1)), and a Taylor series. +#[inline(always)] +pub fn atan2_f64(y: f64, x: f64) -> f64 { use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; - let (ay, ax) = ((y as f64).abs(), (x as f64).abs()); + let (ay, ax) = (y.abs(), x.abs()); let swap = ay > ax; let (n, d) = if swap { (ax, ay) } else { (ay, ax) }; let r = if d == 0.0 { 0.0 } else { n / d }; @@ -64,13 +69,13 @@ pub fn atan2f(y: f32, x: f32) -> f32 { a = if big { FRAC_PI_4 + a } else { a }; a = if swap { FRAC_PI_2 - a } else { a }; a = if x.is_sign_negative() { PI - a } else { a }; - (a as f32).copysign(y) + a.copysign(y) } /// ln of a positive normal f64 to about 1 ULP: x = m * 2^e with m in [sqrt(1/2), sqrt(2)), /// ln(m) from the atanh series. #[inline(always)] -fn ln_f64(x: f64) -> f64 { +pub fn ln_f64(x: f64) -> f64 { let bits = x.to_bits(); let e = (bits.wrapping_sub(0x3fe6_a09e_667f_3bcd) as i64) >> 52; let m = f64::from_bits(bits.wrapping_sub((e as u64) << 52)); diff --git a/negpy_core/src/py/histogram.rs b/negpy_core/src/py/histogram.rs index 056e13d71..d561b87c7 100644 --- a/negpy_core/src/py/histogram.rs +++ b/negpy_core/src/py/histogram.rs @@ -2,9 +2,10 @@ use numpy::{PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; +use crate::color::LabSpace; use crate::histogram::{ - self, COLOR_HIST_BINS, DENSITY_HIST_BINS, OUTPUT_HIST_BINS, Samples, WAVEFORM_BINS, - WAVEFORM_COLS, Waveform, + self, COLOR_HIST_BINS, DENSITY_HIST_BINS, OUTPUT_HIST_BINS, SCOPE_CHROMA_BINS, SCOPE_HUE_BINS, + Samples, WAVEFORM_BINS, WAVEFORM_COLS, Waveform, }; type Counts<'py> = Bound<'py, numpy::PyArrayDyn>; @@ -138,7 +139,39 @@ fn scan_full_scale_columns<'py>( numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_COLS]) } +/// `vectorscope_histogram` of an HxWxC float32 encoded frame sampled every `step`, through the +/// working space (`m` RGB to XYZ, `white`, `eps`, `kappa`, `gamma`): (180, 64) counts. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn vectorscope_histogram<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + step: usize, + m: numpy::PyReadonlyArray2<'py, f32>, + white: numpy::PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, + gamma: f32, +) -> PyResult> { + let s = frame(&img, step)?; + let (m, white) = (m.as_array(), white.as_array()); + if m.shape() != [3, 3] || white.len() != 3 { + return Err(PyValueError::new_err( + "needs a 3x3 matrix and a 3-vector white", + )); + } + let space = LabSpace::new( + std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])), + std::array::from_fn(|c| white[c]), + eps, + kappa, + ); + let counts = py.detach(|| histogram::vectorscope_histogram(s, space, gamma)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![SCOPE_HUE_BINS, SCOPE_CHROMA_BINS]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(vectorscope_histogram, m)?)?; m.add_function(wrap_pyfunction!(density_histogram, m)?)?; m.add_function(wrap_pyfunction!(density_parade, m)?)?; m.add_function(wrap_pyfunction!(output_histogram, m)?)?; diff --git a/tests/test_vectorscope.py b/tests/test_vectorscope.py index a46c8ae09..589b4dc52 100644 --- a/tests/test_vectorscope.py +++ b/tests/test_vectorscope.py @@ -12,15 +12,27 @@ from negpy.features.exposure.analysis import ( SCOPE_CHROMA_BINS, SCOPE_HUE_BINS, + _scope_bins, scope_chroma, scope_radius, vectorscope_density, vectorscope_histogram, ) from negpy.features.process.models import ProcessMode +from negpy.kernel.image.logic import rgb_to_lab_working, working_oetf_decode from negpy.infrastructure.gpu.device import GPUDevice +def test_counts_match_the_numpy_binning() -> None: + rng = np.random.default_rng(3) + frame = rng.random((300, 400, 3), dtype=np.float32) * 1.2 - 0.1 + frame[0, :4] = np.array([np.nan, np.inf, -np.inf, 0.5], np.float32)[:, None] + lab = rgb_to_lab_working(working_oetf_decode(np.clip(np.nan_to_num(frame), 0.0, 1.0))) + flat = _scope_bins(lab[..., 1].ravel().astype(np.float64), lab[..., 2].ravel().astype(np.float64)) + want = np.bincount(flat, minlength=SCOPE_HUE_BINS * SCOPE_CHROMA_BINS).reshape(SCOPE_HUE_BINS, SCOPE_CHROMA_BINS) + assert np.abs(vectorscope_histogram(frame) - want).sum() <= 2 + + def test_a_gray_frame_lands_in_the_first_chroma_bin() -> None: counts = vectorscope_histogram(np.full((20, 30, 3), 0.5, dtype=np.float32)) From 3ee2cd3a9a88f7b4f7b02a2379f59b6099c672b2 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 08:26:29 +0200 Subject: [PATCH 060/133] perf(core): print curve and chemical toning in Rust; Phase 6 corpus The print curve (the Numba _apply_print_curve_kernel, called from apply_characteristic_curve) and _apply_chemical_toning_jit run in Rust; print_lut.tetra_lookup calls the same Rust lookup. Each runs in staged runs of 256 pixels with branch-free sigmoid and softplus, so the transcendental steps vectorize. mathf gains log1p, expm1, tanh, log10 and 10^x in float64, each within 1e-14 of libm. Print curve: replay 39/39 at ulp=1 (2 of 205 M values off by 1 ULP, ledger); against the old Numba kernel every branch (masks, flash, diffusion, dye mix, separation damping, split grade, zone density, all nine print LUTs) is bit-identical; bench 0.21x. Toning (no recordings: no edit uses it): bit-identical to the Numba kernel on 263 M values over ten bath mixes; one thread 0.53x for one bath, 0.99x with all six. The recorder skips a call whose arguments do not deep-copy (a lock in PreviewManager); the Phase 6 corpus is recorded with saved edits. --- negpy/features/exposure/logic.py | 378 +------------- negpy/features/exposure/print_lut.py | 51 +- negpy/features/exposure/shaders/exposure.wgsl | 6 +- negpy/features/toning/logic.py | 148 ++---- negpy/features/transparency/logic.py | 4 +- negpy/services/rendering/gpu_engine.py | 2 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 65 +-- negpy_core/src/lib.rs | 4 + negpy_core/src/mathf.rs | 74 +++ negpy_core/src/print_curve.rs | 483 ++++++++++++++++++ negpy_core/src/py/mod.rs | 2 + negpy_core/src/py/print_curve.rs | 273 ++++++++++ negpy_core/src/py/toning.rs | 89 ++++ negpy_core/src/toning.rs | 127 +++++ scripts/replay.py | 7 +- tests/test_core_same_bits.py | 9 + tests/test_numba_nogil.py | 1 - tests/test_parallel_dispatch.py | 8 +- 19 files changed, 1141 insertions(+), 591 deletions(-) create mode 100644 negpy_core/src/print_curve.rs create mode 100644 negpy_core/src/py/print_curve.rs create mode 100644 negpy_core/src/py/toning.rs create mode 100644 negpy_core/src/toning.rs diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index cf0e34c3a..dd6250d19 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -2,10 +2,10 @@ from typing import Any, Dict, Optional, Tuple import cv2 +import negpy_core import numpy as np -from numba import njit, prange # type: ignore -from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ImageBuffer +from negpy.domain.types import ImageBuffer from negpy.features.exposure.papers import ( PaperProfile, compose_density_matrices, @@ -14,9 +14,8 @@ resolve_saturation_matrix, slope_gain, ) -from negpy.features.exposure.print_lut import lut_black_luminance, tetra_lookup +from negpy.features.exposure.print_lut import lut_black_luminance from negpy.kernel.image.validation import ensure_image -from negpy.kernel.system.parallel import parallel_njit def _expit(x: Any) -> Any: @@ -27,40 +26,12 @@ def _expit(x: Any) -> Any: return np.exp(-np.logaddexp(0.0, -x)) -@njit(inline="always") def _fast_sigmoid(x: float) -> float: - """ - Fast implementation of the logistic sigmoid function. - expit(x) = 1 / (1 + exp(-x)) - """ + """The logistic sigmoid 1 / (1 + exp(-x)), overflow-safe for large |x|.""" if x >= 0: - z = np.exp(-x) - return float(1.0 / (1.0 + z)) - else: - z = np.exp(x) - return float(z / (1.0 + z)) - - -# Plain Python calls this twin: an inline kernel has no cache, so it would compile on every launch. -_fast_sigmoid_cached = njit(cache=True)(_fast_sigmoid.py_func) - - -@njit(inline="always") -def _black_hold(v: float, d_max_eff: float, taper: float) -> float: - """Hold Black's weight on a shadow lift: 1 below `taper` under paper black, smoothstep to 0 at it. - Falls only where the lift is negative, so the lifted curve gets steeper and stays monotone.""" - t = min(max((d_max_eff - v) / taper, 0.0), 1.0) - return t * t * (3.0 - 2.0 * t) - - -@njit(inline="always") -def _softplus(x: float) -> float: - """ - Numerically stable softplus: log(1 + exp(x)). Antiderivative of the sigmoid. - """ - if x > 0: - return float(x + np.log1p(np.exp(-x))) - return float(np.log1p(np.exp(x))) + return 1.0 / (1.0 + math.exp(-x)) + z = math.exp(x) + return z / (1.0 + z) def _inv_softplus_np(y: Any) -> Any: @@ -68,7 +39,6 @@ def _inv_softplus_np(y: Any) -> Any: return np.where(y > 20.0, y, np.log(np.expm1(np.maximum(y, 1e-12)))) -@njit(inline="always") def separation_damping_gain(k: float, damping: float, chroma: float, ref_spread: float) -> float: """ One pixel's effective dye-separation k: the frame-wide k tapered by the @@ -79,7 +49,7 @@ def separation_damping_gain(k: float, damping: float, chroma: float, ref_spread: frame-wide matrix cannot do. Monotone in chroma for k < e**2; the clamp bounds the k -> 0 corner (weakly monotone, so no rank swap). - Single source for the CPU kernel and the tests; exposure.wgsl mirrors it. + negpy_core's print curve and exposure.wgsl mirror it. """ if k <= 0.0: return 0.0 @@ -101,336 +71,10 @@ def separation_damping_gain_np(k: float, damping: float, chroma: Any, ref_spread return np.minimum(kf, np.float32(3.0)) -@njit(inline="always") -def plane_taps(gh: int, gw: int, x: int, y: int, rect_x: float, rect_y: float, rect_w: float, rect_h: float): - """Taps and weights of render pixel (x, y) on a gh×gw analysis-grid plane covering `rect`: OpenCV's half-pixel - bilinear, taps clamped (edge replication outside the frame). exposure.wgsl mirrors this.""" - px = (x + 0.5 - rect_x) * gw / rect_w - 0.5 - py = (y + 0.5 - rect_y) * gh / rect_h - 0.5 - lx = min(max(np.floor(px), 0.0), gw - 1.0) - ly = min(max(np.floor(py), 0.0), gh - 1.0) - hx = min(lx + 1.0, gw - 1.0) - hy = min(ly + 1.0, gh - 1.0) - fx = min(max(px - lx, 0.0), 1.0) - fy = min(max(py - ly, 0.0), 1.0) - return int(lx), int(hx), int(ly), int(hy), fx, fy - - -@njit(inline="always") -def tone_key_weight(lum: float, e0: float, e1: float) -> float: - """A tone-limited mask's weight at luma `lum`: a smoothstep from 0 at e0 to 1 at e1 - (see placement.key_edges). Either edge order works. exposure.wgsl mirrors it.""" - t = (lum - e0) / (e1 - e0) - if t < 0.0: - t = 0.0 - elif t > 1.0: - t = 1.0 - return t * t * (3.0 - 2.0 * t) - - -@njit(inline="always") -def quadratic_core(k: float, pivot: float, curv: float, u: float) -> float: - """k*(u - pivot) + curv*u², held at its vertex on the side where it would fold back. - The Cast Removal clamp bounds curv only near the frame's range. exposure.wgsl mirrors it.""" - if curv != 0.0: - vertex = -k / (2.0 * curv) - if (curv > 0.0 and u < vertex) or (curv < 0.0 and u > vertex): - u = vertex - return k * (u - pivot) + curv * u * u - - -def tone_key_weight_np(lum: Any, e0: float, e1: float) -> Any: - """Vectorized numpy twin of tone_key_weight, for the canvas tint over a whole raster.""" - t = np.clip((np.asarray(lum, dtype=np.float32) - np.float32(e0)) / np.float32(e1 - e0), 0.0, 1.0) - return t * t * (np.float32(3.0) - np.float32(2.0) * t) - - -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _apply_print_curve_kernel( - img: np.ndarray, - pivots: np.ndarray, - slopes: np.ndarray, - curvatures: np.ndarray, - toe: np.ndarray, - shoulder: np.ndarray, - toe_width: np.ndarray, - shoulder_width: np.ndarray, - cmy_offsets: np.ndarray, - shadow_cmy: np.ndarray, - highlight_cmy: np.ndarray, - d_min_rgb: np.ndarray, - d_max: float, - a_toe_base: float, - a_sh_base: float, - width_ref: float, - toe_height: float, - sh_height: float, - zone_center: float, - shadow_density: float, - highlight_density: float, - shadow_grade: np.ndarray, - highlight_grade: np.ndarray, - zone_sh_center: float, - zone_hi_center: float, - zone_k: float, - v_star: float, - midtone_gamma: np.ndarray, - gamma_width: float, - dye_mix: np.ndarray, - use_dye_mix: bool, - sep_k3: np.ndarray, - sep_damping: float, - sep_ref: float, - use_sep_damping: bool, - ev_map: np.ndarray, - ev_scale: np.ndarray, - use_ev: bool, - grade_map: np.ndarray, - use_grade: bool, - key_alpha: np.ndarray, - key_params: np.ndarray, - use_key: bool, - grade_deltas: np.ndarray, - r0: float, - r_min: float, - r_max: float, - flash: float, - flash_v: float, - flash_gamma: float, - flash_map: np.ndarray, - use_flash: bool, - diff_plane: np.ndarray, - diff_rect: np.ndarray, - diff_mix: float, - diff_range: np.ndarray, - use_diff: bool, - lut: np.ndarray, - lut_e_max: np.ndarray, - lut_black: float, - use_lut: bool, - bpc: bool = False, - black_hold: float = 0.0, -) -> np.ndarray: - """ - Asymmetric H&D print curve: a straight line of slope `slope` through the - exposure pivot, smoothly bounded above by the toe (shadows -> paper black - d_max) and below by the shoulder (highlights -> paper white d_min). Toe and - shoulder are independent softplus bounds, so the `toe` slider shapes only - shadows and `shoulder` only highlights (film/print convention). `toe`/`shoulder` - are per-channel 3-arrays (global value + per-layer trims — endpoint crossover), - pre-scaled by toe_shoulder_strength. - - d_min_rgb: per-channel paper-white floor (base+fog incl. tint). dye_mix: - dye coupling above that floor (D_rgb = M · D_dye) when use_dye_mix is set. - ev_map/ev_scale: per-pixel dodge/burn print-exposure offset (stops × the - normalized-space stop size, positive = burn) when use_ev is set; same domain as - cmy_offsets. - grade_map: per-pixel slope multiplier (local_grade_factor_map) when use_grade - is set — burning or dodging through a harder/softer filter. - key_alpha/key_params: tone-limited masks when use_key is set (see - apply_characteristic_curve). Their grade adds to grade_deltas in ISO-R space about r0, - which then replaces grade_map. - flash/flash_v/flash_gamma: preflash as a fraction of the threshold exposure (see - preflash_value). - - Output is linear reflectance (transmittance = 10^-D); the working-space OETF is - applied at the engine output, not here. - - bpc: black point compensation — paper Dmax maps to display black (ICC - relative-colorimetric style). - - lut/lut_e_max: the stock's LUT when use_lut is set; it replaces 10^-D on the density above - base, and BPC then maps the luminance lut_black to 0 for all channels alike. - """ - h, w, c = img.shape - res = np.empty_like(img) - eps = 1e-6 - - # Roll-off sharpness from the width, so a larger width is gentler; the slider sets the - # height. toe = shadow (upper, paper-black) bound, shoulder = highlight (lower, - # paper-white) bound. a_toe_base/a_sh_base carry the sharpness. - a_hl = np.empty(3, dtype=np.float64) - a_sh = np.empty(3, dtype=np.float64) - d_min_eff = np.empty(3, dtype=np.float64) - d_max_eff = np.empty(3, dtype=np.float64) - bpc_black = np.empty(3, dtype=np.float64) - for ch in range(3): - a_hl[ch] = a_sh_base * width_ref / max(shoulder_width[ch], eps) - a_sh_w = a_toe_base * width_ref / max(toe_width[ch], eps) - t_ch = toe[ch] - if t_ch >= 0.0: - d_max_base = d_max - t_ch * toe_height - a_sh[ch] = a_sh_w - else: - # Negative toe tightens the shadow roll-off instead of extending d_max_eff past - # paper black, which has almost no visible effect. - d_max_base = d_max - a_sh[ch] = a_sh_w * (1.0 - t_ch * 4.0) - dmn = d_min_rgb[ch] + shoulder[ch] * sh_height - if dmn < 0.0: - dmn = 0.0 - dmx = d_max_base - if dmx < dmn + 0.1: - dmx = dmn + 0.1 - d_min_eff[ch] = dmn - d_max_eff[ch] = dmx - # BPC references the physical d_max, not d_max_eff, so toe lifts survive. A negative - # toe raises the clip point: the bound reaches d_max only asymptotically, so an exact - # 0 needs the clip inside the shadow range. - db = d_max - if t_ch < 0.0: - db = d_max + t_ch * toe_height - bpc_black[ch] = 10.0**-db - - use_split = ( - shadow_grade[0] != 0.0 - or shadow_grade[1] != 0.0 - or shadow_grade[2] != 0.0 - or highlight_grade[0] != 0.0 - or highlight_grade[1] != 0.0 - or highlight_grade[2] != 0.0 - ) - - # Rows are independent, so parallelise over y. `dens` is allocated per row, so each - # worker thread has its own scratch. - for y in prange(h): - dens = np.empty(3, dtype=np.float64) - for x in range(w): - gfac = 1.0 - ev_px = 0.0 - if use_key: - if use_ev: - ev_px = ev_map[y, x] - # The unburned tone: no mask can move the pixels it selects. - lum = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - dr = grade_deltas[y, x] - for k in range(key_params.shape[0]): - a = key_alpha[y, x, k] * tone_key_weight(lum, key_params[k, 2], key_params[k, 3]) - ev_px += key_params[k, 0] * a - dr += key_params[k, 1] * a - r1 = r0 + dr - if r1 < r_min: - r1 = r_min - elif r1 > r_max: - r1 = r_max - gfac = r0 / r1 - elif use_grade: - gfac = grade_map[y, x] - # The frame's preflash plus the masks' own, pre-summed like the stops. - fl = flash - if use_flash: - fl = fl + flash_map[y, x] - i0x = i1x = i0y = i1y = 0 - fx = fy = 0.0 - if use_diff: - i0x, i1x, i0y, i1y, fx, fy = plane_taps( - diff_plane.shape[0], diff_plane.shape[1], x, y, diff_rect[0], diff_rect[1], diff_rect[2], diff_rect[3] - ) - for ch in range(3): - base = img[y, x, ch] - if use_diff: - # Diffusion mixes light, not density: the paper sees (1-d)·I + d·blur(I), - # with I = 10^(val·r), r the channel's log range (diffusion_plane). - r = diff_range[ch] - top = diff_plane[i0y, i0x, ch] * (1.0 - fx) + diff_plane[i0y, i1x, ch] * fx - bot = diff_plane[i1y, i0x, ch] * (1.0 - fx) + diff_plane[i1y, i1x, ch] * fx - p = top * (1.0 - fy) + bot * fy - base = np.log10((1.0 - diff_mix) * 10.0 ** (base * r) + diff_mix * 10.0 ** (p * r)) / r - val = base + cmy_offsets[ch] - if use_key: - val = val + np.float32(ev_px * ev_scale[ch]) - elif use_ev: - val = val + ev_map[y, x] * ev_scale[ch] - # Quadratic per-channel core; curvature 0 gives the original straight line. - # gfac is the local grade, a slope rotation about this channel's pivot, so the - # region's own midtone holds. The cast-removal curvature stays global. - v = quadratic_core(slopes[ch] * gfac, pivots[ch], curvatures[ch], val) - if fl > 0.0: - v = flash_v + flash_gamma * np.log10(10.0 ** ((v - flash_v) / flash_gamma) + fl) - - # Variable-gamma paper S-curve: extra local gamma at the midtone centre - # (v_star), easing to zero toward the toe and shoulder. Centred on v_star, so - # the reference tone is preserved. - if midtone_gamma[ch] != 0.0: - v = v + midtone_gamma[ch] * gamma_width * np.tanh((v - v_star) / gamma_width) - - # Regional CMY: shadow weight rises with density, highlight falls. - w_sh = _fast_sigmoid(3.0 * (v - zone_center)) - w_hi = 1.0 - w_sh - v = v + shadow_cmy[ch] * w_sh + highlight_cmy[ch] * w_hi - - # Split Grade: a mid-sparing local contrast rotation about the zone centres. - # Must run as its own block before Zone Density, because sequential stays - # monotone and shared weights do not. - if use_split: - w_gsh = _fast_sigmoid(zone_k * (v - zone_sh_center)) - w_ghi = 1.0 - _fast_sigmoid(zone_k * (v - zone_hi_center)) - v = v + shadow_grade[ch] * w_gsh * (v - zone_sh_center) + highlight_grade[ch] * w_ghi * (v - zone_hi_center) - - # Zone Density (ΔD): neutral brightness offsets, mid-sparing - # weights centred in the three-quarter/quarter tones. - if shadow_density != 0.0 or highlight_density != 0.0: - w_zsh = _fast_sigmoid(zone_k * (v - zone_sh_center)) - if black_hold > 0.0 and shadow_density < 0.0: - w_zsh = w_zsh * _black_hold(v, d_max_eff[ch], black_hold) - w_zhi = 1.0 - _fast_sigmoid(zone_k * (v - zone_hi_center)) - v = v + shadow_density * w_zsh + highlight_density * w_zhi - - # Shoulder: smooth lower bound at paper white (highlights). - v1 = d_min_eff[ch] + _softplus(a_hl[ch] * (v - d_min_eff[ch])) / a_hl[ch] - # Toe: smooth upper bound at paper black (shadows). - dens[ch] = d_max_eff[ch] - _softplus(a_sh[ch] * (d_max_eff[ch] - v1)) / a_sh[ch] - - if use_dye_mix: - # Dye unwanted absorptions: mix the densities above paper base. - e0 = dens[0] - d_min_rgb[0] - e1 = dens[1] - d_min_rgb[1] - e2 = dens[2] - d_min_rgb[2] - dens[0] = d_min_rgb[0] + dye_mix[0, 0] * e0 + dye_mix[0, 1] * e1 + dye_mix[0, 2] * e2 - dens[1] = d_min_rgb[1] + dye_mix[1, 0] * e0 + dye_mix[1, 1] * e1 + dye_mix[1, 2] * e2 - dens[2] = d_min_rgb[2] + dye_mix[2, 0] * e0 + dye_mix[2, 1] * e1 + dye_mix[2, 2] * e2 - - if use_sep_damping: - # Chroma-selective dye separation, in place of the frame-wide saturation - # matrix, which then carries the paper's coupling only and keeps - # compose_density_matrices' separation-outermost order. Chroma mirrors - # rms_chroma in negpy_core/src/meter.rs. - s0 = dens[0] - d_min_rgb[0] - s1 = dens[1] - d_min_rgb[1] - s2 = dens[2] - d_min_rgb[2] - s_mean = (s0 + s1 + s2) / 3.0 - chroma = np.sqrt(((s0 - s1) ** 2 + (s1 - s2) ** 2 + (s0 - s2) ** 2) / 3.0) - dens[0] = d_min_rgb[0] + s_mean + separation_damping_gain(sep_k3[0], sep_damping, chroma, sep_ref) * (s0 - s_mean) - dens[1] = d_min_rgb[1] + s_mean + separation_damping_gain(sep_k3[1], sep_damping, chroma, sep_ref) * (s1 - s_mean) - dens[2] = d_min_rgb[2] + s_mean + separation_damping_gain(sep_k3[2], sep_damping, chroma, sep_ref) * (s2 - s_mean) - - if use_lut: - lr, lg, lb = tetra_lookup(lut, lut_e_max, dens[0] - d_min_rgb[0], dens[1] - d_min_rgb[1], dens[2] - d_min_rgb[2]) - rgb = (lr, lg, lb) - for ch in range(3): - transmittance = 10.0 ** (-d_min_rgb[ch]) * rgb[ch] - if bpc: - transmittance = (transmittance - lut_black) / (1.0 - lut_black) - res[y, x, ch] = min(max(transmittance, 0.0), 1.0) - else: - for ch in range(3): - transmittance = 10.0 ** (-dens[ch]) - if bpc: - transmittance = (transmittance - bpc_black[ch]) / (1.0 - bpc_black[ch]) - - final_val = transmittance - if final_val < 0.0: - final_val = 0.0 - elif final_val > 1.0: - final_val = 1.0 - res[y, x, ch] = final_val - return res - - class CharacteristicCurve: """ Asymmetric H&D print curve (toe-linear-shoulder) in density space — the NumPy - mirror of _apply_print_curve_kernel, used by the curve chart so the displayed + mirror of the print curve in negpy_core (print_curve.rs), used by the curve chart so the displayed curve matches the render. Returns density (pre-transmittance/encode). Neutral (no regional CMY color), since the chart shows the achromatic transfer; the achromatic zone density offsets (shadow/highlight ΔD) are included. @@ -815,7 +459,7 @@ def apply_characteristic_curve( lut_black = 0.0 if lut is not None and bpc: lut_black = lut_black_luminance(lut, d_min_rgb, black_density_rgb(float(c["d_max"]), toe_k, float(c["toe_height"]))) - res = _apply_print_curve_kernel( + res = negpy_core.apply_print_curve( np.ascontiguousarray(img.astype(np.float32)), pivots, slopes, @@ -1237,7 +881,7 @@ def highlight_hold_offset( if v >= v_hold: return 0.0 z_hi = float(c["reference_print_density"]) + float(c["zone_density_highlight_offset"]) - w = 1.0 - _fast_sigmoid_cached(float(c["zone_density_sharpness"]) * (v - z_hi)) + w = 1.0 - _fast_sigmoid(float(c["zone_density_sharpness"]) * (v - z_hi)) return float(min((v_hold - v) / max(w, 1e-6), float(c["highlight_hold_max"]))) diff --git a/negpy/features/exposure/print_lut.py b/negpy/features/exposure/print_lut.py index ffc4bc738..ba42aba15 100644 --- a/negpy/features/exposure/print_lut.py +++ b/negpy/features/exposure/print_lut.py @@ -11,7 +11,7 @@ from typing import Optional, Tuple import numpy as np -from numba import njit # type: ignore +import negpy_core from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R from negpy.kernel.system.paths import get_resource_path @@ -69,59 +69,14 @@ def load_print_lut(key: str) -> PrintLut: ) -@njit(inline="always") def tetra_lookup(table: np.ndarray, e_max: np.ndarray, e0: float, e1: float, e2: float) -> Tuple[float, float, float]: """Tetrahedral interpolation, clamped to the table. exposure.wgsl's lut_tetra mirrors it.""" - n = table.shape[0] - 1 - x = min(max(e0 / e_max[0], 0.0), 1.0) * n - y = min(max(e1 / e_max[1], 0.0), 1.0) * n - z = min(max(e2 / e_max[2], 0.0), 1.0) * n - i = min(int(x), n - 1) - j = min(int(y), n - 1) - k = min(int(z), n - 1) - fx = x - i - fy = y - j - fz = z - k - out = np.empty(3, dtype=np.float64) - for c in range(3): - c000 = table[i, j, k, c] - c111 = table[i + 1, j + 1, k + 1, c] - if fx >= fy: - if fy >= fz: - c100 = table[i + 1, j, k, c] - c110 = table[i + 1, j + 1, k, c] - out[c] = c000 + fx * (c100 - c000) + fy * (c110 - c100) + fz * (c111 - c110) - elif fx >= fz: - c100 = table[i + 1, j, k, c] - c101 = table[i + 1, j, k + 1, c] - out[c] = c000 + fx * (c100 - c000) + fz * (c101 - c100) + fy * (c111 - c101) - else: - c001 = table[i, j, k + 1, c] - c101 = table[i + 1, j, k + 1, c] - out[c] = c000 + fz * (c001 - c000) + fx * (c101 - c001) + fy * (c111 - c101) - else: - if fz >= fy: - c001 = table[i, j, k + 1, c] - c011 = table[i, j + 1, k + 1, c] - out[c] = c000 + fz * (c001 - c000) + fy * (c011 - c001) + fx * (c111 - c011) - elif fz >= fx: - c010 = table[i, j + 1, k, c] - c011 = table[i, j + 1, k + 1, c] - out[c] = c000 + fy * (c010 - c000) + fz * (c011 - c010) + fx * (c111 - c011) - else: - c010 = table[i, j + 1, k, c] - c110 = table[i + 1, j + 1, k, c] - out[c] = c000 + fy * (c010 - c000) + fx * (c110 - c010) + fz * (c111 - c110) - return out[0], out[1], out[2] - - -# Plain Python calls this twin: an inline kernel has no cache, so it would compile on every launch. -_tetra_lookup_cached = njit(cache=True)(tetra_lookup.py_func) + return negpy_core.tetra_lookup(np.ascontiguousarray(table, np.float32), np.ascontiguousarray(e_max, np.float32), e0, e1, e2) def lut_black_luminance(lut: PrintLut, d_min_rgb: Tuple[float, float, float], black_rgb: Tuple[float, float, float]) -> float: """Luminance of the print's black (density black_rgb), which black point compensation maps to 0.""" - rgb = _tetra_lookup_cached(lut.table, lut.e_max, *(b - d for b, d in zip(black_rgb, d_min_rgb))) + rgb = tetra_lookup(lut.table, lut.e_max, *(b - d for b, d in zip(black_rgb, d_min_rgb))) return float(sum(w * 10.0**-d * v for w, d, v in zip((LUM_R, LUM_G, LUM_B), d_min_rgb, rgb))) diff --git a/negpy/features/exposure/shaders/exposure.wgsl b/negpy/features/exposure/shaders/exposure.wgsl index a07a364a2..78b108a1c 100644 --- a/negpy/features/exposure/shaders/exposure.wgsl +++ b/negpy/features/exposure/shaders/exposure.wgsl @@ -249,8 +249,8 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } let eps = 1e-6; - // Asymmetric H&D print curve (toe-linear-shoulder); mirrors the CPU - // _apply_print_curve_kernel. toe -> shadow (paper-black) bound, shoulder -> + // Asymmetric H&D print curve (toe-linear-shoulder); mirrors the CPU print curve + // (negpy_core print_curve.rs). toe -> shadow (paper-black) bound, shoulder -> // highlight (paper-white) bound. a_toe_base/a_sh_base carry shadow/highlight // sharpness; width sets gentleness, slider sets roll-off height. let toe_w3 = vec3(params.toe_width_r, params.toe_width_g, params.toe_width_b); @@ -374,7 +374,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } // Separation Damping: chroma-selective dye separation, in place of the - // frame-wide saturation matrix. Mirrors _apply_print_curve_kernel. + // frame-wide saturation matrix. Mirrors negpy_core print_curve.rs. if (params.sep_damping > 0.0) { let s = dens - d_min_rgb; let s_mean = (s.x + s.y + s.z) / 3.0; diff --git a/negpy/features/toning/logic.py b/negpy/features/toning/logic.py index 6892c9a15..3afb23ff2 100644 --- a/negpy/features/toning/logic.py +++ b/negpy/features/toning/logic.py @@ -1,12 +1,11 @@ from typing import Any, Dict import numpy as np -from numba import prange # type: ignore +import negpy_core from negpy.domain.types import ImageBuffer from negpy.kernel.image.logic import lab_to_rgb_working, rgb_to_lab_working from negpy.kernel.image.validation import ensure_image -from negpy.kernel.system.parallel import parallel_njit TONING_CONSTANTS: Dict[str, Any] = { # Selenium (silver -> silver selenide, densest silver first). @@ -87,7 +86,6 @@ } -@parallel_njit(cache=True, fastmath=True) def _apply_chemical_toning_jit( img: np.ndarray, sel_strength: float, @@ -133,121 +131,35 @@ def _apply_chemical_toning_jit( conversion — how it behaves on a lith print, where it "attacks all densities evenly" instead of starting in the highlights. """ - h, w, c = img.shape - res = np.empty_like(img) - eps = 1e-6 - - for y in prange(h): - d3 = np.empty(3, dtype=np.float64) - for x in range(w): - d0 = 0.0 - for ch in range(3): - t = img[y, x, ch] - if t < eps: - t = eps - elif t > 1.0: - t = 1.0 - d3[ch] = -np.log10(t) - d0 += d3[ch] - d0 /= 3.0 - - # Conversion caps at 1: all the remaining silver is toned - # (slider > 1 = longer bath). - c_sel = 0.0 - if sel_strength > 0.0: - frac = d0 / sel_d_ref - if frac > 1.0: - frac = 1.0 - c_sel = sel_strength * frac**sel_power - if c_sel > 1.0: - c_sel = 1.0 - - c_sep = 0.0 - if sep_strength > 0.0: - frac = d0 / sep_d_bleach - if frac > 1.0: - frac = 1.0 - c_sep = sep_strength * (1.0 - frac) ** sep_power - if c_sep > 1.0: - c_sep = 1.0 - - c_au = 0.0 - if gold_strength > 0.0: - if gold_flat > 0.5: - c_au = gold_strength - else: - frac = d0 / gold_d_ref - if frac > 1.0: - frac = 1.0 - c_au = gold_strength * (1.0 - frac) ** gold_power - if c_au > 1.0: - c_au = 1.0 - - c_blue = 0.0 - if blue_strength > 0.0: - frac = d0 / blue_d_ref - if frac > 1.0: - frac = 1.0 - c_blue = blue_strength * frac**blue_power - if c_blue > 1.0: - c_blue = 1.0 - - c_cu = 0.0 - if copper_strength > 0.0: - frac = d0 / copper_d_ref - if frac > 1.0: - frac = 1.0 - c_cu = copper_strength * frac**copper_power - if c_cu > 1.0: - c_cu = 1.0 - - c_van = 0.0 - if van_strength > 0.0: - frac = d0 / van_d_ref - if frac > 1.0: - frac = 1.0 - c_van = van_strength * (1.0 - frac) ** van_power - if c_van > 1.0: - c_van = 1.0 - - a = 1.0 - f_sel = a * c_sel - a -= f_sel - f_sep = a * c_sep - a -= f_sep - f_au = a * c_au - a -= f_au - f_ausp = f_sep * c_au - f_sep -= f_ausp - f_blue = a * c_blue - a -= f_blue - f_cu = a * c_cu - a -= f_cu - f_van = a * c_van - a -= f_van - - for ch in range(3): - # Covering power rides each channel's OWN density, not the mean. The - # mean is the silver reservoir above, but rebuilding the output from it - # would throw away the color a lith print hands this stage. Identical - # for a normal grey B&W print, where d3[ch] == d0. - d = d3[ch] * ( - a - + f_sel * sel_gain[ch] - + f_sep * sep_gain[ch] - + f_au * gold_gain[ch] - + f_ausp * sep_gain[ch] * gold_sepia_gain[ch] - + f_blue * blue_gain[ch] - + f_cu * copper_gain[ch] - + f_van * van_gain[ch] - ) - pixel = 10.0**-d - if pixel < 0.0: - pixel = 0.0 - elif pixel > 1.0: - pixel = 1.0 - res[y, x, ch] = pixel - return res + return negpy_core.apply_chemical_toning( + img, + sel_strength, + sep_strength, + gold_strength, + sel_d_ref, + sel_power, + sel_gain, + sep_d_bleach, + sep_power, + sep_gain, + gold_d_ref, + gold_power, + gold_gain, + gold_sepia_gain, + blue_strength, + blue_d_ref, + blue_power, + blue_gain, + copper_strength, + copper_d_ref, + copper_power, + copper_gain, + van_strength, + van_d_ref, + van_power, + van_gain, + gold_flat, + ) def apply_split_toning( diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index 3e03a3ff4..9585fdea4 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -39,7 +39,7 @@ from negpy.domain.types import ImageBuffer from negpy.features.exposure.logic import ( - _fast_sigmoid_cached, + _fast_sigmoid, effective_grade_range, grade_to_slope, per_channel_dye_separation, @@ -285,7 +285,7 @@ def transfer_auto_terms( d_highlight = pivot + (float(highlight_point) * R - offset - pivot) * contrast if d_highlight < target: _sh_c, hi_c, k_zone = zone_geometry() - w_hi = 1.0 - _fast_sigmoid_cached(k_zone * (d_highlight - hi_c)) + w_hi = 1.0 - _fast_sigmoid(k_zone * (d_highlight - hi_c)) highlight_auto = min((target - d_highlight) / max(w_hi, 1e-6), float(EXPOSURE_CONSTANTS["highlight_hold_max"])) # Shadow Hold: a partial shadow-zone lift on the same tail as Shadow Reach. Never burns. diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index a515ceb4a..975ff5192 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -2169,7 +2169,7 @@ def _upload_unified_uniforms( exp.highlight_yellow * cmy_m, _sh3[2], ) - # Asymmetric H&D print-curve scalars; mirrors _apply_print_curve_kernel. + # Asymmetric H&D print-curve scalars; mirrors negpy_core print_curve.rs. # Per-channel knee widths occupy the dead scalar toe/shoulder/ # midtone_gamma slots and the former flare pad (see exposure.wgsl). + struct.pack( diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index d3baa1ff0..8b76ee6a5 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -18,3 +18,4 @@ tolerance listed for each entry. | `kernel/image/logic.py::_lab_to_rgb_kernel` (and `lab_to_rgb_working`) | One value in the corpus rounds the other way; the float64 operation order copies Numba's fastmath build. | ulp=1 | | `kernel/image/logic.py::_skin_chroma_rein_kernel` (and `skin_chroma_rein`) | `atan2f` and `exp` are `mathf`'s, not glibc's; Numba's own serial and parallel builds of the kernel already differ in the last bit. | ulp=1 | | `features/exposure/analysis.py::vectorscope_histogram` | atan2 and log1p are `mathf`'s, not SVML's; a sample on a bin edge can change bin (none did in 39 recorded frames). | abs=2 | +| `features/exposure/logic.py::apply_characteristic_curve` (the print curve) | `exp`, `log1p`, `tanh` and 10^x are `mathf`'s, not glibc's; 2 of 205 M recorded values round the other way. Every feature branch (masks, flash, diffusion, dye mix, separation damping, the print LUTs) matched the Numba kernel bit for bit on test frames. | ulp=1 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index e57f96d74..452655e23 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1844,33 +1844,28 @@ depends = [] ["features/exposure/logic.py::_apply_print_curve_kernel"] phase = 6 -status = "todo" +status = "ported" calls = ["numba"] depends = ["_black_hold", "_fast_sigmoid", "_softplus", "plane_taps", "quadratic_core", "separation_damping_gain", "tone_key_weight"] ["features/exposure/logic.py::_black_hold"] phase = 6 -status = "todo" -calls = ["numba"] -depends = [] - -["features/exposure/logic.py::_fast_sigmoid"] -phase = 6 -status = "todo" +status = "ported" calls = ["numba"] depends = [] ["features/exposure/logic.py::_softplus"] phase = 6 -status = "todo" +status = "ported" calls = ["numba"] depends = [] ["features/exposure/logic.py::apply_characteristic_curve"] phase = 6 -status = "todo" -calls = [] -depends = ["_apply_print_curve_kernel"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.apply_print_curve"] +depends = [] ["features/exposure/logic.py::contrast_mask_ev"] phase = 6 @@ -1886,7 +1881,7 @@ depends = [] ["features/exposure/logic.py::plane_taps"] phase = 6 -status = "todo" +status = "ported" calls = ["numba"] depends = [] @@ -1898,19 +1893,13 @@ depends = [] ["features/exposure/logic.py::quadratic_core"] phase = 6 -status = "todo" -calls = ["numba"] -depends = [] - -["features/exposure/logic.py::separation_damping_gain"] -phase = 6 -status = "todo" +status = "ported" calls = ["numba"] depends = [] ["features/exposure/logic.py::tone_key_weight"] phase = 6 -status = "todo" +status = "ported" calls = ["numba"] depends = [] @@ -1992,10 +1981,16 @@ status = "todo" calls = [] depends = ["_solve_density", "_solve_grade_and_density", "_solve_with_knee", "predicted_zone"] -["features/exposure/print_lut.py::tetra_lookup"] +["features/exposure/print_lut.py::lut_black_luminance"] phase = 6 status = "todo" -calls = ["numba"] +calls = [] +depends = ["tetra_lookup"] + +["features/exposure/print_lut.py::tetra_lookup"] +phase = 6 +status = "ported" +calls = ["negpy_core.tetra_lookup"] depends = [] ["features/exposure/processor.py::NormalizationProcessor.process"] @@ -2174,13 +2169,13 @@ depends = [] ["features/toning/logic.py::_apply_chemical_toning_jit"] phase = 6 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.apply_chemical_toning"] depends = [] ["features/toning/logic.py::apply_chemical_toning"] phase = 6 -status = "todo" +status = "ported" calls = [] depends = ["_apply_chemical_toning_jit"] @@ -2196,24 +2191,6 @@ status = "todo" calls = [] depends = [] -["features/transparency/logic.py::apply_transfer_curve"] -phase = 6 -status = "todo" -calls = [] -depends = ["shape"] - -["features/transparency/logic.py::apply_transfer_curve.shape"] -phase = 6 -status = "todo" -calls = [] -depends = [] - -["features/transparency/processor.py::TransferProcessor.process"] -phase = 6 -status = "todo" -calls = [] -depends = [] - ["features/transparency/processor.py::TransparencyBaseProcessor.process"] phase = 6 status = "todo" diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 7331a8f55..ddc4a8123 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -13,10 +13,12 @@ pub mod mathf; pub mod median; pub mod meter; pub mod morph; +pub mod print_curve; pub mod resize; pub mod retouch; pub mod shapes; pub mod stats; +pub mod toning; /// `f(dst[i], src[i])` for every i, in parallel blocks: a rayon task per element costs more than /// the work. Pass a `move` closure: a capture read through a reference may alias `dst`, so the @@ -39,6 +41,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::resize::register(m)?; py::filter::register(m)?; py::morph::register(m)?; + py::print_curve::register(m)?; py::gray::register(m)?; py::contours::register(m)?; py::components::register(m)?; @@ -48,5 +51,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::retouch::register(m)?; py::bounds::register(m)?; py::color::register(m)?; + py::toning::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index dda46763e..c9343c597 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -113,6 +113,48 @@ pub fn exp_f64(t: f64) -> f64 { y * f64::from_bits((k + (1023.0 + 4_503_599_627_370_496.0)).to_bits() << 52) } +/// ln(1 + u) for u > -1, accurate for small u: ln(w) * u / (w - 1) with w = 1 + u cancels the +/// rounding of w (Goldberg). +#[inline(always)] +pub fn log1p_f64(u: f64) -> f64 { + let w = 1.0 + u; + let l = ln_f64(w) * (u / (w - 1.0)); + if w == 1.0 { u } else { l } +} + +/// e^x - 1, accurate for small x: (w - 1) * x / ln(w) with w = e^x (Kahan). +#[inline(always)] +pub fn expm1_f64(x: f64) -> f64 { + let w = exp_f64(x); + let m = (w - 1.0) * (x / ln_f64(w)); + if w == 1.0 { + x + } else if x < -30.0 { + -1.0 + } else { + m + } +} + +/// tanh(t) = expm1(2t) / (expm1(2t) + 2), with |t| capped where it is +-1 in f64. +#[inline(always)] +pub fn tanh_f64(t: f64) -> f64 { + let e = expm1_f64(2.0 * t.clamp(-20.0, 20.0)); + e / (e + 2.0) +} + +/// log10 of a positive normal f64. +#[inline(always)] +pub fn log10_f64(x: f64) -> f64 { + ln_f64(x) * std::f64::consts::LOG10_E +} + +/// 10^x. +#[inline(always)] +pub fn exp10_f64(x: f64) -> f64 { + exp_f64(x * std::f64::consts::LN_10) +} + /// x^y for x >= 0 (or NaN) and y > 0: e^(y ln x), 0 at x = 0. The relative error grows with /// |y ln x|, so the result is for rounding to f32, where it is almost always correct. #[inline(always)] @@ -218,6 +260,38 @@ mod pow_tests { } } +#[cfg(test)] +mod f64_tests { + use super::*; + + fn rel(a: f64, b: f64) -> f64 { + if a == b { 0.0 } else { ((a - b) / b).abs() } + } + + #[test] + fn float64_helpers_match_libm() { + let mut worst = [0f64; 5]; + let mut x = -12.0f64; + while x < 12.0 { + worst[0] = worst[0].max(rel( + log1p_f64(x.exp() * 0.999), + libm::log1p(x.exp() * 0.999), + )); + worst[1] = worst[1].max(rel(expm1_f64(x * 0.05), libm::expm1(x * 0.05))); + worst[2] = worst[2].max(rel(tanh_f64(x * 0.3), libm::tanh(x * 0.3))); + worst[3] = worst[3].max(rel(log10_f64(x.exp()), libm::log10(x.exp()))); + worst[4] = worst[4].max(rel(exp10_f64(x * 0.25), libm::exp10(x * 0.25))); + x += 0.000_37; + } + for (i, w) in worst.iter().enumerate() { + assert!(*w < 1e-14, "{i}: {w:e}"); + } + assert_eq!(log1p_f64(1e-300), 1e-300); + assert_eq!(expm1_f64(1e-300), 1e-300); + assert_eq!(tanh_f64(1e5), 1.0); + } +} + #[cfg(test)] mod exhaustive { /// `cargo test --release -- --ignored`. diff --git a/negpy_core/src/print_curve.rs b/negpy_core/src/print_curve.rs new file mode 100644 index 000000000..0d94ab1e0 --- /dev/null +++ b/negpy_core/src/print_curve.rs @@ -0,0 +1,483 @@ +//! The print curve of `apply_characteristic_curve` (negpy/features/exposure/logic.py): a straight +//! line of slope `slope` through the exposure pivot, bounded above by the toe (shadows to paper +//! black d_max) and below by the shoulder (highlights to paper white), two independent softplus +//! bounds. Output is linear reflectance, 10^-D; the working-space OETF comes at the engine output. +//! The float64 order follows the Numba kernel it replaced, which fastmath regrouped, so the float32 +//! output can differ from it by an ULP (negpy_core/LEDGER.md). + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::{exp_f64, exp10_f64, log1p_f64, log10_f64, pow_f64, tanh_f64}; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; + +/// An HxW (or HxWxK) float32 plane. +#[derive(Clone, Copy)] +pub struct Plane<'a> { + pub data: &'a [f32], + pub w: usize, +} + +/// Tone-limited masks: K shape alphas per pixel, (stops, ISO-R delta, e0, e1) per mask, and the +/// unlimited masks' ISO-R plane. +#[derive(Clone, Copy)] +pub struct Keys<'a> { + pub alpha: &'a [f32], + pub params: &'a [[f64; 4]], + pub deltas: Plane<'a>, +} + +#[derive(Clone, Copy)] +pub struct Diffusion<'a> { + pub plane: &'a [f32], + pub gh: usize, + pub gw: usize, + pub rect: [f64; 4], + pub mix: f64, + pub range: [f64; 3], +} + +#[derive(Clone, Copy)] +pub struct Lut<'a> { + pub table: &'a [f32], + pub n: usize, + pub e_max: [f32; 3], + pub black: f64, +} + +/// Every input but the image. Toe and shoulder are per channel (value plus layer trim), scaled +/// by toe_shoulder_strength. `d_min_rgb` is paper white per channel; `dye_mix` couples the dye +/// densities above it. `ev` is dodge and burn in print stops times `ev_scale` (positive burns), +/// `grade` a slope factor per pixel. `keys` are tone-limited masks whose ISO-R grade adds to +/// their deltas about `r0` and replaces `grade`. `flash` is preflash as a fraction of the +/// threshold exposure. `bpc` maps paper black (or the LUT's `black`) to display black. `lut` +/// replaces 10^-D on the density above base. +#[derive(Clone, Copy)] +pub struct PrintCurve<'a> { + pub pivots: [f32; 3], + pub slopes: [f32; 3], + pub curvatures: [f32; 3], + pub toe: [f64; 3], + pub shoulder: [f64; 3], + pub toe_width: [f64; 3], + pub shoulder_width: [f64; 3], + pub cmy_offsets: [f32; 3], + pub shadow_cmy: [f32; 3], + pub highlight_cmy: [f32; 3], + pub d_min_rgb: [f64; 3], + pub d_max: f64, + pub a_toe_base: f64, + pub a_sh_base: f64, + pub width_ref: f64, + pub toe_height: f64, + pub sh_height: f64, + pub zone_center: f64, + pub shadow_density: f64, + pub highlight_density: f64, + pub shadow_grade: [f64; 3], + pub highlight_grade: [f64; 3], + pub zone_sh_center: f64, + pub zone_hi_center: f64, + pub zone_k: f64, + pub v_star: f64, + pub midtone_gamma: [f64; 3], + pub gamma_width: f64, + pub dye_mix: Option<[[f64; 3]; 3]>, + /// Per-channel k, damping, reference spread. + pub sep: Option<([f64; 3], f64, f64)>, + pub ev: Option>, + pub ev_scale: [f32; 3], + pub grade: Option>, + pub keys: Option>, + pub r0: f64, + pub r_min: f64, + pub r_max: f64, + pub flash: f64, + pub flash_v: f64, + pub flash_gamma: f64, + pub flash_map: Option>, + pub diffusion: Option>, + pub lut: Option>, + pub bpc: bool, + pub black_hold: f64, +} + +/// The per-channel bounds the kernel's prologue derives. +#[derive(Clone, Copy)] +struct Bounds { + a_hl: [f64; 3], + a_sh: [f64; 3], + d_min_eff: [f64; 3], + d_max_eff: [f64; 3], + bpc_black: [f64; 3], + use_split: bool, +} + +fn bounds(p: &PrintCurve) -> Bounds { + let eps = 1e-6; + let mut b = Bounds { + a_hl: [0.0; 3], + a_sh: [0.0; 3], + d_min_eff: [0.0; 3], + d_max_eff: [0.0; 3], + bpc_black: [0.0; 3], + use_split: p + .shadow_grade + .iter() + .chain(&p.highlight_grade) + .any(|&g| g != 0.0), + }; + for ch in 0..3 { + b.a_hl[ch] = p.a_sh_base * p.width_ref / p.shoulder_width[ch].max(eps); + let a_sh_w = p.a_toe_base * p.width_ref / p.toe_width[ch].max(eps); + let t = p.toe[ch]; + // A negative toe tightens the shadow roll-off instead of moving d_max_eff past paper black. + let d_max_base = if t >= 0.0 { + b.a_sh[ch] = a_sh_w; + p.d_max - t * p.toe_height + } else { + b.a_sh[ch] = a_sh_w * (1.0 - t * 4.0); + p.d_max + }; + let dmn = (p.d_min_rgb[ch] + p.shoulder[ch] * p.sh_height).max(0.0); + b.d_min_eff[ch] = dmn; + b.d_max_eff[ch] = if d_max_base < dmn + 0.1 { + dmn + 0.1 + } else { + d_max_base + }; + // BPC references the physical d_max, so toe lifts survive; a negative toe raises the clip. + let db = if t < 0.0 { + p.d_max + t * p.toe_height + } else { + p.d_max + }; + b.bpc_black[ch] = exp10_f64(-db); + } + b +} + +/// `_fast_sigmoid`: 1 / (1 + e^-x) above 0, e^x / (1 + e^x) below, both from e^-|x| so the +/// loop has no branch. +#[inline(always)] +fn sigmoid(x: f64) -> f64 { + let z = exp_f64(-x.abs()); + let d = 1.0 + z; + if x >= 0.0 { 1.0 / d } else { z / d } +} + +/// `_softplus`: x + log1p(e^-x) above 0, log1p(e^x) below: max(x, 0) + log1p(e^-|x|). +#[inline(always)] +fn softplus(x: f64) -> f64 { + let l = log1p_f64(exp_f64(-x.abs())); + if x > 0.0 { x + l } else { l } +} + +#[inline(always)] +fn smoothstep01(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// `quadratic_core`: k (u - pivot) + curv u^2, held at its vertex where it would fold back. +#[inline(always)] +fn quadratic_core(k: f64, pivot: f64, curv: f64, mut u: f64) -> f64 { + if curv != 0.0 { + let vertex = -k / (2.0 * curv); + if (curv > 0.0 && u < vertex) || (curv < 0.0 && u > vertex) { + u = vertex; + } + } + k * (u - pivot) + curv * u * u +} + +/// `separation_damping_gain`. +#[inline(always)] +fn separation_gain(k: f64, damping: f64, chroma: f64, reference: f64) -> f64 { + if k <= 0.0 { + return 0.0; + } + let h = (reference - chroma) / (reference + chroma); + pow_f64(k, (1.0 - damping) + damping * h).min(3.0) +} + +/// `plane_taps`: OpenCV's half-pixel bilinear taps on a gh x gw plane covering `rect`. +#[inline(always)] +fn plane_taps( + gh: usize, + gw: usize, + x: usize, + y: usize, + r: [f64; 4], +) -> (usize, usize, usize, usize, f64, f64) { + let px = (x as f64 + 0.5 - r[0]) * gw as f64 / r[2] - 0.5; + let py = (y as f64 + 0.5 - r[1]) * gh as f64 / r[3] - 0.5; + let lx = px.floor().clamp(0.0, gw as f64 - 1.0); + let ly = py.floor().clamp(0.0, gh as f64 - 1.0); + let hx = (lx + 1.0).min(gw as f64 - 1.0); + let hy = (ly + 1.0).min(gh as f64 - 1.0); + let fx = (px - lx).clamp(0.0, 1.0); + let fy = (py - ly).clamp(0.0, 1.0); + (lx as usize, hx as usize, ly as usize, hy as usize, fx, fy) +} + +/// `tetra_lookup`: tetrahedral interpolation clamped to the table; corner differences in float32 +/// as Numba computes them on the float32 table. +#[inline(always)] +pub fn tetra(l: &Lut, e: [f64; 3]) -> [f64; 3] { + let n = l.n - 1; + let pos: [f64; 3] = + std::array::from_fn(|c| (e[c] / l.e_max[c] as f64).clamp(0.0, 1.0) * n as f64); + let idx: [usize; 3] = std::array::from_fn(|c| (pos[c] as usize).min(n - 1)); + let [fx, fy, fz]: [f64; 3] = std::array::from_fn(|c| pos[c] - idx[c] as f64); + let at = |di: usize, dj: usize, dk: usize, c: usize| { + l.table[(((idx[0] + di) * l.n + idx[1] + dj) * l.n + idx[2] + dk) * 3 + c] + }; + std::array::from_fn(|c| { + let c000 = at(0, 0, 0, c); + let c111 = at(1, 1, 1, c); + let d = |a: f32, b: f32| (b - a) as f64; + if fx >= fy { + if fy >= fz { + let (c100, c110) = (at(1, 0, 0, c), at(1, 1, 0, c)); + c000 as f64 + fx * d(c000, c100) + fy * d(c100, c110) + fz * d(c110, c111) + } else if fx >= fz { + let (c100, c101) = (at(1, 0, 0, c), at(1, 0, 1, c)); + c000 as f64 + fx * d(c000, c100) + fz * d(c100, c101) + fy * d(c101, c111) + } else { + let (c001, c101) = (at(0, 0, 1, c), at(1, 0, 1, c)); + c000 as f64 + fz * d(c000, c001) + fx * d(c001, c101) + fy * d(c101, c111) + } + } else if fz >= fy { + let (c001, c011) = (at(0, 0, 1, c), at(0, 1, 1, c)); + c000 as f64 + fz * d(c000, c001) + fy * d(c001, c011) + fx * d(c011, c111) + } else if fz >= fx { + let (c010, c011) = (at(0, 1, 0, c), at(0, 1, 1, c)); + c000 as f64 + fy * d(c000, c010) + fz * d(c010, c011) + fx * d(c011, c111) + } else { + let (c010, c110) = (at(0, 1, 0, c), at(1, 1, 0, c)); + c000 as f64 + fy * d(c000, c010) + fx * d(c010, c110) + fz * d(c110, c111) + } + }) +} + +/// The print curve over an HxWx3 float32 image. +pub fn apply_print_curve(img: &[f32], w: usize, p: &PrintCurve, dst: &mut [f32]) { + let b = bounds(p); + dst.par_chunks_mut(w * 3) + .zip(img.par_chunks(w * 3)) + .enumerate() + .for_each(|(y, (d, s))| { + for (i, (d, s)) in d.chunks_mut(3 * RUN).zip(s.chunks(3 * RUN)).enumerate() { + run(s, d, (y, i * RUN, w), p, &b); + } + }); +} + +/// Pixels per pass: each step of the curve runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn run( + src: &[f32], + dst: &mut [f32], + (y, x0, w): (usize, usize, usize), + p: &PrintCurve, + b: &Bounds, +) { + let px = src.as_chunks::<3>().0; + let n = px.len(); + let at = |plane: &Plane, i: usize| plane.data[y * plane.w + x0 + i]; + let mut gfac = [1f64; RUN]; + let mut ev_px = [0f64; RUN]; + if let Some(k) = &p.keys { + let m = k.params.len(); + for i in 0..n { + if let Some(ev) = &p.ev { + ev_px[i] = at(ev, i) as f64; + } + // The unburned tone: no mask can move the pixels it selects. + let q = px[i]; + let lum = LUMA_R * q[0] as f64 + LUMA_G * q[1] as f64 + LUMA_B * q[2] as f64; + let mut dr = at(&k.deltas, i) as f64; + for (j, kp) in k.params.iter().enumerate() { + let a = k.alpha[(y * w + x0 + i) * m + j] as f64 + * smoothstep01((lum - kp[2]) / (kp[3] - kp[2])); + ev_px[i] += kp[0] * a; + dr += kp[1] * a; + } + gfac[i] = p.r0 / (p.r0 + dr).clamp(p.r_min, p.r_max); + } + } else if let Some(g) = &p.grade { + for i in 0..n { + gfac[i] = at(g, i) as f64; + } + } + let mut fl = [p.flash; RUN]; + if let Some(m) = &p.flash_map { + for i in 0..n { + fl[i] += at(m, i) as f64; + } + } + let any_flash = fl[..n].iter().any(|&f| f > 0.0); + let mut dens = [[0f64; RUN]; 3]; + for ch in 0..3 { + let v = &mut dens[ch]; + for i in 0..n { + v[i] = px[i][ch] as f64; + } + if let Some(d) = &p.diffusion { + // Diffusion mixes light, not density: (1-d) I + d blur(I), I = 10^(val r). + let r = d.range[ch]; + for i in 0..n { + let (i0x, i1x, i0y, i1y, fx, fy) = plane_taps(d.gh, d.gw, x0 + i, y, d.rect); + let tap = |yy: usize, xx: usize| d.plane[(yy * d.gw + xx) * 3 + ch] as f64; + let top = tap(i0y, i0x) * (1.0 - fx) + tap(i0y, i1x) * fx; + let bot = tap(i1y, i0x) * (1.0 - fx) + tap(i1y, i1x) * fx; + let blur = top * (1.0 - fy) + bot * fy; + v[i] = log10_f64((1.0 - d.mix) * exp10_f64(v[i] * r) + d.mix * exp10_f64(blur * r)) + / r; + } + } + let offset = p.cmy_offsets[ch] as f64; + let scale = p.ev_scale[ch]; + if p.keys.is_some() { + for i in 0..n { + v[i] = v[i] + offset + (ev_px[i] * scale as f64) as f32 as f64; + } + } else if let Some(ev) = &p.ev { + for i in 0..n { + v[i] = v[i] + offset + (at(ev, i) * scale) as f64; + } + } else { + for x in v[..n].iter_mut() { + *x += offset; + } + } + let (slope, pivot, curv) = ( + p.slopes[ch] as f64, + p.pivots[ch] as f64, + p.curvatures[ch] as f64, + ); + for i in 0..n { + v[i] = quadratic_core(slope * gfac[i], pivot, curv, v[i]); + } + if any_flash { + for i in 0..n { + let lifted = p.flash_v + + p.flash_gamma + * log10_f64(exp10_f64((v[i] - p.flash_v) / p.flash_gamma) + fl[i]); + v[i] = if fl[i] > 0.0 { lifted } else { v[i] }; + } + } + let mg = p.midtone_gamma[ch]; + if mg != 0.0 { + for x in v[..n].iter_mut() { + *x += mg * p.gamma_width * tanh_f64((*x - p.v_star) / p.gamma_width); + } + } + let (s_cmy, h_cmy) = (p.shadow_cmy[ch] as f64, p.highlight_cmy[ch] as f64); + for x in v[..n].iter_mut() { + let w_sh = sigmoid(3.0 * (*x - p.zone_center)); + *x += s_cmy * w_sh + h_cmy * (1.0 - w_sh); + } + if b.use_split { + let (sg, hg) = (p.shadow_grade[ch], p.highlight_grade[ch]); + for x in v[..n].iter_mut() { + let w_gsh = sigmoid(p.zone_k * (*x - p.zone_sh_center)); + let w_ghi = 1.0 - sigmoid(p.zone_k * (*x - p.zone_hi_center)); + *x = *x + + sg * w_gsh * (*x - p.zone_sh_center) + + hg * w_ghi * (*x - p.zone_hi_center); + } + } + if p.shadow_density != 0.0 || p.highlight_density != 0.0 { + let hold = p.black_hold > 0.0 && p.shadow_density < 0.0; + let dmx = b.d_max_eff[ch]; + for x in v[..n].iter_mut() { + let mut w_zsh = sigmoid(p.zone_k * (*x - p.zone_sh_center)); + if hold { + w_zsh *= smoothstep01((dmx - *x) / p.black_hold); + } + let w_zhi = 1.0 - sigmoid(p.zone_k * (*x - p.zone_hi_center)); + *x += p.shadow_density * w_zsh + p.highlight_density * w_zhi; + } + } + let (dmn, dmx, a_hl, a_sh) = (b.d_min_eff[ch], b.d_max_eff[ch], b.a_hl[ch], b.a_sh[ch]); + for x in v[..n].iter_mut() { + let v1 = dmn + softplus(a_hl * (*x - dmn)) / a_hl; + *x = dmx - softplus(a_sh * (dmx - v1)) / a_sh; + } + } + let dmin = p.d_min_rgb; + if let Some(m) = &p.dye_mix { + for i in 0..n { + let e = [ + dens[0][i] - dmin[0], + dens[1][i] - dmin[1], + dens[2][i] - dmin[2], + ]; + for c in 0..3 { + dens[c][i] = dmin[c] + m[c][0] * e[0] + m[c][1] * e[1] + m[c][2] * e[2]; + } + } + } + if let Some((k3, damping, reference)) = &p.sep { + for i in 0..n { + let s = [ + dens[0][i] - dmin[0], + dens[1][i] - dmin[1], + dens[2][i] - dmin[2], + ]; + let mean = (s[0] + s[1] + s[2]) / 3.0; + let chroma = (((s[0] - s[1]).powi(2) + (s[1] - s[2]).powi(2) + (s[0] - s[2]).powi(2)) + / 3.0) + .sqrt(); + for c in 0..3 { + dens[c][i] = dmin[c] + + mean + + separation_gain(k3[c], *damping, chroma, *reference) * (s[c] - mean); + } + } + } + let out = dst.as_chunks_mut::<3>().0; + if let Some(l) = &p.lut { + let base: [f64; 3] = std::array::from_fn(|c| exp10_f64(-dmin[c])); + for i in 0..n { + let rgb = tetra( + l, + [ + dens[0][i] - dmin[0], + dens[1][i] - dmin[1], + dens[2][i] - dmin[2], + ], + ); + out[i] = std::array::from_fn(|c| { + let mut t = base[c] * rgb[c]; + if p.bpc { + t = (t - l.black) / (1.0 - l.black); + } + t.clamp(0.0, 1.0) as f32 + }); + } + } else { + for c in 0..3 { + let (black, bpc) = (b.bpc_black[c], p.bpc); + for i in 0..n { + let t = exp10_f64(-dens[c][i]); + let t = if bpc { (t - black) / (1.0 - black) } else { t }; + out[i][c] = t.clamp(0.0, 1.0) as f32; + } + } + } +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 868d99e97..d19443d33 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -9,6 +9,8 @@ pub mod gray; pub mod histogram; pub mod meter; pub mod morph; +pub mod print_curve; pub mod resize; pub mod retouch; pub mod stats; +pub mod toning; diff --git a/negpy_core/src/py/print_curve.rs b/negpy_core/src/py/print_curve.rs new file mode 100644 index 000000000..1208ef4f1 --- /dev/null +++ b/negpy_core/src/py/print_curve.rs @@ -0,0 +1,273 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::print_curve::{self, Diffusion, Keys, Lut, Plane, PrintCurve}; + +fn contiguous<'a, T: numpy::Element>(a: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays")) +} + +fn triplet(a: &PyReadonlyArray1<'_, T>) -> PyResult<[T; 3]> { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + s.try_into() + .map_err(|_| PyValueError::new_err("needs a 3-vector")) +} + +/// An HxW plane of the image's height, or an error. +fn plane<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>, h: usize, w: usize) -> PyResult> { + if a.shape().len() < 2 || a.shape()[0] != h || a.shape()[1] != w { + return Err(PyValueError::new_err( + "a per-pixel map does not match the image", + )); + } + Ok(Plane { + data: contiguous(a)?, + w, + }) +} + +/// `_apply_print_curve_kernel` of an HxWx3 float32 image; the arguments are the kernel's. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +#[pyo3(signature = ( + img, pivots, slopes, curvatures, toe, shoulder, toe_width, shoulder_width, cmy_offsets, + shadow_cmy, highlight_cmy, d_min_rgb, d_max, a_toe_base, a_sh_base, width_ref, toe_height, + sh_height, zone_center, shadow_density, highlight_density, shadow_grade, highlight_grade, + zone_sh_center, zone_hi_center, zone_k, v_star, midtone_gamma, gamma_width, dye_mix, + use_dye_mix, sep_k3, sep_damping, sep_ref, use_sep_damping, ev_map, ev_scale, use_ev, + grade_map, use_grade, key_alpha, key_params, use_key, grade_deltas, r0, r_min, r_max, flash, + flash_v, flash_gamma, flash_map, use_flash, diff_plane, diff_rect, diff_mix, diff_range, + use_diff, lut, lut_e_max, lut_black, use_lut, bpc=false, black_hold=0.0 +))] +fn apply_print_curve<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + pivots: PyReadonlyArray1<'py, f32>, + slopes: PyReadonlyArray1<'py, f32>, + curvatures: PyReadonlyArray1<'py, f32>, + toe: PyReadonlyArray1<'py, f64>, + shoulder: PyReadonlyArray1<'py, f64>, + toe_width: PyReadonlyArray1<'py, f64>, + shoulder_width: PyReadonlyArray1<'py, f64>, + cmy_offsets: PyReadonlyArray1<'py, f32>, + shadow_cmy: PyReadonlyArray1<'py, f32>, + highlight_cmy: PyReadonlyArray1<'py, f32>, + d_min_rgb: PyReadonlyArray1<'py, f64>, + d_max: f64, + a_toe_base: f64, + a_sh_base: f64, + width_ref: f64, + toe_height: f64, + sh_height: f64, + zone_center: f64, + shadow_density: f64, + highlight_density: f64, + shadow_grade: PyReadonlyArray1<'py, f64>, + highlight_grade: PyReadonlyArray1<'py, f64>, + zone_sh_center: f64, + zone_hi_center: f64, + zone_k: f64, + v_star: f64, + midtone_gamma: PyReadonlyArray1<'py, f64>, + gamma_width: f64, + dye_mix: PyReadonlyArray2<'py, f64>, + use_dye_mix: bool, + sep_k3: PyReadonlyArray1<'py, f64>, + sep_damping: f64, + sep_ref: f64, + use_sep_damping: bool, + ev_map: PyReadonlyArrayDyn<'py, f32>, + ev_scale: PyReadonlyArray1<'py, f32>, + use_ev: bool, + grade_map: PyReadonlyArrayDyn<'py, f32>, + use_grade: bool, + key_alpha: PyReadonlyArrayDyn<'py, f32>, + key_params: PyReadonlyArray2<'py, f64>, + use_key: bool, + grade_deltas: PyReadonlyArrayDyn<'py, f32>, + r0: f64, + r_min: f64, + r_max: f64, + flash: f64, + flash_v: f64, + flash_gamma: f64, + flash_map: PyReadonlyArrayDyn<'py, f32>, + use_flash: bool, + diff_plane: PyReadonlyArrayDyn<'py, f32>, + diff_rect: PyReadonlyArray1<'py, f64>, + diff_mix: f64, + diff_range: PyReadonlyArray1<'py, f64>, + use_diff: bool, + lut: PyReadonlyArrayDyn<'py, f32>, + lut_e_max: PyReadonlyArray1<'py, f32>, + lut_black: f64, + use_lut: bool, + bpc: bool, + black_hold: f64, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let src = contiguous(&img)?; + let dye = dye_mix.as_array(); + if use_dye_mix && dye.shape() != [3, 3] { + return Err(PyValueError::new_err("dye_mix must be 3x3")); + } + let keys = if use_key { + let kp = key_params.as_array(); + let n = kp.shape()[0]; + if kp.shape()[1] != 4 || key_alpha.shape() != [h, w, n] { + return Err(PyValueError::new_err( + "key_alpha must be HxWxK with K x 4 key_params", + )); + } + Some(( + contiguous(&key_alpha)?, + (0..n) + .map(|i| std::array::from_fn(|j| kp[[i, j]])) + .collect::>(), + plane(&grade_deltas, h, w)?, + )) + } else { + None + }; + let diffusion = if use_diff { + let [gh, gw, 3] = diff_plane.shape()[..] else { + return Err(PyValueError::new_err("diff_plane must be gh x gw x 3")); + }; + Some(Diffusion { + plane: contiguous(&diff_plane)?, + gh, + gw, + rect: triplet_n(&diff_rect)?, + mix: diff_mix, + range: triplet(&diff_range)?, + }) + } else { + None + }; + let lut = if use_lut { + let [n, n1, n2, 3] = lut.shape()[..] else { + return Err(PyValueError::new_err("lut must be N x N x N x 3")); + }; + if n != n1 || n != n2 || n < 2 { + return Err(PyValueError::new_err("lut must be N x N x N x 3")); + } + Some(Lut { + table: contiguous(&lut)?, + n, + e_max: triplet(&lut_e_max)?, + black: lut_black, + }) + } else { + None + }; + let p = PrintCurve { + pivots: triplet(&pivots)?, + slopes: triplet(&slopes)?, + curvatures: triplet(&curvatures)?, + toe: triplet(&toe)?, + shoulder: triplet(&shoulder)?, + toe_width: triplet(&toe_width)?, + shoulder_width: triplet(&shoulder_width)?, + cmy_offsets: triplet(&cmy_offsets)?, + shadow_cmy: triplet(&shadow_cmy)?, + highlight_cmy: triplet(&highlight_cmy)?, + d_min_rgb: triplet(&d_min_rgb)?, + d_max, + a_toe_base, + a_sh_base, + width_ref, + toe_height, + sh_height, + zone_center, + shadow_density, + highlight_density, + shadow_grade: triplet(&shadow_grade)?, + highlight_grade: triplet(&highlight_grade)?, + zone_sh_center, + zone_hi_center, + zone_k, + v_star, + midtone_gamma: triplet(&midtone_gamma)?, + gamma_width, + dye_mix: use_dye_mix.then(|| std::array::from_fn(|r| std::array::from_fn(|c| dye[[r, c]]))), + sep: use_sep_damping + .then(|| triplet(&sep_k3).map(|k| (k, sep_damping, sep_ref))) + .transpose()?, + ev: use_ev.then(|| plane(&ev_map, h, w)).transpose()?, + ev_scale: triplet(&ev_scale)?, + grade: (use_grade && !use_key) + .then(|| plane(&grade_map, h, w)) + .transpose()?, + keys: keys.as_ref().map(|(alpha, params, deltas)| Keys { + alpha, + params, + deltas: *deltas, + }), + r0, + r_min, + r_max, + flash, + flash_v, + flash_gamma, + flash_map: use_flash.then(|| plane(&flash_map, h, w)).transpose()?, + diffusion, + lut, + bpc, + black_hold, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| print_curve::apply_print_curve(src, w, &p, dst)); + } + Ok(out) +} + +fn triplet_n(a: &PyReadonlyArray1<'_, f64>) -> PyResult<[f64; 4]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))? + .try_into() + .map_err(|_| PyValueError::new_err("diff_rect must hold 4 values")) +} + +/// `tetra_lookup(table, e_max, e0, e1, e2)` of an N x N x N x 3 float32 table. +#[pyfunction] +fn tetra_lookup( + table: PyReadonlyArrayDyn<'_, f32>, + e_max: PyReadonlyArray1<'_, f32>, + e0: f64, + e1: f64, + e2: f64, +) -> PyResult<(f64, f64, f64)> { + let [n, n1, n2, 3] = table.shape()[..] else { + return Err(PyValueError::new_err("the table must be N x N x N x 3")); + }; + if n != n1 || n != n2 || n < 2 { + return Err(PyValueError::new_err("the table must be N x N x N x 3")); + } + let l = Lut { + table: contiguous(&table)?, + n, + e_max: triplet(&e_max)?, + black: 0.0, + }; + let [r, g, b] = print_curve::tetra(&l, [e0, e1, e2]); + Ok((r, g, b)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_print_curve, m)?)?; + m.add_function(wrap_pyfunction!(tetra_lookup, m)?) +} diff --git a/negpy_core/src/py/toning.rs b/negpy_core/src/py/toning.rs new file mode 100644 index 000000000..e0d60e2c6 --- /dev/null +++ b/negpy_core/src/py/toning.rs @@ -0,0 +1,89 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::toning::{self, Bath, Toning}; + +/// A float32 gain triplet, widened to float64 as the kernel's arithmetic reads it. +fn gain(a: &PyReadonlyArray1<'_, f32>) -> PyResult<[f64; 3]> { + let g: [f32; 3] = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))? + .try_into() + .map_err(|_| PyValueError::new_err("a gain needs 3 values"))?; + Ok(g.map(f64::from)) +} + +/// `_apply_chemical_toning_jit` of an HxWx3 float32 image; the arguments are the kernel's. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn apply_chemical_toning<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + sel_strength: f64, + sep_strength: f64, + gold_strength: f64, + sel_d_ref: f64, + sel_power: f64, + sel_gain: PyReadonlyArray1<'py, f32>, + sep_d_bleach: f64, + sep_power: f64, + sep_gain: PyReadonlyArray1<'py, f32>, + gold_d_ref: f64, + gold_power: f64, + gold_gain: PyReadonlyArray1<'py, f32>, + gold_sepia_gain: PyReadonlyArray1<'py, f32>, + blue_strength: f64, + blue_d_ref: f64, + blue_power: f64, + blue_gain: PyReadonlyArray1<'py, f32>, + copper_strength: f64, + copper_d_ref: f64, + copper_power: f64, + copper_gain: PyReadonlyArray1<'py, f32>, + van_strength: f64, + van_d_ref: f64, + van_power: f64, + van_gain: PyReadonlyArray1<'py, f32>, + gold_flat: f64, +) -> PyResult>> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let bath = |strength, d_ref, power, g: &PyReadonlyArray1<'py, f32>| -> PyResult { + Ok(Bath { + strength, + d_ref, + power, + gain: gain(g)?, + }) + }; + let t = Toning { + selenium: bath(sel_strength, sel_d_ref, sel_power, &sel_gain)?, + sepia: bath(sep_strength, sep_d_bleach, sep_power, &sep_gain)?, + gold: bath(gold_strength, gold_d_ref, gold_power, &gold_gain)?, + gold_sepia_gain: gain(&gold_sepia_gain)?, + blue: bath(blue_strength, blue_d_ref, blue_power, &blue_gain)?, + copper: bath(copper_strength, copper_d_ref, copper_power, &copper_gain)?, + vanadium: bath(van_strength, van_d_ref, van_power, &van_gain)?, + gold_flat: gold_flat > 0.5, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| toning::apply_chemical_toning(src, dst, &t)); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_chemical_toning, m)?) +} diff --git a/negpy_core/src/toning.rs b/negpy_core/src/toning.rs new file mode 100644 index 000000000..d1751aaf8 --- /dev/null +++ b/negpy_core/src/toning.rs @@ -0,0 +1,127 @@ +//! `_apply_chemical_toning_jit` of negpy/features/toning/logic.py: silver-ledger toning on +//! linear reflectance, in float64 as Numba types it. + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::{exp10_f64, log10_f64, pow_f64}; + +/// One bath: strength, reference density, exponent, per-channel covering-power gain. +#[derive(Clone, Copy)] +pub struct Bath { + pub strength: f64, + pub d_ref: f64, + pub power: f64, + pub gain: [f64; 3], +} + +#[derive(Clone, Copy)] +pub struct Toning { + pub selenium: Bath, + pub sepia: Bath, + pub gold: Bath, + pub gold_sepia_gain: [f64; 3], + pub blue: Bath, + pub copper: Bath, + pub vanadium: Bath, + /// Gold converts all densities alike (a lith print) instead of highlights first. + pub gold_flat: bool, +} + +impl Bath { + /// Conversion per pixel of `d0`, capped at 1: strength (D / d_ref)^power when `rising`, + /// else strength (1 - D / d_ref)^power. All zero while the bath is off. + fn conversion(&self, d0: &[f64], out: &mut [f64], rising: bool) { + if self.strength <= 0.0 { + out.fill(0.0); + return; + } + for (c, &d) in out.iter_mut().zip(d0) { + let frac = (d / self.d_ref).min(1.0); + let x = if rising { frac } else { 1.0 - frac }; + *c = (self.strength * pow_f64(x, self.power)).min(1.0); + } + } +} + +pub fn apply_chemical_toning(img: &[f32], dst: &mut [f32], t: &Toning) { + dst.par_chunks_mut(3 * RUN) + .zip(img.par_chunks(3 * RUN)) + .for_each(|(d, s)| tone_run(s, d, t)); +} + +/// Pixels per pass: each bath's conversion runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn tone_run(src: &[f32], dst: &mut [f32], t: &Toning) { + let px = src.as_chunks::<3>().0; + let n = px.len(); + let mut d3 = [[0f64; RUN]; 3]; + for (ch, d) in d3.iter_mut().enumerate() { + for (d, p) in d.iter_mut().zip(px) { + *d = -log10_f64((p[ch] as f64).clamp(1e-6, 1.0)); + } + } + let mut d0 = [0f64; RUN]; + for (i, m) in d0[..n].iter_mut().enumerate() { + *m = (d3[0][i] + d3[1][i] + d3[2][i]) / 3.0; + } + let d0 = &d0[..n]; + let mut c = [[0f64; RUN]; 6]; + t.selenium.conversion(d0, &mut c[0][..n], true); + t.sepia.conversion(d0, &mut c[1][..n], false); + if t.gold_flat { + c[2][..n].fill(t.gold.strength.clamp(0.0, 1.0)); + } else { + t.gold.conversion(d0, &mut c[2][..n], false); + } + t.blue.conversion(d0, &mut c[3][..n], true); + t.copper.conversion(d0, &mut c[4][..n], true); + t.vanadium.conversion(d0, &mut c[5][..n], false); + // The ledger leaves each channel's covering-power factor k. + let mut k = [[0f64; RUN]; 3]; + for i in 0..n { + let [c_sel, c_sep, c_au, c_blue, c_cu, c_van] = std::array::from_fn(|j| c[j][i]); + // One silver reservoir: each bath claims a share of what is left; gold also plates + // the sulfide. + let mut a = 1.0; + let f_sel = a * c_sel; + a -= f_sel; + let mut f_sep = a * c_sep; + a -= f_sep; + let f_au = a * c_au; + a -= f_au; + let f_ausp = f_sep * c_au; + f_sep -= f_ausp; + let f_blue = a * c_blue; + a -= f_blue; + let f_cu = a * c_cu; + a -= f_cu; + let f_van = a * c_van; + a -= f_van; + for ch in 0..3 { + k[ch][i] = a + + f_sel * t.selenium.gain[ch] + + f_sep * t.sepia.gain[ch] + + f_au * t.gold.gain[ch] + + f_ausp * t.sepia.gain[ch] * t.gold_sepia_gain[ch] + + f_blue * t.blue.gain[ch] + + f_cu * t.copper.gain[ch] + + f_van * t.vanadium.gain[ch]; + } + } + let out = dst.as_chunks_mut::<3>().0; + for ch in 0..3 { + for i in 0..n { + out[i][ch] = exp10_f64(-(d3[ch][i] * k[ch][i])).clamp(0.0, 1.0) as f32; + } + } +} diff --git a/scripts/replay.py b/scripts/replay.py index ecd93f7cb..4c5fdb1ab 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -107,7 +107,10 @@ def _wrap(self, key: str, fn): def wrapper(*args, **kwargs): if not self.active or self.per_frame.get(key, 0) >= CALLS_PER_FRAME or self.saved[key] >= CALLS_PER_FUNCTION: return fn(*args, **kwargs) - inputs = copy.deepcopy((args, kwargs)) + try: + inputs = copy.deepcopy((args, kwargs)) + except (TypeError, AttributeError): + return fn(*args, **kwargs) # a lock or a device handle in the arguments: not replayable result = fn(*args, **kwargs) self.per_frame[key] = self.per_frame.get(key, 0) + 1 self.active = False @@ -121,7 +124,7 @@ def wrapper(*args, **kwargs): (folder / f"{self.saved[key]:04d}.pkl").write_bytes(pickle.dumps(record, protocol=5)) self.saved[key] += 1 except (pickle.PicklingError, TypeError, AttributeError): - pass # a GPU texture or a lock in the arguments: not replayable + pass # a result that does not pickle: not replayable finally: self.active = True return result diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 1e11d7be2..3ddb964f5 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -4,6 +4,9 @@ import numpy as np import pytest +from negpy.features.exposure.logic import apply_characteristic_curve +from negpy.features.toning.logic import apply_chemical_toning + # Recorded on Linux x86-64, where the cv2 oracles pass. Every release platform must give these # bytes; CI runs this file on each one. A port adds its case here; a hash only changes with a # decided change of output. @@ -43,6 +46,10 @@ "gamut_aware_chroma_scale": lambda: negpy_core.gamut_aware_chroma_scale( _LAB, np.float32(1.8), _M.T.copy(), np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787), 10 ), + "print_curve": lambda: apply_characteristic_curve( + _IMG3 * 0.8 - 0.1, (0.45, 2.1), (0.5, 2.0), (0.55, 1.9), toe=0.3, shoulder=0.2, midtone_gamma=0.2, shadow_density=-0.1, bpc=True + ), + "chemical_toning": lambda: apply_chemical_toning(_IMG3, 0.3, 0.4, 0.3, 0.2, 0.3, 0.4), } _HASHES = { @@ -66,6 +73,8 @@ "skin_chroma_rein": "6def89b9f456f16abf406c351745bbb791f15bc69e5b03f2e478ed7391839b01", "matmul_3x3": "d80c524d23c1bc6b7d9592b638a5fb84d4b51691efac9b71b40fbf529e67c019", "gamut_aware_chroma_scale": "ef1a9474817c0bca19e3c847a4667ba47ba75829722a1c7939a77e135d8b7da4", + "print_curve": "c06516de4316987b8cb37dcf977915373302d36b0bd6c1018e3e4bfba0929b09", + "chemical_toning": "7ddd3949c04bd5af38177bfcd6576888092bf73ad173206c34b153d6cce5d648", } diff --git a/tests/test_numba_nogil.py b/tests/test_numba_nogil.py index 646bb809c..40bed3354 100644 --- a/tests/test_numba_nogil.py +++ b/tests/test_numba_nogil.py @@ -3,7 +3,6 @@ import pytest _KERNELS = [ - ("negpy.features.exposure.logic", "_apply_print_curve_kernel"), ("negpy.infrastructure.display.icc_lut", "_apply_lut_u16_jit"), ("negpy.infrastructure.display.icc_lut", "_apply_lut_f32_jit"), ("negpy.infrastructure.display.icc_lut", "_matrix_trc_u8_jit"), diff --git a/tests/test_parallel_dispatch.py b/tests/test_parallel_dispatch.py index 73a82ef21..b30c3aa14 100644 --- a/tests/test_parallel_dispatch.py +++ b/tests/test_parallel_dispatch.py @@ -29,13 +29,11 @@ def _double(arr): def test_serial_and_parallel_variants_never_share_a_cache_file(): # Numba's disk cache keys on qualname and source line, so the serial variant takes its own # qualname: a shared one could load the parallel binary and defeat the failsafe. - from negpy.features.exposure.logic import _apply_print_curve_kernel from negpy.infrastructure.display.icc_lut import _apply_lut_f32_jit - for kernel in (_apply_print_curve_kernel, _apply_lut_f32_jit): - serial, parallel = kernel.serial._cache, kernel.parallel._cache - assert type(serial).__name__ == type(parallel).__name__ == "FunctionCache" - assert serial._cache_file._index_name != parallel._cache_file._index_name + serial, parallel = _apply_lut_f32_jit.serial._cache, _apply_lut_f32_jit.parallel._cache + assert type(serial).__name__ == type(parallel).__name__ == "FunctionCache" + assert serial._cache_file._index_name != parallel._cache_file._index_name assert type(_double.serial._cache).__name__ == "NullCache" # no cache=True, no disk cache From fd8d49b0c9a16807725c70ac8454b8186d263264 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 08:36:15 +0200 Subject: [PATCH 061/133] perf(core): sensor unmix kernels in Rust; restore tone_key_weight_np _unmix_kernel, _log_unmix_kernel, _two_scale_layer_kernel and _two_scale_kernel call Rust, in Numba's types (float32 arrays, float64 where a Python float scalar joins in) and its FMA order; the density unmix adds the offset first, as Numba's build does. The recordings carry no matrix, so each kernel was checked against the old Numba code on recorded frames with a sample matrix: three are bit-identical, the log unmix differs on 0.2 % of values by up to 16 ULP (glibc logf/expf against correctly rounded ones, amplified by the log-domain matrix; ledger). One thread: 0.32x to 0.55x. The print curve commit deleted tone_key_weight_np with the kernel helpers; the canvas overlay imports it. apply_sensor_correction makes its input contiguous: the Rust kernels take no strided views. --- negpy/features/exposure/logic.py | 7 + negpy/features/process/sensor.py | 62 ++------- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 16 +-- negpy_core/src/lib.rs | 2 + negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/sensor.rs | 143 +++++++++++++++++++++ negpy_core/src/sensor.rs | 211 +++++++++++++++++++++++++++++++ tests/test_core_same_bits.py | 12 ++ 9 files changed, 393 insertions(+), 62 deletions(-) create mode 100644 negpy_core/src/py/sensor.rs create mode 100644 negpy_core/src/sensor.rs diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index dd6250d19..666c276ca 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -71,6 +71,13 @@ def separation_damping_gain_np(k: float, damping: float, chroma: Any, ref_spread return np.minimum(kf, np.float32(3.0)) +def tone_key_weight_np(lum: Any, e0: float, e1: float) -> Any: + """A tone-limited mask's weight at luma `lum`, over a whole raster: a smoothstep from 0 at e0 + to 1 at e1 (see placement.key_edges). The print curve in negpy_core and exposure.wgsl mirror it.""" + t = np.clip((np.asarray(lum, dtype=np.float32) - np.float32(e0)) / np.float32(e1 - e0), 0.0, 1.0) + return t * t * (np.float32(3.0) - np.float32(2.0) * t) + + class CharacteristicCurve: """ Asymmetric H&D print curve (toe-linear-shoulder) in density space — the NumPy diff --git a/negpy/features/process/sensor.py b/negpy/features/process/sensor.py index a1670eab2..81020901d 100644 --- a/negpy/features/process/sensor.py +++ b/negpy/features/process/sensor.py @@ -7,13 +7,12 @@ from typing import Optional import cv2 +import negpy_core import numpy as np -from numba import prange from negpy.domain.types import ImageBuffer from negpy.features.exposure.normalization import get_analysis_crop from negpy.features.process.models import ProcessConfig, ProcessMode, SensorUnmix -from negpy.kernel.system.parallel import parallel_njit _EPS = 1e-4 # Three captures whose normalized responses are this ill-conditioned are one light three times. @@ -86,7 +85,7 @@ def apply_sensor_correction(img: ImageBuffer, matrix: Optional[tuple], mode: str if matrix is None: return img m = np.asarray(matrix, dtype=np.float32).reshape(3, 3) - x = img[:, :, :3].astype(np.float32, copy=False) + x = np.ascontiguousarray(img[:, :, :3], dtype=np.float32) mode = SensorUnmix(mode) if mode in (SensorUnmix.DENSITY, SensorUnmix.TWO_SCALE): out = _density_unmix(x, m) if mode == SensorUnmix.DENSITY else _two_scale_unmix(x, m) @@ -97,31 +96,16 @@ def apply_sensor_correction(img: ImageBuffer, matrix: Optional[tuple], mode: str return out -@parallel_njit(cache=True, fastmath=True) def _unmix_kernel(x: np.ndarray, m: np.ndarray, out: np.ndarray) -> None: """The unmix, clipped at zero.""" - h, w = x.shape[0], x.shape[1] - for i in prange(h): - for j in range(w): - x0, x1, x2 = x[i, j, 0], x[i, j, 1], x[i, j, 2] - for c in range(3): - out[i, j, c] = max(m[c, 0] * x0 + m[c, 1] * x1 + m[c, 2] * x2, 0.0) + negpy_core.sensor_unmix(x, m, out) -@parallel_njit(cache=True, fastmath=True) def _log_unmix_kernel(x: np.ndarray, c: np.ndarray, offset: np.ndarray, floor: np.ndarray, out: np.ndarray) -> None: """exp(c @ ln x + offset) per pixel, with each channel held at its ``floor`` or above.""" - h, w = x.shape[0], x.shape[1] - for i in prange(h): - for j in range(w): - l0 = np.log(max(x[i, j, 0], floor[0])) - l1 = np.log(max(x[i, j, 1], floor[1])) - l2 = np.log(max(x[i, j, 2], floor[2])) - for k in range(3): - out[i, j, k] = np.exp(c[k, 0] * l0 + c[k, 1] * l1 + c[k, 2] * l2 + offset[k]) + negpy_core.sensor_log_unmix(x, c, offset, floor, out) -@parallel_njit(cache=True, fastmath=True) def _two_scale_layer_kernel( blurred: np.ndarray, m: np.ndarray, @@ -136,22 +120,9 @@ def _two_scale_layer_kernel( """Soft-floored unmix of the blurred capture, and the blend weight: a smoothstep on the largest ratio of a channel's unmix gain to its gain at the film base (``own``). The gain is the unfloored unmix's, so a channel the floor holds up still reads as amplified.""" - h, w = blurred.shape[0], blurred.shape[1] - for i in prange(h): - for j in range(w): - x0, x1, x2 = blurred[i, j, 0], blurred[i, j, 1], blurred[i, j, 2] - ratio = 0.0 - for c in range(3): - u = m[c, 0] * x0 + m[c, 1] * x1 + m[c, 2] * x2 - ratio = max(ratio, blurred[i, j, c] * own[c] / max(u, log_floor)) - f = floor * m[c, c] * blurred[i, j, c] - d = u - f - color[i, j, c] = max(0.5 * (u + f + np.sqrt(d * d + f * f)), log_floor) - t = min(max((ratio - start) / (full - start), 0.0), 1.0) - weight[i, j] = t * t * (3.0 - 2.0 * t) - - -@parallel_njit(cache=True, fastmath=True) + negpy_core.sensor_two_scale_layer(blurred, m, own, floor, log_floor, start, full, color, weight) + + def _two_scale_kernel( x: np.ndarray, blurred: np.ndarray, @@ -165,24 +136,7 @@ def _two_scale_kernel( ) -> None: """Linear, blended by ``weight`` toward ``color`` carrying the detail x / blurred through c. A channel whose detail sits ``log_defect`` below both others takes their mean instead.""" - h, w = x.shape[0], x.shape[1] - for i in prange(h): - for j in range(w): - x0, x1, x2 = x[i, j, 0], x[i, j, 1], x[i, j, 2] - wt = weight[i, j] - if wt > 0.0: - e0 = np.log(max(x0, floor) / max(blurred[i, j, 0], floor)) - e1 = np.log(max(x1, floor) / max(blurred[i, j, 1], floor)) - e2 = np.log(max(x2, floor) / max(blurred[i, j, 2], floor)) - d0 = 0.5 * (e1 + e2) if e0 < log_defect + min(e1, e2) else e0 - d1 = 0.5 * (e0 + e2) if e1 < log_defect + min(e0, e2) else e1 - d2 = 0.5 * (e0 + e1) if e2 < log_defect + min(e0, e1) else e2 - for k in range(3): - lin = max(m[k, 0] * x0 + m[k, 1] * x1 + m[k, 2] * x2, 0.0) - if wt > 0.0: - capped = color[i, j, k] * np.exp(c[k, 0] * d0 + c[k, 1] * d1 + c[k, 2] * d2) - lin += wt * (capped - lin) - out[i, j, k] = lin + negpy_core.sensor_two_scale(x, blurred, color, weight, m, c, floor, log_defect, out) def density_unmix_matrix(matrix, base) -> np.ndarray: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 8b76ee6a5..f0c693a09 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -19,3 +19,4 @@ tolerance listed for each entry. | `kernel/image/logic.py::_skin_chroma_rein_kernel` (and `skin_chroma_rein`) | `atan2f` and `exp` are `mathf`'s, not glibc's; Numba's own serial and parallel builds of the kernel already differ in the last bit. | ulp=1 | | `features/exposure/analysis.py::vectorscope_histogram` | atan2 and log1p are `mathf`'s, not SVML's; a sample on a bin edge can change bin (none did in 39 recorded frames). | abs=2 | | `features/exposure/logic.py::apply_characteristic_curve` (the print curve) | `exp`, `log1p`, `tanh` and 10^x are `mathf`'s, not glibc's; 2 of 205 M recorded values round the other way. Every feature branch (masks, flash, diffusion, dye mix, separation damping, the print LUTs) matched the Numba kernel bit for bit on test frames. | ulp=1 | +| `features/process/sensor.py::_log_unmix_kernel` (density unmix) | `logf` and `expf` are `mathf`'s (correctly rounded) instead of glibc's; the log-domain matrix and the exp amplify a one-ULP log to up to 16 ULP on 0.2 % of values. The other three unmix kernels match Numba bit for bit. | ulp=16 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 452655e23..db2a4f8f6 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1358,20 +1358,20 @@ depends = ["_log_unmix_kernel", "film_base"] ["features/process/sensor.py::_log_unmix_kernel"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.sensor_log_unmix"] depends = [] ["features/process/sensor.py::_two_scale_kernel"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.sensor_two_scale"] depends = [] ["features/process/sensor.py::_two_scale_layer_kernel"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.sensor_two_scale_layer"] depends = [] ["features/process/sensor.py::_two_scale_unmix"] @@ -1382,8 +1382,8 @@ depends = ["_two_scale_kernel", "_two_scale_layer_kernel", "film_base"] ["features/process/sensor.py::_unmix_kernel"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.sensor_unmix"] depends = [] ["features/process/sensor.py::apply_sensor_correction"] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index ddc4a8123..de71d97f8 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -16,6 +16,7 @@ pub mod morph; pub mod print_curve; pub mod resize; pub mod retouch; +pub mod sensor; pub mod shapes; pub mod stats; pub mod toning; @@ -52,5 +53,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::bounds::register(m)?; py::color::register(m)?; py::toning::register(m)?; + py::sensor::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index d19443d33..e28bf71e6 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -12,5 +12,6 @@ pub mod morph; pub mod print_curve; pub mod resize; pub mod retouch; +pub mod sensor; pub mod stats; pub mod toning; diff --git a/negpy_core/src/py/sensor.rs b/negpy_core/src/py/sensor.rs new file mode 100644 index 000000000..539b28bae --- /dev/null +++ b/negpy_core/src/py/sensor.rs @@ -0,0 +1,143 @@ +use numpy::{ + PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, PyReadwriteArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::sensor::{self, Layer, Matrix}; + +fn data<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays")) +} + +fn matrix(m: &PyReadonlyArray2<'_, f32>) -> PyResult { + let m = m.as_array(); + if m.shape() != [3, 3] { + return Err(PyValueError::new_err("needs a 3x3 matrix")); + } + Ok(std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]]))) +} + +fn triplet(a: &PyReadonlyArray1<'_, f32>) -> PyResult<[f32; 3]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))? + .try_into() + .map_err(|_| PyValueError::new_err("needs a 3-vector")) +} + +/// The writable data of `out`, which must have `shape`. +fn target<'a>( + out: &'a mut PyReadwriteArrayDyn<'_, f32>, + shape: &[usize], +) -> PyResult<&'a mut [f32]> { + if out.shape() != shape { + return Err(PyValueError::new_err("out does not match the input")); + } + out.as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out")) +} + +fn image(x: &PyReadonlyArrayDyn<'_, f32>) -> PyResult<()> { + if x.shape().len() != 3 || x.shape()[2] != 3 { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + Ok(()) +} + +/// `_unmix_kernel(x, m, out)`. +#[pyfunction] +fn sensor_unmix( + py: Python<'_>, + x: PyReadonlyArrayDyn<'_, f32>, + m: PyReadonlyArray2<'_, f32>, + mut out: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&x)?; + let (src, m) = (data(&x)?, matrix(&m)?); + let dst = target(&mut out, x.shape())?; + py.detach(|| sensor::unmix(src, dst, m)); + Ok(()) +} + +/// `_log_unmix_kernel(x, c, offset, floor, out)`. +#[pyfunction] +fn sensor_log_unmix( + py: Python<'_>, + x: PyReadonlyArrayDyn<'_, f32>, + c: PyReadonlyArray2<'_, f32>, + offset: PyReadonlyArray1<'_, f32>, + floor: PyReadonlyArray1<'_, f32>, + mut out: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&x)?; + let (src, c, offset, floor) = (data(&x)?, matrix(&c)?, triplet(&offset)?, triplet(&floor)?); + let dst = target(&mut out, x.shape())?; + py.detach(|| sensor::log_unmix(src, dst, c, offset, floor)); + Ok(()) +} + +/// `_two_scale_layer_kernel(blurred, m, own, floor, log_floor, start, full, color, weight)`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn sensor_two_scale_layer( + py: Python<'_>, + blurred: PyReadonlyArrayDyn<'_, f32>, + m: PyReadonlyArray2<'_, f32>, + own: PyReadonlyArray1<'_, f32>, + floor: f64, + log_floor: f64, + start: f64, + full: f64, + mut color: PyReadwriteArrayDyn<'_, f32>, + mut weight: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&blurred)?; + let (src, m, own) = (data(&blurred)?, matrix(&m)?, triplet(&own)?); + let shape = blurred.shape().to_vec(); + let c = target(&mut color, &shape)?; + let w = target(&mut weight, &shape[..2])?; + let s = Layer { + floor, + log_floor, + start, + full, + }; + py.detach(|| sensor::two_scale_layer(src, m, own, s, c, w)); + Ok(()) +} + +/// `_two_scale_kernel(x, blurred, color, weight, m, c, floor, log_defect, out)`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn sensor_two_scale( + py: Python<'_>, + x: PyReadonlyArrayDyn<'_, f32>, + blurred: PyReadonlyArrayDyn<'_, f32>, + color: PyReadonlyArrayDyn<'_, f32>, + weight: PyReadonlyArrayDyn<'_, f32>, + m: PyReadonlyArray2<'_, f32>, + c: PyReadonlyArray2<'_, f32>, + floor: f64, + log_defect: f64, + mut out: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&x)?; + let shape = x.shape().to_vec(); + if blurred.shape() != shape || color.shape() != shape || weight.shape() != &shape[..2] { + return Err(PyValueError::new_err("the layers do not match the image")); + } + let (xs, b, col, w) = (data(&x)?, data(&blurred)?, data(&color)?, data(&weight)?); + let (m, c) = (matrix(&m)?, matrix(&c)?); + let dst = target(&mut out, &shape)?; + py.detach(|| sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst)); + Ok(()) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(sensor_unmix, m)?)?; + m.add_function(wrap_pyfunction!(sensor_log_unmix, m)?)?; + m.add_function(wrap_pyfunction!(sensor_two_scale_layer, m)?)?; + m.add_function(wrap_pyfunction!(sensor_two_scale, m)?) +} diff --git a/negpy_core/src/sensor.rs b/negpy_core/src/sensor.rs new file mode 100644 index 000000000..872194961 --- /dev/null +++ b/negpy_core/src/sensor.rs @@ -0,0 +1,211 @@ +//! The sensor unmix kernels of negpy/features/process/sensor.py, in the types Numba gives them: +//! float32 arrays, and float64 where a Python float scalar joins the arithmetic. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::{exp_f64, ln_f64}; + +pub type Matrix = [[f32; 3]; 3]; + +/// One row of the 3x3 product in float32, contracted as Numba does. +#[inline(always)] +fn row(m: [f32; 3], x: [f32; 3]) -> f32 { + x[2].mul_add(m[2], x[1].mul_add(m[1], m[0] * x[0])) +} + +/// `_unmix_kernel`: the unmix, clipped at zero. +pub fn unmix(src: &[f32], dst: &mut [f32], m: Matrix) { + dst.par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(d, s)| unmix_block(s, d, m)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn unmix_block(src: &[f32], dst: &mut [f32], m: Matrix) { + for (d, &x) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + *d = m.map(|r| { + let v = row(r, x); + if v < 0.0 { 0.0 } else { v } + }); + } +} + +/// `_log_unmix_kernel`: exp(c ln x + offset) per pixel, each channel held at its floor or above. +pub fn log_unmix(src: &[f32], dst: &mut [f32], c: Matrix, offset: [f32; 3], floor: [f32; 3]) { + dst.par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(d, s)| log_unmix_block(s, d, c, offset, floor)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn log_unmix_block(src: &[f32], dst: &mut [f32], c: Matrix, offset: [f32; 3], floor: [f32; 3]) { + for (d, x) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + let l: [f32; 3] = std::array::from_fn(|k| { + let v = if floor[k] > x[k] { floor[k] } else { x[k] }; + ln_f64(v as f64) as f32 + }); + // Numba adds the offset first. + *d = std::array::from_fn(|k| { + let a = l[2].mul_add( + c[k][2], + l[1].mul_add(c[k][1], l[0].mul_add(c[k][0], offset[k])), + ); + exp_f64(a as f64) as f32 + }); + } +} + +/// The scalars of the two-scale layer pass, Python floats (float64) in the kernel. +#[derive(Clone, Copy)] +pub struct Layer { + pub floor: f64, + pub log_floor: f64, + pub start: f64, + pub full: f64, +} + +/// `_two_scale_layer_kernel`: the soft-floored unmix of the blurred capture into `color`, and +/// the blend weight, a smoothstep on the largest unmix gain over its gain at the film base. +pub fn two_scale_layer( + blurred: &[f32], + m: Matrix, + own: [f32; 3], + s: Layer, + color: &mut [f32], + weight: &mut [f32], +) { + color + .par_chunks_mut(3 * 4096) + .zip(weight.par_chunks_mut(4096)) + .zip(blurred.par_chunks(3 * 4096)) + .for_each(|((c, w), b)| layer_block(b, m, own, s, c, w)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn layer_block( + blurred: &[f32], + m: Matrix, + own: [f32; 3], + s: Layer, + color: &mut [f32], + weight: &mut [f32], +) { + for ((c, w), x) in color + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(weight.iter_mut()) + .zip(blurred.as_chunks::<3>().0) + { + let mut ratio = 0.0f64; + for k in 0..3 { + let u = row(m[k], *x); + let gain = (x[k] * own[k]) as f64 / (u as f64).max(s.log_floor); + ratio = if gain > ratio { gain } else { ratio }; + let f = s.floor * m[k][k] as f64 * x[k] as f64; + let d = u as f64 - f; + c[k] = (0.5 * (u as f64 + f + (d * d + f * f).sqrt())).max(s.log_floor) as f32; + } + let t = ((ratio - s.start) / (s.full - s.start)).clamp(0.0, 1.0); + *w = (t * t * (3.0 - 2.0 * t)) as f32; + } +} + +/// `_two_scale_kernel`: linear, blended by `weight` toward `color` carrying the detail +/// x / blurred through `c`; a channel whose detail sits `log_defect` below both others takes +/// their mean. +#[allow(clippy::too_many_arguments)] +pub fn two_scale( + x: &[f32], + blurred: &[f32], + color: &[f32], + weight: &[f32], + m: Matrix, + c: Matrix, + (floor, log_defect): (f64, f64), + out: &mut [f32], +) { + out.par_chunks_mut(3 * 4096) + .zip(x.par_chunks(3 * 4096)) + .zip(blurred.par_chunks(3 * 4096)) + .zip(color.par_chunks(3 * 4096)) + .zip(weight.par_chunks(4096)) + .for_each(|((((o, x), b), col), w)| { + two_scale_block(x, b, col, w, m, c, (floor, log_defect), o) + }); +} + +#[allow(clippy::too_many_arguments)] +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn two_scale_block( + x: &[f32], + blurred: &[f32], + color: &[f32], + weight: &[f32], + m: Matrix, + c: Matrix, + (floor, log_defect): (f64, f64), + out: &mut [f32], +) { + let (x, b, col) = ( + x.as_chunks::<3>().0, + blurred.as_chunks::<3>().0, + color.as_chunks::<3>().0, + ); + for (i, o) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let wt = weight[i]; + let mut detail = [0f64; 3]; + if wt > 0.0 { + let e: [f64; 3] = std::array::from_fn(|k| { + ln_f64((x[i][k] as f64).max(floor) / (b[i][k] as f64).max(floor)) + }); + let pick = |k: usize, p: usize, q: usize| { + if e[k] < log_defect + e[p].min(e[q]) { + 0.5 * (e[p] + e[q]) + } else { + e[k] + } + }; + detail = [pick(0, 1, 2), pick(1, 0, 2), pick(2, 0, 1)]; + } + *o = std::array::from_fn(|k| { + let v = row(m[k], x[i]); + let mut lin = if v < 0.0 { 0.0 } else { v } as f64; + if wt > 0.0 { + let gain = c[k][0] as f64 * detail[0] + + c[k][1] as f64 * detail[1] + + c[k][2] as f64 * detail[2]; + let capped = col[i][k] as f64 * exp_f64(gain); + lin += wt as f64 * (capped - lin); + } + lin as f32 + }); + } +} diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 3ddb964f5..89d6d52b9 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -15,6 +15,14 @@ _IMG1 = (_RNG.random((83, 61), dtype=np.float32) - 0.4) * 5 _GRAY = negpy_core.bgr_to_gray((_IMG3 * 255).astype(np.uint8)) _M = np.array([[0.58, 0.19, 0.19], [0.30, 0.63, 0.08], [0.03, 0.07, 0.99]], np.float32) + + +def _filled(fill): + out = np.empty_like(_IMG3) + fill(out) + return out + + _LAB = (_IMG3 * np.array([100.0, 160.0, 160.0], np.float32) - np.array([0.0, 80.0, 80.0], np.float32)).astype(np.float32) _CASES = { @@ -50,6 +58,9 @@ _IMG3 * 0.8 - 0.1, (0.45, 2.1), (0.5, 2.0), (0.55, 1.9), toe=0.3, shoulder=0.2, midtone_gamma=0.2, shadow_density=-0.1, bpc=True ), "chemical_toning": lambda: apply_chemical_toning(_IMG3, 0.3, 0.4, 0.3, 0.2, 0.3, 0.4), + "sensor_log_unmix": lambda: _filled( + lambda out: negpy_core.sensor_log_unmix(_IMG3, _M, np.array([0.01, -0.02, 0.03], np.float32), np.full(3, 1e-3, np.float32), out) + ), } _HASHES = { @@ -75,6 +86,7 @@ "gamut_aware_chroma_scale": "ef1a9474817c0bca19e3c847a4667ba47ba75829722a1c7939a77e135d8b7da4", "print_curve": "c06516de4316987b8cb37dcf977915373302d36b0bd6c1018e3e4bfba0929b09", "chemical_toning": "7ddd3949c04bd5af38177bfcd6576888092bf73ad173206c34b153d6cce5d648", + "sensor_log_unmix": "f7eb8efe1bbcfe884fd2e849e99d0546fac2f4829d3fb558d54a0128e681e53b", } From 33d815d38f81e8fb41dfe87838c8d6299273700b Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 08:44:37 +0200 Subject: [PATCH 062/133] perf(core): HDR merge kernels and hair encode in Rust _count_clipped_rows, _accumulate and _hair_encode call Rust. The HDR accumulate takes Numba's float32 order: the roll-off span is the Python-float difference rounded to float32 once, and the sum is fma(v * (1/scale), w, num). The clip count compares at the smallest float32 at or above the threshold, which equals Numba's float64 compare. No HDR recordings; against the old Numba kernels on recorded frames (three-frame brackets) all are bit-identical (52 M values), and _hair_encode replays 14/14. One thread: accumulate 0.52x, clip count 0.38x, hair encode 0.75x. --- negpy/features/hdr/logic.py | 36 ++------------ negpy/features/retouch/logic.py | 12 +---- negpy_core/manifest.toml | 12 ++--- negpy_core/src/hdr.rs | 83 +++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/hdr.rs | 64 +++++++++++++++++++++++++ negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/retouch.rs | 32 +++++++++++++ negpy_core/src/retouch.rs | 44 +++++++++++++++++ 9 files changed, 237 insertions(+), 49 deletions(-) create mode 100644 negpy_core/src/hdr.rs create mode 100644 negpy_core/src/py/hdr.rs diff --git a/negpy/features/hdr/logic.py b/negpy/features/hdr/logic.py index 1691155e1..87492e254 100644 --- a/negpy/features/hdr/logic.py +++ b/negpy/features/hdr/logic.py @@ -19,12 +19,11 @@ from typing import Callable, List, Optional, Sequence, Tuple import cv2 +import negpy_core import numpy as np -from numba import prange # type: ignore from negpy.features.hdr.models import ANCHOR_EV_UNSET, HdrConfig from negpy.features.rgbscan.logic import estimate_shift -from negpy.kernel.system.parallel import parallel_njit #: A sample at or above this is saturated and carries no information. SATURATION = 0.995 @@ -70,20 +69,9 @@ def subsample(frame: np.ndarray) -> np.ndarray: return frame[::step, ::step] if step > 1 else frame -@parallel_njit(cache=True, fastmath=True) def _count_clipped_rows(values: np.ndarray, out: np.ndarray) -> None: - """Saturated-pixel count per row. Per row rather than one total: numba's parfor pass - cannot analyse a reduction whose addend is itself computed by a loop.""" - height, width, channels = values.shape - for y in prange(height): - n = 0 - for x in range(width): - hit = 0 - for c in range(channels): - if values[y, x, c] >= SATURATION: - hit = 1 - n += hit - out[y] = n + """Saturated-pixel count per row.""" + negpy_core.count_clipped_rows(np.ascontiguousarray(values, dtype=np.float32), SATURATION, out) def clipped_fraction(values: np.ndarray) -> float: @@ -314,7 +302,6 @@ def seed_shadow_density(ratios: Sequence[float], anchor: Optional[float] = None) return -min(SEED_SHADOW_PER_STOP * stops, SEED_SHADOW_LIMIT) -@parallel_njit(cache=True, fastmath=True) def _accumulate(num: np.ndarray, den: np.ndarray, values: np.ndarray, ratio: float) -> None: """Add one frame to the weighted sums, in reference units. One fused pass: the numpy form cost more than the RAW decode it follows, mostly in full-size temporaries. @@ -330,22 +317,7 @@ def _accumulate(num: np.ndarray, den: np.ndarray, values: np.ndarray, ratio: flo still carry signal — and is gradual so the frame a pixel is drawn from changes smoothly. A hard switch prints as banding across a gradient. """ - inv_span = np.float32(1.0) / np.float32(SATURATION - ROLLOFF_START) - weight = np.float32(ratio) ** 2 - scale = np.float32(max(ratio, 1e-9)) - height, width, channels = values.shape - for y in prange(height): - for x in range(width): - for c in range(channels): - v = values[y, x, c] - rolloff = (np.float32(SATURATION) - v) * inv_span - if rolloff < np.float32(0.0): - rolloff = np.float32(0.0) - elif rolloff > np.float32(1.0): - rolloff = np.float32(1.0) - w = weight * rolloff - num[y, x, c] += w * (v / scale) - den[y, x, c] += w + negpy_core.hdr_accumulate(num, den, np.ascontiguousarray(values, dtype=np.float32), ratio, SATURATION, ROLLOFF_START) def merge_providers( diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index cabaaa0f8..3ffd0eae9 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -5,12 +5,10 @@ import cv2 import negpy_core import numpy as np -from numba import prange # type: ignore from negpy.domain.types import ImageBuffer from negpy.features.geometry.logic import smooth_polyline from negpy.features.retouch.models import HEAL_SIZE_REF -from negpy.kernel.system.parallel import parallel_njit from negpy.kernel.image.logic import get_luminance from negpy.kernel.image.validation import ensure_image from negpy.kernel.system.logging import get_logger @@ -1282,17 +1280,9 @@ def ir_bake_token(retouch, has_ir: bool) -> str: return f"|ir{int(retouch.ir_attenuation)}r{round(float(retouch.ir_threshold), 3)}" -@parallel_njit(cache=True) def _hair_encode(crop, lo, inv_span, out_u8): """``clip((crop - lo) / span, 0, 1) ** (1/γ)`` to 8-bit, for cv2.inpaint.""" - f32 = np.float32 - inv_gamma = f32(1.0 / _HAIR_INPAINT_GAMMA) - h, w = crop.shape[0], crop.shape[1] - for y in prange(h): - for x in range(w): - for c in range(3): - v = min(max((crop[y, x, c] - lo) * inv_span, f32(0.0)), f32(1.0)) - out_u8[y, x, c] = np.uint8(v**inv_gamma * f32(255.0) + f32(0.5)) + negpy_core.hair_encode(crop, lo, inv_span, np.float32(1.0 / _HAIR_INPAINT_GAMMA), out_u8) def apply_hair_inpaint( diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index db2a4f8f6..c6f34e7f9 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1472,8 +1472,8 @@ depends = [] ["features/retouch/logic.py::_hair_encode"] phase = 4 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.hair_encode"] depends = [] ["features/retouch/logic.py::_ir_clean_base"] @@ -1646,8 +1646,8 @@ depends = ["_scratch_ridge_rows", "_shear_rows"] ["features/hdr/logic.py::_accumulate"] phase = 5 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.hdr_accumulate"] depends = [] ["features/hdr/logic.py::_align"] @@ -1658,8 +1658,8 @@ depends = [] ["features/hdr/logic.py::_count_clipped_rows"] phase = 5 -status = "todo" -calls = ["numba"] +status = "ported" +calls = ["negpy_core.count_clipped_rows"] depends = [] ["features/hdr/logic.py::apply_render_exposure"] diff --git a/negpy_core/src/hdr.rs b/negpy_core/src/hdr.rs new file mode 100644 index 000000000..9cd78756a --- /dev/null +++ b/negpy_core/src/hdr.rs @@ -0,0 +1,83 @@ +//! The bracket-merge kernels of negpy/features/hdr/logic.py. + +use multiversion::multiversion; +use rayon::prelude::*; + +/// `_count_clipped_rows`: per row, the pixels with any channel at or above `saturation`, +/// compared in float64 as Numba compares a float32 with a Python float. +pub fn count_clipped_rows( + values: &[f32], + (w, cn): (usize, usize), + saturation: f64, + out: &mut [i64], +) { + // The smallest float32 at or above the threshold: the float32 compare is then the float64 one. + let mut limit = saturation as f32; + if (limit as f64) < saturation { + limit = f32::from_bits(limit.to_bits() + 1); + } + out.par_iter_mut() + .zip(values.par_chunks(w * cn)) + .for_each(|(n, row)| *n = count_row(row, cn, limit)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn count_row(row: &[f32], cn: usize, limit: f32) -> i64 { + if cn == 3 { + return row + .as_chunks::<3>() + .0 + .iter() + .map(|p| ((p[0] >= limit) | (p[1] >= limit) | (p[2] >= limit)) as i64) + .sum(); + } + row.chunks(cn) + .map(|p| p.iter().fold(false, |hit, &v| hit | (v >= limit)) as i64) + .sum() +} + +/// `_accumulate`: one frame added to the weighted sums in reference units. Weight is ratio^2, +/// rolled off per channel toward saturation; the order is Numba's vector loop. +pub fn accumulate( + num: &mut [f32], + den: &mut [f32], + values: &[f32], + ratio: f64, + (saturation, rolloff_start): (f64, f64), +) { + // The span is a Python float difference, rounded to float32 once. + let inv_span = 1.0 / ((saturation - rolloff_start) as f32); + let saturation = saturation as f32; + let weight = (ratio as f32) * (ratio as f32); + let inv_scale = 1.0 / (ratio.max(1e-9) as f32); + num.par_chunks_mut(1 << 14) + .zip(den.par_chunks_mut(1 << 14)) + .zip(values.par_chunks(1 << 14)) + .for_each(|((n, d), v)| { + accumulate_block(n, d, v, (saturation, inv_span, weight, inv_scale)) + }); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn accumulate_block( + num: &mut [f32], + den: &mut [f32], + values: &[f32], + (sat, inv_span, weight, inv_scale): (f32, f32, f32, f32), +) { + for ((n, d), &v) in num.iter_mut().zip(den.iter_mut()).zip(values) { + let r = (sat - v) * inv_span; + let rolloff = r.clamp(0.0, 1.0); + let w = rolloff * weight; + *n = (v * inv_scale).mul_add(w, *n); + *d += w; + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index de71d97f8..1ca43237b 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -8,6 +8,7 @@ pub mod detect; pub mod distance; pub mod filter; pub mod gray; +pub mod hdr; pub mod histogram; pub mod mathf; pub mod median; @@ -54,5 +55,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::color::register(m)?; py::toning::register(m)?; py::sensor::register(m)?; + py::hdr::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/py/hdr.rs b/negpy_core/src/py/hdr.rs new file mode 100644 index 000000000..6a1356ed5 --- /dev/null +++ b/negpy_core/src/py/hdr.rs @@ -0,0 +1,64 @@ +use numpy::{PyReadonlyArrayDyn, PyReadwriteArray1, PyReadwriteArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::hdr; + +fn contiguous<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays")) +} + +/// `_count_clipped_rows(values, out)` of an HxWxC float32 frame. +#[pyfunction] +fn count_clipped_rows( + py: Python<'_>, + values: PyReadonlyArrayDyn<'_, f32>, + saturation: f64, + mut out: PyReadwrite1<'_>, +) -> PyResult<()> { + let [h, w, cn] = values.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC frame")); + }; + if out.len() != h { + return Err(PyValueError::new_err("out needs one entry per row")); + } + let v = contiguous(&values)?; + let dst = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; + py.detach(|| hdr::count_clipped_rows(v, (w, cn), saturation, dst)); + Ok(()) +} + +type PyReadwrite1<'py> = PyReadwriteArray1<'py, i64>; + +/// `_accumulate(num, den, values, ratio)`, with the module's saturation constants. +#[pyfunction] +fn hdr_accumulate( + py: Python<'_>, + mut num: PyReadwriteArrayDyn<'_, f32>, + mut den: PyReadwriteArrayDyn<'_, f32>, + values: PyReadonlyArrayDyn<'_, f32>, + ratio: f64, + saturation: f64, + rolloff_start: f64, +) -> PyResult<()> { + if num.shape() != values.shape() || den.shape() != values.shape() { + return Err(PyValueError::new_err("the sums do not match the frame")); + } + let v = contiguous(&values)?; + let n = num + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous sums"))?; + let d = den + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous sums"))?; + py.detach(|| hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start))); + Ok(()) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(count_clipped_rows, m)?)?; + m.add_function(wrap_pyfunction!(hdr_accumulate, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index e28bf71e6..9ac20daa0 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -6,6 +6,7 @@ pub mod contours; pub mod convert; pub mod filter; pub mod gray; +pub mod hdr; pub mod histogram; pub mod meter; pub mod morph; diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 30e72cb55..2dde4d811 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -88,7 +88,39 @@ fn add_floor_deficit( Ok(()) } +/// `_hair_encode(crop, lo, inv_span, out_u8)` of an HxWxC float32 tile. +#[pyfunction] +fn hair_encode( + py: Python<'_>, + crop: numpy::PyReadonlyArrayDyn<'_, f32>, + lo: f32, + inv_span: f32, + inv_gamma: f32, + mut out: numpy::PyReadwriteArrayDyn<'_, u8>, +) -> PyResult<()> { + use numpy::PyUntypedArrayMethods; + let [_, _, cn] = crop.shape()[..] else { + return Err(pyo3::exceptions::PyValueError::new_err( + "needs an HxWxC tile", + )); + }; + if cn < 3 || out.shape() != crop.shape() { + return Err(pyo3::exceptions::PyValueError::new_err( + "needs 3 channels and a matching out", + )); + } + let src = crop + .as_slice() + .map_err(|_| pyo3::exceptions::PyValueError::new_err("needs a contiguous tile"))?; + let dst = out + .as_slice_mut() + .map_err(|_| pyo3::exceptions::PyValueError::new_err("needs a contiguous out"))?; + py.detach(|| crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst)); + Ok(()) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(hair_encode, m)?)?; m.add_function(wrap_pyfunction!(fill_rung, m)?)?; m.add_function(wrap_pyfunction!(blend_fill, m)?)?; m.add_function(wrap_pyfunction!(add_floor_deficit, m)?) diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 249783a06..6c41aa900 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -132,3 +132,47 @@ pub fn add_floor_deficit(out: &mut [f32], a: &[f32], blur_src: &[f32], blur_out: ) .for_each(|((o, av), (bs, bo))| deficit_block(o, av, bs, bo)); } + +/// `_hair_encode`: `clip((crop - lo) * inv_span, 0, 1) ** inv_gamma` to 8-bit for the first +/// three channels, in float32 (no fastmath), truncated as `np.uint8` truncates. +pub fn hair_encode( + crop: &[f32], + cn: usize, + lo: f32, + inv_span: f32, + inv_gamma: f32, + out: &mut [u8], +) { + out.par_chunks_mut(cn * 1024) + .zip(crop.par_chunks(cn * 1024)) + .for_each(|(o, c)| hair_encode_block(c, o, cn, (lo, inv_span, inv_gamma))); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn hair_encode_block( + crop: &[f32], + out: &mut [u8], + cn: usize, + (lo, inv_span, inv_gamma): (f32, f32, f32), +) { + let encode = |v: f32| { + let v = ((v - lo) * inv_span).clamp(0.0, 1.0); + let p = crate::mathf::pow_f64(v as f64, inv_gamma as f64) as f32; + (p * 255.0 + 0.5) as u8 + }; + if cn == 3 { + for (o, &v) in out.iter_mut().zip(crop) { + *o = encode(v); + } + return; + } + for (o, c) in out.chunks_mut(cn).zip(crop.chunks(cn)) { + for k in 0..3 { + o[k] = encode(c[k]); + } + } +} From 1086a6fbd69228c29f509b160ce4b61401aba97b Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 08:59:25 +0200 Subject: [PATCH 063/133] perf(core): display LUT and matrix/TRC kernels in Rust; no Numba kernels left _apply_lut_u16_jit, _apply_lut_f32_jit and _matrix_trc_u8_jit run in Rust, so no negpy module compiles a Numba kernel any more. The LUT blends run in float64 as Numba typed them and fuse as its fastmath build does (the first-level products are exact, so the FMA keeps the bits). Corner reads use indices clamped with min, which drops the bounds checks without unsafe; the LUT paths run in staged runs whose gathers vectorize, while the TRC encode keeps scalar table reads in a loop of their own (gathers were slower there). No recordings (Phase 7); against the old Numba kernels on a recorded frame all three are bit-identical (4.7 M values each). One thread: uint16 LUT 0.86x, float32 LUT 0.84x, matrix/TRC 0.78x. test_numba_nogil.py goes with its kernels; parallel_njit itself and the Multi-core CPU Rendering setting stay for now. --- negpy/infrastructure/display/icc_lut.py | 147 +------------------- negpy_core/manifest.toml | 24 +++- negpy_core/src/display.rs | 173 ++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/display.rs | 106 +++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_same_bits.py | 8 ++ tests/test_numba_nogil.py | 17 --- tests/test_parallel_dispatch.py | 11 -- 9 files changed, 316 insertions(+), 173 deletions(-) create mode 100644 negpy_core/src/display.rs create mode 100644 negpy_core/src/py/display.rs delete mode 100644 tests/test_numba_nogil.py diff --git a/negpy/infrastructure/display/icc_lut.py b/negpy/infrastructure/display/icc_lut.py index 74bb88dd3..7cb1ce18e 100644 --- a/negpy/infrastructure/display/icc_lut.py +++ b/negpy/infrastructure/display/icc_lut.py @@ -9,12 +9,11 @@ """ from typing import Any, Optional + +import negpy_core import numpy as np -from numba import prange # type: ignore from PIL import Image, ImageCms -from negpy.kernel.system.parallel import parallel_njit - DEFAULT_LUT_SIZE = 33 @@ -45,115 +44,11 @@ def build_3d_lut( return np.ascontiguousarray(lut) -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _apply_lut_u16_jit(img: np.ndarray, lut: np.ndarray) -> np.ndarray: - h = img.shape[0] - w = img.shape[1] - n = lut.shape[0] - scale = np.float32(n - 1) / np.float32(65535.0) - out = np.empty_like(img) - for y in prange(h): - for x in range(w): - rf = np.float32(img[y, x, 0]) * scale - gf = np.float32(img[y, x, 1]) * scale - bf = np.float32(img[y, x, 2]) * scale - r0 = int(rf) - g0 = int(gf) - b0 = int(bf) - if r0 >= n - 1: - r0 = n - 2 - if g0 >= n - 1: - g0 = n - 2 - if b0 >= n - 1: - b0 = n - 2 - if r0 < 0: - r0 = 0 - if g0 < 0: - g0 = 0 - if b0 < 0: - b0 = 0 - r1 = r0 + 1 - g1 = g0 + 1 - b1 = b0 + 1 - dr = rf - r0 - dg = gf - g0 - db = bf - b0 - for c in range(3): - c000 = lut[r0, g0, b0, c] - c001 = lut[r0, g0, b1, c] - c010 = lut[r0, g1, b0, c] - c011 = lut[r0, g1, b1, c] - c100 = lut[r1, g0, b0, c] - c101 = lut[r1, g0, b1, c] - c110 = lut[r1, g1, b0, c] - c111 = lut[r1, g1, b1, c] - c00 = c000 * (1.0 - db) + c001 * db - c01 = c010 * (1.0 - db) + c011 * db - c10 = c100 * (1.0 - db) + c101 * db - c11 = c110 * (1.0 - db) + c111 * db - c0 = c00 * (1.0 - dg) + c01 * dg - c1 = c10 * (1.0 - dg) + c11 * dg - v = c0 * (1.0 - dr) + c1 * dr - vi = int(v * 65535.0 + 0.5) - if vi < 0: - vi = 0 - elif vi > 65535: - vi = 65535 - out[y, x, c] = vi - return out - - def apply_lut_u16(img: np.ndarray, lut: np.ndarray) -> np.ndarray: """Trilinearly interpolate `lut` onto a uint16 RGB image.""" img_c = np.ascontiguousarray(img) lut_c = np.ascontiguousarray(lut, dtype=np.float32) - return _apply_lut_u16_jit(img_c, lut_c) - - -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _apply_lut_f32_jit(img: np.ndarray, lut: np.ndarray) -> np.ndarray: - h = img.shape[0] - w = img.shape[1] - n = lut.shape[0] - scale = np.float32(n - 1) - out = np.empty_like(img) - for y in prange(h): - for x in range(w): - rf = min(max(img[y, x, 0], np.float32(0.0)), np.float32(1.0)) * scale - gf = min(max(img[y, x, 1], np.float32(0.0)), np.float32(1.0)) * scale - bf = min(max(img[y, x, 2], np.float32(0.0)), np.float32(1.0)) * scale - r0 = int(rf) - g0 = int(gf) - b0 = int(bf) - if r0 >= n - 1: - r0 = n - 2 - if g0 >= n - 1: - g0 = n - 2 - if b0 >= n - 1: - b0 = n - 2 - r1 = r0 + 1 - g1 = g0 + 1 - b1 = b0 + 1 - dr = rf - r0 - dg = gf - g0 - db = bf - b0 - for c in range(3): - c000 = lut[r0, g0, b0, c] - c001 = lut[r0, g0, b1, c] - c010 = lut[r0, g1, b0, c] - c011 = lut[r0, g1, b1, c] - c100 = lut[r1, g0, b0, c] - c101 = lut[r1, g0, b1, c] - c110 = lut[r1, g1, b0, c] - c111 = lut[r1, g1, b1, c] - c00 = c000 * (1.0 - db) + c001 * db - c01 = c010 * (1.0 - db) + c011 * db - c10 = c100 * (1.0 - db) + c101 * db - c11 = c110 * (1.0 - db) + c111 * db - c0 = c00 * (1.0 - dg) + c01 * dg - c1 = c10 * (1.0 - dg) + c11 * dg - out[y, x, c] = c0 * (1.0 - dr) + c1 * dr - return out + return negpy_core.apply_lut_u16(img_c, lut_c) def apply_lut_f32(img: np.ndarray, lut: np.ndarray) -> np.ndarray: @@ -161,7 +56,7 @@ def apply_lut_f32(img: np.ndarray, lut: np.ndarray) -> np.ndarray: # No copy when the input is already contiguous f32, on the hot display path img_c = np.ascontiguousarray(img, dtype=np.float32) lut_c = np.ascontiguousarray(lut, dtype=np.float32) - return _apply_lut_f32_jit(img_c, lut_c) + return negpy_core.apply_lut_f32(img_c, lut_c) def apply_icc_u16_rgb( @@ -199,38 +94,6 @@ def apply_icc_u16_greyscale( return full_lut[img_u16] -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _matrix_trc_u8_jit(img: np.ndarray, dec_lut: np.ndarray, m: np.ndarray, enc_lut: np.ndarray) -> np.ndarray: - h = img.shape[0] - w = img.shape[1] - kmax = enc_lut.shape[1] - 1 - out = np.empty_like(img) - for y in prange(h): - for x in range(w): - r = dec_lut[0, img[y, x, 0]] - g = dec_lut[1, img[y, x, 1]] - b = dec_lut[2, img[y, x, 2]] - for c in range(3): - v = m[c, 0] * r + m[c, 1] * g + m[c, 2] * b - if v < 0.0: - v = 0.0 - elif v > 1.0: - v = 1.0 - f = v * kmax - i0 = int(f) - if i0 >= kmax: - i0 = kmax - 1 - d = f - i0 - e = enc_lut[c, i0] * (1.0 - d) + enc_lut[c, i0 + 1] * d - vi = int(e * 255.0 + 0.5) - if vi < 0: - vi = 0 - elif vi > 255: - vi = 255 - out[y, x, c] = vi - return out - - _ENC_LUT_SIZE = 65536 @@ -268,4 +131,4 @@ def apply_matrix_trc_u8(img_u8: np.ndarray, src_icc: bytes, dst_icc: bytes) -> O enc_lut = np.ascontiguousarray(np.stack(enc_rows).astype(np.float32)) dec_lut = np.ascontiguousarray(np.clip(dec, 0.0, 1.0).astype(np.float32)) m32 = np.ascontiguousarray(m.astype(np.float32)) - return _matrix_trc_u8_jit(np.ascontiguousarray(img_u8), dec_lut, m32, enc_lut) + return negpy_core.matrix_trc_u8(np.ascontiguousarray(img_u8), dec_lut, m32, enc_lut) diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index c6f34e7f9..7113a35aa 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2361,22 +2361,40 @@ depends = [] ["infrastructure/display/icc_lut.py::_apply_lut_f32_jit"] phase = 7 -status = "todo" +status = "ported" calls = ["numba"] depends = [] ["infrastructure/display/icc_lut.py::_apply_lut_u16_jit"] phase = 7 -status = "todo" +status = "ported" calls = ["numba"] depends = [] ["infrastructure/display/icc_lut.py::_matrix_trc_u8_jit"] phase = 7 -status = "todo" +status = "ported" calls = ["numba"] depends = [] +["infrastructure/display/icc_lut.py::apply_lut_f32"] +phase = 7 +status = "todo" +calls = ["negpy_core.apply_lut_f32"] +depends = [] + +["infrastructure/display/icc_lut.py::apply_lut_u16"] +phase = 7 +status = "todo" +calls = ["negpy_core.apply_lut_u16"] +depends = [] + +["infrastructure/display/icc_lut.py::apply_matrix_trc_u8"] +phase = 7 +status = "todo" +calls = ["negpy_core.matrix_trc_u8"] +depends = [] + ["services/assets/half_frame.py::_edge_band"] phase = 7 status = "todo" diff --git a/negpy_core/src/display.rs b/negpy_core/src/display.rs new file mode 100644 index 000000000..e86ee3fe8 --- /dev/null +++ b/negpy_core/src/display.rs @@ -0,0 +1,173 @@ +//! The display transforms of negpy/infrastructure/display/icc_lut.py: a 3D LUT applied by +//! trilinear interpolation, and the matrix/TRC transform between two profiles. Numba types the +//! fractions float64 (a float32 minus an integer), so the blends run in float64. + +// The blend steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +/// An N x N x N x 3 float32 LUT indexed [r, g, b]. +#[derive(Clone, Copy)] +pub struct Lut3<'a> { + pub table: &'a [f32], + pub n: usize, +} + +/// x (1 - d) + y d, contracted as Numba's fastmath build contracts it. +#[inline(always)] +fn lerp(x: f64, y: f64, d: f64) -> f64 { + x.mul_add(1.0 - d, y * d) +} + +/// The lattice cell and float64 fractions of three scaled float32 coordinates (0 to n - 1). +#[inline(always)] +fn cell(pos: [f32; 3], n: usize) -> ((usize, usize, usize), (f64, f64, f64)) { + let idx = pos.map(|p| (crate::convert::trunc_bits(p as f64) as usize).min(n - 2)); + let frac: [f64; 3] = std::array::from_fn(|k| pos[k] as f64 - idx[k] as f64); + ((idx[0], idx[1], idx[2]), (frac[0], frac[1], frac[2])) +} + +/// Pixels per pass: the cell lookup and each channel's blend run as flat loops over a run, so +/// the corner reads vectorize as gathers. +const RUN: usize = 256; + +/// The LUT blend of a run of scaled coordinates (0 to n - 1), per channel. +#[inline(always)] +fn blend_run(lut: &Lut3, pos: &[[f32; 3]], out: &mut [[f64; RUN]; 3]) { + let n = lut.n; + let len = pos.len(); + let mut base = [0usize; RUN]; + let mut frac = [[0f64; RUN]; 3]; + for (i, p) in pos.iter().enumerate() { + let ((r0, g0, b0), (dr, dg, db)) = cell(*p, n); + base[i] = (r0 * n + g0) * n + b0; + frac[0][i] = dr; + frac[1][i] = dg; + frac[2][i] = db; + } + let t = lut.table; + let last = t.len() - 1; + let (sg, sr) = (n, n * n); + for (c, o) in out.iter_mut().enumerate() { + for i in 0..len { + let at = |off: usize| t[((base[i] + off) * 3 + c).min(last)] as f64; + let (dr, dg, db) = (frac[0][i], frac[1][i], frac[2][i]); + let c00 = lerp(at(0), at(1), db); + let c01 = lerp(at(sg), at(sg + 1), db); + let c10 = lerp(at(sr), at(sr + 1), db); + let c11 = lerp(at(sr + sg), at(sr + sg + 1), db); + o[i] = lerp(lerp(c00, c01, dg), lerp(c10, c11, dg), dr); + } + } +} + +/// `_apply_lut_u16_jit`: a uint16 image through the LUT, rounded half up and clipped. +pub fn apply_lut_u16(img: &[u16], lut: Lut3, out: &mut [u16]) { + out.par_chunks_mut(3 * 4096) + .zip(img.par_chunks(3 * 4096)) + .for_each(|(o, i)| lut_u16_block(i, o, lut)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lut_u16_block(img: &[u16], out: &mut [u16], lut: Lut3) { + let scale = (lut.n - 1) as f32 / 65535.0; + let mut pos = [[0f32; 3]; RUN]; + let mut v = [[0f64; RUN]; 3]; + for (o, p) in out.chunks_mut(3 * RUN).zip(img.chunks(3 * RUN)) { + let (o, p) = (o.as_chunks_mut::<3>().0, p.as_chunks::<3>().0); + let pos = &mut pos[..p.len()]; + for (q, p) in pos.iter_mut().zip(p) { + *q = p.map(|x| x as f32 * scale); + } + blend_run(&lut, pos, &mut v); + for (c, v) in v.iter().enumerate() { + for (o, &x) in o.iter_mut().zip(v) { + o[c] = crate::convert::trunc_bits((x * 65535.0 + 0.5).clamp(0.0, 65535.0)) as u16; + } + } + } +} + +/// `_apply_lut_f32_jit`: a float32 image clipped to 0..1 through the LUT. +pub fn apply_lut_f32(img: &[f32], lut: Lut3, out: &mut [f32]) { + out.par_chunks_mut(3 * 4096) + .zip(img.par_chunks(3 * 4096)) + .for_each(|(o, i)| lut_f32_block(i, o, lut)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lut_f32_block(img: &[f32], out: &mut [f32], lut: Lut3) { + let scale = (lut.n - 1) as f32; + let mut pos = [[0f32; 3]; RUN]; + let mut v = [[0f64; RUN]; 3]; + for (o, p) in out.chunks_mut(3 * RUN).zip(img.chunks(3 * RUN)) { + let (o, p) = (o.as_chunks_mut::<3>().0, p.as_chunks::<3>().0); + let pos = &mut pos[..p.len()]; + for (q, p) in pos.iter_mut().zip(p) { + *q = p.map(|x| x.clamp(0.0, 1.0) * scale); + } + blend_run(&lut, pos, &mut v); + for (c, v) in v.iter().enumerate() { + for (o, &x) in o.iter_mut().zip(v) { + o[c] = x as f32; + } + } + } +} + +/// `_matrix_trc_u8_jit`: TRC decode through `dec` (3 x 256), the 3x3 `m` with a gamut clip, +/// TRC encode through `enc` (3 x K) by linear interpolation, to 8-bit rounded half up. +pub fn matrix_trc_u8(img: &[u8], dec: &[f32], m: [[f32; 3]; 3], enc: &[f32], out: &mut [u8]) { + out.par_chunks_mut(3 * 4096) + .zip(img.par_chunks(3 * 4096)) + .for_each(|(o, i)| matrix_trc_block(i, o, dec, m, enc)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn matrix_trc_block(img: &[u8], out: &mut [u8], dec: &[f32], m: [[f32; 3]; 3], enc: &[f32]) { + let k = enc.len() / 3; + let kmax = (k - 1) as f64; + let (dlast, elast) = (dec.len() - 1, enc.len() - 1); + let mut rgb = [[0f32; 3]; RUN]; + let (mut idx, mut frac) = ([0usize; RUN], [0f64; RUN]); + for (o, p) in out.chunks_mut(3 * RUN).zip(img.chunks(3 * RUN)) { + let (o, p) = (o.as_chunks_mut::<3>().0, p.as_chunks::<3>().0); + let n = p.len(); + for (q, p) in rgb.iter_mut().zip(p) { + *q = std::array::from_fn(|c| dec[(c * 256 + p[c] as usize).min(dlast)]); + } + for c in 0..3 { + let row = m[c]; + // The index arithmetic vectorizes; the table reads stay scalar, faster than gathers. + for i in 0..n { + let q = rgb[i]; + let v = q[2] + .mul_add(row[2], q[1].mul_add(row[1], row[0] * q[0])) + .clamp(0.0, 1.0); + let f = v as f64 * kmax; + let i0 = (crate::convert::trunc_bits(f) as usize).min(k - 2); + idx[i] = c * k + i0; + frac[i] = f - i0 as f64; + } + for i in 0..n { + let (lo, hi) = (enc[idx[i].min(elast)], enc[(idx[i] + 1).min(elast)]); + let e = lerp(lo as f64, hi as f64, frac[i]); + o[i][c] = crate::convert::trunc_bits((e * 255.0 + 0.5).clamp(0.0, 255.0)) as u8; + } + } + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 1ca43237b..60d50e6d1 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -5,6 +5,7 @@ pub mod components; pub mod contours; pub mod convert; pub mod detect; +pub mod display; pub mod distance; pub mod filter; pub mod gray; @@ -56,5 +57,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::toning::register(m)?; py::sensor::register(m)?; py::hdr::register(m)?; + py::display::register(m)?; py::analysis::register(m) } diff --git a/negpy_core/src/py/display.rs b/negpy_core/src/py/display.rs new file mode 100644 index 000000000..ff4cfeb67 --- /dev/null +++ b/negpy_core/src/py/display.rs @@ -0,0 +1,106 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::display::{self, Lut3}; + +fn rgb<'a, T: numpy::Element>(img: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + if img.shape().len() != 3 || img.shape()[2] != 3 { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + img.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image")) +} + +fn lut3<'a>(lut: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult> { + let [n, n1, n2, 3] = lut.shape()[..] else { + return Err(PyValueError::new_err("the LUT must be N x N x N x 3")); + }; + if n != n1 || n != n2 || n < 2 { + return Err(PyValueError::new_err("the LUT must be N x N x N x 3")); + } + Ok(Lut3 { + table: lut + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous LUT"))?, + n, + }) +} + +fn filled<'py, T: numpy::Element>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [T]) + Send, +) -> PyResult>> { + let out = PyArrayDyn::::zeros(py, shape, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + py.detach(|| fill(dst)); + } + Ok(out) +} + +/// `_apply_lut_u16_jit(img, lut)`. +#[pyfunction] +fn apply_lut_u16<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u16>, + lut: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let (src, l) = (rgb(&img)?, lut3(&lut)?); + filled(py, img.shape(), |d| display::apply_lut_u16(src, l, d)) +} + +/// `_apply_lut_f32_jit(img, lut)`. +#[pyfunction] +fn apply_lut_f32<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + lut: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let (src, l) = (rgb(&img)?, lut3(&lut)?); + filled(py, img.shape(), |d| display::apply_lut_f32(src, l, d)) +} + +/// `_matrix_trc_u8_jit(img, dec_lut, m, enc_lut)`. +#[pyfunction] +fn matrix_trc_u8<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, + dec_lut: PyReadonlyArray2<'py, f32>, + m: PyReadonlyArray2<'py, f32>, + enc_lut: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let src = rgb(&img)?; + if dec_lut.shape() != [3, 256] + || m.shape() != [3, 3] + || enc_lut.shape()[0] != 3 + || enc_lut.shape()[1] < 2 + { + return Err(PyValueError::new_err( + "needs a 3 x 256 decode, a 3x3 matrix and a 3 x K encode", + )); + } + let dec = dec_lut + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous tables"))?; + let enc = enc_lut + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous tables"))?; + let m = m.as_array(); + let m = std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])); + filled(py, img.shape(), |d| { + display::matrix_trc_u8(src, dec, m, enc, d) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_lut_u16, m)?)?; + m.add_function(wrap_pyfunction!(apply_lut_f32, m)?)?; + m.add_function(wrap_pyfunction!(matrix_trc_u8, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 9ac20daa0..8e9ecbd3c 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -4,6 +4,7 @@ pub mod color; pub mod components; pub mod contours; pub mod convert; +pub mod display; pub mod filter; pub mod gray; pub mod hdr; diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 89d6d52b9..43628a49a 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -17,6 +17,10 @@ _M = np.array([[0.58, 0.19, 0.19], [0.30, 0.63, 0.08], [0.03, 0.07, 0.99]], np.float32) +_LUT = np.ascontiguousarray(np.clip(np.cumsum(_RNG.random((9, 9, 9, 3), dtype=np.float32), axis=0) / 6, 0, 1)) +_RAMP = np.ascontiguousarray(np.tile(np.linspace(0.0, 1.0, 65536, dtype=np.float32) ** 0.45, (3, 1))) + + def _filled(fill): out = np.empty_like(_IMG3) fill(out) @@ -61,6 +65,8 @@ def _filled(fill): "sensor_log_unmix": lambda: _filled( lambda out: negpy_core.sensor_log_unmix(_IMG3, _M, np.array([0.01, -0.02, 0.03], np.float32), np.full(3, 1e-3, np.float32), out) ), + "lut_f32": lambda: negpy_core.apply_lut_f32(_IMG3, _LUT), + "matrix_trc_u8": lambda: negpy_core.matrix_trc_u8((_IMG3 * 255).astype(np.uint8), np.ascontiguousarray(_RAMP[:, ::256]), _M, _RAMP), } _HASHES = { @@ -87,6 +93,8 @@ def _filled(fill): "print_curve": "c06516de4316987b8cb37dcf977915373302d36b0bd6c1018e3e4bfba0929b09", "chemical_toning": "7ddd3949c04bd5af38177bfcd6576888092bf73ad173206c34b153d6cce5d648", "sensor_log_unmix": "f7eb8efe1bbcfe884fd2e849e99d0546fac2f4829d3fb558d54a0128e681e53b", + "lut_f32": "467bb9fd495d4797d912f6aeabd34f9a61b56491105464a0c852ed524488b1c1", + "matrix_trc_u8": "cf09517a502fcd513e1ee8deddd9f1983108d52c2d882dc7bca374096ecee708", } diff --git a/tests/test_numba_nogil.py b/tests/test_numba_nogil.py deleted file mode 100644 index 40bed3354..000000000 --- a/tests/test_numba_nogil.py +++ /dev/null @@ -1,17 +0,0 @@ -import importlib - -import pytest - -_KERNELS = [ - ("negpy.infrastructure.display.icc_lut", "_apply_lut_u16_jit"), - ("negpy.infrastructure.display.icc_lut", "_apply_lut_f32_jit"), - ("negpy.infrastructure.display.icc_lut", "_matrix_trc_u8_jit"), -] - - -@pytest.mark.parametrize("module, name", _KERNELS) -def test_full_image_kernels_release_the_gil(module, name): - kernel = getattr(importlib.import_module(module), name) - dispatchers = (kernel.serial, kernel.parallel) if hasattr(kernel, "serial") else (kernel,) - for d in dispatchers: - assert d.targetoptions.get("nogil") is True diff --git a/tests/test_parallel_dispatch.py b/tests/test_parallel_dispatch.py index b30c3aa14..52c809d15 100644 --- a/tests/test_parallel_dispatch.py +++ b/tests/test_parallel_dispatch.py @@ -26,17 +26,6 @@ def _double(arr): return out -def test_serial_and_parallel_variants_never_share_a_cache_file(): - # Numba's disk cache keys on qualname and source line, so the serial variant takes its own - # qualname: a shared one could load the parallel binary and defeat the failsafe. - from negpy.infrastructure.display.icc_lut import _apply_lut_f32_jit - - serial, parallel = _apply_lut_f32_jit.serial._cache, _apply_lut_f32_jit.parallel._cache - assert type(serial).__name__ == type(parallel).__name__ == "FunctionCache" - assert serial._cache_file._index_name != parallel._cache_file._index_name - assert type(_double.serial._cache).__name__ == "NullCache" # no cache=True, no disk cache - - def test_platform_policy(): assert default_cpu_parallel("darwin") is False assert default_cpu_parallel("win32") is True From 6b1557da75d617a4d318dba66d0a5d4d9d0e186f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 09:42:44 +0200 Subject: [PATCH 064/133] refactor: drop Numba; Multi-core CPU Rendering sets the Rust thread count No negpy module compiles a Numba kernel any more, so numba and llvmlite leave the dependencies, the lock file, build.py and flake.nix. The Preferences toggle and performance.cpu_parallel stay: every negpy_core binding releases the GIL through py::detach, which runs the work on a one-thread pool while the setting is off. It takes effect from the next call. Results do not depend on it. Removed with Numba: parallel_njit and its invocation lock, the NUMBA_THREADING_LAYER pin, the crash prompt and its shutdown markers (both existed for Numba's workqueue abort), the JIT warm-up in preview_manager, the row-block tail that kept _map_row_blocks on the parallel Numba path, and the tests of those. The setting's tooltip and guide line no longer warn of crashes; zh_CN, ja and ko updated. The macOS default stays off for now. --- CLAUDE.md | 3 +- build.py | 6 +- docs/USER_GUIDE.md | 2 +- flake.nix | 2 - negpy/desktop/main.py | 47 +--------- .../view/widgets/preferences_dialog.py | 14 +-- negpy/desktop/workers/render.py | 2 +- negpy/domain/types.py | 4 +- negpy/features/exposure/logic.py | 2 +- negpy/features/lab/logic.py | 4 - negpy/kernel/system/parallel.py | 89 +++---------------- negpy/services/rendering/preview_manager.py | 11 +-- negpy/translations/USER_GUIDE.en.md | 2 +- negpy/translations/USER_GUIDE.ja.md | 2 +- negpy/translations/USER_GUIDE.ko.md | 2 +- negpy/translations/USER_GUIDE.zh_CN.md | 2 +- negpy/translations/ja.json | 6 +- negpy/translations/ko.json | 6 +- negpy/translations/zh_CN.json | 6 +- negpy_core/manifest.toml | 6 ++ negpy_core/src/lib.rs | 1 + negpy_core/src/py/analysis.rs | 69 ++++++++------ negpy_core/src/py/bounds.rs | 4 +- negpy_core/src/py/color.rs | 2 +- negpy_core/src/py/components.rs | 14 ++- negpy_core/src/py/contours.rs | 2 +- negpy_core/src/py/convert.rs | 8 +- negpy_core/src/py/display.rs | 2 +- negpy_core/src/py/filter.rs | 2 +- negpy_core/src/py/gray.rs | 14 +-- negpy_core/src/py/hdr.rs | 6 +- negpy_core/src/py/histogram.rs | 16 ++-- negpy_core/src/py/meter.rs | 14 ++- negpy_core/src/py/mod.rs | 32 +++++++ negpy_core/src/py/morph.rs | 2 +- negpy_core/src/py/print_curve.rs | 2 +- negpy_core/src/py/retouch.rs | 12 ++- negpy_core/src/py/sensor.rs | 10 ++- negpy_core/src/py/stats.rs | 2 +- negpy_core/src/py/toning.rs | 2 +- pyproject.toml | 1 - scripts/replay.py | 3 +- tests/test_numba_cache.py | 44 --------- tests/test_parallel_dispatch.py | 51 ++++------- tests/test_parallel_dispatch_size.py | 20 ----- tests/test_parallel_gate.py | 48 +--------- tests/test_print_wedge.py | 8 -- uv.lock | 46 ---------- 48 files changed, 201 insertions(+), 454 deletions(-) delete mode 100644 tests/test_numba_cache.py delete mode 100644 tests/test_parallel_dispatch_size.py diff --git a/CLAUDE.md b/CLAUDE.md index 49a4acfa2..8b4ab2a5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ All commands run through `uv run`; never invoke pytest/ruff/ty directly. `uv run ## Architecture -NegPy is a film-negative processing desktop app (PyQt6 + WebGPU). Images flow through a multi-stage pipeline implemented twice, CPU (numpy/Numba) and GPU (WGSL via `wgpu`), which must stay in numerical parity. +NegPy is a film-negative processing desktop app (PyQt6 + WebGPU). Images flow through a multi-stage pipeline implemented twice, CPU (numpy and `negpy_core`) and GPU (WGSL via `wgpu`), which must stay in numerical parity. ### Data model @@ -66,6 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. +- `negpy_core` holds every CPU kernel; NegPy has no Numba. Bindings release the GIL through `py::detach`, which runs the work on one thread when "Multi-core CPU Rendering" is off (`kernel/system/parallel.py`). - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. diff --git a/build.py b/build.py index 2ac90f5f5..4b4e70f1e 100644 --- a/build.py +++ b/build.py @@ -43,7 +43,6 @@ def get_macos_target_arch(): "--hidden-import=rawpy", "--hidden-import=cv2", "--hidden-import=numpy", - "--hidden-import=numba", "--hidden-import=PIL", "--hidden-import=PIL.Image", "--hidden-import=PIL.ImageCms", @@ -435,9 +434,8 @@ def check_bundled_dylib_collisions(): bump that silently drops a symbol fails the build instead of shipping broken silently. - Does not check libraries PyInstaller fails to bundle at all (e.g. numba's - optional libomp.dylib) -- that is a different failure mode (absence, not - a collision) with no evidence of user impact; see LIBLCMS2_DYLIB_COLLISION.md. + Does not check libraries PyInstaller fails to bundle at all -- that is a + different failure mode (absence, not a collision); see LIBLCMS2_DYLIB_COLLISION.md. """ frameworks = os.path.join("dist", f"{APP_NAME}.app", "Contents", "Frameworks") all_files = [ diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a6ed2ac79..0e0f9c211 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -838,7 +838,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. It is off at first on macOS, where it can crash. +* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. It is off at first on macOS. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. diff --git a/flake.nix b/flake.nix index 1b2503e6f..57d859c14 100644 --- a/flake.nix +++ b/flake.nix @@ -69,8 +69,6 @@ }; in { numpy = fromNixpkgs "numpy" prev.numpy; - llvmlite = fromNixpkgs "llvmlite" prev.llvmlite; - numba = fromNixpkgs "numba" prev.numba; # nixpkgs' own "opencv-python-headless" is a metapackage that only propagates # "opencv4" (where cv2 actually lives); nixpkgsPrebuilt strips propagation, so # point straight at opencv4 or the venv ends up without a cv2 module. diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 728c54217..233be13a1 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -35,7 +35,7 @@ from negpy.kernel.system.override import apply as apply_override from negpy.kernel.system.override import apply_stored as apply_stored_override from negpy.kernel.system.override import load_or_create as load_override -from negpy.kernel.system.parallel import configure_cpu_parallel, parallel_enabled, resolve_cpu_parallel, set_parallel_enabled +from negpy.kernel.system.parallel import configure_cpu_parallel, resolve_cpu_parallel from negpy.kernel.system.paths import get_resource_path logger = get_logger(__name__) @@ -222,35 +222,6 @@ def _bootstrap_environment() -> None: GearProfiles.ensure_user_dir() -def _offer_to_disable_cpu_parallel(repo, parent) -> None: - """The previous run had multi-core kernels on and did not exit cleanly. Say so. - - Numba's workqueue threading layer terminates the process on concurrent entry, with no - Python exception to catch and nothing written to the log, so the app cannot report the - crash as it happens — only notice afterwards that the last run never finished. Without - this the setting is un-diagnosable: a user turns it on, hits an abort weeks later, and - reports a crash nobody can connect to it. - """ - from PyQt6.QtWidgets import QMessageBox - - box = QMessageBox(parent) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle(tr("NegPy Closed Unexpectedly")) - box.setText(tr("The last session ended unexpectedly with multi-core CPU rendering turned on.")) - box.setInformativeText( - tr( - "That setting is the most likely cause. Turning it off costs some speed on merges " - "and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?" - ) - ) - box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - box.setDefaultButton(QMessageBox.StandardButton.Yes) - if box.exec() == QMessageBox.StandardButton.Yes: - repo.save_global_setting("cpu_parallel", False) - set_parallel_enabled(False) - logger.warning("CPU parallel kernels disabled after an unclean shutdown") - - def main() -> None: """ Desktop entry point. @@ -260,8 +231,6 @@ def main() -> None: _install_exception_hook() # log unhandled slot exceptions to negpy.log instead of aborting try: - os.environ["NUMBA_THREADING_LAYER"] = "workqueue" - apply_override(override_cfg, APP_CONFIG) try: @@ -296,17 +265,10 @@ def main() -> None: if override_cfg.low_vram_export_tiling is None: APP_CONFIG.low_vram_export_tiling = bool(repo.get_global_setting("low_vram_export_tiling", False)) - # Multi-core Numba kernels: override.toml, then the saved setting, then the platform - # default (off on macOS). Read before the flags below are overwritten. - prev_clean_exit = bool(repo.get_global_setting("clean_shutdown", True)) - prev_run_parallel = bool(repo.get_global_setting("cpu_parallel_active", False)) + # Multi-core CPU rendering: override.toml, then the saved setting, then the platform + # default (off on macOS). stored_parallel = repo.get_global_setting("cpu_parallel", None) configure_cpu_parallel(resolve_cpu_parallel(APP_CONFIG.cpu_parallel, None if stored_parallel is None else bool(stored_parallel))) - # Numba's workqueue layer aborts the process outright on concurrent entry, with no - # exception, no dialog and nothing in the log. A marker is the only way an abort can - # later be attributed to this setting rather than reported as a mystery. - repo.save_global_setting("clean_shutdown", False) - repo.save_global_setting("cpu_parallel_active", parallel_enabled()) # Resolve flat-field gains by profile id from the on-disk store, which keeps the numpy # logic layer free of any storage dependency, then migrate any legacy DB-backed profiles @@ -358,13 +320,10 @@ def main() -> None: controller = AppController(session_manager) window = MainWindow(controller) - if prev_clean_exit is False and prev_run_parallel: - _offer_to_disable_cpu_parallel(repo, window) window.show() exit_code = app.exec() controller.cleanup() - repo.save_global_setting("clean_shutdown", True) # PyQt's at-exit cleanup walks sip's wrapper map while it deletes these, and the Python # code their destructors run (event filters, the proxy style) can reallocate the map. for widget in [w for w in app.topLevelWidgets() if w.parent() is None and sip.ispyowned(w)]: diff --git a/negpy/desktop/view/widgets/preferences_dialog.py b/negpy/desktop/view/widgets/preferences_dialog.py index 7321546cb..ad824891e 100644 --- a/negpy/desktop/view/widgets/preferences_dialog.py +++ b/negpy/desktop/view/widgets/preferences_dialog.py @@ -330,7 +330,7 @@ def hint(widget_or_text, kind: str = "muted") -> None: "mdi6.cpu-64-bit", tr("Multi-core CPU Rendering"), parallel_enabled(), - tr("Render on all CPU cores, an experimental mode that can close the app without warning."), + tr("Render on all CPU cores. When off, CPU rendering uses one core."), ) self.parallel_box.toggled.connect(self._on_parallel_changed) self._add_toggles(grid, row, self.gpu_box, self.parallel_box) @@ -512,16 +512,10 @@ def _on_semantic_search_changed(self, checked: bool) -> None: self.session.set_semantic_search_enabled(checked) def _on_parallel_changed(self, checked: bool) -> None: - """Takes effect at once: every kernel is compiled both ways and dispatched per - call, so there is nothing to recompile and no restart to wait for.""" + """Takes effect from the next negpy_core call; no restart.""" set_parallel_enabled(bool(checked)) - self.repo.save_global_settings({"cpu_parallel": bool(checked), "cpu_parallel_active": bool(checked)}) - self.controller.set_status( - tr("Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.") - if checked - else tr("Multi-core CPU rendering off"), - 5000, - ) + self.repo.save_global_setting("cpu_parallel", bool(checked)) + self.controller.set_status(tr("Multi-core CPU rendering on") if checked else tr("Multi-core CPU rendering off"), 5000) def _on_low_vram_tiling_changed(self, checked: bool) -> None: APP_CONFIG.low_vram_export_tiling = bool(checked) diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index 5b04b0551..378bd5cd9 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -1939,7 +1939,7 @@ def _emit_finished_unless_cancelled(self, generation: int, rendered_count: int) @pyqtSlot(ThumbnailRenderTask) def process(self, task: ThumbnailRenderTask) -> None: - """Render each frame sequentially: the CPU engine is already multi-core via numba, + """Render each frame sequentially: the CPU engine is already multi-core, and one frame at a time keeps the live UI responsive while the user keeps editing.""" generation = int(task.generation) with self._cancel_lock: diff --git a/negpy/domain/types.py b/negpy/domain/types.py index afa8d7d12..2a496cbc1 100644 --- a/negpy/domain/types.py +++ b/negpy/domain/types.py @@ -43,8 +43,8 @@ class AppConfig: override_toml_path: str = "" max_texture_size: int | None = None force_hq_preview: bool | None = None - # Multi-core Numba kernels on the CPU pipeline. None = the platform default, on - # everywhere except macOS. True/False = an explicit override.toml choice. + # Multi-core CPU rendering. None = the platform default, on everywhere except macOS. + # True/False = an explicit override.toml choice. cpu_parallel: bool | None = None # Smaller tiles and no readback pipelining in the tiled export path, at the cost of # export speed. Off by default -- a user opts in from Preferences or override.toml diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index 666c276ca..b3789b725 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -62,7 +62,7 @@ def separation_damping_gain(k: float, damping: float, chroma: float, ref_spread: def separation_damping_gain_np(k: float, damping: float, chroma: Any, ref_spread: float) -> Any: """Vectorized numpy twin of separation_damping_gain, for the transfer curve's - per-pixel gain over a whole image rather than the numba kernel's per-pixel scalar. + per-pixel gain over a whole image rather than the print curve's per-pixel scalar. Same math; see that function for the derivation.""" if k <= 0.0: return np.zeros_like(chroma, dtype=np.float32) diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index 19be7a3a2..7b71a902b 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -14,7 +14,6 @@ working_oetf_encode, ) from negpy.kernel.image.validation import ensure_image -from negpy.kernel.system.parallel import SERIAL_MAX_ELEMENTS CLAHE_GRID = 8 @@ -158,12 +157,9 @@ def _map_row_blocks(img: ImageBuffer, fn: Callable[[ImageBuffer], ImageBuffer], if img.shape[0] <= rows: return fn(img) output = np.empty(img.shape, dtype=np.float32) - min_rows = math.ceil(SERIAL_MAX_ELEMENTS / img[0].size) for start in range(0, img.shape[0], rows): end = min(start + rows, img.shape[0]) low, high = max(0, start - halo), min(img.shape[0], end + halo) - # Keep a short tail on the full frame's Numba dispatch path. - low = min(low, max(0, high - min_rows)) block = fn(img[low:high]) output[start:end] = block[start - low : end - low] return output diff --git a/negpy/kernel/system/parallel.py b/negpy/kernel/system/parallel.py index 7dd926c06..c99ebcc1d 100644 --- a/negpy/kernel/system/parallel.py +++ b/negpy/kernel/system/parallel.py @@ -1,51 +1,28 @@ """ -Runtime gate for Numba parallel (multi-core) CPU kernels. - -The app pins NUMBA_THREADING_LAYER=workqueue (desktop/main.py), and workqueue -is not thread-safe under concurrent access: if two threads invoke -parallel=True kernels at the same time, Numba hard-terminates the process. -The render worker, export worker and the CPU display path (ICC LUT) can all -overlap, which surfaced as crashes during rendering/export (reported on macOS -first, but the race is cross-platform). Two safeguards: - -- every parallel kernel is compiled in BOTH serial and parallel variants via - `parallel_njit` and dispatched per call, so parallelism can be switched off - at runtime without recompiling or restarting — the serial variant is the - plain @njit path that has always been stable; -- parallel invocations are serialized behind a process-wide lock, so two - threads can never be inside the workqueue scheduler at once. The kernels - are themselves multi-core, so cross-thread serialization costs ~nothing. - -Default policy: parallel on, except macOS until the lock fix is verified on -real hardware there. Override with `cpu_parallel = true/false` under +The "Multi-core CPU Rendering" setting: negpy_core runs its kernels on every core, or on one +thread when the setting is off. It takes effect from the next call. + +Default policy: on, except macOS. Override with `cpu_parallel = true/false` under [performance] in override.toml. """ import sys -import threading -import types -from typing import Any, Callable, Optional +from typing import Optional -from numba import njit +import negpy_core from negpy.kernel.system.logging import get_logger logger = get_logger(__name__) -# workqueue threading layer: concurrent parallel-kernel entry aborts the process. -_invocation_gate = threading.Lock() - -# Below this many elements a kernel runs the serial variant and skips the gate: the GUI thread's -# chart and wedge curves must not wait behind the render thread's full-frame kernels. -SERIAL_MAX_ELEMENTS = 1 << 16 - def default_cpu_parallel(platform: str = sys.platform) -> bool: - """Platform policy: parallel kernels on, except macOS (pending verification).""" + """Platform policy: multi-core on, except macOS.""" return platform != "darwin" _parallel_enabled: bool = default_cpu_parallel() +negpy_core.set_parallel(_parallel_enabled) def parallel_enabled() -> bool: @@ -55,6 +32,7 @@ def parallel_enabled() -> bool: def set_parallel_enabled(enabled: bool) -> None: global _parallel_enabled _parallel_enabled = bool(enabled) + negpy_core.set_parallel(_parallel_enabled) def resolve_cpu_parallel(file_override: Optional[bool], stored: Optional[bool]) -> Optional[bool]: @@ -73,55 +51,8 @@ def configure_cpu_parallel(override: Optional[bool]) -> None: """Apply the startup policy: explicit override wins, else the platform default.""" set_parallel_enabled(default_cpu_parallel() if override is None else override) logger.info( - "CPU parallel kernels: %s (override=%s, platform=%s)", + "CPU multi-core rendering: %s (override=%s, platform=%s)", "enabled" if _parallel_enabled else "disabled", override, sys.platform, ) - - -def parallel_njit(**jit_kwargs: Any) -> Callable: - """@njit that compiles serial and parallel variants lazily from one source (`prange` runs as - `range` in the serial one) and picks one per call; parallel calls serialize behind the module - lock. cache=True gives each variant its own file, or a serial call could load the parallel binary.""" - jit_kwargs.pop("parallel", None) - - def wrap(py_func: Callable) -> "_DualDispatcher": - return _DualDispatcher(py_func, jit_kwargs) - - return wrap - - -class _DualDispatcher: - """Callable pairing the serial and parallel compilations of one kernel.""" - - def __init__(self, py_func: Callable, jit_kwargs: dict): - self.serial = njit(**jit_kwargs, parallel=False)(_serial_copy(py_func)) - self.parallel = njit(**jit_kwargs, parallel=True)(py_func) - self.__wrapped__ = py_func - self.__name__ = getattr(py_func, "__name__", "kernel") - - def __call__(self, *args: Any, **kwargs: Any) -> Any: - if _parallel_enabled and _first_array_size(args) >= SERIAL_MAX_ELEMENTS: - with _invocation_gate: - return self.parallel(*args, **kwargs) - return self.serial(*args, **kwargs) - - -def _serial_copy(py_func: Callable) -> Callable: - """py_func under its own qualname: Numba's disk cache is keyed by qualname and source line, - so the serial variant gets a cache file apart from the parallel one.""" - copy = types.FunctionType( - py_func.__code__, py_func.__globals__, py_func.__name__ + "__serial", py_func.__defaults__, py_func.__closure__ - ) - copy.__qualname__ = py_func.__qualname__ + "__serial" - copy.__module__ = py_func.__module__ - return copy - - -def _first_array_size(args: tuple) -> int: - for a in args: - size = getattr(a, "size", None) - if isinstance(size, int): - return size - return SERIAL_MAX_ELEMENTS diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index d714face8..42235428c 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -21,7 +21,7 @@ is_xtrans, ) from negpy.infrastructure.gpu.device import GPUDevice -from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, uint16_to_float32, working_oetf_decode, working_oetf_encode +from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, uint16_to_float32 from negpy.kernel.image.validation import ensure_image from negpy.kernel.system.config import APP_CONFIG from negpy.kernel.system.override import effective_max_texture_size @@ -73,15 +73,6 @@ def _output_dimensions_from_raw(raw: Any, postprocessed_h: int, postprocessed_w: return (postprocessed_h, postprocessed_w) -# Pre-warm the Numba JIT so the first actual preview load doesn't pay the compile cost. The -# small-array OETF calls take the serial variant, which has no disk cache, so the Analysis -# chart's first paint would otherwise compile it on the GUI thread. -_warmup = np.zeros((2, 2, 3), dtype=np.uint16) -uint16_to_float32(_warmup) -working_oetf_decode(working_oetf_encode(np.zeros(4, dtype=np.float32))) -del _warmup - - def _linear_preview_key( file_hash: str, *, diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index a6ed2ac79..0e0f9c211 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -838,7 +838,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. It is off at first on macOS, where it can crash. +* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. It is off at first on macOS. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 413ff2f97..47cfd9fb5 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -627,5 +627,5 @@ Scanlight の接続中に表示されます。プリセットは、一種類の - 言語の初期値は「システム設定」です。英語、簡体字中国語、日本語、韓国語を選択でき、再起動後に有効になります。未翻訳のパネルガイドは英語で表示します。 - インターフェースは 80% ~ 120% ズームし、再起動後に有効になります。キャンバスの背景は黒、ダークグレー、ミディアムグレー、または白を選択します。キャンバスはツールバーが画像を覆うように埋め込まれます。ズームおよび逆スクロール ホイール コントロールの切り替えとズーム動作は保持されます。 - ショートカット キーのカスタマイズ、ツールバーの編集、パネル レイアウト管理操作の入り口のリセット、および色覚選択はさまざまなマーカーの色を区別するために使用されます。 -- GPU アクセラレーションが失敗した場合は CPU にフォールバックします。マルチコアCPUは実験的な設定です。セマンティック検索の最初のダウンロード モデル。プレビュー サイズ、キャッシュ、高品質バッファリング、テクスチャ キャップによってメモリが制御され、起動設定を再起動する必要があることを示す行が表示されます。 +- GPU アクセラレーションが失敗した場合は CPU にフォールバックします。マルチコアCPUはすべてのコアを使い、オフでは 1 コアを使います。セマンティック検索の最初のダウンロード モデル。プレビュー サイズ、キャッシュ、高品質バッファリング、テクスチャ キャップによってメモリが制御され、起動設定を再起動する必要があることを示す行が表示されます。 - 継承と永続化の設定は、新しいファイルの継承を制御します。データベースを管理して、使用状況を表示したり、編集、ギャラリー、キャッシュをクリアしたりできます。 override.toml の仕様は設定よりも優先されます。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 39cc79a99..93f9d99d5 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -627,5 +627,5 @@ Scanlight가 연결되어 있을 때 보입니다. 사전 설정은 한 종류 - 언어 기본값은 시스템 기본값입니다. 영어, 중국어 간체, 일본어와 한국어를 선택할 수 있으며 다시 시작하면 적용됩니다. 번역이 없는 패널 도움말은 영어로 표시합니다. - 인터페이스는 80%~120% 확대되고 다시 시작한 후에 적용됩니다. 캔버스 배경은 검정색, 어두운 회색, 중간 회색 또는 흰색을 선택합니다. 도구 모음이 이미지를 덮도록 캔버스가 잠겨 있습니다. 확대/축소 및 역방향 스크롤 휠 제어 전환 및 확대/축소 동작은 유지됩니다. - 단축키 사용자 정의, 도구 모음 편집 및 패널 레이아웃 관리 작업 입구 재설정 및 컬러 비전 선택을 사용하여 다양한 마커의 색상을 구분합니다. -- GPU 가속이 실패하면 CPU로 대체됩니다. 멀티코어 CPU는 실험적인 설정입니다. 의미 검색 첫 번째 다운로드 모델. 미리보기 크기, 캐시, 고품질 버퍼링 및 텍스처 캡이 메모리를 제어하며 시작 설정을 나타내는 줄을 다시 시작해야 합니다. +- GPU 가속이 실패하면 CPU로 대체됩니다. 멀티코어 CPU는 모든 코어를 사용하고, 끄면 코어 하나를 사용합니다. 의미 검색 첫 번째 다운로드 모델. 미리보기 크기, 캐시, 고품질 버퍼링 및 텍스처 캡이 메모리를 제어하며 시작 설정을 나타내는 줄을 다시 시작해야 합니다. - 상속 및 지속성 설정은 새 파일의 상속을 제어합니다. 데이터베이스를 관리하여 사용량을 보거나 편집 내용, 갤러리 및 캐시를 지울 수 있습니다. override.toml의 사양은 기본 설정보다 우선합니다. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index 033425611..d5af69c7b 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -627,5 +627,5 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - 语言默认跟随系统,可选英文、简体中文、日文或韩文,重启后生效;缺失的面板帮助回退英文。 - 界面缩放 80%–120%,重启生效;画布背景选择黑、深灰、中灰或白;沉浸画布使工具栏覆盖图像;保留缩放与反向滚轮控制切换和缩放行为。 - 自定义快捷键、编辑工具栏与重置面板布局管理操作入口,色觉选择用于区分各类标记的配色。 -- GPU 加速失败时回退 CPU;多核 CPU 为实验设置。语义搜索首次下载模型。预览尺寸、缓存、高质量缓冲与纹理上限控制内存,标明启动设置的行需重启。 +- GPU 加速失败时回退 CPU;多核 CPU 使用全部核心,关闭时只用一个核心。语义搜索首次下载模型。预览尺寸、缓存、高质量缓冲与纹理上限控制内存,标明启动设置的行需重启。 - 沿用设置和持久设置控制新文件继承;管理数据库可查看占用或清除编辑、图库与缓存。override.toml 的指定项优先于偏好设置。 diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 5336fb76e..3570c1752 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -1525,8 +1525,8 @@ "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi では多重露出とマルチパスを使用できません。", "Multi-Pass": "マルチパス", "Multi-core CPU Rendering": "マルチコアCPUレンダリング", - "Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.": "マルチコア CPU レンダリングがオンになっています - アプリが予期せず終了した場合はオフにします", "Multi-core CPU rendering off": "マルチコアCPUレンダリングオフ", + "Multi-core CPU rendering on": "マルチコアCPUレンダリングオン", "Multiply by the as-shot WB gains before writing": "書き込み前にショット時の WB ゲインを乗算します", "Multiply the scan values so that they fill more of the file's 16-bit range.": "スキャンの値を乗算し、ファイルの 16 ビットの範囲をより広く使います。", "My Gear": "マイ機材", @@ -1540,7 +1540,6 @@ "Navigation": "ナビゲーション", "Navigator: click or drag to move the view": "ナビゲータ: クリックまたはドラッグしてビューを移動します", "NegPy": "NegPy", - "NegPy Closed Unexpectedly": "NegPy が予期せず終了しました", "NegPy Hit an Error": "NegPy でエラーが発生しました", "NegPy Sidecars (*.negpy)": "NegPy サイドカー (*.negpy)", "NegPy data folder": "NegPyデータフォルダー", @@ -1985,6 +1984,7 @@ "Render exposure up": "レンダリング露出アップ", "Render on all CPU cores, an experimental mode that can close the app without warning.": "すべての CPU コアでレンダリングします。実験的なモードで、警告なしにアプリが終了することがあります。", "Render out-of-date thumbnails again in the background": "古いサムネイルをバックグラウンドで再レンダリングします", + "Render on all CPU cores. When off, CPU rendering uses one core.": "すべての CPU コアでレンダリングします。オフのときは 1 コアで CPU レンダリングします。", "Render the full-resolution scan instead of a downscaled preview": "縮小プレビューの代わりにフル解像度のスキャンをレンダリングします。", "Render the pipeline on the GPU": "GPU でパイプラインをレンダリングする", "Render the source frame before syncing bounds": "境界を同期する前にソースフレームをレンダリングします", @@ -2517,7 +2517,6 @@ "Test Strip: show a grid of versions at different brightness and contrast, then click the one you like.": "テストストリップ: 明るさとコントラストを変えた版のグリッドを表示し、気に入ったものをクリックします。", "Test strip": "テストストリップ", "That roll no longer exists": "そのロールはもう存在しません", - "That setting is the most likely cause. Turning it off costs some speed on merges and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?": "その設定が最も可能性の高い原因です。これをオフにすると、マージとエクスポートの速度が若干低下しますが、これが安全な設定です。\n\nマルチコア CPU レンダリングをオフにしますか?", "The 120 camera's frame size": "120カメラのフレームサイズ", "The Analysis Readout": "分析結果", "The Library: Rolls": "ライブラリ:ロール", @@ -2567,7 +2566,6 @@ "The language of the interface, applied when NegPy starts again.": "インターフェースの言語。NegPy の次回起動時に適用されます。", "The largest brightness change Auto Density may make, so a lower value keeps a roll more even.": "自動濃度が変えてよい明るさの最大量で、下げるとロールがよりそろいます。", "The largest image size the GPU loads at once.": "GPU が一度に読み込む最大の画像サイズ。", - "The last session ended unexpectedly with multi-core CPU rendering turned on.": "前回のセッションは、マルチコア CPU レンダリングがオンの状態で予期せず終了しました。", "The lens model, written to the photo's metadata": "写真のメタデータに書き込むレンズのモデル", "The lens this preset writes onto a frame": "このプリセットがフレームに書き込むレンズ", "The light behind the negative": "ネガの背後にある光源", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index e81c8b5d3..d1c6592ce 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -1525,8 +1525,8 @@ "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi에서는 다중 노출과 멀티패스를 사용할 수 없습니다.", "Multi-Pass": "멀티패스", "Multi-core CPU Rendering": "멀티코어 CPU 렌더링", - "Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.": "멀티 코어 CPU 렌더링 켜기 - 앱이 예기치 않게 닫히면 끄세요.", "Multi-core CPU rendering off": "멀티코어 CPU 렌더링 꺼짐", + "Multi-core CPU rendering on": "멀티코어 CPU 렌더링 켜짐", "Multiply by the as-shot WB gains before writing": "쓰기 전에 실제 WB 게인을 곱하세요.", "Multiply the scan values so that they fill more of the file's 16-bit range.": "스캔 값을 곱해 파일의 16비트 범위를 더 많이 채웁니다.", "My Gear": "내 장비", @@ -1540,7 +1540,6 @@ "Navigation": "네비게이션", "Navigator: click or drag to move the view": "네비게이터: 클릭하거나 드래그하여 보기를 이동합니다.", "NegPy": "NegPy", - "NegPy Closed Unexpectedly": "NegPy가 예기치 않게 종료되었습니다.", "NegPy Hit an Error": "NegPy에 오류가 발생했습니다", "NegPy Sidecars (*.negpy)": "NegPy 사이드카(*.negpy)", "NegPy data folder": "NegPy 데이터 폴더", @@ -1985,6 +1984,7 @@ "Render exposure up": "렌더 노출 증가", "Render on all CPU cores, an experimental mode that can close the app without warning.": "모든 CPU 코어로 렌더링합니다. 실험적 모드로, 앱이 경고 없이 닫힐 수 있습니다.", "Render out-of-date thumbnails again in the background": "오래된 썸네일을 백그라운드에서 다시 렌더링합니다", + "Render on all CPU cores. When off, CPU rendering uses one core.": "모든 CPU 코어로 렌더링합니다. 끄면 CPU 렌더링에 코어 하나만 사용합니다.", "Render the full-resolution scan instead of a downscaled preview": "축소된 미리보기 대신 전체 해상도 스캔을 렌더링합니다.", "Render the pipeline on the GPU": "GPU에서 파이프라인 렌더링", "Render the source frame before syncing bounds": "경계를 동기화하기 전에 소스 프레임 렌더링", @@ -2517,7 +2517,6 @@ "Test Strip: show a grid of versions at different brightness and contrast, then click the one you like.": "테스트 스트립: 밝기와 대비를 달리한 버전의 격자를 보여준 뒤, 마음에 드는 것을 클릭합니다.", "Test strip": "테스트 스트립", "That roll no longer exists": "해당 롤은 더 이상 존재하지 않습니다.", - "That setting is the most likely cause. Turning it off costs some speed on merges and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?": "이 설정이 가장 유력한 원인입니다. 끄면 병합과 내보내기가 조금 느려지지만 안전한 설정입니다.\n\n멀티코어 CPU 렌더링을 끄시겠습니까?", "The 120 camera's frame size": "120 카메라의 프레임 크기", "The Analysis Readout": "분석 판독", "The Library: Rolls": "라이브러리: 롤", @@ -2567,7 +2566,6 @@ "The language of the interface, applied when NegPy starts again.": "인터페이스 언어로, NegPy를 다시 시작하면 적용됩니다.", "The largest brightness change Auto Density may make, so a lower value keeps a roll more even.": "자동 농도가 바꿀 수 있는 최대 밝기 변화로, 낮을수록 롤이 더 고르게 됩니다.", "The largest image size the GPU loads at once.": "GPU가 한 번에 로드하는 최대 이미지 크기입니다.", - "The last session ended unexpectedly with multi-core CPU rendering turned on.": "멀티 코어 CPU 렌더링이 켜진 상태에서 마지막 세션이 예기치 않게 종료되었습니다.", "The lens model, written to the photo's metadata": "사진의 메타데이터에 기록되는 렌즈 모델", "The lens this preset writes onto a frame": "이 사전 설정이 프레임에 기록하는 렌즈", "The light behind the negative": "네거티브 뒤의 광원", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index b5a86d6ec..10f72808b 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -1525,8 +1525,8 @@ "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi 下无法使用多重曝光和多次扫描。", "Multi-Pass": "多次扫描", "Multi-core CPU Rendering": "多核 CPU 渲染", - "Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.": "多核 CPU 渲染已开启,若应用意外关闭请禁用", "Multi-core CPU rendering off": "多核 CPU 渲染已关闭", + "Multi-core CPU rendering on": "多核 CPU 渲染已开启", "Multiply by the as-shot WB gains before writing": "写入前应用拍摄时的白平衡增益", "Multiply the scan values so that they fill more of the file's 16-bit range.": "放大扫描数值,使其占用文件 16 位范围中的更多部分。", "My Gear": "个人器材", @@ -1540,7 +1540,6 @@ "Navigation": "导航", "Navigator: click or drag to move the view": "导航器:点击或拖动以移动视图", "NegPy": "NegPy", - "NegPy Closed Unexpectedly": "NegPy 意外关闭", "NegPy Hit an Error": "NegPy 发生错误", "NegPy Sidecars (*.negpy)": "NegPy 附属文件 (*.negpy)", "NegPy data folder": "NegPy 数据文件夹", @@ -1985,6 +1984,7 @@ "Render exposure up": "提高渲染曝光", "Render on all CPU cores, an experimental mode that can close the app without warning.": "使用所有 CPU 核心渲染,此模式为实验性质,可能导致应用无提示关闭。", "Render out-of-date thumbnails again in the background": "在后台重新渲染过期的缩略图", + "Render on all CPU cores. When off, CPU rendering uses one core.": "使用全部 CPU 核心渲染。关闭时,CPU 渲染只用一个核心。", "Render the full-resolution scan instead of a downscaled preview": "渲染全分辨率扫描,而非缩小的预览", "Render the pipeline on the GPU": "使用 GPU 渲染管线", "Render the source frame before syncing bounds": "同步边界前先渲染源画面", @@ -2517,7 +2517,6 @@ "Test Strip: show a grid of versions at different brightness and contrast, then click the one you like.": "试条:显示不同亮度和反差的网格版本,然后点击你喜欢的那一格。", "Test strip": "试条", "That roll no longer exists": "该胶卷已不存在", - "That setting is the most likely cause. Turning it off costs some speed on merges and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?": "此设置可能是主要原因。关闭后合并和导出速度会有所降低,但更稳定。\n\n关闭多核 CPU 渲染?", "The 120 camera's frame size": "120 相机的画幅尺寸", "The Analysis Readout": "分析读数", "The Library: Rolls": "图库:胶卷", @@ -2567,7 +2566,6 @@ "The language of the interface, applied when NegPy starts again.": "界面语言,在 NegPy 重新启动后生效。", "The largest brightness change Auto Density may make, so a lower value keeps a roll more even.": "自动密度可做的最大亮度变化,数值越低整卷越一致。", "The largest image size the GPU loads at once.": "GPU 一次加载的最大图像尺寸。", - "The last session ended unexpectedly with multi-core CPU rendering turned on.": "上次会话启用多核 CPU 渲染时意外结束。", "The lens model, written to the photo's metadata": "镜头型号,写入照片的元数据", "The lens this preset writes onto a frame": "此预设写入画面的镜头", "The light behind the negative": "负片背后的光源", diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 7113a35aa..9f125ad9f 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2556,3 +2556,9 @@ phase = 9 status = "stays" calls = ["cv2.cvtColor", "cv2.imencode"] depends = [] + +["kernel/system/parallel.py::set_parallel_enabled"] +phase = 9 +status = "todo" +calls = ["negpy_core.set_parallel"] +depends = [] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 60d50e6d1..bea3adfa6 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -40,6 +40,7 @@ mod py; #[cfg(feature = "python")] #[pyo3::pymodule] fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { + py::register(m)?; py::stats::register(m)?; py::resize::register(m)?; py::filter::register(m)?; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index b651e25c5..4dda7279c 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -38,7 +38,7 @@ fn luminance<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::luminance(data, dst)); + super::detach(py, || analysis::luminance(data, dst)); } Ok(out) } @@ -57,7 +57,7 @@ fn to_log_density<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::to_log_density(data, dst)); + super::detach(py, || analysis::to_log_density(data, dst)); } Ok(out) } @@ -76,7 +76,7 @@ fn normalize_to_uint8<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::normalize_to_uint8(data, dst)); + super::detach(py, || analysis::normalize_to_uint8(data, dst)); } Ok(out) } @@ -95,10 +95,9 @@ fn detection_luma<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::detection_luma(data, dst)) - .ok_or_else(|| { - pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") - })?; + super::detach(py, || analysis::detection_luma(data, dst)).ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") + })?; } Ok(out) } @@ -126,7 +125,7 @@ fn base_blocks<'py>( .iter() .map(|&v| v as u8) .collect(); - let found = py.detach(|| analysis::base_blocks(data, &mask, h, w)); + let found = super::detach(py, || analysis::base_blocks(data, &mask, h, w)); found .map(|rows| { let flat: Vec> = rows.iter().map(|r| r.to_vec()).collect(); @@ -148,7 +147,9 @@ fn find_rebate_level( return Err(PyValueError::new_err("find_rebate_level needs a 2-D array")); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::find_rebate_level(data, h, w, film_roi, bed))) + Ok(super::detach(py, || { + analysis::find_rebate_level(data, h, w, film_roi, bed) + })) } /// `_walk_border_depth(lines, limit, bridge, relative_step)` for an L x N float32 array. @@ -183,7 +184,7 @@ fn measure_film_edges( )); }; let data = slice(&lum)?; - let [t, b, l, r] = py.detach(|| analysis::measure_film_edges(data, h, w, film_roi)); + let [t, b, l, r] = super::detach(py, || analysis::measure_film_edges(data, h, w, film_roi)); Ok((t, b, l, r)) } @@ -198,7 +199,9 @@ fn trim_film_edges( return Err(PyValueError::new_err("trim_film_edges needs a 2-D array")); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::trim_film_edges(data, h, w, film_roi))) + Ok(super::detach(py, || { + analysis::trim_film_edges(data, h, w, film_roi) + })) } /// `measure_film_border(lum, film_roi)`: (top, bottom, left, right) border fractions. @@ -214,7 +217,7 @@ fn measure_film_border( )); }; let data = slice(&lum)?; - let [t, b, l, r] = py.detach(|| analysis::measure_film_border(data, h, w, film_roi)); + let [t, b, l, r] = super::detach(py, || analysis::measure_film_border(data, h, w, film_roi)); Ok((t, b, l, r)) } @@ -231,7 +234,9 @@ fn roi_from_measured_border( )); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::roi_from_measured_border(data, h, w, film_roi))) + Ok(super::detach(py, || { + analysis::roi_from_measured_border(data, h, w, film_roi) + })) } /// `_smooth_signal(signal, window)` for a 1-D float32 signal. @@ -272,7 +277,9 @@ fn snap_edge_to_gradient( min_dominance, min_window_px, }; - Ok(py.detach(|| analysis::snap_edge_to_gradient(data, idx, is_start, snap))) + Ok(super::detach(py, || { + analysis::snap_edge_to_gradient(data, idx, is_start, snap) + })) } type Refined<'py> = ( @@ -294,7 +301,9 @@ fn refine_film_roi_by_tiers<'py>( )); }; let data = slice(&lum)?; - let found = py.detach(|| analysis::refine_film_roi_by_tiers(data, h, w, film_roi)); + let found = super::detach(py, || { + analysis::refine_film_roi_by_tiers(data, h, w, film_roi) + }); Ok(found.map(|r| { ( r.roi, @@ -320,10 +329,9 @@ fn refine_frame_bounds( } }; let data = slice(&image)?; - py.detach(|| analysis::refine_frame_bounds(data, h, w, cn)) - .ok_or_else(|| { - PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") - }) + super::detach(py, || analysis::refine_frame_bounds(data, h, w, cn)).ok_or_else(|| { + PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") + }) } type Gated<'py> = ( @@ -388,7 +396,7 @@ fn detect_film_bounds<'py>( } }; let data = slice(&img)?; - let d = py.detach(|| crate::detect::detect_film_bounds(data, h, w, cn)); + let d = super::detach(py, || crate::detect::detect_film_bounds(data, h, w, cn)); Ok(( d.consensus.map(consensus_fields), numpy::PyArray1::from_vec(py, d.profile), @@ -417,7 +425,9 @@ fn normalize_log_image<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::normalize_log_image(data, floors, ceils, dst)); + super::detach(py, || { + analysis::normalize_log_image(data, floors, ceils, dst) + }); } Ok(out) } @@ -438,7 +448,7 @@ fn block_median_grid<'py>( return Err(PyValueError::new_err("block size must be positive")); } let data = slice(&img)?; - let grid = py.detach(|| analysis::block_median_grid(data, (h, w, c), b)); + let grid = super::detach(py, || analysis::block_median_grid(data, (h, w, c), b)); numpy::PyArray1::from_vec(py, grid).reshape(vec![h / b, w / b, c]) } @@ -453,7 +463,9 @@ fn threshold_box( return Err(PyValueError::new_err("threshold_box needs a 2-D array")); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::threshold_box(data, h, w, threshold))) + Ok(super::detach(py, || { + analysis::threshold_box(data, h, w, threshold) + })) } /// Batch autocrop's `_vertical_edge_profile` of an HxW or HxWxC (C >= 3) float32 image: @@ -473,7 +485,7 @@ fn batch_edge_profile<'py>( } }; let data = slice(&img)?; - let (profile, contrast) = py.detach(|| analysis::batch_edge_profile(data, h, w, cn)); + let (profile, contrast) = super::detach(py, || analysis::batch_edge_profile(data, h, w, cn)); Ok((numpy::PyArray1::from_vec(py, profile), contrast)) } @@ -493,10 +505,9 @@ fn film_and_picture( ) -> PyResult<(Option, analysis::Roi)> { let (h, w) = det_dims(&det)?; let data = slice(&det)?; - py.detach(|| analysis::film_and_picture(data, h, w)) - .ok_or_else(|| { - PyValueError::new_err("film_and_picture: image too small for the Sobel path") - }) + super::detach(py, || analysis::film_and_picture(data, h, w)).ok_or_else(|| { + PyValueError::new_err("film_and_picture: image too small for the Sobel path") + }) } /// `measure_base_log` of an HxWx3 float32 detection image: (r, g, b) log10 or None. @@ -507,7 +518,7 @@ fn measure_base_log( ) -> PyResult> { let (h, w) = det_dims(&det)?; let data = slice(&det)?; - Ok(py.detach(|| analysis::measure_base_log(data, h, w))) + Ok(super::detach(py, || analysis::measure_base_log(data, h, w))) } type ConsensusFields = ( diff --git a/negpy_core/src/py/bounds.rs b/negpy_core/src/py/bounds.rs index f82cea685..90b4d0505 100644 --- a/negpy_core/src/py/bounds.rs +++ b/negpy_core/src/py/bounds.rs @@ -20,7 +20,7 @@ fn channel_percentiles( qs: Vec, ) -> PyResult<[Vec; 3]> { let g = grid_of(&grid)?; - Ok(py.detach(|| bounds::channel_percentiles(g, &qs))) + Ok(super::detach(py, || bounds::channel_percentiles(g, &qs))) } /// `_same_pixel_color_floor_refs` with its constants (chroma quantile, chroma cap, first-pass @@ -44,7 +44,7 @@ fn same_pixel_color_floor_refs( min_pixels, band_width, }; - Ok(py.detach(|| { + Ok(super::detach(py, || { bounds::same_pixel_color_floor_refs(g, luma_floors, luma_ceils, base, color_clip, c) })) } diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs index 29cb9b835..094489ca8 100644 --- a/negpy_core/src/py/color.rs +++ b/negpy_core/src/py/color.rs @@ -21,7 +21,7 @@ fn filled<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(data, dst)); + super::detach(py, || fill(data, dst)); } Ok(out) } diff --git a/negpy_core/src/py/components.rs b/negpy_core/src/py/components.rs index f1ee29be5..07d5e2623 100644 --- a/negpy_core/src/py/components.rs +++ b/negpy_core/src/py/components.rs @@ -29,7 +29,9 @@ fn connected_components_with_stats<'py>( let data = mask .as_slice() .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; - let c = py.detach(|| components::connected_components(data, h, w, connectivity)); + let c = super::detach(py, || { + components::connected_components(data, h, w, connectivity) + }); let labels = numpy::PyArray1::from_vec(py, c.labels).reshape(vec![h, w])?; let stats = numpy::PyArray2::from_vec2(py, &c.stats.iter().map(|s| s.to_vec()).collect::>()) @@ -72,7 +74,7 @@ fn distance_transform<'py>( } }; let (data, h, w) = mask_of(&mask)?; - let d = py.detach(|| distance::distance_transform(data, h, w, metric)); + let d = super::detach(py, || distance::distance_transform(data, h, w, metric)); numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) } @@ -88,7 +90,9 @@ fn distance_transform_labels<'py>( mask: PyReadonlyArrayDyn<'py, u8>, ) -> PyResult> { let (data, h, w) = mask_of(&mask)?; - let (d, l) = py.detach(|| crate::distance::distance_transform_labels(data, h, w)); + let (d, l) = super::detach(py, || { + crate::distance::distance_transform_labels(data, h, w) + }); Ok(( numpy::PyArray1::from_vec(py, d).reshape(vec![h, w])?, numpy::PyArray1::from_vec(py, l).reshape(vec![h, w])?, @@ -104,7 +108,9 @@ fn mask_to_score<'py>( floor: f64, ) -> PyResult>> { let (data, h, w) = mask_of(&mask)?; - let d = py.detach(|| crate::distance::mask_to_score(data, h, w, pad, floor)); + let d = super::detach(py, || { + crate::distance::mask_to_score(data, h, w, pad, floor) + }); numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) } diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index 0ccd5c445..3c291ca80 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -19,7 +19,7 @@ fn find_external_contours<'py>( let data = src .as_slice() .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; - let found = py.detach(|| contours::find_external_contours(data, h, w)); + let found = super::detach(py, || contours::find_external_contours(data, h, w)); let k = found.len(); let arrays: Vec>> = found .iter() diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs index 9b23bcd3b..095101cb3 100644 --- a/negpy_core/src/py/convert.rs +++ b/negpy_core/src/py/convert.rs @@ -37,7 +37,7 @@ fn filled<'py, T: numpy::Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(dst)); + super::detach(py, || fill(dst)); } Ok(out) } @@ -129,10 +129,10 @@ fn rgb_to_rgba_into( } let cn = s.dim().2; if let (Some(src), Some(dst)) = (s.as_slice(), d.as_slice_mut()) { - py.detach(|| convert::rgb_to_rgba(src, cn, dst)); + super::detach(py, || convert::rgb_to_rgba(src, cn, dst)); return Ok(()); } - py.detach(|| { + super::detach(py, || { for (srow, mut drow) in s.outer_iter().zip(d.outer_iter_mut()) { for (sp, mut dp) in srow.outer_iter().zip(drow.outer_iter_mut()) { for c in 0..3 { @@ -158,7 +158,7 @@ fn rgba_to_rgb_into( if oy + h > s.dim().0 || ox + w > s.dim().1 || s.dim().2 < 3 || dc < 3 { return Err(PyValueError::new_err("region outside the source")); } - py.detach(|| { + super::detach(py, || { for (y, mut drow) in d.outer_iter_mut().enumerate() { let srow = s.index_axis(numpy::ndarray::Axis(0), oy + y); // A padded readback has whole RGBA rows: copy them as slices when it does. diff --git a/negpy_core/src/py/display.rs b/negpy_core/src/py/display.rs index ff4cfeb67..d2d995d8d 100644 --- a/negpy_core/src/py/display.rs +++ b/negpy_core/src/py/display.rs @@ -40,7 +40,7 @@ fn filled<'py, T: numpy::Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(dst)); + super::detach(py, || fill(dst)); } Ok(out) } diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs index 75e7631ac..3fa5d81a7 100644 --- a/negpy_core/src/py/filter.rs +++ b/negpy_core/src/py/filter.rs @@ -48,7 +48,7 @@ fn filled<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(dst)) + super::detach(py, || fill(dst)) .ok_or_else(|| PyValueError::new_err("kernel shape not covered by negpy_core"))?; } Ok(out) diff --git a/negpy_core/src/py/gray.rs b/negpy_core/src/py/gray.rs index e72fdac95..a09eb3b6a 100644 --- a/negpy_core/src/py/gray.rs +++ b/negpy_core/src/py/gray.rs @@ -35,7 +35,7 @@ fn bgr_to_gray<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| gray::bgr_to_gray(data, dst)); + super::detach(py, || gray::bgr_to_gray(data, dst)); } Ok(out) } @@ -66,8 +66,10 @@ fn gaussian_blur_u8<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border)) - .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; + super::detach(py, || { + gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border) + }) + .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; } Ok(out) } @@ -92,7 +94,7 @@ fn threshold_otsu<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| { + super::detach(py, || { let t = gray::otsu_threshold(data); gray::threshold_binary(data, dst, t, maxval, invert); t @@ -120,7 +122,7 @@ fn canny<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| gray::canny(data, dst, h, w, low, high)) + super::detach(py, || gray::canny(data, dst, h, w, low, high)) .ok_or_else(|| PyValueError::new_err("invalid canny input"))?; } Ok(out) @@ -149,7 +151,7 @@ fn median_blur<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| crate::median::median_blur_u8(data, dst, h, w, ksize)) + super::detach(py, || crate::median::median_blur_u8(data, dst, h, w, ksize)) .ok_or_else(|| PyValueError::new_err("ksize must be odd"))?; } Ok(out) diff --git a/negpy_core/src/py/hdr.rs b/negpy_core/src/py/hdr.rs index 6a1356ed5..d287fbe33 100644 --- a/negpy_core/src/py/hdr.rs +++ b/negpy_core/src/py/hdr.rs @@ -27,7 +27,7 @@ fn count_clipped_rows( let dst = out .as_slice_mut() .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; - py.detach(|| hdr::count_clipped_rows(v, (w, cn), saturation, dst)); + super::detach(py, || hdr::count_clipped_rows(v, (w, cn), saturation, dst)); Ok(()) } @@ -54,7 +54,9 @@ fn hdr_accumulate( let d = den .as_slice_mut() .map_err(|_| PyValueError::new_err("needs contiguous sums"))?; - py.detach(|| hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start))); + super::detach(py, || { + hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start)) + }); Ok(()) } diff --git a/negpy_core/src/py/histogram.rs b/negpy_core/src/py/histogram.rs index d561b87c7..83165584c 100644 --- a/negpy_core/src/py/histogram.rs +++ b/negpy_core/src/py/histogram.rs @@ -44,7 +44,7 @@ fn density_histogram<'py>( step: usize, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = py.detach(|| histogram::density_histogram(s)); + let counts = super::detach(py, || histogram::density_histogram(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![4, DENSITY_HIST_BINS]) } @@ -57,7 +57,7 @@ fn density_parade<'py>( step: usize, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = py.detach(|| histogram::density_parade(s)); + let counts = super::detach(py, || histogram::density_parade(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_BINS, WAVEFORM_COLS]) } @@ -79,11 +79,11 @@ fn output_histogram<'py>( ) -> PyResult> { let counts = if let Ok(a) = img.extract::>() { let s = frame(&a, step)?; - py.detach(|| histogram::output_histogram_u8(s)) + super::detach(py, || histogram::output_histogram_u8(s)) } else { let a = img.extract::>()?; let s = frame(&a, step)?; - py.detach(|| histogram::output_histogram(s)) + super::detach(py, || histogram::output_histogram(s)) }; numpy::PyArray1::from_vec(py, counts).reshape(vec![4, OUTPUT_HIST_BINS]) } @@ -96,7 +96,7 @@ fn color_histogram<'py>( step: usize, ) -> PyResult> { let s = frame(&img, step)?; - let counts = py.detach(|| histogram::color_histogram(s)); + let counts = super::detach(py, || histogram::color_histogram(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![COLOR_HIST_BINS; 3]) } @@ -116,7 +116,7 @@ fn waveform<'py>( _ => return Err(PyValueError::new_err(format!("unknown waveform {kind:?}"))), }; let s = frame(&img, step)?; - let counts = py.detach(|| histogram::waveform(s, kind)); + let counts = super::detach(py, || histogram::waveform(s, kind)); let shape = if kind == Waveform::Luma { vec![WAVEFORM_BINS, WAVEFORM_COLS] } else { @@ -135,7 +135,7 @@ fn scan_full_scale_columns<'py>( level: f32, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = py.detach(|| histogram::scan_full_scale_columns(s, level)); + let counts = super::detach(py, || histogram::scan_full_scale_columns(s, level)); numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_COLS]) } @@ -166,7 +166,7 @@ fn vectorscope_histogram<'py>( eps, kappa, ); - let counts = py.detach(|| histogram::vectorscope_histogram(s, space, gamma)); + let counts = super::detach(py, || histogram::vectorscope_histogram(s, space, gamma)); numpy::PyArray1::from_vec(py, counts).reshape(vec![SCOPE_HUE_BINS, SCOPE_CHROMA_BINS]) } diff --git a/negpy_core/src/py/meter.rs b/negpy_core/src/py/meter.rs index d55c2ca27..d82ab5415 100644 --- a/negpy_core/src/py/meter.rs +++ b/negpy_core/src/py/meter.rs @@ -52,7 +52,9 @@ fn textured_percentile( q: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(py.detach(|| meter::percentile(&textured(g, Some(norm), a), q))) + Ok(super::detach(py, || { + meter::percentile(&textured(g, Some(norm), a), q) + })) } /// The anchor meter's measurement over the grid's textured normalized luma. @@ -65,7 +67,9 @@ fn textured_anchor( clip: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(py.detach(|| meter::anchor_measure(&textured(g, Some(norm), a), clip))) + Ok(super::detach(py, || { + meter::anchor_measure(&textured(g, Some(norm), a), clip) + })) } /// The textural range of the grid's textured raw-log luma. @@ -77,7 +81,9 @@ fn textured_range( clip: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(py.detach(|| meter::textural_range(&textured(g, None, a), clip))) + Ok(super::detach(py, || { + meter::textural_range(&textured(g, None, a), clip) + })) } type Band = (f32, f32); @@ -147,7 +153,7 @@ fn neutral_axis( }; let f = [floors.0, floors.1, floors.2]; let e = [ceils.0, ceils.1, ceils.2]; - let axis = py.detach(|| { + let axis = super::detach(py, || { meter::neutral_axis( data, (f.map(|v| v as f32), e.map(|v| v as f32)), diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 8e9ecbd3c..f7b0a9c82 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -17,3 +17,35 @@ pub mod retouch; pub mod sensor; pub mod stats; pub mod toning; + +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +use pyo3::prelude::*; + +/// The "Multi-core CPU Rendering" setting: off runs every call on one worker thread. +static PARALLEL: AtomicBool = AtomicBool::new(true); + +/// Turns multi-core work on or off for the calls that start after it. +#[pyfunction] +fn set_parallel(enabled: bool) { + PARALLEL.store(enabled, Ordering::Relaxed); +} + +/// `py.detach(f)`, on the global rayon pool or, with multi-core off, on a pool of one thread +/// (the global pool if that one cannot start). +pub(crate) fn detach(py: Python<'_>, f: impl FnOnce() -> T + Send) -> T { + static SERIAL: OnceLock> = OnceLock::new(); + py.detach(|| { + let serial = + SERIAL.get_or_init(|| rayon::ThreadPoolBuilder::new().num_threads(1).build().ok()); + match serial { + Some(pool) if !PARALLEL.load(Ordering::Relaxed) => pool.install(f), + _ => f(), + } + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(set_parallel, m)?) +} diff --git a/negpy_core/src/py/morph.rs b/negpy_core/src/py/morph.rs index c51fe1b02..3c613d1ad 100644 --- a/negpy_core/src/py/morph.rs +++ b/negpy_core/src/py/morph.rs @@ -37,7 +37,7 @@ fn run<'py, T: Pixel + Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| { + super::detach(py, || { if kernel.mask.iter().all(|&v| v != 0) { let ksize = (kernel.w, kernel.h); morph::morphology(data, dst, dims, op, ksize, iterations, border) diff --git a/negpy_core/src/py/print_curve.rs b/negpy_core/src/py/print_curve.rs index 1208ef4f1..914571658 100644 --- a/negpy_core/src/py/print_curve.rs +++ b/negpy_core/src/py/print_curve.rs @@ -230,7 +230,7 @@ fn apply_print_curve<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| print_curve::apply_print_curve(src, w, &p, dst)); + super::detach(py, || print_curve::apply_print_curve(src, w, &p, dst)); } Ok(out) } diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 2dde4d811..ac1393fed 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -37,7 +37,9 @@ fn fill_rung( let fill = fill .as_slice_mut() .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - py.detach(|| retouch::fill_rung(num, den, fill, first, reject_floor_mass, constants)); + super::detach(py, || { + retouch::fill_rung(num, den, fill, first, reject_floor_mass, constants) + }); Ok(()) } @@ -62,7 +64,7 @@ fn blend_fill( if a_out.len() != n { return Err(PyValueError::new_err("array sizes disagree")); } - py.detach(|| retouch::blend_fill(src, score, fill, a_out, ramp)); + super::detach(py, || retouch::blend_fill(src, score, fill, a_out, ramp)); Ok(()) } @@ -84,7 +86,7 @@ fn add_floor_deficit( let out = out .as_slice_mut() .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - py.detach(|| retouch::add_floor_deficit(out, a, bs, bo)); + super::detach(py, || retouch::add_floor_deficit(out, a, bs, bo)); Ok(()) } @@ -115,7 +117,9 @@ fn hair_encode( let dst = out .as_slice_mut() .map_err(|_| pyo3::exceptions::PyValueError::new_err("needs a contiguous out"))?; - py.detach(|| crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst)); + super::detach(py, || { + crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst) + }); Ok(()) } diff --git a/negpy_core/src/py/sensor.rs b/negpy_core/src/py/sensor.rs index 539b28bae..d93166c8c 100644 --- a/negpy_core/src/py/sensor.rs +++ b/negpy_core/src/py/sensor.rs @@ -57,7 +57,7 @@ fn sensor_unmix( image(&x)?; let (src, m) = (data(&x)?, matrix(&m)?); let dst = target(&mut out, x.shape())?; - py.detach(|| sensor::unmix(src, dst, m)); + super::detach(py, || sensor::unmix(src, dst, m)); Ok(()) } @@ -74,7 +74,7 @@ fn sensor_log_unmix( image(&x)?; let (src, c, offset, floor) = (data(&x)?, matrix(&c)?, triplet(&offset)?, triplet(&floor)?); let dst = target(&mut out, x.shape())?; - py.detach(|| sensor::log_unmix(src, dst, c, offset, floor)); + super::detach(py, || sensor::log_unmix(src, dst, c, offset, floor)); Ok(()) } @@ -104,7 +104,7 @@ fn sensor_two_scale_layer( start, full, }; - py.detach(|| sensor::two_scale_layer(src, m, own, s, c, w)); + super::detach(py, || sensor::two_scale_layer(src, m, own, s, c, w)); Ok(()) } @@ -131,7 +131,9 @@ fn sensor_two_scale( let (xs, b, col, w) = (data(&x)?, data(&blurred)?, data(&color)?, data(&weight)?); let (m, c) = (matrix(&m)?, matrix(&c)?); let dst = target(&mut out, &shape)?; - py.detach(|| sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst)); + super::detach(py, || { + sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst) + }); Ok(()) } diff --git a/negpy_core/src/py/stats.rs b/negpy_core/src/py/stats.rs index 5e9be79c6..9c8a1610b 100644 --- a/negpy_core/src/py/stats.rs +++ b/negpy_core/src/py/stats.rs @@ -19,7 +19,7 @@ fn percentiles( let values = values .as_slice() .map_err(|_| PyValueError::new_err("percentiles needs a contiguous array"))?; - py.detach(|| stats::percentiles(values, &qs)) + super::detach(py, || stats::percentiles(values, &qs)) .ok_or_else(|| PyIndexError::new_err("percentiles of an empty array")) } diff --git a/negpy_core/src/py/toning.rs b/negpy_core/src/py/toning.rs index e0d60e2c6..057deab22 100644 --- a/negpy_core/src/py/toning.rs +++ b/negpy_core/src/py/toning.rs @@ -79,7 +79,7 @@ fn apply_chemical_toning<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| toning::apply_chemical_toning(src, dst, &t)); + super::detach(py, || toning::apply_chemical_toning(src, dst, &t)); } Ok(out) } diff --git a/pyproject.toml b/pyproject.toml index 0c784010e..839963fe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "imagecodecs==2026.6.6", "imageio==2.37.3", "jinja2==3.1.6", - "numba==0.65.1", "numpy==2.4.4", # Pinned below its latest release: 1.24.0+ dropped macOS x86_64 wheels entirely, # which breaks install on an Intel Mac. 1.23.2 is the last version with one. diff --git a/scripts/replay.py b/scripts/replay.py index 4c5fdb1ab..7d7bffaac 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -24,8 +24,7 @@ from pathlib import Path if len(sys.argv) > 1 and sys.argv[1] in ("record", "bench"): - # One thread on both sides: the speed gate is per core. Numba reads this at import. - os.environ["NUMBA_NUM_THREADS"] = "1" + # One thread on both sides: the speed gate is per core. os.environ["RAYON_NUM_THREADS"] = "1" import numpy as np # noqa: E402 diff --git a/tests/test_numba_cache.py b/tests/test_numba_cache.py deleted file mode 100644 index 488570c03..000000000 --- a/tests/test_numba_cache.py +++ /dev/null @@ -1,44 +0,0 @@ -import ast -from pathlib import Path - -_ROOT = Path(__file__).resolve().parents[1] / "negpy" - - -def _is_jit(decorator: ast.expr) -> bool: - target = decorator.func if isinstance(decorator, ast.Call) else decorator - name = target.attr if isinstance(target, ast.Attribute) else getattr(target, "id", "") - return name in ("njit", "jit", "parallel_njit") - - -def _inline_only(decorator: ast.expr) -> bool: - return ( - isinstance(decorator, ast.Call) - and _is_jit(decorator) - and any(k.arg == "inline" and getattr(k.value, "value", None) == "always" for k in decorator.keywords) - ) - - -def _module(path: Path) -> str: - return ".".join(path.relative_to(_ROOT.parent).with_suffix("").parts) - - -def test_python_code_never_calls_an_uncached_inline_kernel(): - """An inline helper has no cache, so a call from plain Python compiles it again on every launch.""" - trees = {_module(p): ast.parse(p.read_text(encoding="utf-8")) for p in _ROOT.rglob("*.py")} - inline = { - mod: {n.name for n in tree.body if isinstance(n, ast.FunctionDef) and any(_inline_only(d) for d in n.decorator_list)} - for mod, tree in trees.items() - } - offenders = [] - for mod, tree in trees.items(): - visible = set(inline[mod]) - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module in inline: - visible |= {a.asname or a.name for a in node.names if a.name in inline[node.module]} - for fn in ast.walk(tree): - if not isinstance(fn, ast.FunctionDef) or any(_is_jit(d) for d in fn.decorator_list): - continue - for call in ast.walk(fn): - if isinstance(call, ast.Call) and getattr(call.func, "id", None) in visible: - offenders.append(f"{mod}:{call.lineno} {call.func.id}") - assert offenders == [] diff --git a/tests/test_parallel_dispatch.py b/tests/test_parallel_dispatch.py index 52c809d15..ce6597eb3 100644 --- a/tests/test_parallel_dispatch.py +++ b/tests/test_parallel_dispatch.py @@ -1,14 +1,13 @@ -"""The multi-core failsafe: parallel_njit dual dispatch, platform policy, override.""" +"""The Multi-core CPU Rendering setting: one thread or all, the same bits either way; policy, override.""" import threading import numpy as np import pytest -from numba import prange from negpy.kernel.system import parallel as par from negpy.kernel.system.override import OverrideConfig, _parse -from negpy.kernel.system.parallel import default_cpu_parallel, parallel_njit +from negpy.kernel.system.parallel import default_cpu_parallel @pytest.fixture(autouse=True) @@ -18,34 +17,12 @@ def _restore_flag(): par.set_parallel_enabled(before) -@parallel_njit(fastmath=True) -def _double(arr): - out = np.empty_like(arr) - for i in prange(arr.shape[0]): - out[i] = arr[i] * 2.0 - return out - - def test_platform_policy(): assert default_cpu_parallel("darwin") is False assert default_cpu_parallel("win32") is True assert default_cpu_parallel("linux") is True -def test_dispatch_uses_selected_variant(): - # At or above SERIAL_MAX_ELEMENTS; smaller inputs always take the serial variant. - arr = np.arange(par.SERIAL_MAX_ELEMENTS, dtype=np.float32) - - par.set_parallel_enabled(False) - np.testing.assert_array_equal(_double(arr), arr * 2.0) - assert _double.serial.signatures # serial variant compiled and used - assert not _double.parallel.signatures # parallel never touched while disabled - - par.set_parallel_enabled(True) - np.testing.assert_array_equal(_double(arr), arr * 2.0) - assert _double.parallel.signatures - - def test_print_curve_parity_serial_vs_parallel(): from negpy.features.exposure.logic import apply_characteristic_curve @@ -57,7 +34,7 @@ def test_print_curve_parity_serial_vs_parallel(): par.set_parallel_enabled(False) res_ser = apply_characteristic_curve(img, (0.5, 2.0), (0.5, 2.0), (0.5, 2.0)) - np.testing.assert_allclose(res_par, res_ser, rtol=0, atol=1e-6) + np.testing.assert_array_equal(res_par, res_ser) def test_lab_roundtrip_parity_serial_vs_parallel(): @@ -71,24 +48,26 @@ def test_lab_roundtrip_parity_serial_vs_parallel(): par.set_parallel_enabled(False) lab_ser = rgb_to_lab_working(img) - np.testing.assert_allclose(lab_par, lab_ser, rtol=0, atol=1e-4) + np.testing.assert_array_equal(lab_par, lab_ser) -def test_concurrent_parallel_invocation_is_serialized(): - # Regression for the workqueue concurrent-access abort: many threads invoking - # a parallel kernel simultaneously must be safe (the gate serializes entry). - par.set_parallel_enabled(True) - arr = np.arange(200_000, dtype=np.float32) +def test_concurrent_calls_while_the_setting_flips_are_safe(): + from negpy.kernel.image.logic import rgb_to_lab_working + + img = np.random.default_rng(3).random((300, 400, 3), dtype=np.float32) + want = rgb_to_lab_working(img) errors: list[Exception] = [] - def hammer(): + def hammer(flip: bool): try: - for _ in range(20): - _double(arr) + for i in range(20): + if flip: + par.set_parallel_enabled(i % 2 == 0) + np.testing.assert_array_equal(rgb_to_lab_working(img), want) except Exception as e: # pragma: no cover errors.append(e) - threads = [threading.Thread(target=hammer) for _ in range(6)] + threads = [threading.Thread(target=hammer, args=(i == 0,)) for i in range(6)] for t in threads: t.start() for t in threads: diff --git a/tests/test_parallel_dispatch_size.py b/tests/test_parallel_dispatch_size.py deleted file mode 100644 index aad445cf9..000000000 --- a/tests/test_parallel_dispatch_size.py +++ /dev/null @@ -1,20 +0,0 @@ -import numpy as np - -from negpy.kernel.system import parallel as par - - -def test_small_arrays_take_serial_variant(monkeypatch): - @par.parallel_njit(cache=False) - def double(x): - out = np.empty_like(x) - for i in par.prange(x.shape[0]) if hasattr(par, "prange") else range(x.shape[0]): - out[i] = x[i] * 2 - return out - - calls = [] - monkeypatch.setattr(double, "serial", lambda *a, **k: calls.append("serial") or a[0] * 2) - monkeypatch.setattr(double, "parallel", lambda *a, **k: calls.append("parallel") or a[0] * 2) - monkeypatch.setattr(par, "_parallel_enabled", True) - double(np.ones(8, dtype=np.float32)) - double(np.ones(par.SERIAL_MAX_ELEMENTS, dtype=np.float32)) - assert calls == ["serial", "parallel"] diff --git a/tests/test_parallel_gate.py b/tests/test_parallel_gate.py index 5a76e92f3..8ec93aac8 100644 --- a/tests/test_parallel_gate.py +++ b/tests/test_parallel_gate.py @@ -1,55 +1,9 @@ -"""Every parallel kernel must go through the gate, and the platform default must not move. - -Numba's workqueue threading layer terminates the process outright when two threads enter it -at once — no exception, no dialog, nothing in the log. `parallel_njit` serialises every -parallel invocation behind one lock, which holds only while *every* parallel kernel is -dispatched through it. A kernel compiled `parallel=True` directly would bypass the lock and -reintroduce the abort, and it would show up as a rare crash nobody could attribute. -""" - -import ast -import pathlib +"""The platform default of Multi-core CPU Rendering must not move for anyone who never touches it.""" import pytest from negpy.kernel.system.parallel import default_cpu_parallel, resolve_cpu_parallel -ROOT = pathlib.Path(__file__).resolve().parent.parent / "negpy" -GATE = ROOT / "kernel" / "system" / "parallel.py" - - -def _python_files(): - return [p for p in ROOT.rglob("*.py") if p != GATE] - - -class TestNothingBypassesTheGate: - def test_no_kernel_is_compiled_parallel_directly(self): - """`@njit(parallel=True)` outside parallel_njit is the bypass.""" - offenders = [] - for path in _python_files(): - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - for kw in node.keywords: - if kw.arg == "parallel" and isinstance(kw.value, ast.Constant) and kw.value.value is True: - offenders.append(f"{path.relative_to(ROOT.parent)}:{node.lineno}") - assert not offenders, "compile these through parallel_njit, or they skip the workqueue lock: " + ", ".join(offenders) - - def test_prange_only_appears_in_gated_kernels(self): - """A file using prange but not parallel_njit is either a bypass or a kernel that - silently degraded to a serial loop — both worth failing on.""" - offenders = [ - str(p.relative_to(ROOT.parent)) - for p in _python_files() - if "prange" in (src := p.read_text(encoding="utf-8")) and "parallel_njit" not in src - ] - assert not offenders, "prange outside a parallel_njit kernel: " + ", ".join(offenders) - - def test_the_parallel_variant_is_never_called_directly(self): - offenders = [str(p.relative_to(ROOT.parent)) for p in _python_files() if ".parallel(" in p.read_text(encoding="utf-8")] - assert not offenders, "call the dispatcher, not the parallel variant: " + ", ".join(offenders) - class TestPlatformDefaultIsUnmoved: """Surfacing the setting must not silently re-decide it for anyone who never touches it.""" diff --git a/tests/test_print_wedge.py b/tests/test_print_wedge.py index c531c3eda..ccb4c86ab 100644 --- a/tests/test_print_wedge.py +++ b/tests/test_print_wedge.py @@ -69,11 +69,3 @@ def test_the_render_runs_the_wedges_print_stages() -> None: with patch.object(engine, "print_stages", wraps=engine.print_stages) as spy: DarkroomEngine().process(np.ascontiguousarray(frame), _bw(), source_hash="print-stages") assert spy.called - - -def test_the_wedge_strip_stays_on_the_serial_kernels() -> None: - """The wedge runs on the GUI thread, where the parallel kernels' lock would make it wait behind a render.""" - from negpy.features.exposure.analysis import WEDGE_STEPS - from negpy.kernel.system.parallel import SERIAL_MAX_ELEMENTS - - assert engine._WEDGE_PATCH_PX**2 * WEDGE_STEPS * 3 < SERIAL_MAX_ELEMENTS diff --git a/uv.lock b/uv.lock index dff4c7988..aa150fc88 100644 --- a/uv.lock +++ b/uv.lock @@ -271,26 +271,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/4a/ff49bd77f33af05ca26fee29d601beb1e19ca791bb86efece82a3833885c/libusb_package-1.0.30.0-py3-none-win_amd64.whl", hash = "sha256:90808da724c8939a333d931d0c7226372ca5fefd98e2f2f3ef79fd918aa37522", size = 90711, upload-time = "2026-06-30T07:41:09.318Z" }, ] -[[package]] -name = "llvmlite" -version = "0.47.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, - { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, - { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, - { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, - { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, - { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, - { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, - { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, - { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, - { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, -] - [[package]] name = "macholib" version = "1.16.4" @@ -372,7 +352,6 @@ dependencies = [ { name = "imageio" }, { name = "jinja2" }, { name = "negpy-core" }, - { name = "numba" }, { name = "numpy" }, { name = "onnxruntime" }, { name = "opencv-python-headless" }, @@ -433,7 +412,6 @@ requires-dist = [ { name = "jinja2", specifier = "==3.1.6" }, { name = "negpy-core", editable = "negpy_core" }, { name = "nkscan", marker = "extra == 'nkscan'", specifier = ">=0.14.2" }, - { name = "numba", specifier = "==0.65.1" }, { name = "numpy", specifier = "==2.4.4" }, { name = "onnxruntime", specifier = "==1.23.2" }, { name = "opencv-python-headless", specifier = "==4.13.0.92" }, @@ -488,30 +466,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/21/0836f4875f4448837b9d44e26ef57db4e646c2a78e17c4b1d0d1c207dad9/nkscan-0.15.0-cp313-abi3-win_amd64.whl", hash = "sha256:c3ab8dd6b6d074e688df4df3648b9fe1da1d064d6476cafafafa0b197ced8194", size = 3629610, upload-time = "2026-10-19T18:19:34.148Z" }, ] -[[package]] -name = "numba" -version = "0.65.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "llvmlite" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, - { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, - { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, - { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, - { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, - { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, - { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, -] - [[package]] name = "numpy" version = "2.4.4" From 346f0786fd515ffd002124bcac91b83b204b97ab Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 09:53:48 +0200 Subject: [PATCH 065/133] test(core): build the matrix/TRC same-bits ramp without np.power NumPy's float32 power uses SVML on an AVX-512 CPU and libm elsewhere, so the test input itself differed between CI runners and this machine; the Rust kernel's bits did not. --- tests/test_core_same_bits.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 43628a49a..db284e239 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -18,7 +18,9 @@ _LUT = np.ascontiguousarray(np.clip(np.cumsum(_RNG.random((9, 9, 9, 3), dtype=np.float32), axis=0) / 6, 0, 1)) -_RAMP = np.ascontiguousarray(np.tile(np.linspace(0.0, 1.0, 65536, dtype=np.float32) ** 0.45, (3, 1))) +# Plain arithmetic only: NumPy's own float32 power differs between CPUs. +_LIN = np.linspace(0.0, 1.0, 65536, dtype=np.float32) +_RAMP = np.ascontiguousarray(np.tile(_LIN * (np.float32(2.0) - _LIN), (3, 1))) def _filled(fill): @@ -94,7 +96,7 @@ def _filled(fill): "chemical_toning": "7ddd3949c04bd5af38177bfcd6576888092bf73ad173206c34b153d6cce5d648", "sensor_log_unmix": "f7eb8efe1bbcfe884fd2e849e99d0546fac2f4829d3fb558d54a0128e681e53b", "lut_f32": "467bb9fd495d4797d912f6aeabd34f9a61b56491105464a0c852ed524488b1c1", - "matrix_trc_u8": "cf09517a502fcd513e1ee8deddd9f1983108d52c2d882dc7bca374096ecee708", + "matrix_trc_u8": "acec1eadffb13dfef8473a02fb250b4ad8f57ac211a6265661b77d3cad49ab16", } From ad57f8f5187d69b2cc9a77da17e51661f8cc57df Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 10:12:19 +0200 Subject: [PATCH 066/133] feat: Multi-core CPU Rendering starts on everywhere, macOS included The macOS default of off was a workaround for Numba's workqueue abort, which went with Numba. An install that saved the setting keeps it. --- docs/USER_GUIDE.md | 2 +- negpy/desktop/main.py | 3 +-- negpy/domain/types.py | 2 +- negpy/kernel/system/override.py | 5 ++--- negpy/kernel/system/parallel.py | 24 +++++++----------------- negpy/translations/USER_GUIDE.en.md | 2 +- tests/test_parallel_dispatch.py | 6 ------ tests/test_parallel_gate.py | 17 ++++++----------- 8 files changed, 19 insertions(+), 42 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0e0f9c211..a45f1b671 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -838,7 +838,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. It is off at first on macOS. +* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 233be13a1..70f58ac0a 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -265,8 +265,7 @@ def main() -> None: if override_cfg.low_vram_export_tiling is None: APP_CONFIG.low_vram_export_tiling = bool(repo.get_global_setting("low_vram_export_tiling", False)) - # Multi-core CPU rendering: override.toml, then the saved setting, then the platform - # default (off on macOS). + # Multi-core CPU rendering: override.toml, then the saved setting, then the default (on). stored_parallel = repo.get_global_setting("cpu_parallel", None) configure_cpu_parallel(resolve_cpu_parallel(APP_CONFIG.cpu_parallel, None if stored_parallel is None else bool(stored_parallel))) diff --git a/negpy/domain/types.py b/negpy/domain/types.py index 2a496cbc1..87892797d 100644 --- a/negpy/domain/types.py +++ b/negpy/domain/types.py @@ -43,7 +43,7 @@ class AppConfig: override_toml_path: str = "" max_texture_size: int | None = None force_hq_preview: bool | None = None - # Multi-core CPU rendering. None = the platform default, on everywhere except macOS. + # Multi-core CPU rendering. None = the default, on. # True/False = an explicit override.toml choice. cpu_parallel: bool | None = None # Smaller tiles and no readback pipelining in the tiled export path, at the cost of diff --git a/negpy/kernel/system/override.py b/negpy/kernel/system/override.py index 40b947ceb..dc218610e 100644 --- a/negpy/kernel/system/override.py +++ b/negpy/kernel/system/override.py @@ -53,9 +53,8 @@ # Override HQ preview on startup. Uncomment to force a value. # force_hq_preview = false -# Multi-core CPU rendering kernels. Defaults to true except on macOS, where it -# defaults to false while crash reports are investigated. Uncomment to force. -# cpu_parallel = true +# Multi-core CPU rendering. Defaults to true. Uncomment to force. +# cpu_parallel = false # Smaller tiles and no readback pipelining during export, at some cost to export speed. # Off by default. Turn on if exporting crashes on your GPU (typically an older or diff --git a/negpy/kernel/system/parallel.py b/negpy/kernel/system/parallel.py index c99ebcc1d..97d2b8f46 100644 --- a/negpy/kernel/system/parallel.py +++ b/negpy/kernel/system/parallel.py @@ -2,11 +2,10 @@ The "Multi-core CPU Rendering" setting: negpy_core runs its kernels on every core, or on one thread when the setting is off. It takes effect from the next call. -Default policy: on, except macOS. Override with `cpu_parallel = true/false` under +Default: on. Override with `cpu_parallel = true/false` under [performance] in override.toml. """ -import sys from typing import Optional import negpy_core @@ -16,9 +15,9 @@ logger = get_logger(__name__) -def default_cpu_parallel(platform: str = sys.platform) -> bool: - """Platform policy: multi-core on, except macOS.""" - return platform != "darwin" +def default_cpu_parallel() -> bool: + """Multi-core is on unless the user or override.toml turns it off.""" + return True _parallel_enabled: bool = default_cpu_parallel() @@ -36,12 +35,8 @@ def set_parallel_enabled(enabled: bool) -> None: def resolve_cpu_parallel(file_override: Optional[bool], stored: Optional[bool]) -> Optional[bool]: - """Which answer wins: override.toml, then the saved setting, then the platform default. - - None at every level means "platform default", so an installation that has never touched - the setting keeps whatever its platform does today — on for Windows and Linux, off for - macOS. Surfacing the control must not silently re-decide it for anyone. - """ + """Which answer wins: override.toml, then the saved setting, then the default (on). + None at every level means the default.""" if file_override is not None: return file_override return stored @@ -50,9 +45,4 @@ def resolve_cpu_parallel(file_override: Optional[bool], stored: Optional[bool]) def configure_cpu_parallel(override: Optional[bool]) -> None: """Apply the startup policy: explicit override wins, else the platform default.""" set_parallel_enabled(default_cpu_parallel() if override is None else override) - logger.info( - "CPU multi-core rendering: %s (override=%s, platform=%s)", - "enabled" if _parallel_enabled else "disabled", - override, - sys.platform, - ) + logger.info("CPU multi-core rendering: %s (override=%s)", "enabled" if _parallel_enabled else "disabled", override) diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index 0e0f9c211..a45f1b671 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -838,7 +838,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. It is off at first on macOS. +* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. diff --git a/tests/test_parallel_dispatch.py b/tests/test_parallel_dispatch.py index ce6597eb3..4f18f8b10 100644 --- a/tests/test_parallel_dispatch.py +++ b/tests/test_parallel_dispatch.py @@ -17,12 +17,6 @@ def _restore_flag(): par.set_parallel_enabled(before) -def test_platform_policy(): - assert default_cpu_parallel("darwin") is False - assert default_cpu_parallel("win32") is True - assert default_cpu_parallel("linux") is True - - def test_print_curve_parity_serial_vs_parallel(): from negpy.features.exposure.logic import apply_characteristic_curve diff --git a/tests/test_parallel_gate.py b/tests/test_parallel_gate.py index 8ec93aac8..004123c2f 100644 --- a/tests/test_parallel_gate.py +++ b/tests/test_parallel_gate.py @@ -1,19 +1,14 @@ -"""The platform default of Multi-core CPU Rendering must not move for anyone who never touches it.""" - -import pytest +"""Which answer sets Multi-core CPU Rendering: override.toml, the saved setting, the default.""" from negpy.kernel.system.parallel import default_cpu_parallel, resolve_cpu_parallel -class TestPlatformDefaultIsUnmoved: - """Surfacing the setting must not silently re-decide it for anyone who never touches it.""" - - @pytest.mark.parametrize("platform,expected", [("win32", True), ("linux", True), ("darwin", False)]) - def test_untouched_installs_keep_their_platform_behaviour(self, platform, expected): - assert default_cpu_parallel(platform) is expected - assert resolve_cpu_parallel(None, None) is None, "None must fall through to the platform default" +class TestResolution: + def test_an_untouched_install_takes_the_default_on(self): + assert default_cpu_parallel() is True + assert resolve_cpu_parallel(None, None) is None, "None must fall through to the default" - def test_the_saved_setting_beats_the_platform_default(self): + def test_the_saved_setting_beats_the_default(self): assert resolve_cpu_parallel(None, True) is True assert resolve_cpu_parallel(None, False) is False From 6da176fdad9066b71c7e3f168e002464c8e45b49 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 10:44:40 +0200 Subject: [PATCH 067/133] perf(core): fine rotation, keystone and radial correction warp in Rust negpy_core gains OpenCV's bilinear remap, warpAffine and warpPerspective (float32, replicated edges): 1/32 px fixed-point coordinates rounded as OpenCV rounds them, the same float32 weight products and sum order. 70 oracle cases match cv2 4.13 bit for bit; replay 60/60 for rotation, keystone and the geometry stage. The radial correction builds its source map inside the remap instead of two full-size float32 maps in NumPy. Rust has no SHRT_MAX limit, so the tiled path for 32767 px frames goes (ledger entry). One thread, 24 MP RGB, against cv2 on one thread: affine 0.72x, perspective 0.78x, remap 1.00x; recorded rotation frames 0.89x. --- .github/workflows/ci.yml | 2 +- negpy/features/geometry/logic.py | 101 +--------- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 24 +-- negpy_core/src/lib.rs | 4 +- negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/warp.rs | 130 ++++++++++++ negpy_core/src/warp.rs | 321 ++++++++++++++++++++++++++++++ tests/test_core_same_bits.py | 8 + tests/test_core_warp.py | 61 ++++++ tests/test_geometry_distortion.py | 50 ++--- 11 files changed, 570 insertions(+), 133 deletions(-) create mode 100644 negpy_core/src/py/warp.rs create mode 100644 negpy_core/src/warp.rs create mode 100644 tests/test_core_warp.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da11c03b1..32562a8bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 39cad537a..d2403037f 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1,7 +1,7 @@ import math from dataclasses import dataclass, field, replace from functools import lru_cache -from typing import Callable, Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import cv2 import negpy_core @@ -371,39 +371,6 @@ def _run(profile: np.ndarray, limit: int, from_start: bool) -> int: return ny1, ny2, nx1, nx2 -# OpenCV's warps assert on a side of SHRT_MAX px or more; a larger frame is warped in tiles. -_CV_WARP_MAX = 32767 -_WARP_TILE = 8192 - - -def _remap_tiled(img: ImageBuffer, source_of: Callable[[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray]]) -> ImageBuffer: - """Same-size bilinear warp with replicated edges, one output tile at a time. ``source_of(xs, ys)`` maps output - to source coords; each tile reads only its source box, so every remap stays under OpenCV's size limit.""" - h, w = img.shape[:2] - out = np.empty_like(img) - for y0 in range(0, h, _WARP_TILE): - for x0 in range(0, w, _WARP_TILE): - y1, x1 = min(h, y0 + _WARP_TILE), min(w, x0 + _WARP_TILE) - ys, xs = np.meshgrid(np.arange(y0, y1, dtype=np.float64), np.arange(x0, x1, dtype=np.float64), indexing="ij") - map_x, map_y = source_of(xs, ys) - sx0 = int(np.clip(np.floor(map_x.min()) - 1, 0, w - 1)) - sx1 = int(np.clip(np.ceil(map_x.max()) + 2, sx0 + 1, w)) - sy0 = int(np.clip(np.floor(map_y.min()) - 1, 0, h - 1)) - sy1 = int(np.clip(np.ceil(map_y.max()) + 2, sy0 + 1, h)) - out[y0:y1, x0:x1] = cv2.remap( - np.ascontiguousarray(img[sy0:sy1, sx0:sx1]), - (map_x - sx0).astype(np.float32), - (map_y - sy0).astype(np.float32), - interpolation=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE, - ) - return out - - -def _too_big_to_warp(img: ImageBuffer) -> bool: - return max(img.shape[:2]) >= _CV_WARP_MAX - - def apply_fine_rotation(img: ImageBuffer, angle: float) -> ImageBuffer: """ Sub-degree rotation (bilinear). @@ -412,22 +379,8 @@ def apply_fine_rotation(img: ImageBuffer, angle: float) -> ImageBuffer: return img h, w = img.shape[:2] - center = (w / 2.0, h / 2.0) - m_mat = cv2.getRotationMatrix2D(center, angle, 1.0) - if _too_big_to_warp(img): - inv = cv2.invertAffineTransform(m_mat) - return ensure_image( - _remap_tiled(img, lambda xs, ys: (inv[0, 0] * xs + inv[0, 1] * ys + inv[0, 2], inv[1, 0] * xs + inv[1, 1] * ys + inv[1, 2])) - ) - - res = cv2.warpAffine( - img, - m_mat, - (w, h), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE, - ) - return ensure_image(res) + m_mat = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + return negpy_core.warp_affine(np.ascontiguousarray(img, dtype=np.float32), m_mat, (w, h)) # Radial lens-distortion correction (poly3 / Brown-Conrady k1). The radius is normalized to @@ -488,39 +441,15 @@ def max_ratio(s: float) -> float: return 0.5 * (lo + hi) -def _radial_maps(k1: float, w: int, h: int) -> Tuple[np.ndarray, np.ndarray]: - """cv2.remap source-coordinate maps for the radial correction (incl. scale-to-fill).""" - cx, cy, halfdiag = _radial_center(w, h) - s = compute_distortion_scale(k1, w, h) - inv_hd2 = 1.0 / (halfdiag * halfdiag) - ys, xs = np.meshgrid(np.arange(h, dtype=np.float32), np.arange(w, dtype=np.float32), indexing="ij") - px = (xs - cx) * s - py = (ys - cy) * s - f = 1.0 + k1 * (px * px + py * py) * inv_hd2 - map_x = (cx + px * f).astype(np.float32) - map_y = (cy + py * f).astype(np.float32) - return map_x, map_y - - def apply_radial_distortion(img: ImageBuffer, k1: float) -> ImageBuffer: """Radial lens-distortion correction. Purely geometric — moves pixels via a coordinate remap, never scales values (brightness-preserving). No-op for k1≈0.""" if abs(k1) < _DISTORT_EPS: return img h, w = img.shape[:2] - if _too_big_to_warp(img): - cx, cy, halfdiag = _radial_center(w, h) - s = compute_distortion_scale(k1, w, h) - - def source_of(xs: np.ndarray, ys: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - px, py = (xs - cx) * s, (ys - cy) * s - f = 1.0 + k1 * (px * px + py * py) / (halfdiag * halfdiag) - return cx + px * f, cy + py * f - - return ensure_image(_remap_tiled(img, source_of)) - map_x, map_y = _radial_maps(k1, w, h) - res = cv2.remap(img, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) - return ensure_image(res) + cx, cy, halfdiag = _radial_center(w, h) + s = compute_distortion_scale(k1, w, h) + return negpy_core.remap_radial(np.ascontiguousarray(img, dtype=np.float32), (cx, cy), s, k1, 1.0 / (halfdiag * halfdiag)) def map_point_radial(px: float, py: float, k1: float, w: int, h: int) -> Tuple[float, float]: @@ -590,22 +519,8 @@ def apply_keystone(img: ImageBuffer, converge_v: float, converge_h: float) -> Im if abs(converge_v) < _KEYSTONE_EPS and abs(converge_h) < _KEYSTONE_EPS: return img h, w = img.shape[:2] - if _too_big_to_warp(img): - hi = np.linalg.inv(keystone_matrix(converge_v, converge_h, w, h)) - - def source_of(xs: np.ndarray, ys: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - d = hi[2, 0] * xs + hi[2, 1] * ys + hi[2, 2] - return (hi[0, 0] * xs + hi[0, 1] * ys + hi[0, 2]) / d, (hi[1, 0] * xs + hi[1, 1] * ys + hi[1, 2]) / d - - return ensure_image(_remap_tiled(img, source_of)) - res = cv2.warpPerspective( - img, - keystone_matrix(converge_v, converge_h, w, h), - (w, h), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE, - ) - return ensure_image(res) + m = keystone_matrix(converge_v, converge_h, w, h) + return negpy_core.warp_perspective(np.ascontiguousarray(img, dtype=np.float32), m, (w, h)) def map_point_keystone(px: float, py: float, converge_v: float, converge_h: float, w: int, h: int) -> Tuple[float, float]: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index f0c693a09..175a02185 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -20,3 +20,4 @@ tolerance listed for each entry. | `features/exposure/analysis.py::vectorscope_histogram` | atan2 and log1p are `mathf`'s, not SVML's; a sample on a bin edge can change bin (none did in 39 recorded frames). | abs=2 | | `features/exposure/logic.py::apply_characteristic_curve` (the print curve) | `exp`, `log1p`, `tanh` and 10^x are `mathf`'s, not glibc's; 2 of 205 M recorded values round the other way. Every feature branch (masks, flash, diffusion, dye mix, separation damping, the print LUTs) matched the Numba kernel bit for bit on test frames. | ulp=1 | | `features/process/sensor.py::_log_unmix_kernel` (density unmix) | `logf` and `expf` are `mathf`'s (correctly rounded) instead of glibc's; the log-domain matrix and the exp amplify a one-ULP log to up to 16 ULP on 0.2 % of values. The other three unmix kernels match Numba bit for bit. | ulp=16 | +| `features/geometry/logic.py::apply_fine_rotation`, `apply_keystone`, `apply_radial_distortion`, frames of 32767 px or more on a side | OpenCV cannot warp such a frame, so the Python version warped it in tiles from float64 source maps. negpy_core warps the whole frame with OpenCV's fixed-point coordinates, which can move a sample by 1/32 px. Smaller frames match `cv2` bit for bit. | none (the corpus has no such frame) | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 9f125ad9f..4bc893a18 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -659,12 +659,6 @@ calls = ["negpy_core.refine_roi_to_image"] depends = [] note = "negpy_core::analysis::refine_roi_to_image" -["features/geometry/logic.py::_remap_tiled"] -phase = 2 -status = "todo" -calls = ["cv2.remap"] -depends = [] - ["features/geometry/logic.py::_roi_from_measured_border"] phase = 2 status = "ported" @@ -737,21 +731,21 @@ depends = [] ["features/geometry/logic.py::apply_fine_rotation"] phase = 2 -status = "todo" -calls = ["cv2.getRotationMatrix2D", "cv2.invertAffineTransform", "cv2.warpAffine"] -depends = ["_remap_tiled"] +status = "ported" +calls = ["cv2.getRotationMatrix2D", "negpy_core.warp_affine"] +depends = [] ["features/geometry/logic.py::apply_keystone"] phase = 2 -status = "todo" -calls = ["cv2.warpPerspective"] -depends = ["_remap_tiled", "keystone_matrix"] +status = "ported" +calls = ["negpy_core.warp_perspective"] +depends = ["keystone_matrix"] ["features/geometry/logic.py::apply_radial_distortion"] phase = 2 -status = "todo" -calls = ["cv2.remap"] -depends = ["_remap_tiled"] +status = "ported" +calls = ["negpy_core.remap_radial"] +depends = [] ["features/geometry/logic.py::compute_geometry_crop_rect"] phase = 2 diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index bea3adfa6..406aafcc6 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -22,6 +22,7 @@ pub mod sensor; pub mod shapes; pub mod stats; pub mod toning; +pub mod warp; /// `f(dst[i], src[i])` for every i, in parallel blocks: a rayon task per element costs more than /// the work. Pass a `move` closure: a capture read through a reference may alias `dst`, so the @@ -59,5 +60,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::sensor::register(m)?; py::hdr::register(m)?; py::display::register(m)?; - py::analysis::register(m) + py::analysis::register(m)?; + py::warp::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index f7b0a9c82..df917adc3 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -17,6 +17,7 @@ pub mod retouch; pub mod sensor; pub mod stats; pub mod toning; +pub mod warp; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/negpy_core/src/py/warp.rs b/negpy_core/src/py/warp.rs new file mode 100644 index 000000000..122261328 --- /dev/null +++ b/negpy_core/src/py/warp.rs @@ -0,0 +1,130 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::warp::{self, Image}; + +fn image<'a>(src: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult> { + let (h, w, cn) = match src.shape() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs an HxW or HxWxC image")), + }; + if h == 0 || w == 0 || cn == 0 { + return Err(PyValueError::new_err("needs a non-empty image")); + } + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + Ok(Image { data, h, w, cn }) +} + +/// A new float32 output of `dh` x `dw` with the source's channel layout, filled by `f`. +fn output<'py>( + py: Python<'py>, + src: &PyReadonlyArrayDyn<'py, f32>, + (dw, dh): (usize, usize), + f: impl FnOnce(&mut [f32]) + Send, +) -> PyResult>> { + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&src.shape()[2..]); + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || f(dst)); + } + Ok(out) +} + +fn matrix(m: &PyReadonlyArray2<'_, f64>, rows: usize) -> PyResult<[f64; N]> { + let a = m.as_array(); + if a.shape() != [rows, 3] { + return Err(PyValueError::new_err(format!("needs a {rows}x3 matrix"))); + } + Ok(std::array::from_fn(|i| a[[i / 3, i % 3]])) +} + +/// `cv2.remap(src, map_x, map_y, INTER_LINEAR, borderMode=BORDER_REPLICATE)` of a float32 image. +#[pyfunction] +fn remap_linear<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + map_x: PyReadonlyArray2<'py, f32>, + map_y: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let img = image(&src)?; + if map_x.shape() != map_y.shape() { + return Err(PyValueError::new_err("the maps differ in size")); + } + let (dh, dw) = (map_x.shape()[0], map_x.shape()[1]); + let not_contiguous = |_| PyValueError::new_err("needs contiguous maps"); + let mx = map_x.as_slice().map_err(not_contiguous)?; + let my = map_y.as_slice().map_err(not_contiguous)?; + output(py, &src, (dw, dh), |dst| { + warp::remap_linear(&img, mx, my, dw, dst) + }) +} + +/// `apply_radial_distortion` of a float32 image: the remap with the scale-to-fill `s`, the +/// center and 1 / halfdiag^2, each rounded to float32. +#[pyfunction] +fn remap_radial<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + center: (f32, f32), + s: f32, + k1: f32, + inv_hd2: f32, +) -> PyResult>> { + let img = image(&src)?; + output(py, &src, (img.w, img.h), |dst| { + warp::remap_radial(&img, center, s, k1, inv_hd2, dst) + }) +} + +/// `cv2.warpAffine(src, m, dsize, INTER_LINEAR, borderMode=BORDER_REPLICATE)` of a float32 image. +#[pyfunction] +#[pyo3(signature = (src, m, dsize, inverse_map=false))] +fn warp_affine<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f64>, + dsize: (usize, usize), + inverse_map: bool, +) -> PyResult>> { + let img = image(&src)?; + let m = matrix::<6>(&m, 2)?; + output(py, &src, dsize, |dst| { + warp::warp_affine(&img, m, inverse_map, dsize.0, dst) + }) +} + +/// `cv2.warpPerspective(src, m, dsize, INTER_LINEAR, borderMode=BORDER_REPLICATE)` of a float32 +/// image. +#[pyfunction] +#[pyo3(signature = (src, m, dsize, inverse_map=false))] +fn warp_perspective<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f64>, + dsize: (usize, usize), + inverse_map: bool, +) -> PyResult>> { + let img = image(&src)?; + let m = matrix::<9>(&m, 3)?; + output(py, &src, dsize, |dst| { + warp::warp_perspective(&img, m, inverse_map, dsize, dst) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(remap_linear, m)?)?; + m.add_function(wrap_pyfunction!(remap_radial, m)?)?; + m.add_function(wrap_pyfunction!(warp_affine, m)?)?; + m.add_function(wrap_pyfunction!(warp_perspective, m)?) +} diff --git a/negpy_core/src/warp.rs b/negpy_core/src/warp.rs new file mode 100644 index 000000000..7592268f1 --- /dev/null +++ b/negpy_core/src/warp.rs @@ -0,0 +1,321 @@ +//! OpenCV's bilinear warps of a float32 image with replicated edges: `remap`, `warpAffine` and +//! `warpPerspective`. Each source coordinate is fixed point at 1/32 px, rounded as OpenCV rounds +//! it; the four weights are its table's float32 products, summed left to right. + +use multiversion::multiversion; +use rayon::prelude::*; + +/// An HxWxC float32 image. +#[derive(Clone, Copy)] +pub struct Image<'a> { + pub data: &'a [f32], + pub h: usize, + pub w: usize, + pub cn: usize, +} + +const BITS: i32 = 5; +const MASK: i32 = (1 << BITS) - 1; +const STEP: f32 = 1.0 / (1 << BITS) as f32; + +/// The pixel at fixed-point source coordinates (x, y) into `out`, edges replicated. `CN` is +/// the channel count, or 0 for one known only at run time. +#[inline(always)] +fn sample(src: &Image, x: i32, y: i32, out: &mut [f32]) { + let (fx, fy) = ((x & MASK) as f32 * STEP, (y & MASK) as f32 * STEP); + let (ix, iy) = (x >> BITS, y >> BITS); + let w0 = (1.0 - fy) * (1.0 - fx); + let w1 = (1.0 - fy) * fx; + let w2 = fy * (1.0 - fx); + let w3 = fy * fx; + let cn = if CN == 0 { src.cn } else { CN }; + let row = src.w * cn; + let s = src.data; + let (wl, hl) = (src.w as i32 - 1, src.h as i32 - 1); + if (ix as u32) < wl as u32 && (iy as u32) < hl as u32 { + let a = iy as usize * row + ix as usize * cn; + let (p, q) = (&s[a..][..2 * cn], &s[a + row..][..2 * cn]); + for (k, o) in out[..cn].iter_mut().enumerate() { + *o = p[k] * w0 + p[cn + k] * w1 + q[k] * w2 + q[cn + k] * w3; + } + return; + } + let (x0, x1) = (ix.clamp(0, wl) as usize, (ix + 1).clamp(0, wl) as usize); + let (y0, y1) = (iy.clamp(0, hl) as usize, (iy + 1).clamp(0, hl) as usize); + let (a, b) = (y0 * row + x0 * cn, y0 * row + x1 * cn); + let (c, d) = (y1 * row + x0 * cn, y1 * row + x1 * cn); + for (k, o) in out[..cn].iter_mut().enumerate() { + *o = s[a + k] * w0 + s[b + k] * w1 + s[c + k] * w2 + s[d + k] * w3; + } +} + +fn sample_row(src: &Image, xs: &[i32], ys: &[i32], row: &mut [f32]) { + let cn = if CN == 0 { src.cn } else { CN }; + for ((o, &x), &y) in row.chunks_exact_mut(cn).zip(xs).zip(ys) { + sample::(src, x, y, o); + } +} + +/// Fills a `dw`-wide output, row by row in parallel, from `coords(y, xs, ys)`, which writes the +/// fixed-point source coordinates of output row y. +fn warp( + src: &Image, + dw: usize, + out: &mut [f32], + coords: impl Fn(usize, &mut [i32], &mut [i32]) + Sync, +) { + let cn = src.cn; + out.par_chunks_mut(dw * cn).enumerate().for_each_init( + || (vec![0i32; dw], vec![0i32; dw]), + |(xs, ys), (y, row)| { + coords(y, xs, ys); + match cn { + 1 => sample_row::<1>(src, xs, ys, row), + 3 => sample_row::<3>(src, xs, ys, row), + 4 => sample_row::<4>(src, xs, ys, row), + _ => sample_row::<0>(src, xs, ys, row), + } + }, + ); +} + +/// OpenCV's `cvRound` of a float64 into an int, ties to even, for |v| < 2^30: added to 1.5 * 2^52, +/// the integer fills the low mantissa bits. `round_ties_even` is a libm call on the x86-64 +/// baseline, and the saturating `as` cast keeps LLVM from vectorizing. +#[inline(always)] +fn round_i32(v: f64) -> i32 { + (v + 6755399441055744.0).to_bits() as u32 as i32 +} + +/// Coordinates far outside any image, held where `round_i32` stays exact. +const FAR: f64 = (1u32 << 30) as f64; + +/// `cv2.remap(src, map_x, map_y, INTER_LINEAR, borderMode=BORDER_REPLICATE)`; the maps are +/// float32, one entry per output pixel, `dw` wide. +pub fn remap_linear(src: &Image, map_x: &[f32], map_y: &[f32], dw: usize, out: &mut [f32]) { + warp(src, dw, out, move |y, xs, ys| { + remap_coords(&map_x[y * dw..][..dw], &map_y[y * dw..][..dw], xs, ys) + }); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn remap_coords(mx: &[f32], my: &[f32], xs: &mut [i32], ys: &mut [i32]) { + let scale = (1 << BITS) as f32; + for (((x, y), &a), &b) in xs.iter_mut().zip(ys.iter_mut()).zip(mx).zip(my) { + *x = round_i32(((a * scale) as f64).clamp(-FAR, FAR)); + *y = round_i32(((b * scale) as f64).clamp(-FAR, FAR)); + } +} + +/// The radial lens correction of a same-size image (`apply_radial_distortion`): the remap +/// source of output pixel p is c + (p - c) s f with f = 1 + k1 |(p - c) s|^2 inv_hd2, in float32 +/// as NumPy builds the maps. +pub fn remap_radial( + src: &Image, + (cx, cy): (f32, f32), + s: f32, + k1: f32, + inv_hd2: f32, + out: &mut [f32], +) { + let dw = src.w; + warp(src, dw, out, move |y, xs, ys| { + radial_coords(y as f32, (cx, cy), s, k1, inv_hd2, xs, ys) + }); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn radial_coords( + y: f32, + (cx, cy): (f32, f32), + s: f32, + k1: f32, + inv_hd2: f32, + xs: &mut [i32], + ys: &mut [i32], +) { + let scale = (1 << BITS) as f32; + let py = (y - cy) * s; + for (i, (x, yv)) in xs.iter_mut().zip(ys.iter_mut()).enumerate() { + let px = (i as f32 - cx) * s; + let f = 1.0 + k1 * (px * px + py * py) * inv_hd2; + *x = round_i32((((cx + px * f) * scale) as f64).clamp(-FAR, FAR)); + *yv = round_i32((((cy + py * f) * scale) as f64).clamp(-FAR, FAR)); + } +} + +/// `cv2.warpAffine(src, m, (dw, dh), INTER_LINEAR, BORDER_REPLICATE)`; `m` maps source to +/// output unless `inverse_map` (OpenCV's WARP_INVERSE_MAP). +pub fn warp_affine(src: &Image, m: [f64; 6], inverse_map: bool, dw: usize, out: &mut [f32]) { + let mut m = m; + if !inverse_map { + let d = m[0] * m[4] - m[1] * m[3]; + let d = if d != 0.0 { 1.0 / d } else { 0.0 }; + let (a11, a22) = (m[4] * d, m[0] * d); + m[0] = a11; + m[1] *= -d; + m[3] *= -d; + m[4] = a22; + let b1 = -m[0] * m[2] - m[1] * m[5]; + let b2 = -m[3] * m[2] - m[4] * m[5]; + m[2] = b1; + m[5] = b2; + } + const AB_SCALE: f64 = 1024.0; + const ROUND: i32 = 1 << (10 - BITS - 1); + let adelta: Vec = (0..dw) + .map(|x| round_i32((m[0] * x as f64 * AB_SCALE).clamp(-FAR, FAR))) + .collect(); + let bdelta: Vec = (0..dw) + .map(|x| round_i32((m[3] * x as f64 * AB_SCALE).clamp(-FAR, FAR))) + .collect(); + warp(src, dw, out, move |y, xs, ys| { + let x0 = round_i32(((m[1] * y as f64 + m[2]) * AB_SCALE).clamp(-FAR, FAR)) + ROUND; + let y0 = round_i32(((m[4] * y as f64 + m[5]) * AB_SCALE).clamp(-FAR, FAR)) + ROUND; + for (i, (x, yv)) in xs.iter_mut().zip(ys.iter_mut()).enumerate() { + *x = x0.wrapping_add(adelta[i]) >> (10 - BITS); + *yv = y0.wrapping_add(bdelta[i]) >> (10 - BITS); + } + }); +} + +/// OpenCV's closed-form 3x3 inverse (`cv::invert`, DECOMP_LU, n <= 3); None when singular. +fn invert3(s: [f64; 9]) -> Option<[f64; 9]> { + let e = |i: usize, j: usize| s[i * 3 + j]; + let d = e(0, 0) * (e(1, 1) * e(2, 2) - e(1, 2) * e(2, 1)) + - e(0, 1) * (e(1, 0) * e(2, 2) - e(1, 2) * e(2, 0)) + + e(0, 2) * (e(1, 0) * e(2, 1) - e(1, 1) * e(2, 0)); + if d == 0.0 { + return None; + } + let d = 1.0 / d; + Some([ + (e(1, 1) * e(2, 2) - e(1, 2) * e(2, 1)) * d, + (e(0, 2) * e(2, 1) - e(0, 1) * e(2, 2)) * d, + (e(0, 1) * e(1, 2) - e(0, 2) * e(1, 1)) * d, + (e(1, 2) * e(2, 0) - e(1, 0) * e(2, 2)) * d, + (e(0, 0) * e(2, 2) - e(0, 2) * e(2, 0)) * d, + (e(0, 2) * e(1, 0) - e(0, 0) * e(1, 2)) * d, + (e(1, 0) * e(2, 1) - e(1, 1) * e(2, 0)) * d, + (e(0, 1) * e(2, 0) - e(0, 0) * e(2, 1)) * d, + (e(0, 0) * e(1, 1) - e(0, 1) * e(1, 0)) * d, + ]) +} + +/// `cv2.warpPerspective(src, m, (dw, dh), INTER_LINEAR, BORDER_REPLICATE)`; `m` maps source to +/// output unless `inverse_map`. A singular `m` inverts to zeros, as `cv::invert` leaves it. +pub fn warp_perspective( + src: &Image, + m: [f64; 9], + inverse_map: bool, + (dw, dh): (usize, usize), + out: &mut [f32], +) { + let m = if inverse_map { + m + } else { + invert3(m).unwrap_or([0.0; 9]) + }; + // OpenCV starts the projective sums again at each block of bw0 columns. + let bh0 = dh.min(16); + let bw0 = (1024 / bh0).min(dw); + warp(src, dw, out, move |y, xs, ys| { + let yf = y as f64; + for (b, (xs, ys)) in xs.chunks_mut(bw0).zip(ys.chunks_mut(bw0)).enumerate() { + let xb = (b * bw0) as f64; + let x0 = m[0] * xb + m[1] * yf + m[2]; + let y0 = m[3] * xb + m[4] * yf + m[5]; + let w0 = m[6] * xb + m[7] * yf + m[8]; + persp_coords([x0, y0, w0], [m[0], m[3], m[6]], xs, ys); + } + }); +} + +/// One block's coordinates from the projective sums `start` at its first column and their +/// steps per column. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn persp_coords(start: [f64; 3], step: [f64; 3], xs: &mut [i32], ys: &mut [i32]) { + for (i, (x, y)) in xs.iter_mut().zip(ys.iter_mut()).enumerate() { + let x1 = i as f64; + let w = start[2] + step[2] * x1; + let w = if w != 0.0 { + (1 << BITS) as f64 / w + } else { + 0.0 + }; + *x = round_i32(((start[0] + step[0] * x1) * w).clamp(-FAR, FAR)); + *y = round_i32(((start[1] + step[1] * x1) * w).clamp(-FAR, FAR)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ramp(h: usize, w: usize, cn: usize) -> Vec { + (0..h * w * cn) + .map(|i| (i % 7) as f32 * 0.25 - 0.5) + .collect() + } + + #[test] + fn identity_affine_copies() { + let data = ramp(9, 13, 3); + let src = Image { + data: &data, + h: 9, + w: 13, + cn: 3, + }; + let mut out = vec![0.0; data.len()]; + warp_affine(&src, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0], false, 13, &mut out); + assert_eq!(out, data); + warp_perspective( + &src, + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], + false, + (13, 9), + &mut out, + ); + assert_eq!(out, data); + } + + #[test] + fn remap_half_pixel_averages_and_replicates() { + let data = vec![0.0, 1.0, 2.0, 3.0]; + let src = Image { + data: &data, + h: 1, + w: 4, + cn: 1, + }; + let mut out = vec![0.0; 3]; + remap_linear(&src, &[0.5, -3.0, 9.0], &[0.0; 3], 3, &mut out); + assert_eq!(out, [0.5, 0.0, 3.0]); + } + + #[test] + fn invert3_matches_identity_product() { + let m = [2.0, 0.5, 1.0, 0.25, 3.0, -1.0, 0.001, 0.002, 1.0]; + let inv = invert3(m).unwrap(); + for i in 0..3 { + for j in 0..3 { + let p: f64 = (0..3).map(|k| m[i * 3 + k] * inv[k * 3 + j]).sum(); + assert!((p - (i == j) as i32 as f64).abs() < 1e-12); + } + } + assert!(invert3([0.0; 9]).is_none()); + } +} diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index db284e239..222e5dddc 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -69,6 +69,11 @@ def _filled(fill): ), "lut_f32": lambda: negpy_core.apply_lut_f32(_IMG3, _LUT), "matrix_trc_u8": lambda: negpy_core.matrix_trc_u8((_IMG3 * 255).astype(np.uint8), np.ascontiguousarray(_RAMP[:, ::256]), _M, _RAMP), + "warp_affine": lambda: negpy_core.warp_affine(_IMG3, np.array([[0.998, 0.061, -3.1], [-0.061, 0.998, 4.2]]), (131, 97)), + "warp_perspective": lambda: negpy_core.warp_perspective( + _IMG1, np.array([[1.02, 0.03, -1.5], [-0.01, 0.97, 2.25], [1e-4, -2e-4, 1.0]]), (61, 83) + ), + "remap_radial": lambda: negpy_core.remap_radial(_IMG3, (65.0, 48.0), 0.93, -0.05, 1.0 / 6600.0), } _HASHES = { @@ -97,6 +102,9 @@ def _filled(fill): "sensor_log_unmix": "f7eb8efe1bbcfe884fd2e849e99d0546fac2f4829d3fb558d54a0128e681e53b", "lut_f32": "467bb9fd495d4797d912f6aeabd34f9a61b56491105464a0c852ed524488b1c1", "matrix_trc_u8": "acec1eadffb13dfef8473a02fb250b4ad8f57ac211a6265661b77d3cad49ab16", + "warp_affine": "83562b3183484dcbf80fc65c4fb2f241d3b13ec9793cf43fd22bf11ea44d9e1d", + "warp_perspective": "61e89302e793e24f1ad131b661e95186d7d9a82e8331a96164e338bec3ec1420", + "remap_radial": "a23f3b21f15cb5d9db26cc532ec920dc8b194d73515a786d9febc93ae17370e0", } diff --git a/tests/test_core_warp.py b/tests/test_core_warp.py new file mode 100644 index 000000000..9ee16a11f --- /dev/null +++ b/tests/test_core_warp.py @@ -0,0 +1,61 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.geometry.logic import keystone_matrix + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(11) +_REPLICATE = {"flags": cv2.INTER_LINEAR, "borderMode": cv2.BORDER_REPLICATE} + + +def _image(h, w, cn): + return _RNG.random((h, w, cn) if cn > 1 else (h, w), dtype=np.float32) * 3 - 1 + + +@pytest.mark.parametrize("cn", [1, 2, 3, 4, 5]) +@pytest.mark.parametrize("shape, dshape", [((97, 131), (80, 150)), ((1, 1), (3, 4)), ((300, 517), (300, 517))]) +def test_remap_linear_matches_cv2(cn, shape, dshape): + img = _image(*shape, cn) + mx = (_RNG.random(dshape) * (shape[1] + 20) - 10).astype(np.float32) + my = (_RNG.random(dshape) * (shape[0] + 20) - 10).astype(np.float32) + want = cv2.remap(img, mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + assert negpy_core.remap_linear(img, mx, my).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("angle", [0.7, -3.3, 12.0, 90.0]) +@pytest.mark.parametrize("shape", [(97, 131), (513, 260), (2, 3)]) +def test_warp_affine_matches_cv2(cn, angle, shape): + img = _image(*shape, cn) + h, w = shape + m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + for dsize in ((w, h), (w + 7, h - 1)): + want = cv2.warpAffine(img, m, dsize, **_REPLICATE) + assert negpy_core.warp_affine(img, m, dsize).tobytes() == want.tobytes() + inv = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR | cv2.WARP_INVERSE_MAP, borderMode=cv2.BORDER_REPLICATE) + assert negpy_core.warp_affine(img, m, (w, h), inverse_map=True).tobytes() == inv.tobytes() + + +@pytest.mark.parametrize("cn", [1, 3]) +@pytest.mark.parametrize("converge", [(3.0, -2.0), (-7.5, 1.25), (0.0, 20.0)]) +@pytest.mark.parametrize("shape", [(97, 131), (700, 1030), (5, 9)]) +def test_warp_perspective_matches_cv2(cn, converge, shape): + img = _image(*shape, cn) + h, w = shape + m = keystone_matrix(*converge, w, h) + want = cv2.warpPerspective(img, m, (w, h), **_REPLICATE) + assert negpy_core.warp_perspective(img, m, (w, h)).tobytes() == want.tobytes() + + +def test_warps_reject_bad_input(): + img = _image(10, 10, 3) + with pytest.raises(ValueError): + negpy_core.warp_affine(img[:, ::2], np.eye(2, 3), (5, 10)) + with pytest.raises(ValueError): + negpy_core.warp_perspective(img, np.eye(2, 3), (10, 10)) + with pytest.raises(ValueError): + negpy_core.remap_linear(img, np.zeros((4, 4), np.float32), np.zeros((4, 5), np.float32)) diff --git a/tests/test_geometry_distortion.py b/tests/test_geometry_distortion.py index d301e344b..7b5ed8897 100644 --- a/tests/test_geometry_distortion.py +++ b/tests/test_geometry_distortion.py @@ -4,8 +4,10 @@ import numpy as np import pytest +from core_oracle import cv2_oracle + from negpy.features.geometry.logic import ( - _radial_maps, + _radial_center, apply_radial_distortion, compute_distortion_scale, map_point_radial, @@ -16,6 +18,30 @@ _K1_RANGE = [-0.1, -0.02, -0.001, 0.001, 0.02, 0.1] +def _radial_maps(k1: float, w: int, h: int) -> tuple[np.ndarray, np.ndarray]: + """The cv2.remap source maps of the radial correction, built in NumPy.""" + cx, cy, halfdiag = _radial_center(w, h) + s = compute_distortion_scale(k1, w, h) + inv_hd2 = 1.0 / (halfdiag * halfdiag) + ys, xs = np.meshgrid(np.arange(h, dtype=np.float32), np.arange(w, dtype=np.float32), indexing="ij") + px = (xs - cx) * s + py = (ys - cy) * s + f = 1.0 + k1 * (px * px + py * py) * inv_hd2 + return (cx + px * f).astype(np.float32), (cy + py * f).astype(np.float32) + + +@cv2_oracle +@pytest.mark.parametrize("k1", _K1_RANGE) +@pytest.mark.parametrize("shape", [(60, 90, 3), (131, 77), (40, 50, 4)]) +def test_correction_is_cv2_remap_of_the_numpy_maps(k1, shape): + import cv2 + + img = np.random.default_rng(3).random(shape, dtype=np.float32) + map_x, map_y = _radial_maps(k1, shape[1], shape[0]) + want = cv2.remap(img, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + assert apply_radial_distortion(img, k1).tobytes() == want.tobytes() + + def test_zero_k1_is_identity(): img = np.random.rand(40, 60, 3).astype(np.float32) out = apply_radial_distortion(img, 0.0) @@ -209,28 +235,6 @@ def test_mapping_many_points_solves_the_distortion_scale_once(): assert logic.compute_distortion_scale.cache_info().misses == 1 -@pytest.mark.parametrize( - "warp", - [ - lambda img: logic_mod().apply_fine_rotation(img, 0.7), - lambda img: logic_mod().apply_radial_distortion(img, -0.08), - lambda img: logic_mod().apply_keystone(img, 6.0, -4.0), - ], -) -def test_a_tiled_warp_matches_the_whole_frame_warp(warp, monkeypatch): - logic = logic_mod() - import cv2 - - # Smooth, so OpenCV's 1/32 px sample quantization cannot dominate the comparison. - noise = np.random.default_rng(2).uniform(0, 1, (210, 330, 3)).astype(np.float32) - img = cv2.GaussianBlur(noise, (0, 0), 4.0) - whole = warp(img) - monkeypatch.setattr(logic, "_CV_WARP_MAX", 100) - monkeypatch.setattr(logic, "_WARP_TILE", 64) - tiled = warp(img) - np.testing.assert_allclose(tiled, whole, atol=2e-3) - - def test_a_frame_past_the_opencv_size_limit_still_warps(): logic = logic_mod() img = np.full((24, 33000, 3), 0.5, dtype=np.float32) From cd451511b8aaa8ea5b4028e10d9f798b89ba73d0 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 10:52:56 +0200 Subject: [PATCH 068/133] perf(core): bicubic, zero-border and nearest warps; stitch, HDR, lens use them negpy_core's remap, warpAffine and warpPerspective gain OpenCV's bicubic kernel (its 32x32 weight table, rows of four summed then added, every term in turn at the border), BORDER_CONSTANT and nearest remap. 243 oracle cases match cv2 4.13 bit for bit. Stitch placement, HDR and RGB-scan alignment, lens correction, the transport-scratch shear and the IR overlay layers call them. One thread, 24 MP, against cv2 on one thread: bicubic affine 0.68x, bicubic remap 0.55x, bilinear 0.69-0.73x, nearest remap 0.90x. --- negpy/desktop/view/canvas/overlay.py | 5 +- negpy/features/hdr/logic.py | 3 +- negpy/features/lens/logic.py | 4 +- negpy/features/retouch/logic.py | 2 +- negpy/features/rgbscan/logic.py | 3 +- negpy/features/stitch/logic.py | 12 +- negpy_core/manifest.toml | 24 +-- negpy_core/src/py/warp.rs | 56 +++-- negpy_core/src/warp.rs | 308 +++++++++++++++++++++++---- tests/test_core_same_bits.py | 8 + tests/test_core_warp.py | 42 +++- 11 files changed, 385 insertions(+), 82 deletions(-) diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 140a4a028..1698115b9 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional, Tuple import cv2 +import negpy_core import numpy as np import qtawesome as qta from PyQt6.QtCore import QEvent, QLineF, QPointF, QRectF, QSize, Qt, QTimer, pyqtSignal @@ -2372,7 +2373,7 @@ def _ir_layer_qimage(self) -> Optional[QImage]: h_ir, w_ir = ir.shape[:2] map_x = (uv_grid[..., 0] * (w_ir - 1)).astype(np.float32) map_y = (uv_grid[..., 1] * (h_ir - 1)).astype(np.float32) - remapped = cv2.remap(np.ascontiguousarray(ir, dtype=np.float32), map_x, map_y, interpolation=cv2.INTER_LINEAR) + remapped = negpy_core.remap(np.ascontiguousarray(ir, dtype=np.float32), map_x, map_y, border="constant") gray = np.ascontiguousarray((np.clip(remapped, 0.0, 1.0) * 255.0).astype(np.uint8)) gh, gw = gray.shape[:2] img = QImage(gray.data, gw, gh, gw, QImage.Format.Format_Grayscale8).copy() @@ -2410,7 +2411,7 @@ def _mask_wash_qimage(self, mask: np.ndarray, color: QColor) -> Optional[QImage] h_m, w_m = mask.shape[:2] map_x = (uv_grid[..., 0] * (w_m - 1)).astype(np.float32) map_y = (uv_grid[..., 1] * (h_m - 1)).astype(np.float32) - remapped = cv2.remap(np.ascontiguousarray(mask, dtype=np.float32), map_x, map_y, interpolation=cv2.INTER_NEAREST) + remapped = negpy_core.remap(np.ascontiguousarray(mask, dtype=np.float32), map_x, map_y, "nearest", "constant") img = _tint_qimage([(remapped > 0.5, color)], _IR_CORRECTED_ALPHA) if len(self._wash_cache) > 8: # drop stale ids from prior frames self._wash_cache.clear() diff --git a/negpy/features/hdr/logic.py b/negpy/features/hdr/logic.py index 87492e254..89c9a6c56 100644 --- a/negpy/features/hdr/logic.py +++ b/negpy/features/hdr/logic.py @@ -18,7 +18,6 @@ from dataclasses import dataclass from typing import Callable, List, Optional, Sequence, Tuple -import cv2 import negpy_core import numpy as np @@ -393,7 +392,7 @@ def _align(ref_gray: np.ndarray, mov: np.ndarray, max_shift: float) -> np.ndarra return mov h, w = mov.shape[:2] matrix = np.array([[1.0, 0.0, -dx], [0.0, 1.0, -dy]], dtype=np.float32) - return cv2.warpAffine(mov, matrix, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + return negpy_core.warp_affine(np.ascontiguousarray(mov, dtype=np.float32), matrix.astype(np.float64), (w, h), interpolation="cubic") def merge_frames( diff --git a/negpy/features/lens/logic.py b/negpy/features/lens/logic.py index 0151eb841..b8aec4200 100644 --- a/negpy/features/lens/logic.py +++ b/negpy/features/lens/logic.py @@ -3,7 +3,7 @@ from dataclasses import replace from functools import lru_cache -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer @@ -130,7 +130,7 @@ def apply_lens( for start in range(0, h, 256): stop = min(start + 256, h) mx, my = warp.remap(warp_lens, source.shape, start, stop, channel, corrections) - result[start:stop, :, channel] = cv2.remap(plane, mx, my, cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + result[start:stop, :, channel] = negpy_core.remap(plane, mx, my, "cubic") # Flat-field gains can exceed white before sensor unmix. source = np.maximum(result, 0.0, out=result) return np.ascontiguousarray(apply_exif_orientation(source, orientation)) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 3ffd0eae9..df9102aa1 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -595,7 +595,7 @@ def _shear_rows(plane: np.ndarray, slope: float, about_x: float, width: int) -> ``row``. """ m = np.float32([[1.0, 0.0, 0.0], [-slope, 1.0, slope * about_x]]) - return cv2.warpAffine(plane, m, (width, plane.shape[0]), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + return negpy_core.warp_affine(np.ascontiguousarray(plane, dtype=np.float32), m.astype(np.float64), (width, plane.shape[0])) def trace_scratch(img: ImageBuffer, nx: float, ny: float, threshold: float = 0.5) -> Optional[Tuple[float, float, float, float, float]]: diff --git a/negpy/features/rgbscan/logic.py b/negpy/features/rgbscan/logic.py index 4358368a4..1a20e6ee6 100644 --- a/negpy/features/rgbscan/logic.py +++ b/negpy/features/rgbscan/logic.py @@ -3,6 +3,7 @@ from typing import Callable, Dict, List, Optional, Sequence, Tuple import cv2 +import negpy_core import numpy as np from negpy.domain.tokens import composite_token @@ -286,7 +287,7 @@ def _align_to(ref_gray: np.ndarray, mov: np.ndarray, mov_ch: int, max_shift: flo return mov h, w = mov.shape[:2] matrix = np.array([[1.0, 0.0, -dx], [0.0, 1.0, -dy]], dtype=np.float32) - return cv2.warpAffine(mov, matrix, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + return negpy_core.warp_affine(np.ascontiguousarray(mov, dtype=np.float32), matrix.astype(np.float64), (w, h), interpolation="cubic") def assemble_rgb(r: np.ndarray, g: np.ndarray, b: np.ndarray, align: bool = True) -> np.ndarray: diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 904bf047c..270a015bc 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -8,6 +8,7 @@ from typing import Callable, List, Optional, Sequence, Tuple import cv2 +import negpy_core import numpy as np from negpy.features.stitch.models import StitchConfig @@ -142,13 +143,16 @@ def warp_into_canvas( img: np.ndarray, transform: np.ndarray, canvas_wh: Tuple[int, int], - interpolation: int = cv2.INTER_CUBIC, + interpolation: str = "cubic", ) -> Tuple[np.ndarray, np.ndarray]: """Warp a part and its validity mask into the canvas. The mask is eroded by the >0.999 threshold so resampled border pixels never bleed into the blend.""" - warped = cv2.warpAffine(img, transform.astype(np.float32), canvas_wh, flags=interpolation) + m = transform.astype(np.float32).astype(np.float64) + warped = negpy_core.warp_affine( + np.ascontiguousarray(img, dtype=np.float32), m, canvas_wh, interpolation=interpolation, border="constant" + ) ones = np.ones(img.shape[:2], np.float32) - mask = cv2.warpAffine(ones, transform.astype(np.float32), canvas_wh, flags=cv2.INTER_LINEAR) + mask = negpy_core.warp_affine(ones, m, canvas_wh, border="constant") return warped, mask > 0.999 @@ -310,7 +314,7 @@ def blend_ir( w, h = canvas_wh acc = np.full((h, w), -1.0, np.float32) for i, (ir, transform) in enumerate(zip(irs, transforms)): - warped, mask = warp_into_canvas(ir, transform, canvas_wh, interpolation=cv2.INTER_LINEAR) + warped, mask = warp_into_canvas(ir, transform, canvas_wh, interpolation="linear") if seams is not None: owned = seams[i] acc[owned] = warped[owned] diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 4bc893a18..5fb1e1c33 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1233,7 +1233,7 @@ depends = ["_edge_reads"] ["features/lens/logic.py::apply_lens"] phase = 4 status = "todo" -calls = ["cv2.remap"] +calls = ["negpy_core.remap"] depends = ["fill_scale"] ["features/lens/logic.py::fill_scale"] @@ -1532,8 +1532,8 @@ depends = ["_scratch_ridge"] ["features/retouch/logic.py::_shear_rows"] phase = 4 -status = "todo" -calls = ["cv2.warpAffine"] +status = "ported" +calls = ["negpy_core.warp_affine"] depends = [] ["features/retouch/logic.py::apply_ir_attenuation"] @@ -1646,8 +1646,8 @@ depends = [] ["features/hdr/logic.py::_align"] phase = 5 -status = "todo" -calls = ["cv2.warpAffine"] +status = "ported" +calls = ["negpy_core.warp_affine"] depends = [] ["features/hdr/logic.py::_count_clipped_rows"] @@ -1737,7 +1737,7 @@ depends = ["pair_ratio"] ["features/rgbscan/logic.py::_align_to"] phase = 5 status = "todo" -calls = ["cv2.warpAffine"] +calls = ["negpy_core.warp_affine"] depends = ["estimate_shift"] ["features/rgbscan/logic.py::assemble_rgb"] @@ -1814,8 +1814,8 @@ depends = ["blend_ir", "feather_blend", "find_seams", "seam_blend", "warp_into_c ["features/stitch/logic.py::warp_into_canvas"] phase = 5 -status = "todo" -calls = ["cv2.warpAffine"] +status = "ported" +calls = ["negpy_core.warp_affine"] depends = [] ["features/cyanotype/logic.py::apply_cyanotype"] @@ -2271,14 +2271,14 @@ depends = [] ["desktop/view/canvas/overlay.py::CanvasOverlay._ir_layer_qimage"] phase = 7 -status = "todo" -calls = ["cv2.remap"] +status = "ported" +calls = ["negpy_core.remap"] depends = [] ["desktop/view/canvas/overlay.py::CanvasOverlay._mask_wash_qimage"] phase = 7 -status = "todo" -calls = ["cv2.remap"] +status = "ported" +calls = ["negpy_core.remap"] depends = [] ["desktop/view/canvas/overlay.py::CanvasOverlay._zone_outline"] diff --git a/negpy_core/src/py/warp.rs b/negpy_core/src/py/warp.rs index 122261328..e3f4b7ac3 100644 --- a/negpy_core/src/py/warp.rs +++ b/negpy_core/src/py/warp.rs @@ -4,7 +4,7 @@ use numpy::{ use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use crate::warp::{self, Image}; +use crate::warp::{self, Border, Image, Interp}; fn image<'a>(src: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult> { let (h, w, cn) = match src.shape() { @@ -49,15 +49,40 @@ fn matrix(m: &PyReadonlyArray2<'_, f64>, rows: usize) -> PyResul Ok(std::array::from_fn(|i| a[[i / 3, i % 3]])) } -/// `cv2.remap(src, map_x, map_y, INTER_LINEAR, borderMode=BORDER_REPLICATE)` of a float32 image. +/// The interpolation ("nearest", "linear", "cubic") and border ("replicate", "constant"). +fn mode(interpolation: &str, border: &str, nearest: bool) -> PyResult<(Interp, Border)> { + let interp = match interpolation { + "nearest" if nearest => Interp::Nearest, + "linear" => Interp::Linear, + "cubic" => Interp::Cubic, + _ => { + return Err(PyValueError::new_err(format!( + "unknown interpolation {interpolation:?}" + ))); + } + }; + let border = match border { + "replicate" => Border::Replicate, + "constant" => Border::Constant, + _ => return Err(PyValueError::new_err(format!("unknown border {border:?}"))), + }; + Ok((interp, border)) +} + +/// `cv2.remap(src, map_x, map_y, interpolation, borderMode)` of a float32 image; a constant +/// border reads zero. #[pyfunction] -fn remap_linear<'py>( +#[pyo3(signature = (src, map_x, map_y, interpolation="linear", border="replicate"))] +fn remap<'py>( py: Python<'py>, src: PyReadonlyArrayDyn<'py, f32>, map_x: PyReadonlyArray2<'py, f32>, map_y: PyReadonlyArray2<'py, f32>, + interpolation: &str, + border: &str, ) -> PyResult>> { let img = image(&src)?; + let mode = mode(interpolation, border, true)?; if map_x.shape() != map_y.shape() { return Err(PyValueError::new_err("the maps differ in size")); } @@ -66,7 +91,7 @@ fn remap_linear<'py>( let mx = map_x.as_slice().map_err(not_contiguous)?; let my = map_y.as_slice().map_err(not_contiguous)?; output(py, &src, (dw, dh), |dst| { - warp::remap_linear(&img, mx, my, dw, dst) + warp::remap(&img, mode, (mx, my), dw, dst) }) } @@ -87,43 +112,50 @@ fn remap_radial<'py>( }) } -/// `cv2.warpAffine(src, m, dsize, INTER_LINEAR, borderMode=BORDER_REPLICATE)` of a float32 image. +/// `cv2.warpAffine(src, m, dsize, interpolation, borderMode)` of a float32 image, bilinear or +/// bicubic; a constant border reads zero. #[pyfunction] -#[pyo3(signature = (src, m, dsize, inverse_map=false))] +#[pyo3(signature = (src, m, dsize, inverse_map=false, interpolation="linear", border="replicate"))] fn warp_affine<'py>( py: Python<'py>, src: PyReadonlyArrayDyn<'py, f32>, m: PyReadonlyArray2<'py, f64>, dsize: (usize, usize), inverse_map: bool, + interpolation: &str, + border: &str, ) -> PyResult>> { let img = image(&src)?; + let mode = mode(interpolation, border, false)?; let m = matrix::<6>(&m, 2)?; output(py, &src, dsize, |dst| { - warp::warp_affine(&img, m, inverse_map, dsize.0, dst) + warp::warp_affine(&img, mode, m, inverse_map, dsize.0, dst) }) } -/// `cv2.warpPerspective(src, m, dsize, INTER_LINEAR, borderMode=BORDER_REPLICATE)` of a float32 -/// image. +/// `cv2.warpPerspective(src, m, dsize, interpolation, borderMode)` of a float32 image, bilinear +/// or bicubic; a constant border reads zero. #[pyfunction] -#[pyo3(signature = (src, m, dsize, inverse_map=false))] +#[pyo3(signature = (src, m, dsize, inverse_map=false, interpolation="linear", border="replicate"))] fn warp_perspective<'py>( py: Python<'py>, src: PyReadonlyArrayDyn<'py, f32>, m: PyReadonlyArray2<'py, f64>, dsize: (usize, usize), inverse_map: bool, + interpolation: &str, + border: &str, ) -> PyResult>> { let img = image(&src)?; + let mode = mode(interpolation, border, false)?; let m = matrix::<9>(&m, 3)?; output(py, &src, dsize, |dst| { - warp::warp_perspective(&img, m, inverse_map, dsize, dst) + warp::warp_perspective(&img, mode, m, inverse_map, dsize, dst) }) } pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(remap_linear, m)?)?; + m.add_function(wrap_pyfunction!(remap, m)?)?; m.add_function(wrap_pyfunction!(remap_radial, m)?)?; m.add_function(wrap_pyfunction!(warp_affine, m)?)?; m.add_function(wrap_pyfunction!(warp_perspective, m)?) diff --git a/negpy_core/src/warp.rs b/negpy_core/src/warp.rs index 7592268f1..897b11f06 100644 --- a/negpy_core/src/warp.rs +++ b/negpy_core/src/warp.rs @@ -1,6 +1,7 @@ -//! OpenCV's bilinear warps of a float32 image with replicated edges: `remap`, `warpAffine` and -//! `warpPerspective`. Each source coordinate is fixed point at 1/32 px, rounded as OpenCV rounds -//! it; the four weights are its table's float32 products, summed left to right. +//! OpenCV's warps of a float32 image: `remap`, `warpAffine` and `warpPerspective`, bilinear or +//! bicubic, with replicated edges or a zero border. Each source coordinate is fixed point at +//! 1/32 px, rounded as OpenCV rounds it; the weights are its table's float32 products, summed +//! in its order. use multiversion::multiversion; use rayon::prelude::*; @@ -14,14 +15,62 @@ pub struct Image<'a> { pub cn: usize, } +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Interp { + /// `remap` only: the source pixel nearest the map point. + Nearest, + Linear, + Cubic, +} + +/// What a sample outside the source reads: the nearest edge pixel, or zero. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Border { + Replicate, + Constant, +} + const BITS: i32 = 5; const MASK: i32 = (1 << BITS) - 1; const STEP: f32 = 1.0 / (1 << BITS) as f32; -/// The pixel at fixed-point source coordinates (x, y) into `out`, edges replicated. `CN` is -/// the channel count, or 0 for one known only at run time. +/// OpenCV's bicubic weight table: for each pair of 1/32 px fractions (y major), the products +/// of `interpolateCubic` (A = -0.75) of the y and the x fraction, row major. +type CubicTable = [[f32; 16]; 1 << (2 * BITS)]; + +fn cubic_table() -> &'static CubicTable { + static TAB: std::sync::OnceLock> = std::sync::OnceLock::new(); + TAB.get_or_init(|| { + const A: f32 = -0.75; + let c: [[f32; 4]; 1 << BITS] = std::array::from_fn(|i| { + let x = i as f32 * STEP; + let c0 = ((A * (x + 1.0) - 5.0 * A) * (x + 1.0) + 8.0 * A) * (x + 1.0) - 4.0 * A; + let c1 = ((A + 2.0) * x - (A + 3.0)) * x * x + 1.0; + let c2 = ((A + 2.0) * (1.0 - x) - (A + 3.0)) * (1.0 - x) * (1.0 - x) + 1.0; + [c0, c1, c2, 1.0 - c0 - c1 - c2] + }); + let mut tab = Box::new([[0f32; 16]; 1 << (2 * BITS)]); + for (f, w) in tab.iter_mut().enumerate() { + let (cy, cx) = (c[f >> BITS], c[f & MASK as usize]); + *w = std::array::from_fn(|i| cy[i / 4] * cx[i % 4]); + } + tab + }) +} + +/// The source index of `v` on an axis of `n` under `border`; None reads zero. #[inline(always)] -fn sample(src: &Image, x: i32, y: i32, out: &mut [f32]) { +fn edge(v: i32, n: usize, border: Border) -> Option { + match border { + Border::Replicate => Some(v.clamp(0, n as i32 - 1) as usize), + Border::Constant => (v >= 0 && (v as usize) < n).then_some(v as usize), + } +} + +/// The bilinear pixel at fixed-point source coordinates (x, y) into `out`. `CN` is the channel +/// count, or 0 for one known only at run time. +#[inline(always)] +fn linear(src: &Image, border: Border, x: i32, y: i32, out: &mut [f32]) { let (fx, fy) = ((x & MASK) as f32 * STEP, (y & MASK) as f32 * STEP); let (ix, iy) = (x >> BITS, y >> BITS); let w0 = (1.0 - fy) * (1.0 - fx); @@ -31,8 +80,7 @@ fn sample(src: &Image, x: i32, y: i32, out: &mut [f32]) { let cn = if CN == 0 { src.cn } else { CN }; let row = src.w * cn; let s = src.data; - let (wl, hl) = (src.w as i32 - 1, src.h as i32 - 1); - if (ix as u32) < wl as u32 && (iy as u32) < hl as u32 { + if (ix as u32) < src.w as u32 - 1 && (iy as u32) < src.h as u32 - 1 { let a = iy as usize * row + ix as usize * cn; let (p, q) = (&s[a..][..2 * cn], &s[a + row..][..2 * cn]); for (k, o) in out[..cn].iter_mut().enumerate() { @@ -40,40 +88,140 @@ fn sample(src: &Image, x: i32, y: i32, out: &mut [f32]) { } return; } - let (x0, x1) = (ix.clamp(0, wl) as usize, (ix + 1).clamp(0, wl) as usize); - let (y0, y1) = (iy.clamp(0, hl) as usize, (iy + 1).clamp(0, hl) as usize); - let (a, b) = (y0 * row + x0 * cn, y0 * row + x1 * cn); - let (c, d) = (y1 * row + x0 * cn, y1 * row + x1 * cn); + let (wi, hi) = (src.w as i32, src.h as i32); + if border == Border::Constant && (ix >= wi || ix + 1 < 0 || iy >= hi || iy + 1 < 0) { + out[..cn].fill(0.0); + return; + } + let (x0, x1) = (edge(ix, src.w, border), edge(ix + 1, src.w, border)); + let (y0, y1) = (edge(iy, src.h, border), edge(iy + 1, src.h, border)); + let at = |yy: Option, xx: Option, k: usize| match (yy, xx) { + (Some(yy), Some(xx)) => s[yy * row + xx * cn + k], + _ => 0.0, + }; for (k, o) in out[..cn].iter_mut().enumerate() { - *o = s[a + k] * w0 + s[b + k] * w1 + s[c + k] * w2 + s[d + k] * w3; + *o = at(y0, x0, k) * w0 + at(y0, x1, k) * w1 + at(y1, x0, k) * w2 + at(y1, x1, k) * w3; + } +} + +/// The bicubic pixel at fixed-point source coordinates (x, y): rows of four summed, then added +/// row by row; at the border every term is added in turn, as OpenCV does. +#[inline(always)] +fn cubic( + src: &Image, + border: Border, + tab: &CubicTable, + x: i32, + y: i32, + out: &mut [f32], +) { + let w = &tab[(((y & MASK) << BITS) | (x & MASK)) as usize]; + let (sx, sy) = ((x >> BITS) - 1, (y >> BITS) - 1); + let cn = if CN == 0 { src.cn } else { CN }; + let row = src.w * cn; + let s = src.data; + if (sx as u32) < (src.w as u32).saturating_sub(3) + && (sy as u32) < (src.h as u32).saturating_sub(3) + { + let a = sy as usize * row + sx as usize * cn; + let p: [&[f32]; 4] = std::array::from_fn(|i| &s[a + i * row..][..4 * cn]); + for (k, o) in out[..cn].iter_mut().enumerate() { + let r = |i: usize| { + let (p, w) = (p[i], &w[i * 4..][..4]); + p[k] * w[0] + p[cn + k] * w[1] + p[2 * cn + k] * w[2] + p[3 * cn + k] * w[3] + }; + let mut sum = r(0); + sum += r(1); + sum += r(2); + sum += r(3); + *o = sum; + } + return; + } + let (wi, hi) = (src.w as i32, src.h as i32); + if border == Border::Constant && (sx >= wi || sx + 4 <= 0 || sy >= hi || sy + 4 <= 0) { + out[..cn].fill(0.0); + return; + } + let xs: [Option; 4] = std::array::from_fn(|i| edge(sx + i as i32, src.w, border)); + let ys: [Option; 4] = std::array::from_fn(|i| edge(sy + i as i32, src.h, border)); + for (k, o) in out[..cn].iter_mut().enumerate() { + let mut sum = 0.0f32; + for (i, yy) in ys.iter().enumerate() { + let Some(yy) = yy else { continue }; + for (j, xx) in xs.iter().enumerate() { + if let Some(xx) = xx { + sum += s[yy * row + xx * cn + k] * w[i * 4 + j]; + } + } + } + *o = sum; + } +} + +/// The source pixel at whole-pixel coordinates (x, y) in 1/32 px units. +#[inline(always)] +fn nearest(src: &Image, border: Border, x: i32, y: i32, out: &mut [f32]) { + let cn = if CN == 0 { src.cn } else { CN }; + let (ix, iy) = (x >> BITS, y >> BITS); + if (ix as u32) < src.w as u32 && (iy as u32) < src.h as u32 { + out[..cn].copy_from_slice(&src.data[(iy as usize * src.w + ix as usize) * cn..][..cn]); + return; + } + match ( + edge(x >> BITS, src.w, border), + edge(y >> BITS, src.h, border), + ) { + (Some(xx), Some(yy)) => { + out[..cn].copy_from_slice(&src.data[(yy * src.w + xx) * cn..][..cn]); + } + _ => out[..cn].fill(0.0), } } -fn sample_row(src: &Image, xs: &[i32], ys: &[i32], row: &mut [f32]) { +fn sample_row( + src: &Image, + (interp, border): (Interp, Border), + tab: &CubicTable, + (xs, ys): (&[i32], &[i32]), + row: &mut [f32], +) { let cn = if CN == 0 { src.cn } else { CN }; - for ((o, &x), &y) in row.chunks_exact_mut(cn).zip(xs).zip(ys) { - sample::(src, x, y, o); + let px = row.chunks_exact_mut(cn).zip(xs).zip(ys); + match interp { + Interp::Linear => px.for_each(|((o, &x), &y)| linear::(src, border, x, y, o)), + Interp::Cubic => px.for_each(|((o, &x), &y)| cubic::(src, border, tab, x, y, o)), + Interp::Nearest => px.for_each(|((o, &x), &y)| nearest::(src, border, x, y, o)), } } +static EMPTY: CubicTable = [[0.0; 16]; 1 << (2 * BITS)]; + /// Fills a `dw`-wide output, row by row in parallel, from `coords(y, xs, ys)`, which writes the /// fixed-point source coordinates of output row y. fn warp( src: &Image, + mode: (Interp, Border), dw: usize, out: &mut [f32], coords: impl Fn(usize, &mut [i32], &mut [i32]) + Sync, ) { let cn = src.cn; + let tab = if mode.0 == Interp::Cubic { + cubic_table() + } else { + &EMPTY + }; out.par_chunks_mut(dw * cn).enumerate().for_each_init( || (vec![0i32; dw], vec![0i32; dw]), |(xs, ys), (y, row)| { coords(y, xs, ys); + let c = (&xs[..], &ys[..]); match cn { - 1 => sample_row::<1>(src, xs, ys, row), - 3 => sample_row::<3>(src, xs, ys, row), - 4 => sample_row::<4>(src, xs, ys, row), - _ => sample_row::<0>(src, xs, ys, row), + 1 => sample_row::<1>(src, mode, tab, c, row), + 3 => sample_row::<3>(src, mode, tab, c, row), + 4 => sample_row::<4>(src, mode, tab, c, row), + _ => sample_row::<0>(src, mode, tab, c, row), } }, ); @@ -90,11 +238,30 @@ fn round_i32(v: f64) -> i32 { /// Coordinates far outside any image, held where `round_i32` stays exact. const FAR: f64 = (1u32 << 30) as f64; -/// `cv2.remap(src, map_x, map_y, INTER_LINEAR, borderMode=BORDER_REPLICATE)`; the maps are -/// float32, one entry per output pixel, `dw` wide. -pub fn remap_linear(src: &Image, map_x: &[f32], map_y: &[f32], dw: usize, out: &mut [f32]) { - warp(src, dw, out, move |y, xs, ys| { - remap_coords(&map_x[y * dw..][..dw], &map_y[y * dw..][..dw], xs, ys) +/// `cv2.remap(src, map_x, map_y, interpolation, borderMode)`; the maps are float32, one entry +/// per output pixel, `dw` wide. +pub fn remap( + src: &Image, + mode: (Interp, Border), + (map_x, map_y): (&[f32], &[f32]), + dw: usize, + out: &mut [f32], +) { + // Nearest rounds the map point to a whole pixel. + let scale = if mode.0 == Interp::Nearest { + 1.0 + } else { + (1 << BITS) as f32 + }; + let shift = if mode.0 == Interp::Nearest { BITS } else { 0 }; + warp(src, mode, dw, out, move |y, xs, ys| { + remap_coords( + &map_x[y * dw..][..dw], + &map_y[y * dw..][..dw], + (scale, shift), + xs, + ys, + ) }); } @@ -103,11 +270,17 @@ pub fn remap_linear(src: &Image, map_x: &[f32], map_y: &[f32], dw: usize, out: & "x86_64+avx2+fma", "aarch64+neon" ))] -fn remap_coords(mx: &[f32], my: &[f32], xs: &mut [i32], ys: &mut [i32]) { - let scale = (1 << BITS) as f32; +fn remap_coords( + mx: &[f32], + my: &[f32], + (scale, shift): (f32, i32), + xs: &mut [i32], + ys: &mut [i32], +) { + let far = FAR / (1 << shift) as f64; for (((x, y), &a), &b) in xs.iter_mut().zip(ys.iter_mut()).zip(mx).zip(my) { - *x = round_i32(((a * scale) as f64).clamp(-FAR, FAR)); - *y = round_i32(((b * scale) as f64).clamp(-FAR, FAR)); + *x = round_i32(((a * scale) as f64).clamp(-far, far)) << shift; + *y = round_i32(((b * scale) as f64).clamp(-far, far)) << shift; } } @@ -123,9 +296,13 @@ pub fn remap_radial( out: &mut [f32], ) { let dw = src.w; - warp(src, dw, out, move |y, xs, ys| { - radial_coords(y as f32, (cx, cy), s, k1, inv_hd2, xs, ys) - }); + warp( + src, + (Interp::Linear, Border::Replicate), + dw, + out, + move |y, xs, ys| radial_coords(y as f32, (cx, cy), s, k1, inv_hd2, xs, ys), + ); } #[multiversion(targets( @@ -152,9 +329,16 @@ fn radial_coords( } } -/// `cv2.warpAffine(src, m, (dw, dh), INTER_LINEAR, BORDER_REPLICATE)`; `m` maps source to -/// output unless `inverse_map` (OpenCV's WARP_INVERSE_MAP). -pub fn warp_affine(src: &Image, m: [f64; 6], inverse_map: bool, dw: usize, out: &mut [f32]) { +/// `cv2.warpAffine(src, m, (dw, dh), interpolation, borderMode)`, bilinear or bicubic; `m` maps +/// source to output unless `inverse_map` (OpenCV's WARP_INVERSE_MAP). +pub fn warp_affine( + src: &Image, + mode: (Interp, Border), + m: [f64; 6], + inverse_map: bool, + dw: usize, + out: &mut [f32], +) { let mut m = m; if !inverse_map { let d = m[0] * m[4] - m[1] * m[3]; @@ -177,7 +361,7 @@ pub fn warp_affine(src: &Image, m: [f64; 6], inverse_map: bool, dw: usize, out: let bdelta: Vec = (0..dw) .map(|x| round_i32((m[3] * x as f64 * AB_SCALE).clamp(-FAR, FAR))) .collect(); - warp(src, dw, out, move |y, xs, ys| { + warp(src, mode, dw, out, move |y, xs, ys| { let x0 = round_i32(((m[1] * y as f64 + m[2]) * AB_SCALE).clamp(-FAR, FAR)) + ROUND; let y0 = round_i32(((m[4] * y as f64 + m[5]) * AB_SCALE).clamp(-FAR, FAR)) + ROUND; for (i, (x, yv)) in xs.iter_mut().zip(ys.iter_mut()).enumerate() { @@ -210,10 +394,12 @@ fn invert3(s: [f64; 9]) -> Option<[f64; 9]> { ]) } -/// `cv2.warpPerspective(src, m, (dw, dh), INTER_LINEAR, BORDER_REPLICATE)`; `m` maps source to -/// output unless `inverse_map`. A singular `m` inverts to zeros, as `cv::invert` leaves it. +/// `cv2.warpPerspective(src, m, (dw, dh), interpolation, borderMode)`, bilinear or bicubic; `m` +/// maps source to output unless `inverse_map`. A singular `m` inverts to zeros, as `cv::invert` +/// leaves it. pub fn warp_perspective( src: &Image, + mode: (Interp, Border), m: [f64; 9], inverse_map: bool, (dw, dh): (usize, usize), @@ -227,7 +413,7 @@ pub fn warp_perspective( // OpenCV starts the projective sums again at each block of bw0 columns. let bh0 = dh.min(16); let bw0 = (1024 / bh0).min(dw); - warp(src, dw, out, move |y, xs, ys| { + warp(src, mode, dw, out, move |y, xs, ys| { let yf = y as f64; for (b, (xs, ys)) in xs.chunks_mut(bw0).zip(ys.chunks_mut(bw0)).enumerate() { let xb = (b * bw0) as f64; @@ -280,10 +466,19 @@ mod tests { cn: 3, }; let mut out = vec![0.0; data.len()]; - warp_affine(&src, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0], false, 13, &mut out); + let mode = (Interp::Linear, Border::Replicate); + warp_affine( + &src, + mode, + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + false, + 13, + &mut out, + ); assert_eq!(out, data); warp_perspective( &src, + mode, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], false, (13, 9), @@ -302,8 +497,39 @@ mod tests { cn: 1, }; let mut out = vec![0.0; 3]; - remap_linear(&src, &[0.5, -3.0, 9.0], &[0.0; 3], 3, &mut out); + let (mx, my) = ([0.5, -3.0, 9.0], [0.0; 3]); + remap( + &src, + (Interp::Linear, Border::Replicate), + (&mx, &my), + 3, + &mut out, + ); assert_eq!(out, [0.5, 0.0, 3.0]); + remap( + &src, + (Interp::Linear, Border::Constant), + (&mx, &my), + 3, + &mut out, + ); + assert_eq!(out, [0.5, 0.0, 0.0]); + remap( + &src, + (Interp::Nearest, Border::Replicate), + (&[1.4, 2.5, 3.5], &my), + 3, + &mut out, + ); + assert_eq!(out, [1.0, 2.0, 3.0]); + } + + #[test] + fn cubic_weights_sum_to_one() { + for w in cubic_table().iter() { + assert!((w.iter().sum::() - 1.0).abs() < 1e-5); + } + assert_eq!(cubic_table()[0][5], 1.0); } #[test] diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 222e5dddc..92a26ee12 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -73,6 +73,12 @@ def _filled(fill): "warp_perspective": lambda: negpy_core.warp_perspective( _IMG1, np.array([[1.02, 0.03, -1.5], [-0.01, 0.97, 2.25], [1e-4, -2e-4, 1.0]]), (61, 83) ), + "warp_affine_cubic_constant": lambda: negpy_core.warp_affine( + _IMG3, np.array([[0.998, 0.061, -3.1], [-0.061, 0.998, 4.2]]), (140, 90), interpolation="cubic", border="constant" + ), + "remap_nearest": lambda: negpy_core.remap( + _IMG1, *np.meshgrid(np.arange(70, dtype=np.float32) * 0.9 - 2, np.arange(90, dtype=np.float32) * 0.95 - 1), "nearest", "constant" + ), "remap_radial": lambda: negpy_core.remap_radial(_IMG3, (65.0, 48.0), 0.93, -0.05, 1.0 / 6600.0), } @@ -104,6 +110,8 @@ def _filled(fill): "matrix_trc_u8": "acec1eadffb13dfef8473a02fb250b4ad8f57ac211a6265661b77d3cad49ab16", "warp_affine": "83562b3183484dcbf80fc65c4fb2f241d3b13ec9793cf43fd22bf11ea44d9e1d", "warp_perspective": "61e89302e793e24f1ad131b661e95186d7d9a82e8331a96164e338bec3ec1420", + "warp_affine_cubic_constant": "a3d3d1a3d6c5e0e77686cfd37850199aa880943936e83edf8ada48307501cd69", + "remap_nearest": "16ebb2a1c29640f25e4a490f6edd8817d8dc1f652b6aa001f72aa2ca34d33ac0", "remap_radial": "a23f3b21f15cb5d9db26cc532ec920dc8b194d73515a786d9febc93ae17370e0", } diff --git a/tests/test_core_warp.py b/tests/test_core_warp.py index 9ee16a11f..2d2f21159 100644 --- a/tests/test_core_warp.py +++ b/tests/test_core_warp.py @@ -10,20 +10,40 @@ _RNG = np.random.default_rng(11) _REPLICATE = {"flags": cv2.INTER_LINEAR, "borderMode": cv2.BORDER_REPLICATE} +_INTERP = {"nearest": cv2.INTER_NEAREST, "linear": cv2.INTER_LINEAR, "cubic": cv2.INTER_CUBIC} +_BORDER = {"replicate": cv2.BORDER_REPLICATE, "constant": cv2.BORDER_CONSTANT} +_MODES = [(i, b) for i in ("linear", "cubic") for b in _BORDER] def _image(h, w, cn): return _RNG.random((h, w, cn) if cn > 1 else (h, w), dtype=np.float32) * 3 - 1 +@pytest.mark.parametrize("mode", [*_MODES, ("nearest", "replicate"), ("nearest", "constant")]) @pytest.mark.parametrize("cn", [1, 2, 3, 4, 5]) -@pytest.mark.parametrize("shape, dshape", [((97, 131), (80, 150)), ((1, 1), (3, 4)), ((300, 517), (300, 517))]) -def test_remap_linear_matches_cv2(cn, shape, dshape): +@pytest.mark.parametrize("shape, dshape", [((97, 131), (80, 150)), ((1, 1), (3, 4)), ((3, 2), (9, 9)), ((300, 517), (300, 517))]) +def test_remap_matches_cv2(cn, shape, dshape, mode): + if mode[0] == "cubic" and cn > 4: + pytest.skip("cv2 interpolates bicubic up to 4 channels") img = _image(*shape, cn) mx = (_RNG.random(dshape) * (shape[1] + 20) - 10).astype(np.float32) my = (_RNG.random(dshape) * (shape[0] + 20) - 10).astype(np.float32) - want = cv2.remap(img, mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) - assert negpy_core.remap_linear(img, mx, my).tobytes() == want.tobytes() + want = cv2.remap(img, mx, my, _INTERP[mode[0]], borderMode=_BORDER[mode[1]]) + assert negpy_core.remap(img, mx, my, *mode).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("mode", _MODES) +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("angle", [0.7, -3.3, 12.0]) +@pytest.mark.parametrize("shape", [(97, 131), (2, 3)]) +def test_warp_affine_modes_match_cv2(cn, angle, shape, mode): + img = _image(*shape, cn) + h, w = shape + m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 0.9).astype(np.float32).astype(np.float64) + m[:, 2] += (5.5, -3.25) + want = cv2.warpAffine(img, m, (w + 9, h + 4), flags=_INTERP[mode[0]], borderMode=_BORDER[mode[1]]) + got = negpy_core.warp_affine(img, m, (w + 9, h + 4), interpolation=mode[0], border=mode[1]) + assert got.tobytes() == want.tobytes() @pytest.mark.parametrize("cn", [1, 3, 4]) @@ -51,6 +71,14 @@ def test_warp_perspective_matches_cv2(cn, converge, shape): assert negpy_core.warp_perspective(img, m, (w, h)).tobytes() == want.tobytes() +@pytest.mark.parametrize("mode", _MODES) +def test_warp_perspective_modes_match_cv2(mode): + img = _image(97, 131, 3) + m = keystone_matrix(5.0, -3.0, 131, 97) + want = cv2.warpPerspective(img, m, (131, 97), flags=_INTERP[mode[0]], borderMode=_BORDER[mode[1]]) + assert negpy_core.warp_perspective(img, m, (131, 97), interpolation=mode[0], border=mode[1]).tobytes() == want.tobytes() + + def test_warps_reject_bad_input(): img = _image(10, 10, 3) with pytest.raises(ValueError): @@ -58,4 +86,8 @@ def test_warps_reject_bad_input(): with pytest.raises(ValueError): negpy_core.warp_perspective(img, np.eye(2, 3), (10, 10)) with pytest.raises(ValueError): - negpy_core.remap_linear(img, np.zeros((4, 4), np.float32), np.zeros((4, 5), np.float32)) + negpy_core.remap(img, np.zeros((4, 4), np.float32), np.zeros((4, 5), np.float32)) + with pytest.raises(ValueError): + negpy_core.warp_affine(img, np.eye(2, 3), (10, 10), interpolation="nearest") + with pytest.raises(ValueError): + negpy_core.remap(img, np.zeros((4, 4), np.float32), np.zeros((4, 4), np.float32), border="reflect") From 857346e8fede26fc2d7282d0341c2606f3d4760e Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:09:18 +0200 Subject: [PATCH 069/133] perf(core): bilinear resize in Rust; INTER_AREA upscales negpy_core.resize_linear copies OpenCV's own float32 bilinear resize (row pass, then column pass, a*(1-f) + b*f in float32, rows clipped without zeroing the fraction, an exact halving as INTER_AREA). resize_area now takes upscales through OpenCV's area-fraction variant. 88 oracle cases match cv2 with IPP off bit for bit. opencv-python on x86 runs IPP for this resize, whose bits depend on the CPU; the ledger lists the difference (up to 3 ULP on upscales). No recorded call reaches these resizes; replay of their callers is unchanged. The contrast mask plane, two-scale unmix layers, dust score and IR gain maps and flat-field gains use it. Three bindings bypassed the Multi-core switch through a multi-line py.detach; they go through super::detach now. One thread, 600x400 to 6000x4000: 0.75x of cv2 (IPP), 0.99x of OpenCV without IPP; 3-channel 0.76x; 6000x4000x3 to 1500x1000 1.1x. --- negpy/features/exposure/logic.py | 5 +- negpy/features/flatfield/logic.py | 5 +- negpy/features/process/sensor.py | 2 +- negpy/features/retouch/logic.py | 8 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 16 +-- negpy_core/src/py/analysis.rs | 30 ++--- negpy_core/src/py/resize.rs | 59 ++++++++-- negpy_core/src/resize.rs | 178 ++++++++++++++++++++++++++++-- tests/test_core_resize.py | 48 +++++++- tests/test_core_same_bits.py | 4 + tests/test_ir_dust.py | 3 +- 12 files changed, 305 insertions(+), 54 deletions(-) diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index b3789b725..ed878baa7 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1,7 +1,6 @@ import math from typing import Any, Dict, Optional, Tuple -import cv2 import negpy_core import numpy as np @@ -1261,7 +1260,7 @@ def expand_mask_plane( h, w = out_shape if roi is None: if plane.shape[:2] != (h, w): - plane = cv2.resize(plane, (w, h), interpolation=cv2.INTER_LINEAR) + plane = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (w, h)) return np.ascontiguousarray(plane, dtype=np.float32) y1, y2, x1, x2 = roi @@ -1269,7 +1268,7 @@ def expand_mask_plane( y2, x2 = min(h, y2), min(w, x2) if y2 - y1 < 1 or x2 - x1 < 1: return None - inner = cv2.resize(plane, (x2 - x1, y2 - y1), interpolation=cv2.INTER_LINEAR) + inner = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (x2 - x1, y2 - y1)) if (y1, x1, y2, x2) == (0, 0, h, w): return np.ascontiguousarray(inner, dtype=np.float32) return np.ascontiguousarray(np.pad(inner, ((y1, h - y2), (x1, w - x2)), mode="edge"), dtype=np.float32) diff --git a/negpy/features/flatfield/logic.py b/negpy/features/flatfield/logic.py index c5a0d6689..19d0b6b50 100644 --- a/negpy/features/flatfield/logic.py +++ b/negpy/features/flatfield/logic.py @@ -2,6 +2,7 @@ from typing import Callable, Dict, NamedTuple, Optional, Tuple import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer @@ -179,7 +180,7 @@ def check_reference(reference: ImageBuffer, gain: np.ndarray) -> Evenness: def self_corrected(reference: np.ndarray, gain: np.ndarray) -> np.ndarray: if gain.shape[:2] != reference.shape[:2]: - gain = cv2.resize(gain, (reference.shape[1], reference.shape[0]), interpolation=cv2.INTER_LINEAR) + gain = negpy_core.resize_linear(np.ascontiguousarray(gain, dtype=np.float32), (reference.shape[1], reference.shape[0])) return reference.astype(np.float32) * gain @@ -240,5 +241,5 @@ def apply_flatfield( if gain_crop is not None: gain = gain_crop(gain) if gain.shape[:2] != image.shape[:2]: - gain = cv2.resize(gain, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_LINEAR) + gain = negpy_core.resize_linear(np.ascontiguousarray(gain, dtype=np.float32), (image.shape[1], image.shape[0])) return (image * gain).astype(np.float32) diff --git a/negpy/features/process/sensor.py b/negpy/features/process/sensor.py index 81020901d..cb3fbfcac 100644 --- a/negpy/features/process/sensor.py +++ b/negpy/features/process/sensor.py @@ -199,7 +199,7 @@ def _two_scale_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: weight = np.empty(small.shape[:2], np.float32) _two_scale_layer_kernel(small, m, own, COLOR_FLOOR, _LOG_FLOOR, TWO_SCALE_START, TWO_SCALE_FULL, color, weight) if step > 1: - small, color, weight = (cv2.resize(a, (w, h), interpolation=cv2.INTER_LINEAR) for a in (small, color, weight)) + small, color, weight = (negpy_core.resize_linear(np.ascontiguousarray(a, dtype=np.float32), (w, h)) for a in (small, color, weight)) out = np.empty_like(x) _two_scale_kernel(x, small, color, weight, m, c, _LOG_FLOOR, float(np.log(_DEFECT_RATIO)), out) return out diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index df9102aa1..ce7d272d3 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -980,7 +980,7 @@ def apply_score_repair( factor = factor or 1.0 else: factor = max(h / score_det.shape[0], w / score_det.shape[1]) - score = cv2.resize(score_det, (w, h), interpolation=cv2.INTER_LINEAR) + score = negpy_core.resize_linear(np.ascontiguousarray(score_det, dtype=np.float32), (w, h)) out = score_weighted_fill(src, score, _fill_supports(long_edge or max(h, w), factor), reject_floor_mass=not floor) # The fill buffer becomes the output: blended over the source under the write ramp. a = np.empty((h, w), dtype=np.float32) @@ -1055,7 +1055,7 @@ def repair_components( factor = factor or 1.0 else: factor = max(h / score_det.shape[0], w / score_det.shape[1]) - score = cv2.resize(score_det, (w, h), interpolation=cv2.INTER_LINEAR) + score = negpy_core.resize_linear(np.ascontiguousarray(score_det, dtype=np.float32), (w, h)) if dirty is not None and dirty.shape[:2] != (h, w): dirty = cv2.resize(dirty.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST) > 0 m = (score < 1.0).astype(np.uint8) @@ -1253,7 +1253,7 @@ def ir_ratio_and_gain(ir_det: np.ndarray, img_det: np.ndarray) -> Tuple[np.ndarr def apply_ir_attenuation(img: ImageBuffer, gain_det: np.ndarray) -> ImageBuffer: """Visible buffer × upsampled per-channel IR gain map (new array — buffers are read-only).""" h, w = img.shape[:2] - gain = gain_det if gain_det.shape[:2] == (h, w) else cv2.resize(gain_det, (w, h), interpolation=cv2.INTER_LINEAR) + gain = gain_det if gain_det.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(gain_det, dtype=np.float32), (w, h)) # cv2.multiply, not `a * b`: the product of two float32 buffers is already float32, so # the astype numpy needs here would copy the whole frame a second time. return ensure_image(cv2.multiply(np.ascontiguousarray(img, dtype=np.float32), gain)) @@ -1307,7 +1307,7 @@ def apply_hair_inpaint( dilate_px = max(_HAIR_DILATE_PX, round(factor)) m = np.zeros((h, w), dtype=np.uint8) for hm in masks: - r = hm if hm.shape[:2] == (h, w) else cv2.resize(hm.astype(np.float32), (w, h), interpolation=cv2.INTER_LINEAR) + r = hm if hm.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(hm, dtype=np.float32), (w, h)) m |= (np.asarray(r) > 0.5).astype(np.uint8) if not m.any(): return img diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 175a02185..4222548e2 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -21,3 +21,4 @@ tolerance listed for each entry. | `features/exposure/logic.py::apply_characteristic_curve` (the print curve) | `exp`, `log1p`, `tanh` and 10^x are `mathf`'s, not glibc's; 2 of 205 M recorded values round the other way. Every feature branch (masks, flash, diffusion, dye mix, separation damping, the print LUTs) matched the Numba kernel bit for bit on test frames. | ulp=1 | | `features/process/sensor.py::_log_unmix_kernel` (density unmix) | `logf` and `expf` are `mathf`'s (correctly rounded) instead of glibc's; the log-domain matrix and the exp amplify a one-ULP log to up to 16 ULP on 0.2 % of values. The other three unmix kernels match Numba bit for bit. | ulp=16 | | `features/geometry/logic.py::apply_fine_rotation`, `apply_keystone`, `apply_radial_distortion`, frames of 32767 px or more on a side | OpenCV cannot warp such a frame, so the Python version warped it in tiles from float64 source maps. negpy_core warps the whole frame with OpenCV's fixed-point coordinates, which can move a sample by 1/32 px. Smaller frames match `cv2` bit for bit. | none (the corpus has no such frame) | +| `negpy_core.resize_linear` (bilinear `cv2.resize` of float32: the contrast mask plane, the two-scale unmix layers, dust score and IR gain maps, flat-field gains) | opencv-python on x86-64 runs IPP's float32 bilinear resize, whose bits depend on the CPU it dispatches for; Apple Silicon wheels run OpenCV's own code. negpy_core copies OpenCV's own code. Upscales differ by up to 3 ULP, downscales more. No recorded call reaches these resizes. | ulp=3 (upscale) | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 5fb1e1c33..3c437de70 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1167,7 +1167,7 @@ depends = [] ["features/flatfield/logic.py::apply_flatfield"] phase = 4 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_linear"] depends = [] ["features/flatfield/logic.py::check_copy"] @@ -1203,7 +1203,7 @@ depends = ["_evenness"] ["features/flatfield/logic.py::self_corrected"] phase = 4 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_linear"] depends = [] ["features/flatfield/logic.py::work_copy"] @@ -1371,7 +1371,7 @@ depends = [] ["features/process/sensor.py::_two_scale_unmix"] phase = 4 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.resize"] +calls = ["cv2.GaussianBlur", "cv2.resize", "negpy_core.resize_linear"] depends = ["_two_scale_kernel", "_two_scale_layer_kernel", "film_base"] ["features/process/sensor.py::_unmix_kernel"] @@ -1539,13 +1539,13 @@ depends = [] ["features/retouch/logic.py::apply_ir_attenuation"] phase = 4 status = "todo" -calls = ["cv2.multiply", "cv2.resize"] +calls = ["cv2.multiply", "negpy_core.resize_linear"] depends = [] ["features/retouch/logic.py::apply_score_repair"] phase = 4 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.resize"] +calls = ["cv2.GaussianBlur", "negpy_core.resize_linear"] depends = ["_add_floor_deficit", "_blend_fill", "_borrow_clean_grain", "score_weighted_fill"] ["features/retouch/logic.py::compute_dust_stats"] @@ -1605,7 +1605,7 @@ depends = [] ["features/retouch/logic.py::repair_components"] phase = 4 status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.resize"] +calls = ["cv2.connectedComponentsWithStats", "cv2.resize", "negpy_core.resize_linear"] depends = ["apply_score_repair"] ["features/retouch/logic.py::route_wide_defects"] @@ -1870,7 +1870,7 @@ depends = ["expand_mask_plane"] ["features/exposure/logic.py::expand_mask_plane"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_linear"] depends = [] ["features/exposure/logic.py::plane_taps"] @@ -2500,7 +2500,7 @@ depends = [] ["features/retouch/logic.py::apply_hair_inpaint"] phase = 9 status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement", "cv2.inpaint", "cv2.resize", "np.percentile"] +calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement", "cv2.inpaint", "negpy_core.resize_linear", "np.percentile"] depends = ["_borrow_clean_grain", "_hair_encode"] ["features/rgbscan/logic.py::estimate_shift"] diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 4dda7279c..00ae2edca 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -165,9 +165,10 @@ fn walk_border_depth<'py>( return Err(PyValueError::new_err("walk_border_depth needs a 2-D array")); }; let data = slice(&lines)?; - let out = py - .detach(|| analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step)) - .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; + let out = super::detach(py, || { + analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step) + }) + .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; Ok(numpy::PyArray1::from_vec(py, out)) } @@ -247,13 +248,11 @@ fn smooth_signal<'py>( window: usize, ) -> PyResult>> { let data = slice(&signal)?.to_vec(); - let out = py - .detach(|| analysis::smooth_signal(&data, window)) - .ok_or_else(|| { - PyValueError::new_err( - "smooth_signal covers a window of at most 31, no longer than the signal", - ) - })?; + let out = super::detach(py, || analysis::smooth_signal(&data, window)).ok_or_else(|| { + PyValueError::new_err( + "smooth_signal covers a window of at most 31, no longer than the signal", + ) + })?; Ok(numpy::PyArray1::from_vec(py, out)) } @@ -358,11 +357,12 @@ fn refine_roi_to_image<'py>( } }; let data = slice(&img)?; - let (roi, tiers) = py - .detach(|| analysis::refine_roi_to_image(data, h, w, cn, film_roi)) - .ok_or_else(|| { - PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") - })?; + let (roi, tiers) = super::detach(py, || { + analysis::refine_roi_to_image(data, h, w, cn, film_roi) + }) + .ok_or_else(|| { + PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") + })?; Ok(match tiers { Some(t) => ( roi, diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index 29fe4c02a..2d11e9331 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -1,10 +1,10 @@ -use numpy::{IntoPyArray, PyArrayDyn, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use numpy::{IntoPyArray, PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use crate::resize; -/// `cv2.resize(src, dsize, interpolation=cv2.INTER_AREA)` for a contiguous float32 downscale. +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_AREA)` of a contiguous float32 image. #[pyfunction] fn resize_area<'py>( py: Python<'py>, @@ -25,12 +25,9 @@ fn resize_area<'py>( .as_slice() .map_err(|_| PyValueError::new_err("resize_area needs a contiguous array"))?; let (dw, dh) = dsize; - let out = py - .detach(|| resize::resize_area(data, h, w, cn, dh, dw)) - .ok_or_else(|| { - PyValueError::new_err( - "resize_area covers a float32 downscale, not a 2x2 two-channel one", - ) + let out = + super::detach(py, || resize::resize_area(data, h, w, cn, dh, dw)).ok_or_else(|| { + PyValueError::new_err("resize_area does not cover a 2x2 two-channel downscale") })?; let dims: Vec = if shape.len() == 2 { vec![dh, dw] @@ -42,6 +39,50 @@ fn resize_area<'py>( Ok(arr.into_pyarray(py)) } +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_LINEAR)` of a contiguous float32 image, as +/// OpenCV computes it without IPP. +#[pyfunction] +fn resize_linear<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dsize: (usize, usize), +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_linear needs a 2-D or 3-D array", + )); + } + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_linear needs a contiguous array"))?; + let (dw, dh) = dsize; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + // NumPy's allocator asks for huge pages, so the output faults in far fewer pages. + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || { + resize::resize_linear(data, (h, w, cn), (dh, dw), false, dst) + }) + .ok_or_else(|| { + PyValueError::new_err( + "resize_linear needs a non-empty size and covers no 2x2 two-channel halving", + ) + })?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(resize_area, m)?) + m.add_function(wrap_pyfunction!(resize_area, m)?)?; + m.add_function(wrap_pyfunction!(resize_linear, m)?) } diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs index 1f9178436..d0d9ace5f 100644 --- a/negpy_core/src/resize.rs +++ b/negpy_core/src/resize.rs @@ -1,3 +1,4 @@ +use multiversion::multiversion; use rayon::prelude::*; /// One source-to-destination contribution of OpenCV's area table (`DecimateAlpha`). @@ -44,8 +45,9 @@ fn area_tab(ssize: usize, dsize: usize, scale: f64) -> Vec { tab } -/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_AREA)` for a float32 downscale. `src` is -/// row-major with `cn` channels interleaved. None for an upscale or a 2x2 two-channel image. +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_AREA)` of a float32 image. `src` is +/// row-major with `cn` channels interleaved. An axis that grows makes it OpenCV's bilinear +/// variant. None for a 2x2 two-channel image. pub fn resize_area( src: &[f32], h: usize, @@ -63,7 +65,9 @@ pub fn resize_area( let int_x = (scale_x - scale_x.round()).abs() < f64::EPSILON; let int_y = (scale_y - scale_y.round()).abs() < f64::EPSILON; if scale_x < 1.0 || scale_y < 1.0 { - return None; + let mut dst = vec![0f32; dh * dw * cn]; + resize_linear(src, (h, w, cn), (dh, dw), true, &mut dst)?; + return Some(dst); } if dh == h && dw == w { return Some(src.to_vec()); @@ -106,6 +110,162 @@ pub fn resize_area( Some(dst) } +/// One axis of OpenCV's bilinear resize (`hal::resize`): per destination index, the first +/// source index and the float32 weight of the next one. `area` takes INTER_AREA's upscale +/// fractions. Without `clamp` (the y axis) a fraction outside the source stays and both rows +/// clip to the edge. +fn linear_axis(ssize: usize, dsize: usize, area: bool, clamp: bool) -> Vec<(i64, f32)> { + let inv = dsize as f64 / ssize as f64; + let scale = 1.0 / inv; + (0..dsize) + .map(|d| { + let (mut s, mut f) = if area { + let s = (d as f64 * scale).floor() as i64; + let f = ((d + 1) as f64 - (s + 1) as f64 * inv) as f32; + (s, if f <= 0.0 { 0.0 } else { f - f.floor() }) + } else { + let f = ((d as f64 + 0.5) * scale - 0.5) as f32; + let s = f.floor(); + (s as i64, f - s) + }; + if clamp && s < 0 { + (s, f) = (0, 0.0); + } + if clamp && s >= ssize as i64 - 1 { + (s, f) = (ssize as i64 - 1, 0.0); + } + (s, f) + }) + .collect() +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_LINEAR)` of a float32 image, or with +/// `area` INTER_AREA where either axis grows, as OpenCV runs it without IPP: a row pass then a +/// column pass, each `a * (1 - f) + b * f` in float32. An exact halving is INTER_AREA's mean. +pub fn resize_linear( + src: &[f32], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + area: bool, + dst: &mut [f32], +) -> Option<()> { + if h == 0 || w == 0 || dh == 0 || dw == 0 || cn == 0 || src.len() != h * w * cn { + return None; + } + if dst.len() != dh * dw * cn { + return None; + } + if !area && dw * 2 == w && dh * 2 == h { + dst.copy_from_slice(&resize_area(src, h, w, cn, dh, dw)?); + return Some(()); + } + let xs = linear_axis(w, dw, area, true); + let ys = linear_axis(h, dh, area, false); + // From xmax on, OpenCV reads one source pixel (weight 1) instead of two. + let xmax = xs + .iter() + .position(|&(s, _)| s + 1 >= w as i64) + .unwrap_or(dw); + let xs: Vec<(usize, f32, f32)> = xs.iter().map(|&(s, f)| (s as usize, 1.0 - f, f)).collect(); + let row_pass = |sy: usize, out: &mut [f32]| { + let row = &src[sy * w * cn..][..w * cn]; + match cn { + 1 => linear_row::<1>(row, &xs, xmax, out), + 3 => linear_row::<3>(row, &xs, xmax, out), + 4 => linear_row::<4>(row, &xs, xmax, out), + _ => linear_row_any(row, cn, &xs, xmax, out), + } + }; + let dwidth = dw * cn; + let clip = |v: i64| v.clamp(0, h as i64 - 1) as usize; + // A block of output rows reuses each row pass; a few blocks per thread keep the + // repeated passes at block starts rare. + let block = dh.div_ceil(4 * rayon::current_num_threads()).max(16); + dst.par_chunks_mut(block * dwidth) + .zip(ys.par_chunks(block)) + .for_each(|(out, ys)| { + let mut cache = [ + (usize::MAX, vec![0f32; dwidth]), + (usize::MAX, vec![0f32; dwidth]), + ]; + for (o, &(sy, fy)) in out.chunks_exact_mut(dwidth).zip(ys) { + let (r0, r1) = (clip(sy), clip(sy + 1)); + if cache[0].0 != r0 && cache[1].0 == r0 { + cache.swap(0, 1); + } + let (c0, c1) = cache.split_at_mut(1); + let (c0, c1) = (&mut c0[0], &mut c1[0]); + if c0.0 != r0 { + c0.0 = r0; + row_pass(r0, &mut c0.1); + } + if c1.0 != r1 { + c1.0 = r1; + if r1 == r0 { + c1.1.copy_from_slice(&c0.1); + } else { + row_pass(r1, &mut c1.1); + } + } + blend_rows(&c0.1, &c1.1, (1.0 - fy, fy), o); + } + }); + Some(()) +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn blend_rows(r0: &[f32], r1: &[f32], (b0, b1): (f32, f32), out: &mut [f32]) { + for ((o, &a), &b) in out.iter_mut().zip(r0).zip(r1) { + *o = a * b0 + b * b1; + } +} + +/// One row of the bilinear row pass; from `xmax` on, OpenCV weights one source pixel. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn linear_row( + row: &[f32], + xs: &[(usize, f32, f32)], + xmax: usize, + out: &mut [f32], +) { + let last = row.len() - CN; + let (two, one) = out.split_at_mut(xmax * CN); + for (o, &(sx, a0, a1)) in two.as_chunks_mut::().0.iter_mut().zip(&xs[..xmax]) { + let i = (sx * CN).min(last - CN); + let (p, q) = (&row[i..][..CN], &row[i + CN..][..CN]); + for k in 0..CN { + o[k] = p[k] * a0 + q[k] * a1; + } + } + for (o, &(sx, a0, _)) in one.as_chunks_mut::().0.iter_mut().zip(&xs[xmax..]) { + let p = &row[(sx * CN).min(last)..][..CN]; + for k in 0..CN { + o[k] = p[k] * a0; + } + } +} + +fn linear_row_any(row: &[f32], cn: usize, xs: &[(usize, f32, f32)], xmax: usize, out: &mut [f32]) { + for (dx, (o, &(sx, a0, a1))) in out.chunks_exact_mut(cn).zip(xs).enumerate() { + let p = &row[sx * cn..]; + for (k, o) in o.iter_mut().enumerate() { + *o = if dx < xmax { + p[k] * a0 + p[cn + k] * a1 + } else { + p[k] * a0 + }; + } + } +} + /// OpenCV's `resizeAreaFast_` for an integer scale. A 2x2 one- or four-channel image takes its /// SIMD order, ((a + b) + (c + d)) * 0.25, over the columns the 4-lane vector loop reaches; the /// rest sums each cell row by row, four at a time: sum + (((a + b) + c) + d). @@ -230,11 +390,13 @@ mod tests { } #[test] - fn upscale_is_not_covered() { - let src = vec![0f32; 40 * 40]; - assert!(resize_area(&src, 40, 40, 1, 20, 20).is_some()); - assert!(resize_area(&src, 40, 40, 1, 80, 30).is_none()); - assert!(resize_area(&src, 40, 40, 1, 20, 30).is_some()); + fn upscale_of_a_flat_image_stays_flat() { + let src = vec![0.5f32; 40 * 40]; + let out = resize_area(&src, 40, 40, 1, 80, 30).unwrap(); + assert!(out.iter().all(|&v| v == 0.5)); + let mut lin = vec![0f32; 90 * 70 * 3]; + resize_linear(&vec![0.5f32; 40 * 40 * 3], (40, 40, 3), (90, 70), false, &mut lin).unwrap(); + assert!(lin.iter().all(|&v| v == 0.5)); } #[test] diff --git a/tests/test_core_resize.py b/tests/test_core_resize.py index 00e253eb8..0d0960c77 100644 --- a/tests/test_core_resize.py +++ b/tests/test_core_resize.py @@ -37,9 +37,51 @@ def test_resize_area_matches_cv2_bit_for_bit(shape, dsize, cn): assert got.tobytes() == want.tobytes() -def test_resize_area_rejects_upscale_and_strided_input(): +_SIZES = [ + ((60, 90), (130, 200)), + ((61, 91), (177, 300)), + ((100, 140), (37, 51)), + ((7, 5), (40, 64)), + ((300, 517), (900, 1100)), + ((80, 120), (40, 60)), # an exact halving + ((80, 120), (160, 50)), # one axis grows, one shrinks + ((1, 9), (4, 30)), + ((9, 1), (30, 4)), +] + + +@pytest.fixture +def no_ipp(): + # opencv-python runs IPP's float32 bilinear resize on x86, whose bits depend on the CPU; + # negpy_core copies OpenCV's own code, which a build without IPP runs. + cv2.ipp.setUseIPP(False) + yield + cv2.ipp.setUseIPP(True) + + +@pytest.mark.parametrize("cn", [1, 2, 3, 4]) +@pytest.mark.parametrize("shape, dshape", _SIZES) +def test_resize_linear_matches_cv2_without_ipp(shape, dshape, cn, no_ipp): + img = _image(*shape, cn) + dsize = (dshape[1], dshape[0]) + if cn == 2 and dshape[0] * 2 == shape[0] and dshape[1] * 2 == shape[1]: + with pytest.raises(ValueError): + negpy_core.resize_linear(img, dsize) + return + want = cv2.resize(img, dsize, interpolation=cv2.INTER_LINEAR) + assert negpy_core.resize_linear(img, dsize).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("shape, dshape", [s for s in _SIZES if s[1][0] > s[0][0] or s[1][1] > s[0][1]]) +def test_resize_area_upscale_matches_cv2(shape, dshape, cn): + img = _image(*shape, cn) + dsize = (dshape[1], dshape[0]) + want = cv2.resize(img, dsize, interpolation=cv2.INTER_AREA) + assert negpy_core.resize_area(img, dsize).tobytes() == want.tobytes() + + +def test_resize_area_rejects_strided_input(): img = _image(20, 20, 3) - with pytest.raises(ValueError): - negpy_core.resize_area(img, (40, 40)) with pytest.raises(ValueError): negpy_core.resize_area(img[:, ::2], (5, 10)) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 92a26ee12..09ffc7046 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -34,6 +34,8 @@ def _filled(fill): _CASES = { "percentiles": lambda: np.array(negpy_core.percentiles(_IMG1.ravel(), [0.5, 1.0, 37.0, 50.0, 99.0]), np.float32), "resize_area_general": lambda: negpy_core.resize_area(_IMG3, (47, 31)), + "resize_linear_up": lambda: negpy_core.resize_linear(_IMG3, (300, 177)), + "resize_area_up": lambda: negpy_core.resize_area(_IMG1, (100, 120)), "resize_area_2x2": lambda: negpy_core.resize_area(_IMG1[:82, :60].copy(), (30, 41)), "gaussian_5x5": lambda: negpy_core.gaussian_blur(_IMG3, (5, 5), 0.0), "gaussian_sigma2_replicate": lambda: negpy_core.gaussian_blur(_IMG1, (0, 0), 2.0, 0.0, 1), @@ -85,6 +87,8 @@ def _filled(fill): _HASHES = { "percentiles": "0874152b6e9f91bd3a3b73e15a9d083d15b6e8c7c6f6a9279d9241df10a88464", "resize_area_general": "dbe0fb9e51b0e8bee54cc2bb35756bbf0934077b458c1a44ea27c2e5dd96a16f", + "resize_linear_up": "9e6c1200f4f4f05bf46bbba858c67501044e514e47ce1afb50ac5a0e3e7cabee", + "resize_area_up": "1b0aa67c89167cb289f780e36bdd5465c5d306200b3f1413510b6cfcd608fcac", "resize_area_2x2": "cae2830d9848ee6f270000d57b3dc448138cc99cb5a2303108ac3e095b060488", "gaussian_5x5": "988a867049827fd5912f1950b8574b637d671c3565e0b7af87def261c9f0b084", "gaussian_sigma2_replicate": "2b9be6f95b7f66a03493795d8c7a5e1bb2565a28087578f1981a9cd329c8a74f", diff --git a/tests/test_ir_dust.py b/tests/test_ir_dust.py index ac7b120cc..7b9f26909 100644 --- a/tests/test_ir_dust.py +++ b/tests/test_ir_dust.py @@ -2,6 +2,7 @@ import tempfile import cv2 +import negpy_core import numpy as np import pytest import tifffile @@ -1165,7 +1166,7 @@ def test_ir_attenuation_is_the_upsampled_gain_product(): img = _noisy_frame(64, 96, seed=4) rng = np.random.default_rng(5) gain_det = rng.uniform(1.0, 2.0, (16, 24, 3)).astype(np.float32) - expected = img * cv2.resize(gain_det, (96, 64), interpolation=cv2.INTER_LINEAR) + expected = img * negpy_core.resize_linear(gain_det, (96, 64)) out = apply_ir_attenuation(img, gain_det) assert out.dtype == np.float32 assert np.array_equal(out, expected.astype(np.float32)) From 543156ba4417835f776e58bf941830e75613df47 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:17:38 +0200 Subject: [PATCH 070/133] refactor(core): float32 area and bilinear resizes call negpy_core Every cv2.resize of a float32 image with INTER_AREA (analysis grids, base-log and sensor samples, flat-field reference, detection copies, preview and VRAM caps, bracket and RGB-scan alignment, stitch seams, capture meter thumbnails) now calls negpy_core.resize_area, and the bilinear preview and flat-field peek resizes call resize_linear. A test run logged the dtype of every call site; the uint8 sites (masks, contact sheet), the uint16 loader, the fx-scaled chart fit and the Lanczos export upscale stay on cv2 for now. Replay of all 114 recorded functions unchanged (the one print-curve ledger difference aside); 3468 tests over the touched areas pass. --- negpy/desktop/controller.py | 4 +-- negpy/features/exposure/analysis.py | 8 +++-- negpy/features/exposure/normalization.py | 2 +- negpy/features/flatfield/logic.py | 2 +- negpy/features/process/sensor.py | 6 ++-- negpy/features/retouch/logic.py | 2 +- negpy/features/rgbscan/logic.py | 4 +-- negpy/features/stitch/logic.py | 4 +-- negpy/services/capture/meter.py | 4 +-- negpy/services/rendering/gpu_engine.py | 5 +-- negpy/services/rendering/preview_manager.py | 21 +++-------- negpy_core/manifest.toml | 40 ++++++++++----------- 12 files changed, 48 insertions(+), 54 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 83489e9e8..f0d728db5 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -8,6 +8,7 @@ from typing import Any, Callable, Collection, Dict, List, Optional, Set, Tuple, Union import cv2 +import negpy_core import numpy as np from PyQt6.QtCore import Q_ARG, QMetaObject, QObject, Qt, QThread, QTimer, pyqtSignal from PyQt6.QtGui import QIcon, QPixmap, QTransform @@ -7817,7 +7818,6 @@ def _paint_flatfield_peek(self) -> None: """Paint the selected profile's reference times its gain, ±EVENNESS_RANGE as black..white: a good profile shows gray. With no stored reference, show the light its gain corrects. A sensor-layout profile takes the frame's EXIF orientation, quarter turns and flips only.""" - import cv2 from negpy.features.flatfield.logic import EVENNESS_RANGE, GAIN_VIEW_RANGE, evenness_view, self_corrected from negpy.services.assets.flatfield import FlatFieldProfiles @@ -7862,7 +7862,7 @@ def _paint_flatfield_peek(self) -> None: view = np.ascontiguousarray(view) h, w = view.shape[:2] scale = APP_CONFIG.preview_render_size / max(h, w) - view = cv2.resize(view, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_LINEAR) + view = negpy_core.resize_linear(np.ascontiguousarray(view, dtype=np.float32), (round(w * scale), round(h * scale))) # Display-ready: the map is already black..white, so no working OETF. self._store_peek_frame(view, splash=False, crop_preview_full=False) self.set_status(message, 10000) diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index bac251260..6864e3038 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -317,7 +317,7 @@ def zone_grid(rgb: Any, cells: int = ZONE_GRID_CELLS) -> Optional[np.ndarray]: small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) scale = cells / max(small.shape[:2]) sh, sw = small.shape[:2] - small = cv2.resize(small, (max(1, round(sw * scale)), max(1, round(sh * scale))), interpolation=cv2.INTER_AREA) + small = negpy_core.resize_area(np.ascontiguousarray(small, dtype=np.float32), (max(1, round(sw * scale)), max(1, round(sh * scale)))) z = np.nan_to_num(zone_of_encoded(get_luminance(small))) return np.rint(z).astype(np.int32) @@ -355,7 +355,9 @@ def zone_field(rgb: Any, max_edge: int = ZONE_FIELD_MAX_EDGE) -> Optional[np.nda small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) scale = min(1.0, max_edge / max(small.shape[:2])) sh, sw = small.shape[:2] - lum = get_luminance(cv2.resize(small, (max(1, round(sw * scale)), max(1, round(sh * scale))), interpolation=cv2.INTER_AREA)) + lum = get_luminance( + negpy_core.resize_area(np.ascontiguousarray(small, dtype=np.float32), (max(1, round(sw * scale)), max(1, round(sh * scale)))) + ) lum = negpy_core.gaussian_blur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), 2.0) zones = np.clip(np.rint(np.nan_to_num(zone_of_encoded(lum))), 0, ZONE_COUNT).astype(np.uint8) return cv2.medianBlur(zones, 5) @@ -511,7 +513,7 @@ def paper_grid_mosaic(tiles: Sequence[np.ndarray]) -> np.ndarray: h, w = out.shape[:2] for tile, (x0, y0, x1, y1) in zip(tiles, paper_grid_cells(len(tiles))): top, bottom, left, right = round(y0 * h), round(y1 * h), round(x0 * w), round(x1 * w) - out[top:bottom, left:right] = cv2.resize(tile, (right - left, bottom - top), interpolation=cv2.INTER_AREA) + out[top:bottom, left:right] = negpy_core.resize_area(np.ascontiguousarray(tile, dtype=np.float32), (right - left, bottom - top)) return out diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index a0efffd11..90563ac85 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -532,7 +532,7 @@ def analysis_grid( grid = int(EXPOSURE_CONSTANTS["analysis_grid"]) if max(h, w) > grid: scale = grid / float(max(h, w)) - image = cv2.resize(image, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) + image = negpy_core.resize_area(np.ascontiguousarray(image, dtype=np.float32), (max(1, round(w * scale)), max(1, round(h * scale)))) if rotation: image = np.rot90(image, k=rotation) diff --git a/negpy/features/flatfield/logic.py b/negpy/features/flatfield/logic.py index 19d0b6b50..c4a08f8c0 100644 --- a/negpy/features/flatfield/logic.py +++ b/negpy/features/flatfield/logic.py @@ -109,7 +109,7 @@ def work_copy(reference: ImageBuffer, long_edge: int = _GAIN_WORK_SIZE) -> np.nd h, w = ref.shape[:2] scale = min(1.0, long_edge / max(h, w)) if scale < 1.0: - ref = cv2.resize(ref, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) + ref = negpy_core.resize_area(np.ascontiguousarray(ref, dtype=np.float32), (max(1, round(w * scale)), max(1, round(h * scale)))) return ref diff --git a/negpy/features/process/sensor.py b/negpy/features/process/sensor.py index cb3fbfcac..d610f134d 100644 --- a/negpy/features/process/sensor.py +++ b/negpy/features/process/sensor.py @@ -157,7 +157,9 @@ def film_base(x: np.ndarray) -> Optional[np.ndarray]: h, w = sample.shape[:2] scale = _BASE_GRID_EDGE / max(h, w) if scale < 1.0: - sample = cv2.resize(sample, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) + sample = negpy_core.resize_area( + np.ascontiguousarray(sample, dtype=np.float32), (max(1, round(w * scale)), max(1, round(h * scale))) + ) logs = np.log(np.maximum(sample.reshape(-1, 3), _LOG_FLOOR)).astype(np.float64) # A cell is as thin as its least-thin channel, each channel against its own thin end. thin = (logs - np.percentile(logs, _BASE_PERCENTILE, axis=0)).min(axis=1) @@ -193,7 +195,7 @@ def _two_scale_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: sigma = max(1.0, max(h, w) * _TWO_SCALE_SIGMA) # The color layer and the weight are smooth by construction, so they are built small. step = max(1, int(sigma // 2)) - small = x if step == 1 else cv2.resize(x, (-(-w // step), -(-h // step)), interpolation=cv2.INTER_AREA) + small = x if step == 1 else negpy_core.resize_area(np.ascontiguousarray(x, dtype=np.float32), (-(-w // step), -(-h // step))) small = cv2.GaussianBlur(small, (0, 0), sigma / step) color = np.empty_like(small) weight = np.empty(small.shape[:2], np.float32) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index ce7d272d3..9be9b7d9e 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -1227,7 +1227,7 @@ def ir_ratio_and_gain(ir_det: np.ndarray, img_det: np.ndarray) -> Tuple[np.ndarr ratio[~live] = 1.0 img_det = np.ascontiguousarray(img_det, dtype=np.float32) if img_det.shape[:2] != ratio.shape[:2]: - img_det = cv2.resize(img_det, (ratio.shape[1], ratio.shape[0]), interpolation=cv2.INTER_AREA) + img_det = negpy_core.resize_area(np.ascontiguousarray(img_det, dtype=np.float32), (ratio.shape[1], ratio.shape[0])) vis_log = np.stack([np.log(np.clip(normalize_ir(img_det[:, :, c]), 1e-4, 1.0)) for c in range(3)], axis=-1) ratio, ghost = _ir_decontaminate(ratio, vis_log) diff --git a/negpy/features/rgbscan/logic.py b/negpy/features/rgbscan/logic.py index 1a20e6ee6..26dbd471a 100644 --- a/negpy/features/rgbscan/logic.py +++ b/negpy/features/rgbscan/logic.py @@ -270,8 +270,8 @@ def estimate_shift(ref_gray: np.ndarray, mov_gray: np.ndarray) -> Tuple[float, f if w > _EST_WIDTH: scale = w / _EST_WIDTH sz = (_EST_WIDTH, max(1, round(h / scale))) - r = cv2.resize(ref_gray, sz, interpolation=cv2.INTER_AREA) - m = cv2.resize(mov_gray, sz, interpolation=cv2.INTER_AREA) + r = negpy_core.resize_area(np.ascontiguousarray(ref_gray, dtype=np.float32), sz) + m = negpy_core.resize_area(np.ascontiguousarray(mov_gray, dtype=np.float32), sz) r = np.ascontiguousarray(r, dtype=np.float32) m = np.ascontiguousarray(m, dtype=np.float32) win = cv2.createHanningWindow((r.shape[1], r.shape[0]), cv2.CV_32F) diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 270a015bc..2a6737d74 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -36,7 +36,7 @@ def build_proxy(rgb: np.ndarray) -> Tuple[np.ndarray, float]: scale = min(1.0, PROXY_MAX_EDGE / max(gray.shape)) if scale < 1.0: size = (max(1, round(gray.shape[1] * scale)), max(1, round(gray.shape[0] * scale))) - gray = cv2.resize(gray, size, interpolation=cv2.INTER_AREA) + gray = negpy_core.resize_area(np.ascontiguousarray(gray, dtype=np.float32), size) lo, hi = np.percentile(gray, (1.0, 99.0)) gray = np.clip((gray - lo) / max(hi - lo, 1e-9), 0.0, 1.0) return (gray ** (1.0 / 2.2) * 255.0).astype(np.uint8), scale @@ -222,7 +222,7 @@ def find_seams(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray], canvas # this is where an export tight on memory fails, and it has to degrade to the # average rather than end the export. try: - small = [_seam_cost_image(cv2.resize(img, size, interpolation=cv2.INTER_AREA)) for img in warped] + small = [_seam_cost_image(negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), size)) for img in warped] small_masks = [cv2.resize(m.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) for m in masks] corners = [(0, 0)] * len(small) cut = cv2.detail_DpSeamFinder("COLOR").find(small, corners, [cv2.UMat(m) for m in small_masks]) diff --git a/negpy/services/capture/meter.py b/negpy/services/capture/meter.py index 238cf4454..a1ce3797e 100644 --- a/negpy/services/capture/meter.py +++ b/negpy/services/capture/meter.py @@ -8,7 +8,7 @@ import math from dataclasses import dataclass, field -import cv2 +import negpy_core import numpy as np from negpy.features.geometry.logic import ( @@ -68,7 +68,7 @@ class MeterReading: def ranked_thumb(gray: np.ndarray) -> np.ndarray: """A picture at thumbnail size as zero-mean, unit-variance ranks: two thumbnails' mean product is their rank correlation, which no monotonic tone curve and no exposure change can move.""" - small = cv2.resize(np.ascontiguousarray(gray, dtype=np.float32), _THUMB, interpolation=cv2.INTER_AREA).ravel() + small = negpy_core.resize_area(np.ascontiguousarray(gray, dtype=np.float32), _THUMB).ravel() ranks = np.argsort(np.argsort(small)).astype(np.float32) ranks -= ranks.mean() return ranks / max(float(ranks.std()), 1e-6) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 975ff5192..a70c4745a 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -6,6 +6,7 @@ from typing import Any, Dict, Optional, Tuple import cv2 +import negpy_core import numpy as np import wgpu # type: ignore @@ -153,7 +154,7 @@ def _downsample_for_analysis(img: np.ndarray, max_size: int) -> np.ndarray: scale = min(1.0, max_size / max(h, w)) if scale >= 1.0: return img - return cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) + return negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), (int(w * scale), int(h * scale))) def _build_analysis_source( @@ -2825,7 +2826,7 @@ def _process_tiled( ) preview_scale = APP_CONFIG.preview_render_size / max(h, w) - img_small = cv2.resize(img, (int(w * preview_scale), int(h * preview_scale))) + img_small = negpy_core.resize_linear(np.ascontiguousarray(img, dtype=np.float32), (int(w * preview_scale), int(h * preview_scale))) # This render meters the whole frame for the tiles; the CLAHE CDF it leaves behind # is the one they share. Taken from here rather than from the last preview, which diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index 42235428c..52b2bde4b 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -4,6 +4,7 @@ from typing import Any, Optional, Sequence, Tuple import cv2 +import negpy_core import numpy as np import rawpy @@ -458,26 +459,14 @@ def _load_from_open_raw( scale = vram_cap / max(h_p, w_p) target_w = int(w_p * scale) target_h = int(h_p * scale) - preview_raw = ensure_image( - cv2.resize( - full_linear, - (target_w, target_h), - interpolation=cv2.INTER_AREA, - ) - ) + preview_raw = ensure_image(negpy_core.resize_area(np.ascontiguousarray(full_linear, dtype=np.float32), (target_w, target_h))) metadata["vram_capped_long_edge"] = vram_cap log("preview vram_cap applied: %dx%d -> %dx%d", w_p, h_p, target_w, target_h) elif max(h_p, w_p) > max_res and not full_resolution: scale = max_res / max(h_p, w_p) target_w = int(w_p * scale) target_h = int(h_p * scale) - preview_raw = ensure_image( - cv2.resize( - full_linear, - (target_w, target_h), - interpolation=cv2.INTER_AREA, - ) - ) + preview_raw = ensure_image(negpy_core.resize_area(np.ascontiguousarray(full_linear, dtype=np.float32), (target_w, target_h))) else: # Full-res, or already preview-sized: hand the decoded buffer through as it is. A # defensive copy doubles peak RSS on HQ loads of large scans for no benefit, since @@ -760,7 +749,7 @@ def load_linear_preview_rgb( def _match(buf: ImageBuffer) -> np.ndarray: arr = np.asarray(buf, dtype=np.float32) if arr.shape[:2] != red.shape[:2]: - arr = cv2.resize(arr, (red.shape[1], red.shape[0]), interpolation=cv2.INTER_AREA) + arr = negpy_core.resize_area(np.ascontiguousarray(arr, dtype=np.float32), (red.shape[1], red.shape[0])) return arr merged = assemble_rgb(red, _match(green_out), _match(blue_out), align=align) @@ -868,7 +857,7 @@ def _load(path: str) -> np.ndarray: # at stake. if abs(arr.shape[1] * ref.shape[0] - arr.shape[0] * ref.shape[1]) > 0.02 * ref.shape[1] * arr.shape[0]: raise ValueError(f"bracket frames differ in shape: {arr.shape}, {ref.shape}") - arr = cv2.resize(arr, (ref.shape[1], ref.shape[0]), interpolation=cv2.INTER_AREA) + arr = negpy_core.resize_area(np.ascontiguousarray(arr, dtype=np.float32), (ref.shape[1], ref.shape[0])) return arr providers = [lambda: ref, *[lambda p=p: _load(p) for p in other_paths]] diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 3c437de70..72594fbed 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -72,7 +72,7 @@ depends = [] ["features/exposure/analysis.py::paper_grid_mosaic"] phase = 2 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = [] ["features/exposure/analysis.py::scan_full_scale_columns"] @@ -126,13 +126,13 @@ depends = [] ["features/exposure/analysis.py::zone_field"] phase = 2 status = "todo" -calls = ["cv2.medianBlur", "cv2.resize", "negpy_core.gaussian_blur"] +calls = ["cv2.medianBlur", "negpy_core.gaussian_blur", "negpy_core.resize_area"] depends = ["zone_of_encoded"] ["features/exposure/analysis.py::zone_grid"] phase = 2 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = ["zone_of_encoded"] ["features/exposure/analysis.py::zone_occupancy"] @@ -224,7 +224,7 @@ depends = [] ["features/exposure/normalization.py::analysis_grid"] phase = 2 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = [] ["features/exposure/normalization.py::analyze_log_exposure_bounds"] @@ -1209,7 +1209,7 @@ depends = [] ["features/flatfield/logic.py::work_copy"] phase = 4 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = [] ["features/lens/logic.py::_edge_reads"] @@ -1371,7 +1371,7 @@ depends = [] ["features/process/sensor.py::_two_scale_unmix"] phase = 4 status = "todo" -calls = ["cv2.GaussianBlur", "cv2.resize", "negpy_core.resize_linear"] +calls = ["cv2.GaussianBlur", "negpy_core.resize_area", "negpy_core.resize_linear"] depends = ["_two_scale_kernel", "_two_scale_layer_kernel", "film_base"] ["features/process/sensor.py::_unmix_kernel"] @@ -1389,7 +1389,7 @@ depends = ["_density_unmix", "_two_scale_unmix", "_unmix_kernel"] ["features/process/sensor.py::film_base"] phase = 4 status = "todo" -calls = ["cv2.resize", "np.median", "np.percentile", "np.quantile"] +calls = ["negpy_core.resize_area", "np.median", "np.percentile", "np.quantile"] depends = [] ["features/retouch/clone.py::_cover"] @@ -1587,7 +1587,7 @@ depends = [] ["features/retouch/logic.py::ir_ratio_and_gain"] phase = 4 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = ["_fit_refraction_gammas", "_ir_clean_base", "_ir_decontaminate", "_ir_glare", "_ir_live", "_ir_normalize_ratio", "normalize_ir"] ["features/retouch/logic.py::lines_to_score"] @@ -1779,7 +1779,7 @@ depends = ["warp_into_canvas"] ["features/stitch/logic.py::build_proxy"] phase = 5 status = "todo" -calls = ["cv2.resize", "np.percentile"] +calls = ["negpy_core.resize_area", "np.percentile"] depends = [] ["features/stitch/logic.py::compute_canvas"] @@ -2194,13 +2194,13 @@ depends = [] ["services/rendering/gpu_engine.py::GPUEngine._process_tiled"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["cv2.resize", "negpy_core.resize_linear"] depends = ["_downsample_for_analysis"] ["services/rendering/gpu_engine.py::_downsample_for_analysis"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = [] ["services/rendering/image_processor.py::ImageProcessor.render_display_array"] @@ -2224,7 +2224,7 @@ depends = [] ["services/rendering/preview_manager.py::PreviewManager._load_from_open_raw"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = [] ["services/rendering/preview_manager.py::PreviewManager._try_splash_from_open_raw"] @@ -2236,31 +2236,31 @@ depends = [] ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = ["_load"] ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr._load"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = [] ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = ["_match"] ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb._match"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_area"] depends = [] ["desktop/controller.py::AppController._paint_flatfield_peek"] phase = 7 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_linear"] depends = [] ["desktop/controller.py::_interactive_proxy"] @@ -2452,7 +2452,7 @@ depends = ["ranked_thumb"] ["services/capture/meter.py::ranked_thumb"] phase = 7 status = "todo" -calls = ["cv2.resize", "np.argsort"] +calls = ["negpy_core.resize_area", "np.argsort"] depends = [] ["services/export/contact_sheet.py::_place_tile"] @@ -2506,13 +2506,13 @@ depends = ["_borrow_clean_grain", "_hair_encode"] ["features/rgbscan/logic.py::estimate_shift"] phase = 9 status = "todo" -calls = ["cv2.createHanningWindow", "cv2.phaseCorrelate", "cv2.resize"] +calls = ["cv2.createHanningWindow", "cv2.phaseCorrelate", "negpy_core.resize_area"] depends = [] ["features/stitch/logic.py::find_seams"] phase = 9 status = "todo" -calls = ["cv2.UMat", "cv2.detail_DpSeamFinder", "cv2.resize"] +calls = ["cv2.UMat", "cv2.detail_DpSeamFinder", "cv2.resize", "negpy_core.resize_area"] depends = [] ["features/stitch/logic.py::register_pair"] From 2ff45fd8cc64599611ce9fd1d74a9b1c5bf4764c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:21:19 +0200 Subject: [PATCH 071/133] perf(core): nearest-neighbour resize in Rust for uint8 masks negpy_core.resize_nearest copies OpenCV's resizeNN offsets (floor(d * ssize / dsize), clipped); an upscale copies the output row a repeated source row already gave. The scanlight mask, dust dirty mask, stitch seam masks and the IR overlay mask use it. 27 oracle cases match cv2 bit for bit. One thread: mask upscales 0.32-0.40x of cv2, same size 0.89-1.00x, a 0.09 ms downscale 1.29x (the zeroed output). --- negpy/desktop/view/sidebar/scanlight.py | 4 +- negpy/features/retouch/logic.py | 2 +- negpy/features/stitch/logic.py | 7 ++- negpy/services/rendering/image_processor.py | 3 +- negpy_core/manifest.toml | 8 +-- negpy_core/src/py/resize.rs | 42 ++++++++++++++ negpy_core/src/resize.rs | 61 ++++++++++++++++++++- tests/test_core_resize.py | 9 +++ tests/test_core_same_bits.py | 2 + 9 files changed, 127 insertions(+), 11 deletions(-) diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index b962bfbce..4fd34f438 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -14,7 +14,7 @@ from dataclasses import asdict, dataclass, fields, replace from typing import Optional -import cv2 +import negpy_core import numpy as np import qtawesome as qta from PyQt6.QtCore import QTimer, pyqtSignal, pyqtSlot @@ -164,7 +164,7 @@ class _RawClip: def at(self, shape: tuple) -> np.ndarray: if self.resized is None or self.resized[0] != shape: h, w = shape - self.resized = (shape, cv2.resize(self.mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST).astype(bool)) + self.resized = (shape, negpy_core.resize_nearest(self.mask.astype(np.uint8), (w, h)).astype(bool)) return self.resized[1] diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 9be9b7d9e..fa6329113 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -1057,7 +1057,7 @@ def repair_components( factor = max(h / score_det.shape[0], w / score_det.shape[1]) score = negpy_core.resize_linear(np.ascontiguousarray(score_det, dtype=np.float32), (w, h)) if dirty is not None and dirty.shape[:2] != (h, w): - dirty = cv2.resize(dirty.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST) > 0 + dirty = negpy_core.resize_nearest(dirty.astype(np.uint8), (w, h)) > 0 m = (score < 1.0).astype(np.uint8) if not m.any(): return img diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 2a6737d74..67100e73f 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -223,10 +223,13 @@ def find_seams(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray], canvas # average rather than end the export. try: small = [_seam_cost_image(negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), size)) for img in warped] - small_masks = [cv2.resize(m.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) for m in masks] + small_masks = [negpy_core.resize_nearest(m.astype(np.uint8), size) for m in masks] corners = [(0, 0)] * len(small) cut = cv2.detail_DpSeamFinder("COLOR").find(small, corners, [cv2.UMat(m) for m in small_masks]) - seams = [(cv2.resize(cv2.UMat.get(m), (w, h), interpolation=cv2.INTER_NEAREST) > 0) & valid for m, valid in zip(cut, masks)] + seams = [ + (negpy_core.resize_nearest(np.ascontiguousarray(cv2.UMat.get(m), dtype=np.uint8), (w, h)) > 0) & valid + for m, valid in zip(cut, masks) + ] # Nearest-neighbour upscaling can leave a covered pixel assigned to no part, and # an unassigned pixel has no weight to normalize against. Give each one back to a # part that actually covers it. diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 82b7e9acf..4a7bde085 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -5,6 +5,7 @@ import cv2 import rawpy import imagecodecs +import negpy_core import numpy as np from concurrent.futures import ThreadPoolExecutor from dataclasses import replace as dc_replace @@ -317,7 +318,7 @@ def _without_ir(score: Optional[np.ndarray], hairs: List[np.ndarray], ir_mask: n def _resize_mask(mask: np.ndarray, shape: Tuple[int, int]) -> np.ndarray: if mask.shape[:2] == tuple(shape): return mask > 0 - return cv2.resize(mask.astype(np.uint8), (shape[1], shape[0]), interpolation=cv2.INTER_NEAREST) > 0 + return negpy_core.resize_nearest(mask.astype(np.uint8), (shape[1], shape[0])) > 0 def preview_takes_unmix(params: WorkspaceConfig) -> bool: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 72594fbed..d88449504 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1605,7 +1605,7 @@ depends = [] ["features/retouch/logic.py::repair_components"] phase = 4 status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.resize", "negpy_core.resize_linear"] +calls = ["cv2.connectedComponentsWithStats", "negpy_core.resize_linear", "negpy_core.resize_nearest"] depends = ["apply_score_repair"] ["features/retouch/logic.py::route_wide_defects"] @@ -2218,7 +2218,7 @@ depends = [] ["services/rendering/image_processor.py::_resize_mask"] phase = 6 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_nearest"] depends = [] ["services/rendering/preview_manager.py::PreviewManager._load_from_open_raw"] @@ -2308,7 +2308,7 @@ depends = [] ["desktop/view/sidebar/scanlight.py::_RawClip.at"] phase = 7 status = "todo" -calls = ["cv2.resize"] +calls = ["negpy_core.resize_nearest"] depends = [] ["desktop/view/widgets/charts.py::VectorscopeWidget._trace_image"] @@ -2512,7 +2512,7 @@ depends = [] ["features/stitch/logic.py::find_seams"] phase = 9 status = "todo" -calls = ["cv2.UMat", "cv2.detail_DpSeamFinder", "cv2.resize", "negpy_core.resize_area"] +calls = ["cv2.UMat", "cv2.detail_DpSeamFinder", "negpy_core.resize_area", "negpy_core.resize_nearest"] depends = [] ["features/stitch/logic.py::register_pair"] diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index 2d11e9331..5176e22e4 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -82,7 +82,49 @@ fn resize_linear<'py>( Ok(out) } +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_NEAREST)` of a contiguous uint8 image. +#[pyfunction] +fn resize_nearest<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + dsize: (usize, usize), +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_nearest needs a 2-D or 3-D array", + )); + } + }; + let (dw, dh) = dsize; + if h == 0 || w == 0 || cn == 0 || dh == 0 || dw == 0 { + return Err(PyValueError::new_err( + "resize_nearest needs a non-empty size", + )); + } + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_nearest needs a contiguous array"))?; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || { + resize::resize_nearest(data, (h, w, cn), (dh, dw), dst) + }); + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(resize_nearest, m)?)?; m.add_function(wrap_pyfunction!(resize_area, m)?)?; m.add_function(wrap_pyfunction!(resize_linear, m)?) } diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs index d0d9ace5f..a75e9d47b 100644 --- a/negpy_core/src/resize.rs +++ b/negpy_core/src/resize.rs @@ -110,6 +110,58 @@ pub fn resize_area( Some(dst) } +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_NEAREST)`: each output pixel copies the +/// source pixel at floor(d * ssize / dsize), as OpenCV's `resizeNN` rounds it. +pub fn resize_nearest( + src: &[T], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + dst: &mut [T], +) { + let ofs = |s: usize, d: usize, i: usize| { + ((i as f64 * (1.0 / (d as f64 / s as f64))).floor() as usize).min(s - 1) + }; + // u32 offsets keep a wide row's table in L1. + let xs: Vec = (0..dw).map(|x| (ofs(w, dw, x) * cn) as u32).collect(); + let dwidth = dw * cn; + const ROWS: usize = 16; + dst.par_chunks_mut(ROWS * dwidth) + .enumerate() + .for_each(|(block, out)| { + let mut prev = usize::MAX; + for i in 0..out.len() / dwidth { + let sy = ofs(h, dh, block * ROWS + i); + let (done, rest) = out.split_at_mut(i * dwidth); + let o = &mut rest[..dwidth]; + // An upscale repeats a source row: copy the output row it gave. + if sy == prev { + o.copy_from_slice(&done[(i - 1) * dwidth..]); + continue; + } + prev = sy; + let row = &src[sy * w * cn..][..w * cn]; + match cn { + 1 => nearest_row::(row, &xs, o), + 3 => nearest_row::(row, &xs, o), + 4 => nearest_row::(row, &xs, o), + _ => { + for (o, &x) in o.chunks_exact_mut(cn).zip(&xs) { + o.copy_from_slice(&row[x as usize..][..cn]); + } + } + } + } + }); +} + +fn nearest_row(row: &[T], xs: &[u32], out: &mut [T]) { + let (pixels, _) = row.as_chunks::(); + let last = pixels.len() - 1; + for (o, &x) in out.as_chunks_mut::().0.iter_mut().zip(xs) { + *o = pixels[(x as usize / CN).min(last)]; + } +} + /// One axis of OpenCV's bilinear resize (`hal::resize`): per destination index, the first /// source index and the float32 weight of the next one. `area` takes INTER_AREA's upscale /// fractions. Without `clamp` (the y axis) a fraction outside the source stays and both rows @@ -395,7 +447,14 @@ mod tests { let out = resize_area(&src, 40, 40, 1, 80, 30).unwrap(); assert!(out.iter().all(|&v| v == 0.5)); let mut lin = vec![0f32; 90 * 70 * 3]; - resize_linear(&vec![0.5f32; 40 * 40 * 3], (40, 40, 3), (90, 70), false, &mut lin).unwrap(); + resize_linear( + &vec![0.5f32; 40 * 40 * 3], + (40, 40, 3), + (90, 70), + false, + &mut lin, + ) + .unwrap(); assert!(lin.iter().all(|&v| v == 0.5)); } diff --git a/tests/test_core_resize.py b/tests/test_core_resize.py index 0d0960c77..dd633270d 100644 --- a/tests/test_core_resize.py +++ b/tests/test_core_resize.py @@ -85,3 +85,12 @@ def test_resize_area_rejects_strided_input(): img = _image(20, 20, 3) with pytest.raises(ValueError): negpy_core.resize_area(img[:, ::2], (5, 10)) + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("shape, dshape", _SIZES) +def test_resize_nearest_matches_cv2(shape, dshape, cn): + img = (_image(*shape, cn) * 255).astype(np.uint8) + dsize = (dshape[1], dshape[0]) + want = cv2.resize(img, dsize, interpolation=cv2.INTER_NEAREST) + assert negpy_core.resize_nearest(img, dsize).tobytes() == want.tobytes() diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 09ffc7046..688b00ec8 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -36,6 +36,7 @@ def _filled(fill): "resize_area_general": lambda: negpy_core.resize_area(_IMG3, (47, 31)), "resize_linear_up": lambda: negpy_core.resize_linear(_IMG3, (300, 177)), "resize_area_up": lambda: negpy_core.resize_area(_IMG1, (100, 120)), + "resize_nearest": lambda: negpy_core.resize_nearest(_GRAY, (300, 41)), "resize_area_2x2": lambda: negpy_core.resize_area(_IMG1[:82, :60].copy(), (30, 41)), "gaussian_5x5": lambda: negpy_core.gaussian_blur(_IMG3, (5, 5), 0.0), "gaussian_sigma2_replicate": lambda: negpy_core.gaussian_blur(_IMG1, (0, 0), 2.0, 0.0, 1), @@ -89,6 +90,7 @@ def _filled(fill): "resize_area_general": "dbe0fb9e51b0e8bee54cc2bb35756bbf0934077b458c1a44ea27c2e5dd96a16f", "resize_linear_up": "9e6c1200f4f4f05bf46bbba858c67501044e514e47ce1afb50ac5a0e3e7cabee", "resize_area_up": "1b0aa67c89167cb289f780e36bdd5465c5d306200b3f1413510b6cfcd608fcac", + "resize_nearest": "1b335732ac03cc2345941fba19ec591c930b03fd5abdfac82bdf5111984d9956", "resize_area_2x2": "cae2830d9848ee6f270000d57b3dc448138cc99cb5a2303108ac3e095b060488", "gaussian_5x5": "988a867049827fd5912f1950b8574b637d671c3565e0b7af87def261c9f0b084", "gaussian_sigma2_replicate": "2b9be6f95b7f66a03493795d8c7a5e1bb2565a28087578f1981a9cd329c8a74f", From ac8789a668b26e5669cc98c9c43986669c61c759 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:28:08 +0200 Subject: [PATCH 072/133] perf(core): USM sharpening block in Rust negpy_core.usm_sharpen runs _usm_sharpening_block in one call: Lab, the L* blur (bit-exact sepFilter2D), the soft gate and shadow gain in NumPy's float32 order, the optional edge mask (np.hypot as glibc's float64 formula, the 3x3 box mean), the 3x3 range clamp inline, Lab back and the clip with NumPy's NaN and signed-zero rules. The Python row-block driver stays. Bit-exact against the NumPy/cv2 block on 24 corpus and synthetic cases (36 oracle cases); Lab stage replay 39/39 unchanged. One thread: 1600x1113 0.82-0.86x, 6000x4000 0.89-0.97x (the shared Lab conversions dominate). --- .github/workflows/ci.yml | 2 +- negpy/features/lab/logic.py | 27 +------- negpy/kernel/image/logic.py | 4 ++ negpy_core/manifest.toml | 8 +-- negpy_core/src/lab.rs | 121 +++++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/color.rs | 2 +- negpy_core/src/py/lab.rs | 53 +++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_lab.py | 42 ++++++++++++ tests/test_core_same_bits.py | 5 ++ 11 files changed, 238 insertions(+), 31 deletions(-) create mode 100644 negpy_core/src/lab.rs create mode 100644 negpy_core/src/py/lab.rs create mode 100644 tests/test_core_lab.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32562a8bf..0e5b51c5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index 7b71a902b..e6e6ff450 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -2,10 +2,12 @@ from typing import Callable import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer from negpy.kernel.image.logic import ( + WORKING_LAB, gamut_aware_chroma_scale, lab_to_rgb_working, rgb_to_lab_working, @@ -207,31 +209,8 @@ def _usm_sharpening_block( lab_sharpen_h/v.wgsl and lab.wgsl mirror this.""" if amount <= 0: return img - - lab = rgb_to_lab_working(img.astype(np.float32)) - l_chan, a, b = cv2.split(lab) - k = gaussian_kernel_1d(radius) - l_blur = cv2.sepFilter2D(l_chan, -1, k, k, borderType=cv2.BORDER_REFLECT_101) - - diff = l_chan - l_blur - gain = np.float32(amount * 2.5) * _smoothstep(SHARPEN_GATE_LO, SHARPEN_GATE_HI, np.abs(diff)) * sharpen_shadow_gain(l_chan) - - if masking > 0.0: - gain = gain * _edge_mask(l_chan, masking) - - kern3 = np.ones((3, 3), np.uint8) - l_min = cv2.erode(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) - l_max = cv2.dilate(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) - - l_new = l_chan + diff * gain - l_new = np.clip(l_new, l_min - np.float32(SHARPEN_OVERSHOOT_DARK), l_max + np.float32(SHARPEN_OVERSHOOT_LIGHT)) - l_new = np.clip(l_new, 0.0, 100.0) - - res_lab = cv2.merge([l_new.astype(np.float32), a, b]) - res_rgb = lab_to_rgb_working(res_lab) - - return ensure_image(np.clip(res_rgb, 0.0, 1.0)) + return negpy_core.usm_sharpen(np.ascontiguousarray(img, dtype=np.float32), np.float32(amount * 2.5), masking, k, *WORKING_LAB) def apply_rl_sharpening( diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index 8e6106b13..64a4d78d8 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -207,6 +207,10 @@ def gamut_aware_chroma_scale(lab: np.ndarray, saturation: float, iters: int = 10 return out.reshape(arr.shape) +# The working Lab constants, in the order negpy_core's Lab-stage kernels take them. +WORKING_LAB = (_WORKING_TO_XYZ, _XYZ_TO_WORKING, _WORKING_WHITE, np.float32(_LAB_EPS), np.float32(_LAB_KAPPA)) + + def rgb_to_lab_working(img: np.ndarray) -> np.ndarray: """Linear working RGB -> CIELAB (D65). No transfer decode — the buffer is linear. diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index d88449504..0da20ecd7 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2073,9 +2073,9 @@ depends = [] ["features/lab/logic.py::_usm_sharpening_block"] phase = 6 -status = "todo" -calls = ["cv2.dilate", "cv2.erode", "cv2.merge", "cv2.sepFilter2D", "cv2.split"] -depends = ["_edge_mask"] +status = "ported" +calls = ["negpy_core.usm_sharpen"] +depends = [] ["features/lab/logic.py::apply_chroma_denoise"] phase = 6 @@ -2121,7 +2121,7 @@ depends = ["_saturation_block"] ["features/lab/logic.py::apply_usm_sharpening"] phase = 6 -status = "todo" +status = "ported" calls = [] depends = ["_usm_sharpening_block"] diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs new file mode 100644 index 000000000..4b3302d21 --- /dev/null +++ b/negpy_core/src/lab.rs @@ -0,0 +1,121 @@ +//! The Lab-stage kernels of negpy/features/lab/logic.py, in the float32 order NumPy evaluates +//! them, around the bit-exact OpenCV filters of `filter` and `morph`. + +use rayon::prelude::*; + +use crate::color::{self, LabSpace}; +use crate::filter::{self, Border}; + +// Sharpen constants of logic.py; lab.wgsl mirrors them. +const GATE_LO: f32 = 0.25; +const GATE_HI: f64 = 0.33; +const OVERSHOOT_LIGHT: f32 = 1.0; +const OVERSHOOT_DARK: f32 = 2.0; +const MASK_T_HI: f64 = 10.0; +const SHADOW_FLOOR: f64 = 1.0 / 3.0; +const SHADOW_L_HI: f32 = 35.0; + +/// `_smoothstep(e0, e1, x)`: the edges are Python floats, so e1 - e0 is a float64 difference +/// rounded to float32 once. +#[inline(always)] +fn smoothstep(e0: f64, e1: f64, x: f32) -> f32 { + let t = clip((x - e0 as f32) / (e1 - e0) as f32, 0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// `sharpen_shadow_gain(l)`. +#[inline(always)] +fn shadow_gain(l: f32) -> f32 { + let f = SHADOW_FLOOR as f32; + f + (1.0 - f) * smoothstep(0.0, SHADOW_L_HI as f64, l) +} + +/// `_edge_mask(l, masking)`: central differences on the edge-padded plane, `np.hypot` (glibc's +/// float64 formula), a 3x3 replicate-border box mean, then the smoothstep over 0.5t..t. +fn edge_mask(l: &[f32], (h, w): (usize, usize), masking: f64) -> Option> { + let mut hyp = vec![0f32; h * w]; + hyp.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let (up, down) = (y.saturating_sub(1), (y + 1).min(h - 1)); + let row = &l[y * w..][..w]; + for (x, o) in out.iter_mut().enumerate() { + let gx = (row[(x + 1).min(w - 1)] - row[x.saturating_sub(1)]) * 0.5; + let gy = (l[down * w + x] - l[up * w + x]) * 0.5; + let (gx, gy) = (gx as f64, gy as f64); + *o = (gx * gx + gy * gy).sqrt() as f32; + } + }); + let mut grad = vec![0f32; h * w]; + filter::box_filter(&hyp, &mut grad, (h, w, 1), (3, 3), true, Border::Replicate)?; + let t = MASK_T_HI * masking; + grad.par_iter_mut() + .for_each(|g| *g = smoothstep(0.5 * t, t, *g)); + Some(grad) +} + +/// `_usm_sharpening_block` of an HxWx3 linear image: the L* channel's soft-gated unsharp mask +/// with `k` as the blur taps, clamped to the local 3x3 range, then clipped to 0..1 in RGB. +/// `gain` is `np.float32(amount * 2.5)`. None where a filter does not cover the shape. +pub fn usm_sharpen( + src: &[f32], + (h, w): (usize, usize), + (gain, masking): (f32, f64), + k: &[f32], + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let mut lab = vec![0f32; n * 3]; + color::rgb_to_lab(src, &mut lab, to_lab); + let l: Vec = lab.par_chunks(3).map(|p| p[0]).collect(); + let mut blur = vec![0f32; n]; + filter::sep_filter2d(&l, &mut blur, (h, w, 1), (k, k), Border::Reflect101)?; + let edge = if masking > 0.0 { + Some(edge_mask(&l, (h, w), masking)?) + } else { + None + }; + // The 3x3 erode and dilate (replicated edges) run inline, a row at a time. + lab.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let rows = [y.saturating_sub(1), y, (y + 1).min(h - 1)].map(|r| &l[r * w..][..w]); + let col = |x: usize, f: fn(f32, f32) -> f32| f(f(rows[0][x], rows[1][x]), rows[2][x]); + let (mut lo_col, mut hi_col) = (vec![0f32; w], vec![0f32; w]); + for x in 0..w { + lo_col[x] = col(x, f32_lo); + hi_col[x] = col(x, f32_hi); + } + for (x, p) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1)); + let lo = f32_lo(f32_lo(lo_col[xl], lo_col[x]), lo_col[xr]); + let hi = f32_hi(f32_hi(hi_col[xl], hi_col[x]), hi_col[xr]); + let i = y * w + x; + let (l0, d) = (l[i], l[i] - blur[i]); + let mut g = gain * smoothstep(GATE_LO as f64, GATE_HI, d.abs()) * shadow_gain(l0); + if let Some(e) = &edge { + g *= e[i]; + } + let v = clip(l0 + d * g, lo - OVERSHOOT_DARK, hi + OVERSHOOT_LIGHT); + p[0] = clip(v, 0.0, 100.0); + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); + Some(()) +} + +/// `cv2.erode`'s float32 minimum. +fn f32_lo(a: f32, b: f32) -> f32 { + if b < a { b } else { a } +} + +/// `cv2.dilate`'s float32 maximum. +fn f32_hi(a: f32, b: f32) -> f32 { + if b > a { b } else { a } +} + +/// `np.clip(v, lo, hi)`, NumPy's `min(max(v, lo), hi)` with `max(a, b) = a > b ? a : b` +/// unless a is NaN: a NaN in any argument comes through, and clip(-0.0, 0.0, ..) is +0.0. +#[inline(always)] +fn clip(v: f32, lo: f32, hi: f32) -> f32 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 406aafcc6..bb04e79e8 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -11,6 +11,7 @@ pub mod filter; pub mod gray; pub mod hdr; pub mod histogram; +pub mod lab; pub mod mathf; pub mod median; pub mod meter; @@ -61,5 +62,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::hdr::register(m)?; py::display::register(m)?; py::analysis::register(m)?; - py::warp::register(m) + py::warp::register(m)?; + py::lab::register(m) } diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs index 094489ca8..dbd92b23d 100644 --- a/negpy_core/src/py/color.rs +++ b/negpy_core/src/py/color.rs @@ -46,7 +46,7 @@ fn oetf_decode<'py>( filled(py, &img, |s, d| color::oetf_decode(s, d, gamma)) } -fn space( +pub(crate) fn space( m: &PyReadonlyArray2<'_, f32>, white: &PyReadonlyArray1<'_, f32>, eps: f32, diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs new file mode 100644 index 000000000..b50b68b95 --- /dev/null +++ b/negpy_core/src/py/lab.rs @@ -0,0 +1,53 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use super::color::space; +use crate::lab; + +/// `_usm_sharpening_block(img, amount, radius, masking)` of an HxWx3 float32 image: `gain` is +/// `np.float32(amount * 2.5)`, `k` the blur taps, then the working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn usm_sharpen<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + gain: f32, + masking: f64, + k: PyReadonlyArray1<'py, f32>, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let k = k.as_slice().map_err(not_contiguous)?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || { + lab::usm_sharpen(src, (h, w), (gain, masking), k, spaces, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(usm_sharpen, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index df917adc3..20765397a 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -9,6 +9,7 @@ pub mod filter; pub mod gray; pub mod hdr; pub mod histogram; +pub mod lab; pub mod meter; pub mod morph; pub mod print_curve; diff --git a/tests/test_core_lab.py b/tests/test_core_lab.py new file mode 100644 index 000000000..522fbfbba --- /dev/null +++ b/tests/test_core_lab.py @@ -0,0 +1,42 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.lab.logic import _smoothstep, gaussian_kernel_1d, sharpen_shadow_gain +from negpy.kernel.image.logic import WORKING_LAB, lab_to_rgb_working, rgb_to_lab_working + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(17) + + +def _usm_reference(img, amount, radius, masking): + """The NumPy and OpenCV sharpen block that negpy_core.usm_sharpen replaces.""" + lab = rgb_to_lab_working(img) + l_chan, a, b = cv2.split(lab) + k = gaussian_kernel_1d(radius) + diff = l_chan - cv2.sepFilter2D(l_chan, -1, k, k, borderType=cv2.BORDER_REFLECT_101) + gain = np.float32(amount * 2.5) * _smoothstep(0.25, 0.33, np.abs(diff)) * sharpen_shadow_gain(l_chan) + if masking > 0.0: + lp = np.pad(l_chan, 1, mode="edge") + gx = (lp[1:-1, 2:] - lp[1:-1, :-2]) * np.float32(0.5) + gy = (lp[2:, 1:-1] - lp[:-2, 1:-1]) * np.float32(0.5) + grad = cv2.blur(np.hypot(gx, gy).astype(np.float32), (3, 3), borderType=cv2.BORDER_REPLICATE) + gain = gain * _smoothstep(5.0 * masking, 10.0 * masking, grad) + kern3 = np.ones((3, 3), np.uint8) + l_min = cv2.erode(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) + l_max = cv2.dilate(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) + l_new = np.clip(l_chan + diff * gain, l_min - np.float32(2.0), l_max + np.float32(1.0)) + l_new = np.clip(l_new, 0.0, 100.0) + return np.clip(lab_to_rgb_working(cv2.merge([l_new.astype(np.float32), a, b])), 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (5, 7), (300, 200)]) +@pytest.mark.parametrize("amount, radius, masking", [(0.25, 1.0, 0.0), (1.0, 2.3, 0.0), (0.6, 0.8, 0.5), (2.0, 1.0, 1.0)]) +def test_usm_sharpen_matches_numpy_and_cv2(shape, amount, radius, masking): + img = cv2.GaussianBlur(_RNG.random((*shape, 3), dtype=np.float32), (0, 0), 1.2) + want = _usm_reference(img, amount, radius, masking) + got = negpy_core.usm_sharpen(img, np.float32(amount * 2.5), masking, gaussian_kernel_1d(radius), *WORKING_LAB) + assert got.tobytes() == want.tobytes() diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 688b00ec8..331052fb8 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -6,6 +6,7 @@ from negpy.features.exposure.logic import apply_characteristic_curve from negpy.features.toning.logic import apply_chemical_toning +from negpy.kernel.image.logic import WORKING_LAB # Recorded on Linux x86-64, where the cv2 oracles pass. Every release platform must give these # bytes; CI runs this file on each one. A port adds its case here; a hash only changes with a @@ -72,6 +73,9 @@ def _filled(fill): ), "lut_f32": lambda: negpy_core.apply_lut_f32(_IMG3, _LUT), "matrix_trc_u8": lambda: negpy_core.matrix_trc_u8((_IMG3 * 255).astype(np.uint8), np.ascontiguousarray(_RAMP[:, ::256]), _M, _RAMP), + "usm_sharpen": lambda: negpy_core.usm_sharpen( + _IMG3, np.float32(2.0), 0.4, np.array([0.0625, 0.25, 0.375, 0.25, 0.0625], np.float32), *WORKING_LAB + ), "warp_affine": lambda: negpy_core.warp_affine(_IMG3, np.array([[0.998, 0.061, -3.1], [-0.061, 0.998, 4.2]]), (131, 97)), "warp_perspective": lambda: negpy_core.warp_perspective( _IMG1, np.array([[1.02, 0.03, -1.5], [-0.01, 0.97, 2.25], [1e-4, -2e-4, 1.0]]), (61, 83) @@ -114,6 +118,7 @@ def _filled(fill): "sensor_log_unmix": "f7eb8efe1bbcfe884fd2e849e99d0546fac2f4829d3fb558d54a0128e681e53b", "lut_f32": "467bb9fd495d4797d912f6aeabd34f9a61b56491105464a0c852ed524488b1c1", "matrix_trc_u8": "acec1eadffb13dfef8473a02fb250b4ad8f57ac211a6265661b77d3cad49ab16", + "usm_sharpen": "7267d2a3976c9381eadde01fe11ea60d46345b4747283e6ac68a2aa4a025d0a8", "warp_affine": "83562b3183484dcbf80fc65c4fb2f241d3b13ec9793cf43fd22bf11ea44d9e1d", "warp_perspective": "61e89302e793e24f1ad131b661e95186d7d9a82e8331a96164e338bec3ec1420", "warp_affine_cubic_constant": "a3d3d1a3d6c5e0e77686cfd37850199aa880943936e83edf8ada48307501cd69", From 7f01c9e6e81624f62915bfc42ac59995f685e0fc Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:31:28 +0200 Subject: [PATCH 073/133] perf(core): chroma denoise in Rust negpy_core.chroma_denoise runs apply_chroma_denoise once sigma is known: Lab, the a*/b* low pass (bit-exact sepFilter2D, reflect-101), the central-difference edge keep (cv2.filter2D drops the zero tap, so -0.5a + 0.5c rounds once like (c - a) * 0.5), the band blend in NumPy's float32 order, Lab back and the clip. The USM gate and overshoot constants and the chroma edge constants move to lab.rs; the shader comments point there. Bit-exact against the NumPy/cv2 version on 18 corpus and synthetic cases; 9 oracle cases. One thread: 0.61-0.71x. --- negpy/features/lab/logic.py | 42 ++------------------ negpy/features/lab/shaders/lab.wgsl | 4 +- negpy_core/manifest.toml | 12 ++---- negpy_core/src/lab.rs | 60 ++++++++++++++++++++++++++++- negpy_core/src/py/lab.rs | 42 +++++++++++++++++++- tests/test_core_lab.py | 26 +++++++++++++ 6 files changed, 134 insertions(+), 52 deletions(-) diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index e6e6ff450..aca739d8a 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -85,15 +85,8 @@ def apply_clahe(img: ImageBuffer, strength: float) -> ImageBuffer: return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) -# Sharpen constants, mirrored as WGSL consts in shaders/lab.wgsl. -# Gate separating grain from detail in L*, sized against |L - blur| at a 1 px radius, -# which tops out near 1.0. -SHARPEN_GATE_LO = 0.25 -SHARPEN_GATE_HI = 0.33 -# L*-domain USM exaggerates light halos, so overshoot above the local max is clamped -# tighter than undershoot below the local min. -SHARPEN_OVERSHOOT_LIGHT = 1.0 -SHARPEN_OVERSHOOT_DARK = 2.0 +# Sharpen constants, mirrored as WGSL consts in shaders/lab.wgsl; the USM gate and overshoot +# limits live in negpy_core/src/lab.rs. SHARPEN_MASK_T_HI = 10.0 # Gain rolls off toward paper black, where the thin negative's grain lives: floor at # L* 0, full gain from SHARPEN_SHADOW_L_HI up (Gallagher & Gindele, US 7,228,004). @@ -286,31 +279,10 @@ def _saturation_block(img: ImageBuffer, saturation: float, skin_protection: floa return ensure_image(np.clip(res_rgb, 0.0, 1.0)) -# Chroma Denoise edge test: the low band's gradient times sigma, in CIELAB a*/b* units. Chroma -# above the cutoff is kept from LO, fully from HI. Mirrored as WGSL literals in lab.wgsl. -CHROMA_EDGE_LO = 1.0 -CHROMA_EDGE_HI = 2.5 # Below this blur sigma (px) both engines skip the stage. CHROMA_DENOISE_MIN_SIGMA = 0.25 -def chroma_edge_keep(low_ab: np.ndarray, sigma: float) -> np.ndarray: - """How much of the chroma above the cutoff a pixel keeps (0 to 1). Central differences on the - edge-replicated low band, as the shader reads it with clamped coordinates.""" - gx = cv2.filter2D(low_ab, -1, _CENTRAL_DIFF, borderType=cv2.BORDER_REPLICATE) - gy = cv2.filter2D(low_ab, -1, _CENTRAL_DIFF.T, borderType=cv2.BORDER_REPLICATE) - sq = gx * gx - sq += gy * gy - g = np.sqrt(sq[:, :, 0] + sq[:, :, 1]) - g *= np.float32(sigma / (CHROMA_EDGE_HI - CHROMA_EDGE_LO)) - g -= np.float32(CHROMA_EDGE_LO / (CHROMA_EDGE_HI - CHROMA_EDGE_LO)) - t = np.clip(g, 0.0, 1.0, out=g) - return t * t * (3.0 - 2.0 * t) - - -_CENTRAL_DIFF = np.array([[-0.5, 0.0, 0.5]], dtype=np.float32) - - def chroma_denoise_sigma(radius: float, scale_factor: float) -> float: """The low-pass sigma in render pixels for both engines; 0.0 when the stage does not run.""" sigma = radius * scale_factor @@ -326,16 +298,8 @@ def apply_chroma_denoise(img: ImageBuffer, radius: float, scale_factor: float = if sigma == 0.0: return img - lab = rgb_to_lab_working(img.astype(np.float32)) - ab = np.ascontiguousarray(lab[:, :, 1:3]) k = gaussian_kernel_1d(sigma) - low = cv2.sepFilter2D(ab, -1, k, k) - ab -= low - ab *= chroma_edge_keep(low, sigma)[:, :, None] - ab += low - lab[:, :, 1:3] = ab - - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + return negpy_core.chroma_denoise(np.ascontiguousarray(img, dtype=np.float32), sigma, k, *WORKING_LAB) # Halation mask threshold in LINEAR reflectance: the regions the negative rendered dense, diff --git a/negpy/features/lab/shaders/lab.wgsl b/negpy/features/lab/shaders/lab.wgsl index 0783e2f92..473b67543 100644 --- a/negpy/features/lab/shaders/lab.wgsl +++ b/negpy/features/lab/shaders/lab.wgsl @@ -24,7 +24,7 @@ struct LabUniforms { // (low-pass a*, low-pass b*) from chroma_lowpass_h/v.wgsl. 1x1 dummy when Chroma Denoise is off. @group(0) @binding(4) var chroma_tex: texture_2d; -// Sharpen constants — mirrored from features/lab/logic.py. +// Sharpen constants — mirrored from negpy_core/src/lab.rs. const SHARPEN_GATE_LO = 0.25; const SHARPEN_GATE_HI = 0.33; const SHARPEN_OVERSHOOT_LIGHT = 1.0; @@ -122,7 +122,7 @@ const FIBONACCI_64 = array, 64>( // accumulator the same way a Gaussian convolution kernel is normalized (sum=1). const BLOOM_GAUSS_SUM = 27.668145; -// Chroma Denoise edge test — mirrors CHROMA_EDGE_LO/HI in lab/logic.py. +// Chroma Denoise edge test — mirrors CHROMA_EDGE_LO/HI in negpy_core/src/lab.rs. const CHROMA_EDGE_LO = 1.0; const CHROMA_EDGE_HI = 2.5; diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 0da20ecd7..7d9ea4292 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2079,9 +2079,9 @@ depends = [] ["features/lab/logic.py::apply_chroma_denoise"] phase = 6 -status = "todo" -calls = ["cv2.sepFilter2D"] -depends = ["chroma_edge_keep"] +status = "ported" +calls = ["negpy_core.chroma_denoise"] +depends = [] ["features/lab/logic.py::apply_clahe"] phase = 6 @@ -2125,12 +2125,6 @@ status = "ported" calls = [] depends = ["_usm_sharpening_block"] -["features/lab/logic.py::chroma_edge_keep"] -phase = 6 -status = "todo" -calls = ["cv2.filter2D"] -depends = [] - ["features/lab/processor.py::PhotoLabProcessor.process"] phase = 6 status = "todo" diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs index 4b3302d21..c1c70c11d 100644 --- a/negpy_core/src/lab.rs +++ b/negpy_core/src/lab.rs @@ -6,9 +6,12 @@ use rayon::prelude::*; use crate::color::{self, LabSpace}; use crate::filter::{self, Border}; -// Sharpen constants of logic.py; lab.wgsl mirrors them. +// Sharpen constants; shaders/lab.wgsl mirrors them. The gate separates grain from detail in +// L*, sized against |L - blur| at a 1 px radius, which tops out near 1.0. const GATE_LO: f32 = 0.25; const GATE_HI: f64 = 0.33; +// L*-domain USM exaggerates light halos, so overshoot above the local max is clamped tighter +// than undershoot below the local min. const OVERSHOOT_LIGHT: f32 = 1.0; const OVERSHOOT_DARK: f32 = 2.0; const MASK_T_HI: f64 = 10.0; @@ -119,3 +122,58 @@ fn clip(v: f32, lo: f32, hi: f32) -> f32 { let m = if v.is_nan() || v > lo { v } else { lo }; if m.is_nan() || m < hi { m } else { hi } } + +// Chroma Denoise edge test: the low band's gradient times sigma, in CIELAB a*/b* units. Chroma +// above the cutoff is kept from LO, fully from HI. lab.wgsl mirrors them as literals. +const CHROMA_EDGE_LO: f64 = 1.0; +const CHROMA_EDGE_HI: f64 = 2.5; + +/// `apply_chroma_denoise` once sigma is known: the a*/b* band above the Gaussian low pass `k` +/// (reflect-101 edges) is kept only where the low band has a color edge (`chroma_edge_keep`). +pub fn chroma_denoise( + src: &[f32], + (h, w): (usize, usize), + sigma: f64, + k: &[f32], + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let mut lab = vec![0f32; n * 3]; + color::rgb_to_lab(src, &mut lab, to_lab); + let ab: Vec = lab.par_chunks(3).flat_map_iter(|p| [p[1], p[2]]).collect(); + let mut low = vec![0f32; n * 2]; + filter::sep_filter2d(&ab, &mut low, (h, w, 2), (k, k), Border::Reflect101)?; + let span = CHROMA_EDGE_HI - CHROMA_EDGE_LO; + let (scale, shift) = ((sigma / span) as f32, (CHROMA_EDGE_LO / span) as f32); + lab.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let (up, down) = (y.saturating_sub(1), (y + 1).min(h - 1)); + let at = |r: usize, x: usize| &low[(r * w + x) * 2..][..2]; + for (x, p) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (left, right) = (at(y, x.saturating_sub(1)), at(y, (x + 1).min(w - 1))); + let (top, bottom) = (at(up, x), at(down, x)); + // cv2.filter2D drops the zero tap: -0.5 a + 0.5 c rounds once, as (c - a) * 0.5 does. + let sq: [f32; 2] = std::array::from_fn(|c| { + let gx = (right[c] - left[c]) * 0.5; + let gy = (bottom[c] - top[c]) * 0.5; + let s = gx * gx; + s + gy * gy + }); + let mut g = (sq[0] + sq[1]).sqrt(); + g *= scale; + g -= shift; + let t = clip(g, 0.0, 1.0); + let keep = t * t * (3.0 - 2.0 * t); + let i = y * w + x; + for c in 0..2 { + let lo = low[i * 2 + c]; + let mut v = ab[i * 2 + c] - lo; + v *= keep; + p[1 + c] = v + lo; + } + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); + Some(()) +} diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index b50b68b95..c5bb17a39 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -48,6 +48,46 @@ fn usm_sharpen<'py>( Ok(out) } +/// `apply_chroma_denoise` of an HxWx3 float32 image at a known low-pass `sigma` with blur taps +/// `k`, then the working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn chroma_denoise<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + sigma: f64, + k: PyReadonlyArray1<'py, f32>, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let k = k.as_slice().map_err(not_contiguous)?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || { + lab::chroma_denoise(src, (h, w), sigma, k, spaces, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(usm_sharpen, m)?) + m.add_function(wrap_pyfunction!(usm_sharpen, m)?)?; + m.add_function(wrap_pyfunction!(chroma_denoise, m)?) } diff --git a/tests/test_core_lab.py b/tests/test_core_lab.py index 522fbfbba..7a89033e2 100644 --- a/tests/test_core_lab.py +++ b/tests/test_core_lab.py @@ -40,3 +40,29 @@ def test_usm_sharpen_matches_numpy_and_cv2(shape, amount, radius, masking): want = _usm_reference(img, amount, radius, masking) got = negpy_core.usm_sharpen(img, np.float32(amount * 2.5), masking, gaussian_kernel_1d(radius), *WORKING_LAB) assert got.tobytes() == want.tobytes() + + +def _chroma_reference(img, sigma): + """The NumPy and OpenCV chroma denoise that negpy_core.chroma_denoise replaces.""" + lab = rgb_to_lab_working(img) + ab = np.ascontiguousarray(lab[:, :, 1:3]) + k = gaussian_kernel_1d(sigma) + low = cv2.sepFilter2D(ab, -1, k, k) + diff = np.array([[-0.5, 0.0, 0.5]], dtype=np.float32) + gx = cv2.filter2D(low, -1, diff, borderType=cv2.BORDER_REPLICATE) + gy = cv2.filter2D(low, -1, diff.T, borderType=cv2.BORDER_REPLICATE) + g = np.sqrt((gx * gx + gy * gy).sum(axis=2, dtype=np.float32)) + g = g * np.float32(sigma / 1.5) - np.float32(1.0 / 1.5) + t = np.clip(g, 0.0, 1.0) + ab = (ab - low) * (t * t * (3.0 - 2.0 * t))[:, :, None] + low + lab[:, :, 1:3] = ab + return np.clip(lab_to_rgb_working(lab), 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (3, 4), (300, 200)]) +@pytest.mark.parametrize("sigma", [0.5, 2.0, 6.0]) +def test_chroma_denoise_matches_numpy_and_cv2(shape, sigma): + img = _RNG.random((*shape, 3), dtype=np.float32) + want = _chroma_reference(img, sigma) + got = negpy_core.chroma_denoise(img, sigma, gaussian_kernel_1d(sigma), *WORKING_LAB) + assert got.tobytes() == want.tobytes() From be198690d28479cd0804092f4a5ab127c2222ff1 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:35:13 +0200 Subject: [PATCH 074/133] perf(core): glow and halation in Rust negpy_core.glow_and_halation runs both blooms in one call: the masks in NumPy's float32 order (thresholds and the luminance row come from Python), the bit-exact GaussianBlur, the additions and the clip. WORKING_INV_GAMMA names the TRC exponent the encode and the bloom share. Bit-exact against the NumPy/cv2 version on 16 corpus and synthetic cases; 8 oracle cases. One thread: 0.63-0.99x (the blur dominates on large frames). --- negpy/features/lab/logic.py | 31 ++++------------- negpy/kernel/image/logic.py | 5 ++- negpy_core/manifest.toml | 4 +-- negpy_core/src/lab.rs | 66 +++++++++++++++++++++++++++++++++++++ negpy_core/src/py/lab.rs | 43 ++++++++++++++++++++++++ tests/test_core_lab.py | 29 +++++++++++++++- 6 files changed, 149 insertions(+), 29 deletions(-) diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index aca739d8a..5a8b8e9c0 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -7,6 +7,7 @@ from negpy.domain.types import ImageBuffer from negpy.kernel.image.logic import ( + WORKING_INV_GAMMA, WORKING_LAB, gamut_aware_chroma_scale, lab_to_rgb_working, @@ -324,33 +325,13 @@ def apply_glow_and_halation( if glow_amount == 0.0 and halation_strength == 0.0: return img - result = img.copy().astype(np.float32) - + glow = halation = None if glow_amount > 0.0: # Highlight mask in the display domain (keeps the 0.5 threshold); bloom is linear. - enc = working_oetf_encode(img) - luma = enc[:, :, 0] * np.float32(LUM_R) + enc[:, :, 1] * np.float32(LUM_G) + enc[:, :, 2] * np.float32(LUM_B) - threshold = 0.5 - glow_mask = np.clip((luma - threshold) / (1.0 - threshold), 0.0, 1.0) base_r = max(3, int(15 * scale_factor)) - k = min((base_r * 2 + 1) | 1, 201) - sigma = base_r * 0.5 - highlights = (img * glow_mask[:, :, np.newaxis]).astype(np.float32) - glow_blur = cv2.GaussianBlur(highlights, (k, k), sigma) - result = result + glow_blur * glow_amount - + glow = (np.float32(glow_amount), min((base_r * 2 + 1) | 1, 201), base_r * 0.5, 0.5) if halation_strength > 0.0: - lin_luma = img[:, :, 0] * np.float32(LUM_R) + img[:, :, 1] * np.float32(LUM_G) + img[:, :, 2] * np.float32(LUM_B) - t = HALATION_THRESHOLD_LINEAR - hal_mask = np.clip((lin_luma - t) / (1.0 - t), 0.0, 1.0) base_r = max(5, int(25 * scale_factor)) - k = min((base_r * 2 + 1) | 1, 301) - sigma = base_r * 0.5 - red_hl = np.zeros_like(img, dtype=np.float32) - red_hl[:, :, 0] = img[:, :, 0] * hal_mask - red_hl[:, :, 1] = img[:, :, 0] * hal_mask * 0.3 - red_hl[:, :, 2] = img[:, :, 0] * hal_mask * 0.05 - hal_blur = cv2.GaussianBlur(red_hl, (k, k), sigma) - result = result + hal_blur * halation_strength - - return ensure_image(np.clip(result, 0.0, 1.0)) + halation = (np.float32(halation_strength), min((base_r * 2 + 1) | 1, 301), base_r * 0.5, HALATION_THRESHOLD_LINEAR) + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) + return negpy_core.glow_and_halation(np.ascontiguousarray(img, dtype=np.float32), glow, halation, lum, WORKING_INV_GAMMA) diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index 64a4d78d8..b27837986 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -74,10 +74,13 @@ def _oetf_decode_flat(flat: np.ndarray, gamma: float) -> np.ndarray: return negpy_core.oetf_decode(np.ascontiguousarray(flat, dtype=np.float32), gamma) +WORKING_INV_GAMMA = np.float32(1.0 / _WORKING_GAMMA) + + def working_oetf_encode(img: np.ndarray) -> np.ndarray: """Scene-linear -> display-encoded code values [0,1] (Adobe RGB TRC).""" flat = np.ascontiguousarray(img, dtype=np.float32).reshape(-1) - return _oetf_encode_flat(flat, np.float32(1.0 / _WORKING_GAMMA)).reshape(img.shape) + return _oetf_encode_flat(flat, WORKING_INV_GAMMA).reshape(img.shape) def working_oetf_decode(img: np.ndarray) -> np.ndarray: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 7d9ea4292..5732054ab 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2091,8 +2091,8 @@ depends = ["_clahe_cdfs"] ["features/lab/logic.py::apply_glow_and_halation"] phase = 6 -status = "todo" -calls = ["cv2.GaussianBlur"] +status = "ported" +calls = ["negpy_core.glow_and_halation"] depends = [] ["features/lab/logic.py::apply_resize_sharpening"] diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs index c1c70c11d..35cabd93b 100644 --- a/negpy_core/src/lab.rs +++ b/negpy_core/src/lab.rs @@ -177,3 +177,69 @@ pub fn chroma_denoise( dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); Some(()) } + +/// One Gaussian bloom of `apply_glow_and_halation`: the highlights above `threshold` (a Python +/// float) of luma, blurred by (ksize, sigma) and added at `amount`. +pub struct Bloom { + pub amount: f32, + pub ksize: usize, + pub sigma: f64, + pub threshold: f64, +} + +/// `apply_glow_and_halation`: glow masks highlights on the display-encoded luma (`inv_gamma` +/// TRC), halation the red channel on linear luma, each blurred and added, then clipped. `lum` +/// is the luminance row. +pub fn glow_and_halation( + src: &[f32], + (h, w): (usize, usize), + (glow, halation): (Option, Option), + (lum, inv_gamma): ([f32; 3], f32), + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + dst.copy_from_slice(src); + let luma = |p: &[f32]| p[0] * lum[0] + p[1] * lum[1] + p[2] * lum[2]; + let ramp = |v: f32, t: f64| clip((v - t as f32) / (1.0 - t) as f32, 0.0, 1.0); + let mut light = vec![0f32; n * 3]; + let mut blur = vec![0f32; n * 3]; + let mut add = |light: &[f32], b: &Bloom, dst: &mut [f32]| -> Option<()> { + filter::gaussian_blur( + light, + &mut blur, + (h, w, 3), + (b.ksize, b.ksize), + (b.sigma, 0.0), + Border::Reflect101, + )?; + dst.par_iter_mut() + .zip(blur.par_iter()) + .for_each(|(d, &v)| *d += v * b.amount); + Some(()) + }; + if let Some(b) = &glow { + let mut enc = vec![0f32; n * 3]; + color::oetf_encode(src, &mut enc, inv_gamma); + light + .par_chunks_mut(3) + .zip(src.par_chunks(3)) + .zip(enc.par_chunks(3)) + .for_each(|((o, p), e)| { + let m = ramp(luma(e), b.threshold); + o.copy_from_slice(&[p[0] * m, p[1] * m, p[2] * m]); + }); + add(&light, b, dst)?; + } + if let Some(b) = &halation { + light + .par_chunks_mut(3) + .zip(src.par_chunks(3)) + .for_each(|(o, p)| { + let r = p[0] * ramp(luma(p), b.threshold); + o.copy_from_slice(&[r, r * 0.3, r * 0.05]); + }); + add(&light, b, dst)?; + } + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); + Some(()) +} diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index c5bb17a39..384d44db2 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -87,7 +87,50 @@ fn chroma_denoise<'py>( Ok(out) } +type BloomArgs = Option<(f32, usize, f64, f64)>; + +/// `apply_glow_and_halation` of an HxWx3 float32 image. Each bloom is `(amount, ksize, sigma, +/// threshold)` or None when off; `lum` is the luminance row, `inv_gamma` the working TRC's. +#[pyfunction] +fn glow_and_halation<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + glow: BloomArgs, + halation: BloomArgs, + lum: (f32, f32, f32), + inv_gamma: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let bloom = |b: BloomArgs| { + b.map(|(amount, ksize, sigma, threshold)| lab::Bloom { + amount, + ksize, + sigma, + threshold, + }) + }; + let (glow, halation) = (bloom(glow), bloom(halation)); + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || { + lab::glow_and_halation(src, (h, w), (glow, halation), (lum.into(), inv_gamma), dst) + }) + .ok_or_else(|| PyValueError::new_err("a bloom needs an odd kernel size"))?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(glow_and_halation, m)?)?; m.add_function(wrap_pyfunction!(usm_sharpen, m)?)?; m.add_function(wrap_pyfunction!(chroma_denoise, m)?) } diff --git a/tests/test_core_lab.py b/tests/test_core_lab.py index 7a89033e2..b9a2a0f50 100644 --- a/tests/test_core_lab.py +++ b/tests/test_core_lab.py @@ -5,7 +5,8 @@ from core_oracle import cv2_oracle from negpy.features.lab.logic import _smoothstep, gaussian_kernel_1d, sharpen_shadow_gain -from negpy.kernel.image.logic import WORKING_LAB, lab_to_rgb_working, rgb_to_lab_working +from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R, apply_glow_and_halation +from negpy.kernel.image.logic import WORKING_LAB, lab_to_rgb_working, rgb_to_lab_working, working_oetf_encode pytestmark = cv2_oracle @@ -66,3 +67,29 @@ def test_chroma_denoise_matches_numpy_and_cv2(shape, sigma): want = _chroma_reference(img, sigma) got = negpy_core.chroma_denoise(img, sigma, gaussian_kernel_1d(sigma), *WORKING_LAB) assert got.tobytes() == want.tobytes() + + +def _glow_reference(img, glow_amount, halation_strength, scale_factor): + """The NumPy and OpenCV bloom that negpy_core.glow_and_halation replaces.""" + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) + result = img.copy() + if glow_amount > 0.0: + enc = working_oetf_encode(img) + mask = np.clip((enc[:, :, 0] * lum[0] + enc[:, :, 1] * lum[1] + enc[:, :, 2] * lum[2] - 0.5) / 0.5, 0.0, 1.0) + r = max(3, int(15 * scale_factor)) + result = result + cv2.GaussianBlur(img * mask[:, :, None], (min((r * 2 + 1) | 1, 201),) * 2, r * 0.5) * glow_amount + if halation_strength > 0.0: + mask = np.clip((img[:, :, 0] * lum[0] + img[:, :, 1] * lum[1] + img[:, :, 2] * lum[2] - 0.65) / (1.0 - 0.65), 0.0, 1.0) + red = img[:, :, 0] * mask + r = max(5, int(25 * scale_factor)) + hl = np.stack([red, red * 0.3, red * 0.05], axis=-1) + result = result + cv2.GaussianBlur(hl, (min((r * 2 + 1) | 1, 301),) * 2, r * 0.5) * halation_strength + return np.clip(result, 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (5, 7)]) +@pytest.mark.parametrize("glow, halation, scale", [(0.3, 0.0, 1.0), (0.0, 0.5, 1.0), (0.4, 0.2, 0.5), (1.0, 1.0, 2.0)]) +def test_glow_and_halation_matches_numpy_and_cv2(shape, glow, halation, scale): + img = _RNG.random((*shape, 3), dtype=np.float32) + want = _glow_reference(img, glow, halation, scale) + assert apply_glow_and_halation(img, glow, halation, scale).tobytes() == want.tobytes() From 33df9a4ad631b28a5b1f7e78ce9db5c631d1cb3a Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:37:51 +0200 Subject: [PATCH 075/133] perf(core): CLAHE in Rust negpy_core.clahe runs apply_clahe in one call: Lab, the L* bins, the per-tile histograms (integer counts, merged across threads, so the core count never changes them), the clipped CDFs with the float32 limit and the remainder to the first bins, the smoothstep tile blend and the mix back by strength. The NumPy reference and the CDF invariant test move to tests/test_core_lab.py. Bit-exact on 28 corpus and synthetic cases; replay 39/39. One thread: 0.58x. --- negpy/features/lab/logic.py | 58 +------------------- negpy_core/manifest.toml | 12 ++--- negpy_core/src/lab.rs | 103 ++++++++++++++++++++++++++++++++++++ negpy_core/src/py/lab.rs | 36 +++++++++++++ tests/test_core_lab.py | 53 ++++++++++++++++++- tests/test_lab_logic.py | 12 ----- 6 files changed, 195 insertions(+), 79 deletions(-) diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index 5a8b8e9c0..e161bda7b 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -19,44 +19,6 @@ from negpy.kernel.image.validation import ensure_image -CLAHE_GRID = 8 -CLAHE_BINS = 256 - - -def _clahe_cdfs(bins: np.ndarray, clip_limit: float) -> np.ndarray: - """ - Per-tile clipped CDFs, (64, 256) float32. Mirrors clahe_hist.wgsl / - clahe_cdf.wgsl exactly (integer counts, f32-truncated limit, excess - redistributed evenly with the remainder going to the first bins). - """ - h, w = bins.shape - tsy, tsx = (h + CLAHE_GRID - 1) // CLAHE_GRID, (w + CLAHE_GRID - 1) // CLAHE_GRID - ty = (np.arange(h) // tsy).astype(np.int32) - tx = (np.arange(w) // tsx).astype(np.int32) - comb = (ty[:, None] * CLAHE_GRID + tx[None, :]) * CLAHE_BINS + bins - hist = np.bincount(comb.ravel(), minlength=CLAHE_GRID * CLAHE_GRID * CLAHE_BINS) - hist = hist.reshape(CLAHE_GRID * CLAHE_GRID, CLAHE_BINS) - - total = hist.sum(axis=1) - limit = np.maximum(1, (np.float32(clip_limit) * total.astype(np.float32) / np.float32(CLAHE_BINS)).astype(np.int64)) - clipped = np.minimum(hist, limit[:, None]) - excess = (hist - clipped).sum(axis=1) - inc, rem = excess // CLAHE_BINS, excess % CLAHE_BINS - counts = clipped + inc[:, None] + (np.arange(CLAHE_BINS)[None, :] < rem[:, None]) - return np.cumsum(counts, axis=1).astype(np.float32) / np.maximum(total, 1)[:, None].astype(np.float32) - - -def _clahe_axis(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - # tile_pos = pos/dims*8 - 0.5, smoothstep frac; true floor (can be -1), clamp 0..7. - tp = np.arange(n, dtype=np.float32) / np.float32(n) * np.float32(CLAHE_GRID) - np.float32(0.5) - tf = np.floor(tp) - rf = tp - tf - fr = rf * rf * (np.float32(3.0) - np.float32(2.0) * rf) - lo = np.maximum(tf.astype(np.int32), 0) - hi = np.minimum(tf.astype(np.int32) + 1, CLAHE_GRID - 1) - return lo, hi, fr - - def apply_clahe(img: ImageBuffer, strength: float) -> ImageBuffer: """ L-channel Contrast Limited Adaptive Histogram Equalization. @@ -65,25 +27,7 @@ def apply_clahe(img: ImageBuffer, strength: float) -> ImageBuffer: """ if strength <= 0: return img - - lab = rgb_to_lab_working(img) - l_chan = lab[..., 0] - h, w = l_chan.shape - bins = np.clip(l_chan / np.float32(100.0) * np.float32(255.0), 0.0, 255.0).astype(np.int32) - cdfs = _clahe_cdfs(bins, strength * 2.5).reshape(-1) - - y0, y1, fy = _clahe_axis(h) - x0, x1, fx = _clahe_axis(w) - v00 = cdfs[(y0[:, None] * CLAHE_GRID + x0[None, :]) * CLAHE_BINS + bins] - v10 = cdfs[(y0[:, None] * CLAHE_GRID + x1[None, :]) * CLAHE_BINS + bins] - v01 = cdfs[(y1[:, None] * CLAHE_GRID + x0[None, :]) * CLAHE_BINS + bins] - v11 = cdfs[(y1[:, None] * CLAHE_GRID + x1[None, :]) * CLAHE_BINS + bins] - top = v00 + (v10 - v00) * fx[None, :] - bot = v01 + (v11 - v01) * fx[None, :] - cdf_l = top + (bot - top) * fy[:, None] - - lab[..., 0] = l_chan + (cdf_l * np.float32(100.0) - l_chan) * np.float32(strength) - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + return negpy_core.clahe(np.ascontiguousarray(img, dtype=np.float32), strength, *WORKING_LAB) # Sharpen constants, mirrored as WGSL consts in shaders/lab.wgsl; the USM gate and overshoot diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 5732054ab..013661e79 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2053,12 +2053,6 @@ status = "todo" calls = [] depends = [] -["features/lab/logic.py::_clahe_cdfs"] -phase = 6 -status = "todo" -calls = ["np.bincount", "np.cumsum"] -depends = [] - ["features/lab/logic.py::_edge_mask"] phase = 6 status = "todo" @@ -2085,9 +2079,9 @@ depends = [] ["features/lab/logic.py::apply_clahe"] phase = 6 -status = "todo" -calls = [] -depends = ["_clahe_cdfs"] +status = "ported" +calls = ["negpy_core.clahe"] +depends = [] ["features/lab/logic.py::apply_glow_and_halation"] phase = 6 diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs index 35cabd93b..500e2f571 100644 --- a/negpy_core/src/lab.rs +++ b/negpy_core/src/lab.rs @@ -243,3 +243,106 @@ pub fn glow_and_halation( dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); Some(()) } + +const CLAHE_GRID: usize = 8; +const CLAHE_BINS: usize = 256; + +/// `_clahe_cdfs`: per tile of the 8x8 grid, the clipped CDF of the bins. The limit is the +/// float32 product truncated, at least 1; the excess spreads evenly, the remainder to the +/// first bins. +fn clahe_cdfs(bins: &[u8], (h, w): (usize, usize), clip_limit: f32) -> Vec<[f32; CLAHE_BINS]> { + let (tsy, tsx) = (h.div_ceil(CLAHE_GRID), w.div_ceil(CLAHE_GRID)); + let tiles = CLAHE_GRID * CLAHE_GRID; + let hist = bins + .par_chunks(w) + .enumerate() + .fold( + || vec![0i64; tiles * CLAHE_BINS], + |mut acc, (y, row)| { + let base = (y / tsy) * CLAHE_GRID; + for (x, &b) in row.iter().enumerate() { + acc[(base + x / tsx) * CLAHE_BINS + b as usize] += 1; + } + acc + }, + ) + .reduce( + || vec![0i64; tiles * CLAHE_BINS], + |mut a, b| { + a.iter_mut().zip(b).for_each(|(a, b)| *a += b); + a + }, + ); + hist.as_chunks::() + .0 + .iter() + .map(|t| { + let total: i64 = t.iter().sum(); + let limit = ((clip_limit * total as f32 / CLAHE_BINS as f32) as i64).max(1); + let excess: i64 = t.iter().map(|&c| c - c.min(limit)).sum(); + let (inc, rem) = (excess / CLAHE_BINS as i64, excess % CLAHE_BINS as i64); + let denom = total.max(1) as f32; + let mut run = 0i64; + std::array::from_fn(|b| { + run += t[b].min(limit) + inc + ((b as i64) < rem) as i64; + run as f32 / denom + }) + }) + .collect() +} + +/// `_clahe_axis(n)`: per position the two tile indices and the smoothstep fraction, from +/// tile_pos = pos / n * 8 - 0.5 in float32. +fn clahe_axis(n: usize) -> Vec<(usize, usize, f32)> { + (0..n) + .map(|i| { + let tp = i as f32 / n as f32 * CLAHE_GRID as f32 - 0.5; + let tf = tp.floor(); + let rf = tp - tf; + let t = tf as i32; + let (lo, hi) = ( + t.max(0) as usize, + (t + 1).min(CLAHE_GRID as i32 - 1) as usize, + ); + (lo, hi, rf * rf * (3.0 - 2.0 * rf)) + }) + .collect() +} + +/// `apply_clahe(img, strength)` for strength > 0: the L* channel equalized per tile of a +/// fixed 8x8 grid, blended between tiles, and mixed back by `strength`; clahe_*.wgsl mirror it. +pub fn clahe( + src: &[f32], + (h, w): (usize, usize), + strength: f64, + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) { + let n = h * w; + let mut lab = vec![0f32; n * 3]; + color::rgb_to_lab(src, &mut lab, to_lab); + // np.clip(...).astype(int32) truncates; a NaN lands in bin 0 here. + let bins: Vec = lab + .par_chunks(3) + .map(|p| clip(p[0] / 100.0 * 255.0, 0.0, 255.0) as u8) + .collect(); + let cdfs = clahe_cdfs(&bins, (h, w), (strength * 2.5) as f32); + let (ys, xs) = (clahe_axis(h), clahe_axis(w)); + let s = strength as f32; + lab.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let (y0, y1, fy) = ys[y]; + for (x, p) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (x0, x1, fx) = xs[x]; + let b = bins[y * w + x] as usize; + let v = |ty: usize, tx: usize| cdfs[ty * CLAHE_GRID + tx][b]; + let (v00, v10, v01, v11) = (v(y0, x0), v(y0, x1), v(y1, x0), v(y1, x1)); + let top = v00 + (v10 - v00) * fx; + let bot = v01 + (v11 - v01) * fx; + let cdf = top + (bot - top) * fy; + let l = p[0]; + p[0] = l + (cdf * 100.0 - l) * s; + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); +} diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index 384d44db2..ab749ae87 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -129,7 +129,43 @@ fn glow_and_halation<'py>( Ok(out) } +/// `apply_clahe(img, strength)` of an HxWx3 float32 image for strength > 0, then the working +/// Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn clahe<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + strength: f64, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || lab::clahe(src, (h, w), strength, spaces, dst)); + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(clahe, m)?)?; m.add_function(wrap_pyfunction!(glow_and_halation, m)?)?; m.add_function(wrap_pyfunction!(usm_sharpen, m)?)?; m.add_function(wrap_pyfunction!(chroma_denoise, m)?) diff --git a/tests/test_core_lab.py b/tests/test_core_lab.py index b9a2a0f50..a3a4cfb11 100644 --- a/tests/test_core_lab.py +++ b/tests/test_core_lab.py @@ -5,7 +5,7 @@ from core_oracle import cv2_oracle from negpy.features.lab.logic import _smoothstep, gaussian_kernel_1d, sharpen_shadow_gain -from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R, apply_glow_and_halation +from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R, apply_clahe, apply_glow_and_halation from negpy.kernel.image.logic import WORKING_LAB, lab_to_rgb_working, rgb_to_lab_working, working_oetf_encode pytestmark = cv2_oracle @@ -93,3 +93,54 @@ def test_glow_and_halation_matches_numpy_and_cv2(shape, glow, halation, scale): img = _RNG.random((*shape, 3), dtype=np.float32) want = _glow_reference(img, glow, halation, scale) assert apply_glow_and_halation(img, glow, halation, scale).tobytes() == want.tobytes() + + +def _clahe_cdfs(bins, clip_limit): + """Per-tile clipped CDFs, (64, 256): integer counts, a float32-truncated limit, the excess + spread evenly with the remainder to the first bins, as clahe_hist.wgsl and clahe_cdf.wgsl.""" + h, w = bins.shape + tsy, tsx = -(-h // 8), -(-w // 8) + comb = ((np.arange(h) // tsy)[:, None] * 8 + (np.arange(w) // tsx)[None, :]) * 256 + bins + hist = np.bincount(comb.ravel(), minlength=64 * 256).reshape(64, 256) + total = hist.sum(axis=1) + limit = np.maximum(1, (np.float32(clip_limit) * total.astype(np.float32) / np.float32(256)).astype(np.int64)) + clipped = np.minimum(hist, limit[:, None]) + excess = (hist - clipped).sum(axis=1) + counts = clipped + (excess // 256)[:, None] + (np.arange(256)[None, :] < (excess % 256)[:, None]) + return np.cumsum(counts, axis=1).astype(np.float32) / np.maximum(total, 1)[:, None].astype(np.float32) + + +def _clahe_axis(n): + tp = np.arange(n, dtype=np.float32) / np.float32(n) * np.float32(8) - np.float32(0.5) + tf = np.floor(tp) + rf = tp - tf + return np.maximum(tf.astype(np.int32), 0), np.minimum(tf.astype(np.int32) + 1, 7), rf * rf * (np.float32(3.0) - np.float32(2.0) * rf) + + +def _clahe_reference(img, strength): + """The NumPy CLAHE that negpy_core.clahe replaces.""" + lab = rgb_to_lab_working(img) + l_chan = lab[..., 0] + bins = np.clip(l_chan / np.float32(100.0) * np.float32(255.0), 0.0, 255.0).astype(np.int32) + cdfs = _clahe_cdfs(bins, strength * 2.5).reshape(-1) + (y0, y1, fy), (x0, x1, fx) = _clahe_axis(img.shape[0]), _clahe_axis(img.shape[1]) + v = [cdfs[(ty[:, None] * 8 + tx[None, :]) * 256 + bins] for ty, tx in ((y0, x0), (y0, x1), (y1, x0), (y1, x1))] + top = v[0] + (v[1] - v[0]) * fx[None, :] + bot = v[2] + (v[3] - v[2]) * fx[None, :] + cdf_l = top + (bot - top) * fy[:, None] + lab[..., 0] = l_chan + (cdf_l * np.float32(100.0) - l_chan) * np.float32(strength) + return np.clip(lab_to_rgb_working(lab), 0.0, 1.0) + + +def test_clahe_cdfs_are_monotone_and_end_at_one(): + cdfs = _clahe_cdfs(np.random.default_rng(7).integers(0, 256, (128, 128)).astype(np.int32), 2.5) + assert cdfs.shape == (64, 256) + assert np.all(np.diff(cdfs, axis=1) >= 0) + assert np.all(cdfs[:, -1] == 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (5, 7), (1, 1), (300, 200)]) +@pytest.mark.parametrize("strength", [0.1, 0.5, 1.0, 3.0]) +def test_clahe_matches_numpy(shape, strength): + img = _RNG.random((*shape, 3), dtype=np.float32) + assert apply_clahe(img, strength).tobytes() == _clahe_reference(img, strength).tobytes() diff --git a/tests/test_lab_logic.py b/tests/test_lab_logic.py index 05143dd78..07436db77 100644 --- a/tests/test_lab_logic.py +++ b/tests/test_lab_logic.py @@ -55,18 +55,6 @@ def test_clahe_flat_image_near_identity(self) -> None: res = apply_clahe(img, 1.0) np.testing.assert_allclose(res, img, atol=0.02) - def test_clahe_cdf_invariants(self) -> None: - """Per-tile CDFs are monotone and end exactly at 1.0 — the excess - redistribution conserves the tile total, mirroring clahe_cdf.wgsl.""" - from negpy.features.lab.logic import _clahe_cdfs - - rng = np.random.default_rng(7) - bins = rng.integers(0, 256, (128, 128)).astype(np.int32) - cdfs = _clahe_cdfs(bins, 2.5) - self.assertEqual(cdfs.shape, (64, 256)) - self.assertTrue(np.all(np.diff(cdfs, axis=1) >= 0)) - self.assertTrue(np.all(cdfs[:, -1] == 1.0)) - def test_output_sharpening(self) -> None: """Sharpening should increase local variance.""" # Create a simple square From bfaaa9f85ec9a96aa4ba0dbc170f4c5259f17b2b Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 11:41:27 +0200 Subject: [PATCH 076/133] perf(core): Richardson-Lucy sharpening in Rust; lab logic has no cv2 left negpy_core.rl_sharpen runs apply_rl_sharpening in one call: luminance, the RL iterations on the bit-exact sepFilter2D, the ratio, L* with a correctly rounded cube root (mathf::cbrtf, new), the shadow gain and edge mask, and the RGB factor. With a correctly rounded cube root it matches the NumPy/cv2 version bit for bit; NumPy's float32 np.cbrt is SVML here, up to 2 ULP off, so outputs move by up to 7e-7 (ledger, abs=1e-6). The unused _lab_l_from_y, _edge_mask and SHARPEN_MASK_T_HI go; features/lab/logic.py imports no cv2. One thread: 0.80-0.87x. --- negpy/features/lab/logic.py | 51 +++--------------- negpy/features/lab/shaders/lab.wgsl | 2 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 13 ++--- negpy_core/src/lab.rs | 81 +++++++++++++++++++++++++++++ negpy_core/src/mathf.rs | 33 ++++++++++++ negpy_core/src/py/lab.rs | 40 ++++++++++++++ tests/test_core_lab.py | 36 ++++++++++++- 8 files changed, 201 insertions(+), 56 deletions(-) diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index e161bda7b..cda0c8f49 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -1,7 +1,6 @@ import math from typing import Callable -import cv2 import negpy_core import numpy as np @@ -30,9 +29,8 @@ def apply_clahe(img: ImageBuffer, strength: float) -> ImageBuffer: return negpy_core.clahe(np.ascontiguousarray(img, dtype=np.float32), strength, *WORKING_LAB) -# Sharpen constants, mirrored as WGSL consts in shaders/lab.wgsl; the USM gate and overshoot -# limits live in negpy_core/src/lab.rs. -SHARPEN_MASK_T_HI = 10.0 +# Sharpen constants, mirrored as WGSL consts in shaders/lab.wgsl; the USM gate, overshoot and +# edge-mask limits live in negpy_core/src/lab.rs. # Gain rolls off toward paper black, where the thin negative's grain lives: floor at # L* 0, full gain from SHARPEN_SHADOW_L_HI up (Gallagher & Gindele, US 7,228,004). SHARPEN_SHADOW_FLOOR = 1.0 / 3.0 @@ -62,24 +60,6 @@ def _smoothstep(e0: float, e1: float, x: np.ndarray) -> np.ndarray: return t * t * (np.float32(3.0) - np.float32(2.0) * t) -def _lab_l_from_y(y: np.ndarray) -> np.ndarray: - """CIELAB L* from linear luminance Y (D65, Yn=1) — mirrors lab_l_from_y in the shaders.""" - y = np.maximum(y, 0.0) - f = np.where(y > 0.008856, np.cbrt(y), np.float32(7.787) * y + np.float32(16.0 / 116.0)) - return (np.float32(116.0) * f - np.float32(16.0)).astype(np.float32) - - -def _edge_mask(l_chan: np.ndarray, masking: float) -> np.ndarray: - """Boxed |∇L*| edge mask (smoothstep over 0.5t..t, t=10·masking); shared by - both sharpen methods. Mirrors the WGSL boxed-gradient loop.""" - lp = np.pad(l_chan, 1, mode="edge") - gx = (lp[1:-1, 2:] - lp[1:-1, :-2]) * np.float32(0.5) - gy = (lp[2:, 1:-1] - lp[:-2, 1:-1]) * np.float32(0.5) - grad = cv2.blur(np.hypot(gx, gy).astype(np.float32), (3, 3), borderType=cv2.BORDER_REPLICATE) - t = SHARPEN_MASK_T_HI * masking - return _smoothstep(0.5 * t, t, grad) - - def sharpen_shadow_gain(l_chan: np.ndarray) -> np.ndarray: """Per-pixel sharpen gain multiplier from L*; mirrors sharpen_shadow_gain in lab.wgsl.""" f = np.float32(SHARPEN_SHADOW_FLOOR) @@ -162,30 +142,11 @@ def apply_rl_sharpening( rl_*.wgsl mirrors this.""" if amount <= 0: return img - - rgb = img.astype(np.float32) - obs = ( - np.maximum(rgb[..., 0], 0.0) * np.float32(LUM_R) - + np.maximum(rgb[..., 1], 0.0) * np.float32(LUM_G) - + np.maximum(rgb[..., 2], 0.0) * np.float32(LUM_B) - ).astype(np.float32) - + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) k = gaussian_kernel_1d(radius) - est = obs.copy() - for _ in range(rl_iterations(radius)): - blurred = cv2.sepFilter2D(est, -1, k, k, borderType=cv2.BORDER_REFLECT_101) - corr = cv2.sepFilter2D(obs / np.maximum(blurred, np.float32(RL_EPS)), -1, k, k, borderType=cv2.BORDER_REFLECT_101) - est = est * corr - - ratio = est / np.maximum(obs, np.float32(RL_EPS)) - l_obs = _lab_l_from_y(obs) - gain = np.float32(amount) * sharpen_shadow_gain(l_obs) - if masking > 0.0: - gain = gain * _edge_mask(l_obs, masking) - - factor = np.maximum(np.float32(1.0) + (ratio - np.float32(1.0)) * gain, 0.0) - out = rgb * factor[..., np.newaxis] - return ensure_image(np.clip(out, 0.0, 1.0)) + return negpy_core.rl_sharpen( + np.ascontiguousarray(img, dtype=np.float32), np.float32(amount), masking, k, rl_iterations(radius), lum, np.float32(RL_EPS) + ) def apply_saturation(img: ImageBuffer, saturation: float, skin_protection: float = 0.0) -> ImageBuffer: diff --git a/negpy/features/lab/shaders/lab.wgsl b/negpy/features/lab/shaders/lab.wgsl index 473b67543..1e9b8748d 100644 --- a/negpy/features/lab/shaders/lab.wgsl +++ b/negpy/features/lab/shaders/lab.wgsl @@ -38,7 +38,7 @@ const RL_EPS = 1e-6; // logic.py and the y row in rgb_to_lab below. const WORKING_LUMA_COEFFS = vec3(0.2973769, 0.6273491, 0.0752741); -// CIELAB L* from linear luminance Y (D65, Yn=1) — mirrors _lab_l_from_y in logic.py. +// CIELAB L* from linear luminance Y (D65, Yn=1) — mirrors lab_l_from_y in negpy_core/src/lab.rs. // Mirrors sharpen_shadow_gain in logic.py: gain rolls off toward paper black. fn sharpen_shadow_gain(l: f32) -> f32 { return SHARPEN_SHADOW_FLOOR + (1.0 - SHARPEN_SHADOW_FLOOR) * smoothstep(0.0, SHARPEN_SHADOW_L_HI, l); diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 4222548e2..35386809b 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -22,3 +22,4 @@ tolerance listed for each entry. | `features/process/sensor.py::_log_unmix_kernel` (density unmix) | `logf` and `expf` are `mathf`'s (correctly rounded) instead of glibc's; the log-domain matrix and the exp amplify a one-ULP log to up to 16 ULP on 0.2 % of values. The other three unmix kernels match Numba bit for bit. | ulp=16 | | `features/geometry/logic.py::apply_fine_rotation`, `apply_keystone`, `apply_radial_distortion`, frames of 32767 px or more on a side | OpenCV cannot warp such a frame, so the Python version warped it in tiles from float64 source maps. negpy_core warps the whole frame with OpenCV's fixed-point coordinates, which can move a sample by 1/32 px. Smaller frames match `cv2` bit for bit. | none (the corpus has no such frame) | | `negpy_core.resize_linear` (bilinear `cv2.resize` of float32: the contrast mask plane, the two-scale unmix layers, dust score and IR gain maps, flat-field gains) | opencv-python on x86-64 runs IPP's float32 bilinear resize, whose bits depend on the CPU it dispatches for; Apple Silicon wheels run OpenCV's own code. negpy_core copies OpenCV's own code. Upscales differ by up to 3 ULP, downscales more. No recorded call reaches these resizes. | ulp=3 (upscale) | +| `features/lab/logic.py::apply_rl_sharpening` | L* takes a correctly rounded cube root (`mathf::cbrtf`); NumPy's float32 `np.cbrt` runs SVML here, up to 2 ULP off on a third of inputs. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 013661e79..a9e2b50c2 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2053,12 +2053,6 @@ status = "todo" calls = [] depends = [] -["features/lab/logic.py::_edge_mask"] -phase = 6 -status = "todo" -calls = ["cv2.blur"] -depends = [] - ["features/lab/logic.py::_saturation_block"] phase = 6 status = "todo" @@ -2103,9 +2097,10 @@ depends = ["_usm_sharpening_block"] ["features/lab/logic.py::apply_rl_sharpening"] phase = 6 -status = "todo" -calls = ["cv2.sepFilter2D"] -depends = ["_edge_mask"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.rl_sharpen"] +depends = [] ["features/lab/logic.py::apply_saturation"] phase = 6 diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs index 500e2f571..206d5545c 100644 --- a/negpy_core/src/lab.rs +++ b/negpy_core/src/lab.rs @@ -346,3 +346,84 @@ pub fn clahe( color::lab_to_rgb(&lab, dst, to_rgb); dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); } + +/// The scalars of `apply_rl_sharpening`: `amount` as float32, the edge `masking`, the +/// iteration count, the luminance row and the noise floor `eps`. +pub struct Rl { + pub amount: f32, + pub masking: f64, + pub iterations: usize, + pub lum: [f32; 3], + pub eps: f32, +} + +/// `_lab_l_from_y`: CIELAB L* of linear luminance (D65, Yn = 1), the cube root correctly +/// rounded where NumPy runs SVML's. +#[inline(always)] +fn lab_l_from_y(y: f32) -> f32 { + let y = if y.is_nan() || y > 0.0 { y } else { 0.0 }; + let f = if y > 0.008856 { + crate::mathf::cbrtf(y) + } else { + 7.787 * y + (16.0f64 / 116.0) as f32 + }; + 116.0 * f - 16.0 +} + +/// `apply_rl_sharpening` for amount > 0: Richardson-Lucy deconvolution of linear Y with the +/// Gaussian taps `k` (reflect-101 edges), applied to RGB as the ratio est / obs, gated by the +/// shadow gain and the optional edge mask, then clipped. +pub fn rl_sharpen( + src: &[f32], + (h, w): (usize, usize), + p: &Rl, + k: &[f32], + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let lum = p.lum; + let pos = |v: f32| if v.is_nan() || v > 0.0 { v } else { 0.0 }; + let obs: Vec = src + .par_chunks(3) + .map(|q| pos(q[0]) * lum[0] + pos(q[1]) * lum[1] + pos(q[2]) * lum[2]) + .collect(); + let floor = |v: f32| if v.is_nan() || v > p.eps { v } else { p.eps }; + let mut est = obs.clone(); + let (mut blurred, mut ratio, mut corr) = (vec![0f32; n], vec![0f32; n], vec![0f32; n]); + let blur = |s: &[f32], d: &mut [f32]| { + filter::sep_filter2d(s, d, (h, w, 1), (k, k), Border::Reflect101) + }; + for _ in 0..p.iterations { + blur(&est, &mut blurred)?; + ratio + .par_iter_mut() + .zip(obs.par_iter().zip(blurred.par_iter())) + .for_each(|(r, (&o, &b))| *r = o / floor(b)); + blur(&ratio, &mut corr)?; + est.par_iter_mut() + .zip(corr.par_iter()) + .for_each(|(e, &c)| *e *= c); + } + let l_obs: Vec = obs.par_iter().map(|&y| lab_l_from_y(y)).collect(); + let edge = if p.masking > 0.0 { + Some(edge_mask(&l_obs, (h, w), p.masking)?) + } else { + None + }; + dst.par_chunks_mut(3) + .zip(src.par_chunks(3)) + .enumerate() + .for_each(|(i, (d, q))| { + let r = est[i] / floor(obs[i]); + let mut g = p.amount * shadow_gain(l_obs[i]); + if let Some(e) = &edge { + g *= e[i]; + } + let f = 1.0 + (r - 1.0) * g; + let f = if f.is_nan() || f > 0.0 { f } else { 0.0 }; + for c in 0..3 { + d[c] = clip(q[c] * f, 0.0, 1.0); + } + }); + Some(()) +} diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index c9343c597..a6e685165 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -169,10 +169,43 @@ pub fn pow_f64(x: f64, y: f64) -> f64 { } } +/// Cube root of a non-negative f32 (NaN and +inf pass through): an exponent-third estimate +/// in f64, then three Halley steps, rounded once to f32. The test checks a sample against +/// the correctly rounded value. +#[inline(always)] +pub fn cbrtf(x: f32) -> f32 { + let d = x as f64; + // fdlibm's estimate: a third of the bits plus a bias puts the exponent at e/3, within 7%. + let mut y = f64::from_bits(d.to_bits() / 3 + 0x2a9f_7893_0000_0000); + for _ in 0..3 { + let y3 = y * y * y; + y *= (y3 + 2.0 * d) / (2.0 * y3 + d); + } + if x == 0.0 || !x.is_finite() { + x + } else { + y as f32 + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn cbrtf_is_correctly_rounded_on_a_sample() { + let mut bits = 1u32; + while bits < f32::INFINITY.to_bits() { + let x = f32::from_bits(bits); + assert_eq!(cbrtf(x), libm::cbrt(x as f64) as f32, "{x:e}"); + bits += 9973; + } + assert_eq!(cbrtf(0.0), 0.0); + assert_eq!(cbrtf(27.0), 3.0); + assert!(cbrtf(f32::NAN).is_nan()); + assert_eq!(cbrtf(f32::INFINITY), f32::INFINITY); + } + #[test] fn log10f_is_correctly_rounded_on_a_sample() { let mut bits = 1e-6f32.to_bits(); diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index ab749ae87..106c587ff 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -164,7 +164,47 @@ fn clahe<'py>( Ok(out) } +/// `apply_rl_sharpening(img, amount, radius, masking)` of an HxWx3 float32 image for amount > 0: +/// `amount` as float32, the blur taps `k`, the iteration count, the luminance row and RL_EPS. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn rl_sharpen<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + amount: f32, + masking: f64, + k: PyReadonlyArray1<'py, f32>, + iterations: usize, + lum: (f32, f32, f32), + eps: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let k = k.as_slice().map_err(not_contiguous)?; + let params = lab::Rl { + amount, + masking, + iterations, + lum: lum.into(), + eps, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || lab::rl_sharpen(src, (h, w), ¶ms, k, dst)) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rl_sharpen, m)?)?; m.add_function(wrap_pyfunction!(clahe, m)?)?; m.add_function(wrap_pyfunction!(glow_and_halation, m)?)?; m.add_function(wrap_pyfunction!(usm_sharpen, m)?)?; diff --git a/tests/test_core_lab.py b/tests/test_core_lab.py index a3a4cfb11..a65a1f9b0 100644 --- a/tests/test_core_lab.py +++ b/tests/test_core_lab.py @@ -5,7 +5,7 @@ from core_oracle import cv2_oracle from negpy.features.lab.logic import _smoothstep, gaussian_kernel_1d, sharpen_shadow_gain -from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R, apply_clahe, apply_glow_and_halation +from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R, RL_EPS, apply_clahe, apply_glow_and_halation, apply_rl_sharpening, rl_iterations from negpy.kernel.image.logic import WORKING_LAB, lab_to_rgb_working, rgb_to_lab_working, working_oetf_encode pytestmark = cv2_oracle @@ -144,3 +144,37 @@ def test_clahe_cdfs_are_monotone_and_end_at_one(): def test_clahe_matches_numpy(shape, strength): img = _RNG.random((*shape, 3), dtype=np.float32) assert apply_clahe(img, strength).tobytes() == _clahe_reference(img, strength).tobytes() + + +def _rl_reference(img, amount, radius, masking): + """The NumPy and OpenCV Richardson-Lucy sharpen that negpy_core.rl_sharpen replaces, with a + correctly rounded cube root where NumPy's own depends on the CPU.""" + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) + obs = (np.maximum(img[..., 0], 0.0) * lum[0] + np.maximum(img[..., 1], 0.0) * lum[1] + np.maximum(img[..., 2], 0.0) * lum[2]).astype( + np.float32 + ) + k = gaussian_kernel_1d(radius) + est = obs.copy() + for _ in range(rl_iterations(radius)): + blurred = cv2.sepFilter2D(est, -1, k, k, borderType=cv2.BORDER_REFLECT_101) + est = est * cv2.sepFilter2D(obs / np.maximum(blurred, np.float32(RL_EPS)), -1, k, k, borderType=cv2.BORDER_REFLECT_101) + ratio = est / np.maximum(obs, np.float32(RL_EPS)) + y = np.maximum(obs, 0.0) + cbrt = np.cbrt(y.astype(np.float64)).astype(np.float32) + l_obs = np.float32(116.0) * np.where(y > 0.008856, cbrt, np.float32(7.787) * y + np.float32(16.0 / 116.0)) - np.float32(16.0) + gain = np.float32(amount) * sharpen_shadow_gain(l_obs) + if masking > 0.0: + lp = np.pad(l_obs, 1, mode="edge") + gx = (lp[1:-1, 2:] - lp[1:-1, :-2]) * np.float32(0.5) + gy = (lp[2:, 1:-1] - lp[:-2, 1:-1]) * np.float32(0.5) + grad = cv2.blur(np.hypot(gx, gy).astype(np.float32), (3, 3), borderType=cv2.BORDER_REPLICATE) + gain = gain * _smoothstep(5.0 * masking, 10.0 * masking, grad) + factor = np.maximum(np.float32(1.0) + (ratio - np.float32(1.0)) * gain, 0.0) + return np.clip(img * factor[..., None], 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (6, 7), (200, 300)]) +@pytest.mark.parametrize("amount, radius, masking", [(0.25, 1.0, 0.0), (1.0, 0.6, 0.0), (0.6, 1.4, 0.5)]) +def test_rl_sharpen_matches_numpy_and_cv2(shape, amount, radius, masking): + img = cv2.GaussianBlur(_RNG.random((*shape, 3), dtype=np.float32), (0, 0), 1.0) - np.float32(0.05) + assert apply_rl_sharpening(img, amount, radius, masking).tobytes() == _rl_reference(img, amount, radius, masking).tobytes() From 98d76a72cb239d4520b23b7e0d9210c765f64b3d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 12:19:06 +0200 Subject: [PATCH 077/133] perf(core): Sabattier, cyanotype and lith in Rust; wide filters re-filter fewer rows negpy_core.sabattier, cyanotype and lith run each process in one call, staged in runs of 256 pixels so the transcendentals vectorize: the density, the fold or curve, 10^-D, L* and the hue path (np.interp in float64), then Lab to RGB. The constants stay in the Python dicts. With correctly rounded transcendentals each matches the NumPy version bit for bit; against NumPy's SVML they move by up to 6e-7 (ledger, abs=1e-6). sep_filter2d strips grow with the kernel radius: a 32-row strip re-filtered 2r halo rows, about four times the work at 101 taps. One thread, 24 MP: Sabattier 0.53x (0.66x with the Mackie line), cyanotype 0.31x, lith 0.38x; glow and halation now 0.77-0.82x; a 101-tap sepFilter2D 0.84x of cv2. --- .github/workflows/ci.yml | 2 +- negpy/features/cyanotype/logic.py | 60 ++---- negpy/features/lith/logic.py | 50 ++--- negpy/features/sabattier/logic.py | 23 +-- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 15 +- negpy_core/src/altprocess.rs | 297 ++++++++++++++++++++++++++++++ negpy_core/src/filter.rs | 55 +++--- negpy_core/src/lib.rs | 4 +- negpy_core/src/py/altprocess.rs | 166 +++++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_altprocess.py | 117 ++++++++++++ tests/test_core_same_bits.py | 14 ++ 13 files changed, 678 insertions(+), 127 deletions(-) create mode 100644 negpy_core/src/altprocess.rs create mode 100644 negpy_core/src/py/altprocess.rs create mode 100644 tests/test_core_altprocess.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e5b51c5d..91cd4bfd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py tests/test_core_altprocess.py diff --git a/negpy/features/cyanotype/logic.py b/negpy/features/cyanotype/logic.py index fdaa56107..7e8a0759c 100644 --- a/negpy/features/cyanotype/logic.py +++ b/negpy/features/cyanotype/logic.py @@ -1,11 +1,11 @@ -from typing import Any, Dict, Sequence, Tuple +from typing import Any, Dict +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer from negpy.features.altprocess.models import Sensitizer -from negpy.kernel.image.logic import _LAB_EPS, _LAB_KAPPA, lab_to_rgb_working -from negpy.kernel.image.validation import ensure_image +from negpy.kernel.image.logic import WORKING_LAB CYANOTYPE_CONSTANTS: Dict[str, Any] = { # Transfer. @@ -52,14 +52,6 @@ def sensitizer_constants(sensitizer: str) -> Dict[str, Any]: return SENSITIZERS.get(Sensitizer(sensitizer), SENSITIZERS[Sensitizer.CLASSIC]) -def _hue_path(u: np.ndarray, path: Sequence[Sequence[float]]) -> Tuple[np.ndarray, np.ndarray]: - """(a*, b*) along the sensitizer's density hue path, clamped outside the anchors.""" - xp = np.asarray(CYANOTYPE_CONSTANTS["path_u"], dtype=np.float32) - a = np.interp(u, xp, np.asarray([p[0] for p in path], dtype=np.float32)).astype(np.float32) - b = np.interp(u, xp, np.asarray([p[1] for p in path], dtype=np.float32)).astype(np.float32) - return a, b - - def apply_cyanotype( img: ImageBuffer, sensitizer: str = Sensitizer.CLASSIC, @@ -94,33 +86,19 @@ def apply_cyanotype( c = CYANOTYPE_CONSTANTS sc = sensitizer_constants(sensitizer) - d_max = np.float32(sc["d_max"]) - - arr = np.clip(img.astype(np.float32), 1e-6, 1.0) - luma = arr[:, :, 0] * np.float32(0.2126) + arr[:, :, 1] * np.float32(0.7152) + arr[:, :, 2] * np.float32(0.0722) - - base = -np.log10(np.clip(luma, 1e-6, 1.0)) - t = np.clip((base + np.float32(0.301 * exposure)) / np.float32(max(scale, 0.1)), 0.0, 1.0).astype(np.float32) - - m = np.float32(c["mid_compress"]) - v = np.float32(2.0) * t - np.float32(1.0) - u0 = (np.float32(1.0) - m) * t + m * np.float32(0.5) * (np.float32(1.0) + v * np.abs(v)) - - b_amt = np.float32(bleach) - u_b = u0 * (np.float32(1.0) - b_amt * (np.float32(1.0) - np.float32(c["bleach_floor"]) * u0)) - - t_amt = np.float32(tannin) - u = u_b + t_amt * (u0 * np.float32(c["tannin_restore"]) - u_b) - dens = (d_max * (np.float32(1.0) + t_amt * np.float32(c["tannin_dmax_gain"])) * u).astype(np.float32) - - frac = np.clip(u, 0.0, 1.0).astype(np.float32) - a_blue, b_blue = _hue_path(frac, sc["path"]) - brown = c["brown_dir"] - a_star = a_blue + t_amt * (np.float32(brown[0]) * frac - a_blue) - b_star = b_blue + t_amt * (np.float32(brown[1]) * frac - b_blue) - - grey = np.power(np.float32(10.0), -dens) - f = np.where(grey > np.float32(_LAB_EPS), np.cbrt(grey), np.float32(_LAB_KAPPA) * grey + np.float32(16.0 / 116.0)) - lab = np.dstack([np.float32(116.0) * f - np.float32(16.0), a_star, b_star]) - - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + f32 = np.float32 + scalars = [ + f32(sc["d_max"]), + f32(0.301 * exposure), + f32(max(scale, 0.1)), + f32(c["mid_compress"]), + f32(bleach), + f32(c["bleach_floor"]), + f32(tannin), + f32(c["tannin_restore"]), + f32(c["tannin_dmax_gain"]), + ] + brown = (f32(c["brown_dir"][0]), f32(c["brown_dir"][1])) + path_u = [f32(v) for v in c["path_u"]] + anchors = [(f32(a), f32(b)) for a, b in sc["path"]] + return negpy_core.cyanotype(np.ascontiguousarray(img, dtype=np.float32), scalars, brown, path_u, anchors, WORKING_LAB) diff --git a/negpy/features/lith/logic.py b/negpy/features/lith/logic.py index f2f2e37ae..0e19465f9 100644 --- a/negpy/features/lith/logic.py +++ b/negpy/features/lith/logic.py @@ -1,10 +1,10 @@ -from typing import Any, Dict, Sequence, Tuple +from typing import Any, Dict, Sequence +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer -from negpy.kernel.image.logic import _LAB_EPS, _LAB_KAPPA, lab_to_rgb_working -from negpy.kernel.image.validation import ensure_image +from negpy.kernel.image.logic import WORKING_LAB LITH_CONSTANTS: Dict[str, Any] = { # Highlight branch (first development phase). @@ -38,14 +38,6 @@ } -def _hue_path(u: np.ndarray, path: Sequence[Sequence[float]]) -> Tuple[np.ndarray, np.ndarray]: - """(a*, b*) along the paper's density hue path, clamped outside the anchors.""" - xp = np.asarray(LITH_CONSTANTS["path_u"], dtype=np.float32) - a = np.interp(u, xp, np.asarray([p[0] for p in path], dtype=np.float32)).astype(np.float32) - b = np.interp(u, xp, np.asarray([p[1] for p in path], dtype=np.float32)).astype(np.float32) - return a, b - - def apply_lith( img: ImageBuffer, path: Sequence[Sequence[float]], @@ -74,26 +66,16 @@ def apply_lith( return img c = LITH_CONSTANTS - arr = np.clip(img.astype(np.float32), 1e-6, 1.0) - luma = arr[:, :, 0] * np.float32(0.2126) + arr[:, :, 1] * np.float32(0.7152) + arr[:, :, 2] * np.float32(0.0722) - - base = -np.log10(np.clip(luma, 1e-6, 1.0)) - over = np.float32(0.301 * exposure) - d0 = base + over - - foot_max = np.float32(c["foot_max"]) - d_foot = base + over * np.float32(c["foot_veil"]) - d_h = foot_max * (np.float32(1.0) - np.exp(-np.float32(c["foot_rate"]) * d_foot / foot_max)) - - knee = np.float32(d_max * (c["knee_lo"] - c["knee_span"] * snatch)) - width = np.float32(max(c["abrupt_lo"] - c["abrupt_span"] * abruptness, 0.01)) - - g = 1.0 / (1.0 + np.exp(-np.clip((d0 - knee) / width, -30.0, 30.0))) - dens = (d_h + (np.float32(d_max) - d_h) * g).astype(np.float32) - - grey = np.power(np.float32(10.0), -dens) - a_star, b_star = _hue_path(np.clip(dens / np.float32(d_max), 0.0, 1.0), path) - f = np.where(grey > np.float32(_LAB_EPS), np.cbrt(grey), np.float32(_LAB_KAPPA) * grey + np.float32(16.0 / 116.0)) - lab = np.dstack([np.float32(116.0) * f - np.float32(16.0), a_star, b_star]) - - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + f32 = np.float32 + scalars = [ + f32(d_max), + f32(0.301 * exposure), + f32(c["foot_max"]), + f32(c["foot_veil"]), + f32(c["foot_rate"]), + f32(d_max * (c["knee_lo"] - c["knee_span"] * snatch)), + f32(max(c["abrupt_lo"] - c["abrupt_span"] * abruptness, 0.01)), + ] + path_u = [f32(v) for v in c["path_u"]] + anchors = [(f32(a), f32(b)) for a, b in path] + return negpy_core.lith(np.ascontiguousarray(img, dtype=np.float32), scalars, path_u, anchors, WORKING_LAB) diff --git a/negpy/features/sabattier/logic.py b/negpy/features/sabattier/logic.py index af91c1f47..53a745c48 100644 --- a/negpy/features/sabattier/logic.py +++ b/negpy/features/sabattier/logic.py @@ -1,11 +1,10 @@ from typing import Any, Dict -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer from negpy.features.lab.logic import gaussian_kernel_1d -from negpy.kernel.image.validation import ensure_image SABATTIER_CONSTANTS: Dict[str, Any] = { # Softness of the fold at the re-exposure density, in D. The softplus below turns the @@ -35,22 +34,10 @@ def apply_sabattier( return img c = SABATTIER_CONSTANTS - arr = np.clip(img.astype(np.float32), 1e-6, 1.0) - luma = arr[:, :, 0] * np.float32(0.2126) + arr[:, :, 1] * np.float32(0.7152) + arr[:, :, 2] * np.float32(0.0722) - dens = -np.log10(np.clip(luma, 1e-6, 1.0)) - - re = np.float32(reexposure * d_max) - w = np.float32(c["fold_width"]) - shortfall = w * np.log1p(np.exp(np.clip((re - dens) / w, -30.0, 30.0))) - reversal = np.float32(strength) * shortfall - if line_sigma_px > 0.0: - developed = 1.0 / (1.0 + np.exp(-np.clip((dens - re) / np.float32(c["edge_width"]), -30.0, 30.0))) - k = gaussian_kernel_1d(float(line_sigma_px)) - bromide = cv2.sepFilter2D(developed.astype(np.float32), -1, k, k, borderType=cv2.BORDER_REPLICATE) - reversal = reversal * (np.float32(1.0) - bromide) - - grey = np.power(np.float32(10.0), -np.minimum(dens + reversal, np.float32(d_max))).astype(np.float32) - return ensure_image(np.clip(np.dstack([grey, grey, grey]), 0.0, 1.0)) + f32 = np.float32 + scalars = (f32(reexposure * d_max), f32(c["fold_width"]), f32(c["edge_width"]), f32(strength), f32(d_max)) + k = gaussian_kernel_1d(float(line_sigma_px)) if line_sigma_px > 0.0 else np.zeros(0, np.float32) + return negpy_core.sabattier(np.ascontiguousarray(img, dtype=np.float32), scalars, k) def line_sigma_px(agitation: float, shape: tuple) -> float: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 35386809b..474481b3f 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -23,3 +23,4 @@ tolerance listed for each entry. | `features/geometry/logic.py::apply_fine_rotation`, `apply_keystone`, `apply_radial_distortion`, frames of 32767 px or more on a side | OpenCV cannot warp such a frame, so the Python version warped it in tiles from float64 source maps. negpy_core warps the whole frame with OpenCV's fixed-point coordinates, which can move a sample by 1/32 px. Smaller frames match `cv2` bit for bit. | none (the corpus has no such frame) | | `negpy_core.resize_linear` (bilinear `cv2.resize` of float32: the contrast mask plane, the two-scale unmix layers, dust score and IR gain maps, flat-field gains) | opencv-python on x86-64 runs IPP's float32 bilinear resize, whose bits depend on the CPU it dispatches for; Apple Silicon wheels run OpenCV's own code. negpy_core copies OpenCV's own code. Upscales differ by up to 3 ULP, downscales more. No recorded call reaches these resizes. | ulp=3 (upscale) | | `features/lab/logic.py::apply_rl_sharpening` | L* takes a correctly rounded cube root (`mathf::cbrtf`); NumPy's float32 `np.cbrt` runs SVML here, up to 2 ULP off on a third of inputs. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | +| `features/sabattier/logic.py::apply_sabattier`, `features/cyanotype/logic.py::apply_cyanotype`, `features/lith/logic.py::apply_lith` | log10, exp, log1p, 10^x and the cube root are `mathf`'s, correctly rounded (or within an ULP of it); NumPy's float32 versions run SVML or its own SIMD code. With correctly rounded transcendentals each port matches bit for bit. | abs=1e-6 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index a9e2b50c2..81ada3342 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1820,8 +1820,9 @@ depends = [] ["features/cyanotype/logic.py::apply_cyanotype"] phase = 6 -status = "todo" -calls = [] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.cyanotype"] depends = [] ["features/cyanotype/processor.py::CyanotypeProcessor.process"] @@ -2122,8 +2123,9 @@ depends = [] ["features/lith/logic.py::apply_lith"] phase = 6 -status = "todo" -calls = [] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.lith"] depends = [] ["features/lith/processor.py::LithProcessor.process"] @@ -2134,8 +2136,9 @@ depends = [] ["features/sabattier/logic.py::apply_sabattier"] phase = 6 -status = "todo" -calls = ["cv2.sepFilter2D"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.sabattier"] depends = [] ["features/sabattier/processor.py::SabattierProcessor.process"] diff --git a/negpy_core/src/altprocess.rs b/negpy_core/src/altprocess.rs new file mode 100644 index 000000000..fe9bb531b --- /dev/null +++ b/negpy_core/src/altprocess.rs @@ -0,0 +1,297 @@ +//! The alternative processes of negpy/features/{sabattier,cyanotype,lith}/logic.py, in NumPy's +//! float32 order. The transcendentals are `mathf`'s, correctly rounded or nearly; NumPy's +//! float32 ones run SVML or its own SIMD code, whose bits differ by CPU. + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::color::{self, LabSpace}; +use crate::filter::{self, Border}; +use crate::mathf::{cbrtf, exp_f64, exp10_f64, log1p_f64, log10f}; + +/// `np.clip(v, lo, hi)`: a NaN comes through. +#[inline(always)] +fn clip(v: f32, lo: f32, hi: f32) -> f32 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} + +#[inline(always)] +fn expf(x: f32) -> f32 { + exp_f64(x as f64) as f32 +} + +/// `np.power(np.float32(10.0), x)`. +#[inline(always)] +fn exp10f(x: f32) -> f32 { + exp10_f64(x as f64) as f32 +} + +/// Pixels per pass: each step runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +/// The print density of each pixel of a run: `-log10` of the clipped Rec. 709 luma. +#[inline(always)] +fn density_run(px: &[[f32; 3]], out: &mut [f32]) { + for (o, p) in out.iter_mut().zip(px) { + let c = |v: f32| clip(v, 1e-6, 1.0); + let luma = c(p[0]) * 0.2126 + c(p[1]) * 0.7152 + c(p[2]) * 0.0722; + *o = -log10f(clip(luma, 1e-6, 1.0)); + } +} + +/// `np.interp(x, xp, fp)` of float32 anchors, in float64: the slope of each segment, then +/// slope * (x - xp[j]) + fp[j]; the ends hold, and a NaN comes through. +#[derive(Clone, Copy)] +pub struct Interp { + xp: [f64; 4], + fp: [f64; 4], + slope: [f64; 3], +} + +impl Interp { + pub fn new(xp: [f32; 4], fp: [f32; 4]) -> Self { + let (xp, fp) = (xp.map(|v| v as f64), fp.map(|v| v as f64)); + let slope = std::array::from_fn(|j| (fp[j + 1] - fp[j]) / (xp[j + 1] - xp[j])); + Self { xp, fp, slope } + } + + #[inline(always)] + fn at(&self, x: f32) -> f32 { + let (xp, fp, sl) = (self.xp, self.fp, self.slope); + let x = x as f64; + let (s, x0, f0) = if x < xp[1] { + (sl[0], xp[0], fp[0]) + } else if x < xp[2] { + (sl[1], xp[1], fp[1]) + } else { + (sl[2], xp[2], fp[2]) + }; + let v = s * (x - x0) + f0; + let v = if x >= xp[3] { fp[3] } else { v }; + (if x < xp[0] { fp[0] } else { v }) as f32 + } +} + +/// L* of a linear grey level (D65, Yn = 1), the CIELAB constants as float32. +#[inline(always)] +fn lightness(grey: f32, eps: f32, kappa: f32) -> f32 { + let c = cbrtf(grey); + let lin = kappa * grey + (16.0f64 / 116.0) as f32; + 116.0 * (if grey > eps { c } else { lin }) - 16.0 +} + +/// Lab of each pixel from `block`, then clipped working RGB. +fn print_lab( + src: &[f32], + block: impl Fn(&[[f32; 3]], &mut [[f32; 3]]) + Sync, + to_rgb: LabSpace, + dst: &mut [f32], +) { + let mut lab = vec![0f32; src.len()]; + lab.par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(o, s)| block(s.as_chunks::<3>().0, o.as_chunks_mut::<3>().0)); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); +} + +/// The scalars of `apply_sabattier`; float32 ones are rounded as NumPy rounds them. +pub struct Sabattier { + /// `np.float32(reexposure * d_max)`. + pub re: f32, + pub fold_width: f32, + pub edge_width: f32, + pub strength: f32, + pub d_max: f32, +} + +/// Density and reversal of a block; with `developed`, the silver the bromide mask reads. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn sabattier_block( + px: &[[f32; 3]], + s: &Sabattier, + dens: &mut [f32], + rev: &mut [f32], + developed: Option<&mut [f32]>, +) { + density_run(px, dens); + for (r, &d) in rev.iter_mut().zip(dens.iter()) { + let x = clip((s.re - d) / s.fold_width, -30.0, 30.0); + *r = s.strength * (s.fold_width * log1p_f64(expf(x) as f64) as f32); + } + if let Some(dev) = developed { + for (o, &d) in dev.iter_mut().zip(dens.iter()) { + *o = 1.0 / (1.0 + expf(-clip((d - s.re) / s.edge_width, -30.0, 30.0))); + } + } +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn sabattier_out(dens: &[f32], rev: &[f32], d_max: f32, out: &mut [[f32; 3]]) { + for ((o, &d), &r) in out.iter_mut().zip(dens).zip(rev) { + let v = d + r; + let m = if v.is_nan() || v < d_max { v } else { d_max }; + *o = [clip(exp10f(-m), 0.0, 1.0); 3]; + } +} + +/// `apply_sabattier` once enabled; `k` holds the Mackie-line taps (empty: no line). +pub fn sabattier( + src: &[f32], + (h, w): (usize, usize), + s: &Sabattier, + k: &[f32], + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let (mut dens, mut rev) = (vec![0f32; n], vec![0f32; n]); + let px = src.as_chunks::<3>().0; + let line = !k.is_empty(); + let mut developed = vec![0f32; if line { n } else { 0 }]; + let blocks = dens + .par_chunks_mut(RUN) + .zip(rev.par_chunks_mut(RUN)) + .zip(px.par_chunks(RUN)); + if line { + blocks + .zip(developed.par_chunks_mut(RUN)) + .for_each(|(((d, r), p), dev)| sabattier_block(p, s, d, r, Some(dev))); + } else { + blocks.for_each(|((d, r), p)| sabattier_block(p, s, d, r, None)); + } + if line { + let mut bromide = vec![0f32; n]; + filter::sep_filter2d( + &developed, + &mut bromide, + (h, w, 1), + (k, k), + Border::Replicate, + )?; + rev.par_iter_mut() + .zip(bromide.par_iter()) + .for_each(|(r, &b)| *r *= 1.0 - b); + } + dst.as_chunks_mut::<3>() + .0 + .par_chunks_mut(RUN) + .zip(dens.par_chunks(RUN).zip(rev.par_chunks(RUN))) + .for_each(|(o, (d, r))| sabattier_out(d, r, s.d_max, o)); + Some(()) +} + +/// The scalars of `apply_cyanotype`, float32 as NumPy rounds them. +pub struct Cyanotype { + pub d_max: f32, + /// `np.float32(0.301 * exposure)` and `np.float32(max(scale, 0.1))`. + pub over: f32, + pub scale: f32, + pub mid_compress: f32, + pub bleach: f32, + pub bleach_floor: f32, + pub tannin: f32, + pub tannin_restore: f32, + pub tannin_dmax_gain: f32, + pub brown: [f32; 2], + pub a_path: Interp, + pub b_path: Interp, + pub lab_eps: f32, + pub lab_kappa: f32, +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn cyanotype_block(px: &[[f32; 3]], c: &Cyanotype, out: &mut [[f32; 3]]) { + let (mut base, mut dens, mut frac) = ([0f32; RUN], [0f32; RUN], [0f32; RUN]); + for (px, out) in px.chunks(RUN).zip(out.chunks_mut(RUN)) { + let n = px.len(); + density_run(px, &mut base[..n]); + for i in 0..n { + let t = clip((base[i] + c.over) / c.scale, 0.0, 1.0); + let m = c.mid_compress; + let v = 2.0 * t - 1.0; + let u0 = (1.0 - m) * t + m * 0.5 * (1.0 + v * v.abs()); + let u_b = u0 * (1.0 - c.bleach * (1.0 - c.bleach_floor * u0)); + let u = u_b + c.tannin * (u0 * c.tannin_restore - u_b); + dens[i] = c.d_max * (1.0 + c.tannin * c.tannin_dmax_gain) * u; + frac[i] = clip(u, 0.0, 1.0); + } + for i in 0..n { + out[i][0] = lightness(exp10f(-dens[i]), c.lab_eps, c.lab_kappa); + } + for i in 0..n { + let (f, a, b) = (frac[i], c.a_path.at(frac[i]), c.b_path.at(frac[i])); + out[i][1] = a + c.tannin * (c.brown[0] * f - a); + out[i][2] = b + c.tannin * (c.brown[1] * f - b); + } + } +} + +/// `apply_cyanotype` once enabled. +pub fn cyanotype(src: &[f32], c: &Cyanotype, to_rgb: LabSpace, dst: &mut [f32]) { + print_lab(src, |px, out| cyanotype_block(px, c, out), to_rgb, dst); +} + +/// The scalars of `apply_lith`, float32 as NumPy rounds them. +pub struct Lith { + pub d_max: f32, + /// `np.float32(0.301 * exposure)`. + pub over: f32, + pub foot_max: f32, + pub foot_veil: f32, + pub foot_rate: f32, + pub knee: f32, + pub width: f32, + pub a_path: Interp, + pub b_path: Interp, + pub lab_eps: f32, + pub lab_kappa: f32, +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lith_block(px: &[[f32; 3]], c: &Lith, out: &mut [[f32; 3]]) { + let (mut base, mut dens) = ([0f32; RUN], [0f32; RUN]); + for (px, out) in px.chunks(RUN).zip(out.chunks_mut(RUN)) { + let n = px.len(); + density_run(px, &mut base[..n]); + for i in 0..n { + let d0 = base[i] + c.over; + let d_foot = base[i] + c.over * c.foot_veil; + let d_h = c.foot_max * (1.0 - expf(-c.foot_rate * d_foot / c.foot_max)); + let g = 1.0 / (1.0 + expf(-clip((d0 - c.knee) / c.width, -30.0, 30.0))); + dens[i] = d_h + (c.d_max - d_h) * g; + } + for i in 0..n { + out[i][0] = lightness(exp10f(-dens[i]), c.lab_eps, c.lab_kappa); + } + for i in 0..n { + let frac = clip(dens[i] / c.d_max, 0.0, 1.0); + out[i][1] = c.a_path.at(frac); + out[i][2] = c.b_path.at(frac); + } + } +} + +/// `apply_lith` once enabled. +pub fn lith(src: &[f32], c: &Lith, to_rgb: LabSpace, dst: &mut [f32]) { + print_lab(src, |px, out| lith_block(px, c, out), to_rgb, dst); +} diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs index da66d16f0..be3493dc5 100644 --- a/negpy_core/src/filter.rs +++ b/negpy_core/src/filter.rs @@ -533,33 +533,36 @@ pub fn sep_filter2d( row_filter(fast, padded, out, cn, kx, sx); }; - // Strips of output rows, each with the row-filtered rows it reads kept in cache. - const STRIP: usize = 32; - dst.par_chunks_mut(STRIP * n).enumerate().for_each_init( - || { - ( - vec![0f32; (w + 2 * rx) * cn], - vec![0f32; (STRIP + 2 * ry) * n], - ) - }, - |(padded, buf), (strip, out)| { - let y0 = strip * STRIP; - let count = out.len() / n; - for t in 0..count + 2 * ry { - let line = &mut buf[t * n..(t + 1) * n]; - match border_interpolate((y0 + t) as isize - ry as isize, h, border) { - Some(sy) => row_pass(padded, sy, line), - None => line.fill(0.0), + // Strips of output rows, each with the row-filtered rows it reads kept in cache. A strip + // filters its 2 * ry halo rows again, so it grows with the kernel. + let strip_rows = (8 * ry).max(32); + dst.par_chunks_mut(strip_rows * n) + .enumerate() + .for_each_init( + || { + ( + vec![0f32; (w + 2 * rx) * cn], + vec![0f32; (strip_rows + 2 * ry) * n], + ) + }, + |(padded, buf), (strip, out)| { + let y0 = strip * strip_rows; + let count = out.len() / n; + for t in 0..count + 2 * ry { + let line = &mut buf[t * n..(t + 1) * n]; + match border_interpolate((y0 + t) as isize - ry as isize, h, border) { + Some(sy) => row_pass(padded, sy, line), + None => line.fill(0.0), + } } - } - for (yy, line) in out.chunks_exact_mut(n).enumerate() { - let rows: Vec<&[f32]> = (0..ky.len()) - .map(|j| &buf[(yy + j) * n..(yy + j + 1) * n]) - .collect(); - column_filter(fast, &rows, line, ky, sy); - } - }, - ); + for (yy, line) in out.chunks_exact_mut(n).enumerate() { + let rows: Vec<&[f32]> = (0..ky.len()) + .map(|j| &buf[(yy + j) * n..(yy + j + 1) * n]) + .collect(); + column_filter(fast, &rows, line, ky, sy); + } + }, + ); Some(()) } diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index bb04e79e8..b9c764594 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod altprocess; pub mod analysis; pub mod bounds; pub mod color; @@ -63,5 +64,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::display::register(m)?; py::analysis::register(m)?; py::warp::register(m)?; - py::lab::register(m) + py::lab::register(m)?; + py::altprocess::register(m) } diff --git a/negpy_core/src/py/altprocess.rs b/negpy_core/src/py/altprocess.rs new file mode 100644 index 000000000..45e4850e1 --- /dev/null +++ b/negpy_core/src/py/altprocess.rs @@ -0,0 +1,166 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use super::color::space; +use crate::altprocess::{self, Cyanotype, Interp, Lith, Sabattier}; +use crate::color::LabSpace; + +/// The HxWx3 image, and a new output of its shape filled by `f`. +fn run<'py>( + py: Python<'py>, + img: &PyReadonlyArrayDyn<'py, f32>, + f: impl FnOnce(&[f32], (usize, usize), &mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || f(src, (h, w), dst)) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + +/// The working Lab space for Lab to RGB: the matrix, the white, eps and kappa. +type Working<'py> = ( + PyReadonlyArray2<'py, f32>, + PyReadonlyArray2<'py, f32>, + PyReadonlyArray1<'py, f32>, + f32, + f32, +); + +fn to_rgb(lab: &Working<'_>) -> PyResult { + space(&lab.1, &lab.2, lab.3, lab.4) +} + +fn path(xp: [f32; 4], anchors: [(f32, f32); 4]) -> (Interp, Interp) { + ( + Interp::new(xp, anchors.map(|a| a.0)), + Interp::new(xp, anchors.map(|a| a.1)), + ) +} + +/// `apply_sabattier` once enabled: (re, fold_width, edge_width, strength, d_max) as float32, +/// and the Mackie-line taps (empty: no line). +#[pyfunction] +fn sabattier<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + scalars: (f32, f32, f32, f32, f32), + k: PyReadonlyArray1<'py, f32>, +) -> PyResult>> { + let (re, fold_width, edge_width, strength, d_max) = scalars; + let s = Sabattier { + re, + fold_width, + edge_width, + strength, + d_max, + }; + let k = k + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous taps"))?; + run(py, &img, |src, dims, dst| { + altprocess::sabattier(src, dims, &s, k, dst) + }) +} + +/// `apply_cyanotype` once enabled. `scalars` is (d_max, over, scale, mid_compress, bleach, +/// bleach_floor, tannin, tannin_restore, tannin_dmax_gain) as float32; then the brown +/// direction, the hue path and the working Lab constants. +#[pyfunction] +fn cyanotype<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + scalars: [f32; 9], + brown: (f32, f32), + path_u: [f32; 4], + anchors: [(f32, f32); 4], + lab: Working<'py>, +) -> PyResult>> { + let [ + d_max, + over, + scale, + mid_compress, + bleach, + bleach_floor, + tannin, + tannin_restore, + tannin_dmax_gain, + ] = scalars; + let (a_path, b_path) = path(path_u, anchors); + let c = Cyanotype { + d_max, + over, + scale, + mid_compress, + bleach, + bleach_floor, + tannin, + tannin_restore, + tannin_dmax_gain, + brown: [brown.0, brown.1], + a_path, + b_path, + lab_eps: lab.3, + lab_kappa: lab.4, + }; + let s = to_rgb(&lab)?; + run(py, &img, |src, _, dst| { + altprocess::cyanotype(src, &c, s, dst); + Some(()) + }) +} + +/// `apply_lith` once enabled. `scalars` is (d_max, over, foot_max, foot_veil, foot_rate, knee, +/// width) as float32; then the hue path and the working Lab constants. +#[pyfunction] +fn lith<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + scalars: [f32; 7], + path_u: [f32; 4], + anchors: [(f32, f32); 4], + lab: Working<'py>, +) -> PyResult>> { + let [d_max, over, foot_max, foot_veil, foot_rate, knee, width] = scalars; + let (a_path, b_path) = path(path_u, anchors); + let c = Lith { + d_max, + over, + foot_max, + foot_veil, + foot_rate, + knee, + width, + a_path, + b_path, + lab_eps: lab.3, + lab_kappa: lab.4, + }; + let s = to_rgb(&lab)?; + run(py, &img, |src, _, dst| { + altprocess::lith(src, &c, s, dst); + Some(()) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(sabattier, m)?)?; + m.add_function(wrap_pyfunction!(cyanotype, m)?)?; + m.add_function(wrap_pyfunction!(lith, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 20765397a..556e15d2f 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,3 +1,4 @@ +pub mod altprocess; pub mod analysis; pub mod bounds; pub mod color; diff --git a/tests/test_core_altprocess.py b/tests/test_core_altprocess.py new file mode 100644 index 000000000..d4663ca4b --- /dev/null +++ b/tests/test_core_altprocess.py @@ -0,0 +1,117 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.cyanotype.logic import CYANOTYPE_CONSTANTS, apply_cyanotype, sensitizer_constants +from negpy.features.lab.logic import gaussian_kernel_1d +from negpy.features.lith.logic import LITH_CONSTANTS, apply_lith +from negpy.features.sabattier.logic import SABATTIER_CONSTANTS, apply_sabattier +from negpy.kernel.image.logic import _LAB_EPS, _LAB_KAPPA, lab_to_rgb_working + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(23) +_F = np.float32 + + +def _cr(fn): + """A float32 transcendental, correctly rounded: NumPy's own run SVML, whose bits depend on the CPU.""" + return lambda x: fn(np.asarray(x, dtype=np.float64)).astype(np.float32) + + +_log10, _exp, _log1p, _cbrt = _cr(np.log10), _cr(np.exp), _cr(np.log1p), _cr(np.cbrt) + + +def _pow10(x): + return np.power(10.0, np.asarray(x, dtype=np.float64)).astype(np.float32) + + +def _density(img): + arr = np.clip(img, 1e-6, 1.0) + luma = arr[:, :, 0] * _F(0.2126) + arr[:, :, 1] * _F(0.7152) + arr[:, :, 2] * _F(0.0722) + return -_log10(np.clip(luma, 1e-6, 1.0)) + + +def _interp(u, xp, anchors, i): + return np.interp(u, np.asarray(xp, _F), np.asarray([p[i] for p in anchors], _F)).astype(_F) + + +def _to_rgb(dens, a, b): + grey = _pow10(-dens) + f = np.where(grey > _F(_LAB_EPS), _cbrt(grey), _F(_LAB_KAPPA) * grey + _F(16.0 / 116.0)) + return np.clip(lab_to_rgb_working(np.dstack([_F(116.0) * f - _F(16.0), a, b])), 0.0, 1.0) + + +def _sabattier_reference(img, d_max, strength, reexposure, sigma): + c = SABATTIER_CONSTANTS + dens = _density(img) + re, w = _F(reexposure * d_max), _F(c["fold_width"]) + reversal = _F(strength) * (w * _log1p(_exp(np.clip((re - dens) / w, -30.0, 30.0)))) + if sigma > 0.0: + developed = 1.0 / (1.0 + _exp(-np.clip((dens - re) / _F(c["edge_width"]), -30.0, 30.0))) + k = gaussian_kernel_1d(sigma) + reversal = reversal * (_F(1.0) - cv2.sepFilter2D(developed.astype(_F), -1, k, k, borderType=cv2.BORDER_REPLICATE)) + grey = _pow10(-np.minimum(dens + reversal, _F(d_max))) + return np.clip(np.dstack([grey, grey, grey]), 0.0, 1.0) + + +def _cyanotype_reference(img, sensitizer, exposure, scale, bleach, tannin): + c, sc = CYANOTYPE_CONSTANTS, sensitizer_constants(sensitizer) + t = np.clip((_density(img) + _F(0.301 * exposure)) / _F(max(scale, 0.1)), 0.0, 1.0) + m = _F(c["mid_compress"]) + v = _F(2.0) * t - _F(1.0) + u0 = (_F(1.0) - m) * t + m * _F(0.5) * (_F(1.0) + v * np.abs(v)) + u_b = u0 * (_F(1.0) - _F(bleach) * (_F(1.0) - _F(c["bleach_floor"]) * u0)) + u = u_b + _F(tannin) * (u0 * _F(c["tannin_restore"]) - u_b) + dens = _F(sc["d_max"]) * (_F(1.0) + _F(tannin) * _F(c["tannin_dmax_gain"])) * u + frac = np.clip(u, 0.0, 1.0) + a, b = (_interp(frac, c["path_u"], sc["path"], i) for i in (0, 1)) + a = a + _F(tannin) * (_F(c["brown_dir"][0]) * frac - a) + b = b + _F(tannin) * (_F(c["brown_dir"][1]) * frac - b) + return _to_rgb(dens, a, b) + + +def _lith_reference(img, path, d_max, exposure, snatch, abruptness): + c = LITH_CONSTANTS + base = _density(img) + over = _F(0.301 * exposure) + foot_max = _F(c["foot_max"]) + d_h = foot_max * (_F(1.0) - _exp(-_F(c["foot_rate"]) * (base + over * _F(c["foot_veil"])) / foot_max)) + knee = _F(d_max * (c["knee_lo"] - c["knee_span"] * snatch)) + width = _F(max(c["abrupt_lo"] - c["abrupt_span"] * abruptness, 0.01)) + g = 1.0 / (1.0 + _exp(-np.clip((base + over - knee) / width, -30.0, 30.0))) + dens = d_h + (_F(d_max) - d_h) * g + frac = np.clip(dens / _F(d_max), 0.0, 1.0) + return _to_rgb(dens, *(_interp(frac, c["path_u"], path, i) for i in (0, 1))) + + +_IMAGES = [_RNG.random((97, 131, 3), dtype=np.float32), _RNG.random((40, 50, 3), dtype=np.float32) ** 3] + + +@pytest.mark.parametrize("img", _IMAGES) +@pytest.mark.parametrize("d_max, strength, reexposure, sigma", [(2.1, 1.3, 0.45, 0.0), (1.9, 0.6, 0.3, 2.5)]) +def test_sabattier_matches_numpy(img, d_max, strength, reexposure, sigma): + want = _sabattier_reference(img, d_max, strength, reexposure, sigma) + assert apply_sabattier(img, d_max, True, strength, reexposure, sigma).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("img", _IMAGES) +@pytest.mark.parametrize("args", [("classic", 0.0, 1.4, 0.0, 0.0), ("new", 1.0, 2.3, 0.6, 0.4)]) +def test_cyanotype_matches_numpy(img, args): + assert apply_cyanotype(img, args[0], True, *args[1:]).tobytes() == _cyanotype_reference(img, *args).tobytes() + + +@pytest.mark.parametrize("img", _IMAGES) +@pytest.mark.parametrize("args", [(2.0, 0.0, 0.55, 0.6), (1.8, 1.5, 0.9, 0.1)]) +def test_lith_matches_numpy(img, args): + path = ((4.0, 12.0), (6.0, 18.0), (2.0, 8.0), (0.0, 0.5)) + want = _lith_reference(img, path, *args) + d_max, exposure, snatch, abruptness = args + assert apply_lith(img, path, d_max, True, exposure, snatch, abruptness).tobytes() == want.tobytes() + + +def test_sabattier_rejects_a_bad_image(): + with pytest.raises(ValueError): + negpy_core.sabattier(np.zeros((4, 4), np.float32), (_F(1.0),) * 5, np.zeros(0, np.float32)) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 331052fb8..705595b5d 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -76,6 +76,18 @@ def _filled(fill): "usm_sharpen": lambda: negpy_core.usm_sharpen( _IMG3, np.float32(2.0), 0.4, np.array([0.0625, 0.25, 0.375, 0.25, 0.0625], np.float32), *WORKING_LAB ), + "sabattier": lambda: negpy_core.sabattier( + _IMG3, + (np.float32(0.9), np.float32(0.08), np.float32(0.1), np.float32(1.3), np.float32(2.0)), + np.array([0.25, 0.5, 0.25], np.float32), + ), + "lith": lambda: negpy_core.lith( + _IMG3, + [np.float32(v) for v in (2.0, 0.3, 0.7, 0.6, 0.6, 1.7, 0.14)], + [np.float32(v) for v in (0.1, 0.35, 0.65, 1.0)], + [(np.float32(a), np.float32(b)) for a, b in ((4.0, 12.0), (6.0, 18.0), (2.0, 8.0), (0.0, 0.5))], + WORKING_LAB, + ), "warp_affine": lambda: negpy_core.warp_affine(_IMG3, np.array([[0.998, 0.061, -3.1], [-0.061, 0.998, 4.2]]), (131, 97)), "warp_perspective": lambda: negpy_core.warp_perspective( _IMG1, np.array([[1.02, 0.03, -1.5], [-0.01, 0.97, 2.25], [1e-4, -2e-4, 1.0]]), (61, 83) @@ -119,6 +131,8 @@ def _filled(fill): "lut_f32": "467bb9fd495d4797d912f6aeabd34f9a61b56491105464a0c852ed524488b1c1", "matrix_trc_u8": "acec1eadffb13dfef8473a02fb250b4ad8f57ac211a6265661b77d3cad49ab16", "usm_sharpen": "7267d2a3976c9381eadde01fe11ea60d46345b4747283e6ac68a2aa4a025d0a8", + "sabattier": "f8956f67786c7c1d0e628af4b82fb12b6dde42e70da2504585f5bf20e7fbb461", + "lith": "98dc05c62e191da2ad00c1f2327c48beff5af7e882be64cac5302cb0ea5099e5", "warp_affine": "83562b3183484dcbf80fc65c4fb2f241d3b13ec9793cf43fd22bf11ea44d9e1d", "warp_perspective": "61e89302e793e24f1ad131b661e95186d7d9a82e8331a96164e338bec3ec1420", "warp_affine_cubic_constant": "a3d3d1a3d6c5e0e77686cfd37850199aa880943936e83edf8ada48307501cd69", From bcd763326b0ad9ce7876727a6b195e64141b0df4 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 12:21:18 +0200 Subject: [PATCH 078/133] perf(core): split toning in Rust negpy_core.split_toning runs apply_split_toning in one call. The hue terms are float64 scalars, so NumPy adds tint * mask to a* and b* in float64 and rounds once; the port does the same, with the cos and sin taken in Python. Bit-exact; 3 oracle cases; one thread 0.88x. --- negpy/features/toning/logic.py | 28 +++++++------------------ negpy_core/manifest.toml | 4 ++-- negpy_core/src/lab.rs | 31 +++++++++++++++++++++++++++ negpy_core/src/py/lab.rs | 38 ++++++++++++++++++++++++++++++++++ tests/test_core_lab.py | 24 +++++++++++++++++++++ 5 files changed, 103 insertions(+), 22 deletions(-) diff --git a/negpy/features/toning/logic.py b/negpy/features/toning/logic.py index 3afb23ff2..e057410d4 100644 --- a/negpy/features/toning/logic.py +++ b/negpy/features/toning/logic.py @@ -1,10 +1,10 @@ -from typing import Any, Dict +from typing import Any, Dict, Optional, Tuple import numpy as np import negpy_core from negpy.domain.types import ImageBuffer -from negpy.kernel.image.logic import lab_to_rgb_working, rgb_to_lab_working +from negpy.kernel.image.logic import WORKING_LAB from negpy.kernel.image.validation import ensure_image TONING_CONSTANTS: Dict[str, Any] = { @@ -176,25 +176,13 @@ def apply_split_toning( if shadow_strength == 0.0 and highlight_strength == 0.0: return img - lab = rgb_to_lab_working(img.astype(np.float32)) - L = lab[:, :, 0] # 0–100 CIELAB (Adobe RGB working space) + def tint(hue: float, strength: float) -> Optional[Tuple[float, float]]: + rad = np.radians(hue) + return (float(np.cos(rad) * 20.0 * strength), float(np.sin(rad) * 20.0 * strength)) if strength > 0.0 else None - # Smoothstep masks, as toning.wgsl. - if shadow_strength > 0.0: - t = np.clip(1.0 - L / 50.0, 0.0, 1.0) - s_mask = t * t * (3.0 - 2.0 * t) - rad = np.radians(shadow_hue) - lab[:, :, 1] += np.cos(rad) * 20.0 * shadow_strength * s_mask - lab[:, :, 2] += np.sin(rad) * 20.0 * shadow_strength * s_mask - - if highlight_strength > 0.0: - t = np.clip((L - 50.0) / 50.0, 0.0, 1.0) - h_mask = t * t * (3.0 - 2.0 * t) - rad = np.radians(highlight_hue) - lab[:, :, 1] += np.cos(rad) * 20.0 * highlight_strength * h_mask - lab[:, :, 2] += np.sin(rad) * 20.0 * highlight_strength * h_mask - - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + # Smoothstep masks of L* below and above 50, as toning.wgsl. + shadow, highlight = tint(shadow_hue, shadow_strength), tint(highlight_hue, highlight_strength) + return negpy_core.split_toning(np.ascontiguousarray(img, dtype=np.float32), shadow, highlight, *WORKING_LAB) def apply_chemical_toning( diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 81ada3342..1eb2c63e8 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2161,8 +2161,8 @@ depends = ["_apply_chemical_toning_jit"] ["features/toning/logic.py::apply_split_toning"] phase = 6 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.split_toning"] depends = [] ["features/toning/processor.py::ToningProcessor.process"] diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs index 206d5545c..f10690ceb 100644 --- a/negpy_core/src/lab.rs +++ b/negpy_core/src/lab.rs @@ -427,3 +427,34 @@ pub fn rl_sharpen( }); Some(()) } + +/// `apply_split_toning`: each tint is (cos, sin) of its hue times 20 times its strength, as +/// float64; NumPy adds tint * mask to a* and b* in float64 and rounds once. The masks are the +/// float32 smoothsteps of L* below and above 50. +pub fn split_toning( + src: &[f32], + (shadow, highlight): (Option<[f64; 2]>, Option<[f64; 2]>), + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) { + let mut lab = vec![0f32; src.len()]; + color::rgb_to_lab(src, &mut lab, to_lab); + let step = |t: f32| { + let t = clip(t, 0.0, 1.0); + t * t * (3.0 - 2.0 * t) + }; + lab.par_chunks_mut(3).for_each(|p| { + let l = p[0]; + for (tint, mask) in [ + (shadow, step(1.0 - l / 50.0)), + (highlight, step((l - 50.0) / 50.0)), + ] { + if let Some(c) = tint { + p[1] = (p[1] as f64 + c[0] * mask as f64) as f32; + p[2] = (p[2] as f64 + c[1] * mask as f64) as f32; + } + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); +} diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index 106c587ff..077b1b298 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -203,7 +203,45 @@ fn rl_sharpen<'py>( Ok(out) } +/// `apply_split_toning` of an HxWx3 float32 image: each tint is (cos, sin) of its hue times 20 +/// times its strength, or None when off; then the working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn split_toning<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + shadow: Option<(f64, f64)>, + highlight: Option<(f64, f64)>, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let tints = (shadow.map(|t| [t.0, t.1]), highlight.map(|t| [t.0, t.1])); + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || lab::split_toning(src, tints, spaces, dst)); + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(split_toning, m)?)?; m.add_function(wrap_pyfunction!(rl_sharpen, m)?)?; m.add_function(wrap_pyfunction!(clahe, m)?)?; m.add_function(wrap_pyfunction!(glow_and_halation, m)?)?; diff --git a/tests/test_core_lab.py b/tests/test_core_lab.py index a65a1f9b0..ee98da2b0 100644 --- a/tests/test_core_lab.py +++ b/tests/test_core_lab.py @@ -6,6 +6,7 @@ from negpy.features.lab.logic import _smoothstep, gaussian_kernel_1d, sharpen_shadow_gain from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R, RL_EPS, apply_clahe, apply_glow_and_halation, apply_rl_sharpening, rl_iterations +from negpy.features.toning.logic import apply_split_toning from negpy.kernel.image.logic import WORKING_LAB, lab_to_rgb_working, rgb_to_lab_working, working_oetf_encode pytestmark = cv2_oracle @@ -178,3 +179,26 @@ def _rl_reference(img, amount, radius, masking): def test_rl_sharpen_matches_numpy_and_cv2(shape, amount, radius, masking): img = cv2.GaussianBlur(_RNG.random((*shape, 3), dtype=np.float32), (0, 0), 1.0) - np.float32(0.05) assert apply_rl_sharpening(img, amount, radius, masking).tobytes() == _rl_reference(img, amount, radius, masking).tobytes() + + +def _split_reference(img, shadow_hue, shadow_strength, highlight_hue, highlight_strength): + """The NumPy split toning that negpy_core.split_toning replaces.""" + lab = rgb_to_lab_working(img) + lum = lab[:, :, 0] + for hue, strength, t in ( + (shadow_hue, shadow_strength, 1.0 - lum / 50.0), + (highlight_hue, highlight_strength, (lum - 50.0) / 50.0), + ): + if strength > 0.0: + t = np.clip(t, 0.0, 1.0) + mask = t * t * (3.0 - 2.0 * t) + rad = np.radians(hue) + lab[:, :, 1] += np.cos(rad) * 20.0 * strength * mask + lab[:, :, 2] += np.sin(rad) * 20.0 * strength * mask + return np.clip(lab_to_rgb_working(lab), 0.0, 1.0) + + +@pytest.mark.parametrize("args", [(30.0, 0.4, 210.0, 0.0), (0.0, 0.0, 45.0, 0.7), (300.0, 1.0, 120.0, 0.5)]) +def test_split_toning_matches_numpy(args): + img = _RNG.random((61, 83, 3), dtype=np.float32) + assert apply_split_toning(img, *args).tobytes() == _split_reference(img, *args).tobytes() From 1fafe56752175fd3dcb3efae5146f0bc8b36456d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 12:28:09 +0200 Subject: [PATCH 079/133] perf(core): filed negative carrier in Rust negpy_core.apply_carrier composites the rebate along the print edges in runs of 256 pixels per row span (left band, middle, right band): the fBm hash noise without saturating casts, the gate and filed bounds with their corner arcs, the flare, the tone lookup (np.interp in float64) and the clip. carrier_profiles, a seeded NumPy table, stays in Python and is passed in. The carrier constants and their reasons move to finish.rs; the shader comments point there. With a correctly rounded cube root the port matches the NumPy carrier bit for bit; against NumPy's SVML cbrt it moves by up to 8.3e-7 (ledger, abs=1e-6). 9 oracle cases. One thread: 0.75-0.86x. --- .github/workflows/ci.yml | 2 +- negpy/features/finish/logic.py | 203 +------------ negpy/features/finish/shaders/finish.wgsl | 4 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 10 +- negpy_core/src/finish.rs | 346 ++++++++++++++++++++++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/finish.rs | 57 ++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_finish.py | 237 +++++++++++++++ tests/test_core_same_bits.py | 11 + tests/test_finish_logic.py | 11 - 12 files changed, 673 insertions(+), 214 deletions(-) create mode 100644 negpy_core/src/finish.rs create mode 100644 negpy_core/src/py/finish.rs create mode 100644 tests/test_core_finish.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91cd4bfd6..323cffee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: run: uv sync --group dev - name: Ported-function oracles - run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py tests/test_core_altprocess.py + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py tests/test_core_altprocess.py tests/test_core_finish.py diff --git a/negpy/features/finish/logic.py b/negpy/features/finish/logic.py index dec7113a4..9ba36ce16 100644 --- a/negpy/features/finish/logic.py +++ b/negpy/features/finish/logic.py @@ -1,40 +1,17 @@ +import negpy_core import numpy as np + from negpy.domain.types import ImageBuffer from negpy.kernel.image.validation import ensure_image # Fixed seed: one filed carrier per darkroom, identical every frame and on both paths. _CARRIER_SEED = 1898 -# All CARRIER_* below are fractions of the rebate width and mirrored as WGSL literals in -# finish.wgsl. The picture-side boundary is the camera's film gate, machine-cut, so it -# gets JITTER * INNER_ROUGH and no slider. The paper-side one is the hand-filed aperture. +# Profile samples per edge; the other carrier constants live in negpy_core/src/finish.rs, +# mirrored as WGSL literals in finish.wgsl. CARRIER_SAMPLES = 2048 -CARRIER_JITTER = 0.24 -CARRIER_INNER_ROUGH = 0.2 -CARRIER_OUTER_JITTER = 0.4 -CARRIER_CORNER = 1.4 -CARRIER_GATE_CORNER = 0.2 -# The film never sits centered in the carrier. -CARRIER_OFFSET_X = -0.2 -CARRIER_OFFSET_Y = -0.25 -CARRIER_MARGIN = 0.7 -# The gate prints soft; filed metal is a hard stop, which is what reads as filed. -CARRIER_SOFT = 0.22 -CARRIER_FILED_SOFT = 0.06 -CARRIER_FLARE_DEPTH = 0.25 -CARRIER_FLARE_GAIN = 0.6 -CARRIER_FLARE_BASE = 0.35 -# A 1-D profile is a height field, so it cannot overhang or shed a fleck. The 2-D field -# displacing the distance field is what makes the edge read as torn metal. Hash noise -# rather than a library, because WGSL has to reproduce it bit for bit. -CARRIER_NOISE_SEED = 0x51ED270B -CARRIER_NOISE_CELL = 0.35 -CARRIER_NOISE_OCTAVES = 4 -CARRIER_NOISE_OUTER = 0.12 -CARRIER_NOISE_INNER = 0.02 # Tone table sampled at t = u**POWER, dense in the toe. CARRIER_TONE_SAMPLES = 64 CARRIER_TONE_POWER = 3.0 -_CARRIER_BLOCK_PIXELS = 1 << 19 _carrier_cache: np.ndarray | None = None @@ -87,43 +64,6 @@ def carrier_profiles() -> np.ndarray: return _carrier_cache -def _hash_lattice(ix: np.ndarray, iy: np.ndarray) -> np.ndarray: - """Lattice value in [-1, 1). u32 wrap-around only, so WGSL reproduces it exactly.""" - h = (ix * np.uint32(0x27D4EB2D)) ^ (iy * np.uint32(0x165667B1)) ^ np.uint32(CARRIER_NOISE_SEED) - h ^= h >> np.uint32(15) - h = h * np.uint32(0x2C1B3C6D) - h ^= h >> np.uint32(13) - h = h * np.uint32(0x297A2D39) - h ^= h >> np.uint32(16) - return (h >> np.uint32(8)).astype(np.float32) * np.float32(2.0 / 16777216.0) - np.float32(1.0) - - -def _value_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: - fx, fy = np.floor(x), np.floor(y) - ux, uy = (x - fx), (y - fy) - ux = ux * ux * (3.0 - 2.0 * ux) - uy = uy * uy * (3.0 - 2.0 * uy) - i0, j0 = fx.astype(np.int64).astype(np.uint32), fy.astype(np.int64).astype(np.uint32) - i1, j1 = i0 + np.uint32(1), j0 + np.uint32(1) - lo = _hash_lattice(i0, j0) - lo = lo + (_hash_lattice(i1, j0) - lo) * ux - hi = _hash_lattice(i0, j1) - hi = hi + (_hash_lattice(i1, j1) - hi) * ux - return lo + (hi - lo) * uy - - -def carrier_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: - """fBm value noise in [-1, 1]; x/y in noise cells. Mirrored in finish.wgsl.""" - total = np.zeros(np.broadcast_shapes(x.shape, y.shape), dtype=np.float32) - amp, freq, norm = np.float32(1.0), np.float32(1.0), np.float32(0.0) - for _ in range(CARRIER_NOISE_OCTAVES): - total += amp * _value_noise(x * freq, y * freq) - norm += amp - amp *= np.float32(0.5) - freq *= np.float32(2.0) - return total / norm - - def carrier_tone_exposures() -> np.ndarray: """Exposure fraction of each tone-table entry, 0 (bare paper) to 1 (full rebate).""" return np.linspace(0.0, 1.0, CARRIER_TONE_SAMPLES, dtype=np.float32) ** np.float32(CARRIER_TONE_POWER) @@ -134,14 +74,6 @@ def linear_carrier_tone() -> np.ndarray: return np.repeat((1.0 - carrier_tone_exposures())[:, None], 3, axis=1).astype(np.float32) -def carrier_tone_lookup(tone: np.ndarray, t: np.ndarray) -> np.ndarray: - """(..., 3) paper-relative color at exposure fraction t.""" - # cbrt inverts CARRIER_TONE_POWER = 3. - u = np.cbrt(np.clip(t, 0.0, 1.0)) * np.float32(CARRIER_TONE_SAMPLES - 1) - grid = np.arange(CARRIER_TONE_SAMPLES, dtype=np.float32) - return np.stack([np.interp(u, grid, tone[:, c]).astype(np.float32) for c in range(3)], axis=-1) - - def apply_carrier( img: ImageBuffer, width_px: float, @@ -158,130 +90,11 @@ def apply_carrier( """ if width_px <= 0.0: return img - - h, w = img.shape[:2] - tone = linear_carrier_tone() if tone is None else np.asarray(tone, dtype=np.float32) - band = ( - int( - np.ceil( - width_px - * ( - CARRIER_MARGIN - + CARRIER_CORNER * corner - + 1.0 - + CARRIER_JITTER - + CARRIER_NOISE_OUTER - + CARRIER_NOISE_INNER - + max(abs(CARRIER_OFFSET_X), abs(CARRIER_OFFSET_Y)) - ) - + max(1.0, width_px * CARRIER_SOFT) - ) - ) - + 1 + tone = linear_carrier_tone() if tone is None else np.ascontiguousarray(tone, dtype=np.float32) + paper32 = tuple(np.float32(v) for v in paper) + return negpy_core.apply_carrier( + np.ascontiguousarray(img, dtype=np.float32), width_px, rough, flare, corner, paper32, tone, carrier_profiles() ) - out = img.copy() - if 2 * band >= h or 2 * band >= w: - regions = [(0, h, 0, w, (0, 1, 2, 3))] - else: - # An edge's effect ends within `band`. - hb, wb = h - band, w - band - regions = [ - (0, band, 0, band, (0, 2)), - (0, band, wb, w, (0, 3)), - (hb, h, 0, band, (1, 2)), - (hb, h, wb, w, (1, 3)), - (0, band, band, wb, (0,)), - (hb, h, band, wb, (1,)), - (band, hb, 0, band, (2,)), - (band, hb, wb, w, (3,)), - ] - for y0, y1, x0, x1, sides in regions: - rows = max(1, _CARRIER_BLOCK_PIXELS // max(1, x1 - x0)) - for b0 in range(y0, y1, rows): - b1 = min(b0 + rows, y1) - block = img[b0:b1, x0:x1] - out[b0:b1, x0:x1] = _carrier_block(block, b0, x0, h, w, width_px, rough, flare, corner, paper, tone, sides) - return ensure_image(np.clip(out, 0.0, 1.0)) - - -def _carrier_block( - img: np.ndarray, - y0: int, - x0: int, - h: int, - w: int, - width_px: float, - rough: float, - flare: float, - corner: float, - paper: tuple[float, float, float], - tone: np.ndarray, - sides: tuple[int, ...], -) -> np.ndarray: - profiles = carrier_profiles() - soft = max(1.0, width_px * CARRIER_SOFT) - soft_filed = max(1.0, width_px * CARRIER_FILED_SOFT) - margin = width_px * CARRIER_MARGIN - radius = width_px * CARRIER_CORNER * corner - gate_radius = width_px * CARRIER_GATE_CORNER - cell = max(1.0, width_px * CARRIER_NOISE_CELL) - py = np.arange(y0, y0 + img.shape[0], dtype=np.float32)[:, None] - px = np.arange(x0, x0 + img.shape[1], dtype=np.float32)[None, :] - sx = (px + 0.5) / np.float32(w) - sy = (py + 0.5) / np.float32(h) - end_x = np.minimum(px, (w - 1.0) - px) - end_y = np.minimum(py, (h - 1.0) - py) - qx = px - np.float32(width_px * CARRIER_OFFSET_X) - qy = py - np.float32(width_px * CARRIER_OFFSET_Y) - fend_x = np.minimum(qx, (w - 1.0) - qx) - fend_y = np.minimum(qy, (h - 1.0) - qy) - n2 = carrier_noise(px / cell, py / cell) - - def prof(row: int, s: np.ndarray) -> np.ndarray: - return profiles[row, np.minimum((s * CARRIER_SAMPLES).astype(np.int32), CARRIER_SAMPLES - 1)] - - def arc(r: float, at: float, end: np.ndarray) -> np.ndarray | np.float32: - """Corner retreat of a boundary `at` px from the print edge, from its own corner.""" - if r <= 0.0: - return np.float32(0.0) - x = np.clip(r - (end - at), 0.0, r) - return r - np.sqrt(np.maximum(r * r - x * x, 0.0)) - - def bounds(edge: int, s: np.ndarray, end: np.ndarray, fend: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - outer = margin + width_px * rough * (CARRIER_OUTER_JITTER * prof(edge + 4, s) + CARRIER_NOISE_OUTER * n2) - wobble = CARRIER_JITTER * CARRIER_INNER_ROUGH * prof(edge, s) + CARRIER_NOISE_INNER * n2 - inner = margin + width_px * (1.0 + wobble) + arc(gate_radius, margin + width_px, end) - return outer + arc(radius, margin, fend), inner - - # (edge, profile position, gate end, filed end, gate distance, filed distance) - edges = ( - (0, sx, end_x, fend_x, py, qy), - (1, sx, end_x, fend_x, (h - 1.0) - py, (h - 1.0) - qy), - (2, sy, end_y, fend_y, px, qx), - (3, sy, end_y, fend_y, (w - 1.0) - px, (w - 1.0) - qx), - ) - reach = max(1.0, width_px * CARRIER_FLARE_DEPTH) - # Floored |noise|, not a one-sided gate: that left whole edges with no flare. - flare_amp = flare * CARRIER_FLARE_GAIN * (CARRIER_FLARE_BASE + (1.0 - CARRIER_FLARE_BASE) * np.abs(n2)) - a_in = np.ones(img.shape[:2], dtype=np.float32) - a_out = np.ones(img.shape[:2], dtype=np.float32) - peaks, gates = [], [] - for e, s, end, fend, d, fd in (edges[k] for k in sides): - outer, inner = bounds(e, s, end, fend) - a_in = a_in * np.clip((d - inner) / soft + 0.5, 0.0, 1.0) - a_out = a_out * np.clip((fd - outer) / soft_filed + 0.5, 0.0, 1.0) - peaks.append(np.clip(1.0 - np.abs(fd - outer) / reach, 0.0, 1.0) ** 2) - gates.append(np.clip(1.0 + (fd - outer) / reach, 0.0, 1.0)) - lit = np.float32(0.0) - if flare > 0.0: - # The bevel spans only the aperture, so the other edges gate each edge's flare. - for e in range(len(peaks)): - lit = lit + peaks[e] * np.prod([gates[k] for k in range(len(gates)) if k != e] or [np.float32(1.0)], axis=0) - lit = flare_amp * lit - - rebate = np.asarray(paper, dtype=np.float32) * carrier_tone_lookup(tone, a_out + lit) - shown = (a_in * a_out)[..., None] - return img * shown + rebate * (1.0 - shown) def apply_vignette(img: ImageBuffer, stops: float, size: float, roundness: float = 0.0) -> ImageBuffer: diff --git a/negpy/features/finish/shaders/finish.wgsl b/negpy/features/finish/shaders/finish.wgsl index 068af2f15..1b78cdff1 100644 --- a/negpy/features/finish/shaders/finish.wgsl +++ b/negpy/features/finish/shaders/finish.wgsl @@ -23,7 +23,7 @@ struct FinishUniforms { // (CARRIER_TONE_SAMPLES, 3) from rebate_tone(). @group(0) @binding(4) var carrier_tone: array; -// Every CARRIER_* below mirrors logic.py — keep in sync or preview drifts from export. +// Every CARRIER_* below mirrors negpy_core/src/finish.rs — keep in sync or preview drifts from export. const CARRIER_SAMPLES: i32 = 2048; const CARRIER_JITTER: f32 = 0.24; const CARRIER_INNER_ROUGH: f32 = 0.2; @@ -152,7 +152,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { color = color * exp2(-params.vignette_stops * factor); } - // Filed-carrier rebate, mirroring apply_carrier() in logic.py. + // Filed-carrier rebate, mirroring apply_carrier() in negpy_core/src/finish.rs. if (params.carrier_width_px > 0.0) { let soft = max(1.0, params.carrier_width_px * CARRIER_SOFT); let sx = (px.x + 0.5) / full.x; diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 474481b3f..84acdf222 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -24,3 +24,4 @@ tolerance listed for each entry. | `negpy_core.resize_linear` (bilinear `cv2.resize` of float32: the contrast mask plane, the two-scale unmix layers, dust score and IR gain maps, flat-field gains) | opencv-python on x86-64 runs IPP's float32 bilinear resize, whose bits depend on the CPU it dispatches for; Apple Silicon wheels run OpenCV's own code. negpy_core copies OpenCV's own code. Upscales differ by up to 3 ULP, downscales more. No recorded call reaches these resizes. | ulp=3 (upscale) | | `features/lab/logic.py::apply_rl_sharpening` | L* takes a correctly rounded cube root (`mathf::cbrtf`); NumPy's float32 `np.cbrt` runs SVML here, up to 2 ULP off on a third of inputs. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | | `features/sabattier/logic.py::apply_sabattier`, `features/cyanotype/logic.py::apply_cyanotype`, `features/lith/logic.py::apply_lith` | log10, exp, log1p, 10^x and the cube root are `mathf`'s, correctly rounded (or within an ULP of it); NumPy's float32 versions run SVML or its own SIMD code. With correctly rounded transcendentals each port matches bit for bit. | abs=1e-6 | +| `features/finish/logic.py::apply_carrier` | The rebate tone lookup takes a correctly rounded cube root; NumPy's float32 `np.cbrt` runs SVML here. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 1eb2c63e8..a3fb90fcb 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2020,7 +2020,8 @@ depends = [] ["features/finish/logic.py::_carrier_block"] phase = 6 -status = "todo" +status = "ported" +compare = "abs=1e-6" calls = [] depends = ["carrier_profiles"] @@ -2032,9 +2033,10 @@ depends = [] ["features/finish/logic.py::apply_carrier"] phase = 6 -status = "todo" -calls = [] -depends = ["_carrier_block"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.apply_carrier"] +depends = ["carrier_profiles"] ["features/finish/logic.py::carrier_profiles"] phase = 6 diff --git a/negpy_core/src/finish.rs b/negpy_core/src/finish.rs new file mode 100644 index 000000000..33e27c5c7 --- /dev/null +++ b/negpy_core/src/finish.rs @@ -0,0 +1,346 @@ +//! The filed negative carrier of negpy/features/finish/logic.py (`apply_carrier`), in the +//! float32 order NumPy evaluates it. Python floats join float32 arrays as float32, so each +//! Python-side product (width * rough, r * r, ...) is rounded once before it meets an array. + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::cbrtf; + +// Fractions of the rebate width, mirrored as WGSL literals in finish.wgsl. The picture-side +// boundary is the camera's film gate, machine-cut, so it gets JITTER * INNER_ROUGH and no +// slider. The paper-side one is the hand-filed aperture. +const SAMPLES: usize = 2048; +const JITTER: f64 = 0.24; +const INNER_ROUGH: f64 = 0.2; +const OUTER_JITTER: f64 = 0.4; +const CORNER: f64 = 1.4; +const GATE_CORNER: f64 = 0.2; +// The film never sits centered in the carrier. +const OFFSET_X: f64 = -0.2; +const OFFSET_Y: f64 = -0.25; +const MARGIN: f64 = 0.7; +// The gate prints soft; filed metal is a hard stop, which is what reads as filed. +const SOFT: f64 = 0.22; +const FILED_SOFT: f64 = 0.06; +const FLARE_DEPTH: f64 = 0.25; +const FLARE_GAIN: f64 = 0.6; +const FLARE_BASE: f64 = 0.35; +// A 1-D profile is a height field, so it cannot overhang or shed a fleck. The 2-D field +// displacing the distance field is what makes the edge read as torn metal. Hash noise rather +// than a library, because WGSL has to reproduce it bit for bit. +const NOISE_SEED: u32 = 0x51ED_270B; +const NOISE_CELL: f64 = 0.35; +const NOISE_OCTAVES: usize = 4; +const NOISE_OUTER: f64 = 0.12; +const NOISE_INNER: f64 = 0.02; +const TONE_SAMPLES: usize = 64; + +/// `np.clip(v, lo, hi)`: a NaN comes through. +#[inline(always)] +fn clip(v: f32, lo: f32, hi: f32) -> f32 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} + +/// A whole float32 below 2^22 in magnitude as an integer: added to 1.5 * 2^23, it fills the low +/// mantissa bits. The saturating `as` cast keeps LLVM from vectorizing. +#[inline(always)] +fn whole(v: f32) -> i32 { + (v + 12_582_912.0).to_bits() as i32 - 0x4B40_0000 +} + +/// `_hash_lattice`: a lattice value in [-1, 1) from u32 wrap-around arithmetic. +#[inline(always)] +fn hash_lattice(ix: u32, iy: u32) -> f32 { + let mut h = ix.wrapping_mul(0x27D4_EB2D) ^ iy.wrapping_mul(0x1656_67B1) ^ NOISE_SEED; + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 13; + h = h.wrapping_mul(0x297A_2D39); + h ^= h >> 16; + (h >> 8) as f32 * (2.0f64 / 16_777_216.0) as f32 - 1.0 +} + +/// `_value_noise` at one point. +#[inline(always)] +fn value_noise(x: f32, y: f32) -> f32 { + let (fx, fy) = (x.floor(), y.floor()); + let (ux, uy) = (x - fx, y - fy); + let ux = ux * ux * (3.0 - 2.0 * ux); + let uy = uy * uy * (3.0 - 2.0 * uy); + // astype(int64).astype(uint32) wraps a negative index. + let (i0, j0) = (whole(fx) as u32, whole(fy) as u32); + let (i1, j1) = (i0.wrapping_add(1), j0.wrapping_add(1)); + let lo = hash_lattice(i0, j0); + let lo = lo + (hash_lattice(i1, j0) - lo) * ux; + let hi = hash_lattice(i0, j1); + let hi = hi + (hash_lattice(i1, j1) - hi) * ux; + lo + (hi - lo) * uy +} + +/// `carrier_noise`: fBm value noise in [-1, 1], x and y in noise cells. +#[inline(always)] +fn carrier_noise(x: f32, y: f32) -> f32 { + let (mut total, mut amp, mut freq, mut norm) = (0.0f32, 1.0f32, 1.0f32, 0.0f32); + for _ in 0..NOISE_OCTAVES { + total += amp * value_noise(x * freq, y * freq); + norm += amp; + amp *= 0.5; + freq *= 2.0; + } + total / norm +} + +/// The inputs of `apply_carrier`; `profiles` is `carrier_profiles()` (8 x 2048) and `tone` the +/// (64, 3) rebate tone table. +pub struct Carrier<'a> { + pub width_px: f64, + pub rough: f64, + pub flare: f64, + pub corner: f64, + pub paper: [f32; 3], + pub tone: &'a [[f32; 3]], + pub profiles: &'a [f32], +} + +/// The scalars `_carrier_block` derives from the inputs. +struct Derived { + soft: f32, + soft_filed: f32, + margin: f64, + radius: f64, + gate_radius: f64, + cell: f32, + off_x: f32, + off_y: f32, + reach: f32, +} + +/// `arc(r, at, end)`: the corner retreat of a boundary `at` px from the print edge. +#[inline(always)] +fn arc(r: f64, at: f64, end: f32) -> f32 { + if r <= 0.0 { + return 0.0; + } + let rf = r as f32; + let x = clip(rf - (end - at as f32), 0.0, rf); + let s = (r * r) as f32 - x * x; + rf - (if s.is_nan() || s > 0.0 { s } else { 0.0 }).sqrt() +} + +/// `carrier_tone_lookup` of one exposure fraction: np.interp on the table at +/// cbrt(t) * 63, in float64. +#[inline(always)] +fn tone_at(tone: &[[f32; 3]], t: f32) -> [f32; 3] { + let u = cbrtf(clip(t, 0.0, 1.0)) * (TONE_SAMPLES - 1) as f32; + let last = TONE_SAMPLES - 1; + // u is in 0..63 (or NaN, which np.interp passes through). + let j = (whole(u.floor()).clamp(0, last as i32 - 1)) as usize; + let x = u as f64; + std::array::from_fn(|c| { + let (f0, f1) = (tone[j][c] as f64, tone[j + 1][c] as f64); + let v = ((f1 - f0) * (x - j as f64) + f0) as f32; + if x >= last as f64 { tone[last][c] } else { v } + }) +} + +impl Carrier<'_> { + fn derived(&self) -> Derived { + let w = self.width_px; + Derived { + soft: (w * SOFT).max(1.0) as f32, + soft_filed: (w * FILED_SOFT).max(1.0) as f32, + margin: w * MARGIN, + radius: w * CORNER * self.corner, + gate_radius: w * GATE_CORNER, + cell: (w * NOISE_CELL).max(1.0) as f32, + off_x: (w * OFFSET_X) as f32, + off_y: (w * OFFSET_Y) as f32, + reach: (w * FLARE_DEPTH).max(1.0) as f32, + } + } + + /// The edge band past which no edge has an effect. + fn band(&self) -> usize { + let w = self.width_px; + let extent = MARGIN + + CORNER * self.corner + + 1.0 + + JITTER + + NOISE_OUTER + + NOISE_INNER + + OFFSET_X.abs().max(OFFSET_Y.abs()); + (w * extent + (w * SOFT).max(1.0)).ceil() as usize + 1 + } + + #[inline(always)] + fn prof(&self, row: usize, s: f32) -> f32 { + // astype(int32) truncates; s is positive. + let i = whole((s * SAMPLES as f32).floor()).clamp(0, SAMPLES as i32 - 1); + self.profiles[row * SAMPLES + i as usize] + } +} + +/// Pixels per pass: each step runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +/// `_carrier_block` over the pixels x0.. of row y (`src` and `out`) of an HxW print, for the +/// edges in `sides`, in order, composited and clipped. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +#[allow(clippy::too_many_arguments)] +fn carrier_run( + c: &Carrier, + d: &Derived, + (x0, y): (usize, usize), + (h, w): (usize, usize), + sides: &[usize], + src: &[[f32; 3]], + out: &mut [[f32; 3]], +) { + let n = src.len(); + let py = y as f32; + let (wl, hl) = ((w as f64 - 1.0) as f32, (h as f64 - 1.0) as f32); + let sy = (py + 0.5) / h as f32; + let end_y = py.min(hl - py); + let qy = py - d.off_y; + let fend_y = qy.min(hl - qy); + let wr = (c.width_px * c.rough) as f32; + let mut n2 = [0f32; RUN]; + for i in 0..n { + n2[i] = carrier_noise((x0 + i) as f32 / d.cell, py / d.cell); + } + let (mut a_in, mut a_out) = ([1f32; RUN], [1f32; RUN]); + let (mut peaks, mut gates) = ([[0f32; RUN]; 4], [[0f32; RUN]; 4]); + for (k, &e) in sides.iter().enumerate() { + for i in 0..n { + let px = (x0 + i) as f32; + let sx = (px + 0.5) / w as f32; + let end_x = px.min(wl - px); + let qx = px - d.off_x; + let fend_x = qx.min(wl - qx); + let (s, end, fend, dist, fdist) = match e { + 0 => (sx, end_x, fend_x, py, qy), + 1 => (sx, end_x, fend_x, hl - py, hl - qy), + 2 => (sy, end_y, fend_y, px, qx), + _ => (sy, end_y, fend_y, wl - px, wl - qx), + }; + let jitter = OUTER_JITTER as f32 * c.prof(e + 4, s) + NOISE_OUTER as f32 * n2[i]; + let outer = d.margin as f32 + wr * jitter + arc(d.radius, d.margin, fend); + let wobble = (JITTER * INNER_ROUGH) as f32 * c.prof(e, s) + NOISE_INNER as f32 * n2[i]; + let inner = d.margin as f32 + + c.width_px as f32 * (1.0 + wobble) + + arc(d.gate_radius, d.margin + c.width_px, end); + a_in[i] *= clip((dist - inner) / d.soft + 0.5, 0.0, 1.0); + a_out[i] *= clip((fdist - outer) / d.soft_filed + 0.5, 0.0, 1.0); + let p = clip(1.0 - (fdist - outer).abs() / d.reach, 0.0, 1.0); + peaks[k][i] = p * p; + gates[k][i] = clip(1.0 + (fdist - outer) / d.reach, 0.0, 1.0); + } + } + let mut lit = [0f32; RUN]; + // The bevel spans only the aperture, so the other edges gate each edge's flare. + if c.flare > 0.0 { + let m = sides.len(); + for e in 0..m { + let mut g = [1f32; RUN]; + let mut first = true; + for k in (0..m).filter(|&k| k != e) { + for i in 0..n { + g[i] = if first { + gates[k][i] + } else { + g[i] * gates[k][i] + }; + } + first = false; + } + for i in 0..n { + lit[i] += peaks[e][i] * g[i]; + } + } + // Floored |noise|, not a one-sided gate: that left whole edges with no flare. + let gain = (c.flare * FLARE_GAIN) as f32; + for i in 0..n { + lit[i] *= gain * (FLARE_BASE as f32 + (1.0 - FLARE_BASE) as f32 * n2[i].abs()); + } + } + for i in 0..n { + let t = tone_at(c.tone, a_out[i] + lit[i]); + let shown = a_in[i] * a_out[i]; + let p = src[i]; + out[i] = std::array::from_fn(|k| { + clip(p[k] * shown + c.paper[k] * t[k] * (1.0 - shown), 0.0, 1.0) + }); + } +} + +/// `apply_carrier` for width_px > 0: the rebate composited along the print edges, then the +/// whole print clipped to 0..1. +pub fn apply_carrier(src: &[f32], (h, w): (usize, usize), c: &Carrier, dst: &mut [f32]) { + let d = c.derived(); + let band = c.band(); + let all = 2 * band >= h || 2 * band >= w; + dst.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let row = src[y * w * 3..][..w * 3].as_chunks::<3>().0; + let out = out.as_chunks_mut::<3>().0; + let vert = if y < band { + Some(0) + } else if y >= h - band { + Some(1) + } else { + None + }; + // Spans of one side set: the left band, the middle, the right band. + let spans: [(usize, usize, Option); 3] = if all { + [(0, w, None), (w, w, None), (w, w, None)] + } else { + [ + (0, band, Some(2)), + (band, w - band, None), + (w - band, w, Some(3)), + ] + }; + for (a, b, horiz) in spans { + let mut sides = [0usize; 4]; + let m = if all { + sides = [0, 1, 2, 3]; + 4 + } else { + let mut m = 0; + for e in [vert, horiz].into_iter().flatten() { + sides[m] = e; + m += 1; + } + m + }; + if m == 0 { + for (o, p) in out[a..b].iter_mut().zip(&row[a..b]) { + *o = p.map(|v| clip(v, 0.0, 1.0)); + } + continue; + } + let mut x = a; + while x < b { + let e = (x + RUN).min(b); + carrier_run( + c, + &d, + (x, y), + (h, w), + &sides[..m], + &row[x..e], + &mut out[x..e], + ); + x = e; + } + } + }); +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index b9c764594..6c2b9f506 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -9,6 +9,7 @@ pub mod detect; pub mod display; pub mod distance; pub mod filter; +pub mod finish; pub mod gray; pub mod hdr; pub mod histogram; @@ -65,5 +66,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::analysis::register(m)?; py::warp::register(m)?; py::lab::register(m)?; - py::altprocess::register(m) + py::altprocess::register(m)?; + py::finish::register(m) } diff --git a/negpy_core/src/py/finish.rs b/negpy_core/src/py/finish.rs new file mode 100644 index 000000000..5f19df765 --- /dev/null +++ b/negpy_core/src/py/finish.rs @@ -0,0 +1,57 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::finish::{self, Carrier}; + +/// `apply_carrier(img, width_px, rough, flare, corner, paper, tone)` of an HxWx3 float32 image +/// for width_px > 0, with `carrier_profiles()` as `profiles`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn apply_carrier<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + width_px: f64, + rough: f64, + flare: f64, + corner: f64, + paper: (f32, f32, f32), + tone: PyReadonlyArray2<'py, f32>, + profiles: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + if tone.shape() != [64, 3] || profiles.shape() != [8, 2048] { + return Err(PyValueError::new_err( + "needs a 64x3 tone table and 8x2048 profiles", + )); + } + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let tone = tone.as_slice().map_err(not_contiguous)?; + let c = Carrier { + width_px, + rough, + flare, + corner, + paper: paper.into(), + tone: tone.as_chunks::<3>().0, + profiles: profiles.as_slice().map_err(not_contiguous)?, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, || finish::apply_carrier(src, (h, w), &c, dst)); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_carrier, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 556e15d2f..7195e019e 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -7,6 +7,7 @@ pub mod contours; pub mod convert; pub mod display; pub mod filter; +pub mod finish; pub mod gray; pub mod hdr; pub mod histogram; diff --git a/tests/test_core_finish.py b/tests/test_core_finish.py new file mode 100644 index 000000000..77f85ca63 --- /dev/null +++ b/tests/test_core_finish.py @@ -0,0 +1,237 @@ +"""negpy_core.apply_carrier against the NumPy carrier it replaces, with a correctly rounded +cube root where NumPy's own depends on the CPU.""" + +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.finish.logic import CARRIER_SAMPLES, CARRIER_TONE_SAMPLES, apply_carrier, carrier_profiles, linear_carrier_tone + +pytestmark = cv2_oracle + +CARRIER_JITTER = 0.24 +CARRIER_INNER_ROUGH = 0.2 +CARRIER_OUTER_JITTER = 0.4 +CARRIER_CORNER = 1.4 +CARRIER_GATE_CORNER = 0.2 +# The film never sits centered in the carrier. +CARRIER_OFFSET_X = -0.2 +CARRIER_OFFSET_Y = -0.25 +CARRIER_MARGIN = 0.7 +# The gate prints soft; filed metal is a hard stop, which is what reads as filed. +CARRIER_SOFT = 0.22 +CARRIER_FILED_SOFT = 0.06 +CARRIER_FLARE_DEPTH = 0.25 +CARRIER_FLARE_GAIN = 0.6 +CARRIER_FLARE_BASE = 0.35 +# A 1-D profile is a height field, so it cannot overhang or shed a fleck. The 2-D field +# displacing the distance field is what makes the edge read as torn metal. Hash noise +# rather than a library, because WGSL has to reproduce it bit for bit. +CARRIER_NOISE_SEED = 0x51ED270B +CARRIER_NOISE_CELL = 0.35 +CARRIER_NOISE_OCTAVES = 4 +CARRIER_NOISE_OUTER = 0.12 +CARRIER_NOISE_INNER = 0.02 +# Tone table sampled at t = u**POWER, dense in the toe. +_CARRIER_BLOCK_PIXELS = 1 << 19 + + +def _hash_lattice(ix: np.ndarray, iy: np.ndarray) -> np.ndarray: + """Lattice value in [-1, 1). u32 wrap-around only, so WGSL reproduces it exactly.""" + h = (ix * np.uint32(0x27D4EB2D)) ^ (iy * np.uint32(0x165667B1)) ^ np.uint32(CARRIER_NOISE_SEED) + h ^= h >> np.uint32(15) + h = h * np.uint32(0x2C1B3C6D) + h ^= h >> np.uint32(13) + h = h * np.uint32(0x297A2D39) + h ^= h >> np.uint32(16) + return (h >> np.uint32(8)).astype(np.float32) * np.float32(2.0 / 16777216.0) - np.float32(1.0) + + +def _value_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: + fx, fy = np.floor(x), np.floor(y) + ux, uy = (x - fx), (y - fy) + ux = ux * ux * (3.0 - 2.0 * ux) + uy = uy * uy * (3.0 - 2.0 * uy) + i0, j0 = fx.astype(np.int64).astype(np.uint32), fy.astype(np.int64).astype(np.uint32) + i1, j1 = i0 + np.uint32(1), j0 + np.uint32(1) + lo = _hash_lattice(i0, j0) + lo = lo + (_hash_lattice(i1, j0) - lo) * ux + hi = _hash_lattice(i0, j1) + hi = hi + (_hash_lattice(i1, j1) - hi) * ux + return lo + (hi - lo) * uy + + +def carrier_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: + """fBm value noise in [-1, 1]; x/y in noise cells. Mirrored in finish.wgsl.""" + total = np.zeros(np.broadcast_shapes(x.shape, y.shape), dtype=np.float32) + amp, freq, norm = np.float32(1.0), np.float32(1.0), np.float32(0.0) + for _ in range(CARRIER_NOISE_OCTAVES): + total += amp * _value_noise(x * freq, y * freq) + norm += amp + amp *= np.float32(0.5) + freq *= np.float32(2.0) + return total / norm + + +def carrier_tone_lookup(tone, t): + u = np.cbrt(np.clip(t, 0.0, 1.0).astype(np.float64)).astype(np.float32) * np.float32(CARRIER_TONE_SAMPLES - 1) + grid = np.arange(CARRIER_TONE_SAMPLES, dtype=np.float32) + return np.stack([np.interp(u, grid, tone[:, c]).astype(np.float32) for c in range(3)], axis=-1) + + +def _carrier_reference( + img: np.ndarray, + width_px: float, + rough: float, + flare: float = 0.0, + corner: float = 0.0, + paper: tuple[float, float, float] = (1.0, 1.0, 1.0), + tone: np.ndarray | None = None, +) -> np.ndarray: + """ + Filed-out negative carrier: the rebate prints between the film gate and the filed + aperture, inside a margin of bare paper (scene-linear, matching the mat). The filed + edge's penumbra and the flare are exposure, read through tone (rebate_tone). + """ + if width_px <= 0.0: + return img + + h, w = img.shape[:2] + tone = linear_carrier_tone() if tone is None else np.asarray(tone, dtype=np.float32) + band = ( + int( + np.ceil( + width_px + * ( + CARRIER_MARGIN + + CARRIER_CORNER * corner + + 1.0 + + CARRIER_JITTER + + CARRIER_NOISE_OUTER + + CARRIER_NOISE_INNER + + max(abs(CARRIER_OFFSET_X), abs(CARRIER_OFFSET_Y)) + ) + + max(1.0, width_px * CARRIER_SOFT) + ) + ) + + 1 + ) + out = img.copy() + if 2 * band >= h or 2 * band >= w: + regions = [(0, h, 0, w, (0, 1, 2, 3))] + else: + # An edge's effect ends within `band`. + hb, wb = h - band, w - band + regions = [ + (0, band, 0, band, (0, 2)), + (0, band, wb, w, (0, 3)), + (hb, h, 0, band, (1, 2)), + (hb, h, wb, w, (1, 3)), + (0, band, band, wb, (0,)), + (hb, h, band, wb, (1,)), + (band, hb, 0, band, (2,)), + (band, hb, wb, w, (3,)), + ] + for y0, y1, x0, x1, sides in regions: + rows = max(1, _CARRIER_BLOCK_PIXELS // max(1, x1 - x0)) + for b0 in range(y0, y1, rows): + b1 = min(b0 + rows, y1) + block = img[b0:b1, x0:x1] + out[b0:b1, x0:x1] = _carrier_block(block, b0, x0, h, w, width_px, rough, flare, corner, paper, tone, sides) + return np.clip(out, 0.0, 1.0) + + +def _carrier_block( + img: np.ndarray, + y0: int, + x0: int, + h: int, + w: int, + width_px: float, + rough: float, + flare: float, + corner: float, + paper: tuple[float, float, float], + tone: np.ndarray, + sides: tuple[int, ...], +) -> np.ndarray: + profiles = carrier_profiles() + soft = max(1.0, width_px * CARRIER_SOFT) + soft_filed = max(1.0, width_px * CARRIER_FILED_SOFT) + margin = width_px * CARRIER_MARGIN + radius = width_px * CARRIER_CORNER * corner + gate_radius = width_px * CARRIER_GATE_CORNER + cell = max(1.0, width_px * CARRIER_NOISE_CELL) + py = np.arange(y0, y0 + img.shape[0], dtype=np.float32)[:, None] + px = np.arange(x0, x0 + img.shape[1], dtype=np.float32)[None, :] + sx = (px + 0.5) / np.float32(w) + sy = (py + 0.5) / np.float32(h) + end_x = np.minimum(px, (w - 1.0) - px) + end_y = np.minimum(py, (h - 1.0) - py) + qx = px - np.float32(width_px * CARRIER_OFFSET_X) + qy = py - np.float32(width_px * CARRIER_OFFSET_Y) + fend_x = np.minimum(qx, (w - 1.0) - qx) + fend_y = np.minimum(qy, (h - 1.0) - qy) + n2 = carrier_noise(px / cell, py / cell) + + def prof(row: int, s: np.ndarray) -> np.ndarray: + return profiles[row, np.minimum((s * CARRIER_SAMPLES).astype(np.int32), CARRIER_SAMPLES - 1)] + + def arc(r: float, at: float, end: np.ndarray) -> np.ndarray | np.float32: + """Corner retreat of a boundary `at` px from the print edge, from its own corner.""" + if r <= 0.0: + return np.float32(0.0) + x = np.clip(r - (end - at), 0.0, r) + return r - np.sqrt(np.maximum(r * r - x * x, 0.0)) + + def bounds(edge: int, s: np.ndarray, end: np.ndarray, fend: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + outer = margin + width_px * rough * (CARRIER_OUTER_JITTER * prof(edge + 4, s) + CARRIER_NOISE_OUTER * n2) + wobble = CARRIER_JITTER * CARRIER_INNER_ROUGH * prof(edge, s) + CARRIER_NOISE_INNER * n2 + inner = margin + width_px * (1.0 + wobble) + arc(gate_radius, margin + width_px, end) + return outer + arc(radius, margin, fend), inner + + # (edge, profile position, gate end, filed end, gate distance, filed distance) + edges = ( + (0, sx, end_x, fend_x, py, qy), + (1, sx, end_x, fend_x, (h - 1.0) - py, (h - 1.0) - qy), + (2, sy, end_y, fend_y, px, qx), + (3, sy, end_y, fend_y, (w - 1.0) - px, (w - 1.0) - qx), + ) + reach = max(1.0, width_px * CARRIER_FLARE_DEPTH) + # Floored |noise|, not a one-sided gate: that left whole edges with no flare. + flare_amp = flare * CARRIER_FLARE_GAIN * (CARRIER_FLARE_BASE + (1.0 - CARRIER_FLARE_BASE) * np.abs(n2)) + a_in = np.ones(img.shape[:2], dtype=np.float32) + a_out = np.ones(img.shape[:2], dtype=np.float32) + peaks, gates = [], [] + for e, s, end, fend, d, fd in (edges[k] for k in sides): + outer, inner = bounds(e, s, end, fend) + a_in = a_in * np.clip((d - inner) / soft + 0.5, 0.0, 1.0) + a_out = a_out * np.clip((fd - outer) / soft_filed + 0.5, 0.0, 1.0) + peaks.append(np.clip(1.0 - np.abs(fd - outer) / reach, 0.0, 1.0) ** 2) + gates.append(np.clip(1.0 + (fd - outer) / reach, 0.0, 1.0)) + lit = np.float32(0.0) + if flare > 0.0: + # The bevel spans only the aperture, so the other edges gate each edge's flare. + for e in range(len(peaks)): + lit = lit + peaks[e] * np.prod([gates[k] for k in range(len(gates)) if k != e] or [np.float32(1.0)], axis=0) + lit = flare_amp * lit + + rebate = np.asarray(paper, dtype=np.float32) * carrier_tone_lookup(tone, a_out + lit) + shown = (a_in * a_out)[..., None] + return img * shown + rebate * (1.0 - shown) + + +_RNG = np.random.default_rng(29) +_TONES = [ + linear_carrier_tone(), + np.ascontiguousarray(np.clip(linear_carrier_tone() * _RNG.uniform(0.8, 1.1, (64, 3)).astype(np.float32), 0, 1.2)), +] + + +@pytest.mark.parametrize("shape", [(300, 400), (120, 90), (40, 30)]) +@pytest.mark.parametrize("width, rough, flare, corner, tone", [(12.0, 0.5, 0.0, 0.0, 0), (20.0, 1.0, 0.7, 0.5, 1), (7.3, 0.2, 1.0, 1.0, 1)]) +def test_carrier_matches_numpy(shape, width, rough, flare, corner, tone): + img = _RNG.random((*shape, 3), dtype=np.float32) * np.float32(1.2) + paper = (0.98, 0.97, 0.95) + want = _carrier_reference(img, width, rough, flare, corner, paper, _TONES[tone]) + assert apply_carrier(img, width, rough, flare, corner, paper, _TONES[tone]).tobytes() == want.tobytes() diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 705595b5d..c5accec89 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -88,6 +88,16 @@ def _filled(fill): [(np.float32(a), np.float32(b)) for a, b in ((4.0, 12.0), (6.0, 18.0), (2.0, 8.0), (0.0, 0.5))], WORKING_LAB, ), + "carrier": lambda: negpy_core.apply_carrier( + _IMG3, + 9.0, + 0.7, + 0.5, + 0.4, + (np.float32(0.98), np.float32(0.97), np.float32(0.95)), + np.ascontiguousarray(np.repeat(_LIN[::1040][:64, None], 3, axis=1)), + np.ascontiguousarray(np.tile(_LIN[::32] * np.float32(2.0) - np.float32(1.0), (8, 1))), + ), "warp_affine": lambda: negpy_core.warp_affine(_IMG3, np.array([[0.998, 0.061, -3.1], [-0.061, 0.998, 4.2]]), (131, 97)), "warp_perspective": lambda: negpy_core.warp_perspective( _IMG1, np.array([[1.02, 0.03, -1.5], [-0.01, 0.97, 2.25], [1e-4, -2e-4, 1.0]]), (61, 83) @@ -133,6 +143,7 @@ def _filled(fill): "usm_sharpen": "7267d2a3976c9381eadde01fe11ea60d46345b4747283e6ac68a2aa4a025d0a8", "sabattier": "f8956f67786c7c1d0e628af4b82fb12b6dde42e70da2504585f5bf20e7fbb461", "lith": "98dc05c62e191da2ad00c1f2327c48beff5af7e882be64cac5302cb0ea5099e5", + "carrier": "97c9d045201ba345010f91b09ea07699fecc4e009d7738dc8591295b59e6081c", "warp_affine": "83562b3183484dcbf80fc65c4fb2f241d3b13ec9793cf43fd22bf11ea44d9e1d", "warp_perspective": "61e89302e793e24f1ad131b661e95186d7d9a82e8331a96164e338bec3ec1420", "warp_affine_cubic_constant": "a3d3d1a3d6c5e0e77686cfd37850199aa880943936e83edf8ada48307501cd69", diff --git a/tests/test_finish_logic.py b/tests/test_finish_logic.py index ec2e5b6a9..3c9025277 100644 --- a/tests/test_finish_logic.py +++ b/tests/test_finish_logic.py @@ -4,7 +4,6 @@ CARRIER_SAMPLES, apply_carrier, apply_vignette, - carrier_noise, carrier_profiles, linear_carrier_tone, ) @@ -259,16 +258,6 @@ def test_flare_deterministic(self) -> None: b = apply_carrier(img, width_px=8.0, rough=1.0, flare=0.7) np.testing.assert_array_equal(a, b) - def test_noise_is_two_dimensional_and_bounded(self) -> None: - x = np.linspace(0.0, 12.0, 64, dtype=np.float32)[None, :] - y = np.linspace(0.0, 12.0, 48, dtype=np.float32)[:, None] - n = carrier_noise(x, y) - self.assertEqual(n.shape, (48, 64)) - self.assertLessEqual(float(np.abs(n).max()), 1.0) - np.testing.assert_array_equal(n, carrier_noise(x, y)) - # Genuinely 2-D: rows differ, so the field is not a function of x alone. - self.assertGreater(float(np.abs(n[0] - n[20]).max()), 0.05) - def test_filed_edge_is_not_a_height_field(self) -> None: """The 2-D field must be able to overhang and shed flecks — a 1-D edge cannot.""" img = np.full((300, 400, 3), 0.5, dtype=np.float32) From 81002a94a9229ec4ac64522fdca53ff3ee751d5c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 12:33:35 +0200 Subject: [PATCH 080/133] perf(core): skew edge sampling in Rust negpy_core.skew_sample runs _fit_side's per-station sampling in one call: the 3-column mean profile, the sigma-1 Gaussian, np.gradient, the median gate and the subpixel peaks in NumPy's float32 and float64 order. A frame ran _profile_peaks about 4,000 times from Python. RANSAC, polyfit and LSD stay in Python. Replay of every skew function bit-identical (42 to 60 calls each); 4 oracle cases. One thread: measure_frame_skew 0.69x on 10 recorded frames. --- negpy/features/geometry/skew.py | 34 +-------- negpy_core/manifest.toml | 14 +--- negpy_core/src/lib.rs | 4 +- negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/skew.rs | 39 ++++++++++ negpy_core/src/skew.rs | 126 ++++++++++++++++++++++++++++++++ tests/test_core_skew.py | 56 ++++++++++++++ 7 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 negpy_core/src/py/skew.rs create mode 100644 negpy_core/src/skew.rs create mode 100644 tests/test_core_skew.py diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 094e7ae63..7c4e553d6 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -118,22 +118,6 @@ def _coarse_angles(lum: np.ndarray) -> list[float]: return modes -def _profile_peaks(profile: np.ndarray) -> np.ndarray: - """Subpixel positions of the strong gradient steps along one profile.""" - if profile.size < 5: - return np.empty(0) - grad = np.abs(np.gradient(negpy_core.gaussian_blur(profile.reshape(-1, 1).astype(np.float32), (1, 0), 0, 1.0).ravel())) - peak = float(grad.max()) - if peak <= 3.0 * float(np.median(grad)) + 1e-6: - return np.empty(0) - i = np.arange(1, grad.size - 1) - i = i[(grad[i] >= grad[i - 1]) & (grad[i] > grad[i + 1]) & (grad[i] >= 0.4 * peak)] - a, b, c = grad[i - 1], grad[i], grad[i + 1] - denom = a - 2 * b + c - safe = np.where(np.abs(denom) > 1e-9, denom, 1.0) - return i + np.where(np.abs(denom) > 1e-9, 0.5 * (a - c) / safe, 0.0) - - def _ransac_line(xs: np.ndarray, ys: np.ndarray, expected: float, half: float, span: float) -> np.ndarray | None: """The straight run of peaks with the most distinct stations, preferring one near `expected`.""" if xs.size < 12: @@ -171,21 +155,11 @@ def _fit_side(lum: np.ndarray, horizontal: bool, at: float, start: float, stop: return None stations = np.linspace(start + 0.1 * length, stop - 0.1 * length, int(min(240, max(24, length / 4)))) + plane = np.ascontiguousarray(lum, dtype=np.float32) + def sample(center: np.ndarray, half: float, nearest: bool) -> tuple[np.ndarray, np.ndarray]: - xs, ys = [], [] - for x, c in zip(stations, center): - x0 = int(round(x)) - lo, hi = int(max(0, math.floor(c - half))), int(min(h, math.ceil(c + half) + 1)) - if hi - lo < 5 or x0 < 1 or x0 > w - 2: - continue - peaks = _profile_peaks(img[lo:hi, x0 - 1 : x0 + 2].mean(axis=1)) - if peaks.size == 0: - continue - if nearest: - peaks = peaks[[int(np.argmin(np.abs(peaks - (c - lo))))]] - xs.extend([x] * peaks.size) - ys.extend(lo + peaks) - return np.asarray(xs), np.asarray(ys) + """Per station, the gradient steps of the 3-column mean profile within `half` of `center`.""" + return negpy_core.skew_sample(plane, horizontal, stations, np.ascontiguousarray(center, dtype=np.float64), float(half), nearest) # Keystone leaves the far end of an edge a degree or two inside the box; a stronger edge # outside it (the holder, the next frame) sits past the detector's error. diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index a3fb90fcb..6e15dcf06 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -872,14 +872,14 @@ depends = [] ["features/geometry/skew.py::_fit_side"] phase = 2 status = "todo" -calls = ["np.mean", "np.median"] -depends = ["_profile_peaks", "_ransac_line", "sample"] +calls = ["negpy_core.skew_sample", "np.mean", "np.median"] +depends = ["_ransac_line", "sample"] ["features/geometry/skew.py::_fit_side.sample"] phase = 2 status = "todo" -calls = [] -depends = ["_profile_peaks"] +calls = ["negpy_core.skew_sample"] +depends = [] ["features/geometry/skew.py::_frame_lines"] phase = 2 @@ -911,12 +911,6 @@ status = "todo" calls = ["negpy_core.morphology_ex", "negpy_core.sobel", "np.percentile"] depends = ["_fit_side"] -["features/geometry/skew.py::_profile_peaks"] -phase = 2 -status = "todo" -calls = ["negpy_core.gaussian_blur", "np.median"] -depends = [] - ["features/geometry/skew.py::_ransac_line"] phase = 2 status = "todo" diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 6c2b9f506..4132f46c0 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -23,6 +23,7 @@ pub mod resize; pub mod retouch; pub mod sensor; pub mod shapes; +pub mod skew; pub mod stats; pub mod toning; pub mod warp; @@ -67,5 +68,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::warp::register(m)?; py::lab::register(m)?; py::altprocess::register(m)?; - py::finish::register(m) + py::finish::register(m)?; + py::skew::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 7195e019e..59456dc43 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -18,6 +18,7 @@ pub mod print_curve; pub mod resize; pub mod retouch; pub mod sensor; +pub mod skew; pub mod stats; pub mod toning; pub mod warp; diff --git a/negpy_core/src/py/skew.rs b/negpy_core/src/py/skew.rs new file mode 100644 index 000000000..7e40a9368 --- /dev/null +++ b/negpy_core/src/py/skew.rs @@ -0,0 +1,39 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::skew; + +type Samples<'py> = (Bound<'py, PyArray1>, Bound<'py, PyArray1>); + +/// `_fit_side`'s `sample(center, half, nearest)` on an HxW float32 luminance plane, read along +/// rows (`horizontal`) or columns: (stations, positions) of the profile peaks. +#[pyfunction] +fn skew_sample<'py>( + py: Python<'py>, + lum: PyReadonlyArray2<'py, f32>, + horizontal: bool, + stations: PyReadonlyArray1<'py, f64>, + centers: PyReadonlyArray1<'py, f64>, + half: f64, + nearest: bool, +) -> PyResult> { + let (h, w) = (lum.shape()[0], lum.shape()[1]); + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let img = lum.as_slice().map_err(not_contiguous)?; + let (st, ce) = ( + stations.as_slice().map_err(not_contiguous)?, + centers.as_slice().map_err(not_contiguous)?, + ); + if st.len() != ce.len() { + return Err(PyValueError::new_err("needs one center per station")); + } + let (xs, ys) = super::detach(py, || { + skew::sample(img, (h, w), horizontal, (st, ce), half, nearest) + }); + Ok((xs.into_pyarray(py), ys.into_pyarray(py))) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(skew_sample, m)?) +} diff --git a/negpy_core/src/skew.rs b/negpy_core/src/skew.rs new file mode 100644 index 000000000..fa4e9b0b4 --- /dev/null +++ b/negpy_core/src/skew.rs @@ -0,0 +1,126 @@ +//! The edge sampling of negpy/features/geometry/skew.py: `_fit_side`'s per-station profiles +//! and `_profile_peaks`, in NumPy's float32 and float64 order. + +use rayon::prelude::*; + +use crate::filter::{self, Border}; + +/// `_profile_peaks`: subpixel positions of the strong gradient steps along one profile, after a +/// sigma-1 Gaussian (reflect-101) and `np.gradient`. +pub fn profile_peaks(profile: &[f32]) -> Vec { + let n = profile.len(); + if n < 5 { + return Vec::new(); + } + let mut blur = vec![0f32; n]; + if filter::gaussian_blur( + profile, + &mut blur, + (n, 1, 1), + (1, 0), + (0.0, 1.0), + Border::Reflect101, + ) + .is_none() + { + return Vec::new(); + } + let mut grad: Vec = (0..n) + .map(|i| match i { + 0 => blur[1] - blur[0], + _ if i == n - 1 => blur[n - 1] - blur[n - 2], + _ => (blur[i + 1] - blur[i - 1]) / 2.0, + }) + .map(f32::abs) + .collect(); + let peak = grad.iter().copied().fold(f32::NEG_INFINITY, f32::max) as f64; + let mut sorted = grad.clone(); + sorted.sort_unstable_by(f32::total_cmp); + let median = if n % 2 == 1 { + sorted[n / 2] + } else { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + }; + if peak <= 3.0 * median as f64 + 1e-6 { + return Vec::new(); + } + // A Python float meets the float32 gradient as float32. + let (bar, tiny) = ((0.4 * peak) as f32, 1e-9f64 as f32); + let g = std::mem::take(&mut grad); + (1..n - 1) + .filter(|&i| g[i] >= g[i - 1] && g[i] > g[i + 1] && g[i] >= bar) + .map(|i| { + let (a, b, c) = (g[i - 1], g[i], g[i + 1]); + let denom = a - 2.0 * b + c; + let frac = if denom.abs() > tiny { + 0.5 * (a - c) / denom + } else { + 0.0 + }; + i as f64 + frac as f64 + }) + .collect() +} + +/// Python's `round` of a float64: ties to even. +fn round_half_even(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +/// `_fit_side`'s `sample(center, half, nearest)` on an HxW luminance plane read along rows +/// (`horizontal`) or columns: per station, the peaks of the 3-column mean profile within +/// `half` of its center, or only the one nearest the center. Returns (stations, positions). +pub fn sample( + img: &[f32], + (h, w): (usize, usize), + horizontal: bool, + (stations, centers): (&[f64], &[f64]), + half: f64, + nearest: bool, +) -> (Vec, Vec) { + let (vh, vw) = if horizontal { (h, w) } else { (w, h) }; + let at = |r: usize, c: usize| { + if horizontal { + img[r * w + c] + } else { + img[c * w + r] + } + }; + let found: Vec<(f64, Vec)> = stations + .par_iter() + .zip(centers.par_iter()) + .map(|(&x, &c)| { + let x0 = round_half_even(x); + let lo = ((c - half).floor() as i64).max(0); + let hi = ((c + half).ceil() as i64 + 1).min(vh as i64); + if hi - lo < 5 || x0 < 1 || x0 > vw as i64 - 2 { + return (x, Vec::new()); + } + let (lo, x0) = (lo as usize, x0 as usize); + let profile: Vec = (lo..hi as usize) + .map(|r| (at(r, x0 - 1) + at(r, x0) + at(r, x0 + 1)) / 3.0) + .collect(); + let mut peaks = profile_peaks(&profile); + if nearest && !peaks.is_empty() { + let want = c - lo as f64; + let best = peaks + .iter() + .enumerate() + .fold((0, f64::INFINITY), |(bi, bd), (i, &p)| { + let d = (p - want).abs(); + if d < bd { (i, d) } else { (bi, bd) } + }) + .0; + peaks = vec![peaks[best]]; + } + (x, peaks.into_iter().map(|p| lo as f64 + p).collect()) + }) + .collect(); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for (x, peaks) in found { + xs.extend(std::iter::repeat_n(x, peaks.len())); + ys.extend(peaks); + } + (xs, ys) +} diff --git a/tests/test_core_skew.py b/tests/test_core_skew.py new file mode 100644 index 000000000..a900b6ba4 --- /dev/null +++ b/tests/test_core_skew.py @@ -0,0 +1,56 @@ +import math + +import negpy_core +import numpy as np +import pytest + + +def _profile_peaks(profile): + """The NumPy peak finder that negpy_core.skew_sample replaces.""" + if profile.size < 5: + return np.empty(0) + grad = np.abs(np.gradient(negpy_core.gaussian_blur(profile.reshape(-1, 1).astype(np.float32), (1, 0), 0, 1.0).ravel())) + peak = float(grad.max()) + if peak <= 3.0 * float(np.median(grad)) + 1e-6: + return np.empty(0) + i = np.arange(1, grad.size - 1) + i = i[(grad[i] >= grad[i - 1]) & (grad[i] > grad[i + 1]) & (grad[i] >= 0.4 * peak)] + a, b, c = grad[i - 1], grad[i], grad[i + 1] + denom = a - 2 * b + c + safe = np.where(np.abs(denom) > 1e-9, denom, 1.0) + return i + np.where(np.abs(denom) > 1e-9, 0.5 * (a - c) / safe, 0.0) + + +def _sample(lum, horizontal, stations, center, half, nearest): + img = lum if horizontal else lum.T + h, w = img.shape + xs, ys = [], [] + for x, c in zip(stations, center): + x0 = int(round(x)) + lo, hi = int(max(0, math.floor(c - half))), int(min(h, math.ceil(c + half) + 1)) + if hi - lo < 5 or x0 < 1 or x0 > w - 2: + continue + peaks = _profile_peaks(img[lo:hi, x0 - 1 : x0 + 2].mean(axis=1)) + if peaks.size == 0: + continue + if nearest: + peaks = peaks[[int(np.argmin(np.abs(peaks - (c - lo))))]] + xs.extend([x] * peaks.size) + ys.extend(lo + peaks) + return np.asarray(xs), np.asarray(ys) + + +@pytest.mark.parametrize("horizontal", [True, False]) +@pytest.mark.parametrize("nearest", [False, True]) +def test_skew_sample_matches_numpy(horizontal, nearest): + rng = np.random.default_rng(31) + lum = rng.random((180, 260), dtype=np.float32) * np.float32(0.05) + lum[60:, :] += np.float32(0.8) + lum[:, 200:] += np.float32(0.3) + lum[:, 7] = 1.0 + stations = np.linspace(4.0, (260 if horizontal else 180) - 5.0, 37) + center = np.full(stations.size, 61.0) + np.linspace(-3.0, 3.0, stations.size) + want = _sample(lum, horizontal, stations, center, 14.0, nearest) + got = negpy_core.skew_sample(lum, horizontal, stations, center, 14.0, nearest) + assert want[0].tobytes() == got[0].tobytes() + assert want[1].tobytes() == got[1].tobytes() From 9a4fcac0468d625646cf4561ba5771052513ad56 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 12:51:50 +0200 Subject: [PATCH 081/133] perf(core): faster median blur: 3x3 network, small-window counting, flatter histograms negpy_core.median_blur takes Paeth's 3x3 min/max network for k=3 and a bitwise counting median (the largest t that k^2/2 + 1 values reach) for k=5, both flat loops over a padded row. Larger windows keep the constant-time histogram method, now in u16 column stripes of 512 with the fine histograms by coarse bin, as OpenCV lays them out. Exact against cv2 on every window size tested. One thread, 1600x1113: k=3 0.28 ms (was 58), k=5 5 ms (was 62), k>=7 1.47x of cv2 (was 1.5-1.8x). --- negpy_core/src/median.rs | 319 +++++++++++++++++++++++++++++---------- 1 file changed, 237 insertions(+), 82 deletions(-) diff --git a/negpy_core/src/median.rs b/negpy_core/src/median.rs index fa1d13f05..a3b2d96fe 100644 --- a/negpy_core/src/median.rs +++ b/negpy_core/src/median.rs @@ -1,15 +1,12 @@ //! `cv2.medianBlur` for single-channel uint8: the median of a `k` x `k` window, edges //! replicated as OpenCV does. A median of integers is exact, so only the border rule matters. -use rayon::prelude::*; +// The steps index several per-column arrays of one row in step. +#![allow(clippy::needless_range_loop)] -fn bump(hist: &mut [u16; N], bin: usize, up: bool) { - hist[bin] = if up { hist[bin] + 1 } else { hist[bin] - 1 }; -} +use multiversion::multiversion; +use rayon::prelude::*; -/// Perreault and Hebert's constant-time median: column histograms slid down the rows, a 16-bin -/// coarse window histogram slid along each row, and each 16-bin fine segment brought up to date -/// only when the search enters it. pub fn median_blur_u8(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) -> Option<()> { if h == 0 || w == 0 @@ -20,89 +17,247 @@ pub fn median_blur_u8(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) { return None; } - if k == 1 { - dst.copy_from_slice(src); - return Some(()); + match k { + 1 => dst.copy_from_slice(src), + 3 => median3(src, dst, h, w), + 5 => small(src, dst, h, w, k), + _ => histogram(src, dst, h, w, k), + } + Some(()) +} + +/// 3 x 3: each column's three values sorted, then the median of the nine as the middle of +/// (max of the lows, median of the middles, min of the highs) of three neighboring columns. +fn median3(src: &[u8], dst: &mut [u8], h: usize, w: usize) { + dst.par_chunks_mut(w).enumerate().for_each_init( + || vec![0u8; 3 * (w + 2)], + |rows, (y, out)| { + for (dy, padded) in rows.chunks_exact_mut(w + 2).enumerate() { + let row = &src[(y + dy).saturating_sub(1).min(h - 1) * w..][..w]; + padded[1..=w].copy_from_slice(row); + padded[0] = row[0]; + padded[w + 1] = row[w - 1]; + } + median3_row(rows, out); + }, + ); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn median3_row(rows: &[u8], out: &mut [u8]) { + let w = out.len(); + let pw = w + 2; + let (r0, rest) = rows.split_at(pw); + let (r1, r2) = rest.split_at(pw); + let (mut lo, mut mid, mut hi) = (vec![0u8; pw], vec![0u8; pw], vec![0u8; pw]); + for x in 0..pw { + let (a, b, c) = (r0[x], r1[x], r2[x]); + let (a, b) = (a.min(b), a.max(b)); + let (b, c) = (b.min(c), b.max(c)); + let (a, b) = (a.min(b), a.max(b)); + lo[x] = a; + mid[x] = b; + hi[x] = c; + } + let med3 = |a: u8, b: u8, c: u8| a.min(b).max(a.max(b).min(c)); + for x in 0..w { + let l = lo[x].max(lo[x + 1]).max(lo[x + 2]); + let m = med3(mid[x], mid[x + 1], mid[x + 2]); + let u = hi[x].min(hi[x + 1]).min(hi[x + 2]); + out[x] = med3(l, m, u); + } +} + +/// Windows up to 5 x 5, a row at a time: the median is the largest t that at least k^2/2 + 1 +/// of the window's values reach, found bit by bit, so every step is a flat loop over the row. +fn small(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) { + let r = k / 2; + dst.par_chunks_mut(w).enumerate().for_each_init( + || vec![0u8; k * (w + 2 * r)], + |rows, (y, out)| { + // The k source rows, each padded by r replicated pixels on both sides. + let pw = w + 2 * r; + for (dy, padded) in rows.chunks_exact_mut(pw).enumerate() { + let sy = (y + dy).saturating_sub(r).min(h - 1); + let row = &src[sy * w..][..w]; + padded[r..r + w].copy_from_slice(row); + padded[..r].fill(row[0]); + padded[r + w..].fill(row[w - 1]); + } + small_row(rows, pw, k, out); + }, + ); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn small_row(rows: &[u8], pw: usize, k: usize, out: &mut [u8]) { + let w = out.len(); + let need = (k * k / 2 + 1) as u8; + out.fill(0); + let mut count = vec![0u8; w]; + for bit in (0..8).rev() { + count.fill(0); + for dy in 0..k { + let row = &rows[dy * pw..][..pw]; + for dx in 0..k { + let s = &row[dx..dx + w]; + for x in 0..w { + count[x] += (s[x] >= (out[x] | 1 << bit)) as u8; + } + } + } + for x in 0..w { + if count[x] >= need { + out[x] |= 1 << bit; + } + } + } +} + +/// Columns per stripe: a stripe's column histograms stay in cache. +const STRIPE: usize = 512; +/// Output rows per task. +const BAND: usize = 128; + +/// Perreault and Hebert's constant-time median: column histograms slid down the rows, a 16-bin +/// coarse window histogram slid along each row, and each 16-bin fine segment brought up to date +/// only when the search enters it. Counts fit u16 for any window OpenCV allows. +fn histogram(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) { + let tasks: Vec<(usize, usize)> = (0..h.div_ceil(BAND)) + .flat_map(|b| (0..w.div_ceil(STRIPE)).map(move |s| (b, s))) + .collect(); + let parts: Vec<(usize, usize, Vec)> = tasks + .into_par_iter() + .map(|(b, s)| { + let (y0, x0) = (b * BAND, s * STRIPE); + let (rows, cols) = (BAND.min(h - y0), STRIPE.min(w - x0)); + let mut out = vec![0u8; rows * cols]; + histogram_tile(src, (h, w), k, (y0, x0), cols, &mut out); + (y0, x0, out) + }) + .collect(); + for (y0, x0, out) in parts { + let cols = STRIPE.min(w - x0); + for (dy, row) in out.chunks_exact(cols).enumerate() { + dst[(y0 + dy) * w + x0..][..cols].copy_from_slice(row); + } } - let r = (k / 2) as isize; - let rank = (k * k / 2) as u32; +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn histogram_tile( + src: &[u8], + (h, w): (usize, usize), + k: usize, + (y0, x0): (usize, usize), + cols: usize, + out: &mut [u8], +) { + let r = k / 2; + let rank = (k * k / 2) as u16; let cy = |v: isize| v.clamp(0, h as isize - 1) as usize; - let cx = |v: isize| v.clamp(0, w as isize - 1) as usize; - const BAND: usize = 256; - dst.par_chunks_mut(BAND * w) - .enumerate() - .for_each(|(bi, band)| { - let y0 = bi * BAND; - // Column histograms over rows y0 - r ..= y0 + r: fine (256 bins) and coarse (16). - let mut fine_col = vec![[0u16; 256]; w]; - let mut coarse_col = vec![[0u16; 16]; w]; - for dy in -r..=r { - for (x, &v) in src[cy(y0 as isize + dy) * w..][..w].iter().enumerate() { - bump(&mut fine_col[x], v as usize, true); - bump(&mut coarse_col[x], (v >> 4) as usize, true); + // Column histograms for the stripe and r columns either side, replicated at the edges. + // Fine ones by coarse bin, then column: the window slides along one contiguous run. + let span = cols + 2 * r; + let mut fine = vec![[0u16; 16]; 16 * span]; + let mut coarse = vec![[0u16; 16]; span]; + let mut padded = vec![0u8; span]; + let fill = |y: usize, padded: &mut [u8]| { + let row = &src[y * w..][..w]; + for (i, p) in padded.iter_mut().enumerate() { + *p = row[(x0 + i).saturating_sub(r).min(w - 1)]; + } + }; + let add = |fine: &mut [[u16; 16]], coarse: &mut [[u16; 16]], padded: &[u8], up: bool| { + for (i, (&v, c)) in padded.iter().zip(coarse.iter_mut()).enumerate() { + let (hi, lo) = ((v >> 4) as usize, (v & 15) as usize); + let f = &mut fine[hi * span + i][lo]; + if up { + *f += 1; + c[hi] += 1; + } else { + *f -= 1; + c[hi] -= 1; + } + } + }; + for dy in -(r as isize)..=r as isize { + fill(cy(y0 as isize + dy), &mut padded); + add(&mut fine, &mut coarse, &padded, true); + } + for (dy, out) in out.chunks_exact_mut(cols).enumerate() { + let y = (y0 + dy) as isize; + if dy > 0 { + fill(cy(y - r as isize - 1), &mut padded); + add(&mut fine, &mut coarse, &padded, false); + fill(cy(y + r as isize), &mut padded); + add(&mut fine, &mut coarse, &padded, true); + } + // Output column x reads histogram columns x ..= x + 2r. + let mut win = [0u16; 16]; + for c in &coarse[..k] { + for b in 0..16 { + win[b] += c[b]; + } + } + let mut seg = [[0u16; 16]; 16]; + // The output column each fine segment was last summed for. + let mut synced = [usize::MAX; 16]; + for (x, o) in out.iter_mut().enumerate() { + let mut seen = 0u16; + let mut b = 0; + for (i, &c) in win.iter().enumerate() { + b = i; + if seen + c > rank { + break; } + seen += c; } - for (dy, out) in band.chunks_mut(w).enumerate() { - let y = (y0 + dy) as isize; - if dy > 0 { - let gone = &src[cy(y - r - 1) * w..][..w]; - let new = &src[cy(y + r) * w..][..w]; - for x in 0..w { - bump(&mut fine_col[x], gone[x] as usize, false); - bump(&mut coarse_col[x], (gone[x] >> 4) as usize, false); - bump(&mut fine_col[x], new[x] as usize, true); - bump(&mut coarse_col[x], (new[x] >> 4) as usize, true); + let run = &fine[b * span..][..span]; + let s = &mut seg[b]; + if synced[b] == usize::MAX || synced[b] + k <= x { + *s = [0; 16]; + for col in &run[x..x + k] { + for i in 0..16 { + s[i] += col[i]; } } - let mut coarse = [0u32; 16]; - for dx in -r..=r { - for (c, &v) in coarse.iter_mut().zip(&coarse_col[cx(dx)]) { - *c += v as u32; + } else { + for j in synced[b] + 1..=x { + let (gone, new) = (&run[j - 1], &run[j + 2 * r]); + for i in 0..16 { + s[i] = s[i].wrapping_add(new[i]).wrapping_sub(gone[i]); } } - let mut fine = [[0u32; 16]; 16]; - // The output column each fine segment was last summed for. - let mut synced = [isize::MIN; 16]; - for (x, o) in out.iter_mut().enumerate() { - let xi = x as isize; - let mut seen = 0u32; - let mut b = 0; - while seen + coarse[b] <= rank { - seen += coarse[b]; - b += 1; - } - let seg = &mut fine[b]; - let lo = b << 4; - if synced[b] < xi - 2 * r { - *seg = [0; 16]; - for dx in -r..=r { - for (f, &v) in seg.iter_mut().zip(&fine_col[cx(xi + dx)][lo..lo + 16]) { - *f += v as u32; - } - } - } else { - for j in synced[b] + 1..=xi { - let (gone, new) = (&fine_col[cx(j - r - 1)], &fine_col[cx(j + r)]); - for i in 0..16 { - seg[i] = (seg[i] + new[lo + i] as u32) - gone[lo + i] as u32; - } - } - } - synced[b] = xi; - let mut v = 0; - while seen + seg[v] <= rank { - seen += seg[v]; - v += 1; - } - *o = (lo + v) as u8; - if x + 1 < w { - let (gone, new) = (&coarse_col[cx(xi - r)], &coarse_col[cx(xi + r + 1)]); - for i in 0..16 { - coarse[i] = (coarse[i] + new[i] as u32) - gone[i] as u32; - } - } + } + synced[b] = x; + let mut v = 0; + for (i, &c) in s.iter().enumerate() { + v = i; + if seen + c > rank { + break; } + seen += c; } - }); - Some(()) + *o = ((b << 4) + v) as u8; + if x + 1 < cols { + let (gone, new) = (&coarse[x], &coarse[x + k]); + for i in 0..16 { + win[i] = win[i].wrapping_add(new[i]).wrapping_sub(gone[i]); + } + } + } + } } From f7a775d16a7ee82d65559f4f44f61b998c192c36 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 12:59:37 +0200 Subject: [PATCH 082/133] fix(replay): the recorder passes the IR preview to the pipeline The app hands load_linear_preview's ir_preview to run_pipeline as ir_buffer; the recorder dropped it, so the IR repair path never ran while recording. Six IR strips now record every IR-path function. --- scripts/replay.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/replay.py b/scripts/replay.py index 7d7bffaac..bee9051c8 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -143,13 +143,15 @@ def _run_frame(path: str, repo=None, overrides: dict | None = None) -> tuple: from negpy.services.rendering.image_processor import ImageProcessor from negpy.services.rendering.preview_manager import PreviewManager - buffer, _, _ = PreviewManager().load_linear_preview(path) + buffer, _, meta = PreviewManager().load_linear_preview(path) saved = repo.load_file_settings(file_hashes(path)[0]) if repo else None config = with_auto_crop(saved or WorkspaceConfig(), True) for section, fields in (overrides or {}).items(): config = dataclasses.replace(config, **{section: dataclasses.replace(getattr(config, section), **fields)}) # Stage caching on, as the preview runs: the base read (measure_base_log) happens only then. - ImageProcessor(use_gpu=False).run_pipeline(buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False) + ImageProcessor(use_gpu=False).run_pipeline( + buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False, ir_buffer=meta.get("ir_preview") + ) return buffer, config From ed639eef9cd7cb89ecf4234b866a50dc0940926e Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 13:15:07 +0200 Subject: [PATCH 083/133] perf(core): fix multi-core slowdowns; the speed gate counts every core Users run Multi-core CPU Rendering, so `make bench` now times all cores, and recordings store both an all-core and a one-core time. Measuring every corpus function on all cores against one core found density_parade 18x slower on all cores: rayon's fold makes an accumulator per split job, and each held 1.5 MB of counters. It and the CLAHE histograms now take one chunk of rows per thread. Percentile blocks hold at least a million values, so clearing and merging a 1 MB histogram per thread pays off. Worst all-core/one-core ratio over the corpus: 18x -> 1.16x (a 1 ms function). Replay unchanged. --- CLAUDE.md | 6 +++--- negpy_core/src/histogram.rs | 20 +++++++++++--------- negpy_core/src/lab.rs | 18 ++++++++++-------- negpy_core/src/stats.rs | 8 ++++---- scripts/replay.py | 36 ++++++++++++++++++++++-------------- 5 files changed, 50 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8b4ab2a5d..6cb3e324d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ make run-sim # Launch with a simulated camera, Scanlight and film scanners make all # format + Rust checks + lint + type check + tests (run before committing) make rust # cargo fmt --check + clippy -D warnings + cargo test for negpy_core make replay # replay recorded real calls through the current code, bit for bit (PHASE=N) -make bench # one-thread speed of ported functions against the recorded Python (PHASE=N) +make bench # all-core speed of ported functions against the recorded Python (PHASE=N) make test # pytest only make lint # ruff check make type # ty check (not mypy) @@ -65,10 +65,10 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - `mul_add` is one instruction only on a CPU with FMA; the release baseline has none, so a loop that uses it takes `#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))]`. `mul_add` is exactly fused on every path, so the bits never depend on the CPU. - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). -- **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. +- **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. - `negpy_core` holds every CPU kernel; NegPy has no Numba. Bindings release the GIL through `py::detach`, which runs the work on one thread when "Multi-core CPU Rendering" is off (`kernel/system/parallel.py`). - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. -- **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower on one thread). Both are local tools, not CI. +- **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. ### Pipeline diff --git a/negpy_core/src/histogram.rs b/negpy_core/src/histogram.rs index 4be798f23..027919fb0 100644 --- a/negpy_core/src/histogram.rs +++ b/negpy_core/src/histogram.rs @@ -189,16 +189,18 @@ fn count_rows( n: usize, count: impl Fn(&[T], &mut [u32]) + Sync, ) -> Vec { - s.row_starts() - .par_iter() - .fold( - || (vec![0u32; LANES * n], Vec::new()), - |(mut counts, mut buf), &y| { + // One chunk of rows per thread: rayon's fold makes an accumulator per split job, and each + // holds lanes of counters for every cell. + let rows = s.row_starts(); + let chunk = rows.len().div_ceil(rayon::current_num_threads()).max(1); + rows.par_chunks(chunk) + .map(|ys| { + let (mut counts, mut buf) = (vec![0u32; LANES * n], Vec::new()); + for &y in ys { count(s.rgb_row(y, &mut buf), &mut counts); - (counts, buf) - }, - ) - .map(|(counts, _)| fold_lanes(&counts)) + } + fold_lanes(&counts) + }) .reduce( || vec![0u64; n], |mut a, b| { diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs index f10690ceb..69d9b44db 100644 --- a/negpy_core/src/lab.rs +++ b/negpy_core/src/lab.rs @@ -253,19 +253,21 @@ const CLAHE_BINS: usize = 256; fn clahe_cdfs(bins: &[u8], (h, w): (usize, usize), clip_limit: f32) -> Vec<[f32; CLAHE_BINS]> { let (tsy, tsx) = (h.div_ceil(CLAHE_GRID), w.div_ceil(CLAHE_GRID)); let tiles = CLAHE_GRID * CLAHE_GRID; + // One chunk of rows per thread: rayon's fold would make a histogram per split job. + let chunk = h.div_ceil(rayon::current_num_threads()).max(1); let hist = bins - .par_chunks(w) + .par_chunks(chunk * w) .enumerate() - .fold( - || vec![0i64; tiles * CLAHE_BINS], - |mut acc, (y, row)| { - let base = (y / tsy) * CLAHE_GRID; + .map(|(ci, block)| { + let mut acc = vec![0i64; tiles * CLAHE_BINS]; + for (dy, row) in block.chunks(w).enumerate() { + let base = ((ci * chunk + dy) / tsy) * CLAHE_GRID; for (x, &b) in row.iter().enumerate() { acc[(base + x / tsx) * CLAHE_BINS + b as usize] += 1; } - acc - }, - ) + } + acc + }) .reduce( || vec![0i64; tiles * CLAHE_BINS], |mut a, b| { diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index ac8519803..fca415d75 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -54,13 +54,13 @@ fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { .collect(); } - // One histogram per thread-sized block: each costs a megabyte to clear and merge. Four - // interleaved u32 lanes, because on a smooth image neighbours share a bucket and one counter - // would serialize on its own increments. + // One histogram per thread-sized block, and at least a million values per block: each costs a + // megabyte to clear and merge. Four interleaved u32 lanes, because on a smooth image + // neighbours share a bucket and one counter would serialize on its own increments. let block = values .len() .div_ceil(rayon::current_num_threads()) - .max(CHUNK); + .max(1 << 20); let hist = values .par_chunks(block) .map(|chunk| { diff --git a/scripts/replay.py b/scripts/replay.py index bee9051c8..ac226d9be 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -2,9 +2,11 @@ record FILE... run each frame through the preview load, the CPU pipeline, Square Before Crop and the Auto-crop All Frames evidence, then resolve the files as one roll; save every - call of a recorded function: inputs, result and its time on one thread, called alone. + call of a recorded function: inputs, result and its time on all cores and on one, + called alone. replay call the current code with each saved input and compare the result bit for bit. - bench time the current code on one thread against the saved time. + bench time the current code on all cores against the saved time (one core against one + for a recording that has no all-core time). Record on main just before a group is ported, so the corpus holds the behavior users have now. The corpus lives in $NEGPY_CORPUS (default ~/negpy-corpus), on a local disk that every worktree reads. @@ -23,17 +25,8 @@ import time from pathlib import Path -if len(sys.argv) > 1 and sys.argv[1] in ("record", "bench"): - # One thread on both sides: the speed gate is per core. - os.environ["RAYON_NUM_THREADS"] = "1" - import numpy as np # noqa: E402 -if len(sys.argv) > 1 and sys.argv[1] in ("record", "bench"): - import cv2 # noqa: E402 - - cv2.setNumThreads(1) - ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from core_manifest import load as load_manifest # noqa: E402 @@ -69,7 +62,17 @@ def _targets(phases: list[int] | None, statuses: tuple[str, ...]) -> list[str]: return sorted(k for k, v in manifest.items() if v["status"] in statuses and (not phases or v["phase"] in phases)) -def _time(fn, args, kwargs) -> float: +def _threads(all_cores: bool) -> None: + """Every core, as users run the app, or one, for the per-core figure.""" + import cv2 + import negpy_core + + cv2.setNumThreads(-1 if all_cores else 1) + negpy_core.set_parallel(all_cores) + + +def _time(fn, args, kwargs, all_cores: bool = True) -> float: + _threads(all_cores) best = float("inf") for _ in range(TIMING_REPEATS): a, kw = copy.deepcopy((args, kwargs)) @@ -117,7 +120,9 @@ def wrapper(*args, **kwargs): payload = pickle.dumps({"args": inputs[0], "kwargs": inputs[1], "result": result, "frame": self.frame}, protocol=5) if len(payload) <= MAX_CALL_BYTES: record = pickle.loads(payload) - record["seconds"] = _time(fn, inputs[0], inputs[1]) + record["seconds_all"] = _time(fn, inputs[0], inputs[1]) + record["seconds"] = _time(fn, inputs[0], inputs[1], all_cores=False) + _threads(True) folder = _folder(key) folder.mkdir(parents=True, exist_ok=True) (folder / f"{self.saved[key]:04d}.pkl").write_bytes(pickle.dumps(record, protocol=5)) @@ -291,7 +296,10 @@ def bench(phases: list[int] | None) -> int: ratios = [] for f in files: rec = pickle.loads(f.read_bytes()) - ratios.append(_time(fn, rec["args"], rec["kwargs"]) / max(rec["seconds"], 1e-9)) + # The gate is all cores, as users run the app; older recordings timed one. + all_cores = "seconds_all" in rec + saved = rec["seconds_all"] if all_cores else rec["seconds"] + ratios.append(_time(fn, rec["args"], rec["kwargs"], all_cores) / max(saved, 1e-9)) ratio = statistics.median(ratios) slower += ratio > 1.0 print(f"{'SLOWER' if ratio > 1.0 else 'ok':6s} {ratio:6.3f}x {key}") From b9daf2fc2b8ebf55da7254137c1736cba6d304e7 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 13:17:05 +0200 Subject: [PATCH 084/133] ci: run on mz/core on demand only Pushes to the integration branch no longer start CI; run it with gh workflow run ci.yml --ref mz/core. --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 323cffee4..3811b6054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,11 @@ name: CI on: push: - branches: [ main, mz/core ] + branches: [ main ] pull_request: - branches: [ main, mz/core ] + branches: [ main ] + # mz/core runs on demand: gh workflow run ci.yml --ref mz/core + workflow_dispatch: jobs: test: From cd13d78b0cd04e74eac4e5c3060585bb759e9804 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 13:22:06 +0200 Subject: [PATCH 085/133] refactor: remove Multi-core CPU Rendering; negpy_core always uses every core The setting existed for Numba's crash on macOS, which is gone. The Preferences toggle, the cpu_parallel global setting and override.toml key, kernel/system/parallel.py and negpy_core.set_parallel go; bindings call py.detach directly. An override.toml that still sets cpu_parallel is ignored. The replay tool times all cores only. Guide and translations updated. --- CLAUDE.md | 2 +- docs/USER_GUIDE.md | 2 - negpy/desktop/main.py | 5 - .../view/widgets/preferences_dialog.py | 16 +-- negpy/domain/types.py | 3 - negpy/kernel/system/override.py | 11 -- negpy/kernel/system/parallel.py | 48 -------- negpy/translations/USER_GUIDE.en.md | 2 - negpy/translations/USER_GUIDE.ja.md | 2 +- negpy/translations/USER_GUIDE.ko.md | 2 +- negpy/translations/USER_GUIDE.zh_CN.md | 2 +- negpy/translations/ja.json | 5 - negpy/translations/ko.json | 5 - negpy/translations/zh_CN.json | 5 - negpy_core/manifest.toml | 6 - negpy_core/src/lib.rs | 1 - negpy_core/src/py/altprocess.rs | 2 +- negpy_core/src/py/analysis.rs | 99 +++++++--------- negpy_core/src/py/bounds.rs | 4 +- negpy_core/src/py/color.rs | 2 +- negpy_core/src/py/components.rs | 14 +-- negpy_core/src/py/contours.rs | 2 +- negpy_core/src/py/convert.rs | 8 +- negpy_core/src/py/display.rs | 2 +- negpy_core/src/py/filter.rs | 2 +- negpy_core/src/py/finish.rs | 2 +- negpy_core/src/py/gray.rs | 14 +-- negpy_core/src/py/hdr.rs | 6 +- negpy_core/src/py/histogram.rs | 16 +-- negpy_core/src/py/lab.rs | 20 ++-- negpy_core/src/py/meter.rs | 14 +-- negpy_core/src/py/mod.rs | 32 ----- negpy_core/src/py/morph.rs | 2 +- negpy_core/src/py/print_curve.rs | 2 +- negpy_core/src/py/resize.rs | 23 ++-- negpy_core/src/py/retouch.rs | 12 +- negpy_core/src/py/sensor.rs | 10 +- negpy_core/src/py/skew.rs | 4 +- negpy_core/src/py/stats.rs | 2 +- negpy_core/src/py/toning.rs | 2 +- negpy_core/src/py/warp.rs | 2 +- scripts/replay.py | 29 ++--- tests/test_lab_row_blocks.py | 5 +- tests/test_parallel_dispatch.py | 109 ------------------ tests/test_parallel_gate.py | 19 --- 45 files changed, 128 insertions(+), 449 deletions(-) delete mode 100644 negpy/kernel/system/parallel.py delete mode 100644 tests/test_parallel_dispatch.py delete mode 100644 tests/test_parallel_gate.py diff --git a/CLAUDE.md b/CLAUDE.md index 6cb3e324d..9af613cb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. -- `negpy_core` holds every CPU kernel; NegPy has no Numba. Bindings release the GIL through `py::detach`, which runs the work on one thread when "Multi-core CPU Rendering" is off (`kernel/system/parallel.py`). +- `negpy_core` holds every CPU kernel; NegPy has no Numba. Bindings release the GIL with `py.detach` and run on every core: the app has no single-core mode. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a45f1b671..6ff2923be 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -838,7 +838,6 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. @@ -861,7 +860,6 @@ If NegPy crashes at launch or draws wrongly, edit `Documents/NegPy/override.toml | `display.qt_rhi_backend` | `"auto"`, `"vulkan"`, `"d3d12"`, `"metal"`, `"opengl"`, `"software"` | How the window is drawn. | | `display.qt_platform` | `"auto"`, `"xcb"`, `"wayland"` | Window system (Linux only). | | `performance.force_hq_preview` | `true` / `false` | Turns HQ Preview on or off. | -| `performance.cpu_parallel` | `true` / `false` | Multi-core CPU Rendering. | | `logging.level` | `"debug"`, `"info"`, `"warning"`, `"error"` | How much NegPy logs. Use `"debug"` for a bug report. | The memory rows of Preferences can be set here too (`max_texture_size`, `preview_render_size`, `preview_cache_max_bytes`, `preview_cache_max_entries`, `preview_cache_max_full_res_entries`, `render_memo_max_entries`). diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 70f58ac0a..54c795fd5 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -35,7 +35,6 @@ from negpy.kernel.system.override import apply as apply_override from negpy.kernel.system.override import apply_stored as apply_stored_override from negpy.kernel.system.override import load_or_create as load_override -from negpy.kernel.system.parallel import configure_cpu_parallel, resolve_cpu_parallel from negpy.kernel.system.paths import get_resource_path logger = get_logger(__name__) @@ -265,10 +264,6 @@ def main() -> None: if override_cfg.low_vram_export_tiling is None: APP_CONFIG.low_vram_export_tiling = bool(repo.get_global_setting("low_vram_export_tiling", False)) - # Multi-core CPU rendering: override.toml, then the saved setting, then the default (on). - stored_parallel = repo.get_global_setting("cpu_parallel", None) - configure_cpu_parallel(resolve_cpu_parallel(APP_CONFIG.cpu_parallel, None if stored_parallel is None else bool(stored_parallel))) - # Resolve flat-field gains by profile id from the on-disk store, which keeps the numpy # logic layer free of any storage dependency, then migrate any legacy DB-backed profiles # into that store. diff --git a/negpy/desktop/view/widgets/preferences_dialog.py b/negpy/desktop/view/widgets/preferences_dialog.py index ad824891e..063c4db66 100644 --- a/negpy/desktop/view/widgets/preferences_dialog.py +++ b/negpy/desktop/view/widgets/preferences_dialog.py @@ -34,7 +34,6 @@ load_or_create, toml_pinned_keys, ) -from negpy.kernel.system.parallel import parallel_enabled, set_parallel_enabled from negpy.services.assets import semantic_model UI_SCALES: tuple[int, ...] = (80, 90, 100, 110, 120) @@ -326,14 +325,7 @@ def hint(widget_or_text, kind: str = "muted") -> None: self.session.state.gpu_enabled and self._gpu_available, tr("Render the pipeline on the GPU"), ) - self.parallel_box = labeled_toggle( - "mdi6.cpu-64-bit", - tr("Multi-core CPU Rendering"), - parallel_enabled(), - tr("Render on all CPU cores. When off, CPU rendering uses one core."), - ) - self.parallel_box.toggled.connect(self._on_parallel_changed) - self._add_toggles(grid, row, self.gpu_box, self.parallel_box) + self._add_toggles(grid, row, self.gpu_box) row += 1 if self._gpu_available: self.gpu_box.toggled.connect(self._on_gpu_changed) @@ -511,12 +503,6 @@ def _on_semantic_search_changed(self, checked: bool) -> None: if checked != self.session.state.semantic_search_enabled: self.session.set_semantic_search_enabled(checked) - def _on_parallel_changed(self, checked: bool) -> None: - """Takes effect from the next negpy_core call; no restart.""" - set_parallel_enabled(bool(checked)) - self.repo.save_global_setting("cpu_parallel", bool(checked)) - self.controller.set_status(tr("Multi-core CPU rendering on") if checked else tr("Multi-core CPU rendering off"), 5000) - def _on_low_vram_tiling_changed(self, checked: bool) -> None: APP_CONFIG.low_vram_export_tiling = bool(checked) self.repo.save_global_setting("low_vram_export_tiling", bool(checked)) diff --git a/negpy/domain/types.py b/negpy/domain/types.py index 87892797d..9cbb0b4f4 100644 --- a/negpy/domain/types.py +++ b/negpy/domain/types.py @@ -43,9 +43,6 @@ class AppConfig: override_toml_path: str = "" max_texture_size: int | None = None force_hq_preview: bool | None = None - # Multi-core CPU rendering. None = the default, on. - # True/False = an explicit override.toml choice. - cpu_parallel: bool | None = None # Smaller tiles and no readback pipelining in the tiled export path, at the cost of # export speed. Off by default -- a user opts in from Preferences or override.toml # when their specific GPU (typically an older/weaker integrated one) needs it. diff --git a/negpy/kernel/system/override.py b/negpy/kernel/system/override.py index dc218610e..dca8e9d8b 100644 --- a/negpy/kernel/system/override.py +++ b/negpy/kernel/system/override.py @@ -53,9 +53,6 @@ # Override HQ preview on startup. Uncomment to force a value. # force_hq_preview = false -# Multi-core CPU rendering. Defaults to true. Uncomment to force. -# cpu_parallel = false - # Smaller tiles and no readback pipelining during export, at some cost to export speed. # Off by default. Turn on if exporting crashes on your GPU (typically an older or # memory-constrained integrated one) -- also available in Preferences. @@ -103,7 +100,6 @@ class OverrideConfig: qt_rhi_backend: str = "auto" qt_platform: str = "auto" force_hq_preview: bool | None = None - cpu_parallel: bool | None = None low_vram_export_tiling: bool | None = None max_texture_size: int | None = None preview_render_size: int | None = None @@ -169,9 +165,6 @@ def _parse(data: dict) -> OverrideConfig: raw_hq = performance.get("force_hq_preview") force_hq: bool | None = bool(raw_hq) if isinstance(raw_hq, bool) else None - raw_par = performance.get("cpu_parallel") - cpu_parallel: bool | None = bool(raw_par) if isinstance(raw_par, bool) else None - raw_low_vram = performance.get("low_vram_export_tiling") low_vram_tiling: bool | None = bool(raw_low_vram) if isinstance(raw_low_vram, bool) else None @@ -202,7 +195,6 @@ def _parse(data: dict) -> OverrideConfig: qt_rhi_backend=qt_rhi, qt_platform=qt_platform, force_hq_preview=force_hq, - cpu_parallel=cpu_parallel, low_vram_export_tiling=low_vram_tiling, max_texture_size=max_tex, preview_render_size=prev_size, @@ -267,9 +259,6 @@ def apply(cfg: OverrideConfig, app_config: AppConfig) -> None: if cfg.force_hq_preview is not None: app_config.force_hq_preview = cfg.force_hq_preview - if cfg.cpu_parallel is not None: - app_config.cpu_parallel = cfg.cpu_parallel - if cfg.low_vram_export_tiling is not None: app_config.low_vram_export_tiling = cfg.low_vram_export_tiling diff --git a/negpy/kernel/system/parallel.py b/negpy/kernel/system/parallel.py deleted file mode 100644 index 97d2b8f46..000000000 --- a/negpy/kernel/system/parallel.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -The "Multi-core CPU Rendering" setting: negpy_core runs its kernels on every core, or on one -thread when the setting is off. It takes effect from the next call. - -Default: on. Override with `cpu_parallel = true/false` under -[performance] in override.toml. -""" - -from typing import Optional - -import negpy_core - -from negpy.kernel.system.logging import get_logger - -logger = get_logger(__name__) - - -def default_cpu_parallel() -> bool: - """Multi-core is on unless the user or override.toml turns it off.""" - return True - - -_parallel_enabled: bool = default_cpu_parallel() -negpy_core.set_parallel(_parallel_enabled) - - -def parallel_enabled() -> bool: - return _parallel_enabled - - -def set_parallel_enabled(enabled: bool) -> None: - global _parallel_enabled - _parallel_enabled = bool(enabled) - negpy_core.set_parallel(_parallel_enabled) - - -def resolve_cpu_parallel(file_override: Optional[bool], stored: Optional[bool]) -> Optional[bool]: - """Which answer wins: override.toml, then the saved setting, then the default (on). - None at every level means the default.""" - if file_override is not None: - return file_override - return stored - - -def configure_cpu_parallel(override: Optional[bool]) -> None: - """Apply the startup policy: explicit override wins, else the platform default.""" - set_parallel_enabled(default_cpu_parallel() if override is None else override) - logger.info("CPU multi-core rendering: %s (override=%s)", "enabled" if _parallel_enabled else "disabled", override) diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index a45f1b671..6ff2923be 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -838,7 +838,6 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. When off, CPU rendering uses one core. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. @@ -861,7 +860,6 @@ If NegPy crashes at launch or draws wrongly, edit `Documents/NegPy/override.toml | `display.qt_rhi_backend` | `"auto"`, `"vulkan"`, `"d3d12"`, `"metal"`, `"opengl"`, `"software"` | How the window is drawn. | | `display.qt_platform` | `"auto"`, `"xcb"`, `"wayland"` | Window system (Linux only). | | `performance.force_hq_preview` | `true` / `false` | Turns HQ Preview on or off. | -| `performance.cpu_parallel` | `true` / `false` | Multi-core CPU Rendering. | | `logging.level` | `"debug"`, `"info"`, `"warning"`, `"error"` | How much NegPy logs. Use `"debug"` for a bug report. | The memory rows of Preferences can be set here too (`max_texture_size`, `preview_render_size`, `preview_cache_max_bytes`, `preview_cache_max_entries`, `preview_cache_max_full_res_entries`, `render_memo_max_entries`). diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 47cfd9fb5..9c3f8281e 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -627,5 +627,5 @@ Scanlight の接続中に表示されます。プリセットは、一種類の - 言語の初期値は「システム設定」です。英語、簡体字中国語、日本語、韓国語を選択でき、再起動後に有効になります。未翻訳のパネルガイドは英語で表示します。 - インターフェースは 80% ~ 120% ズームし、再起動後に有効になります。キャンバスの背景は黒、ダークグレー、ミディアムグレー、または白を選択します。キャンバスはツールバーが画像を覆うように埋め込まれます。ズームおよび逆スクロール ホイール コントロールの切り替えとズーム動作は保持されます。 - ショートカット キーのカスタマイズ、ツールバーの編集、パネル レイアウト管理操作の入り口のリセット、および色覚選択はさまざまなマーカーの色を区別するために使用されます。 -- GPU アクセラレーションが失敗した場合は CPU にフォールバックします。マルチコアCPUはすべてのコアを使い、オフでは 1 コアを使います。セマンティック検索の最初のダウンロード モデル。プレビュー サイズ、キャッシュ、高品質バッファリング、テクスチャ キャップによってメモリが制御され、起動設定を再起動する必要があることを示す行が表示されます。 +- GPU アクセラレーションが失敗した場合は CPU にフォールバックします。セマンティック検索の最初のダウンロード モデル。プレビュー サイズ、キャッシュ、高品質バッファリング、テクスチャ キャップによってメモリが制御され、起動設定を再起動する必要があることを示す行が表示されます。 - 継承と永続化の設定は、新しいファイルの継承を制御します。データベースを管理して、使用状況を表示したり、編集、ギャラリー、キャッシュをクリアしたりできます。 override.toml の仕様は設定よりも優先されます。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 93f9d99d5..c5db136f1 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -627,5 +627,5 @@ Scanlight가 연결되어 있을 때 보입니다. 사전 설정은 한 종류 - 언어 기본값은 시스템 기본값입니다. 영어, 중국어 간체, 일본어와 한국어를 선택할 수 있으며 다시 시작하면 적용됩니다. 번역이 없는 패널 도움말은 영어로 표시합니다. - 인터페이스는 80%~120% 확대되고 다시 시작한 후에 적용됩니다. 캔버스 배경은 검정색, 어두운 회색, 중간 회색 또는 흰색을 선택합니다. 도구 모음이 이미지를 덮도록 캔버스가 잠겨 있습니다. 확대/축소 및 역방향 스크롤 휠 제어 전환 및 확대/축소 동작은 유지됩니다. - 단축키 사용자 정의, 도구 모음 편집 및 패널 레이아웃 관리 작업 입구 재설정 및 컬러 비전 선택을 사용하여 다양한 마커의 색상을 구분합니다. -- GPU 가속이 실패하면 CPU로 대체됩니다. 멀티코어 CPU는 모든 코어를 사용하고, 끄면 코어 하나를 사용합니다. 의미 검색 첫 번째 다운로드 모델. 미리보기 크기, 캐시, 고품질 버퍼링 및 텍스처 캡이 메모리를 제어하며 시작 설정을 나타내는 줄을 다시 시작해야 합니다. +- GPU 가속이 실패하면 CPU로 대체됩니다. 의미 검색 첫 번째 다운로드 모델. 미리보기 크기, 캐시, 고품질 버퍼링 및 텍스처 캡이 메모리를 제어하며 시작 설정을 나타내는 줄을 다시 시작해야 합니다. - 상속 및 지속성 설정은 새 파일의 상속을 제어합니다. 데이터베이스를 관리하여 사용량을 보거나 편집 내용, 갤러리 및 캐시를 지울 수 있습니다. override.toml의 사양은 기본 설정보다 우선합니다. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index d5af69c7b..0fd57f56d 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -627,5 +627,5 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - 语言默认跟随系统,可选英文、简体中文、日文或韩文,重启后生效;缺失的面板帮助回退英文。 - 界面缩放 80%–120%,重启生效;画布背景选择黑、深灰、中灰或白;沉浸画布使工具栏覆盖图像;保留缩放与反向滚轮控制切换和缩放行为。 - 自定义快捷键、编辑工具栏与重置面板布局管理操作入口,色觉选择用于区分各类标记的配色。 -- GPU 加速失败时回退 CPU;多核 CPU 使用全部核心,关闭时只用一个核心。语义搜索首次下载模型。预览尺寸、缓存、高质量缓冲与纹理上限控制内存,标明启动设置的行需重启。 +- GPU 加速失败时回退 CPU;语义搜索首次下载模型。预览尺寸、缓存、高质量缓冲与纹理上限控制内存,标明启动设置的行需重启。 - 沿用设置和持久设置控制新文件继承;管理数据库可查看占用或清除编辑、图库与缓存。override.toml 的指定项优先于偏好设置。 diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 3570c1752..86350a86c 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -1524,9 +1524,6 @@ "Moves the white point: positive brightens the print, and negative brings back highlights.": "白点を動かします: 正の値でプリントが明るくなり、負の値でハイライトが戻ります。", "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi では多重露出とマルチパスを使用できません。", "Multi-Pass": "マルチパス", - "Multi-core CPU Rendering": "マルチコアCPUレンダリング", - "Multi-core CPU rendering off": "マルチコアCPUレンダリングオフ", - "Multi-core CPU rendering on": "マルチコアCPUレンダリングオン", "Multiply by the as-shot WB gains before writing": "書き込み前にショット時の WB ゲインを乗算します", "Multiply the scan values so that they fill more of the file's 16-bit range.": "スキャンの値を乗算し、ファイルの 16 ビットの範囲をより広く使います。", "My Gear": "マイ機材", @@ -1982,9 +1979,7 @@ "Render Exposure ↑/↓": "レンダリング露出 ↑/↓", "Render exposure down": "レンダリング露出ダウン", "Render exposure up": "レンダリング露出アップ", - "Render on all CPU cores, an experimental mode that can close the app without warning.": "すべての CPU コアでレンダリングします。実験的なモードで、警告なしにアプリが終了することがあります。", "Render out-of-date thumbnails again in the background": "古いサムネイルをバックグラウンドで再レンダリングします", - "Render on all CPU cores. When off, CPU rendering uses one core.": "すべての CPU コアでレンダリングします。オフのときは 1 コアで CPU レンダリングします。", "Render the full-resolution scan instead of a downscaled preview": "縮小プレビューの代わりにフル解像度のスキャンをレンダリングします。", "Render the pipeline on the GPU": "GPU でパイプラインをレンダリングする", "Render the source frame before syncing bounds": "境界を同期する前にソースフレームをレンダリングします", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index d1c6592ce..1d51fb30e 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -1524,9 +1524,6 @@ "Moves the white point: positive brightens the print, and negative brings back highlights.": "흰점을 옮깁니다: 양수는 인화를 밝게 하고, 음수는 밝은 영역을 되살립니다.", "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi에서는 다중 노출과 멀티패스를 사용할 수 없습니다.", "Multi-Pass": "멀티패스", - "Multi-core CPU Rendering": "멀티코어 CPU 렌더링", - "Multi-core CPU rendering off": "멀티코어 CPU 렌더링 꺼짐", - "Multi-core CPU rendering on": "멀티코어 CPU 렌더링 켜짐", "Multiply by the as-shot WB gains before writing": "쓰기 전에 실제 WB 게인을 곱하세요.", "Multiply the scan values so that they fill more of the file's 16-bit range.": "스캔 값을 곱해 파일의 16비트 범위를 더 많이 채웁니다.", "My Gear": "내 장비", @@ -1982,9 +1979,7 @@ "Render Exposure ↑/↓": "렌더 노출 ↑/↓", "Render exposure down": "렌더 노출 감소", "Render exposure up": "렌더 노출 증가", - "Render on all CPU cores, an experimental mode that can close the app without warning.": "모든 CPU 코어로 렌더링합니다. 실험적 모드로, 앱이 경고 없이 닫힐 수 있습니다.", "Render out-of-date thumbnails again in the background": "오래된 썸네일을 백그라운드에서 다시 렌더링합니다", - "Render on all CPU cores. When off, CPU rendering uses one core.": "모든 CPU 코어로 렌더링합니다. 끄면 CPU 렌더링에 코어 하나만 사용합니다.", "Render the full-resolution scan instead of a downscaled preview": "축소된 미리보기 대신 전체 해상도 스캔을 렌더링합니다.", "Render the pipeline on the GPU": "GPU에서 파이프라인 렌더링", "Render the source frame before syncing bounds": "경계를 동기화하기 전에 소스 프레임 렌더링", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index 10f72808b..39cbfb511 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -1524,9 +1524,6 @@ "Moves the white point: positive brightens the print, and negative brings back highlights.": "移动白点:正值使照片变亮,负值找回高光。", "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi 下无法使用多重曝光和多次扫描。", "Multi-Pass": "多次扫描", - "Multi-core CPU Rendering": "多核 CPU 渲染", - "Multi-core CPU rendering off": "多核 CPU 渲染已关闭", - "Multi-core CPU rendering on": "多核 CPU 渲染已开启", "Multiply by the as-shot WB gains before writing": "写入前应用拍摄时的白平衡增益", "Multiply the scan values so that they fill more of the file's 16-bit range.": "放大扫描数值,使其占用文件 16 位范围中的更多部分。", "My Gear": "个人器材", @@ -1982,9 +1979,7 @@ "Render Exposure ↑/↓": "渲染曝光 ↑/↓", "Render exposure down": "降低渲染曝光", "Render exposure up": "提高渲染曝光", - "Render on all CPU cores, an experimental mode that can close the app without warning.": "使用所有 CPU 核心渲染,此模式为实验性质,可能导致应用无提示关闭。", "Render out-of-date thumbnails again in the background": "在后台重新渲染过期的缩略图", - "Render on all CPU cores. When off, CPU rendering uses one core.": "使用全部 CPU 核心渲染。关闭时,CPU 渲染只用一个核心。", "Render the full-resolution scan instead of a downscaled preview": "渲染全分辨率扫描,而非缩小的预览", "Render the pipeline on the GPU": "使用 GPU 渲染管线", "Render the source frame before syncing bounds": "同步边界前先渲染源画面", diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 6e15dcf06..6cb91a6b1 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2532,9 +2532,3 @@ phase = 9 status = "stays" calls = ["cv2.cvtColor", "cv2.imencode"] depends = [] - -["kernel/system/parallel.py::set_parallel_enabled"] -phase = 9 -status = "todo" -calls = ["negpy_core.set_parallel"] -depends = [] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 4132f46c0..ab0af1638 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -45,7 +45,6 @@ mod py; #[cfg(feature = "python")] #[pyo3::pymodule] fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { - py::register(m)?; py::stats::register(m)?; py::resize::register(m)?; py::filter::register(m)?; diff --git a/negpy_core/src/py/altprocess.rs b/negpy_core/src/py/altprocess.rs index 45e4850e1..e71dc8ab8 100644 --- a/negpy_core/src/py/altprocess.rs +++ b/negpy_core/src/py/altprocess.rs @@ -27,7 +27,7 @@ fn run<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || f(src, (h, w), dst)) + py.detach(|| f(src, (h, w), dst)) .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 00ae2edca..b651e25c5 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -38,7 +38,7 @@ fn luminance<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || analysis::luminance(data, dst)); + py.detach(|| analysis::luminance(data, dst)); } Ok(out) } @@ -57,7 +57,7 @@ fn to_log_density<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || analysis::to_log_density(data, dst)); + py.detach(|| analysis::to_log_density(data, dst)); } Ok(out) } @@ -76,7 +76,7 @@ fn normalize_to_uint8<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || analysis::normalize_to_uint8(data, dst)); + py.detach(|| analysis::normalize_to_uint8(data, dst)); } Ok(out) } @@ -95,9 +95,10 @@ fn detection_luma<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || analysis::detection_luma(data, dst)).ok_or_else(|| { - pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") - })?; + py.detach(|| analysis::detection_luma(data, dst)) + .ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") + })?; } Ok(out) } @@ -125,7 +126,7 @@ fn base_blocks<'py>( .iter() .map(|&v| v as u8) .collect(); - let found = super::detach(py, || analysis::base_blocks(data, &mask, h, w)); + let found = py.detach(|| analysis::base_blocks(data, &mask, h, w)); found .map(|rows| { let flat: Vec> = rows.iter().map(|r| r.to_vec()).collect(); @@ -147,9 +148,7 @@ fn find_rebate_level( return Err(PyValueError::new_err("find_rebate_level needs a 2-D array")); }; let data = slice(&lum)?; - Ok(super::detach(py, || { - analysis::find_rebate_level(data, h, w, film_roi, bed) - })) + Ok(py.detach(|| analysis::find_rebate_level(data, h, w, film_roi, bed))) } /// `_walk_border_depth(lines, limit, bridge, relative_step)` for an L x N float32 array. @@ -165,10 +164,9 @@ fn walk_border_depth<'py>( return Err(PyValueError::new_err("walk_border_depth needs a 2-D array")); }; let data = slice(&lines)?; - let out = super::detach(py, || { - analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step) - }) - .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; + let out = py + .detach(|| analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step)) + .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; Ok(numpy::PyArray1::from_vec(py, out)) } @@ -185,7 +183,7 @@ fn measure_film_edges( )); }; let data = slice(&lum)?; - let [t, b, l, r] = super::detach(py, || analysis::measure_film_edges(data, h, w, film_roi)); + let [t, b, l, r] = py.detach(|| analysis::measure_film_edges(data, h, w, film_roi)); Ok((t, b, l, r)) } @@ -200,9 +198,7 @@ fn trim_film_edges( return Err(PyValueError::new_err("trim_film_edges needs a 2-D array")); }; let data = slice(&lum)?; - Ok(super::detach(py, || { - analysis::trim_film_edges(data, h, w, film_roi) - })) + Ok(py.detach(|| analysis::trim_film_edges(data, h, w, film_roi))) } /// `measure_film_border(lum, film_roi)`: (top, bottom, left, right) border fractions. @@ -218,7 +214,7 @@ fn measure_film_border( )); }; let data = slice(&lum)?; - let [t, b, l, r] = super::detach(py, || analysis::measure_film_border(data, h, w, film_roi)); + let [t, b, l, r] = py.detach(|| analysis::measure_film_border(data, h, w, film_roi)); Ok((t, b, l, r)) } @@ -235,9 +231,7 @@ fn roi_from_measured_border( )); }; let data = slice(&lum)?; - Ok(super::detach(py, || { - analysis::roi_from_measured_border(data, h, w, film_roi) - })) + Ok(py.detach(|| analysis::roi_from_measured_border(data, h, w, film_roi))) } /// `_smooth_signal(signal, window)` for a 1-D float32 signal. @@ -248,11 +242,13 @@ fn smooth_signal<'py>( window: usize, ) -> PyResult>> { let data = slice(&signal)?.to_vec(); - let out = super::detach(py, || analysis::smooth_signal(&data, window)).ok_or_else(|| { - PyValueError::new_err( - "smooth_signal covers a window of at most 31, no longer than the signal", - ) - })?; + let out = py + .detach(|| analysis::smooth_signal(&data, window)) + .ok_or_else(|| { + PyValueError::new_err( + "smooth_signal covers a window of at most 31, no longer than the signal", + ) + })?; Ok(numpy::PyArray1::from_vec(py, out)) } @@ -276,9 +272,7 @@ fn snap_edge_to_gradient( min_dominance, min_window_px, }; - Ok(super::detach(py, || { - analysis::snap_edge_to_gradient(data, idx, is_start, snap) - })) + Ok(py.detach(|| analysis::snap_edge_to_gradient(data, idx, is_start, snap))) } type Refined<'py> = ( @@ -300,9 +294,7 @@ fn refine_film_roi_by_tiers<'py>( )); }; let data = slice(&lum)?; - let found = super::detach(py, || { - analysis::refine_film_roi_by_tiers(data, h, w, film_roi) - }); + let found = py.detach(|| analysis::refine_film_roi_by_tiers(data, h, w, film_roi)); Ok(found.map(|r| { ( r.roi, @@ -328,9 +320,10 @@ fn refine_frame_bounds( } }; let data = slice(&image)?; - super::detach(py, || analysis::refine_frame_bounds(data, h, w, cn)).ok_or_else(|| { - PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") - }) + py.detach(|| analysis::refine_frame_bounds(data, h, w, cn)) + .ok_or_else(|| { + PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") + }) } type Gated<'py> = ( @@ -357,12 +350,11 @@ fn refine_roi_to_image<'py>( } }; let data = slice(&img)?; - let (roi, tiers) = super::detach(py, || { - analysis::refine_roi_to_image(data, h, w, cn, film_roi) - }) - .ok_or_else(|| { - PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") - })?; + let (roi, tiers) = py + .detach(|| analysis::refine_roi_to_image(data, h, w, cn, film_roi)) + .ok_or_else(|| { + PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") + })?; Ok(match tiers { Some(t) => ( roi, @@ -396,7 +388,7 @@ fn detect_film_bounds<'py>( } }; let data = slice(&img)?; - let d = super::detach(py, || crate::detect::detect_film_bounds(data, h, w, cn)); + let d = py.detach(|| crate::detect::detect_film_bounds(data, h, w, cn)); Ok(( d.consensus.map(consensus_fields), numpy::PyArray1::from_vec(py, d.profile), @@ -425,9 +417,7 @@ fn normalize_log_image<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { - analysis::normalize_log_image(data, floors, ceils, dst) - }); + py.detach(|| analysis::normalize_log_image(data, floors, ceils, dst)); } Ok(out) } @@ -448,7 +438,7 @@ fn block_median_grid<'py>( return Err(PyValueError::new_err("block size must be positive")); } let data = slice(&img)?; - let grid = super::detach(py, || analysis::block_median_grid(data, (h, w, c), b)); + let grid = py.detach(|| analysis::block_median_grid(data, (h, w, c), b)); numpy::PyArray1::from_vec(py, grid).reshape(vec![h / b, w / b, c]) } @@ -463,9 +453,7 @@ fn threshold_box( return Err(PyValueError::new_err("threshold_box needs a 2-D array")); }; let data = slice(&lum)?; - Ok(super::detach(py, || { - analysis::threshold_box(data, h, w, threshold) - })) + Ok(py.detach(|| analysis::threshold_box(data, h, w, threshold))) } /// Batch autocrop's `_vertical_edge_profile` of an HxW or HxWxC (C >= 3) float32 image: @@ -485,7 +473,7 @@ fn batch_edge_profile<'py>( } }; let data = slice(&img)?; - let (profile, contrast) = super::detach(py, || analysis::batch_edge_profile(data, h, w, cn)); + let (profile, contrast) = py.detach(|| analysis::batch_edge_profile(data, h, w, cn)); Ok((numpy::PyArray1::from_vec(py, profile), contrast)) } @@ -505,9 +493,10 @@ fn film_and_picture( ) -> PyResult<(Option, analysis::Roi)> { let (h, w) = det_dims(&det)?; let data = slice(&det)?; - super::detach(py, || analysis::film_and_picture(data, h, w)).ok_or_else(|| { - PyValueError::new_err("film_and_picture: image too small for the Sobel path") - }) + py.detach(|| analysis::film_and_picture(data, h, w)) + .ok_or_else(|| { + PyValueError::new_err("film_and_picture: image too small for the Sobel path") + }) } /// `measure_base_log` of an HxWx3 float32 detection image: (r, g, b) log10 or None. @@ -518,7 +507,7 @@ fn measure_base_log( ) -> PyResult> { let (h, w) = det_dims(&det)?; let data = slice(&det)?; - Ok(super::detach(py, || analysis::measure_base_log(data, h, w))) + Ok(py.detach(|| analysis::measure_base_log(data, h, w))) } type ConsensusFields = ( diff --git a/negpy_core/src/py/bounds.rs b/negpy_core/src/py/bounds.rs index 90b4d0505..f82cea685 100644 --- a/negpy_core/src/py/bounds.rs +++ b/negpy_core/src/py/bounds.rs @@ -20,7 +20,7 @@ fn channel_percentiles( qs: Vec, ) -> PyResult<[Vec; 3]> { let g = grid_of(&grid)?; - Ok(super::detach(py, || bounds::channel_percentiles(g, &qs))) + Ok(py.detach(|| bounds::channel_percentiles(g, &qs))) } /// `_same_pixel_color_floor_refs` with its constants (chroma quantile, chroma cap, first-pass @@ -44,7 +44,7 @@ fn same_pixel_color_floor_refs( min_pixels, band_width, }; - Ok(super::detach(py, || { + Ok(py.detach(|| { bounds::same_pixel_color_floor_refs(g, luma_floors, luma_ceils, base, color_clip, c) })) } diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs index dbd92b23d..7639b5c91 100644 --- a/negpy_core/src/py/color.rs +++ b/negpy_core/src/py/color.rs @@ -21,7 +21,7 @@ fn filled<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || fill(data, dst)); + py.detach(|| fill(data, dst)); } Ok(out) } diff --git a/negpy_core/src/py/components.rs b/negpy_core/src/py/components.rs index 07d5e2623..f1ee29be5 100644 --- a/negpy_core/src/py/components.rs +++ b/negpy_core/src/py/components.rs @@ -29,9 +29,7 @@ fn connected_components_with_stats<'py>( let data = mask .as_slice() .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; - let c = super::detach(py, || { - components::connected_components(data, h, w, connectivity) - }); + let c = py.detach(|| components::connected_components(data, h, w, connectivity)); let labels = numpy::PyArray1::from_vec(py, c.labels).reshape(vec![h, w])?; let stats = numpy::PyArray2::from_vec2(py, &c.stats.iter().map(|s| s.to_vec()).collect::>()) @@ -74,7 +72,7 @@ fn distance_transform<'py>( } }; let (data, h, w) = mask_of(&mask)?; - let d = super::detach(py, || distance::distance_transform(data, h, w, metric)); + let d = py.detach(|| distance::distance_transform(data, h, w, metric)); numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) } @@ -90,9 +88,7 @@ fn distance_transform_labels<'py>( mask: PyReadonlyArrayDyn<'py, u8>, ) -> PyResult> { let (data, h, w) = mask_of(&mask)?; - let (d, l) = super::detach(py, || { - crate::distance::distance_transform_labels(data, h, w) - }); + let (d, l) = py.detach(|| crate::distance::distance_transform_labels(data, h, w)); Ok(( numpy::PyArray1::from_vec(py, d).reshape(vec![h, w])?, numpy::PyArray1::from_vec(py, l).reshape(vec![h, w])?, @@ -108,9 +104,7 @@ fn mask_to_score<'py>( floor: f64, ) -> PyResult>> { let (data, h, w) = mask_of(&mask)?; - let d = super::detach(py, || { - crate::distance::mask_to_score(data, h, w, pad, floor) - }); + let d = py.detach(|| crate::distance::mask_to_score(data, h, w, pad, floor)); numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) } diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index 3c291ca80..0ccd5c445 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -19,7 +19,7 @@ fn find_external_contours<'py>( let data = src .as_slice() .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; - let found = super::detach(py, || contours::find_external_contours(data, h, w)); + let found = py.detach(|| contours::find_external_contours(data, h, w)); let k = found.len(); let arrays: Vec>> = found .iter() diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs index 095101cb3..9b23bcd3b 100644 --- a/negpy_core/src/py/convert.rs +++ b/negpy_core/src/py/convert.rs @@ -37,7 +37,7 @@ fn filled<'py, T: numpy::Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || fill(dst)); + py.detach(|| fill(dst)); } Ok(out) } @@ -129,10 +129,10 @@ fn rgb_to_rgba_into( } let cn = s.dim().2; if let (Some(src), Some(dst)) = (s.as_slice(), d.as_slice_mut()) { - super::detach(py, || convert::rgb_to_rgba(src, cn, dst)); + py.detach(|| convert::rgb_to_rgba(src, cn, dst)); return Ok(()); } - super::detach(py, || { + py.detach(|| { for (srow, mut drow) in s.outer_iter().zip(d.outer_iter_mut()) { for (sp, mut dp) in srow.outer_iter().zip(drow.outer_iter_mut()) { for c in 0..3 { @@ -158,7 +158,7 @@ fn rgba_to_rgb_into( if oy + h > s.dim().0 || ox + w > s.dim().1 || s.dim().2 < 3 || dc < 3 { return Err(PyValueError::new_err("region outside the source")); } - super::detach(py, || { + py.detach(|| { for (y, mut drow) in d.outer_iter_mut().enumerate() { let srow = s.index_axis(numpy::ndarray::Axis(0), oy + y); // A padded readback has whole RGBA rows: copy them as slices when it does. diff --git a/negpy_core/src/py/display.rs b/negpy_core/src/py/display.rs index d2d995d8d..ff4cfeb67 100644 --- a/negpy_core/src/py/display.rs +++ b/negpy_core/src/py/display.rs @@ -40,7 +40,7 @@ fn filled<'py, T: numpy::Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || fill(dst)); + py.detach(|| fill(dst)); } Ok(out) } diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs index 3fa5d81a7..75e7631ac 100644 --- a/negpy_core/src/py/filter.rs +++ b/negpy_core/src/py/filter.rs @@ -48,7 +48,7 @@ fn filled<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || fill(dst)) + py.detach(|| fill(dst)) .ok_or_else(|| PyValueError::new_err("kernel shape not covered by negpy_core"))?; } Ok(out) diff --git a/negpy_core/src/py/finish.rs b/negpy_core/src/py/finish.rs index 5f19df765..e903951ed 100644 --- a/negpy_core/src/py/finish.rs +++ b/negpy_core/src/py/finish.rs @@ -47,7 +47,7 @@ fn apply_carrier<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || finish::apply_carrier(src, (h, w), &c, dst)); + py.detach(|| finish::apply_carrier(src, (h, w), &c, dst)); } Ok(out) } diff --git a/negpy_core/src/py/gray.rs b/negpy_core/src/py/gray.rs index a09eb3b6a..e72fdac95 100644 --- a/negpy_core/src/py/gray.rs +++ b/negpy_core/src/py/gray.rs @@ -35,7 +35,7 @@ fn bgr_to_gray<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || gray::bgr_to_gray(data, dst)); + py.detach(|| gray::bgr_to_gray(data, dst)); } Ok(out) } @@ -66,10 +66,8 @@ fn gaussian_blur_u8<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { - gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border) - }) - .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; + py.detach(|| gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border)) + .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; } Ok(out) } @@ -94,7 +92,7 @@ fn threshold_otsu<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { + py.detach(|| { let t = gray::otsu_threshold(data); gray::threshold_binary(data, dst, t, maxval, invert); t @@ -122,7 +120,7 @@ fn canny<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || gray::canny(data, dst, h, w, low, high)) + py.detach(|| gray::canny(data, dst, h, w, low, high)) .ok_or_else(|| PyValueError::new_err("invalid canny input"))?; } Ok(out) @@ -151,7 +149,7 @@ fn median_blur<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || crate::median::median_blur_u8(data, dst, h, w, ksize)) + py.detach(|| crate::median::median_blur_u8(data, dst, h, w, ksize)) .ok_or_else(|| PyValueError::new_err("ksize must be odd"))?; } Ok(out) diff --git a/negpy_core/src/py/hdr.rs b/negpy_core/src/py/hdr.rs index d287fbe33..6a1356ed5 100644 --- a/negpy_core/src/py/hdr.rs +++ b/negpy_core/src/py/hdr.rs @@ -27,7 +27,7 @@ fn count_clipped_rows( let dst = out .as_slice_mut() .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; - super::detach(py, || hdr::count_clipped_rows(v, (w, cn), saturation, dst)); + py.detach(|| hdr::count_clipped_rows(v, (w, cn), saturation, dst)); Ok(()) } @@ -54,9 +54,7 @@ fn hdr_accumulate( let d = den .as_slice_mut() .map_err(|_| PyValueError::new_err("needs contiguous sums"))?; - super::detach(py, || { - hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start)) - }); + py.detach(|| hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start))); Ok(()) } diff --git a/negpy_core/src/py/histogram.rs b/negpy_core/src/py/histogram.rs index 83165584c..d561b87c7 100644 --- a/negpy_core/src/py/histogram.rs +++ b/negpy_core/src/py/histogram.rs @@ -44,7 +44,7 @@ fn density_histogram<'py>( step: usize, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = super::detach(py, || histogram::density_histogram(s)); + let counts = py.detach(|| histogram::density_histogram(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![4, DENSITY_HIST_BINS]) } @@ -57,7 +57,7 @@ fn density_parade<'py>( step: usize, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = super::detach(py, || histogram::density_parade(s)); + let counts = py.detach(|| histogram::density_parade(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_BINS, WAVEFORM_COLS]) } @@ -79,11 +79,11 @@ fn output_histogram<'py>( ) -> PyResult> { let counts = if let Ok(a) = img.extract::>() { let s = frame(&a, step)?; - super::detach(py, || histogram::output_histogram_u8(s)) + py.detach(|| histogram::output_histogram_u8(s)) } else { let a = img.extract::>()?; let s = frame(&a, step)?; - super::detach(py, || histogram::output_histogram(s)) + py.detach(|| histogram::output_histogram(s)) }; numpy::PyArray1::from_vec(py, counts).reshape(vec![4, OUTPUT_HIST_BINS]) } @@ -96,7 +96,7 @@ fn color_histogram<'py>( step: usize, ) -> PyResult> { let s = frame(&img, step)?; - let counts = super::detach(py, || histogram::color_histogram(s)); + let counts = py.detach(|| histogram::color_histogram(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![COLOR_HIST_BINS; 3]) } @@ -116,7 +116,7 @@ fn waveform<'py>( _ => return Err(PyValueError::new_err(format!("unknown waveform {kind:?}"))), }; let s = frame(&img, step)?; - let counts = super::detach(py, || histogram::waveform(s, kind)); + let counts = py.detach(|| histogram::waveform(s, kind)); let shape = if kind == Waveform::Luma { vec![WAVEFORM_BINS, WAVEFORM_COLS] } else { @@ -135,7 +135,7 @@ fn scan_full_scale_columns<'py>( level: f32, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = super::detach(py, || histogram::scan_full_scale_columns(s, level)); + let counts = py.detach(|| histogram::scan_full_scale_columns(s, level)); numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_COLS]) } @@ -166,7 +166,7 @@ fn vectorscope_histogram<'py>( eps, kappa, ); - let counts = super::detach(py, || histogram::vectorscope_histogram(s, space, gamma)); + let counts = py.detach(|| histogram::vectorscope_histogram(s, space, gamma)); numpy::PyArray1::from_vec(py, counts).reshape(vec![SCOPE_HUE_BINS, SCOPE_CHROMA_BINS]) } diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index 077b1b298..ff4db12c1 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -40,10 +40,8 @@ fn usm_sharpen<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { - lab::usm_sharpen(src, (h, w), (gain, masking), k, spaces, dst) - }) - .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + py.detach(|| lab::usm_sharpen(src, (h, w), (gain, masking), k, spaces, dst)) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) } @@ -79,10 +77,8 @@ fn chroma_denoise<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { - lab::chroma_denoise(src, (h, w), sigma, k, spaces, dst) - }) - .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + py.detach(|| lab::chroma_denoise(src, (h, w), sigma, k, spaces, dst)) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) } @@ -121,7 +117,7 @@ fn glow_and_halation<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { + py.detach(|| { lab::glow_and_halation(src, (h, w), (glow, halation), (lum.into(), inv_gamma), dst) }) .ok_or_else(|| PyValueError::new_err("a bloom needs an odd kernel size"))?; @@ -159,7 +155,7 @@ fn clahe<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || lab::clahe(src, (h, w), strength, spaces, dst)); + py.detach(|| lab::clahe(src, (h, w), strength, spaces, dst)); } Ok(out) } @@ -197,7 +193,7 @@ fn rl_sharpen<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || lab::rl_sharpen(src, (h, w), ¶ms, k, dst)) + py.detach(|| lab::rl_sharpen(src, (h, w), ¶ms, k, dst)) .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) @@ -235,7 +231,7 @@ fn split_toning<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || lab::split_toning(src, tints, spaces, dst)); + py.detach(|| lab::split_toning(src, tints, spaces, dst)); } Ok(out) } diff --git a/negpy_core/src/py/meter.rs b/negpy_core/src/py/meter.rs index d82ab5415..d55c2ca27 100644 --- a/negpy_core/src/py/meter.rs +++ b/negpy_core/src/py/meter.rs @@ -52,9 +52,7 @@ fn textured_percentile( q: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(super::detach(py, || { - meter::percentile(&textured(g, Some(norm), a), q) - })) + Ok(py.detach(|| meter::percentile(&textured(g, Some(norm), a), q))) } /// The anchor meter's measurement over the grid's textured normalized luma. @@ -67,9 +65,7 @@ fn textured_anchor( clip: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(super::detach(py, || { - meter::anchor_measure(&textured(g, Some(norm), a), clip) - })) + Ok(py.detach(|| meter::anchor_measure(&textured(g, Some(norm), a), clip))) } /// The textural range of the grid's textured raw-log luma. @@ -81,9 +77,7 @@ fn textured_range( clip: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(super::detach(py, || { - meter::textural_range(&textured(g, None, a), clip) - })) + Ok(py.detach(|| meter::textural_range(&textured(g, None, a), clip))) } type Band = (f32, f32); @@ -153,7 +147,7 @@ fn neutral_axis( }; let f = [floors.0, floors.1, floors.2]; let e = [ceils.0, ceils.1, ceils.2]; - let axis = super::detach(py, || { + let axis = py.detach(|| { meter::neutral_axis( data, (f.map(|v| v as f32), e.map(|v| v as f32)), diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 59456dc43..5f2dc1c3c 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -22,35 +22,3 @@ pub mod skew; pub mod stats; pub mod toning; pub mod warp; - -use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, Ordering}; - -use pyo3::prelude::*; - -/// The "Multi-core CPU Rendering" setting: off runs every call on one worker thread. -static PARALLEL: AtomicBool = AtomicBool::new(true); - -/// Turns multi-core work on or off for the calls that start after it. -#[pyfunction] -fn set_parallel(enabled: bool) { - PARALLEL.store(enabled, Ordering::Relaxed); -} - -/// `py.detach(f)`, on the global rayon pool or, with multi-core off, on a pool of one thread -/// (the global pool if that one cannot start). -pub(crate) fn detach(py: Python<'_>, f: impl FnOnce() -> T + Send) -> T { - static SERIAL: OnceLock> = OnceLock::new(); - py.detach(|| { - let serial = - SERIAL.get_or_init(|| rayon::ThreadPoolBuilder::new().num_threads(1).build().ok()); - match serial { - Some(pool) if !PARALLEL.load(Ordering::Relaxed) => pool.install(f), - _ => f(), - } - }) -} - -pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(set_parallel, m)?) -} diff --git a/negpy_core/src/py/morph.rs b/negpy_core/src/py/morph.rs index 3c613d1ad..c51fe1b02 100644 --- a/negpy_core/src/py/morph.rs +++ b/negpy_core/src/py/morph.rs @@ -37,7 +37,7 @@ fn run<'py, T: Pixel + Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { + py.detach(|| { if kernel.mask.iter().all(|&v| v != 0) { let ksize = (kernel.w, kernel.h); morph::morphology(data, dst, dims, op, ksize, iterations, border) diff --git a/negpy_core/src/py/print_curve.rs b/negpy_core/src/py/print_curve.rs index 914571658..1208ef4f1 100644 --- a/negpy_core/src/py/print_curve.rs +++ b/negpy_core/src/py/print_curve.rs @@ -230,7 +230,7 @@ fn apply_print_curve<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || print_curve::apply_print_curve(src, w, &p, dst)); + py.detach(|| print_curve::apply_print_curve(src, w, &p, dst)); } Ok(out) } diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index 5176e22e4..f689aafe9 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -25,8 +25,9 @@ fn resize_area<'py>( .as_slice() .map_err(|_| PyValueError::new_err("resize_area needs a contiguous array"))?; let (dw, dh) = dsize; - let out = - super::detach(py, || resize::resize_area(data, h, w, cn, dh, dw)).ok_or_else(|| { + let out = py + .detach(|| resize::resize_area(data, h, w, cn, dh, dw)) + .ok_or_else(|| { PyValueError::new_err("resize_area does not cover a 2x2 two-channel downscale") })?; let dims: Vec = if shape.len() == 2 { @@ -70,14 +71,12 @@ fn resize_linear<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { - resize::resize_linear(data, (h, w, cn), (dh, dw), false, dst) - }) - .ok_or_else(|| { - PyValueError::new_err( - "resize_linear needs a non-empty size and covers no 2x2 two-channel halving", - ) - })?; + py.detach(|| resize::resize_linear(data, (h, w, cn), (dh, dw), false, dst)) + .ok_or_else(|| { + PyValueError::new_err( + "resize_linear needs a non-empty size and covers no 2x2 two-channel halving", + ) + })?; } Ok(out) } @@ -116,9 +115,7 @@ fn resize_nearest<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || { - resize::resize_nearest(data, (h, w, cn), (dh, dw), dst) - }); + py.detach(|| resize::resize_nearest(data, (h, w, cn), (dh, dw), dst)); } Ok(out) } diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index ac1393fed..2dde4d811 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -37,9 +37,7 @@ fn fill_rung( let fill = fill .as_slice_mut() .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - super::detach(py, || { - retouch::fill_rung(num, den, fill, first, reject_floor_mass, constants) - }); + py.detach(|| retouch::fill_rung(num, den, fill, first, reject_floor_mass, constants)); Ok(()) } @@ -64,7 +62,7 @@ fn blend_fill( if a_out.len() != n { return Err(PyValueError::new_err("array sizes disagree")); } - super::detach(py, || retouch::blend_fill(src, score, fill, a_out, ramp)); + py.detach(|| retouch::blend_fill(src, score, fill, a_out, ramp)); Ok(()) } @@ -86,7 +84,7 @@ fn add_floor_deficit( let out = out .as_slice_mut() .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - super::detach(py, || retouch::add_floor_deficit(out, a, bs, bo)); + py.detach(|| retouch::add_floor_deficit(out, a, bs, bo)); Ok(()) } @@ -117,9 +115,7 @@ fn hair_encode( let dst = out .as_slice_mut() .map_err(|_| pyo3::exceptions::PyValueError::new_err("needs a contiguous out"))?; - super::detach(py, || { - crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst) - }); + py.detach(|| crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst)); Ok(()) } diff --git a/negpy_core/src/py/sensor.rs b/negpy_core/src/py/sensor.rs index d93166c8c..539b28bae 100644 --- a/negpy_core/src/py/sensor.rs +++ b/negpy_core/src/py/sensor.rs @@ -57,7 +57,7 @@ fn sensor_unmix( image(&x)?; let (src, m) = (data(&x)?, matrix(&m)?); let dst = target(&mut out, x.shape())?; - super::detach(py, || sensor::unmix(src, dst, m)); + py.detach(|| sensor::unmix(src, dst, m)); Ok(()) } @@ -74,7 +74,7 @@ fn sensor_log_unmix( image(&x)?; let (src, c, offset, floor) = (data(&x)?, matrix(&c)?, triplet(&offset)?, triplet(&floor)?); let dst = target(&mut out, x.shape())?; - super::detach(py, || sensor::log_unmix(src, dst, c, offset, floor)); + py.detach(|| sensor::log_unmix(src, dst, c, offset, floor)); Ok(()) } @@ -104,7 +104,7 @@ fn sensor_two_scale_layer( start, full, }; - super::detach(py, || sensor::two_scale_layer(src, m, own, s, c, w)); + py.detach(|| sensor::two_scale_layer(src, m, own, s, c, w)); Ok(()) } @@ -131,9 +131,7 @@ fn sensor_two_scale( let (xs, b, col, w) = (data(&x)?, data(&blurred)?, data(&color)?, data(&weight)?); let (m, c) = (matrix(&m)?, matrix(&c)?); let dst = target(&mut out, &shape)?; - super::detach(py, || { - sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst) - }); + py.detach(|| sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst)); Ok(()) } diff --git a/negpy_core/src/py/skew.rs b/negpy_core/src/py/skew.rs index 7e40a9368..a9a770344 100644 --- a/negpy_core/src/py/skew.rs +++ b/negpy_core/src/py/skew.rs @@ -28,9 +28,7 @@ fn skew_sample<'py>( if st.len() != ce.len() { return Err(PyValueError::new_err("needs one center per station")); } - let (xs, ys) = super::detach(py, || { - skew::sample(img, (h, w), horizontal, (st, ce), half, nearest) - }); + let (xs, ys) = py.detach(|| skew::sample(img, (h, w), horizontal, (st, ce), half, nearest)); Ok((xs.into_pyarray(py), ys.into_pyarray(py))) } diff --git a/negpy_core/src/py/stats.rs b/negpy_core/src/py/stats.rs index 9c8a1610b..5e9be79c6 100644 --- a/negpy_core/src/py/stats.rs +++ b/negpy_core/src/py/stats.rs @@ -19,7 +19,7 @@ fn percentiles( let values = values .as_slice() .map_err(|_| PyValueError::new_err("percentiles needs a contiguous array"))?; - super::detach(py, || stats::percentiles(values, &qs)) + py.detach(|| stats::percentiles(values, &qs)) .ok_or_else(|| PyIndexError::new_err("percentiles of an empty array")) } diff --git a/negpy_core/src/py/toning.rs b/negpy_core/src/py/toning.rs index 057deab22..e0d60e2c6 100644 --- a/negpy_core/src/py/toning.rs +++ b/negpy_core/src/py/toning.rs @@ -79,7 +79,7 @@ fn apply_chemical_toning<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || toning::apply_chemical_toning(src, dst, &t)); + py.detach(|| toning::apply_chemical_toning(src, dst, &t)); } Ok(out) } diff --git a/negpy_core/src/py/warp.rs b/negpy_core/src/py/warp.rs index e3f4b7ac3..6442059f8 100644 --- a/negpy_core/src/py/warp.rs +++ b/negpy_core/src/py/warp.rs @@ -36,7 +36,7 @@ fn output<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - super::detach(py, || f(dst)); + py.detach(|| f(dst)); } Ok(out) } diff --git a/scripts/replay.py b/scripts/replay.py index ac226d9be..42a3cf63a 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -2,11 +2,11 @@ record FILE... run each frame through the preview load, the CPU pipeline, Square Before Crop and the Auto-crop All Frames evidence, then resolve the files as one roll; save every - call of a recorded function: inputs, result and its time on all cores and on one, - called alone. + call of a recorded function: inputs, result and its time on all cores, called + alone. replay call the current code with each saved input and compare the result bit for bit. - bench time the current code on all cores against the saved time (one core against one - for a recording that has no all-core time). + bench time the current code on all cores against the saved time (older recordings + saved a one-core time). Record on main just before a group is ported, so the corpus holds the behavior users have now. The corpus lives in $NEGPY_CORPUS (default ~/negpy-corpus), on a local disk that every worktree reads. @@ -62,17 +62,11 @@ def _targets(phases: list[int] | None, statuses: tuple[str, ...]) -> list[str]: return sorted(k for k, v in manifest.items() if v["status"] in statuses and (not phases or v["phase"] in phases)) -def _threads(all_cores: bool) -> None: - """Every core, as users run the app, or one, for the per-core figure.""" +def _time(fn, args, kwargs) -> float: + """Best of a few runs on every core, as the app runs.""" import cv2 - import negpy_core - cv2.setNumThreads(-1 if all_cores else 1) - negpy_core.set_parallel(all_cores) - - -def _time(fn, args, kwargs, all_cores: bool = True) -> float: - _threads(all_cores) + cv2.setNumThreads(-1) best = float("inf") for _ in range(TIMING_REPEATS): a, kw = copy.deepcopy((args, kwargs)) @@ -121,8 +115,6 @@ def wrapper(*args, **kwargs): if len(payload) <= MAX_CALL_BYTES: record = pickle.loads(payload) record["seconds_all"] = _time(fn, inputs[0], inputs[1]) - record["seconds"] = _time(fn, inputs[0], inputs[1], all_cores=False) - _threads(True) folder = _folder(key) folder.mkdir(parents=True, exist_ok=True) (folder / f"{self.saved[key]:04d}.pkl").write_bytes(pickle.dumps(record, protocol=5)) @@ -296,10 +288,9 @@ def bench(phases: list[int] | None) -> int: ratios = [] for f in files: rec = pickle.loads(f.read_bytes()) - # The gate is all cores, as users run the app; older recordings timed one. - all_cores = "seconds_all" in rec - saved = rec["seconds_all"] if all_cores else rec["seconds"] - ratios.append(_time(fn, rec["args"], rec["kwargs"], all_cores) / max(saved, 1e-9)) + # Older recordings saved a one-core time only. + saved = rec.get("seconds_all", rec.get("seconds")) + ratios.append(_time(fn, rec["args"], rec["kwargs"]) / max(saved, 1e-9)) ratio = statistics.median(ratios) slower += ratio > 1.0 print(f"{'SLOWER' if ratio > 1.0 else 'ok':6s} {ratio:6.3f}x {key}") diff --git a/tests/test_lab_row_blocks.py b/tests/test_lab_row_blocks.py index b4b9ced75..5eb61f6bd 100644 --- a/tests/test_lab_row_blocks.py +++ b/tests/test_lab_row_blocks.py @@ -7,7 +7,6 @@ _usm_sharpening_block, _saturation_block, ) -from negpy.kernel.system import parallel @pytest.mark.parametrize("radius,mask", [(0.1, 0.0), (1.0, 0.0), (3.0, 0.6)]) @@ -20,11 +19,9 @@ def test_sharpen_blocks_match_full_frame(radius, mask): np.testing.assert_array_equal(image, original) -@pytest.mark.parametrize("parallel_enabled", [False, True]) @pytest.mark.parametrize("tail_rows", [1, 21, 22]) @pytest.mark.parametrize("saturation,skin", [(1.0, 1.0), (0.6, 0.7)]) -def test_saturation_short_tail_matches_full_frame(monkeypatch, parallel_enabled, tail_rows, saturation, skin): - monkeypatch.setattr(parallel, "_parallel_enabled", parallel_enabled) +def test_saturation_short_tail_matches_full_frame(tail_rows, saturation, skin): image = np.random.default_rng(991).uniform(0.001, 0.999, (3072 + tail_rows, 1024, 3)).astype(np.float32) image[::7] = 0.0 image[1::11] = 1.0 diff --git a/tests/test_parallel_dispatch.py b/tests/test_parallel_dispatch.py deleted file mode 100644 index 4f18f8b10..000000000 --- a/tests/test_parallel_dispatch.py +++ /dev/null @@ -1,109 +0,0 @@ -"""The Multi-core CPU Rendering setting: one thread or all, the same bits either way; policy, override.""" - -import threading - -import numpy as np -import pytest - -from negpy.kernel.system import parallel as par -from negpy.kernel.system.override import OverrideConfig, _parse -from negpy.kernel.system.parallel import default_cpu_parallel - - -@pytest.fixture(autouse=True) -def _restore_flag(): - before = par.parallel_enabled() - yield - par.set_parallel_enabled(before) - - -def test_print_curve_parity_serial_vs_parallel(): - from negpy.features.exposure.logic import apply_characteristic_curve - - rng = np.random.default_rng(7) - img = rng.random((64, 96, 3), dtype=np.float32) - - par.set_parallel_enabled(True) - res_par = apply_characteristic_curve(img, (0.5, 2.0), (0.5, 2.0), (0.5, 2.0)) - par.set_parallel_enabled(False) - res_ser = apply_characteristic_curve(img, (0.5, 2.0), (0.5, 2.0), (0.5, 2.0)) - - np.testing.assert_array_equal(res_par, res_ser) - - -def test_lab_roundtrip_parity_serial_vs_parallel(): - from negpy.kernel.image.logic import rgb_to_lab_working - - rng = np.random.default_rng(11) - img = rng.random((32, 48, 3), dtype=np.float32) - - par.set_parallel_enabled(True) - lab_par = rgb_to_lab_working(img) - par.set_parallel_enabled(False) - lab_ser = rgb_to_lab_working(img) - - np.testing.assert_array_equal(lab_par, lab_ser) - - -def test_concurrent_calls_while_the_setting_flips_are_safe(): - from negpy.kernel.image.logic import rgb_to_lab_working - - img = np.random.default_rng(3).random((300, 400, 3), dtype=np.float32) - want = rgb_to_lab_working(img) - errors: list[Exception] = [] - - def hammer(flip: bool): - try: - for i in range(20): - if flip: - par.set_parallel_enabled(i % 2 == 0) - np.testing.assert_array_equal(rgb_to_lab_working(img), want) - except Exception as e: # pragma: no cover - errors.append(e) - - threads = [threading.Thread(target=hammer, args=(i == 0,)) for i in range(6)] - for t in threads: - t.start() - for t in threads: - t.join() - assert not errors - - -def test_override_parsing_and_apply(): - assert _parse({"performance": {"cpu_parallel": False}}).cpu_parallel is False - assert _parse({"performance": {"cpu_parallel": True}}).cpu_parallel is True - assert _parse({"performance": {}}).cpu_parallel is None - assert _parse({"performance": {"cpu_parallel": "yes"}}).cpu_parallel is None # non-bool ignored - - from negpy.kernel.system.override import apply as apply_override - from negpy.domain.types import AppConfig - - cfg = AppConfig( - thumbnail_size=1, - max_workers=1, - preview_render_size=1, - max_history_steps=1, - edits_db_path="", - settings_db_path="", - presets_dir="", - cache_dir="", - user_icc_dir="", - crosstalk_dir="", - sensor_dir="", - flatfield_dir="", - gear_dir="", - default_export_dir="", - adobe_rgb_profile="", - ) - assert cfg.cpu_parallel is None - apply_override(OverrideConfig(cpu_parallel=False), cfg) - assert cfg.cpu_parallel is False - - -def test_configure_cpu_parallel_override_wins(): - par.configure_cpu_parallel(False) - assert par.parallel_enabled() is False - par.configure_cpu_parallel(True) - assert par.parallel_enabled() is True - par.configure_cpu_parallel(None) - assert par.parallel_enabled() is default_cpu_parallel() diff --git a/tests/test_parallel_gate.py b/tests/test_parallel_gate.py deleted file mode 100644 index 004123c2f..000000000 --- a/tests/test_parallel_gate.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Which answer sets Multi-core CPU Rendering: override.toml, the saved setting, the default.""" - -from negpy.kernel.system.parallel import default_cpu_parallel, resolve_cpu_parallel - - -class TestResolution: - def test_an_untouched_install_takes_the_default_on(self): - assert default_cpu_parallel() is True - assert resolve_cpu_parallel(None, None) is None, "None must fall through to the default" - - def test_the_saved_setting_beats_the_default(self): - assert resolve_cpu_parallel(None, True) is True - assert resolve_cpu_parallel(None, False) is False - - def test_the_override_file_beats_the_saved_setting(self): - """override.toml is the escape hatch for a machine that cannot boot far enough to - reach the menu.""" - assert resolve_cpu_parallel(False, True) is False - assert resolve_cpu_parallel(True, False) is True From 3f0c6deec876394b64faad853f3d98cff5c23800 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 14:14:26 +0200 Subject: [PATCH 086/133] perf(core): port the IR dust path and the score repair to Rust Phase 4, IR path: _ir_clean_base, _ir_live, _ir_glare, normalize_ir, _ir_normalize_ratio, _ir_decontaminate's division and apply_score_repair (score fill, clean-grain borrow and floor rule in one call) run in negpy_core. All bit-exact on the IR corpus (15/15 functions), including NumPy's own float32 exp, now copied as mathf::np_expf. ir_ratio_and_gain: 173 ms -> 110 ms on a 1600x1059 strip. Ported functions 0.21x-0.69x of the recorded Python with every core. Primitives: - rect morphology works in row bands (dilate 9x9 3-ch 9 ms -> 2 ms) - box filter strips write dst directly and touch it first in large blocks; parallel first writes to one fresh huge page stalled - every binding goes through py::detach: work under 64K elements runs on the calling thread, larger work enters the all-core pool once per call (a 30x30 repair 400 us -> ~90 us) Known gap: apply_score_repair on 30x30 crops (optical dust path) is still 1.5-1.75x of the recorded Python. --- CLAUDE.md | 4 +- negpy/features/retouch/logic.py | 167 ++----------- negpy_core/manifest.toml | 42 ++-- negpy_core/src/filter.rs | 77 +++--- negpy_core/src/mathf.rs | 59 +++++ negpy_core/src/morph.rs | 70 ++---- negpy_core/src/py/altprocess.rs | 2 +- negpy_core/src/py/analysis.rs | 110 +++++--- negpy_core/src/py/bounds.rs | 6 +- negpy_core/src/py/color.rs | 2 +- negpy_core/src/py/components.rs | 16 +- negpy_core/src/py/contours.rs | 4 +- negpy_core/src/py/convert.rs | 8 +- negpy_core/src/py/display.rs | 2 +- negpy_core/src/py/filter.rs | 2 +- negpy_core/src/py/finish.rs | 4 +- negpy_core/src/py/gray.rs | 18 +- negpy_core/src/py/hdr.rs | 8 +- negpy_core/src/py/histogram.rs | 20 +- negpy_core/src/py/lab.rs | 26 +- negpy_core/src/py/meter.rs | 14 +- negpy_core/src/py/mod.rs | 42 ++++ negpy_core/src/py/morph.rs | 2 +- negpy_core/src/py/print_curve.rs | 4 +- negpy_core/src/py/resize.rs | 29 ++- negpy_core/src/py/retouch.rs | 337 +++++++++++++++++++------ negpy_core/src/py/sensor.rs | 14 +- negpy_core/src/py/skew.rs | 4 +- negpy_core/src/py/stats.rs | 2 +- negpy_core/src/py/toning.rs | 4 +- negpy_core/src/py/warp.rs | 2 +- negpy_core/src/retouch.rs | 414 +++++++++++++++++++++++++++++++ negpy_core/src/stats.rs | 13 + tests/test_core_ir.py | 98 ++++++++ 34 files changed, 1173 insertions(+), 453 deletions(-) create mode 100644 tests/test_core_ir.py diff --git a/CLAUDE.md b/CLAUDE.md index 9af613cb9..a7adf6931 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored `negpy_core/` is a PyO3 extension (abi3-py313, maturin, a `uv` workspace member) that takes over CPU kernels one group at a time. - A ported function keeps its Python signature; the Python body becomes a call into `negpy_core`, and the Python implementation goes in the same change. No runtime switch, no fallback. -- Core code in `src/.rs` takes slices or `ndarray` views and plain numbers, never config. `src/py/.rs` holds only the wrappers: dtype, shape and contiguity checks, `py.detach` around the work, errors as Python exceptions. No panic crosses into Python. +- Core code in `src/.rs` takes slices or `ndarray` views and plain numbers, never config. `src/py/.rs` holds only the wrappers: dtype, shape and contiguity checks, `py::detach` around the work, errors as Python exceptions. No panic crosses into Python. - `cargo test` builds without Python: PyO3 is behind the `python` feature, which maturin turns on. - Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `pkg-config`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. - No `unsafe` except SIMD, each with a reason. Names mirror the Python names. @@ -66,7 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. -- `negpy_core` holds every CPU kernel; NegPy has no Numba. Bindings release the GIL with `py.detach` and run on every core: the app has no single-core mode. +- `negpy_core` holds every CPU kernel; NegPy has no Numba. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). The app has no single-core mode. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index fa6329113..d2df25b3e 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -738,24 +738,12 @@ def ir_detect_target(buffer_long_edge: int, preview_long_edge: int) -> int: def _ir_live(plane: np.ndarray) -> np.ndarray: """Film under the head: everything but the below-floor regions large enough to be holder.""" - dead = plane < _IR_DEAD_FLOOR - if not dead.any(): - return np.ones(plane.shape[:2], dtype=bool) - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(dead.astype(np.uint8), connectivity=8) - holder = np.zeros(n_lbl, dtype=bool) - holder[1:] = stats[1:, cv2.CC_STAT_AREA] >= _IR_DEAD_MIN_AREA * dead.size - return ~holder[labels] + return negpy_core.ir_live(np.ascontiguousarray(plane, dtype=np.float32), _IR_DEAD_FLOOR, _IR_DEAD_MIN_AREA) def _ir_glare(plane: np.ndarray, live: np.ndarray) -> Optional[np.ndarray]: """Pixels more than ``_IR_GLARE_SIGMA`` over the median of the live film, or None.""" - sample = plane.ravel()[_fit_sample(live)] - if sample.size < 500: - return None - med = float(np.median(sample)) - sigma = 1.4826 * float(np.median(np.abs(sample - med))) - glare = plane > med + _IR_GLARE_SIGMA * sigma - return glare if glare.any() else None + return negpy_core.ir_glare(np.ascontiguousarray(plane, dtype=np.float32), np.ascontiguousarray(live), _IR_FIT_MAX_PX, _IR_GLARE_SIGMA) def _ir_detect_scale(plane: np.ndarray) -> float: @@ -855,9 +843,7 @@ def normalize_ir(plane: np.ndarray, glare: Optional[np.ndarray] = None) -> np.nd ``glare`` pixels stay out of the base.""" plane = np.ascontiguousarray(plane, dtype=np.float32) win = _ir_win(_IR_BASE_WIN, _ir_detect_scale(plane)) - src = plane if glare is None else np.where(glare, np.float32(0.0), plane) - base = cv2.blur(cv2.dilate(src, cv2.getStructuringElement(cv2.MORPH_RECT, (win, win))), (win, win)) - return plane / np.maximum(base, 1e-4) + return negpy_core.normalize_ir(plane, None if glare is None else np.ascontiguousarray(glare), win) def ir_detect_cutoff(slider: float, attenuation: bool) -> float: @@ -879,44 +865,6 @@ def ir_defect_score(ratio: np.ndarray, cutoff: float) -> np.ndarray: return cv2.erode(score, np.ones((3, 3), np.uint8)) -def score_weighted_fill( - img: np.ndarray, - score: np.ndarray, - scales: Tuple[int, ...] = _IR_FILL_SCALES, - reject_floor_mass: bool = False, -) -> np.ndarray: - """Multiscale score-normalized average, blended coarse→fine by clean fraction. - Where no support holds clean film the quotient tends to zero and the original-floor - rule in apply_score_repair keeps the source pixel. - - ``reject_floor_mass`` measures that clean fraction *above* the score floor. A defect - pixel scores 0.02 rather than 0, so a support seeing nothing but defect still carries - den ≈ 0.02 — enough confidence to win, with the defect's own value as its candidate, - and the fill quietly puts back a share of what it was asked to remove. Callers keeping - the original-floor rule leave this off: it corrects them downstream, and rejecting those - rungs outright shifts weight onto the coarsest support, which is the one that reaches - across a tonal edge and over-lifts the dense side of it. A repair allowed to darken has - no such corrector, so it has to pay for honest confidence there instead. - """ - weighted = img * score[..., None] - fill = np.empty_like(img) - for i, k in enumerate(scales): - if i == len(scales) - 1: - num = cv2.GaussianBlur(weighted, (k, k), 0) - den = cv2.GaussianBlur(score, (k, k), 0) - else: - num = cv2.boxFilter(weighted, -1, (k, k)) - den = cv2.boxFilter(score, -1, (k, k)) - _fill_rung(num, den, fill, i == 0, reject_floor_mass) - return fill - - -def _fill_rung(num, den, fill, first, reject_floor_mass): - """One rung of the fill ladder in place: the score-normalized candidate, blended over the - coarser result by the rung's clean fraction, in the array form's float32 operation order.""" - negpy_core.fill_rung(num, den, fill, bool(first), bool(reject_floor_mass), (_IR_SCORE_FLOOR, _IR_FILL_TAU)) - - def _fill_supports(buffer_long_edge: int, factor: float) -> Tuple[int, ...]: """Fill ladder in buffer px: ``_IR_FILL_SCALES`` at the detection plane, plus a coarse rung at the reference footprint when detection runs finer than it. Both ends carry: a small fine @@ -927,32 +875,6 @@ def _fill_supports(buffer_long_edge: int, factor: float) -> Tuple[int, ...]: return tuple(dict.fromkeys([int(round(_IR_FILL_SCALES[0] * film)) | 1] + fine)) -def _borrow_clean_grain(src: np.ndarray, clean: np.ndarray, sigma: float, idx: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - """Detail of the nearest clean pixel, high-passed at ``sigma``, for the flat pixel - indices ``idx`` only: an (N, 3) array, N = len(idx), plus the ``sigma`` blur of ``src`` - it was measured against, for a caller that needs the same low-pass again. - - Real film rather than synthesized noise: the donor is the closest pixel the IR score calls - clean, so it carries the same emulsion, density and scanner noise as the hole it fills. - Ceiling: deep inside a wide defect every pixel resolves to the same few boundary donors and - the paste flattens toward a constant, leaving those interiors to the fill's own blend. - Only the repaired pixels take grain, so the donor lookup runs on their indices alone; the - distance transform still covers the frame, because a donor can sit anywhere near a hole. - """ - h, w = clean.shape - # DIST_LABEL_PIXEL numbers the zero pixels 1..N in raster order, so flatnonzero inverts it. - _, labels = cv2.distanceTransformWithLabels((~clean).astype(np.uint8), cv2.DIST_L2, 3, labelType=cv2.DIST_LABEL_PIXEL) - nearest = np.flatnonzero(clean.ravel())[labels.ravel()[idx].astype(np.intp) - 1] - # Mirror through the donor, do not sample it: a whole row across a hair shares one - # nearest clean pixel, and pasting that verbatim streaks the grain into bands. - ny, nx = np.divmod(nearest, w) - py, px = np.divmod(idx, w) - mirrored = np.clip(2 * ny - py, 0, h - 1) * w + np.clip(2 * nx - px, 0, w - 1) - take = np.where(clean.ravel()[mirrored], mirrored, nearest) - blur = cv2.GaussianBlur(src, (0, 0), sigma) - return src.reshape(-1, 3)[take] - blur.reshape(-1, 3)[take], blur - - def apply_score_repair( img: ImageBuffer, score_det: np.ndarray, @@ -981,42 +903,12 @@ def apply_score_repair( else: factor = max(h / score_det.shape[0], w / score_det.shape[1]) score = negpy_core.resize_linear(np.ascontiguousarray(score_det, dtype=np.float32), (w, h)) - out = score_weighted_fill(src, score, _fill_supports(long_edge or max(h, w), factor), reject_floor_mass=not floor) - # The fill buffer becomes the output: blended over the source under the write ramp. - a = np.empty((h, w), dtype=np.float32) - _blend_fill(src, score, out, a) - # A weighted average lands grainless, so a repair reads as a smooth patch against film. - sigma = max(1.0, factor) - clean = score >= _IR_WRITE_HI - idx = np.flatnonzero(~clean) - blur_src: Optional[np.ndarray] = None - if clean.any() and idx.size: - grain, blur_src = _borrow_clean_grain(src, clean, sigma, idx) - out.reshape(-1, 3)[idx] += a.reshape(-1, 1)[idx] * grain - # Original-floor rule: dust is dark in negative transmittance, so repairs only lighten. - # Compared on the low-frequency deficit, because per pixel the rule is a half-wave - # rectifier that keeps the fill's grain peaks, clips its troughs and leaves the repair - # bright and half-textured. Under the same ramp, so an untouched pixel stays identical. - if floor: - if blur_src is None: - blur_src = cv2.GaussianBlur(src, (0, 0), sigma) - _add_floor_deficit(out, a, blur_src, cv2.GaussianBlur(out, (0, 0), sigma)) - else: - np.maximum(out, 0.0, out=out) + supports = list(_fill_supports(long_edge or max(h, w), factor)) + constants = (_IR_SCORE_FLOOR, _IR_FILL_TAU, _IR_WRITE_HI, _IR_WRITE_HI - _IR_WRITE_LO) + out = negpy_core.apply_score_repair(src, score, supports, bool(floor), max(1.0, factor), constants) return ensure_image(out) -def _blend_fill(src, score, fill, a_out): - """``fill`` becomes ``src * (1 - a) + fill * a`` under the smoothstep write ramp of the - score; the ramp is kept in ``a_out`` for the grain and floor passes.""" - negpy_core.blend_fill(src, score, fill, a_out, (_IR_WRITE_HI, _IR_WRITE_HI - _IR_WRITE_LO)) - - -def _add_floor_deficit(out, a, blur_src, blur_out): - """``out += a * max(blur_src - blur_out, 0)``, then clamps at zero, in place.""" - negpy_core.add_floor_deficit(out, a, blur_src, blur_out) - - def film_scale(shape: Tuple[int, int]) -> float: """Pixels per unit of film footprint for a buffer this size — the factor a score measured at the buffer's own resolution needs so the fill's supports stay film-scale rather than @@ -1135,7 +1027,8 @@ def _ir_decontaminate(ratio: np.ndarray, vis_log: np.ndarray) -> Tuple[np.ndarra # defect away as ghost and the division stops lifting it. Trim by ratio percentile: a # fixed cutoff fails because a strong ghost drags clean film below it, and residual # fails because the dust fits itself perfectly. - keep = _fit_sample(ratio >= np.percentile(ratio, _IR_XTALK_TRIM)) + ratio = np.ascontiguousarray(ratio, dtype=np.float32) + keep = _fit_sample(ratio >= np.float32(negpy_core.percentiles(ratio.ravel(), [_IR_XTALK_TRIM])[0])) y = np.log(np.clip(ratio.ravel()[keep], 1e-4, 1.0)) x = vis_log.reshape(-1, vis_log.shape[-1])[keep] if y.size < 500: @@ -1148,7 +1041,7 @@ def _ir_decontaminate(ratio: np.ndarray, vis_log: np.ndarray) -> Tuple[np.ndarra ghost = float(np.abs(b).sum()) if ghost < _IR_XTALK_MIN: return ratio, ghost - return np.clip(ratio / np.exp((vis_log * b).sum(-1)), 0.0, 1.5).astype(np.float32), ghost + return negpy_core.ir_ghost_divide(ratio, np.ascontiguousarray(vis_log, dtype=np.float32), b.tolist()), ghost def _fit_refraction_gammas(ratio: np.ndarray, vis_log: np.ndarray, img_det: np.ndarray) -> Tuple[float, ...]: @@ -1177,16 +1070,11 @@ def _fit_refraction_gammas(ratio: np.ndarray, vis_log: np.ndarray, img_det: np.n def _ir_clean_base(img_det: np.ndarray, ratio: np.ndarray) -> np.ndarray: """Local clean-film level per channel over ``_IR_CAP_WIN``: mean of the pixels the IR ratio calls clean, minus ``_IR_CAP_SIGMA`` of their σ (see the constants block).""" - win_px = _ir_win(_IR_CAP_WIN, _ir_detect_scale(ratio)) - win = (win_px, win_px) - w_clean = (ratio >= _IR_GAIN_IDENTITY).astype(np.float32) - den = np.maximum(cv2.blur(w_clean, win), 1e-6)[..., None] - mean = cv2.blur(img_det * w_clean[..., None], win) / den - var = cv2.blur(img_det * img_det * w_clean[..., None], win) / den - mean * mean - base = mean - _IR_CAP_SIGMA * np.sqrt(np.clip(var, 0.0, None)) - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, win) - dil = cv2.blur(cv2.dilate(img_det, kernel), win) - return np.where(den > _IR_CAP_MIN_SUPPORT, base, dil) + win = _ir_win(_IR_CAP_WIN, _ir_detect_scale(ratio)) + constants = (win, _IR_GAIN_IDENTITY, _IR_CAP_SIGMA, _IR_CAP_MIN_SUPPORT) + return negpy_core.ir_clean_base( + np.ascontiguousarray(img_det, dtype=np.float32), np.ascontiguousarray(ratio, dtype=np.float32), constants + ) def _ir_normalize_ratio(ratio: np.ndarray, live: np.ndarray) -> np.ndarray: @@ -1194,23 +1082,11 @@ def _ir_normalize_ratio(ratio: np.ndarray, live: np.ndarray) -> np.ndarray: ``_IR_GAIN_IDENTITY``, dip scale on ``_IR_REF_SIGMA`` (see the constants block). ``live`` only: the dead-margin 1.0s inflate σ ~20% on a strip scan. MAD, not std, so a dusty minority can't move either landmark.""" - sample = ratio[live] - if sample.size < 500: # nothing measurable: leave the landmarks alone - return ratio - med = float(np.median(sample)) - sigma = 1.4826 * float(np.median(np.abs(sample - med))) - pivot = float(np.clip(med - _IR_NOISE_SIGMA * sigma, _IR_PIVOT_LO, _IR_GAIN_IDENTITY)) - if pivot <= _IR_PIVOT_LO: - logger.warning("IR dust: clean-film pivot floored at %.2f (IR σ %.3f) — very noisy IR plane", _IR_PIVOT_LO, sigma) - # A clipped pivot is an exact no-op: x/x is 1.0 in IEEE, float32 × 1.0 exact. - scale = _IR_GAIN_IDENTITY / pivot - ratio = (ratio * scale).astype(np.float32) - # The rescale carries σ with it, so there is no second median pass. Dips only: a - # noiseless plane takes the full _IR_SCALE_MAX and would land its clean level above 1. - k = float(np.clip(_IR_REF_SIGMA / max(sigma * scale, 1e-6), 1.0, _IR_SCALE_MAX)) - if k > 1.0: - stretched = _IR_GAIN_IDENTITY - (_IR_GAIN_IDENTITY - ratio) * k - ratio = np.where(ratio < _IR_GAIN_IDENTITY, stretched, ratio).astype(np.float32) + ratio = np.array(ratio, dtype=np.float32) + constants = (_IR_GAIN_IDENTITY, _IR_NOISE_SIGMA, _IR_PIVOT_LO, _IR_REF_SIGMA, _IR_SCALE_MAX) + measured = negpy_core.ir_normalize_ratio(ratio, np.ascontiguousarray(live), constants) + if measured is not None and measured[0] <= _IR_PIVOT_LO: + logger.warning("IR dust: clean-film pivot floored at %.2f (IR σ %.3f) — very noisy IR plane", _IR_PIVOT_LO, measured[1]) return ratio @@ -1370,10 +1246,7 @@ def apply_hair_inpaint( out.reshape(-1, 3)[(y0 + ay0 + ry) * w + (x0 + ax0 + rx)] = tile_px * (1.0 - a) + dec * a # Navier-Stokes propagates a smooth field, so the fill lands grainless. See # apply_score_repair, which fills the same kind of hole by a different route. - idx = np.flatnonzero(m) - clean = m == 0 - if clean.any() and idx.size: - out.reshape(-1, 3)[idx] += _borrow_clean_grain(src, clean, max(1.0, factor), idx)[0] + negpy_core.add_clean_grain(out, np.ascontiguousarray(src, dtype=np.float32), m == 0, max(1.0, factor)) return out diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 6cb91a6b1..3d4f3f898 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1422,12 +1422,6 @@ status = "ported" calls = ["negpy_core.blend_fill"] depends = [] -["features/retouch/logic.py::_borrow_clean_grain"] -phase = 4 -status = "todo" -calls = ["cv2.GaussianBlur", "cv2.distanceTransformWithLabels"] -depends = [] - ["features/retouch/logic.py::_box_mean_std"] phase = 4 status = "todo" @@ -1466,32 +1460,32 @@ depends = [] ["features/retouch/logic.py::_ir_clean_base"] phase = 4 -status = "todo" -calls = ["cv2.blur", "cv2.dilate", "cv2.getStructuringElement"] +status = "ported" +calls = ["negpy_core.ir_clean_base"] depends = [] ["features/retouch/logic.py::_ir_decontaminate"] phase = 4 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.ir_ghost_divide", "negpy_core.percentiles"] depends = [] ["features/retouch/logic.py::_ir_glare"] phase = 4 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.ir_glare"] depends = [] ["features/retouch/logic.py::_ir_live"] phase = 4 -status = "todo" -calls = ["cv2.connectedComponentsWithStats"] +status = "ported" +calls = ["negpy_core.ir_live"] depends = [] ["features/retouch/logic.py::_ir_normalize_ratio"] phase = 4 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.ir_normalize_ratio"] depends = [] ["features/retouch/logic.py::_is_hair"] @@ -1538,9 +1532,9 @@ depends = [] ["features/retouch/logic.py::apply_score_repair"] phase = 4 -status = "todo" -calls = ["cv2.GaussianBlur", "negpy_core.resize_linear"] -depends = ["_add_floor_deficit", "_blend_fill", "_borrow_clean_grain", "score_weighted_fill"] +status = "ported" +calls = ["negpy_core.apply_score_repair", "negpy_core.resize_linear"] +depends = [] ["features/retouch/logic.py::compute_dust_stats"] phase = 4 @@ -1592,8 +1586,8 @@ depends = ["_mask_to_score", "_scratch_ridge_rows", "_shear_rows"] ["features/retouch/logic.py::normalize_ir"] phase = 4 -status = "todo" -calls = ["cv2.blur", "cv2.dilate", "cv2.getStructuringElement"] +status = "ported" +calls = ["negpy_core.normalize_ir"] depends = [] ["features/retouch/logic.py::repair_components"] @@ -1610,7 +1604,7 @@ depends = [] ["features/retouch/logic.py::score_weighted_fill"] phase = 4 -status = "todo" +status = "ported" calls = ["cv2.GaussianBlur", "cv2.boxFilter"] depends = ["_fill_rung"] @@ -2482,8 +2476,8 @@ depends = [] ["features/retouch/logic.py::apply_hair_inpaint"] phase = 9 status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement", "cv2.inpaint", "negpy_core.resize_linear", "np.percentile"] -depends = ["_borrow_clean_grain", "_hair_encode"] +calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement", "cv2.inpaint", "negpy_core.add_clean_grain", "negpy_core.resize_linear", "np.percentile"] +depends = ["_hair_encode"] ["features/rgbscan/logic.py::estimate_shift"] phase = 9 diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs index be3493dc5..f954dc29c 100644 --- a/negpy_core/src/filter.rs +++ b/negpy_core/src/filter.rs @@ -747,15 +747,20 @@ pub fn box_filter( }; // Bands of rows: their row sums in parallel, then the column recurrence (sequential down a - // column, independent across columns) in parallel strips, each into its own tile. The ring - // keeps the row sums a later row still subtracts. + // column, independent across columns) in parallel strips. The ring keeps the row sums a + // later row still subtracts. const BAND: usize = 128; const STRIP: usize = 512; - let slots = kh + BAND; + let total = h + kh - 1; + let slots = kh + BAND.min(total); let mut ring: Vec> = vec![vec![0f64; n]; slots]; let mut sum = vec![0f64; n]; let zeros = vec![0f64; n]; - let total = h + kh - 1; + // One strip of columns per thread, each writing its part of every output row. + let strip = n.div_ceil(rayon::current_num_threads()).max(STRIP); + // Strips write every row of a band at once, so threads would fault the same fresh huge + // pages together; touching dst first in large blocks avoids that contention. + dst.par_chunks_mut(1 << 19).for_each(|c| c.fill(0.0)); let mut rc0 = 0; while rc0 < total { let rc1 = (rc0 + BAND).min(total); @@ -775,51 +780,29 @@ pub fn box_filter( // Output rows this band writes: extended row rc writes image row rc - (kh - 1). let y0 = rc0.max(kh - 1) - (kh - 1); let y1 = rc1.saturating_sub(kh - 1).max(y0); - // One strip per thread; when one strip spans the row it writes straight into dst. - let threads = rayon::current_num_threads(); - let strip = n.div_ceil(threads).max(STRIP); - let column = |x0: usize, s: &mut [f64], tile: &mut [f32], tile_w: usize, tile_x0: usize| { - let sw = s.len(); - for rc in rc0..rc1 { - let sp = &line(rc)[x0..x0 + sw]; - // Before kh - 1 rows have passed, nothing leaves the window yet. - let old = if rc + 1 >= kh { - &line(rc + 1 - kh)[x0..x0 + sw] - } else { - &zeros[..sw] - }; - if let Some(y) = rc.checked_sub(kh - 1) { - let at = (y - y0) * tile_w + tile_x0; - column_step(s, sp, old, Some(&mut tile[at..at + sw]), scale); - } else { - column_step(s, sp, old, None, scale); - } + let mut outs: Vec> = (0..n.div_ceil(strip)).map(|_| Vec::new()).collect(); + for row in dst[y0 * n..y1 * n].chunks_exact_mut(n) { + for (o, part) in outs.iter_mut().zip(row.chunks_mut(strip)) { + o.push(part); } - }; - if strip >= n { - column(0, &mut sum, &mut dst[y0 * n..y1 * n], n, 0); - } else { - let tiles: Vec> = sum - .par_chunks_mut(strip) - .enumerate() - .map(|(si, s)| { - let mut tile = vec![0f32; (y1 - y0) * s.len()]; - let sw = s.len(); - column(si * strip, s, &mut tile, sw, 0); - tile - }) - .collect(); - dst[y0 * n..y1 * n] - .par_chunks_mut(n) - .enumerate() - .for_each(|(j, out)| { - for (si, tile) in tiles.iter().enumerate() { - let sw = tile.len() / (y1 - y0); - out[si * strip..si * strip + sw] - .copy_from_slice(&tile[j * sw..(j + 1) * sw]); - } - }); } + sum.par_chunks_mut(strip) + .zip(outs.par_iter_mut()) + .enumerate() + .for_each(|(si, (s, out))| { + let (x0, sw) = (si * strip, s.len()); + for rc in rc0..rc1 { + let sp = &line(rc)[x0..x0 + sw]; + // Before kh - 1 rows have passed, nothing leaves the window yet. + let old = if rc + 1 >= kh { + &line(rc + 1 - kh)[x0..x0 + sw] + } else { + &zeros[..sw] + }; + let o = rc.checked_sub(kh - 1).map(|y| &mut *out[y - y0]); + column_step(s, sp, old, o, scale); + } + }); rc0 = rc1; } Some(()) diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index a6e685165..807005a1f 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -188,10 +188,69 @@ pub fn cbrtf(x: f32) -> f32 { } } +/// NumPy's float32 `exp` on x86 with AVX2 or AVX-512, which runs its own SIMD code, not libm: +/// a Cody-Waite reduction by ln 2 and a rational polynomial, every step fused. Other platforms +/// run libm's expf. +// The constants are NumPy's literals, rounded to f32 as its C compiler rounds them. +#[allow(clippy::excessive_precision)] +#[inline(always)] +pub fn np_expf(x: f32) -> f32 { + const XMAX: f32 = 88.722_839_355_468_75; + const XMIN: f32 = -103.972_084_045_410_156_25; + const MAGIC: f32 = 12_582_912.0; + const LN2_HI: f32 = -6.931_457_52e-1; + const LN2_LO: f32 = -1.428_606_77e-6; + let (nan, big, small) = (x.is_nan(), x >= XMAX, x <= XMIN); + let x = if nan || big || small { 0.0 } else { x }; + let q = (x * std::f32::consts::LOG2_E + MAGIC) - MAGIC; + let r = q.mul_add(LN2_LO, q.mul_add(LN2_HI, x)); + let num = 5.082_762_527_590_693_718_096e-4f32 + .mul_add(r, 6.757_896_990_527_504_603_057e-3) + .mul_add(r, 5.114_512_081_637_298_353_406e-2) + .mul_add(r, 2.473_615_434_895_520_810_817e-1) + .mul_add(r, 7.257_664_613_233_124_478_488e-1) + .mul_add(r, 9.999_999_999_980_870_924_916e-1); + let den = 2.159_509_375_685_829_852_307e-2f32 + .mul_add(r, -2.742_335_390_411_667_452_936e-1) + .mul_add(r, 1.0); + // AVX-512's scalef rounds once, also into the subnormals; the product is exact in f64. + let scale = f64::from_bits((q as f64 + (1023.0 + 4_503_599_627_370_496.0)).to_bits() << 52); + let y = ((num / den) as f64 * scale) as f32; + if nan { + f32::NAN + } else if big { + f32::INFINITY + } else if small { + 0.0 + } else { + y + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn np_expf_matches_numpy() { + // np.exp of a float32 array on an AVX-512 x86 CPU, as bits. + for (x, want) in [ + (0.0f32, 0x3f80_0000u32), + (1.0, 0x402d_f855), + (-1.0, 0x3ebc_5ab1), + (0.5, 0x3fd3_094c), + (10.0, 0x46ac_14ef), + (-50.0, 0x1b69_2beb), + (88.0, 0x7ef8_82b7), + (-90.0, 0x0008_ec28), + ] { + assert_eq!(np_expf(x).to_bits(), want, "{x}"); + } + assert_eq!(np_expf(89.0), f32::INFINITY); + assert_eq!(np_expf(-104.0), 0.0); + assert!(np_expf(f32::NAN).is_nan()); + } + #[test] fn cbrtf_is_correctly_rounded_on_a_sample() { let mut bits = 1u32; diff --git a/negpy_core/src/morph.rs b/negpy_core/src/morph.rs index ce85a7be6..aa28ca1d3 100644 --- a/negpy_core/src/morph.rs +++ b/negpy_core/src/morph.rs @@ -152,55 +152,35 @@ fn rect_pass( .map(|px| border_interpolate(px as isize - ax as isize, w, border)) .collect(); - let mut rows = vec![identity; h * n]; - rows.par_chunks_mut(n).enumerate().for_each_init( - || (Vec::new(), Vec::new()), - |(padded, scratch): &mut (Vec, Vec), (y, out)| { - padded.resize((w + kw - 1) * cn, identity); - let row = &src[y * n..(y + 1) * n]; - padded[ax * cn..(ax + w) * cn].copy_from_slice(row); - for px in (0..ax).chain(ax + w..w + kw - 1) { - let chunk = &mut padded[px * cn..(px + 1) * cn]; - match xmap[px] { - Some(sx) => chunk.copy_from_slice(&row[sx * cn..(sx + 1) * cn]), - None => chunk.fill(identity), + // Bands of output rows, each with its own row pass over the kh - 1 rows of halo, so the + // intermediate rows stay in cache. + let band = (2 * kh).max(32); + dst.par_chunks_mut(band * n).enumerate().for_each_init( + || (Vec::new(), Vec::new(), Vec::new(), Vec::new()), + |(padded, scratch, cur, next): &mut (Vec, Vec, Vec, Vec), (bi, out)| { + let total = out.len() / n + kh - 1; + cur.clear(); + cur.resize(total * n, identity); + for (t, line) in cur.chunks_exact_mut(n).enumerate() { + let Some(y) = border_interpolate((bi * band + t) as isize - ay as isize, h, border) + else { + continue; + }; + padded.resize((w + kw - 1) * cn, identity); + let row = &src[y * n..(y + 1) * n]; + padded[ax * cn..(ax + w) * cn].copy_from_slice(row); + for px in (0..ax).chain(ax + w..w + kw - 1) { + let chunk = &mut padded[px * cn..(px + 1) * cn]; + match xmap[px] { + Some(sx) => chunk.copy_from_slice(&row[sx * cn..(sx + 1) * cn]), + None => chunk.fill(identity), + } } + window::(padded, scratch, line, kw, cn); } - window::(padded, scratch, out, kw, cn); + window::(cur, next, out, kh, n); }, ); - - // The column pass doubles whole rows: row t of `a` holds the extreme of rows t..t+win. - let total = h + kh - 1; - let mut a = vec![identity; total * n]; - a.par_chunks_mut(n).enumerate().for_each(|(t, line)| { - if let Some(sy) = border_interpolate(t as isize - ay as isize, h, border) { - line.copy_from_slice(&rows[sy * n..(sy + 1) * n]); - } - }); - let mut b = vec![identity; total * n]; - let mut win = 1; - while win * 2 <= kh { - let valid = total - win * 2 + 1; - b[..valid * n] - .par_chunks_mut(n) - .enumerate() - .for_each(|(t, line)| { - let (x, y) = (&a[t * n..(t + 1) * n], &a[(t + win) * n..(t + win + 1) * n]); - for ((o, &p), &q) in line.iter_mut().zip(x).zip(y) { - *o = pick::(p, q); - } - }); - std::mem::swap(&mut a, &mut b); - win *= 2; - } - let off = kh - win; - dst.par_chunks_mut(n).enumerate().for_each(|(y, line)| { - let (x, z) = (&a[y * n..(y + 1) * n], &a[(y + off) * n..(y + off + 1) * n]); - for ((o, &p), &q) in line.iter_mut().zip(x).zip(z) { - *o = pick::(p, q); - } - }); } /// `cv2.erode`/`cv2.dilate` with a full `kw` x `kh` rectangle and `iterations`: OpenCV turns diff --git a/negpy_core/src/py/altprocess.rs b/negpy_core/src/py/altprocess.rs index e71dc8ab8..b6b1d292b 100644 --- a/negpy_core/src/py/altprocess.rs +++ b/negpy_core/src/py/altprocess.rs @@ -27,7 +27,7 @@ fn run<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| f(src, (h, w), dst)) + super::detach(py, dst.len(), || f(src, (h, w), dst)) .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index b651e25c5..acae371f2 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -38,7 +38,7 @@ fn luminance<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::luminance(data, dst)); + super::detach(py, dst.len(), || analysis::luminance(data, dst)); } Ok(out) } @@ -57,7 +57,7 @@ fn to_log_density<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::to_log_density(data, dst)); + super::detach(py, dst.len(), || analysis::to_log_density(data, dst)); } Ok(out) } @@ -76,7 +76,7 @@ fn normalize_to_uint8<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::normalize_to_uint8(data, dst)); + super::detach(py, dst.len(), || analysis::normalize_to_uint8(data, dst)); } Ok(out) } @@ -95,10 +95,9 @@ fn detection_luma<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::detection_luma(data, dst)) - .ok_or_else(|| { - pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") - })?; + super::detach(py, dst.len(), || analysis::detection_luma(data, dst)).ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") + })?; } Ok(out) } @@ -126,7 +125,7 @@ fn base_blocks<'py>( .iter() .map(|&v| v as u8) .collect(); - let found = py.detach(|| analysis::base_blocks(data, &mask, h, w)); + let found = super::detach(py, data.len(), || analysis::base_blocks(data, &mask, h, w)); found .map(|rows| { let flat: Vec> = rows.iter().map(|r| r.to_vec()).collect(); @@ -148,7 +147,9 @@ fn find_rebate_level( return Err(PyValueError::new_err("find_rebate_level needs a 2-D array")); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::find_rebate_level(data, h, w, film_roi, bed))) + Ok(super::detach(py, data.len(), || { + analysis::find_rebate_level(data, h, w, film_roi, bed) + })) } /// `_walk_border_depth(lines, limit, bridge, relative_step)` for an L x N float32 array. @@ -164,9 +165,10 @@ fn walk_border_depth<'py>( return Err(PyValueError::new_err("walk_border_depth needs a 2-D array")); }; let data = slice(&lines)?; - let out = py - .detach(|| analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step)) - .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; + let out = super::detach(py, data.len(), || { + analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step) + }) + .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; Ok(numpy::PyArray1::from_vec(py, out)) } @@ -183,7 +185,9 @@ fn measure_film_edges( )); }; let data = slice(&lum)?; - let [t, b, l, r] = py.detach(|| analysis::measure_film_edges(data, h, w, film_roi)); + let [t, b, l, r] = super::detach(py, data.len(), || { + analysis::measure_film_edges(data, h, w, film_roi) + }); Ok((t, b, l, r)) } @@ -198,7 +202,9 @@ fn trim_film_edges( return Err(PyValueError::new_err("trim_film_edges needs a 2-D array")); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::trim_film_edges(data, h, w, film_roi))) + Ok(super::detach(py, data.len(), || { + analysis::trim_film_edges(data, h, w, film_roi) + })) } /// `measure_film_border(lum, film_roi)`: (top, bottom, left, right) border fractions. @@ -214,7 +220,9 @@ fn measure_film_border( )); }; let data = slice(&lum)?; - let [t, b, l, r] = py.detach(|| analysis::measure_film_border(data, h, w, film_roi)); + let [t, b, l, r] = super::detach(py, data.len(), || { + analysis::measure_film_border(data, h, w, film_roi) + }); Ok((t, b, l, r)) } @@ -231,7 +239,9 @@ fn roi_from_measured_border( )); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::roi_from_measured_border(data, h, w, film_roi))) + Ok(super::detach(py, data.len(), || { + analysis::roi_from_measured_border(data, h, w, film_roi) + })) } /// `_smooth_signal(signal, window)` for a 1-D float32 signal. @@ -242,13 +252,13 @@ fn smooth_signal<'py>( window: usize, ) -> PyResult>> { let data = slice(&signal)?.to_vec(); - let out = py - .detach(|| analysis::smooth_signal(&data, window)) - .ok_or_else(|| { + let out = super::detach(py, data.len(), || analysis::smooth_signal(&data, window)).ok_or_else( + || { PyValueError::new_err( "smooth_signal covers a window of at most 31, no longer than the signal", ) - })?; + }, + )?; Ok(numpy::PyArray1::from_vec(py, out)) } @@ -272,7 +282,9 @@ fn snap_edge_to_gradient( min_dominance, min_window_px, }; - Ok(py.detach(|| analysis::snap_edge_to_gradient(data, idx, is_start, snap))) + Ok(super::detach(py, data.len(), || { + analysis::snap_edge_to_gradient(data, idx, is_start, snap) + })) } type Refined<'py> = ( @@ -294,7 +306,9 @@ fn refine_film_roi_by_tiers<'py>( )); }; let data = slice(&lum)?; - let found = py.detach(|| analysis::refine_film_roi_by_tiers(data, h, w, film_roi)); + let found = super::detach(py, data.len(), || { + analysis::refine_film_roi_by_tiers(data, h, w, film_roi) + }); Ok(found.map(|r| { ( r.roi, @@ -320,10 +334,12 @@ fn refine_frame_bounds( } }; let data = slice(&image)?; - py.detach(|| analysis::refine_frame_bounds(data, h, w, cn)) - .ok_or_else(|| { - PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") - }) + super::detach(py, data.len(), || { + analysis::refine_frame_bounds(data, h, w, cn) + }) + .ok_or_else(|| { + PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") + }) } type Gated<'py> = ( @@ -350,11 +366,12 @@ fn refine_roi_to_image<'py>( } }; let data = slice(&img)?; - let (roi, tiers) = py - .detach(|| analysis::refine_roi_to_image(data, h, w, cn, film_roi)) - .ok_or_else(|| { - PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") - })?; + let (roi, tiers) = super::detach(py, data.len(), || { + analysis::refine_roi_to_image(data, h, w, cn, film_roi) + }) + .ok_or_else(|| { + PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") + })?; Ok(match tiers { Some(t) => ( roi, @@ -388,7 +405,9 @@ fn detect_film_bounds<'py>( } }; let data = slice(&img)?; - let d = py.detach(|| crate::detect::detect_film_bounds(data, h, w, cn)); + let d = super::detach(py, data.len(), || { + crate::detect::detect_film_bounds(data, h, w, cn) + }); Ok(( d.consensus.map(consensus_fields), numpy::PyArray1::from_vec(py, d.profile), @@ -417,7 +436,9 @@ fn normalize_log_image<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| analysis::normalize_log_image(data, floors, ceils, dst)); + super::detach(py, dst.len(), || { + analysis::normalize_log_image(data, floors, ceils, dst) + }); } Ok(out) } @@ -438,7 +459,9 @@ fn block_median_grid<'py>( return Err(PyValueError::new_err("block size must be positive")); } let data = slice(&img)?; - let grid = py.detach(|| analysis::block_median_grid(data, (h, w, c), b)); + let grid = super::detach(py, data.len(), || { + analysis::block_median_grid(data, (h, w, c), b) + }); numpy::PyArray1::from_vec(py, grid).reshape(vec![h / b, w / b, c]) } @@ -453,7 +476,9 @@ fn threshold_box( return Err(PyValueError::new_err("threshold_box needs a 2-D array")); }; let data = slice(&lum)?; - Ok(py.detach(|| analysis::threshold_box(data, h, w, threshold))) + Ok(super::detach(py, data.len(), || { + analysis::threshold_box(data, h, w, threshold) + })) } /// Batch autocrop's `_vertical_edge_profile` of an HxW or HxWxC (C >= 3) float32 image: @@ -473,7 +498,9 @@ fn batch_edge_profile<'py>( } }; let data = slice(&img)?; - let (profile, contrast) = py.detach(|| analysis::batch_edge_profile(data, h, w, cn)); + let (profile, contrast) = super::detach(py, data.len(), || { + analysis::batch_edge_profile(data, h, w, cn) + }); Ok((numpy::PyArray1::from_vec(py, profile), contrast)) } @@ -493,10 +520,9 @@ fn film_and_picture( ) -> PyResult<(Option, analysis::Roi)> { let (h, w) = det_dims(&det)?; let data = slice(&det)?; - py.detach(|| analysis::film_and_picture(data, h, w)) - .ok_or_else(|| { - PyValueError::new_err("film_and_picture: image too small for the Sobel path") - }) + super::detach(py, data.len(), || analysis::film_and_picture(data, h, w)).ok_or_else(|| { + PyValueError::new_err("film_and_picture: image too small for the Sobel path") + }) } /// `measure_base_log` of an HxWx3 float32 detection image: (r, g, b) log10 or None. @@ -507,7 +533,9 @@ fn measure_base_log( ) -> PyResult> { let (h, w) = det_dims(&det)?; let data = slice(&det)?; - Ok(py.detach(|| analysis::measure_base_log(data, h, w))) + Ok(super::detach(py, data.len(), || { + analysis::measure_base_log(data, h, w) + })) } type ConsensusFields = ( diff --git a/negpy_core/src/py/bounds.rs b/negpy_core/src/py/bounds.rs index f82cea685..bff521999 100644 --- a/negpy_core/src/py/bounds.rs +++ b/negpy_core/src/py/bounds.rs @@ -20,7 +20,9 @@ fn channel_percentiles( qs: Vec, ) -> PyResult<[Vec; 3]> { let g = grid_of(&grid)?; - Ok(py.detach(|| bounds::channel_percentiles(g, &qs))) + Ok(super::detach(py, g.len(), || { + bounds::channel_percentiles(g, &qs) + })) } /// `_same_pixel_color_floor_refs` with its constants (chroma quantile, chroma cap, first-pass @@ -44,7 +46,7 @@ fn same_pixel_color_floor_refs( min_pixels, band_width, }; - Ok(py.detach(|| { + Ok(super::detach(py, g.len(), || { bounds::same_pixel_color_floor_refs(g, luma_floors, luma_ceils, base, color_clip, c) })) } diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs index 7639b5c91..77f118eee 100644 --- a/negpy_core/src/py/color.rs +++ b/negpy_core/src/py/color.rs @@ -21,7 +21,7 @@ fn filled<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(data, dst)); + super::detach(py, dst.len(), || fill(data, dst)); } Ok(out) } diff --git a/negpy_core/src/py/components.rs b/negpy_core/src/py/components.rs index f1ee29be5..18bae0628 100644 --- a/negpy_core/src/py/components.rs +++ b/negpy_core/src/py/components.rs @@ -29,7 +29,9 @@ fn connected_components_with_stats<'py>( let data = mask .as_slice() .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; - let c = py.detach(|| components::connected_components(data, h, w, connectivity)); + let c = super::detach(py, data.len(), || { + components::connected_components(data, h, w, connectivity) + }); let labels = numpy::PyArray1::from_vec(py, c.labels).reshape(vec![h, w])?; let stats = numpy::PyArray2::from_vec2(py, &c.stats.iter().map(|s| s.to_vec()).collect::>()) @@ -72,7 +74,9 @@ fn distance_transform<'py>( } }; let (data, h, w) = mask_of(&mask)?; - let d = py.detach(|| distance::distance_transform(data, h, w, metric)); + let d = super::detach(py, data.len(), || { + distance::distance_transform(data, h, w, metric) + }); numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) } @@ -88,7 +92,9 @@ fn distance_transform_labels<'py>( mask: PyReadonlyArrayDyn<'py, u8>, ) -> PyResult> { let (data, h, w) = mask_of(&mask)?; - let (d, l) = py.detach(|| crate::distance::distance_transform_labels(data, h, w)); + let (d, l) = super::detach(py, data.len(), || { + crate::distance::distance_transform_labels(data, h, w) + }); Ok(( numpy::PyArray1::from_vec(py, d).reshape(vec![h, w])?, numpy::PyArray1::from_vec(py, l).reshape(vec![h, w])?, @@ -104,7 +110,9 @@ fn mask_to_score<'py>( floor: f64, ) -> PyResult>> { let (data, h, w) = mask_of(&mask)?; - let d = py.detach(|| crate::distance::mask_to_score(data, h, w, pad, floor)); + let d = super::detach(py, data.len(), || { + crate::distance::mask_to_score(data, h, w, pad, floor) + }); numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) } diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index 0ccd5c445..0d05bfb1b 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -19,7 +19,9 @@ fn find_external_contours<'py>( let data = src .as_slice() .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; - let found = py.detach(|| contours::find_external_contours(data, h, w)); + let found = super::detach(py, data.len(), || { + contours::find_external_contours(data, h, w) + }); let k = found.len(); let arrays: Vec>> = found .iter() diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs index 9b23bcd3b..86f018bf6 100644 --- a/negpy_core/src/py/convert.rs +++ b/negpy_core/src/py/convert.rs @@ -37,7 +37,7 @@ fn filled<'py, T: numpy::Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(dst)); + super::detach(py, dst.len(), || fill(dst)); } Ok(out) } @@ -129,10 +129,10 @@ fn rgb_to_rgba_into( } let cn = s.dim().2; if let (Some(src), Some(dst)) = (s.as_slice(), d.as_slice_mut()) { - py.detach(|| convert::rgb_to_rgba(src, cn, dst)); + super::detach(py, dst.len(), || convert::rgb_to_rgba(src, cn, dst)); return Ok(()); } - py.detach(|| { + super::detach(py, 0, || { for (srow, mut drow) in s.outer_iter().zip(d.outer_iter_mut()) { for (sp, mut dp) in srow.outer_iter().zip(drow.outer_iter_mut()) { for c in 0..3 { @@ -158,7 +158,7 @@ fn rgba_to_rgb_into( if oy + h > s.dim().0 || ox + w > s.dim().1 || s.dim().2 < 3 || dc < 3 { return Err(PyValueError::new_err("region outside the source")); } - py.detach(|| { + super::detach(py, 0, || { for (y, mut drow) in d.outer_iter_mut().enumerate() { let srow = s.index_axis(numpy::ndarray::Axis(0), oy + y); // A padded readback has whole RGBA rows: copy them as slices when it does. diff --git a/negpy_core/src/py/display.rs b/negpy_core/src/py/display.rs index ff4cfeb67..693723ace 100644 --- a/negpy_core/src/py/display.rs +++ b/negpy_core/src/py/display.rs @@ -40,7 +40,7 @@ fn filled<'py, T: numpy::Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(dst)); + super::detach(py, dst.len(), || fill(dst)); } Ok(out) } diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs index 75e7631ac..2d4f39262 100644 --- a/negpy_core/src/py/filter.rs +++ b/negpy_core/src/py/filter.rs @@ -48,7 +48,7 @@ fn filled<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| fill(dst)) + super::detach(py, dst.len(), || fill(dst)) .ok_or_else(|| PyValueError::new_err("kernel shape not covered by negpy_core"))?; } Ok(out) diff --git a/negpy_core/src/py/finish.rs b/negpy_core/src/py/finish.rs index e903951ed..c3ee0ab9d 100644 --- a/negpy_core/src/py/finish.rs +++ b/negpy_core/src/py/finish.rs @@ -47,7 +47,9 @@ fn apply_carrier<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| finish::apply_carrier(src, (h, w), &c, dst)); + super::detach(py, dst.len(), || { + finish::apply_carrier(src, (h, w), &c, dst) + }); } Ok(out) } diff --git a/negpy_core/src/py/gray.rs b/negpy_core/src/py/gray.rs index e72fdac95..c7ab90e3f 100644 --- a/negpy_core/src/py/gray.rs +++ b/negpy_core/src/py/gray.rs @@ -35,7 +35,7 @@ fn bgr_to_gray<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| gray::bgr_to_gray(data, dst)); + super::detach(py, dst.len(), || gray::bgr_to_gray(data, dst)); } Ok(out) } @@ -66,8 +66,10 @@ fn gaussian_blur_u8<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border)) - .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; + super::detach(py, dst.len(), || { + gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border) + }) + .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; } Ok(out) } @@ -92,7 +94,7 @@ fn threshold_otsu<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| { + super::detach(py, dst.len(), || { let t = gray::otsu_threshold(data); gray::threshold_binary(data, dst, t, maxval, invert); t @@ -120,7 +122,7 @@ fn canny<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| gray::canny(data, dst, h, w, low, high)) + super::detach(py, dst.len(), || gray::canny(data, dst, h, w, low, high)) .ok_or_else(|| PyValueError::new_err("invalid canny input"))?; } Ok(out) @@ -149,8 +151,10 @@ fn median_blur<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| crate::median::median_blur_u8(data, dst, h, w, ksize)) - .ok_or_else(|| PyValueError::new_err("ksize must be odd"))?; + super::detach(py, dst.len(), || { + crate::median::median_blur_u8(data, dst, h, w, ksize) + }) + .ok_or_else(|| PyValueError::new_err("ksize must be odd"))?; } Ok(out) } diff --git a/negpy_core/src/py/hdr.rs b/negpy_core/src/py/hdr.rs index 6a1356ed5..2b5b536a3 100644 --- a/negpy_core/src/py/hdr.rs +++ b/negpy_core/src/py/hdr.rs @@ -27,7 +27,9 @@ fn count_clipped_rows( let dst = out .as_slice_mut() .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; - py.detach(|| hdr::count_clipped_rows(v, (w, cn), saturation, dst)); + super::detach(py, dst.len(), || { + hdr::count_clipped_rows(v, (w, cn), saturation, dst) + }); Ok(()) } @@ -54,7 +56,9 @@ fn hdr_accumulate( let d = den .as_slice_mut() .map_err(|_| PyValueError::new_err("needs contiguous sums"))?; - py.detach(|| hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start))); + super::detach(py, v.len(), || { + hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start)) + }); Ok(()) } diff --git a/negpy_core/src/py/histogram.rs b/negpy_core/src/py/histogram.rs index d561b87c7..f0d4b77a3 100644 --- a/negpy_core/src/py/histogram.rs +++ b/negpy_core/src/py/histogram.rs @@ -44,7 +44,7 @@ fn density_histogram<'py>( step: usize, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = py.detach(|| histogram::density_histogram(s)); + let counts = super::detach(py, s.img.len(), || histogram::density_histogram(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![4, DENSITY_HIST_BINS]) } @@ -57,7 +57,7 @@ fn density_parade<'py>( step: usize, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = py.detach(|| histogram::density_parade(s)); + let counts = super::detach(py, s.img.len(), || histogram::density_parade(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_BINS, WAVEFORM_COLS]) } @@ -79,11 +79,11 @@ fn output_histogram<'py>( ) -> PyResult> { let counts = if let Ok(a) = img.extract::>() { let s = frame(&a, step)?; - py.detach(|| histogram::output_histogram_u8(s)) + super::detach(py, s.img.len(), || histogram::output_histogram_u8(s)) } else { let a = img.extract::>()?; let s = frame(&a, step)?; - py.detach(|| histogram::output_histogram(s)) + super::detach(py, s.img.len(), || histogram::output_histogram(s)) }; numpy::PyArray1::from_vec(py, counts).reshape(vec![4, OUTPUT_HIST_BINS]) } @@ -96,7 +96,7 @@ fn color_histogram<'py>( step: usize, ) -> PyResult> { let s = frame(&img, step)?; - let counts = py.detach(|| histogram::color_histogram(s)); + let counts = super::detach(py, s.img.len(), || histogram::color_histogram(s)); numpy::PyArray1::from_vec(py, counts).reshape(vec![COLOR_HIST_BINS; 3]) } @@ -116,7 +116,7 @@ fn waveform<'py>( _ => return Err(PyValueError::new_err(format!("unknown waveform {kind:?}"))), }; let s = frame(&img, step)?; - let counts = py.detach(|| histogram::waveform(s, kind)); + let counts = super::detach(py, s.img.len(), || histogram::waveform(s, kind)); let shape = if kind == Waveform::Luma { vec![WAVEFORM_BINS, WAVEFORM_COLS] } else { @@ -135,7 +135,9 @@ fn scan_full_scale_columns<'py>( level: f32, ) -> PyResult> { let s = samples(&img, roi, step)?; - let counts = py.detach(|| histogram::scan_full_scale_columns(s, level)); + let counts = super::detach(py, s.img.len(), || { + histogram::scan_full_scale_columns(s, level) + }); numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_COLS]) } @@ -166,7 +168,9 @@ fn vectorscope_histogram<'py>( eps, kappa, ); - let counts = py.detach(|| histogram::vectorscope_histogram(s, space, gamma)); + let counts = super::detach(py, s.img.len(), || { + histogram::vectorscope_histogram(s, space, gamma) + }); numpy::PyArray1::from_vec(py, counts).reshape(vec![SCOPE_HUE_BINS, SCOPE_CHROMA_BINS]) } diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index ff4db12c1..3df27a5f1 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -40,8 +40,10 @@ fn usm_sharpen<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| lab::usm_sharpen(src, (h, w), (gain, masking), k, spaces, dst)) - .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + super::detach(py, dst.len(), || { + lab::usm_sharpen(src, (h, w), (gain, masking), k, spaces, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) } @@ -77,8 +79,10 @@ fn chroma_denoise<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| lab::chroma_denoise(src, (h, w), sigma, k, spaces, dst)) - .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + super::detach(py, dst.len(), || { + lab::chroma_denoise(src, (h, w), sigma, k, spaces, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) } @@ -117,7 +121,7 @@ fn glow_and_halation<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| { + super::detach(py, dst.len(), || { lab::glow_and_halation(src, (h, w), (glow, halation), (lum.into(), inv_gamma), dst) }) .ok_or_else(|| PyValueError::new_err("a bloom needs an odd kernel size"))?; @@ -155,7 +159,9 @@ fn clahe<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| lab::clahe(src, (h, w), strength, spaces, dst)); + super::detach(py, dst.len(), || { + lab::clahe(src, (h, w), strength, spaces, dst) + }); } Ok(out) } @@ -193,8 +199,10 @@ fn rl_sharpen<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| lab::rl_sharpen(src, (h, w), ¶ms, k, dst)) - .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + super::detach(py, dst.len(), || { + lab::rl_sharpen(src, (h, w), ¶ms, k, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; } Ok(out) } @@ -231,7 +239,7 @@ fn split_toning<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| lab::split_toning(src, tints, spaces, dst)); + super::detach(py, dst.len(), || lab::split_toning(src, tints, spaces, dst)); } Ok(out) } diff --git a/negpy_core/src/py/meter.rs b/negpy_core/src/py/meter.rs index d55c2ca27..58bd4dfe2 100644 --- a/negpy_core/src/py/meter.rs +++ b/negpy_core/src/py/meter.rs @@ -52,7 +52,9 @@ fn textured_percentile( q: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(py.detach(|| meter::percentile(&textured(g, Some(norm), a), q))) + Ok(super::detach(py, g.0.len(), || { + meter::percentile(&textured(g, Some(norm), a), q) + })) } /// The anchor meter's measurement over the grid's textured normalized luma. @@ -65,7 +67,9 @@ fn textured_anchor( clip: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(py.detach(|| meter::anchor_measure(&textured(g, Some(norm), a), clip))) + Ok(super::detach(py, g.0.len(), || { + meter::anchor_measure(&textured(g, Some(norm), a), clip) + })) } /// The textural range of the grid's textured raw-log luma. @@ -77,7 +81,9 @@ fn textured_range( clip: f64, ) -> PyResult { let (g, a) = (grid_of(&grid)?, activity(gate)?); - Ok(py.detach(|| meter::textural_range(&textured(g, None, a), clip))) + Ok(super::detach(py, g.0.len(), || { + meter::textural_range(&textured(g, None, a), clip) + })) } type Band = (f32, f32); @@ -147,7 +153,7 @@ fn neutral_axis( }; let f = [floors.0, floors.1, floors.2]; let e = [ceils.0, ceils.1, ceils.2]; - let axis = py.detach(|| { + let axis = super::detach(py, data.len(), || { meter::neutral_axis( data, (f.map(|v| v as f32), e.map(|v| v as f32)), diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 5f2dc1c3c..b6f185de2 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -22,3 +22,45 @@ pub mod skew; pub mod stats; pub mod toning; pub mod warp; + +use pyo3::Python; +use pyo3::marker::Ungil; + +/// Work under this many elements stays on the calling thread: waking other workers for each +/// parallel pass of a tiny image costs more than the passes. +const SMALL: usize = 1 << 16; + +thread_local! { + /// A one-thread pool made of the calling thread itself, so its parallel passes run inline. + static LOCAL: Option = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .use_current_thread() + .build() + .ok(); +} + +/// Every core. A thread that joined `LOCAL` stays in it, so large work enters this pool +/// explicitly rather than through rayon's global one. +fn all_cores() -> &'static rayon::ThreadPool { + static POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + rayon::ThreadPoolBuilder::new() + .build() + .expect("cannot start the negpy_core thread pool") + }) +} + +/// `py.detach(f)` for work of `n` elements: the GIL released, and every binding's work run +/// through here so that each call enters a pool once. +pub fn detach( + py: Python<'_>, + n: usize, + f: impl FnOnce() -> T + Ungil + Send, +) -> T { + py.detach(|| { + LOCAL.with(|local| match local { + Some(p) if n < SMALL => p.install(f), + _ => all_cores().install(f), + }) + }) +} diff --git a/negpy_core/src/py/morph.rs b/negpy_core/src/py/morph.rs index c51fe1b02..f1fec4740 100644 --- a/negpy_core/src/py/morph.rs +++ b/negpy_core/src/py/morph.rs @@ -37,7 +37,7 @@ fn run<'py, T: Pixel + Element>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| { + super::detach(py, dst.len(), || { if kernel.mask.iter().all(|&v| v != 0) { let ksize = (kernel.w, kernel.h); morph::morphology(data, dst, dims, op, ksize, iterations, border) diff --git a/negpy_core/src/py/print_curve.rs b/negpy_core/src/py/print_curve.rs index 1208ef4f1..95ce4d0ca 100644 --- a/negpy_core/src/py/print_curve.rs +++ b/negpy_core/src/py/print_curve.rs @@ -230,7 +230,9 @@ fn apply_print_curve<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| print_curve::apply_print_curve(src, w, &p, dst)); + super::detach(py, dst.len(), || { + print_curve::apply_print_curve(src, w, &p, dst) + }); } Ok(out) } diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index f689aafe9..03b741f81 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -25,11 +25,12 @@ fn resize_area<'py>( .as_slice() .map_err(|_| PyValueError::new_err("resize_area needs a contiguous array"))?; let (dw, dh) = dsize; - let out = py - .detach(|| resize::resize_area(data, h, w, cn, dh, dw)) - .ok_or_else(|| { - PyValueError::new_err("resize_area does not cover a 2x2 two-channel downscale") - })?; + let out = super::detach(py, data.len(), || { + resize::resize_area(data, h, w, cn, dh, dw) + }) + .ok_or_else(|| { + PyValueError::new_err("resize_area does not cover a 2x2 two-channel downscale") + })?; let dims: Vec = if shape.len() == 2 { vec![dh, dw] } else { @@ -71,12 +72,14 @@ fn resize_linear<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| resize::resize_linear(data, (h, w, cn), (dh, dw), false, dst)) - .ok_or_else(|| { - PyValueError::new_err( - "resize_linear needs a non-empty size and covers no 2x2 two-channel halving", - ) - })?; + super::detach(py, dst.len(), || { + resize::resize_linear(data, (h, w, cn), (dh, dw), false, dst) + }) + .ok_or_else(|| { + PyValueError::new_err( + "resize_linear needs a non-empty size and covers no 2x2 two-channel halving", + ) + })?; } Ok(out) } @@ -115,7 +118,9 @@ fn resize_nearest<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| resize::resize_nearest(data, (h, w, cn), (dh, dw), dst)); + super::detach(py, dst.len(), || { + resize::resize_nearest(data, (h, w, cn), (dh, dw), dst) + }); } Ok(out) } diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 2dde4d811..5b7479ea2 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -14,80 +14,6 @@ fn plane<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>, n: usize) -> PyResult<&'a [f32] Ok(s) } -fn pixels(a: &PyReadwriteArrayDyn<'_, f32>) -> PyResult { - match a.shape()[..] { - [h, w, 3] => Ok(h * w), - _ => Err(PyValueError::new_err("needs an HxWx3 array")), - } -} - -/// `_fill_rung(num, den, fill, first, reject_floor_mass)` in place, with (score floor, tau). -#[pyfunction] -fn fill_rung( - py: Python<'_>, - num: PyReadonlyArrayDyn<'_, f32>, - den: PyReadonlyArrayDyn<'_, f32>, - mut fill: PyReadwriteArrayDyn<'_, f32>, - first: bool, - reject_floor_mass: bool, - constants: (f32, f32), -) -> PyResult<()> { - let n = pixels(&fill)?; - let (num, den) = (plane(&num, 3 * n)?, plane(&den, n)?); - let fill = fill - .as_slice_mut() - .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - py.detach(|| retouch::fill_rung(num, den, fill, first, reject_floor_mass, constants)); - Ok(()) -} - -/// `_blend_fill(src, score, fill, a_out)` in place, with (write ramp top, ramp span). -#[pyfunction] -fn blend_fill( - py: Python<'_>, - src: PyReadonlyArrayDyn<'_, f32>, - score: PyReadonlyArrayDyn<'_, f32>, - mut fill: PyReadwriteArrayDyn<'_, f32>, - mut a_out: PyReadwriteArrayDyn<'_, f32>, - ramp: (f32, f32), -) -> PyResult<()> { - let n = pixels(&fill)?; - let (src, score) = (plane(&src, 3 * n)?, plane(&score, n)?); - let fill = fill - .as_slice_mut() - .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - let a_out = a_out - .as_slice_mut() - .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - if a_out.len() != n { - return Err(PyValueError::new_err("array sizes disagree")); - } - py.detach(|| retouch::blend_fill(src, score, fill, a_out, ramp)); - Ok(()) -} - -/// `_add_floor_deficit(out, a, blur_src, blur_out)` in place. -#[pyfunction] -fn add_floor_deficit( - py: Python<'_>, - mut out: PyReadwriteArrayDyn<'_, f32>, - a: PyReadonlyArrayDyn<'_, f32>, - blur_src: PyReadonlyArrayDyn<'_, f32>, - blur_out: PyReadonlyArrayDyn<'_, f32>, -) -> PyResult<()> { - let n = pixels(&out)?; - let (a, bs, bo) = ( - plane(&a, n)?, - plane(&blur_src, 3 * n)?, - plane(&blur_out, 3 * n)?, - ); - let out = out - .as_slice_mut() - .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; - py.detach(|| retouch::add_floor_deficit(out, a, bs, bo)); - Ok(()) -} - /// `_hair_encode(crop, lo, inv_span, out_u8)` of an HxWxC float32 tile. #[pyfunction] fn hair_encode( @@ -115,13 +41,266 @@ fn hair_encode( let dst = out .as_slice_mut() .map_err(|_| pyo3::exceptions::PyValueError::new_err("needs a contiguous out"))?; - py.detach(|| crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst)); + super::detach(py, dst.len(), || { + crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst) + }); + Ok(()) +} + +/// `_ir_clean_base(img_det, ratio)` with (window, identity, sigma, min_support). +#[pyfunction] +fn ir_clean_base<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + ratio: PyReadonlyArrayDyn<'py, f32>, + constants: (usize, f32, f32, f32), +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + if ratio.shape() != [h, w] { + return Err(PyValueError::new_err("needs an HxW ratio")); + } + let (src, ratio) = (plane(&img, 3 * h * w)?, plane(&ratio, h * w)?); + let (win, identity, sigma, min_support) = constants; + let c = retouch::CleanBase { + win, + identity, + sigma, + min_support, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + retouch::ir_clean_base(src, ratio, (h, w), &c, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid clean-base input"))?; + } + Ok(out) +} + +fn plane2<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let [h, w] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW plane")); + }; + Ok((plane(a, h * w)?, (h, w))) +} + +fn mask<'a>(a: &'a PyReadonlyArrayDyn<'_, bool>, n: usize) -> PyResult<&'a [bool]> { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + if s.len() != n { + return Err(PyValueError::new_err("array sizes disagree")); + } + Ok(s) +} + +fn bool_array<'py>( + py: Python<'py>, + data: Vec, + (h, w): (usize, usize), +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + data.into_pyarray(py).reshape(vec![h, w]) +} + +/// `_ir_live(plane)` with (dead floor, minimum holder area as a fraction of the frame). +#[pyfunction] +fn ir_live<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + floor: f32, + min_area: f64, +) -> PyResult>> { + let (src, dims) = plane2(&plane)?; + let live = super::detach(py, src.len(), || { + retouch::ir_live(src, dims, floor, min_area) + }); + bool_array(py, live, dims) +} + +/// `_ir_glare(plane, live)` with (sample cap, sigmas). +#[pyfunction] +fn ir_glare<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + live: PyReadonlyArrayDyn<'py, bool>, + cap: usize, + sigmas: f64, +) -> PyResult>>> { + let (src, dims) = plane2(&plane)?; + let live = mask(&live, src.len())?; + super::detach(py, src.len(), || retouch::ir_glare(src, live, cap, sigmas)) + .map(|g| bool_array(py, g, dims)) + .transpose() +} + +/// `normalize_ir(plane, glare)` for a float32 plane, with the base window in pixels. +#[pyfunction] +#[pyo3(signature = (plane, glare, win))] +fn normalize_ir<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + glare: Option>, + win: usize, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let (src, dims) = plane2(&plane)?; + let glare = glare.as_ref().map(|g| mask(g, src.len())).transpose()?; + let out = PyArrayDyn::::zeros(py, plane.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + retouch::normalize_ir(src, glare, dims, win, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid normalize_ir input"))?; + } + Ok(out) +} + +/// `_ir_normalize_ratio(ratio, live)` in place, with (identity, noise sigma, pivot floor, +/// reference sigma, scale cap); returns (pivot, sigma), or None when it left the ratio alone. +#[pyfunction] +fn ir_normalize_ratio( + py: Python<'_>, + mut ratio: PyReadwriteArrayDyn<'_, f32>, + live: PyReadonlyArrayDyn<'_, bool>, + constants: (f64, f64, f64, f64, f64), +) -> PyResult> { + let r = ratio + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + let live = mask(&live, r.len())?; + let (identity, noise_sigma, pivot_lo, ref_sigma, scale_max) = constants; + let c = retouch::RatioNorm { + identity, + noise_sigma, + pivot_lo, + ref_sigma, + scale_max, + }; + Ok(super::detach(py, r.len(), || { + retouch::ir_normalize_ratio(r, live, &c) + })) +} + +/// `_ir_decontaminate`'s division of an HxW ratio by the ghost of HxWx3 `vis_log`. +#[pyfunction] +fn ir_ghost_divide<'py>( + py: Python<'py>, + ratio: PyReadonlyArrayDyn<'py, f32>, + vis_log: PyReadonlyArrayDyn<'py, f32>, + b: [f32; 3], +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let (r, (h, w)) = plane2(&ratio)?; + if vis_log.shape() != [h, w, 3] { + return Err(PyValueError::new_err("needs an HxWx3 vis_log")); + } + let v = plane(&vis_log, 3 * h * w)?; + let out = PyArrayDyn::::zeros(py, ratio.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || retouch::ir_ghost_divide(r, v, b, dst)); + } + Ok(out) +} + +fn image3<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let [h, w, 3] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + Ok((plane(a, 3 * h * w)?, (h, w))) +} + +fn new_image<'py>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let out = PyArrayDyn::::zeros(py, shape, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(dst)) + .ok_or_else(|| PyValueError::new_err("invalid input"))?; + } + Ok(out) +} + +/// `out[idx] += _borrow_clean_grain(src, clean, sigma, idx)[0]` for every pixel `clean` leaves +/// out, in place. +#[pyfunction] +fn add_clean_grain( + py: Python<'_>, + mut out: PyReadwriteArrayDyn<'_, f32>, + src: PyReadonlyArrayDyn<'_, f32>, + clean: PyReadonlyArrayDyn<'_, bool>, + sigma: f64, +) -> PyResult<()> { + let (s, dims) = image3(&src)?; + let clean = mask(&clean, dims.0 * dims.1)?; + if out.shape() != src.shape() { + return Err(PyValueError::new_err("needs a matching out")); + } + let o = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; + super::detach(py, s.len(), || { + retouch::add_clean_grain(s, clean, dims, sigma, None, o) + }); Ok(()) } +/// `apply_score_repair` of an HxWx3 image and a score at its resolution, with the supports, the +/// floor rule, the grain sigma and (score floor, tau, write hi, write hi - write lo). +#[pyfunction] +fn apply_score_repair<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + score: PyReadonlyArrayDyn<'py, f32>, + supports: Vec, + floor: bool, + sigma: f64, + constants: (f32, f32, f32, f32), +) -> PyResult>> { + let (src, dims) = image3(&img)?; + let sc = plane(&score, dims.0 * dims.1)?; + let (score_floor, tau, write_hi, write_span) = constants; + let c = retouch::ScoreRepair { + score_floor, + tau, + write_hi, + write_span, + }; + new_image(py, img.shape(), |dst| { + retouch::apply_score_repair(src, sc, dims, &supports, floor, sigma, &c, dst) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(hair_encode, m)?)?; - m.add_function(wrap_pyfunction!(fill_rung, m)?)?; - m.add_function(wrap_pyfunction!(blend_fill, m)?)?; - m.add_function(wrap_pyfunction!(add_floor_deficit, m)?) + m.add_function(wrap_pyfunction!(add_clean_grain, m)?)?; + m.add_function(wrap_pyfunction!(apply_score_repair, m)?)?; + m.add_function(wrap_pyfunction!(ir_ghost_divide, m)?)?; + m.add_function(wrap_pyfunction!(ir_live, m)?)?; + m.add_function(wrap_pyfunction!(ir_glare, m)?)?; + m.add_function(wrap_pyfunction!(normalize_ir, m)?)?; + m.add_function(wrap_pyfunction!(ir_normalize_ratio, m)?)?; + m.add_function(wrap_pyfunction!(ir_clean_base, m)?)?; + m.add_function(wrap_pyfunction!(hair_encode, m)?) } diff --git a/negpy_core/src/py/sensor.rs b/negpy_core/src/py/sensor.rs index 539b28bae..0741daf3c 100644 --- a/negpy_core/src/py/sensor.rs +++ b/negpy_core/src/py/sensor.rs @@ -57,7 +57,7 @@ fn sensor_unmix( image(&x)?; let (src, m) = (data(&x)?, matrix(&m)?); let dst = target(&mut out, x.shape())?; - py.detach(|| sensor::unmix(src, dst, m)); + super::detach(py, dst.len(), || sensor::unmix(src, dst, m)); Ok(()) } @@ -74,7 +74,9 @@ fn sensor_log_unmix( image(&x)?; let (src, c, offset, floor) = (data(&x)?, matrix(&c)?, triplet(&offset)?, triplet(&floor)?); let dst = target(&mut out, x.shape())?; - py.detach(|| sensor::log_unmix(src, dst, c, offset, floor)); + super::detach(py, dst.len(), || { + sensor::log_unmix(src, dst, c, offset, floor) + }); Ok(()) } @@ -104,7 +106,9 @@ fn sensor_two_scale_layer( start, full, }; - py.detach(|| sensor::two_scale_layer(src, m, own, s, c, w)); + super::detach(py, src.len(), || { + sensor::two_scale_layer(src, m, own, s, c, w) + }); Ok(()) } @@ -131,7 +135,9 @@ fn sensor_two_scale( let (xs, b, col, w) = (data(&x)?, data(&blurred)?, data(&color)?, data(&weight)?); let (m, c) = (matrix(&m)?, matrix(&c)?); let dst = target(&mut out, &shape)?; - py.detach(|| sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst)); + super::detach(py, dst.len(), || { + sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst) + }); Ok(()) } diff --git a/negpy_core/src/py/skew.rs b/negpy_core/src/py/skew.rs index a9a770344..8bbca9fca 100644 --- a/negpy_core/src/py/skew.rs +++ b/negpy_core/src/py/skew.rs @@ -28,7 +28,9 @@ fn skew_sample<'py>( if st.len() != ce.len() { return Err(PyValueError::new_err("needs one center per station")); } - let (xs, ys) = py.detach(|| skew::sample(img, (h, w), horizontal, (st, ce), half, nearest)); + let (xs, ys) = super::detach(py, img.len(), || { + skew::sample(img, (h, w), horizontal, (st, ce), half, nearest) + }); Ok((xs.into_pyarray(py), ys.into_pyarray(py))) } diff --git a/negpy_core/src/py/stats.rs b/negpy_core/src/py/stats.rs index 5e9be79c6..81e75f09d 100644 --- a/negpy_core/src/py/stats.rs +++ b/negpy_core/src/py/stats.rs @@ -19,7 +19,7 @@ fn percentiles( let values = values .as_slice() .map_err(|_| PyValueError::new_err("percentiles needs a contiguous array"))?; - py.detach(|| stats::percentiles(values, &qs)) + super::detach(py, values.len(), || stats::percentiles(values, &qs)) .ok_or_else(|| PyIndexError::new_err("percentiles of an empty array")) } diff --git a/negpy_core/src/py/toning.rs b/negpy_core/src/py/toning.rs index e0d60e2c6..723d3c5e9 100644 --- a/negpy_core/src/py/toning.rs +++ b/negpy_core/src/py/toning.rs @@ -79,7 +79,9 @@ fn apply_chemical_toning<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| toning::apply_chemical_toning(src, dst, &t)); + super::detach(py, dst.len(), || { + toning::apply_chemical_toning(src, dst, &t) + }); } Ok(out) } diff --git a/negpy_core/src/py/warp.rs b/negpy_core/src/py/warp.rs index 6442059f8..36fb524e0 100644 --- a/negpy_core/src/py/warp.rs +++ b/negpy_core/src/py/warp.rs @@ -36,7 +36,7 @@ fn output<'py>( let dst = rw .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; - py.detach(|| f(dst)); + super::detach(py, dst.len(), || f(dst)); } Ok(out) } diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 6c41aa900..8f6f6d924 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -176,3 +176,417 @@ fn hair_encode_block( } } } + +/// The scalars of `_ir_clean_base`, float32 as NumPy rounds them. +pub struct CleanBase { + pub win: usize, + pub identity: f32, + pub sigma: f32, + pub min_support: f32, +} + +/// `_ir_clean_base(img_det, ratio)` of an HxWx3 image: the clean-film mean minus `sigma` of its +/// spread where enough of the window is clean, else the box mean of the window maximum. +pub fn ir_clean_base( + img: &[f32], + ratio: &[f32], + (h, w): (usize, usize), + c: &CleanBase, + dst: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter}; + use crate::morph::{self, Op}; + let n = h * w; + if ratio.len() != n || img.len() != 3 * n || dst.len() != img.len() { + return None; + } + let win = (c.win, c.win); + let identity = c.identity; + let clean: Vec = ratio + .par_iter() + .map(move |&r| if r >= identity { 1.0 } else { 0.0 }) + .collect(); + let (mut p1, mut p2) = (vec![0f32; 3 * n], vec![0f32; 3 * n]); + p1.par_chunks_mut(3 * BLOCK) + .zip(p2.par_chunks_mut(3 * BLOCK)) + .zip(img.par_chunks(3 * BLOCK).zip(clean.par_chunks(BLOCK))) + .for_each(|((p1, p2), (px, wc))| { + for (i, &k) in wc.iter().enumerate() { + for j in 3 * i..3 * i + 3 { + let v = px[j]; + p1[j] = v * k; + p2[j] = v * v * k; + } + } + }); + let border = Border::Reflect101; + let mut den = vec![0f32; n]; + box_filter(&clean, &mut den, (h, w, 1), win, true, border)?; + let (mut b1, mut b2) = (vec![0f32; 3 * n], vec![0f32; 3 * n]); + box_filter(&p1, &mut b1, (h, w, 3), win, true, border)?; + box_filter(&p2, &mut b2, (h, w, 3), win, true, border)?; + // p1 holds the dilated image, then its box mean. + morph::morphology( + img, + &mut p1, + (h, w, 3), + Op::Dilate, + win, + 1, + Border::Constant, + )?; + box_filter(&p1, &mut p2, (h, w, 3), win, true, border)?; + let (sigma, support) = (c.sigma, c.min_support); + dst.par_chunks_mut(3 * BLOCK) + .zip(den.par_chunks(BLOCK)) + .zip(b1.par_chunks(3 * BLOCK).zip(b2.par_chunks(3 * BLOCK))) + .zip(p2.par_chunks(3 * BLOCK)) + .for_each(|(((o, d), (b1, b2)), dil)| clean_base_block(o, d, b1, b2, dil, sigma, support)); + Some(()) +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn clean_base_block( + out: &mut [f32], + den: &[f32], + b1: &[f32], + b2: &[f32], + dil: &[f32], + sigma: f32, + support: f32, +) { + for (i, &d) in den.iter().enumerate() { + let d = py_max(d, 1e-6); + for j in 3 * i..3 * i + 3 { + let mean = b1[j] / d; + let var = b2[j] / d - mean * mean; + let var = if var.is_nan() || var > 0.0 { var } else { 0.0 }; + let base = mean - sigma * var.sqrt(); + out[j] = if d > support { base } else { dil[j] }; + } + } +} + +/// `_ir_live(plane)`: 1 where film lies under the head, 0 in the below-`floor` regions of at +/// least `min_area` of the frame. +pub fn ir_live(plane: &[f32], (h, w): (usize, usize), floor: f32, min_area: f64) -> Vec { + let dead: Vec = plane.par_iter().map(move |&v| (v < floor) as u8).collect(); + if !dead.par_iter().any(|&d| d != 0) { + return vec![true; h * w]; + } + let cc = crate::components::connected_components(&dead, h, w, 8); + let bar = min_area * (h * w) as f64; + let holder: Vec = (0..cc.count) + .map(|l| l > 0 && cc.stats[l][4] as f64 >= bar) + .collect(); + cc.labels.par_iter().map(|&l| !holder[l as usize]).collect() +} + +/// `_fit_sample(mask)`: the values under `mask`, every `step`-th one so at most `cap` remain. +fn fit_sample(values: &[f32], mask: &[bool], cap: usize) -> Vec { + let n = mask.par_iter().filter(|&&m| m).count(); + let step = n.div_ceil(cap).max(1); + values + .iter() + .zip(mask) + .filter(|&(_, &m)| m) + .step_by(step) + .map(|(&v, _)| v) + .collect() +} + +/// The median of `sample` and 1.4826 times its median absolute deviation, as Python floats. +fn median_and_sigma(sample: &[f32]) -> (f64, f64) { + let med = crate::stats::median(sample); + let dev: Vec = sample.par_iter().map(move |&v| (v - med).abs()).collect(); + (med as f64, 1.4826 * crate::stats::median(&dev) as f64) +} + +/// `_ir_glare(plane, live)`: 1 more than `sigmas` deviations over the live median, `None` when +/// fewer than 500 samples or no pixel qualifies. +pub fn ir_glare(plane: &[f32], live: &[bool], cap: usize, sigmas: f64) -> Option> { + let sample = fit_sample(plane, live, cap); + if sample.len() < 500 { + return None; + } + let (med, sigma) = median_and_sigma(&sample); + let bar = (med + sigmas * sigma) as f32; + let glare: Vec = plane.par_iter().map(move |&v| v > bar).collect(); + glare.par_iter().any(|&g| g).then_some(glare) +} + +/// `normalize_ir(plane, glare)`: the plane over the box mean of its `win` x `win` maximum, +/// glare pixels zeroed for the base. +pub fn normalize_ir( + plane: &[f32], + glare: Option<&[bool]>, + (h, w): (usize, usize), + win: usize, + dst: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter}; + use crate::morph::{self, Op}; + let n = h * w; + if plane.len() != n || dst.len() != n || glare.is_some_and(|g| g.len() != n) { + return None; + } + let src: std::borrow::Cow<[f32]> = match glare { + Some(g) => plane + .par_iter() + .zip(g) + .map(|(&v, &g)| if g { 0.0 } else { v }) + .collect::>() + .into(), + None => plane.into(), + }; + let mut dil = vec![0f32; n]; + morph::morphology( + &src, + &mut dil, + (h, w, 1), + Op::Dilate, + (win, win), + 1, + Border::Constant, + )?; + box_filter(&dil, dst, (h, w, 1), (win, win), true, Border::Reflect101)?; + crate::par_zip(dst, plane, move |d, &p| *d = p / py_max(*d, 1e-4)); + Some(()) +} + +/// The scalars of `_ir_normalize_ratio`, as Python floats. +pub struct RatioNorm { + pub identity: f64, + pub noise_sigma: f64, + pub pivot_lo: f64, + pub ref_sigma: f64, + pub scale_max: f64, +} + +/// `np.clip(v, lo, hi)` of Python floats: a NaN comes through. +fn clip_f64(v: f64, lo: f64, hi: f64) -> f64 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} + +/// `_ir_normalize_ratio(ratio, live)` in place; returns the (pivot, sigma) it measured, or +/// `None` when fewer than 500 live pixels leave the ratio alone. +pub fn ir_normalize_ratio(ratio: &mut [f32], live: &[bool], c: &RatioNorm) -> Option<(f64, f64)> { + let sample: Vec = ratio + .par_iter() + .zip(live) + .filter(|&(_, &l)| l) + .map(|(&v, _)| v) + .collect(); + if sample.len() < 500 { + return None; + } + let (med, sigma) = median_and_sigma(&sample); + let pivot = clip_f64(med - c.noise_sigma * sigma, c.pivot_lo, c.identity); + let scale = c.identity / pivot; + let spread = sigma * scale; + let k = clip_f64( + c.ref_sigma / if 1e-6 > spread { 1e-6 } else { spread }, + 1.0, + c.scale_max, + ); + let (s32, k32, id32) = (scale as f32, k as f32, c.identity as f32); + let stretch = k > 1.0; + ratio.par_chunks_mut(BLOCK).for_each(move |run| { + for v in run { + let r = *v * s32; + *v = if stretch && r < id32 { + id32 - (id32 - r) * k32 + } else { + r + }; + } + }); + Some((pivot, sigma)) +} + +/// `np.clip(ratio / np.exp((vis_log * b).sum(-1)), 0.0, 1.5)`: `_ir_decontaminate`'s division, +/// in float32 (lstsq keeps float32). +pub fn ir_ghost_divide(ratio: &[f32], vis_log: &[f32], b: [f32; 3], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(ratio.par_chunks(BLOCK).zip(vis_log.par_chunks(3 * BLOCK))) + .for_each(move |(o, (r, v))| ghost_divide_block(o, r, v, b)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn ghost_divide_block(out: &mut [f32], ratio: &[f32], vis_log: &[f32], b: [f32; 3]) { + for ((o, &r), v) in out.iter_mut().zip(ratio).zip(vis_log.as_chunks::<3>().0) { + let s = ((-0.0 + v[0] * b[0]) + v[1] * b[1]) + v[2] * b[2]; + let q = r / crate::mathf::np_expf(s); + let m = if q.is_nan() || q > 0.0 { q } else { 0.0 }; + *o = if m.is_nan() || m < 1.5 { m } else { 1.5 }; + } +} + +/// `score_weighted_fill(img, score, scales, reject_floor_mass)` of an HxWx3 image: box means +/// of the score-weighted image over the score at every support but the last, which is a +/// Gaussian, each rung blended over the coarser ones by `fill_rung`. `reject_floor_mass` counts +/// a rung's confidence above the score floor: without the original-floor rule, a support that +/// sees only defect would otherwise win with the defect as its candidate. +pub fn score_weighted_fill( + img: &[f32], + score: &[f32], + (h, w): (usize, usize), + scales: &[usize], + reject_floor_mass: bool, + constants: (f32, f32), + fill: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter, gaussian_blur}; + let n = h * w; + if score.len() != n || img.len() != 3 * n || fill.len() != img.len() { + return None; + } + let mut weighted = vec![0f32; 3 * n]; + weighted + .par_chunks_mut(3 * BLOCK) + .zip(img.par_chunks(3 * BLOCK).zip(score.par_chunks(BLOCK))) + .for_each(|(o, (px, s))| { + for (i, &k) in s.iter().enumerate() { + for j in 3 * i..3 * i + 3 { + o[j] = px[j] * k; + } + } + }); + let (mut num, mut den) = (vec![0f32; 3 * n], vec![0f32; n]); + let border = Border::Reflect101; + for (i, &k) in scales.iter().enumerate() { + if i == scales.len() - 1 { + gaussian_blur(&weighted, &mut num, (h, w, 3), (k, k), (0.0, 0.0), border)?; + gaussian_blur(score, &mut den, (h, w, 1), (k, k), (0.0, 0.0), border)?; + } else { + box_filter(&weighted, &mut num, (h, w, 3), (k, k), true, border)?; + box_filter(score, &mut den, (h, w, 1), (k, k), true, border)?; + } + fill_rung(&num, &den, fill, i == 0, reject_floor_mass, constants); + } + Some(()) +} + +/// `_borrow_clean_grain` added into `out` at every pixel `clean` leaves out: the detail of the +/// pixel mirrored through the nearest clean one (that clean pixel itself when the mirror is +/// not clean), high-passed at `sigma`, times `a` when given. Returns the blur of `src`, or +/// `None` without touching `out` when no pixel or every pixel is clean. +pub fn add_clean_grain( + src: &[f32], + clean: &[bool], + (h, w): (usize, usize), + sigma: f64, + a: Option<&[f32]>, + out: &mut [f32], +) -> Option> { + if !clean.par_iter().any(|&c| c) || clean.par_iter().all(|&c| c) { + return None; + } + let holes: Vec = clean.par_iter().map(|&c| !c as u8).collect(); + let (_, labels) = crate::distance::distance_transform_labels(&holes, h, w); + // The labels number the clean pixels from 1 in raster order. + let donors: Vec = (0..h * w).filter(|&p| clean[p]).collect(); + let mut blur = vec![0f32; 3 * h * w]; + crate::filter::gaussian_blur( + src, + &mut blur, + (h, w, 3), + (0, 0), + (sigma, sigma), + crate::filter::Border::Reflect101, + )?; + let (hi, wi) = (h as isize - 1, w as isize - 1); + out.par_chunks_mut(3 * w).enumerate().for_each(|(y, row)| { + for x in 0..w { + let p = y * w + x; + if clean[p] { + continue; + } + let nearest = donors[labels[p] as usize - 1]; + let (ny, nx) = ((nearest / w) as isize, (nearest % w) as isize); + let my = (2 * ny - y as isize).clamp(0, hi) as usize; + let mx = (2 * nx - x as isize).clamp(0, wi) as usize; + let mirrored = my * w + mx; + let take = if clean[mirrored] { mirrored } else { nearest }; + for c in 0..3 { + let g = src[3 * take + c] - blur[3 * take + c]; + row[3 * x + c] += match a { + Some(a) => a[p] * g, + None => g, + }; + } + } + }); + Some(blur) +} + +/// The scalars of `apply_score_repair`, float32 as NumPy rounds them. +pub struct ScoreRepair { + pub score_floor: f32, + pub tau: f32, + pub write_hi: f32, + /// `np.float32(write_hi - write_lo)`, subtracted as Python floats. + pub write_span: f32, +} + +/// `apply_score_repair` of an HxWx3 image and a score at its resolution, with the fill +/// supports and grain `sigma` already chosen. +#[allow(clippy::too_many_arguments)] +pub fn apply_score_repair( + src: &[f32], + score: &[f32], + (h, w): (usize, usize), + supports: &[usize], + floor: bool, + sigma: f64, + c: &ScoreRepair, + out: &mut [f32], +) -> Option<()> { + let n = h * w; + score_weighted_fill( + src, + score, + (h, w), + supports, + !floor, + (c.score_floor, c.tau), + out, + )?; + let mut a = vec![0f32; n]; + blend_fill(src, score, out, &mut a, (c.write_hi, c.write_span)); + let hi = c.write_hi; + let clean: Vec = score.par_iter().map(move |&s| s >= hi).collect(); + let blur_src = add_clean_grain(src, &clean, (h, w), sigma, Some(&a), out); + if floor { + let gauss = |img: &[f32]| { + let mut b = vec![0f32; 3 * n]; + crate::filter::gaussian_blur( + img, + &mut b, + (h, w, 3), + (0, 0), + (sigma, sigma), + crate::filter::Border::Reflect101, + ) + .map(|_| b) + }; + let blur_src = match blur_src { + Some(b) => b, + None => gauss(src)?, + }; + let blur_out = gauss(out)?; + add_floor_deficit(out, &a, &blur_src, &blur_out); + } else { + out.par_chunks_mut(BLOCK).for_each(|run| { + for v in run { + *v = if v.is_nan() || *v > 0.0 { *v } else { 0.0 }; + } + }); + } + Some(()) +} diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index fca415d75..9854f51a5 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -221,6 +221,19 @@ pub fn np_median(values: &mut [f32]) -> f32 { (lo + hi) / 2.0 } +/// `np.median` of float32 values without reordering them, by the histogram selection. +pub fn median(values: &[f32]) -> f32 { + let n = values.len(); + if n == 0 || values.par_iter().any(|v| v.is_nan()) { + return f32::NAN; + } + if n % 2 == 1 { + return select_ranks(values, &[n / 2])[0]; + } + let m = select_ranks(values, &[n / 2 - 1, n / 2]); + (m[0] + m[1]) / 2.0 +} + /// NumPy's float32 sum of a contiguous run: one pairwise sum (blocks of 128, eight /// accumulators), the order `np.sum` and `mean(axis=-1)` use. pub fn pairwise_sum_f32(a: &[f32]) -> f32 { diff --git a/tests/test_core_ir.py b/tests/test_core_ir.py new file mode 100644 index 000000000..c96fd45d0 --- /dev/null +++ b/tests/test_core_ir.py @@ -0,0 +1,98 @@ +"""The IR dust kernels against the cv2 and NumPy code they replace.""" + +from pathlib import Path + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.retouch import logic as L + +pytestmark = cv2_oracle + + +def _scene(seed=0, shape=(120, 90)): + rng = np.random.default_rng(seed) + plane = (0.8 + 0.05 * rng.standard_normal(shape)).astype(np.float32) + plane[30:34, 20:60] = 0.3 # a hair + plane[:, :6] = 0.01 # the holder margin + plane[70:72, 40:42] = 2.0 # glare + img = rng.random(shape + (3,), dtype=np.float32) + return plane, img + + +def test_ir_live_matches_components(): + plane, _ = _scene() + dead = plane < L._IR_DEAD_FLOOR + n, labels, stats, _ = cv2.connectedComponentsWithStats(dead.astype(np.uint8), connectivity=8) + holder = np.zeros(n, dtype=bool) + holder[1:] = stats[1:, cv2.CC_STAT_AREA] >= L._IR_DEAD_MIN_AREA * dead.size + np.testing.assert_array_equal(L._ir_live(plane), ~holder[labels]) + + +def test_ir_glare_matches_median_rule(): + plane, _ = _scene(1) + live = L._ir_live(plane) + sample = plane.ravel()[L._fit_sample(live)] + med = float(np.median(sample)) + sigma = 1.4826 * float(np.median(np.abs(sample - med))) + np.testing.assert_array_equal(L._ir_glare(plane, live), plane > med + L._IR_GLARE_SIGMA * sigma) + + +def test_normalize_ir_matches_cv2(): + plane, _ = _scene(2) + glare = plane > 1.5 + win = L._ir_win(L._IR_BASE_WIN, L._ir_detect_scale(plane)) + src = np.where(glare, np.float32(0.0), plane) + base = cv2.blur(cv2.dilate(src, cv2.getStructuringElement(cv2.MORPH_RECT, (win, win))), (win, win)) + want = plane / np.maximum(base, 1e-4) + assert L.normalize_ir(plane, glare).tobytes() == want.tobytes() + + +def test_ir_clean_base_matches_cv2(): + plane, img = _scene(3) + ratio = L.normalize_ir(plane) + win = (L._ir_win(L._IR_CAP_WIN, L._ir_detect_scale(ratio)),) * 2 + w_clean = (ratio >= L._IR_GAIN_IDENTITY).astype(np.float32) + den = np.maximum(cv2.blur(w_clean, win), 1e-6)[..., None] + mean = cv2.blur(img * w_clean[..., None], win) / den + var = cv2.blur(img * img * w_clean[..., None], win) / den - mean * mean + base = mean - L._IR_CAP_SIGMA * np.sqrt(np.clip(var, 0.0, None)) + dil = cv2.blur(cv2.dilate(img, cv2.getStructuringElement(cv2.MORPH_RECT, win)), win) + want = np.where(den > L._IR_CAP_MIN_SUPPORT, base, dil) + assert L._ir_clean_base(img, ratio).tobytes() == want.tobytes() + + +def test_ir_normalize_ratio_matches_numpy(): + plane, _ = _scene(4) + live = L._ir_live(plane) + ratio = L.normalize_ir(plane) + sample = ratio[live] + med = float(np.median(sample)) + sigma = 1.4826 * float(np.median(np.abs(sample - med))) + pivot = float(np.clip(med - L._IR_NOISE_SIGMA * sigma, L._IR_PIVOT_LO, L._IR_GAIN_IDENTITY)) + scale = L._IR_GAIN_IDENTITY / pivot + want = (ratio * scale).astype(np.float32) + k = float(np.clip(L._IR_REF_SIGMA / max(sigma * scale, 1e-6), 1.0, L._IR_SCALE_MAX)) + if k > 1.0: + stretched = L._IR_GAIN_IDENTITY - (L._IR_GAIN_IDENTITY - want) * k + want = np.where(want < L._IR_GAIN_IDENTITY, stretched, want).astype(np.float32) + assert L._ir_normalize_ratio(ratio, live).tobytes() == want.tobytes() + + +def test_ghost_divide_matches_numpy_float32_exp(): + rng = np.random.default_rng(5) + ratio = rng.random((64, 48), dtype=np.float32) + vis_log = -3.0 * rng.random((64, 48, 3), dtype=np.float32) + b = np.array([0.31, 0.07, 0.52], dtype=np.float32) + want = np.clip(ratio / np.exp((vis_log * b).sum(-1)), 0.0, 1.5).astype(np.float32) + assert negpy_core.ir_ghost_divide(ratio, vis_log, b.tolist()).tobytes() == want.tobytes() + + +def test_every_binding_enters_a_pool_through_detach(): + """A thread that ran small work stays in its one-thread pool, so a binding that called + py.detach directly would run its large work on one core.""" + src = Path(__file__).parents[1] / "negpy_core" / "src" / "py" + offenders = [p.name for p in src.glob("*.rs") if p.name != "mod.rs" and ".detach(" in p.read_text()] + assert offenders == [] From 3e889b05bca64d2016cbbf8cd70e22a25dcbe0e6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 14:30:31 +0200 Subject: [PATCH 087/133] perf(core): repair_components in Rust; connected components over row runs The per-defect crops run in parallel in one call instead of one Python call each. Connected components label row runs, so only foreground pixels cost work: a sparse 1600x1113 mask 5.5 ms -> under cv2's 3.4 ms. repair_components 0.60-0.64x of the recorded Python; bit-exact 32/32. --- negpy/features/retouch/logic.py | 31 ++----- negpy_core/manifest.toml | 6 +- negpy_core/src/components.rs | 153 +++++++++++++++++++------------- negpy_core/src/py/retouch.rs | 55 ++++++++++++ negpy_core/src/retouch.rs | 84 ++++++++++++++++++ 5 files changed, 242 insertions(+), 87 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index d2df25b3e..a222ceba2 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -950,31 +950,16 @@ def repair_components( score = negpy_core.resize_linear(np.ascontiguousarray(score_det, dtype=np.float32), (w, h)) if dirty is not None and dirty.shape[:2] != (h, w): dirty = negpy_core.resize_nearest(dirty.astype(np.uint8), (w, h)) > 0 - m = (score < 1.0).astype(np.uint8) - if not m.any(): - return img - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) - # Past this many, cropping costs more than the whole-frame convolutions it avoids — - # and more than reusing base_out row by row, so this path stays non-incremental. - if n_lbl - 1 > _REPAIR_MAX_COMPONENTS: - return apply_score_repair(img, score, floor=floor, factor=factor) src = np.ascontiguousarray(img, dtype=np.float32) out = np.ascontiguousarray(base_out, dtype=np.float32).copy() if base_out is not None else src.copy() - # Reach of the coarsest support, so every crop holds the clean film the fill averages. - pad = max(_fill_supports(max(h, w), factor)) - for i in range(1, n_lbl): - bx, by = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) - cw, ch = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) - if dirty is not None and not dirty[by : by + ch, bx : bx + cw][labels[by : by + ch, bx : bx + cw] == i].any(): - continue # untouched since base_out was made: its fill there is already correct - x0, y0 = max(0, bx - pad), max(0, by - pad) - x1 = min(w, bx + cw + pad) - y1 = min(h, by + ch + pad) - sub = apply_score_repair(src[y0:y1, x0:x1], score[y0:y1, x0:x1], floor=floor, long_edge=max(h, w), factor=factor) - # This component only: a neighbour clipped by the crop repairs badly here and gets - # its own padded crop anyway. - mb = labels[y0:y1, x0:x1] == i - out[y0:y1, x0:x1][mb] = np.asarray(sub)[mb] + supports = _fill_supports(max(h, w), factor) + constants = (_IR_SCORE_FLOOR, _IR_FILL_TAU, _IR_WRITE_HI, _IR_WRITE_HI - _IR_WRITE_LO) + # Past the cap, cropping costs more than the whole-frame convolutions it avoids. The pad is + # the coarsest support's reach, so every crop holds the clean film the fill averages. + dirty_mask = None if dirty is None else np.ascontiguousarray(dirty, dtype=bool) + args = (list(supports), bool(floor), max(1.0, factor), constants, max(supports), _REPAIR_MAX_COMPONENTS) + if not negpy_core.repair_components(src, score, out, *args, dirty_mask): + return img return ensure_image(out) diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 3d4f3f898..e03c7e761 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1592,9 +1592,9 @@ depends = [] ["features/retouch/logic.py::repair_components"] phase = 4 -status = "todo" -calls = ["cv2.connectedComponentsWithStats", "negpy_core.resize_linear", "negpy_core.resize_nearest"] -depends = ["apply_score_repair"] +status = "ported" +calls = ["negpy_core.repair_components", "negpy_core.resize_linear", "negpy_core.resize_nearest"] +depends = [] ["features/retouch/logic.py::route_wide_defects"] phase = 4 diff --git a/negpy_core/src/components.rs b/negpy_core/src/components.rs index 41ce3f991..0bd43a9ba 100644 --- a/negpy_core/src/components.rs +++ b/negpy_core/src/components.rs @@ -27,66 +27,82 @@ fn union(parent: &mut [u32], a: u32, b: u32) -> u32 { lo } -/// Components of the nonzero pixels of an `h` x `w` mask, `connectivity` 4 or 8. +/// The `[x0, x1)` runs of nonzero bytes in a row; zero words of eight are skipped whole. +fn row_runs(row: &[u8]) -> Vec<(usize, usize)> { + let mut runs = Vec::new(); + let mut x = 0; + let w = row.len(); + while x < w { + if x + 8 <= w && row[x..x + 8].iter().all(|&v| v == 0) { + x += 8; + continue; + } + if row[x] == 0 { + x += 1; + continue; + } + let start = x; + while x < w && row[x] != 0 { + x += 1; + } + runs.push((start, x)); + } + runs +} + +/// Components of the nonzero pixels of an `h` x `w` mask, `connectivity` 4 or 8, labelled +/// over runs of each row so that only foreground pixels cost work. pub fn connected_components(mask: &[u8], h: usize, w: usize, connectivity: u8) -> Components { - let mut prov = vec![0u32; h * w]; + use rayon::prelude::*; + let rows: Vec> = mask.par_chunks(w.max(1)).map(row_runs).collect(); + // An 8-connected run touches runs above that reach one column past either end. + let reach = usize::from(connectivity == 8); let mut parent: Vec = vec![0]; + let mut run_label: Vec> = Vec::with_capacity(h); for y in 0..h { - for x in 0..w { - if mask[y * w + x] == 0 { - continue; + let mut labels = Vec::with_capacity(rows[y].len()); + let above = if y > 0 { &rows[y - 1][..] } else { &[] }; + let above_labels = if y > 0 { &run_label[y - 1][..] } else { &[] }; + let mut j = 0; + for &(x0, x1) in &rows[y] { + let lo = x0.saturating_sub(reach); + let hi = x1 + reach; + while j < above.len() && above[j].1 <= lo { + j += 1; } - let mut neighbours = [0u32; 4]; - let mut k = 0; - let mut push = |l: u32| { - if l != 0 { - neighbours[k] = l; - k += 1; - } - }; - if x > 0 { - push(prov[y * w + x - 1]); - } - if y > 0 { - push(prov[(y - 1) * w + x]); - if connectivity == 8 { - if x > 0 { - push(prov[(y - 1) * w + x - 1]); - } - if x + 1 < w { - push(prov[(y - 1) * w + x + 1]); - } - } + let mut l = 0u32; + let mut k = j; + while k < above.len() && above[k].0 < hi { + let a = above_labels[k]; + l = if l == 0 { + find(&mut parent, a) + } else { + union(&mut parent, l, a) + }; + k += 1; } - prov[y * w + x] = if k == 0 { + if l == 0 { parent.push(parent.len() as u32); - (parent.len() - 1) as u32 - } else { - let mut l = neighbours[0]; - for &n in &neighbours[1..k] { - l = union(&mut parent, l, n); - } - find(&mut parent, l) - }; + l = (parent.len() - 1) as u32; + } + labels.push(l); } + run_label.push(labels); } // Number the roots by the first pixel (4-connected) or 2x2 block (8-connected) of their - // component in raster order. + // component in raster order; a run's first pixel is its smallest key. let blocks_w = w.div_ceil(2); let mut first = vec![usize::MAX; parent.len()]; for y in 0..h { - for x in 0..w { - let l = prov[y * w + x]; - if l != 0 { - let root = find(&mut parent, l) as usize; - let key = if connectivity == 8 { - (y / 2) * blocks_w + x / 2 - } else { - y * w + x - }; - first[root] = first[root].min(key); - } + for (&(x0, _), &l) in rows[y].iter().zip(&run_label[y]) { + let root = find(&mut parent, l) as usize; + let key = if connectivity == 8 { + (y / 2) * blocks_w + x0 / 2 + } else { + y * w + x0 + }; + first[root] = first[root].min(key); } } let mut roots: Vec = (1..parent.len()) @@ -102,24 +118,39 @@ pub fn connected_components(mask: &[u8], h: usize, w: usize, connectivity: u8) - let mut labels = vec![0i32; h * w]; let mut lo = vec![[i32::MAX, i32::MAX, i32::MIN, i32::MIN, 0i32]; count]; let mut integrals = vec![[0u64; 2]; count]; + let sum_to = |n: usize| (n as u64) * (n as u64).saturating_sub(1) / 2; for y in 0..h { - for x in 0..w { - let p = prov[y * w + x]; - let l = if p == 0 { - 0 - } else { - final_label[find(&mut parent, p) as usize] - }; - labels[y * w + x] = l; + // The background between this row's runs. + let mut gaps = Vec::with_capacity(rows[y].len() + 1); + let mut x = 0; + for &(x0, x1) in &rows[y] { + if x0 > x { + gaps.push((x, x0)); + } + x = x1; + } + if x < w { + gaps.push((x, w)); + } + let fg = rows[y].iter().zip(&run_label[y]).map(|(&r, &l)| { + let root = find(&mut parent, l) as usize; + (r, final_label[root]) + }); + let spans: Vec<((usize, usize), i32)> = + fg.chain(gaps.into_iter().map(|g| (g, 0))).collect(); + for ((x0, x1), l) in spans { + if l != 0 { + labels[y * w + x0..y * w + x1].fill(l); + } let s = &mut lo[l as usize]; - let (xi, yi) = (x as i32, y as i32); - s[0] = s[0].min(xi); + let yi = y as i32; + s[0] = s[0].min(x0 as i32); s[1] = s[1].min(yi); - s[2] = s[2].max(xi); + s[2] = s[2].max(x1 as i32 - 1); s[3] = s[3].max(yi); - s[4] += 1; - integrals[l as usize][0] += x as u64; - integrals[l as usize][1] += y as u64; + s[4] += (x1 - x0) as i32; + integrals[l as usize][0] += sum_to(x1) - sum_to(x0); + integrals[l as usize][1] += (y * (x1 - x0)) as u64; } } let mut stats = Vec::with_capacity(count); diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 5b7479ea2..71b8b750d 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -293,7 +293,62 @@ fn apply_score_repair<'py>( }) } +/// `repair_components` into `out`, which holds the base, with the supports, the floor rule, +/// the grain sigma, (score floor, tau, write hi, write hi - write lo), the crop pad and the +/// component cap; returns False, leaving `out`, when the score holds no defect. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +#[pyo3(signature = (img, score, out, supports, floor, sigma, constants, pad, max_components, dirty=None))] +fn repair_components( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + score: PyReadonlyArrayDyn<'_, f32>, + mut out: PyReadwriteArrayDyn<'_, f32>, + supports: Vec, + floor: bool, + sigma: f64, + constants: (f32, f32, f32, f32), + pad: usize, + max_components: usize, + dirty: Option>, +) -> PyResult { + let (src, dims) = image3(&img)?; + let n = dims.0 * dims.1; + let sc = plane(&score, n)?; + let dirty = dirty.as_ref().map(|d| mask(d, n)).transpose()?; + if out.shape() != img.shape() { + return Err(PyValueError::new_err("needs a matching out")); + } + let o = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; + let (score_floor, tau, write_hi, write_span) = constants; + let c = retouch::ScoreRepair { + score_floor, + tau, + write_hi, + write_span, + }; + super::detach(py, src.len(), || { + retouch::repair_components( + src, + sc, + dims, + &supports, + floor, + sigma, + &c, + pad, + max_components, + dirty, + o, + ) + }) + .ok_or_else(|| PyValueError::new_err("invalid repair input")) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(repair_components, m)?)?; m.add_function(wrap_pyfunction!(add_clean_grain, m)?)?; m.add_function(wrap_pyfunction!(apply_score_repair, m)?)?; m.add_function(wrap_pyfunction!(ir_ghost_divide, m)?)?; diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 8f6f6d924..74319bfd9 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -590,3 +590,87 @@ pub fn apply_score_repair( } Some(()) } + +/// Repaired pixels of one defect: flat index and value. +type Patch = Vec<(usize, [f32; 3])>; + +/// `repair_components`: `apply_score_repair` per 8-connected defect (score < 1), each in its +/// own crop padded by `pad`, written back on that defect's pixels only. `out` holds the base +/// to write into. With `dirty`, a defect holding no dirty pixel keeps `out` as it is. Past +/// `max_components` the whole frame is repaired at once and `dirty` is ignored. Returns false, +/// with `out` untouched, when the score holds no defect. +#[allow(clippy::too_many_arguments)] +pub fn repair_components( + src: &[f32], + score: &[f32], + (h, w): (usize, usize), + supports: &[usize], + floor: bool, + sigma: f64, + c: &ScoreRepair, + pad: usize, + max_components: usize, + dirty: Option<&[bool]>, + out: &mut [f32], +) -> Option { + let m: Vec = score.par_iter().map(|&s| (s < 1.0) as u8).collect(); + if !m.par_iter().any(|&v| v != 0) { + return Some(false); + } + let cc = crate::components::connected_components(&m, h, w, 8); + if cc.count - 1 > max_components { + apply_score_repair(src, score, (h, w), supports, floor, sigma, c, out)?; + return Some(true); + } + let labels = &cc.labels; + let fixes: Vec = (1..cc.count) + .into_par_iter() + .map(|i| { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + let own = |y: usize, x: usize| labels[y * w + x] == i as i32; + if let Some(d) = dirty { + let touched = + (by..by + bh).any(|y| (bx..bx + bw).any(|x| own(y, x) && d[y * w + x])); + if !touched { + return Some(Vec::new()); + } + } + let (x0, y0) = (bx.saturating_sub(pad), by.saturating_sub(pad)); + let (x1, y1) = ((bx + bw + pad).min(w), (by + bh + pad).min(h)); + let (cw, ch) = (x1 - x0, y1 - y0); + let mut crop = Vec::with_capacity(3 * cw * ch); + let mut crop_score = Vec::with_capacity(cw * ch); + for y in y0..y1 { + crop.extend_from_slice(&src[3 * (y * w + x0)..3 * (y * w + x1)]); + crop_score.extend_from_slice(&score[y * w + x0..y * w + x1]); + } + let mut sub = vec![0f32; crop.len()]; + apply_score_repair( + &crop, + &crop_score, + (ch, cw), + supports, + floor, + sigma, + c, + &mut sub, + )?; + let mut px = Vec::new(); + for y in y0..y1 { + for x in x0..x1 { + if own(y, x) { + let k = 3 * ((y - y0) * cw + x - x0); + px.push((y * w + x, [sub[k], sub[k + 1], sub[k + 2]])); + } + } + } + Some(px) + }) + .collect::>>()?; + for px in fixes { + for (p, v) in px { + out[3 * p..3 * p + 3].copy_from_slice(&v); + } + } + Some(true) +} From 22637d25c04fca6a75d0f811e7b8803c2d9ca208 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 14:41:06 +0200 Subject: [PATCH 088/133] perf(core): rest of the IR path in Rust; NumPy's float32 log route_wide_defects, _fit_refraction_gammas, ir_defect_score and apply_ir_attenuation run in negpy_core, bit-exact on the IR corpus. mathf::np_logf copies NumPy's own AVX2/AVX-512 float32 log. The gamma fit's flatness test takes OpenCV's own Laplacian where opencv-python runs IPP's (ledger). Retouch outputs come from numpy.empty, which gets huge pages. route_wide_defects 0.25x, ir_defect_score 0.43x, gamma fit 0.87-0.94x, attenuation 0.85-0.90x of the recorded Python. --- negpy/features/retouch/logic.py | 48 ++----- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 16 +-- negpy_core/src/mathf.rs | 70 ++++++++++ negpy_core/src/py/retouch.rs | 107 ++++++++++++++- negpy_core/src/retouch.rs | 222 ++++++++++++++++++++++++++++++-- tests/test_core_ir.py | 62 +++++++++ 7 files changed, 471 insertions(+), 55 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index a222ceba2..86b31647d 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -860,9 +860,8 @@ def ir_defect_score(ratio: np.ndarray, cutoff: float) -> np.ndarray: scale: a sampling allowance, not a film footprint — widening it with the plane re-fattens the mask the finer detection just tightened.""" span = max(_IR_GAIN_IDENTITY - cutoff, 1e-4) - t = (np.ascontiguousarray(ratio, dtype=np.float32) - cutoff) / span - score = np.clip(t * (1.0 - _IR_SCORE_FLOOR) + _IR_SCORE_FLOOR, _IR_SCORE_FLOOR, 1.0) - return cv2.erode(score, np.ones((3, 3), np.uint8)) + constants = (cutoff, span, 1.0 - _IR_SCORE_FLOOR, _IR_SCORE_FLOOR) + return negpy_core.ir_defect_score(np.ascontiguousarray(ratio, dtype=np.float32), constants) def _fill_supports(buffer_long_edge: int, factor: float) -> Tuple[int, ...]: @@ -971,29 +970,12 @@ def route_wide_defects(score: np.ndarray, *, budget: Optional[float] = _IR_ROUTE detector, where a misregistered IR plane can call half the frame a defect; a full-width line clears it on its own, so it would refuse exactly the case that needs the inpaint. """ - at_floor = (score <= _IR_SCORE_FLOOR + 1e-6).astype(np.uint8) - if not at_floor.any(): - return None - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(at_floor, connectivity=8) scale = _ir_detect_scale(score) - radius = int(round(_IR_ROUTE_RADIUS * scale)) - side = 2 * radius - 1 - routed = np.zeros_like(at_floor) - hit = False - for i in range(1, n_lbl): - bw, bh = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) - if min(bw, bh) < side: # can't contain a side² solid → radius under the bar - continue - x0, y0 = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) - own = labels[y0 : y0 + bh, x0 : x0 + bw] == i - if float(cv2.distanceTransform(np.pad(own.astype(np.uint8), 1), cv2.DIST_C, 3).max()) >= radius: - # Written inside the bounding box: a hand-placed score arrives at full resolution. - routed[y0 : y0 + bh, x0 : x0 + bw][own] = 1 - hit = True - if not hit: - return None k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(_IR_ROUTE_DILATE * scale)) + 1,) * 2) - routed = cv2.dilate(routed, k) + bar = np.float32(_IR_SCORE_FLOOR + 1e-6) + routed = negpy_core.route_wide_defects(np.ascontiguousarray(score, dtype=np.float32), bar, int(round(_IR_ROUTE_RADIUS * scale)), k) + if routed is None: + return None frac = float(routed.mean()) if budget is not None and frac > budget: logger.warning("Retouch: routed defects cover %.1f%% of the frame — inpaint skipped, fill only", frac * 100.0) @@ -1039,17 +1021,9 @@ def _fit_refraction_gammas(ratio: np.ndarray, vis_log: np.ndarray, img_det: np.n dominated it, reading γ 1.9/2.2/2.2 for dust measuring ~1.0/1.1/1.2 and over-correcting every speck into a dark cyan blob. Median alone reads 1.3/1.8/1.8, flat-only least squares 1.4/2.1/2.0, and γ 1.5 already tints.""" - band = (ratio > 0.70) & (ratio < 0.92) - if int(band.sum()) < 500: - return (_IR_GAMMA_FALLBACK,) * 3 - # ksize=5 carries its own smoothing, so no separate blur. - edge = np.abs(cv2.Laplacian(img_det[:, :, 1], cv2.CV_32F, ksize=5)) - flat = band & (edge < np.percentile(edge[band], _IR_FIT_FLAT_PCT)) - fit = _fit_sample(flat if int(flat.sum()) >= _IR_FIT_MIN_PX else band) - # The band bounds the ratio away from 1, so the per-pixel slope needs no guard. - xb = np.log(ratio.ravel()[fit]) - vl = vis_log.reshape(-1, 3)[fit] - return tuple(float(np.clip(np.median(vl[:, c] / xb), _IR_GAMMA_LO, _IR_GAMMA_HI)) for c in range(3)) + f32 = (np.ascontiguousarray(a, dtype=np.float32) for a in (ratio, vis_log, img_det)) + gamma = (_IR_GAMMA_LO, _IR_GAMMA_HI) + return negpy_core.fit_refraction_gammas(*f32, (0.70, 0.92), _IR_FIT_FLAT_PCT, _IR_FIT_MIN_PX, _IR_FIT_MAX_PX, _IR_GAMMA_FALLBACK, gamma) def _ir_clean_base(img_det: np.ndarray, ratio: np.ndarray) -> np.ndarray: @@ -1115,9 +1089,7 @@ def apply_ir_attenuation(img: ImageBuffer, gain_det: np.ndarray) -> ImageBuffer: """Visible buffer × upsampled per-channel IR gain map (new array — buffers are read-only).""" h, w = img.shape[:2] gain = gain_det if gain_det.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(gain_det, dtype=np.float32), (w, h)) - # cv2.multiply, not `a * b`: the product of two float32 buffers is already float32, so - # the astype numpy needs here would copy the whole frame a second time. - return ensure_image(cv2.multiply(np.ascontiguousarray(img, dtype=np.float32), gain)) + return ensure_image(negpy_core.ir_attenuate(np.ascontiguousarray(img, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32))) def luma_bake_token(retouch) -> str: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 84acdf222..961e920f8 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -25,3 +25,4 @@ tolerance listed for each entry. | `features/lab/logic.py::apply_rl_sharpening` | L* takes a correctly rounded cube root (`mathf::cbrtf`); NumPy's float32 `np.cbrt` runs SVML here, up to 2 ULP off on a third of inputs. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | | `features/sabattier/logic.py::apply_sabattier`, `features/cyanotype/logic.py::apply_cyanotype`, `features/lith/logic.py::apply_lith` | log10, exp, log1p, 10^x and the cube root are `mathf`'s, correctly rounded (or within an ULP of it); NumPy's float32 versions run SVML or its own SIMD code. With correctly rounded transcendentals each port matches bit for bit. | abs=1e-6 | | `features/finish/logic.py::apply_carrier` | The rebate tone lookup takes a correctly rounded cube root; NumPy's float32 `np.cbrt` runs SVML here. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | +| `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index e03c7e761..35b185503 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1448,8 +1448,8 @@ depends = [] ["features/retouch/logic.py::_fit_refraction_gammas"] phase = 4 -status = "todo" -calls = ["cv2.Laplacian", "np.median", "np.percentile"] +status = "ported" +calls = ["negpy_core.fit_refraction_gammas"] depends = [] ["features/retouch/logic.py::_hair_encode"] @@ -1526,8 +1526,8 @@ depends = [] ["features/retouch/logic.py::apply_ir_attenuation"] phase = 4 -status = "todo" -calls = ["cv2.multiply", "negpy_core.resize_linear"] +status = "ported" +calls = ["negpy_core.ir_attenuate", "negpy_core.resize_linear"] depends = [] ["features/retouch/logic.py::apply_score_repair"] @@ -1568,8 +1568,8 @@ depends = [] ["features/retouch/logic.py::ir_defect_score"] phase = 4 -status = "todo" -calls = ["cv2.erode"] +status = "ported" +calls = ["negpy_core.ir_defect_score"] depends = [] ["features/retouch/logic.py::ir_ratio_and_gain"] @@ -1598,8 +1598,8 @@ depends = [] ["features/retouch/logic.py::route_wide_defects"] phase = 4 -status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement"] +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.route_wide_defects"] depends = [] ["features/retouch/logic.py::score_weighted_fill"] diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs index 807005a1f..26a0d5b48 100644 --- a/negpy_core/src/mathf.rs +++ b/negpy_core/src/mathf.rs @@ -227,6 +227,54 @@ pub fn np_expf(x: f32) -> f32 { } } +/// NumPy's float32 `log` on x86 with AVX2 or AVX-512, its own SIMD code: the mantissa folded +/// into [sqrt(1/2), sqrt(2)), a rational polynomial for log(1 + x), every step fused. Negative +/// input gives -NaN, as NumPy's does. +#[allow(clippy::excessive_precision)] +#[inline(always)] +pub fn np_logf(x: f32) -> f32 { + use std::f32::consts::{FRAC_1_SQRT_2, LN_2}; + let (neg, zero, inf, nan) = (x < 0.0, x == 0.0, x == f32::INFINITY, x.is_nan()); + // Subnormals scale by 2^100 first, exactly. + let sub = x < f32::MIN_POSITIVE; + let v = if sub { + x * f32::from_bits(0x7180_0000) + } else { + x + }; + let bits = v.to_bits(); + let e = ((bits >> 23) & 0xff) as i32 - 126 - if sub { 100 } else { 0 }; + let m = f32::from_bits((bits & 0x7f_ffff) | (126 << 23)); + let fold = m <= FRAC_1_SQRT_2; + let m = if fold { m + m } else { m }; + let e = (if fold { e - 1 } else { e }) as f32; + let r = m - 1.0; + let num = 2.589_979_117_907_922_693_523e-2f32 + .mul_add(r, 3.808_837_741_388_407_920_751e-1) + .mul_add(r, 1.480_000_633_576_506_585_156) + .mul_add(r, 2.112_677_543_073_053_063_722) + .mul_add(r, 9.999_999_999_999_998_702_752e-1) + .mul_add(r, 0.0); + let den = 5.875_095_403_124_574_342_950e-3f32 + .mul_add(r, 1.546_476_374_983_906_719_538e-1) + .mul_add(r, 9.864_942_958_519_418_960_339e-1) + .mul_add(r, 2.453_006_071_784_736_363_091) + .mul_add(r, 2.612_677_543_073_109_236_779) + .mul_add(r, 1.0); + let y = e.mul_add(LN_2, num / den); + if nan { + f32::NAN + } else if neg { + -f32::NAN + } else if zero { + f32::NEG_INFINITY + } else if inf { + f32::INFINITY + } else { + y + } +} + #[cfg(test)] mod tests { use super::*; @@ -251,6 +299,28 @@ mod tests { assert!(np_expf(f32::NAN).is_nan()); } + #[test] + fn np_logf_matches_numpy() { + // np.log of a float32 array on an AVX-512 x86 CPU, as bits. + for (x, want) in [ + (1.0f32, 0x0000_0000u32), + (2.0, 0x3f31_7218), + (0.5, 0xbf31_7218), + (0.1, 0xc013_5d8e), + (0.75, 0xbe93_4b11), + (3.0e-5, 0xc126_a107), + (1e30, 0x428a_27b5), + (0.707_106_77, 0xbeb1_7218), + (1e-40, 0xc2b8_34f2), + (123.456, 0x409a_1bbb), + (-1.0, 0xffc0_0000), + ] { + assert_eq!(np_logf(x).to_bits(), want, "{x}"); + } + assert_eq!(np_logf(0.0), f32::NEG_INFINITY); + assert_eq!(np_logf(f32::INFINITY), f32::INFINITY); + } + #[test] fn cbrtf_is_correctly_rounded_on_a_sample() { let mut bits = 1u32; diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 71b8b750d..9868fc2b5 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -231,7 +231,12 @@ fn new_image<'py>( fill: impl FnOnce(&mut [f32]) -> Option<()> + Send, ) -> PyResult>> { use numpy::{PyArrayDyn, PyArrayMethods}; - let out = PyArrayDyn::::zeros(py, shape, false); + // numpy.empty, not zeros: NumPy's allocator asks for huge pages, and every pixel is written. + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "float32"))? + .cast_into::>()?; { let mut rw = out.readwrite(); let dst = rw @@ -347,7 +352,107 @@ fn repair_components( .ok_or_else(|| PyValueError::new_err("invalid repair input")) } +/// `route_wide_defects` before its budget check, with the at-floor bar, the radius and the +/// dilate kernel: a uint8 HxW mask, or None. +#[pyfunction] +fn route_wide_defects<'py>( + py: Python<'py>, + score: PyReadonlyArrayDyn<'py, f32>, + bar: f32, + radius: usize, + kernel: numpy::PyReadonlyArray2<'py, u8>, +) -> PyResult>>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let (sc, (h, w)) = plane2(&score)?; + let k = kernel.as_array(); + let cells: Vec = k.iter().copied().collect(); + let kernel = crate::morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }; + super::detach(py, sc.len(), || { + retouch::route_wide_defects(sc, (h, w), bar, radius, kernel) + }) + .map(|r| r.into_pyarray(py).reshape([h, w])) + .transpose() +} + +/// `_fit_refraction_gammas(ratio, vis_log, img_det)` with (band low, band high), the flat +/// percentile, the minimum flat count, the sample cap, the fallback and (gamma low, high). +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn fit_refraction_gammas( + py: Python<'_>, + ratio: PyReadonlyArrayDyn<'_, f32>, + vis_log: PyReadonlyArrayDyn<'_, f32>, + img: PyReadonlyArrayDyn<'_, f32>, + band: (f32, f32), + flat_pct: f64, + min_flat: usize, + cap: usize, + fallback: f64, + range: (f32, f32), +) -> PyResult<(f64, f64, f64)> { + let (r, dims) = plane2(&ratio)?; + let (v, (h, w)) = image3(&vis_log)?; + let (src, idims) = image3(&img)?; + if dims != (h, w) || idims != dims { + return Err(PyValueError::new_err("array shapes disagree")); + } + let c = retouch::GammaFit { + band, + flat_pct, + min_flat, + cap, + fallback, + range, + }; + let g = super::detach(py, r.len(), || { + retouch::fit_refraction_gammas(r, v, src, dims, &c) + }) + .ok_or_else(|| PyValueError::new_err("invalid gamma fit input"))?; + Ok((g[0], g[1], g[2])) +} + +/// `ir_defect_score(ratio, cutoff)` with (cutoff, span, 1 - floor, floor) as float32. +#[pyfunction] +fn ir_defect_score<'py>( + py: Python<'py>, + ratio: PyReadonlyArrayDyn<'py, f32>, + constants: (f32, f32, f32, f32), +) -> PyResult>> { + let (r, dims) = plane2(&ratio)?; + new_image(py, ratio.shape(), |dst| { + retouch::ir_defect_score(r, dims, constants, dst) + }) +} + +/// `apply_ir_attenuation`'s product of an HxWx3 image and a gain map of its shape. +#[pyfunction] +fn ir_attenuate<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + gain: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let (src, (h, w)) = image3(&img)?; + if gain.shape() != img.shape() { + return Err(PyValueError::new_err( + "needs a gain map of the image's shape", + )); + } + let g = plane(&gain, 3 * h * w)?; + new_image(py, img.shape(), |dst| { + retouch::ir_attenuate(src, g, dst); + Some(()) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(ir_defect_score, m)?)?; + m.add_function(wrap_pyfunction!(ir_attenuate, m)?)?; + m.add_function(wrap_pyfunction!(fit_refraction_gammas, m)?)?; + m.add_function(wrap_pyfunction!(route_wide_defects, m)?)?; m.add_function(wrap_pyfunction!(repair_components, m)?)?; m.add_function(wrap_pyfunction!(add_clean_grain, m)?)?; m.add_function(wrap_pyfunction!(apply_score_repair, m)?)?; diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 74319bfd9..224f6db0b 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -282,16 +282,45 @@ pub fn ir_live(plane: &[f32], (h, w): (usize, usize), floor: f32, min_area: f64) cc.labels.par_iter().map(|&l| !holder[l as usize]).collect() } +/// Strided flat indices of `mask`, at most `cap` of them, as `_fit_sample` takes them: every +/// `step`-th set pixel in raster order, counted per block and then picked in parallel. +fn fit_indices(mask: &[bool], cap: usize) -> Vec { + const CHUNK: usize = 1 << 16; + let counts: Vec = mask + .par_chunks(CHUNK) + .map(|c| c.iter().filter(|&&m| m).count()) + .collect(); + let step = counts.iter().sum::().div_ceil(cap).max(1); + let starts: Vec = counts + .iter() + .scan(0, |acc, &c| { + let s = *acc; + *acc += c; + Some(s) + }) + .collect(); + mask.par_chunks(CHUNK) + .zip(starts) + .enumerate() + .flat_map_iter(|(b, (c, start))| { + let mut rank = start; + c.iter().enumerate().filter_map(move |(i, &m)| { + if !m { + return None; + } + let take = rank % step == 0; + rank += 1; + take.then_some(b * CHUNK + i) + }) + }) + .collect() +} + /// `_fit_sample(mask)`: the values under `mask`, every `step`-th one so at most `cap` remain. fn fit_sample(values: &[f32], mask: &[bool], cap: usize) -> Vec { - let n = mask.par_iter().filter(|&&m| m).count(); - let step = n.div_ceil(cap).max(1); - values - .iter() - .zip(mask) - .filter(|&(_, &m)| m) - .step_by(step) - .map(|(&v, _)| v) + fit_indices(mask, cap) + .into_iter() + .map(|i| values[i]) .collect() } @@ -674,3 +703,180 @@ pub fn repair_components( } Some(true) } + +/// `route_wide_defects` up to its budget check: 1 on the at-floor (score <= `bar`) components +/// that hold a chessboard distance of `radius` from their own edge, dilated by `kernel`; +/// `None` when no component qualifies. +pub fn route_wide_defects( + score: &[f32], + (h, w): (usize, usize), + bar: f32, + radius: usize, + kernel: crate::morph::Kernel, +) -> Option> { + use crate::distance::{Metric, distance_transform}; + let at_floor: Vec = score.par_iter().map(move |&s| (s <= bar) as u8).collect(); + if !at_floor.par_iter().any(|&v| v != 0) { + return None; + } + let cc = crate::components::connected_components(&at_floor, h, w, 8); + let side = (2 * radius).saturating_sub(1); + let labels = &cc.labels; + let wide: Vec = (1..cc.count) + .into_par_iter() + .filter(|&i| { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + if bw.min(bh) < side { + return false; + } + // The component alone in its box, with a zero border. + let (pw, ph) = (bw + 2, bh + 2); + let mut own = vec![0u8; pw * ph]; + for y in 0..bh { + for x in 0..bw { + own[(y + 1) * pw + x + 1] = (labels[(by + y) * w + bx + x] == i as i32) as u8; + } + } + let d = distance_transform(&own, ph, pw, Metric::Chessboard); + d.iter().fold(0f32, |m, &v| m.max(v)) >= radius as f32 + }) + .collect(); + if wide.is_empty() { + return None; + } + let mut routed = vec![0u8; h * w]; + for &i in &wide { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + for y in by..by + bh { + for x in bx..bx + bw { + if labels[y * w + x] == i as i32 { + routed[y * w + x] = 1; + } + } + } + } + let mut out = vec![0u8; h * w]; + crate::morph::morphology_mask( + &routed, + &mut out, + (h, w, 1), + crate::morph::Op::Dilate, + kernel, + 1, + crate::filter::Border::Constant, + )?; + Some(out) +} + +/// The scalars of `_fit_refraction_gammas`. +pub struct GammaFit { + pub band: (f32, f32), + pub flat_pct: f64, + pub min_flat: usize, + pub cap: usize, + pub fallback: f64, + pub range: (f32, f32), +} + +/// `_fit_refraction_gammas(ratio, vis_log, img_det)`: per channel, the median slope of the +/// visible log over the IR log across the shallow-dust band, on its locally flat part. The +/// flatness is cv2's 5x5 Laplacian of the green channel without IPP. +pub fn fit_refraction_gammas( + ratio: &[f32], + vis_log: &[f32], + img: &[f32], + (h, w): (usize, usize), + c: &GammaFit, +) -> Option<[f64; 3]> { + use crate::filter::{Border, sep_filter2d}; + let (lo, hi) = c.band; + let band: Vec = ratio.par_iter().map(move |&r| r > lo && r < hi).collect(); + if band.par_iter().filter(|&&b| b).count() < 500 { + return Some([c.fallback; 3]); + } + let mut green = vec![0f32; h * w]; + green + .par_chunks_mut(BLOCK) + .zip(img.par_chunks(3 * BLOCK)) + .for_each(|(g, px)| { + for (g, p) in g.iter_mut().zip(px.as_chunks::<3>().0) { + *g = p[1]; + } + }); + let d2 = [1.0f32, 0.0, -2.0, 0.0, 1.0]; + let sm = [1.0f32, 4.0, 6.0, 4.0, 1.0]; + let (mut dx, mut dy) = (vec![0f32; h * w], vec![0f32; h * w]); + sep_filter2d(&green, &mut dx, (h, w, 1), (&d2, &sm), Border::Reflect101)?; + sep_filter2d(&green, &mut dy, (h, w, 1), (&sm, &d2), Border::Reflect101)?; + crate::par_zip(&mut dx, &dy, |a, &b| *a = (*a + b).abs()); + let in_band: Vec = dx + .par_chunks(BLOCK) + .zip(band.par_chunks(BLOCK)) + .flat_map_iter(|(e, b)| { + e.iter() + .zip(b) + .filter(|&(_, &b)| b) + .map(|(&e, _)| e) + .collect::>() + }) + .collect(); + let bar = crate::stats::np_percentiles(&in_band, &[c.flat_pct])?[0]; + let flat: Vec = band + .par_iter() + .zip(&dx) + .map(move |(&b, &e)| b && e < bar) + .collect(); + let enough = flat.par_iter().filter(|&&f| f).count() >= c.min_flat; + let fit = fit_indices(if enough { &flat } else { &band }, c.cap); + let xb: Vec = fit + .iter() + .map(|&i| crate::mathf::np_logf(ratio[i])) + .collect(); + let (glo, ghi) = c.range; + Some(std::array::from_fn(|ch| { + let slopes: Vec = fit + .iter() + .zip(&xb) + .map(|(&i, &x)| vis_log[3 * i + ch] / x) + .collect(); + let m = crate::stats::median(&slopes); + let m = if m.is_nan() || m > glo { m } else { glo }; + (if m.is_nan() || m < ghi { m } else { ghi }) as f64 + })) +} + +/// `ir_defect_score`: the ratio mapped from `cutoff` (score `floor`) to `cutoff + span` +/// (score 1), clipped, then a 3x3 erode, in float32. +pub fn ir_defect_score( + ratio: &[f32], + (h, w): (usize, usize), + (cutoff, span, keep, floor): (f32, f32, f32, f32), + dst: &mut [f32], +) -> Option<()> { + let mut score = vec![0f32; h * w]; + crate::par_zip(&mut score, ratio, move |s, &r| { + let v = ((r - cutoff) / span) * keep + floor; + let v = if v.is_nan() || v > floor { v } else { floor }; + *s = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + }); + crate::morph::morphology( + &score, + dst, + (h, w, 1), + crate::morph::Op::Erode, + (3, 3), + 1, + crate::filter::Border::Constant, + ) +} + +/// `apply_ir_attenuation`'s product of the image and a gain map of its size. +pub fn ir_attenuate(img: &[f32], gain: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(3 * BLOCK) + .zip(img.par_chunks(3 * BLOCK).zip(gain.par_chunks(3 * BLOCK))) + .for_each(|(o, (a, b))| { + for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { + *o = a * b; + } + }); +} diff --git a/tests/test_core_ir.py b/tests/test_core_ir.py index c96fd45d0..50425a429 100644 --- a/tests/test_core_ir.py +++ b/tests/test_core_ir.py @@ -96,3 +96,65 @@ def test_every_binding_enters_a_pool_through_detach(): src = Path(__file__).parents[1] / "negpy_core" / "src" / "py" offenders = [p.name for p in src.glob("*.rs") if p.name != "mod.rs" and ".detach(" in p.read_text()] assert offenders == [] + + +def _route_cv2(score): + at_floor = (score <= L._IR_SCORE_FLOOR + 1e-6).astype(np.uint8) + n, labels, stats, _ = cv2.connectedComponentsWithStats(at_floor, connectivity=8) + scale = L._ir_detect_scale(score) + radius = int(round(L._IR_ROUTE_RADIUS * scale)) + routed = np.zeros_like(at_floor) + for i in range(1, n): + bw, bh = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) + if min(bw, bh) < 2 * radius - 1: + continue + x0, y0 = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) + own = labels[y0 : y0 + bh, x0 : x0 + bw] == i + if float(cv2.distanceTransform(np.pad(own.astype(np.uint8), 1), cv2.DIST_C, 3).max()) >= radius: + routed[y0 : y0 + bh, x0 : x0 + bw][own] = 1 + if not routed.any(): + return None + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(L._IR_ROUTE_DILATE * scale)) + 1,) * 2) + return cv2.dilate(routed, k) + + +def test_route_wide_defects_matches_cv2(): + score = np.ones((200, 160), dtype=np.float32) + score[20:40, 30:60] = L._IR_SCORE_FLOOR # wide: routed + score[100:103, 10:150] = L._IR_SCORE_FLOOR # a thin line: too narrow + score[150:170, 100:104] = L._IR_SCORE_FLOOR + want = _route_cv2(score) + assert want is not None + np.testing.assert_array_equal(L.route_wide_defects(score, budget=None), want) + assert L.route_wide_defects(np.ones((50, 50), dtype=np.float32)) is None + + +def test_ir_defect_score_matches_cv2(): + plane, _ = _scene(6) + ratio = L.normalize_ir(plane) + cutoff = 0.6 + span = max(L._IR_GAIN_IDENTITY - cutoff, 1e-4) + t = (ratio - cutoff) / span + score = np.clip(t * (1.0 - L._IR_SCORE_FLOOR) + L._IR_SCORE_FLOOR, L._IR_SCORE_FLOOR, 1.0) + want = cv2.erode(score, np.ones((3, 3), np.uint8)) + assert L.ir_defect_score(ratio, cutoff).tobytes() == want.tobytes() + + +def test_fit_refraction_gammas_matches_numpy(): + rng = np.random.default_rng(7) + ratio = rng.uniform(0.6, 1.0, (160, 120)).astype(np.float32) + img = rng.random((160, 120, 3), dtype=np.float32) + vis_log = (np.log(ratio)[..., None] * np.float32([1.1, 1.3, 1.6])).astype(np.float32) + vis_log += 0.01 * rng.standard_normal(vis_log.shape).astype(np.float32) + band = (ratio > 0.70) & (ratio < 0.92) + cv2.ipp.setUseIPP(False) + try: + edge = np.abs(cv2.Laplacian(img[:, :, 1], cv2.CV_32F, ksize=5)) + finally: + cv2.ipp.setUseIPP(True) + flat = band & (edge < np.percentile(edge[band], L._IR_FIT_FLAT_PCT)) + fit = L._fit_sample(flat if int(flat.sum()) >= L._IR_FIT_MIN_PX else band) + xb = np.log(ratio.ravel()[fit]) + vl = vis_log.reshape(-1, 3)[fit] + want = tuple(float(np.clip(np.median(vl[:, c] / xb), L._IR_GAMMA_LO, L._IR_GAMMA_HI)) for c in range(3)) + assert L._fit_refraction_gammas(ratio, vis_log, img) == want From 983193144bb0ca8660174de59731576be468871a Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 14:47:04 +0200 Subject: [PATCH 089/133] perf(core): optical dust detection in Rust compute_dust_stats (after the NumPy log10), detect_luma_score and split_hairs run in negpy_core, the hair test included. The orphaned Python helpers (_proxy_norm, _is_hair, _box_mean_std) are gone. Bit-exact on the corpus: 36/36, 36/36, 33/33. compute_dust_stats 0.46x, detect_luma_score 0.57x, split_hairs 0.55x of the recorded Python. --- negpy/features/retouch/logic.py | 110 ++-------- negpy_core/manifest.toml | 26 +-- negpy_core/src/py/retouch.rs | 140 +++++++++++++ negpy_core/src/retouch.rs | 343 ++++++++++++++++++++++++++++++++ tests/test_core_ir.py | 38 ++++ 5 files changed, 549 insertions(+), 108 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 86b31647d..ae7cd7a75 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -43,7 +43,7 @@ # defect raises its own bar. Clean film of any grain sits under the knee. _DETECT_TEXTURE_KNEE = 0.02 _DETECT_TEXTURE_GAIN = 40.0 # bar multiplier per unit of σ over the knee -# A hair-shaped component (_is_hair) tests its median σ against a higher, steeper knee: a hair across +# A hair-shaped component (negpy_core's is_hair) tests its median σ against a higher, steeper knee: a hair across # busy film stays under it, a seed grown along a tonal edge does not. _DETECT_HAIR_TEXTURE_KNEE = 0.075 _DETECT_HAIR_TEXTURE_GAIN = 150.0 @@ -206,7 +206,7 @@ # Strong hairs and scratches route to structure-following inpaint instead of the weighted # fill: a long twist crosses varied background, and averaging across it smears the structure -# the inpaint follows. Detection-scale px. See _is_hair. +# the inpaint follows. Detection-scale px. See is_hair in negpy_core/src/retouch.rs. _HAIR_MIN_AREA = 20 _HAIR_MIN_ELONG = 8.0 # area/thickness² ≈ length/thickness; round specks measure 1–3 # cv2.inpaint fill: the dilate covers the PSF skirt at 1:1 (apply_hair_inpaint widens it to @@ -223,37 +223,6 @@ _HAIR_INPAINT_PAD = 16 -def _proxy_norm(img: ImageBuffer) -> Tuple[float, float]: - """(lo, spread) percentile normalization of the detection proxy.""" - dens = -np.log10(np.clip(get_luminance(img), 1e-6, None)) - lo, hi = np.percentile(dens, (0.5, 99.5)) - return float(lo), max(float(hi - lo), _PROXY_MIN_SPREAD) - - -def _is_hair(labels_sub: np.ndarray, area: int) -> bool: - """Hair/scratch (thin) rather than speck: ``2*max(distanceTransform)`` is the widest - the defect ever gets, so ``area/thickness²`` reads as length/thickness for a ribbon. - - Thin, not straight — bending moves no interior pixel further from its edge, so a - twist scores like a straight hair, where PCA extent/width (the obvious measure) - calls it compact. The real hair on samples/ir/18.tiff: PCA aspect 2.45 = "speck", - thinness 26.3 = hair. - """ - if area < _HAIR_MIN_AREA: - return False - # Pad, or a component touching the sub-image border reads as thin along that edge. - dist = negpy_core.distance_transform(np.pad(labels_sub.astype(np.uint8), 1), cv2.DIST_L2, 5) - thickness = 2.0 * float(dist.max()) - return area / max(thickness * thickness, 1e-6) >= _HAIR_MIN_ELONG - - -def _box_mean_std(plane: np.ndarray, win: int) -> Tuple[np.ndarray, np.ndarray]: - """Local mean and standard deviation of ``plane`` over a ``win``×``win`` box.""" - mean = cv2.blur(plane, (win, win)) - var = cv2.blur(plane * plane, (win, win)) - mean * mean - return mean, np.sqrt(np.clip(var, 0.0, None)) - - def _density(img: ImageBuffer) -> np.ndarray: """Log density of the luminance. Local contrast measured here is exposure-invariant: a gain on the source is an offset in density and cancels in every difference.""" @@ -271,20 +240,7 @@ def split_hairs(mask: np.ndarray) -> Tuple[np.ndarray, Optional[np.ndarray]]: """Defect mask → ``(compact, hairs)``. Compact defects go to the weighted fill; long twisted ones to structure-following inpaint, which keeps detail the fill would average across. ``hairs`` is None when nothing is thin enough.""" - n_lbl, labels, stats, _ = negpy_core.connected_components_with_stats(np.ascontiguousarray(mask, dtype=np.uint8), 8) - compact = np.zeros(mask.shape[:2], dtype=np.uint8) - hairs: Optional[np.ndarray] = None - for i in range(1, n_lbl): - x0, y0 = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) - bw, bh = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) - labels_sub = labels[y0 : y0 + bh, x0 : x0 + bw] == i - if _is_hair(labels_sub, int(stats[i, cv2.CC_STAT_AREA])): - if hairs is None: - hairs = np.zeros(mask.shape[:2], dtype=np.uint8) - hairs[y0 : y0 + bh, x0 : x0 + bw][labels_sub] = 1 - else: - compact[y0 : y0 + bh, x0 : x0 + bw][labels_sub] = 1 - return compact, hairs + return negpy_core.split_hairs(np.ascontiguousarray(mask, dtype=np.uint8), (_HAIR_MIN_AREA, _HAIR_MIN_ELONG)) def _u8(plane: np.ndarray) -> np.ndarray: @@ -300,22 +256,16 @@ def compute_dust_stats(img: ImageBuffer, dust_size: int) -> Tuple[np.ndarray, .. expensive part of a detection pass, cacheable across threshold changes. ``z`` is the 3x3-averaged density excess over the median background in units of its own local MAD σ; ``texture`` the wide-window σ of the density.""" - lo, spread = _proxy_norm(img) - proxy = np.clip((_density(img) - lo) / spread, 0.0, 1.0).astype(np.float32) + shape = img.shape[:2] # Size is film footprint at _IR_DETECT_REF; the windows follow the plane (see _ir_win). - base_size = max(1.0, float(dust_size)) * film_scale(proxy.shape) + base_size = max(1.0, float(dust_size)) * film_scale(shape) v_win = int(max(3, base_size * 3.0)) * 2 + 1 - w_win = _texture_win(proxy.shape, dust_size) # Median: the level of the film around a defect narrower than half the window. Opening: # identity on the soft ramp of a tonal edge and on anything wider than a speck, both of # which the median alone calls dense. Whichever is higher is the background. disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(base_size)) + 1,) * 2) - background = np.maximum(cv2.medianBlur(_u8(proxy), v_win).astype(np.float32) / 255.0, cv2.morphologyEx(proxy, cv2.MORPH_OPEN, disk)) - excess = cv2.blur(proxy - background, (_DETECT_AVG_PX, _DETECT_AVG_PX)) - mad = cv2.medianBlur(_u8(np.abs(excess) * _DETECT_MAD_GAIN), w_win).astype(np.float32) / (255.0 * _DETECT_MAD_GAIN) - z = excess / np.maximum(mad / 0.6745, _DETECT_SIGMA_MIN) - _, texture = _box_mean_std(proxy, w_win) - return tuple(np.ascontiguousarray(a.astype(np.float32)) for a in (proxy, background, z, texture)) + constants = (_PROXY_MIN_SPREAD, _DETECT_AVG_PX, _DETECT_MAD_GAIN, _DETECT_SIGMA_MIN) + return negpy_core.dust_stats(_density(img), v_win, _texture_win(shape, dust_size), disk, constants) def detect_bar(slider: float) -> float: @@ -344,45 +294,21 @@ def detect_luma_score( proxy, background, z, texture = stats[:4] hi = detect_bar(dust_threshold) hi_hair = hi if hair_threshold is None else detect_bar(hair_threshold) - seeds = (z >= min(hi, hi_hair)) & (proxy > _DETECT_PROXY_MIN) - if not np.any(seeds): - return None, None scale = film_scale(proxy.shape) lo = max(_DETECT_Z_GROW, min(hi, hi_hair) * _DETECT_Z_GROW_FRAC) reach = int(round(_DETECT_GROW_REACH * max(1, dust_size) * scale)) disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * reach + 1,) * 2) - near = negpy_core.morphology_ex(seeds.astype(np.uint8), cv2.MORPH_DILATE, disk) > 0 - n_lbl, lab, stats_cc, _ = negpy_core.connected_components_with_stats((near & (z >= lo)).astype(np.uint8), 8) - bar = hi * (1.0 + _DETECT_TEXTURE_GAIN * np.maximum(texture - _DETECT_TEXTURE_KNEE, 0.0)) - strong = np.zeros(n_lbl, dtype=bool) - strong[np.unique(lab[seeds & (z >= bar)])] = True - peak = np.zeros(n_lbl, dtype=np.float32) - np.maximum.at(peak, lab[seeds], z[seeds]) - keep = np.zeros(n_lbl, dtype=bool) - hairs = [] - for i in np.flatnonzero(peak[1:] > 0) + 1: - x0, y0 = int(stats_cc[i, cv2.CC_STAT_LEFT]), int(stats_cc[i, cv2.CC_STAT_TOP]) - bw, bh = int(stats_cc[i, cv2.CC_STAT_WIDTH]), int(stats_cc[i, cv2.CC_STAT_HEIGHT]) - if _is_hair(lab[y0 : y0 + bh, x0 : x0 + bw] == i, int(stats_cc[i, cv2.CC_STAT_AREA])): - hairs.append(i) - else: - keep[i] = strong[i] - if hairs: - is_hair = np.zeros(n_lbl, dtype=bool) - is_hair[hairs] = True - _, filled = _box_mean_std(np.where(is_hair[lab], background, proxy), _texture_win(proxy.shape, dust_size)) - hair_tex = np.minimum(texture, _DETECT_HAIR_FILL_GAIN * filled) - for i in hairs: - x0, y0 = int(stats_cc[i, cv2.CC_STAT_LEFT]), int(stats_cc[i, cv2.CC_STAT_TOP]) - bw, bh = int(stats_cc[i, cv2.CC_STAT_WIDTH]), int(stats_cc[i, cv2.CC_STAT_HEIGHT]) - tex = float(np.median(hair_tex[y0 : y0 + bh, x0 : x0 + bw][lab[y0 : y0 + bh, x0 : x0 + bw] == i])) - keep[i] = peak[i] >= hi_hair * (1.0 + _DETECT_HAIR_TEXTURE_GAIN * max(tex - _DETECT_HAIR_TEXTURE_KNEE, 0.0)) - if not keep.any(): - return None, None - hit = keep[lab].astype(np.uint8) - compact, hair_mask = split_hairs(hit) - score = _mask_to_score(compact, _DETECT_PAD_PX * scale) if compact.any() else None - return score, hair_mask + planes = tuple(np.ascontiguousarray(a, dtype=np.float32) for a in (proxy, background, z, texture)) + return negpy_core.detect_luma_score( + planes, + (hi, hi_hair, lo), + disk, + (_DETECT_PROXY_MIN, _DETECT_TEXTURE_KNEE, _DETECT_TEXTURE_GAIN), + (_HAIR_MIN_AREA, _HAIR_MIN_ELONG), + (_DETECT_HAIR_TEXTURE_KNEE, _DETECT_HAIR_TEXTURE_GAIN, _DETECT_HAIR_FILL_GAIN), + _texture_win(proxy.shape, dust_size), + (_DETECT_PAD_PX * scale, _IR_SCORE_FLOOR), + ) def exclusion_cover(strokes: List[Tuple], shape: Tuple[int, int]) -> np.ndarray: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 35b185503..9028ea98d 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1424,7 +1424,7 @@ depends = [] ["features/retouch/logic.py::_box_mean_std"] phase = 4 -status = "todo" +status = "ported" calls = ["cv2.blur"] depends = [] @@ -1500,12 +1500,6 @@ status = "ported" calls = ["negpy_core.mask_to_score"] depends = [] -["features/retouch/logic.py::_proxy_norm"] -phase = 4 -status = "todo" -calls = ["np.percentile"] -depends = [] - ["features/retouch/logic.py::_scratch_ridge"] phase = 4 status = "todo" @@ -1538,15 +1532,15 @@ depends = [] ["features/retouch/logic.py::compute_dust_stats"] phase = 4 -status = "todo" -calls = ["cv2.blur", "cv2.getStructuringElement", "cv2.medianBlur", "cv2.morphologyEx"] -depends = ["_box_mean_std", "_density", "_proxy_norm"] +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.dust_stats"] +depends = ["_density"] ["features/retouch/logic.py::detect_luma_score"] phase = 4 -status = "todo" -calls = ["cv2.getStructuringElement", "negpy_core.connected_components_with_stats", "negpy_core.morphology_ex", "np.median", "np.unique"] -depends = ["_box_mean_std", "_is_hair", "_mask_to_score", "compute_dust_stats", "split_hairs"] +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.detect_luma_score"] +depends = ["compute_dust_stats"] ["features/retouch/logic.py::downsample_ir"] phase = 4 @@ -1610,9 +1604,9 @@ depends = ["_fill_rung"] ["features/retouch/logic.py::split_hairs"] phase = 4 -status = "todo" -calls = ["negpy_core.connected_components_with_stats"] -depends = ["_is_hair"] +status = "ported" +calls = ["negpy_core.split_hairs"] +depends = [] ["features/retouch/logic.py::strokes_to_score"] phase = 4 diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 9868fc2b5..b852cd0e5 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -448,7 +448,147 @@ fn ir_attenuate<'py>( }) } +type Plane<'py> = Bound<'py, numpy::PyArray2>; + +fn plane_array<'py>(py: Python<'py>, v: Vec, (h, w): (usize, usize)) -> PyResult> { + use numpy::{IntoPyArray, PyArrayMethods}; + v.into_pyarray(py).reshape([h, w]) +} + +/// `compute_dust_stats` from the density plane, with the median and texture windows, the +/// opening disk and (minimum spread, averaging window, MAD gain, sigma floor). +#[pyfunction] +fn dust_stats<'py>( + py: Python<'py>, + dens: PyReadonlyArrayDyn<'py, f32>, + v_win: usize, + w_win: usize, + disk: numpy::PyReadonlyArray2<'py, u8>, + constants: (f64, usize, f32, f32), +) -> PyResult<(Plane<'py>, Plane<'py>, Plane<'py>, Plane<'py>)> { + let (d, dims) = plane2(&dens)?; + let k = disk.as_array(); + let cells: Vec = k.iter().copied().collect(); + let (min_spread, avg_px, mad_gain, sigma_min) = constants; + let c = retouch::DustStats { + v_win, + w_win, + disk: crate::morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }, + min_spread, + avg_px, + mad_gain, + sigma_min, + }; + let [p, b, z, t] = super::detach(py, d.len(), || retouch::dust_stats(d, dims, &c)) + .ok_or_else(|| PyValueError::new_err("invalid dust stats input"))?; + Ok(( + plane_array(py, p, dims)?, + plane_array(py, b, dims)?, + plane_array(py, z, dims)?, + plane_array(py, t, dims)?, + )) +} + +fn u8_array<'py>( + py: Python<'py>, + v: Vec, + (h, w): (usize, usize), +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + v.into_pyarray(py).reshape([h, w]) +} + +type SplitHairs<'py> = ( + Bound<'py, numpy::PyArray2>, + Option>>, +); + +/// `split_hairs(mask)` of a uint8 mask with (minimum hair area, minimum elongation). +#[pyfunction] +fn split_hairs<'py>( + py: Python<'py>, + mask: numpy::PyReadonlyArray2<'py, u8>, + hair: (usize, f64), +) -> PyResult> { + let dims = (mask.shape()[0], mask.shape()[1]); + let m = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous mask"))?; + let (compact, hairs) = super::detach(py, m.len(), || retouch::split_hairs(m, dims, hair)); + Ok(( + u8_array(py, compact, dims)?, + hairs.map(|h| u8_array(py, h, dims)).transpose()?, + )) +} + +type LumaScore<'py> = (Option>, Option>>); + +/// `detect_luma_score` from (proxy, background, z, texture) with (hi, hi_hair, lo), the +/// reach disk, and the constants it reads, in the order of `retouch::LumaDetect`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn detect_luma_score<'py>( + py: Python<'py>, + stats: ( + PyReadonlyArrayDyn<'py, f32>, + PyReadonlyArrayDyn<'py, f32>, + PyReadonlyArrayDyn<'py, f32>, + PyReadonlyArrayDyn<'py, f32>, + ), + bars: (f64, f64, f64), + reach_disk: numpy::PyReadonlyArray2<'py, u8>, + texture: (f32, f32, f32), + hair: (usize, f64), + hair_texture: (f64, f64, f32), + texture_win: usize, + pad: (f64, f64), +) -> PyResult> { + let (proxy, dims) = plane2(&stats.0)?; + let n = proxy.len(); + let (background, z, tex) = ( + plane(&stats.1, n)?, + plane(&stats.2, n)?, + plane(&stats.3, n)?, + ); + let k = reach_disk.as_array(); + let cells: Vec = k.iter().copied().collect(); + let c = retouch::LumaDetect { + hi: bars.0, + hi_hair: bars.1, + lo: bars.2, + proxy_min: texture.0, + reach_disk: crate::morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }, + texture_knee: texture.1, + texture_gain: texture.2, + hair, + hair_fill_gain: hair_texture.2, + hair_texture: (hair_texture.0, hair_texture.1), + texture_win, + pad_px: pad.0, + score_floor: pad.1, + }; + let (score, hairs) = super::detach(py, n, || { + retouch::detect_luma_score([proxy, background, z, tex], dims, &c) + }) + .ok_or_else(|| PyValueError::new_err("invalid detection input"))?; + Ok(( + score.map(|s| plane_array(py, s, dims)).transpose()?, + hairs.map(|h| u8_array(py, h, dims)).transpose()?, + )) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(split_hairs, m)?)?; + m.add_function(wrap_pyfunction!(detect_luma_score, m)?)?; + m.add_function(wrap_pyfunction!(dust_stats, m)?)?; m.add_function(wrap_pyfunction!(ir_defect_score, m)?)?; m.add_function(wrap_pyfunction!(ir_attenuate, m)?)?; m.add_function(wrap_pyfunction!(fit_refraction_gammas, m)?)?; diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 224f6db0b..009bc9292 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -880,3 +880,346 @@ pub fn ir_attenuate(img: &[f32], gain: &[f32], dst: &mut [f32]) { } }); } + +/// `_box_mean_std(plane, win)`: the box mean and the standard deviation over `win` x `win`, +/// `sqrt(max(blur(p * p) - mean * mean, 0))` in float32. +pub fn box_mean_std( + plane: &[f32], + (h, w): (usize, usize), + win: usize, + mean: &mut [f32], + std: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter}; + let mut sq = vec![0f32; h * w]; + crate::par_zip(&mut sq, plane, |s, &p| *s = p * p); + box_filter(plane, mean, (h, w, 1), (win, win), true, Border::Reflect101)?; + box_filter(&sq, std, (h, w, 1), (win, win), true, Border::Reflect101)?; + crate::par_zip(std, mean, |s, &m| { + let v = *s - m * m; + *s = (if v.is_nan() || v > 0.0 { v } else { 0.0 }).sqrt(); + }); + Some(()) +} + +/// `_u8(plane * gain)`: clipped to 0..1, scaled to 8 bits and rounded half up, in float32. +fn to_u8(plane: &[f32], gain: f32) -> Vec { + plane + .par_iter() + .map(move |&p| { + let v = p * gain; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + let v = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + (v * 255.0 + 0.5) as u8 + }) + .collect() +} + +/// The scalars of `compute_dust_stats`. +pub struct DustStats<'a> { + /// Median and texture windows. + pub v_win: usize, + pub w_win: usize, + pub disk: crate::morph::Kernel<'a>, + pub min_spread: f64, + pub avg_px: usize, + pub mad_gain: f32, + pub sigma_min: f32, +} + +/// `compute_dust_stats` from the density plane: (proxy, background, z, texture). +pub fn dust_stats(dens: &[f32], (h, w): (usize, usize), c: &DustStats) -> Option<[Vec; 4]> { + use crate::filter::{Border, box_filter}; + let n = h * w; + let q = crate::stats::np_percentiles_f64(dens, &[0.5, 99.5]); + let spread = q[1] - q[0]; + let spread = if c.min_spread > spread { + c.min_spread + } else { + spread + }; + let (lo, spread) = (q[0] as f32, spread as f32); + let mut proxy = vec![0f32; n]; + crate::par_zip(&mut proxy, dens, move |p, &d| { + let v = (d - lo) / spread; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + *p = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + }); + let mut med = vec![0u8; n]; + crate::median::median_blur_u8(&to_u8(&proxy, 1.0), &mut med, h, w, c.v_win)?; + let mut background = vec![0f32; n]; + crate::morph::morphology_mask( + &proxy, + &mut background, + (h, w, 1), + crate::morph::Op::Open, + crate::morph::Kernel { ..c.disk }, + 1, + Border::Constant, + )?; + background + .par_chunks_mut(BLOCK) + .zip(med.par_chunks(BLOCK)) + .for_each(|(b, m)| { + for (b, &m) in b.iter_mut().zip(m) { + let a = m as f32 / 255.0; + *b = if a.is_nan() || b.is_nan() { + f32::NAN + } else if a > *b { + a + } else { + *b + }; + } + }); + let mut diff = vec![0f32; n]; + diff.par_chunks_mut(BLOCK) + .zip(proxy.par_chunks(BLOCK).zip(background.par_chunks(BLOCK))) + .for_each(|(d, (p, b))| { + for ((d, &p), &b) in d.iter_mut().zip(p).zip(b) { + *d = p - b; + } + }); + let mut z = vec![0f32; n]; + box_filter( + &diff, + &mut z, + (h, w, 1), + (c.avg_px, c.avg_px), + true, + Border::Reflect101, + )?; + let excess_u8: Vec = { + let abs: Vec = z.par_iter().map(|v| v.abs()).collect(); + to_u8(&abs, c.mad_gain) + }; + crate::median::median_blur_u8(&excess_u8, &mut med, h, w, c.w_win)?; + let (div, floor) = (255.0 * c.mad_gain, c.sigma_min); + z.par_chunks_mut(BLOCK) + .zip(med.par_chunks(BLOCK)) + .for_each(|(z, m)| { + for (z, &m) in z.iter_mut().zip(m) { + let sigma = (m as f32 / div) / 0.6745; + let sigma = if sigma.is_nan() || sigma > floor { + sigma + } else { + floor + }; + *z /= sigma; + } + }); + let (mut mean, mut texture) = (vec![0f32; n], vec![0f32; n]); + box_mean_std(&proxy, (h, w), c.w_win, &mut mean, &mut texture)?; + Some([proxy, background, z, texture]) +} + +/// `_is_hair`: thin rather than a speck. Twice the largest L2 (5x5) distance from the edge of +/// the padded component is its thickness; area over thickness squared reads as length over +/// thickness for a ribbon, bent or straight. +fn is_hair( + own: &[u8], + (bh, bw): (usize, usize), + area: usize, + (min_area, min_elong): (usize, f64), +) -> bool { + use crate::distance::{Metric, distance_transform}; + if area < min_area { + return false; + } + let (ph, pw) = (bh + 2, bw + 2); + let mut padded = vec![0u8; ph * pw]; + for y in 0..bh { + padded[(y + 1) * pw + 1..(y + 1) * pw + 1 + bw].copy_from_slice(&own[y * bw..(y + 1) * bw]); + } + let d = distance_transform(&padded, ph, pw, Metric::L2Mask5); + let thickness = 2.0 * d.iter().fold(0f32, |m, &v| m.max(v)) as f64; + let sq = thickness * thickness; + area as f64 / if 1e-6 > sq { 1e-6 } else { sq } >= min_elong +} + +/// Component `i` of `labels` in its bounding box, 1 on its pixels. +fn component_crop( + labels: &[i32], + w: usize, + i: usize, + s: [i32; 5], +) -> (Vec, (usize, usize, usize, usize)) { + let [bx, by, bw, bh, _] = s.map(|v| v as usize); + let mut own = vec![0u8; bw * bh]; + for y in 0..bh { + for x in 0..bw { + own[y * bw + x] = (labels[(by + y) * w + bx + x] == i as i32) as u8; + } + } + (own, (bx, by, bw, bh)) +} + +/// `split_hairs(mask)`: (compact, hairs), hairs `None` when no component is thin enough. +pub fn split_hairs( + mask: &[u8], + (h, w): (usize, usize), + hair: (usize, f64), +) -> (Vec, Option>) { + let cc = crate::components::connected_components(mask, h, w, 8); + let classes: Vec = (1..cc.count) + .into_par_iter() + .map(|i| { + let (own, (_, _, bw, bh)) = component_crop(&cc.labels, w, i, cc.stats[i]); + is_hair(&own, (bh, bw), cc.stats[i][4] as usize, hair) + }) + .collect(); + let (mut compact, mut hairs) = (vec![0u8; h * w], vec![0u8; h * w]); + let mut any_hair = false; + for (p, &l) in cc.labels.iter().enumerate() { + if l > 0 { + if classes[l as usize - 1] { + hairs[p] = 1; + any_hair = true; + } else { + compact[p] = 1; + } + } + } + (compact, any_hair.then_some(hairs)) +} + +/// The scalars of `detect_luma_score`, as Python floats unless noted. +pub struct LumaDetect<'a> { + pub hi: f64, + pub hi_hair: f64, + pub lo: f64, + pub proxy_min: f32, + pub reach_disk: crate::morph::Kernel<'a>, + pub texture_knee: f32, + pub texture_gain: f32, + pub hair: (usize, f64), + pub hair_fill_gain: f32, + pub hair_texture: (f64, f64), + pub texture_win: usize, + pub pad_px: f64, + pub score_floor: f64, +} + +/// `detect_luma_score` from its stats planes: (score, hair mask), each `None` when empty. +pub fn detect_luma_score( + stats: [&[f32]; 4], + (h, w): (usize, usize), + c: &LumaDetect, +) -> Option<(Option>, Option>)> { + let [proxy, background, z, texture] = stats; + let n = h * w; + let seed_bar = c.hi.min(c.hi_hair) as f32; + let (pmin, lo) = (c.proxy_min, c.lo as f32); + let seeds: Vec = z + .par_iter() + .zip(proxy) + .map(move |(&z, &p)| (z >= seed_bar && p > pmin) as u8) + .collect(); + if !seeds.par_iter().any(|&s| s != 0) { + return Some((None, None)); + } + let mut near = vec![0u8; n]; + crate::morph::morphology_mask( + &seeds, + &mut near, + (h, w, 1), + crate::morph::Op::Dilate, + crate::morph::Kernel { ..c.reach_disk }, + 1, + crate::filter::Border::Constant, + )?; + let grow: Vec = near + .par_iter() + .zip(z) + .map(move |(&nv, &z)| (nv != 0 && z >= lo) as u8) + .collect(); + let cc = crate::components::connected_components(&grow, h, w, 8); + let lab = &cc.labels; + let (hi32, knee, gain) = (c.hi as f32, c.texture_knee, c.texture_gain); + let mut strong = vec![false; cc.count]; + let mut peak = vec![0f32; cc.count]; + for p in 0..n { + if seeds[p] == 0 { + continue; + } + let l = lab[p] as usize; + let t = texture[p] - knee; + let bar = hi32 * (1.0 + gain * if t.is_nan() || t > 0.0 { t } else { 0.0 }); + if z[p] >= bar { + strong[l] = true; + } + if z[p] > peak[l] || z[p].is_nan() { + peak[l] = if peak[l].is_nan() { peak[l] } else { z[p] }; + } + } + let candidates: Vec = (1..cc.count).filter(|&i| peak[i] > 0.0).collect(); + let hairy: Vec = candidates + .par_iter() + .map(|&i| { + let (own, (_, _, bw, bh)) = component_crop(lab, w, i, cc.stats[i]); + is_hair(&own, (bh, bw), cc.stats[i][4] as usize, c.hair) + }) + .collect(); + let mut keep = vec![false; cc.count]; + let mut is_hair_label = vec![false; cc.count]; + for (&i, &hr) in candidates.iter().zip(&hairy) { + if hr { + is_hair_label[i] = true; + } else { + keep[i] = strong[i]; + } + } + if is_hair_label.iter().any(|&b| b) { + let filled_src: Vec = (0..n) + .into_par_iter() + .map(|p| { + if is_hair_label[lab[p] as usize] { + background[p] + } else { + proxy[p] + } + }) + .collect(); + let (mut mean, mut filled) = (vec![0f32; n], vec![0f32; n]); + box_mean_std(&filled_src, (h, w), c.texture_win, &mut mean, &mut filled)?; + let fill_gain = c.hair_fill_gain; + let hair_tex: Vec = texture + .par_iter() + .zip(&filled) + .map(move |(&t, &f)| { + let g = fill_gain * f; + if t.is_nan() || g.is_nan() { + f32::NAN + } else if g < t { + g + } else { + t + } + }) + .collect(); + let (hknee, hgain) = c.hair_texture; + for (&i, _) in candidates.iter().zip(&hairy).filter(|(_, h)| **h) { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + let vals: Vec = (by..by + bh) + .flat_map(|y| (bx..bx + bw).map(move |x| y * w + x)) + .filter(|&p| lab[p] == i as i32) + .map(|p| hair_tex[p]) + .collect(); + let tex = crate::stats::median(&vals) as f64; + let over = tex - hknee; + // Python's max keeps a NaN first argument. + let bar = c.hi_hair * (1.0 + hgain * if 0.0 > over { 0.0 } else { over }); + keep[i] = peak[i] >= bar as f32; + } + } + if !keep.iter().any(|&k| k) { + return Some((None, None)); + } + let hit: Vec = lab.par_iter().map(|&l| keep[l as usize] as u8).collect(); + let (compact, hairs) = split_hairs(&hit, (h, w), c.hair); + let score = compact + .par_iter() + .any(|&v| v != 0) + .then(|| crate::distance::mask_to_score(&compact, h, w, c.pad_px, c.score_floor)); + Some((score, hairs)) +} diff --git a/tests/test_core_ir.py b/tests/test_core_ir.py index 50425a429..af7e516b6 100644 --- a/tests/test_core_ir.py +++ b/tests/test_core_ir.py @@ -158,3 +158,41 @@ def test_fit_refraction_gammas_matches_numpy(): vl = vis_log.reshape(-1, 3)[fit] want = tuple(float(np.clip(np.median(vl[:, c] / xb), L._IR_GAMMA_LO, L._IR_GAMMA_HI)) for c in range(3)) assert L._fit_refraction_gammas(ratio, vis_log, img) == want + + +def _dust_stats_cv2(img, dust_size): + dens = L._density(img) + lo, hi = np.percentile(dens, (0.5, 99.5)) + lo, spread = float(lo), max(float(hi - lo), L._PROXY_MIN_SPREAD) + proxy = np.clip((dens - lo) / spread, 0.0, 1.0).astype(np.float32) + base_size = max(1.0, float(dust_size)) * L.film_scale(proxy.shape) + v_win = int(max(3, base_size * 3.0)) * 2 + 1 + w_win = L._texture_win(proxy.shape, dust_size) + disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(base_size)) + 1,) * 2) + background = np.maximum(cv2.medianBlur(L._u8(proxy), v_win).astype(np.float32) / 255.0, cv2.morphologyEx(proxy, cv2.MORPH_OPEN, disk)) + excess = cv2.blur(proxy - background, (L._DETECT_AVG_PX, L._DETECT_AVG_PX)) + mad = cv2.medianBlur(L._u8(np.abs(excess) * L._DETECT_MAD_GAIN), w_win).astype(np.float32) / (255.0 * L._DETECT_MAD_GAIN) + z = excess / np.maximum(mad / 0.6745, L._DETECT_SIGMA_MIN) + mean = cv2.blur(proxy, (w_win, w_win)) + texture = np.sqrt(np.clip(cv2.blur(proxy * proxy, (w_win, w_win)) - mean * mean, 0.0, None)) + return proxy, background, z, texture + + +def test_compute_dust_stats_matches_cv2(): + rng = np.random.default_rng(8) + img = (0.3 + 0.2 * rng.random((140, 180, 3))).astype(np.float32) + img[40:43, 50:53] *= 0.2 # a speck + for got, want in zip(L.compute_dust_stats(img, 2), _dust_stats_cv2(img, 2)): + assert got.tobytes() == want.tobytes() + + +def test_split_hairs_matches_components(): + mask = np.zeros((80, 80), dtype=np.uint8) + mask[10:12, 5:70] = 1 # a hair + mask[40:45, 40:45] = 1 # a speck + compact, hairs = L.split_hairs(mask) + want_hair = np.zeros_like(mask) + want_hair[10:12, 5:70] = 1 + np.testing.assert_array_equal(hairs, want_hair) + np.testing.assert_array_equal(compact, mask - want_hair) + assert L.split_hairs(mask - want_hair)[1] is None From 0a929ae3e656673e613736e93f1455e8558b53fa Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 14:47:40 +0200 Subject: [PATCH 090/133] style(core): name the detection result type for clippy --- negpy_core/src/retouch.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 009bc9292..67ef43d70 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -1100,12 +1100,15 @@ pub struct LumaDetect<'a> { pub score_floor: f64, } -/// `detect_luma_score` from its stats planes: (score, hair mask), each `None` when empty. +/// A fill score for specks and an inpaint mask for hairs, each `None` when empty. +pub type Detection = (Option>, Option>); + +/// `detect_luma_score` from its stats planes: (score, hair mask). pub fn detect_luma_score( stats: [&[f32]; 4], (h, w): (usize, usize), c: &LumaDetect, -) -> Option<(Option>, Option>)> { +) -> Option { let [proxy, background, z, texture] = stats; let n = h * w; let seed_bar = c.hi.min(c.hi_hair) as f32; From 34ea3ddb6d2d0be3c2b4fbcf1737e0f9df7e0fbc Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 14:58:00 +0200 Subject: [PATCH 091/133] perf(core): flat-field reference checks and gain in Rust The lit mask, _evenness, _is_clipped, compute_gain and the nearest-valid fill run in negpy_core, bit-exact on a new corpus (~/negpy-corpus-p4) that records the Phase 4 features the preview workload never reaches: local masks, painted heal, clone and scratch strokes, dust exclusions, hue trim, the sensor unmix, the flat field, the chart fit and the scratch trace. check_reference and self_corrected inherit resize_linear's ledgered IPP difference (ulp=4 / abs=1e-6). _is_clipped 0.06x, check_reference 0.18x, _evenness 0.29x, compute_gain 0.52x of the recorded Python. --- negpy/features/flatfield/logic.py | 63 +++----- negpy_core/LEDGER.md | 2 +- negpy_core/manifest.toml | 36 +++-- negpy_core/src/flatfield.rs | 255 ++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/flatfield.rs | 147 +++++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_flatfield.py | 81 ++++++++++ 8 files changed, 529 insertions(+), 58 deletions(-) create mode 100644 negpy_core/src/flatfield.rs create mode 100644 negpy_core/src/py/flatfield.rs create mode 100644 tests/test_core_flatfield.py diff --git a/negpy/features/flatfield/logic.py b/negpy/features/flatfield/logic.py index c4a08f8c0..619d4bf56 100644 --- a/negpy/features/flatfield/logic.py +++ b/negpy/features/flatfield/logic.py @@ -1,7 +1,6 @@ import hashlib from typing import Callable, Dict, NamedTuple, Optional, Tuple -import cv2 import negpy_core import numpy as np @@ -45,6 +44,7 @@ # pixels at _CLIP_LEVEL in some channel means the reference clipped. _CLIP_LEVEL = 0.98 _CLIP_FRACTION = 0.001 +_LIT = (_LIT_PERCENTILE, _LIT_FRACTION, _MIN_LIT_FRACTION) class GainEntry(NamedTuple): @@ -88,19 +88,12 @@ def _resolve(profile_id: str) -> Optional[GainEntry]: def compute_gain(reference: ImageBuffer) -> np.ndarray: """Per-channel gain = mean(blur) / blur over the lit area, on a downsampled copy.""" - ref = work_copy(reference) + ref = np.ascontiguousarray(work_copy(reference), dtype=np.float32) sigma = max(ref.shape[:2]) / _BLUR_DIVISOR - eps = 1e-4 - lit = _lit_mask(ref) - # Normalized convolution: a dark carrier edge in the reference would otherwise bleed into - # the blur and overcorrect the frame next to it. - num = cv2.GaussianBlur(ref * lit[..., None], (0, 0), sigmaX=sigma, sigmaY=sigma) - den = cv2.GaussianBlur(lit, (0, 0), sigmaX=sigma, sigmaY=sigma) - blur = np.clip(num / np.clip(den, eps, None)[..., None], eps, None) - blur = _fill_from_nearest(blur, den >= _MIN_LIT_WEIGHT) - means = (blur * lit[..., None]).sum(axis=(0, 1)) / lit.sum() - gain = means[None, None, :] / blur - return np.clip(gain, _GAIN_MIN, _GAIN_MAX).astype(np.float32) + gain, dark = negpy_core.compute_gain(ref, _LIT, sigma, 1e-4, _MIN_LIT_WEIGHT, (_GAIN_MIN, _GAIN_MAX)) + if dark: + _warn_dark() + return gain def work_copy(reference: ImageBuffer, long_edge: int = _GAIN_WORK_SIZE) -> np.ndarray: @@ -120,26 +113,20 @@ def check_copy(reference: ImageBuffer) -> np.ndarray: def _lit_mask(ref: np.ndarray) -> np.ndarray: """1 where the reference sees the light source, 0 on carrier or mask edges in frame.""" - lum = ref.mean(axis=2) - lit = (lum > _LIT_FRACTION * np.percentile(lum, _LIT_PERCENTILE)).astype(np.uint8) - # Drop the soft transition at the carrier edge: a holder's shadow edge spans about two pixels - # here above the fraction. Outside the image counts as dark, so a partly lit carrier lip on the - # outermost rows or columns goes too. - lit = cv2.erode(lit, np.ones((5, 5), np.uint8), borderType=cv2.BORDER_CONSTANT, borderValue=0) - if lit.sum() < _MIN_LIT_FRACTION * lit.size: - logger.warning("Flat-field: reference is almost all dark; computing the gain over the whole frame") - return np.ones(lum.shape, np.float32) + lit = negpy_core.lit_mask(np.ascontiguousarray(ref, dtype=np.float32), _LIT) + if lit is None: + _warn_dark() + return np.ones(ref.shape[:2], np.float32) return lit.astype(np.float32) +def _warn_dark() -> None: + logger.warning("Flat-field: reference is almost all dark; computing the gain over the whole frame") + + def _fill_from_nearest(values: np.ndarray, valid: np.ndarray) -> np.ndarray: """Replace each invalid pixel with the value of the nearest valid one.""" - if valid.all() or not valid.any(): - return values - _, labels = cv2.distanceTransformWithLabels((~valid).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL) - source = np.zeros(labels.max() + 1, np.int64) - source[labels[valid]] = np.flatnonzero(valid) - return values.reshape(-1, values.shape[2])[source[labels]].reshape(values.shape) + return negpy_core.fill_from_nearest(np.ascontiguousarray(values, dtype=np.float32), np.ascontiguousarray(valid, dtype=bool)) class Evenness(NamedTuple): @@ -185,21 +172,17 @@ def self_corrected(reference: np.ndarray, gain: np.ndarray) -> np.ndarray: def _is_clipped(image: np.ndarray) -> bool: - lit = _lit_mask(image).astype(bool) - return float((image[lit] >= _CLIP_LEVEL).any(axis=1).mean()) > _CLIP_FRACTION + fraction, dark = negpy_core.clipped_fraction(np.ascontiguousarray(image, dtype=np.float32), _LIT, _CLIP_LEVEL) + if dark: + _warn_dark() + return fraction > _CLIP_FRACTION def _evenness(image: ImageBuffer) -> Tuple[np.ndarray, Evenness]: - img = image.astype(np.float32) - lit = _lit_mask(img).astype(bool) - median = np.clip(np.median(img[lit], axis=0), 1e-6, None) - ratio = img / median - ratio[~lit] = 0.0 - lum = ratio[lit].mean(axis=1) - low, high = np.percentile(lum - 1.0, [1.0, 99.0]) - chroma = np.percentile(ratio[lit] / np.clip(lum, 1e-6, None)[:, None] - 1.0, [1.0, 99.0], axis=0) - clipped = float((img[lit] >= _CLIP_LEVEL).any(axis=1).mean()) > _CLIP_FRACTION - return ratio, Evenness(float(low), float(high), float(np.abs(chroma).max()), clipped) + ratio, (low, high, color, clipped, dark) = negpy_core.evenness(np.ascontiguousarray(image, dtype=np.float32), _LIT, _CLIP_LEVEL) + if dark: + _warn_dark() + return ratio, Evenness(low, high, color, clipped > _CLIP_FRACTION) def gain_token(gain: np.ndarray) -> str: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 961e920f8..664789e1e 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -21,7 +21,7 @@ tolerance listed for each entry. | `features/exposure/logic.py::apply_characteristic_curve` (the print curve) | `exp`, `log1p`, `tanh` and 10^x are `mathf`'s, not glibc's; 2 of 205 M recorded values round the other way. Every feature branch (masks, flash, diffusion, dye mix, separation damping, the print LUTs) matched the Numba kernel bit for bit on test frames. | ulp=1 | | `features/process/sensor.py::_log_unmix_kernel` (density unmix) | `logf` and `expf` are `mathf`'s (correctly rounded) instead of glibc's; the log-domain matrix and the exp amplify a one-ULP log to up to 16 ULP on 0.2 % of values. The other three unmix kernels match Numba bit for bit. | ulp=16 | | `features/geometry/logic.py::apply_fine_rotation`, `apply_keystone`, `apply_radial_distortion`, frames of 32767 px or more on a side | OpenCV cannot warp such a frame, so the Python version warped it in tiles from float64 source maps. negpy_core warps the whole frame with OpenCV's fixed-point coordinates, which can move a sample by 1/32 px. Smaller frames match `cv2` bit for bit. | none (the corpus has no such frame) | -| `negpy_core.resize_linear` (bilinear `cv2.resize` of float32: the contrast mask plane, the two-scale unmix layers, dust score and IR gain maps, flat-field gains) | opencv-python on x86-64 runs IPP's float32 bilinear resize, whose bits depend on the CPU it dispatches for; Apple Silicon wheels run OpenCV's own code. negpy_core copies OpenCV's own code. Upscales differ by up to 3 ULP, downscales more. No recorded call reaches these resizes. | ulp=3 (upscale) | +| `negpy_core.resize_linear` (bilinear `cv2.resize` of float32: the contrast mask plane, the two-scale unmix layers, dust score and IR gain maps, flat-field gains) | opencv-python on x86-64 runs IPP's float32 bilinear resize, whose bits depend on the CPU it dispatches for; Apple Silicon wheels run OpenCV's own code. negpy_core copies OpenCV's own code. Upscales differ by up to 4 ULP, downscales more; the flat-field check (`self_corrected`, `check_reference`) carries it. | ulp=4 (upscale); abs=1e-6 for `check_reference` | | `features/lab/logic.py::apply_rl_sharpening` | L* takes a correctly rounded cube root (`mathf::cbrtf`); NumPy's float32 `np.cbrt` runs SVML here, up to 2 ULP off on a third of inputs. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | | `features/sabattier/logic.py::apply_sabattier`, `features/cyanotype/logic.py::apply_cyanotype`, `features/lith/logic.py::apply_lith` | log10, exp, log1p, 10^x and the cube root are `mathf`'s, correctly rounded (or within an ULP of it); NumPy's float32 versions run SVML or its own SIMD code. With correctly rounded transcendentals each port matches bit for bit. | abs=1e-6 | | `features/finish/logic.py::apply_carrier` | The rebate tone lookup takes a correctly rounded cube root; NumPy's float32 `np.cbrt` runs SVML here. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 9028ea98d..48568f0b3 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1136,26 +1136,26 @@ depends = ["_oetf_encode_flat"] ["features/flatfield/logic.py::_evenness"] phase = 4 -status = "todo" -calls = ["np.median", "np.percentile"] -depends = ["_lit_mask"] +status = "ported" +calls = ["negpy_core.evenness"] +depends = [] ["features/flatfield/logic.py::_fill_from_nearest"] phase = 4 -status = "todo" -calls = ["cv2.distanceTransformWithLabels"] +status = "ported" +calls = ["negpy_core.fill_from_nearest"] depends = [] ["features/flatfield/logic.py::_is_clipped"] phase = 4 -status = "todo" -calls = [] -depends = ["_lit_mask"] +status = "ported" +calls = ["negpy_core.clipped_fraction"] +depends = [] ["features/flatfield/logic.py::_lit_mask"] phase = 4 -status = "todo" -calls = ["cv2.erode", "np.percentile"] +status = "ported" +calls = ["negpy_core.lit_mask"] depends = [] ["features/flatfield/logic.py::apply_flatfield"] @@ -1166,25 +1166,26 @@ depends = [] ["features/flatfield/logic.py::check_copy"] phase = 4 -status = "todo" +status = "ported" calls = [] depends = ["work_copy"] ["features/flatfield/logic.py::check_reference"] phase = 4 -status = "todo" +status = "ported" +compare = "abs=1e-6" calls = [] depends = ["_is_clipped", "check_copy", "evenness", "self_corrected"] ["features/flatfield/logic.py::compute_gain"] phase = 4 -status = "todo" -calls = ["cv2.GaussianBlur"] -depends = ["_fill_from_nearest", "_lit_mask", "work_copy"] +status = "ported" +calls = ["negpy_core.compute_gain"] +depends = ["work_copy"] ["features/flatfield/logic.py::evenness"] phase = 4 -status = "todo" +status = "ported" calls = [] depends = ["_evenness"] @@ -1197,12 +1198,13 @@ depends = ["_evenness"] ["features/flatfield/logic.py::self_corrected"] phase = 4 status = "todo" +compare = "ulp=4" calls = ["negpy_core.resize_linear"] depends = [] ["features/flatfield/logic.py::work_copy"] phase = 4 -status = "todo" +status = "ported" calls = ["negpy_core.resize_area"] depends = [] diff --git a/negpy_core/src/flatfield.rs b/negpy_core/src/flatfield.rs new file mode 100644 index 000000000..e419c1c1e --- /dev/null +++ b/negpy_core/src/flatfield.rs @@ -0,0 +1,255 @@ +//! The flat-field reference checks of negpy/features/flatfield/logic.py, in NumPy's float32 +//! operation order. + +use rayon::prelude::*; + +use crate::stats::{np_percentiles, np_percentiles_f64}; + +const BLOCK: usize = 1 << 13; + +/// `img.mean(axis=2)` of a 3-channel pixel: the three summed in order, then over 3. +#[inline(always)] +fn mean3(p: &[f32]) -> f32 { + ((p[0] + p[1]) + p[2]) / 3.0 +} + +/// The scalars of `_lit_mask`, as Python floats. +pub struct Lit { + pub percentile: f64, + pub fraction: f64, + pub min_fraction: f64, +} + +/// `_lit_mask(ref)`: true where the mean of the channels clears `fraction` of its +/// `percentile`, eroded 5x5 with the outside dark. `None` when under `min_fraction` of the +/// frame is lit, where the caller takes the whole frame. +pub fn lit_mask(img: &[f32], (h, w): (usize, usize), c: &Lit) -> Option> { + let lum: Vec = img.par_chunks(3).map(mean3).collect(); + let bar = (c.fraction as f32) * np_percentiles(&lum, &[c.percentile])?[0]; + let above: Vec = lum.par_iter().map(move |&l| (l > bar) as u8).collect(); + let mut lit = vec![0u8; h * w]; + crate::morph::morphology( + &above, + &mut lit, + (h, w, 1), + crate::morph::Op::Erode, + (5, 5), + 1, + crate::filter::Border::Constant, + )?; + // A dark outside clears every pixel whose window leaves the frame. + for (y, row) in lit.chunks_exact_mut(w).enumerate() { + if y < 2 || y + 2 >= h { + row.fill(0); + } else { + row[..2.min(w)].fill(0); + row[w.saturating_sub(2)..].fill(0); + } + } + let count = lit.par_iter().filter(|&&v| v != 0).count(); + (count as f64 >= c.min_fraction * (h * w) as f64).then(|| lit.iter().map(|&v| v != 0).collect()) +} + +/// Lit pixels with a channel at `level` or over, as a fraction of the lit pixels (float64). +fn clipped_fraction(img: &[f32], lit: &[bool], level: f32) -> f64 { + let (n, hit) = img + .par_chunks(3 * BLOCK) + .zip(lit.par_chunks(BLOCK)) + .map(|(px, l)| { + let mut n = 0usize; + let mut hit = 0usize; + for (p, &l) in px.as_chunks::<3>().0.iter().zip(l) { + if l { + n += 1; + hit += (p[0] >= level || p[1] >= level || p[2] >= level) as usize; + } + } + (n, hit) + }) + .reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1)); + hit as f64 / n as f64 +} + +/// `_is_clipped(image)`'s fraction of lit pixels at the clip level, and whether the frame was +/// too dark for a lit mask. +pub fn clipped(img: &[f32], dims: (usize, usize), c: &Lit, level: f32) -> (f64, bool) { + let lit = lit_mask(img, dims, c); + let dark = lit.is_none(); + let lit = lit.unwrap_or_else(|| vec![true; dims.0 * dims.1]); + (clipped_fraction(img, &lit, level), dark) +} + +/// What `_evenness` measures: (low, high, color, clipped fraction). +pub struct Evenness { + pub low: f64, + pub high: f64, + pub color: f64, + pub clipped: f64, + /// Too dark for a lit mask: the whole frame was measured. + pub dark: bool, +} + +/// `_evenness(image)`: each channel over its lit-area median (0 off the lit area) into +/// `ratio`, and the spread of the lit pixels about it. +pub fn evenness( + img: &[f32], + dims: (usize, usize), + c: &Lit, + level: f32, + ratio: &mut [f32], +) -> Option { + let n = dims.0 * dims.1; + let lit = lit_mask(img, dims, c); + let dark = lit.is_none(); + let lit = lit.unwrap_or_else(|| vec![true; n]); + let lit_px: Vec = (0..n).filter(|&p| lit[p]).collect(); + let median: [f32; 3] = std::array::from_fn(|ch| { + let vals: Vec = lit_px.par_iter().map(|&p| img[3 * p + ch]).collect(); + let m = crate::stats::median(&vals); + if m.is_nan() || m > 1e-6 { m } else { 1e-6 } + }); + ratio + .par_chunks_mut(3 * BLOCK) + .zip(img.par_chunks(3 * BLOCK).zip(lit.par_chunks(BLOCK))) + .for_each(|(r, (px, l))| { + for ((r, p), &l) in r + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(px.as_chunks::<3>().0.iter()) + .zip(l) + { + for ch in 0..3 { + r[ch] = if l { p[ch] / median[ch] } else { 0.0 }; + } + } + }); + let lum: Vec = lit_px + .par_iter() + .map(|&p| mean3(&ratio[3 * p..3 * p + 3])) + .collect(); + let dev: Vec = lum.par_iter().map(|&l| l - 1.0).collect(); + let q = np_percentiles_f64(&dev, &[1.0, 99.0]); + let color = (0..3) + .map(|ch| { + let chroma: Vec = lit_px + .par_iter() + .zip(&lum) + .map(|(&p, &l)| { + ratio[3 * p + ch] / if l.is_nan() || l > 1e-6 { l } else { 1e-6 } - 1.0 + }) + .collect(); + np_percentiles_f64(&chroma, &[1.0, 99.0]) + }) + .flat_map(|q| q.into_iter().map(f64::abs)) + .fold( + f64::NEG_INFINITY, + |m, v| if v > m || v.is_nan() { v } else { m }, + ); + Some(Evenness { + low: q[0], + high: q[1], + color, + clipped: clipped_fraction(img, &lit, level), + dark, + }) +} + +/// `_fill_from_nearest(values, valid)` in place: each invalid pixel takes the values of its +/// nearest valid one (OpenCV's L2 5x5 labels). +pub fn fill_from_nearest(values: &mut [f32], valid: &[bool], (h, w): (usize, usize), cn: usize) { + if valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { + return; + } + let holes: Vec = valid.iter().map(|&v| !v as u8).collect(); + let (_, labels) = crate::distance::distance_transform_labels(&holes, h, w); + // The labels number the valid pixels from 1 in raster order. + let donors: Vec = (0..h * w).filter(|&p| valid[p]).collect(); + for p in 0..h * w { + if !valid[p] { + let d = donors[labels[p] as usize - 1]; + for c in 0..cn { + values[cn * p + c] = values[cn * d + c]; + } + } + } +} + +/// The scalars of `compute_gain`. +pub struct Gain { + pub lit: Lit, + pub sigma: f64, + pub eps: f32, + pub min_lit_weight: f32, + pub range: (f32, f32), +} + +/// `compute_gain` of the reference at its working size: the lit-area mean of a normalized +/// Gaussian blur over the blur, per channel. Returns whether the reference was too dark for a +/// lit mask. +pub fn compute_gain( + reference: &[f32], + (h, w): (usize, usize), + c: &Gain, + gain: &mut [f32], +) -> Option { + use crate::filter::{Border, gaussian_blur}; + let n = h * w; + let lit_mask = lit_mask(reference, (h, w), &c.lit); + let dark = lit_mask.is_none(); + let lit: Vec = match lit_mask { + Some(m) => m.iter().map(|&v| v as u8 as f32).collect(), + None => vec![1.0; n], + }; + let weighted: Vec = reference + .as_chunks::<3>() + .0 + .iter() + .zip(&lit) + .flat_map(|(p, &l)| [p[0] * l, p[1] * l, p[2] * l]) + .collect(); + let (mut num, mut den) = (vec![0f32; 3 * n], vec![0f32; n]); + let s = (c.sigma, c.sigma); + gaussian_blur( + &weighted, + &mut num, + (h, w, 3), + (0, 0), + s, + Border::Reflect101, + )?; + gaussian_blur(&lit, &mut den, (h, w, 1), (0, 0), s, Border::Reflect101)?; + let eps = c.eps; + let clip_lo = |v: f32, lo: f32| if v.is_nan() || v > lo { v } else { lo }; + for (b, &d) in num.as_chunks_mut::<3>().0.iter_mut().zip(&den) { + let d = clip_lo(d, eps); + for v in b { + *v = clip_lo(*v / d, eps); + } + } + let valid: Vec = den.iter().map(|&d| d >= c.min_lit_weight).collect(); + fill_from_nearest(&mut num, &valid, (h, w), 3); + // (blur * lit).sum(axis=(0, 1)): one running sum per channel in raster order. + let mut sums = [0f32; 3]; + for (b, &l) in num.as_chunks::<3>().0.iter().zip(&lit) { + for ch in 0..3 { + sums[ch] += b[ch] * l; + } + } + let total = crate::stats::pairwise_sum_f32(&lit); + let means = sums.map(|s| s / total); + let (lo, hi) = c.range; + for (g, b) in gain + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(num.as_chunks::<3>().0.iter()) + { + for ch in 0..3 { + let v = means[ch] / b[ch]; + let v = if v.is_nan() || v > lo { v } else { lo }; + g[ch] = if v.is_nan() || v < hi { v } else { hi }; + } + } + Some(dark) +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index ab0af1638..1b95a7abd 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -10,6 +10,7 @@ pub mod display; pub mod distance; pub mod filter; pub mod finish; +pub mod flatfield; pub mod gray; pub mod hdr; pub mod histogram; @@ -68,5 +69,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::lab::register(m)?; py::altprocess::register(m)?; py::finish::register(m)?; + py::flatfield::register(m)?; py::skew::register(m) } diff --git a/negpy_core/src/py/flatfield.rs b/negpy_core/src/py/flatfield.rs new file mode 100644 index 000000000..8c8c61a40 --- /dev/null +++ b/negpy_core/src/py/flatfield.rs @@ -0,0 +1,147 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::flatfield::{self, Lit}; + +fn image3<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let [h, w, 3] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + Ok((s, (h, w))) +} + +fn lit(c: (f64, f64, f64)) -> Lit { + Lit { + percentile: c.0, + fraction: c.1, + min_fraction: c.2, + } +} + +/// `_lit_mask(ref)` with (percentile, fraction, minimum lit fraction): an HxW bool mask, or +/// None when too little of the frame is lit. +#[pyfunction] +fn lit_mask<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, f64), +) -> PyResult>>> { + use numpy::IntoPyArray; + let (src, (h, w)) = image3(&img)?; + let c = lit(constants); + let mask = super::detach(py, src.len(), || flatfield::lit_mask(src, (h, w), &c)); + mask.map(|m| m.into_pyarray(py).reshape([h, w])).transpose() +} + +/// `_is_clipped(image)`'s (fraction of lit pixels at `level`, frame too dark for a lit mask). +#[pyfunction] +fn clipped_fraction( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + constants: (f64, f64, f64), + level: f32, +) -> PyResult<(f64, bool)> { + let (src, dims) = image3(&img)?; + let c = lit(constants); + Ok(super::detach(py, src.len(), || { + flatfield::clipped(src, dims, &c, level) + })) +} + +type Evenness<'py> = (Bound<'py, PyArrayDyn>, (f64, f64, f64, f64, bool)); + +/// `_evenness(image)`: (ratio, (low, high, color, clipped fraction, too dark)). +#[pyfunction] +fn evenness<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, f64), + level: f32, +) -> PyResult> { + let (src, dims) = image3(&img)?; + let c = lit(constants); + let out = py + .import("numpy")? + .getattr("empty")? + .call1((img.shape().to_vec(), "float32"))? + .cast_into::>()?; + let e = { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || { + flatfield::evenness(src, dims, &c, level, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid evenness input"))? + }; + Ok((out, (e.low, e.high, e.color, e.clipped, e.dark))) +} + +/// `compute_gain` of the reference at its working size, with the lit-mask constants, sigma, +/// eps, the minimum lit weight and (gain min, gain max): (gain, too dark). +#[pyfunction] +fn compute_gain<'py>( + py: Python<'py>, + reference: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, f64), + sigma: f64, + eps: f32, + min_lit_weight: f32, + range: (f32, f32), +) -> PyResult<(Bound<'py, PyArrayDyn>, bool)> { + let (src, dims) = image3(&reference)?; + let c = flatfield::Gain { + lit: lit(constants), + sigma, + eps, + min_lit_weight, + range, + }; + let out = PyArrayDyn::::zeros(py, reference.shape(), false); + let dark = { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || { + flatfield::compute_gain(src, dims, &c, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid reference"))? + }; + Ok((out, dark)) +} + +/// `_fill_from_nearest(values, valid)` of an HxWxC float32 array: a filled copy. +#[pyfunction] +fn fill_from_nearest<'py>( + py: Python<'py>, + values: PyReadonlyArrayDyn<'py, f32>, + valid: PyReadonlyArrayDyn<'py, bool>, +) -> PyResult>> { + let [h, w, cn] = values.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC array")); + }; + if valid.shape() != [h, w] { + return Err(PyValueError::new_err("needs an HxW mask")); + } + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let mut v = values.as_slice().map_err(not_contiguous)?.to_vec(); + let m = valid.as_slice().map_err(not_contiguous)?; + super::detach(py, v.len(), || { + flatfield::fill_from_nearest(&mut v, m, (h, w), cn) + }); + numpy::PyArray1::from_vec(py, v).reshape(vec![h, w, cn]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compute_gain, m)?)?; + m.add_function(wrap_pyfunction!(fill_from_nearest, m)?)?; + m.add_function(wrap_pyfunction!(lit_mask, m)?)?; + m.add_function(wrap_pyfunction!(clipped_fraction, m)?)?; + m.add_function(wrap_pyfunction!(evenness, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index b6f185de2..817908216 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -8,6 +8,7 @@ pub mod convert; pub mod display; pub mod filter; pub mod finish; +pub mod flatfield; pub mod gray; pub mod hdr; pub mod histogram; diff --git a/tests/test_core_flatfield.py b/tests/test_core_flatfield.py new file mode 100644 index 000000000..0c49d129b --- /dev/null +++ b/tests/test_core_flatfield.py @@ -0,0 +1,81 @@ +"""The flat-field reference checks against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +from negpy.features.flatfield import logic as F + +pytestmark = cv2_oracle + + +def _reference(shape=(170, 256), seed=0): + rng = np.random.default_rng(seed) + h, w = shape + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + r2 = ((x - w / 2) / w) ** 2 + ((y - h / 2) / h) ** 2 + ref = (0.8 * (1.0 - 0.9 * r2))[..., None] * np.array([1.0, 0.95, 0.9], dtype=np.float32) + ref = ref + 0.01 * rng.standard_normal(ref.shape) + ref[:, : w // 20] *= 0.1 + ref[5:8, 100:104] = 0.99 # clipped + return ref.astype(np.float32) + + +def _lit_numpy(ref): + lum = ref.mean(axis=2) + lit = (lum > F._LIT_FRACTION * np.percentile(lum, F._LIT_PERCENTILE)).astype(np.uint8) + lit = cv2.erode(lit, np.ones((5, 5), np.uint8), borderType=cv2.BORDER_CONSTANT, borderValue=0) + if lit.sum() < F._MIN_LIT_FRACTION * lit.size: + return np.ones(lum.shape, np.float32) + return lit.astype(np.float32) + + +def _fill_numpy(values, valid): + if valid.all() or not valid.any(): + return values + _, labels = cv2.distanceTransformWithLabels((~valid).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL) + source = np.zeros(labels.max() + 1, np.int64) + source[labels[valid]] = np.flatnonzero(valid) + return values.reshape(-1, values.shape[2])[source[labels]].reshape(values.shape) + + +def test_lit_mask_matches_numpy(): + ref = _reference() + assert F._lit_mask(ref).tobytes() == _lit_numpy(ref).tobytes() + dark = np.zeros((40, 40, 3), dtype=np.float32) + assert F._lit_mask(dark).tobytes() == np.ones((40, 40), np.float32).tobytes() + + +def test_evenness_matches_numpy(): + img = _reference(seed=1) + lit = _lit_numpy(img).astype(bool) + median = np.clip(np.median(img[lit], axis=0), 1e-6, None) + ratio = img / median + ratio[~lit] = 0.0 + lum = ratio[lit].mean(axis=1) + low, high = np.percentile(lum - 1.0, [1.0, 99.0]) + chroma = np.percentile(ratio[lit] / np.clip(lum, 1e-6, None)[:, None] - 1.0, [1.0, 99.0], axis=0) + clipped = float((img[lit] >= F._CLIP_LEVEL).any(axis=1).mean()) > F._CLIP_FRACTION + got_ratio, got = F._evenness(img) + assert got_ratio.tobytes() == ratio.tobytes() + assert got == F.Evenness(float(low), float(high), float(np.abs(chroma).max()), clipped) + + +def test_compute_gain_matches_numpy(): + ref = F.work_copy(_reference((400, 600), seed=2)) + sigma = max(ref.shape[:2]) / F._BLUR_DIVISOR + lit = _lit_numpy(ref) + num = cv2.GaussianBlur(ref * lit[..., None], (0, 0), sigmaX=sigma, sigmaY=sigma) + den = cv2.GaussianBlur(lit, (0, 0), sigmaX=sigma, sigmaY=sigma) + blur = np.clip(num / np.clip(den, 1e-4, None)[..., None], 1e-4, None) + blur = _fill_numpy(blur, den >= F._MIN_LIT_WEIGHT) + means = (blur * lit[..., None]).sum(axis=(0, 1)) / lit.sum() + want = np.clip(means[None, None, :] / blur, F._GAIN_MIN, F._GAIN_MAX).astype(np.float32) + assert F.compute_gain(_reference((400, 600), seed=2)).tobytes() == want.tobytes() + + +def test_fill_from_nearest_matches_cv2(): + rng = np.random.default_rng(3) + values = rng.random((50, 70, 3), dtype=np.float32) + valid = rng.random((50, 70)) > 0.6 + assert F._fill_from_nearest(values, valid).tobytes() == _fill_numpy(values, valid).tobytes() From 949e2f84125bbf9da0c9ee28764e663eb21204db Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 15:03:57 +0200 Subject: [PATCH 092/133] perf(core): local masks in Rust, with an exact port of cv2.fillPoly rasterise and compute_local_maps run in negpy_core: OpenCV's CollectPolyEdges/FillEdgeCollection (matches cv2 on 3000 random self-intersecting polygons), the Gaussian feather and the gradient ramp, then the plane sums in mask order. The vertex mapping and the outline smoothing stay in Python. Bit-exact on the corpus; compute_local_maps 0.50x, rasterise 0.42x. --- negpy/features/local/logic.py | 60 +++----- negpy_core/manifest.toml | 10 +- negpy_core/src/py/contours.rs | 104 +++++++++++++ negpy_core/src/shapes.rs | 272 ++++++++++++++++++++++++++++++++++ tests/test_core_local.py | 55 +++++++ 5 files changed, 459 insertions(+), 42 deletions(-) create mode 100644 tests/test_core_local.py diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index 7a1b8dd47..5511e3dfb 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -1,7 +1,7 @@ import math from typing import List, Sequence, Tuple -import cv2 +import negpy_core import numpy as np from negpy.features.local.models import MAX_KEYED_MASKS, LocalAdjustmentsConfig, LocalMask, MaskKey, MaskShape @@ -40,17 +40,19 @@ def outline_points(shape: MaskShape, pts: Sequence[Point]) -> List[Point]: return smooth_polyline(list(pts), closed=True) -def _rasterise_ramp(a: Point, b: Point, h: int, w: int) -> np.ndarray: - """The card-edge ramp. Alpha is 1 at `a` and 0 at `b`, and constant beyond each.""" - ax, ay = a[0] * w, a[1] * h - dx, dy = b[0] * w - ax, b[1] * h - ay - denom = dx * dx + dy * dy - if denom <= 1e-9: - return np.zeros((h, w), dtype=np.float32) - xs = (np.arange(w, dtype=np.float32) - ax) * (dx / denom) - ys = (np.arange(h, dtype=np.float32) - ay) * (dy / denom) - t = np.clip(xs[None, :] + ys[:, None], 0.0, 1.0) - return (1.0 - t * t * (3.0 - 2.0 * t)).astype(np.float32) +def _mask_spec(shape: MaskShape, pts: Sequence[Point], h: int, w: int, feather_sigma: float) -> tuple: + """The alpha half of a negpy_core mask spec: ("fill", outline px, (sigma, ...)) or a gradient's + card-edge ramp, 1 at the first point and 0 at the second, constant beyond each.""" + if shape == MaskShape.GRADIENT: + (a, b) = pts[0], pts[1] + ax, ay = a[0] * w, a[1] * h + dx, dy = b[0] * w - ax, b[1] * h - ay + denom = dx * dx + dy * dy + if denom <= 1e-9: + return ("empty", None, (0.0, 0.0, 0.0, 0.0, 0.0)) + return ("ramp", None, (0.0, ax, ay, dx / denom, dy / denom)) + outline = np.array([[x * w, y * h] for x, y in outline_points(shape, pts)], dtype=np.float32) + return ("fill", outline.astype(np.int32), (float(feather_sigma), 0.0, 0.0, 0.0, 0.0)) def rasterise( @@ -63,20 +65,11 @@ def rasterise( ) -> np.ndarray: """Rasterise the control points, normalised to [0,1], to a float32 alpha [h, w]. - `feather_sigma` is a Gaussian sigma in pixels on the hard fill. A gradient ignores - it, because the distance between its two points sets the softness. + `feather_sigma` is a Gaussian sigma in pixels on the hard fill, over a kernel of about 3 + sigma. A gradient ignores it, because the distance between its two points sets the softness. """ - if shape == MaskShape.GRADIENT: - alpha = _rasterise_ramp(pts[0], pts[1], h, w) - else: - outline = np.array([[x * w, y * h] for x, y in outline_points(shape, pts)], dtype=np.float32) - filled = np.zeros((h, w), dtype=np.uint8) - cv2.fillPoly(filled, [outline.astype(np.int32)], 255) - alpha = filled.astype(np.float32) / 255.0 - if feather_sigma > 1e-3: - k = int(feather_sigma * 3) | 1 # odd kernel covering ~3 sigma - alpha = cv2.GaussianBlur(alpha, (k, k), feather_sigma) - return 1.0 - alpha if invert else alpha + kind, outline, params = _mask_spec(shape, pts, h, w, feather_sigma) + return negpy_core.rasterise_mask((h, w), (kind, outline, params, bool(invert), -1, (0.0, 0.0, 0.0))) def _tinted_area(mask, grid: int) -> np.ndarray | None: @@ -133,6 +126,7 @@ def compute_local_maps( return maps short_side = float(min(h, w)) + specs = [] for i, mask in enumerate(config.masks): if not mask.enabled or len(mask.vertices) < min_points(mask.shape): continue @@ -152,18 +146,10 @@ def compute_local_maps( ) for rx, ry in mask.vertices ] - - alpha = rasterise(mask.shape, transformed, h, w, mask.feather * short_side, mask.invert) - if i in limited: - maps[:, :, 3 + limited.index(i)] = alpha - continue - maps[:, :, 0] += mask.stops * alpha - if mask.grade: - maps[:, :, 1] += mask.grade * alpha - if mask.flash: - maps[:, :, 2] += mask.flash * alpha - - return maps + kind, outline, params = _mask_spec(mask.shape, transformed, h, w, mask.feather * short_side) + plane = limited.index(i) if i in limited else -1 + specs.append((kind, outline, params, bool(mask.invert), plane, (float(mask.stops), float(mask.grade), float(mask.flash)))) + return negpy_core.local_maps((h, w, 3 + len(limited)), specs) def limited_indices(config: LocalAdjustmentsConfig) -> List[int]: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 48568f0b3..59093b2fe 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1252,9 +1252,9 @@ depends = ["rasterise"] ["features/local/logic.py::compute_local_maps"] phase = 4 -status = "todo" -calls = [] -depends = ["rasterise"] +status = "ported" +calls = ["negpy_core.local_maps"] +depends = [] ["features/local/logic.py::overlapping_masks"] phase = 4 @@ -1264,8 +1264,8 @@ depends = ["_tinted_area"] ["features/local/logic.py::rasterise"] phase = 4 -status = "todo" -calls = ["cv2.GaussianBlur", "cv2.fillPoly"] +status = "ported" +calls = ["negpy_core.rasterise_mask"] depends = [] ["features/process/capture_color.py::lightbox_level"] diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index 0d05bfb1b..63a8a5eb0 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -106,7 +106,111 @@ fn fill_convex_poly<'py>( numpy::PyArray1::from_vec(py, mask).reshape([h, w]) } +/// `cv2.fillPoly(zeros((h, w), uint8), [points], 255)` as a new mask. +#[pyfunction] +fn fill_poly<'py>( + py: Python<'py>, + shape: (usize, usize), + pts: numpy::PyReadonlyArrayDyn<'_, i32>, +) -> PyResult>> { + let (h, w) = shape; + let v: Vec<(i64, i64)> = int_points(&pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + let mut mask = vec![0u8; h * w]; + crate::shapes::fill_poly(&mut mask, w, h, &v, 255); + numpy::PyArray1::from_vec(py, mask).reshape([h, w]) +} + +/// One mask for `local_maps`: ("fill", outline Nx2 int32, sigma) or ("ramp", (ax, ay, kx, ky)) +/// or ("empty",), then invert, plane (-1 for none), stops, grade, flash. +type MaskSpec<'py> = ( + String, + Option>, + (f64, f32, f32, f32, f32), + bool, + i64, + (f32, f32, f32), +); + +fn local_mask(spec: MaskSpec<'_>, planes: usize) -> PyResult { + use crate::shapes::{LocalMask, MaskAlpha}; + let (kind, outline, (sigma, ax, ay, kx, ky), invert, plane, (stops, grade, flash)) = spec; + let alpha = match kind.as_str() { + "fill" => { + let pts = outline.ok_or_else(|| PyValueError::new_err("a fill needs its outline"))?; + let outline = int_points(&pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + MaskAlpha::Fill { outline, sigma } + } + "ramp" => MaskAlpha::Ramp { ax, ay, kx, ky }, + _ => MaskAlpha::Empty, + }; + let plane = usize::try_from(plane).ok(); + if plane.is_some_and(|k| 3 + k >= planes) { + return Err(PyValueError::new_err("plane out of range")); + } + Ok(LocalMask { + alpha, + invert, + plane, + stops, + grade, + flash, + }) +} + +/// `rasterise` of one mask spec (as for `local_maps`) to an HxW float32 alpha. +#[pyfunction] +fn rasterise_mask<'py>( + py: Python<'py>, + shape: (usize, usize), + spec: MaskSpec<'py>, +) -> PyResult>> { + let (h, w) = shape; + let m = local_mask(spec, 4)?; + let mut alpha = vec![0f32; h * w]; + super::detach(py, alpha.len(), || { + crate::shapes::rasterise(&m.alpha, (h, w), m.invert, &mut alpha) + }) + .ok_or_else(|| PyValueError::new_err("invalid mask"))?; + numpy::PyArray1::from_vec(py, alpha).reshape([h, w]) +} + +/// `compute_local_maps`' HxWxP float32 planes from masks already in pixel space. +#[pyfunction] +fn local_maps<'py>( + py: Python<'py>, + shape: (usize, usize, usize), + masks: Vec>, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let (h, w, planes) = shape; + let specs = masks + .into_iter() + .map(|m| local_mask(m, planes)) + .collect::>>()?; + let out = PyArrayDyn::::zeros(py, vec![h, w, planes], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + crate::shapes::local_maps(&specs, (h, w), planes, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid mask"))?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(local_maps, m)?)?; + m.add_function(wrap_pyfunction!(rasterise_mask, m)?)?; + m.add_function(wrap_pyfunction!(fill_poly, m)?)?; m.add_function(wrap_pyfunction!(find_external_contours, m)?)?; m.add_function(wrap_pyfunction!(min_area_rect, m)?)?; m.add_function(wrap_pyfunction!(box_points, m)?)?; diff --git a/negpy_core/src/shapes.rs b/negpy_core/src/shapes.rs index 7738e2d76..8735aadf8 100644 --- a/negpy_core/src/shapes.rs +++ b/negpy_core/src/shapes.rs @@ -601,3 +601,275 @@ mod tests { assert_eq!(mask.iter().filter(|&&v| v == 1).count(), 25); } } + +#[derive(Clone, Copy)] +struct PolyEdge { + y0: i64, + y1: i64, + x: i64, + dx: i64, + next: usize, +} + +/// `cv2.fillPoly(mask, [pts], color)` for one contour (8-connected outline, shift 0): OpenCV's +/// CollectPolyEdges and FillEdgeCollection, the active edge list kept as indices. +pub fn fill_poly(mask: &mut [u8], w: usize, h: usize, v: &[(i64, i64)], color: u8) { + const NONE: usize = usize::MAX; + let count = v.len(); + if count == 0 { + return; + } + let mut edges: Vec = Vec::with_capacity(count + 1); + let mut pt0 = (v[count - 1].0 << XY_SHIFT, v[count - 1].1); + for &p in v { + let pt1 = (p.0 << XY_SHIFT, p.1); + let (mut pt0c, mut pt1c) = (pt0, pt1); + let mut t0 = ((pt0.0 + (XY_ONE >> 1)) >> XY_SHIFT, pt0.1); + let mut t1 = ((pt1.0 + (XY_ONE >> 1)) >> XY_SHIFT, pt1.1); + draw_line(mask, w, h, t0, t1, color); + let out = |q: (i64, i64)| q.0 < 0 || q.0 >= w as i64 || q.1 < 0 || q.1 >= h as i64; + if out(t0) || out(t1) { + clip_line(w as i64, h as i64, &mut t0, &mut t1); + if t0.1 != t1.1 { + pt0c.1 = t0.1; + pt1c.1 = t1.1; + } + } + pt0c.0 = t0.0 << XY_SHIFT; + pt1c.0 = t1.0 << XY_SHIFT; + if pt0.1 != pt1.1 { + let dx = (pt1c.0 - pt0c.0) / (pt1c.1 - pt0c.1); + let (y0, y1, x) = if pt0.1 < pt1.1 { + (pt0.1, pt1.1, pt0c.0 + (pt0.1 - pt0c.1) * dx) + } else { + (pt1.1, pt0.1, pt1c.0 + (pt1.1 - pt1c.1) * dx) + }; + edges.push(PolyEdge { + y0, + y1, + x, + dx, + next: NONE, + }); + } + pt0 = pt1; + } + + let total = edges.len(); + if total < 2 { + return; + } + let (mut y_max, mut y_min) = (i64::MIN, i64::MAX); + let (mut x_max, mut x_min) = (-1i64, i64::MAX); + for e in &edges { + let x1 = e.x + (e.y1 - e.y0) * e.dx; + y_min = y_min.min(e.y0); + y_max = y_max.max(e.y1); + x_min = x_min.min(e.x).min(x1); + x_max = x_max.max(e.x).max(x1); + } + if y_max < 0 || y_min >= h as i64 || x_max < 0 || x_min >= (w as i64) << XY_SHIFT { + return; + } + edges.sort_by_key(|e| (e.y0, e.x, e.dx)); + edges.push(PolyEdge { + y0: i64::MAX, + y1: 0, + x: 0, + dx: 0, + next: NONE, + }); + // Slot `total + 1` is the list head (OpenCV's `tmp`). + let head = total + 1; + edges.push(PolyEdge { + y0: i64::MAX, + y1: 0, + x: 0, + dx: 0, + next: NONE, + }); + let delta = XY_ONE - 1; + let mut i = 0; + let y_max = y_max.min(h as i64); + let mut y = edges[0].y0; + while y < y_max { + let mut draw = false; + let clipline = y < 0; + let mut prelast = head; + let mut last = edges[head].next; + while last != NONE || edges[i].y0 == y { + if last != NONE && edges[last].y1 == y { + edges[prelast].next = edges[last].next; + last = edges[last].next; + continue; + } + let keep_prelast = prelast; + if last != NONE && (edges[i].y0 > y || edges[last].x < edges[i].x) { + prelast = last; + last = edges[last].next; + } else if i < total { + edges[prelast].next = i; + edges[i].next = last; + prelast = i; + i += 1; + } else { + break; + } + if draw { + if !clipline { + let (a, b) = (edges[keep_prelast].x, edges[prelast].x); + let (mut x1, mut x2) = if a > b { + ((b + delta) >> XY_SHIFT, a >> XY_SHIFT) + } else { + ((a + delta) >> XY_SHIFT, b >> XY_SHIFT) + }; + if x1 < w as i64 && x2 >= 0 { + x1 = x1.max(0); + x2 = x2.min(w as i64 - 1); + let row = y as usize * w; + mask[row + x1 as usize..=row + x2 as usize].fill(color); + } + } + edges[keep_prelast].x += edges[keep_prelast].dx; + edges[prelast].x += edges[prelast].dx; + } + draw = !draw; + } + + // Bubble sort of the active list by x. + let mut keep_prelast = NONE; + loop { + let mut prelast = head; + let mut last = edges[head].next; + let mut last_exchange = NONE; + while last != keep_prelast && last != NONE && edges[last].next != NONE { + let te = edges[last].next; + if edges[last].x > edges[te].x { + edges[prelast].next = te; + edges[last].next = edges[te].next; + edges[te].next = last; + prelast = te; + last_exchange = prelast; + } else { + prelast = last; + last = te; + } + } + if last_exchange == NONE { + break; + } + keep_prelast = last_exchange; + if keep_prelast == edges[head].next || keep_prelast == head { + break; + } + } + y += 1; + } +} + +/// One mask of `compute_local_maps`, its outline already in pixels. +pub enum MaskAlpha { + /// A filled outline, feathered by a Gaussian of this sigma in pixels. + Fill { + outline: Vec<(i64, i64)>, + sigma: f64, + }, + /// `_rasterise_ramp`: 1 at (ax, ay), 0 at (ax + dx, ay + dy), the offsets over their + /// squared length. + Ramp { ax: f32, ay: f32, kx: f32, ky: f32 }, + /// A ramp whose two points coincide. + Empty, +} + +/// `rasterise` into `alpha` (h x w float32). +pub fn rasterise( + mask: &MaskAlpha, + (h, w): (usize, usize), + invert: bool, + alpha: &mut [f32], +) -> Option<()> { + use rayon::prelude::*; + match mask { + MaskAlpha::Fill { outline, sigma } => { + let mut filled = vec![0u8; h * w]; + fill_poly(&mut filled, w, h, outline, 255); + let hard: Vec = filled.par_iter().map(|&v| v as f32 / 255.0).collect(); + if *sigma > 1e-3 { + let k = (sigma * 3.0) as usize | 1; + crate::filter::gaussian_blur( + &hard, + alpha, + (h, w, 1), + (k, k), + (*sigma, *sigma), + crate::filter::Border::Reflect101, + )?; + } else { + alpha.copy_from_slice(&hard); + } + } + &MaskAlpha::Ramp { ax, ay, kx, ky } => { + let xs: Vec = (0..w).map(|x| (x as f32 - ax) * kx).collect(); + alpha.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let yv = (y as f32 - ay) * ky; + for (a, &xv) in row.iter_mut().zip(&xs) { + let t = xv + yv; + let t = if t.is_nan() || t > 0.0 { t } else { 0.0 }; + let t = if t.is_nan() || t < 1.0 { t } else { 1.0 }; + *a = 1.0 - t * t * (3.0 - 2.0 * t); + } + }); + } + MaskAlpha::Empty => alpha.fill(0.0), + } + if invert { + alpha.par_iter_mut().for_each(|a| *a = 1.0 - *a); + } + Some(()) +} + +/// One mask of `compute_local_maps`: its alpha, and either its own plane (a tone-limited +/// mask) or the stops, grade and flash it adds to planes 0-2. +pub struct LocalMask { + pub alpha: MaskAlpha, + pub invert: bool, + pub plane: Option, + pub stops: f32, + pub grade: f32, + pub flash: f32, +} + +/// `compute_local_maps`' planes (h x w x planes float32) from masks in pixel space. +pub fn local_maps( + masks: &[LocalMask], + (h, w): (usize, usize), + planes: usize, + maps: &mut [f32], +) -> Option<()> { + use rayon::prelude::*; + maps.fill(0.0); + let mut alpha = vec![0f32; h * w]; + for m in masks { + rasterise(&m.alpha, (h, w), m.invert, &mut alpha)?; + let (stops, grade, flash, plane) = (m.stops, m.grade, m.flash, m.plane); + maps.par_chunks_mut(planes * 1024) + .zip(alpha.par_chunks(1024)) + .for_each(|(px, a)| { + for (p, &a) in px.chunks_exact_mut(planes).zip(a) { + match plane { + Some(k) => p[3 + k] = a, + None => { + p[0] += stops * a; + if grade != 0.0 { + p[1] += grade * a; + } + if flash != 0.0 { + p[2] += flash * a; + } + } + } + } + }); + } + Some(()) +} diff --git a/tests/test_core_local.py b/tests/test_core_local.py new file mode 100644 index 000000000..c2cd03378 --- /dev/null +++ b/tests/test_core_local.py @@ -0,0 +1,55 @@ +"""cv2.fillPoly and the local-mask rasteriser against the cv2 and NumPy code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.local.logic import outline_points, rasterise +from negpy.features.local.models import MaskShape + +pytestmark = cv2_oracle + + +def test_fill_poly_matches_cv2_on_random_polygons(): + rng = np.random.default_rng(0) + for _ in range(500): + h, w = (int(v) for v in rng.integers(5, 90, 2)) + n = int(rng.integers(3, 30)) + pts = np.stack([rng.integers(-20, w + 20, n), rng.integers(-20, h + 20, n)], 1).astype(np.int32) + want = np.zeros((h, w), np.uint8) + cv2.fillPoly(want, [pts], 255) + np.testing.assert_array_equal(negpy_core.fill_poly((h, w), pts), want) + + +def _rasterise_cv2(shape, pts, h, w, sigma, invert): + if shape == MaskShape.GRADIENT: + (a, b) = pts + ax, ay = a[0] * w, a[1] * h + dx, dy = b[0] * w - ax, b[1] * h - ay + denom = dx * dx + dy * dy + xs = (np.arange(w, dtype=np.float32) - ax) * (dx / denom) + ys = (np.arange(h, dtype=np.float32) - ay) * (dy / denom) + t = np.clip(xs[None, :] + ys[:, None], 0.0, 1.0) + alpha = (1.0 - t * t * (3.0 - 2.0 * t)).astype(np.float32) + else: + outline = np.array([[x * w, y * h] for x, y in outline_points(shape, pts)], dtype=np.float32) + filled = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(filled, [outline.astype(np.int32)], 255) + alpha = filled.astype(np.float32) / 255.0 + if sigma > 1e-3: + k = int(sigma * 3) | 1 + alpha = cv2.GaussianBlur(alpha, (k, k), sigma) + return 1.0 - alpha if invert else alpha + + +def test_rasterise_matches_cv2(): + cases = [ + (MaskShape.POLYGON, ((0.2, 0.2), (0.5, 0.15), (0.6, 0.45), (0.25, 0.5)), 6.0, False), + (MaskShape.OVAL, ((0.6, 0.6), (0.85, 0.6), (0.6, 0.8)), 3.5, True), + (MaskShape.GRADIENT, ((0.5, 0.0), (0.4, 0.7)), 0.0, False), + (MaskShape.POLYGON, ((0.1, 0.9), (0.9, 0.1), (0.9, 0.9), (0.1, 0.1)), 0.0, False), + ] + for shape, pts, sigma, invert in cases: + want = _rasterise_cv2(shape, pts, 150, 210, sigma, invert) + assert rasterise(shape, pts, 150, 210, sigma, invert).tobytes() == want.tobytes(), shape From da2b2595012bf9cce4f52810c56b4b427e300fbe Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 15:16:21 +0200 Subject: [PATCH 093/133] perf(core): scratch ridge and trace in Rust; one-pass narrow box filter _scratch_ridge (after NumPy's log10) and the slope search of trace_scratch run in negpy_core, the 161 shears in parallel. Bit-exact on the corpus; trace_scratch 0.51x, the ridge 0.96-1.02x (a third of it is the NumPy density). Box filters up to five wide run in one parallel pass: each column strip sums its own rows into a ring that stays in cache. 3-channel 3x3 and 5x5 now beat cv2 on all cores. Wider kernels keep taller bands with a 2 MB ring, and column strips shrink to 64 values so a single-channel plane uses every thread. The density-unmix entries take their ledgered ulp=16 compare. --- negpy/features/retouch/logic.py | 28 +++----- negpy_core/manifest.toml | 12 ++-- negpy_core/src/filter.rs | 96 ++++++++++++++++++++++++--- negpy_core/src/py/retouch.rs | 39 +++++++++++ negpy_core/src/retouch.rs | 113 ++++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 32 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index ae7cd7a75..8bb497eb0 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -503,14 +503,9 @@ def _scratch_ridge(img: ImageBuffer) -> np.ndarray: divided by a local scale of that same response. Local, because a global scale lets one busy corner of the frame set the bar and bury a faint scratch running through smooth sky. """ - dens = _density(img) - fine = cv2.GaussianBlur(dens, (1, 0), sigmaX=0, sigmaY=_SCRATCH_FINE_PX) - broad = cv2.GaussianBlur(dens, (1, 0), sigmaX=0, sigmaY=_SCRATCH_BROAD_PX) - ridge = fine - broad - win = (_SCRATCH_NOISE_WIN, _SCRATCH_NOISE_WIN) - # mean|x| is 0.8σ for gaussian noise, and the bars above are quoted in that ratio. - scale = cv2.blur(np.abs(ridge), win) / 0.8 - return ridge / np.maximum(scale, 1e-6) + # The noise scale is mean|x| over 0.8, its ratio to σ for gaussian noise: the bars above + # are quoted in σ. + return negpy_core.scratch_ridge(_density(img), (_SCRATCH_FINE_PX, _SCRATCH_BROAD_PX, _SCRATCH_NOISE_WIN)) def _shear_rows(plane: np.ndarray, slope: float, about_x: float, width: int) -> np.ndarray: @@ -547,17 +542,14 @@ def trace_scratch(img: ImageBuffer, nx: float, ny: float, threshold: float = 0.5 z, z0 = _scratch_ridge_rows(img, y0 - reach, y1 + reach) pull = np.exp(-0.5 * ((np.arange(y0, y1) - cy) / _SCRATCH_CLICK_PULL) ** 2) - best: Optional[Tuple[float, float, int, np.ndarray]] = None - for slope in np.arange(-_SCRATCH_SLOPE_MAX, _SCRATCH_SLOPE_MAX + 1e-9, _SCRATCH_SLOPE_STEP): - # Sheared with its reach, then cut: a line leaves the search rows across a wide frame. - sheared = _shear_rows(z, float(slope), cx, w)[y0 - z0 : y1 - z0] - strength = np.abs(sheared.mean(axis=1)) * pull - k = int(np.argmax(strength)) - if best is None or strength[k] > best[0]: - best = (float(strength[k]), float(slope), k, sheared[k]) - if best is None or best[0] < _SCRATCH_MIN_EVIDENCE: + slopes = np.arange(-_SCRATCH_SLOPE_MAX, _SCRATCH_SLOPE_MAX + 1e-9, _SCRATCH_SLOPE_STEP) + # Each slope shears the ridge with its reach, then cuts the search rows: a line leaves them + # across a wide frame. + found = negpy_core.scratch_slope_search(np.ascontiguousarray(z, dtype=np.float32), cx, (y0 - z0, y1 - z0), pull, slopes) + if found is None or found[0] < _SCRATCH_MIN_EVIDENCE: return None - _, slope, k, along = best + _, index, k, along = found + slope = float(slopes[index]) # A transport scratch fades in and out, so measure its extent instead of assuming it # spans the frame. diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 59093b2fe..2df8cfedd 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1343,6 +1343,7 @@ depends = ["_corr"] ["features/process/sensor.py::_density_unmix"] phase = 4 status = "todo" +compare = "ulp=16" calls = [] depends = ["_log_unmix_kernel", "film_base"] @@ -1379,6 +1380,7 @@ depends = [] ["features/process/sensor.py::apply_sensor_correction"] phase = 4 status = "todo" +compare = "ulp=16" calls = [] depends = ["_density_unmix", "_two_scale_unmix", "_unmix_kernel"] @@ -1504,13 +1506,13 @@ depends = [] ["features/retouch/logic.py::_scratch_ridge"] phase = 4 -status = "todo" -calls = ["cv2.GaussianBlur", "cv2.blur"] +status = "ported" +calls = ["negpy_core.scratch_ridge"] depends = ["_density"] ["features/retouch/logic.py::_scratch_ridge_rows"] phase = 4 -status = "todo" +status = "ported" calls = [] depends = ["_scratch_ridge"] @@ -1618,8 +1620,8 @@ depends = ["_density", "_mask_to_score"] ["features/retouch/logic.py::trace_scratch"] phase = 4 -status = "todo" -calls = ["cv2.blur", "np.median"] +status = "ported" +calls = ["cv2.blur", "negpy_core.scratch_slope_search", "np.median"] depends = ["_scratch_ridge_rows", "_shear_rows"] ["features/hdr/logic.py::_accumulate"] diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs index f954dc29c..cf53cccc4 100644 --- a/negpy_core/src/filter.rs +++ b/negpy_core/src/filter.rs @@ -666,6 +666,73 @@ fn column_step(sum: &mut [f64], sp: &[f64], old: &[f64], out: Option<&mut [f32]> } } +/// `box_filter` for a kernel up to five wide in one parallel pass: each strip of pixels sums +/// its own rows (fresh sums need no state from the left) into a ring of `kh` rows that stays in +/// cache, then runs the column recurrence down the frame. +fn box_narrow( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + (kw, kh): (usize, usize), + scale: f64, + (xmap, ymap): (&[Option], &[Option]), +) { + let n = w * cn; + let px = w.div_ceil(rayon::current_num_threads()).max(64 / cn.max(1)); + let total = h + kh - 1; + let ax = kw / 2; + let (lo, hi) = (ax.min(w), (w + ax + 1).saturating_sub(kw).max(ax.min(w))); + // Many threads writing the same fresh huge page at once stall each other. + dst.par_chunks_mut(1 << 19).for_each(|c| c.fill(0.0)); + let mut outs: Vec> = (0..w.div_ceil(px)).map(|_| Vec::new()).collect(); + for row in dst.chunks_exact_mut(n) { + for (o, part) in outs.iter_mut().zip(row.chunks_mut(px * cn)) { + o.push(part); + } + } + outs.par_iter_mut().enumerate().for_each(|(si, out)| { + let x0 = si * px; + let sw = out[0].len(); + let pw = sw / cn; + let mut ring = vec![0f64; kh * sw]; + let mut sum = vec![0f64; sw]; + let zeros = vec![0f64; sw]; + for (rc, &source) in ymap.iter().enumerate().take(total) { + let slot = rc % kh; + let line = &mut ring[slot * sw..(slot + 1) * sw]; + match source { + None => line.fill(0.0), + Some(y) => { + let row = &src[y * n..(y + 1) * n]; + // Pixels whose taps all lie inside the row sum straight off the source. + let (a, b) = (lo.clamp(x0, x0 + pw), hi.clamp(x0, x0 + pw)); + for x in (x0..a).chain(b.max(a)..x0 + pw) { + for c in 0..cn { + line[(x - x0) * cn + c] = (0..kw).fold(0.0, |s, k| { + s + xmap[x + k].map_or(0.0, |sx| row[sx * cn + c] as f64) + }); + } + } + if b > a { + let out = &mut line[(a - x0) * cn..(b - x0) * cn]; + narrow_rows(out, &row[(a - ax) * cn..], cn, kw); + } + } + } + // After writing, row rc + 1 - kh leaves the window; it sits kh - 1 slots back. + let line = &ring[slot * sw..(slot + 1) * sw]; + let old = if rc + 1 >= kh { + let o = (rc + 1) % kh; + &ring[o * sw..(o + 1) * sw] + } else { + &zeros[..] + }; + let o = rc.checked_sub(kh - 1).map(|y| &mut *out[y]); + column_step(&mut sum, line, old, o, scale); + } + }); +} + /// `cv2.boxFilter(src, -1, (kw, kh), normalize=..., borderType=border)` (and `cv2.blur`) for /// float32, anchor at the center. Row sums in float64: a fresh sum per output up to five wide, /// a running sum (`s += a - b`) past that. Column sums keep a running total: add the new row @@ -703,6 +770,10 @@ pub fn box_filter( let ymap: Vec> = (0..h + kh - 1) .map(|r| border_interpolate(r as isize - ay as isize, h, border)) .collect(); + if kw <= 5 { + box_narrow(src, dst, (h, w, cn), (kw, kh), scale, (&xmap, &ymap)); + return Some(()); + } let row_sums = |y: Option, pad: &mut Vec, out: &mut [f64]| { let Some(y) = y else { out.fill(0.0); @@ -749,22 +820,29 @@ pub fn box_filter( // Bands of rows: their row sums in parallel, then the column recurrence (sequential down a // column, independent across columns) in parallel strips. The ring keeps the row sums a // later row still subtracts. - const BAND: usize = 128; - const STRIP: usize = 512; + // Bands as tall as about 2 MB of row sums allow: each band costs two parallel phases, and the + // ring stays in cache. + const STRIP: usize = 64; let total = h + kh - 1; - let slots = kh + BAND.min(total); - let mut ring: Vec> = vec![vec![0f64; n]; slots]; + let band_rows = ((1usize << 18) / n).clamp(128, total.max(128)); + let slots = kh + band_rows.min(total); + let mut ring_buf = vec![0f64; slots * n]; + ring_buf.par_chunks_mut(1 << 18).for_each(|c| c.fill(0.0)); + let mut ring: Vec<&mut [f64]> = ring_buf.chunks_exact_mut(n).collect(); let mut sum = vec![0f64; n]; let zeros = vec![0f64; n]; // One strip of columns per thread, each writing its part of every output row. - let strip = n.div_ceil(rayon::current_num_threads()).max(STRIP); - // Strips write every row of a band at once, so threads would fault the same fresh huge - // pages together; touching dst first in large blocks avoids that contention. + let strip = n + .div_ceil(rayon::current_num_threads()) + .next_multiple_of(16) + .max(STRIP); + // Many threads writing the same fresh huge page at once stall each other; the ring and dst + // are touched first in large blocks. dst.par_chunks_mut(1 << 19).for_each(|c| c.fill(0.0)); let mut rc0 = 0; while rc0 < total { - let rc1 = (rc0 + BAND).min(total); - let band: Vec<(usize, &mut Vec)> = ring + let rc1 = (rc0 + band_rows).min(total); + let band: Vec<(usize, &mut &mut [f64])> = ring .iter_mut() .enumerate() .filter_map(|(slot, row)| { diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index b852cd0e5..f269b7c6d 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -585,7 +585,46 @@ fn detect_luma_score<'py>( )) } +/// `_scratch_ridge` from the density plane, with (fine sigma, broad sigma, noise window). +#[pyfunction] +fn scratch_ridge<'py>( + py: Python<'py>, + dens: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, usize), +) -> PyResult>> { + let (d, dims) = plane2(&dens)?; + new_image(py, dens.shape(), |dst| { + retouch::scratch_ridge(d, dims, constants, dst) + }) +} + +type SlopeFound<'py> = (f64, usize, usize, Bound<'py, numpy::PyArray1>); + +/// `trace_scratch`'s slope search over the ridge band `z`: (strength, slope index, row, +/// that sheared row), or None for bad input. +#[pyfunction] +fn scratch_slope_search<'py>( + py: Python<'py>, + z: PyReadonlyArrayDyn<'py, f32>, + cx: f64, + rows: (usize, usize), + pull: numpy::PyReadonlyArray1<'py, f64>, + slopes: numpy::PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + use numpy::IntoPyArray; + let (zs, dims) = plane2(&z)?; + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let pull = pull.as_slice().map_err(not_contiguous)?; + let slopes = slopes.as_slice().map_err(not_contiguous)?; + let found = super::detach(py, zs.len() * slopes.len(), || { + retouch::scratch_slope_search(zs, dims, cx, rows.0..rows.1, pull, slopes) + }); + Ok(found.map(|(s, i, k, row)| (s, i, k, row.into_pyarray(py)))) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(scratch_ridge, m)?)?; + m.add_function(wrap_pyfunction!(scratch_slope_search, m)?)?; m.add_function(wrap_pyfunction!(split_hairs, m)?)?; m.add_function(wrap_pyfunction!(detect_luma_score, m)?)?; m.add_function(wrap_pyfunction!(dust_stats, m)?)?; diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 67ef43d70..884fda268 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -1226,3 +1226,116 @@ pub fn detect_luma_score( .then(|| crate::distance::mask_to_score(&compact, h, w, c.pad_px, c.score_floor)); Some((score, hairs)) } + +/// `_scratch_ridge` from the density plane: the vertical band-pass (Gaussian of `fine` minus +/// Gaussian of `broad` sigma, down the columns only) over the box mean of its magnitude across +/// `win`, which is 0.8 sigma for Gaussian noise. +pub fn scratch_ridge( + dens: &[f32], + (h, w): (usize, usize), + (fine, broad, win): (f64, f64, usize), + dst: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter, gaussian_blur}; + let n = h * w; + let (mut a, mut b) = (vec![0f32; n], vec![0f32; n]); + gaussian_blur( + dens, + &mut a, + (h, w, 1), + (1, 0), + (0.0, fine), + Border::Reflect101, + )?; + gaussian_blur( + dens, + &mut b, + (h, w, 1), + (1, 0), + (0.0, broad), + Border::Reflect101, + )?; + crate::par_zip(&mut a, &b, |r, &b| *r -= b); + let abs: Vec = a.par_iter().map(|v| v.abs()).collect(); + box_filter( + &abs, + &mut b, + (h, w, 1), + (win, win), + true, + Border::Reflect101, + )?; + dst.par_chunks_mut(BLOCK) + .zip(a.par_chunks(BLOCK).zip(b.par_chunks(BLOCK))) + .for_each(|(o, (r, s))| { + for ((o, &r), &s) in o.iter_mut().zip(r).zip(s) { + let s = s / 0.8; + *o = r / if s.is_nan() || s > 1e-6 { s } else { 1e-6 }; + } + }); + Some(()) +} + +/// The strongest sheared row of `trace_scratch`'s slope search: (strength, slope index, row, +/// that row). For each slope the ridge band `z` is sheared about `cx` as `_shear_rows` does, +/// rows `rows` of it averaged and weighted by `pull`. The first slope wins a tie. +pub fn scratch_slope_search( + z: &[f32], + (h, w): (usize, usize), + cx: f64, + rows: std::ops::Range, + pull: &[f64], + slopes: &[f64], +) -> Option<(f64, usize, usize, Vec)> { + use crate::warp::{Border, Image, Interp, warp_affine}; + if rows.end > h || pull.len() != rows.len() || z.len() != h * w { + return None; + } + let img = Image { + data: z, + h, + w, + cn: 1, + }; + let found: Vec<(f64, usize, Vec)> = slopes + .par_iter() + .map(|&slope| { + // The matrix goes through float32, as `np.float32([...])` builds it. + let m = [ + 1.0, + 0.0, + 0.0, + -slope as f32 as f64, + 1.0, + (slope * cx) as f32 as f64, + ]; + let mut sheared = vec![0f32; h * w]; + warp_affine( + &img, + (Interp::Linear, Border::Replicate), + m, + false, + w, + &mut sheared, + ); + let mut best = (f64::NEG_INFINITY, 0usize); + for (i, (y, &p)) in rows.clone().zip(pull).enumerate() { + let row = &sheared[y * w..(y + 1) * w]; + let mean = crate::stats::pairwise_sum_f32(row) / w as f32; + let s = mean.abs() as f64 * p; + if s > best.0 || (s.is_nan() && !best.0.is_nan()) { + best = (s, i); + } + } + let y = rows.start + best.1; + (best.0, best.1, sheared[y * w..(y + 1) * w].to_vec()) + }) + .collect(); + let mut best: Option<(f64, usize, usize, Vec)> = None; + for (i, (s, k, row)) in found.into_iter().enumerate() { + if best.as_ref().is_none_or(|b| s > b.0) { + best = Some((s, i, k, row)); + } + } + best +} From 3781952748fab89efa6db8ff38c77444614e3e48 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 15:35:43 +0200 Subject: [PATCH 094/133] perf(core): hue trim, clone strokes, heal strokes and exclusions in Rust Exact ports of OpenCV's ThickLine (with its fixed-point FillConvexPoly and Line2), the filled midpoint Circle and an open cv2.polylines; brush footprints match cv2 on 4000 random strokes. On top of them: apply_clone_stroke (feather, tone match, blend), strokes_to_score's per-stroke search, exclusion_cover and drop_exclusions (its distance transform on the footprint's box). apply_hue_trim is one call. Bit-exact on both corpora. hue trim 0.29x, heal strokes 0.72x, clone 0.92-0.99x, exclusions at parity (the frame allocation dominates). test_manual_bake_incremental counted Python apply_score_repair calls inside repair_components, which runs in Rust since 8bd1e012; it now checks the same contract by its pixels. --- negpy/features/process/hue.py | 12 +- negpy/features/retouch/clone.py | 67 ++---- negpy/features/retouch/logic.py | 78 +++---- negpy_core/manifest.toml | 38 ++-- negpy_core/src/lab.rs | 26 +++ negpy_core/src/py/contours.rs | 22 ++ negpy_core/src/py/lab.rs | 36 ++++ negpy_core/src/py/retouch.rs | 119 +++++++++++ negpy_core/src/retouch.rs | 295 ++++++++++++++++++++++++++ negpy_core/src/shapes.rs | 286 +++++++++++++++++++++++-- tests/test_core_local.py | 15 ++ tests/test_manual_bake_incremental.py | 31 ++- 12 files changed, 854 insertions(+), 171 deletions(-) diff --git a/negpy/features/process/hue.py b/negpy/features/process/hue.py index 5a583c2cb..24cea3eac 100644 --- a/negpy/features/process/hue.py +++ b/negpy/features/process/hue.py @@ -10,10 +10,11 @@ from __future__ import annotations +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer -from negpy.kernel.image.logic import lab_to_rgb_working, rgb_to_lab_working +from negpy.kernel.image.logic import WORKING_LAB from negpy.kernel.image.validation import ensure_image @@ -21,11 +22,6 @@ def apply_hue_trim(img: ImageBuffer, degrees: float) -> ImageBuffer: """Rotate every hue by `degrees` in the working CIELAB a*b* plane. Identity at 0.""" if degrees == 0.0: return img - lab = rgb_to_lab_working(np.asarray(img[:, :, :3], dtype=np.float32)) rad = float(np.radians(degrees)) - cos, sin = float(np.cos(rad)), float(np.sin(rad)) - a = lab[:, :, 1].copy() - b = lab[:, :, 2].copy() - lab[:, :, 1] = a * cos - b * sin - lab[:, :, 2] = a * sin + b * cos - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + rotation = (float(np.cos(rad)), float(np.sin(rad))) + return ensure_image(negpy_core.hue_trim(np.ascontiguousarray(img[:, :, :3], dtype=np.float32), rotation, *WORKING_LAB)) diff --git a/negpy/features/retouch/clone.py b/negpy/features/retouch/clone.py index e5be3acba..17e31cf68 100644 --- a/negpy/features/retouch/clone.py +++ b/negpy/features/retouch/clone.py @@ -2,19 +2,14 @@ stroke can copy from an earlier one.""" import hashlib -from typing import Iterable, Tuple +from typing import Iterable -import cv2 +import negpy_core import numpy as np from negpy.features.geometry.logic import smooth_polyline from negpy.features.retouch.models import HEAL_SIZE_REF -# Tone match gain, six stops either way; also bounds the ratio where the source surround is near black. -_MATCH_GAIN_MIN = 1.0 / 64.0 -_MATCH_GAIN_MAX = 64.0 -_EPS = 1e-6 - def clone_token(retouch) -> str: """Config identity of the clone strokes, folded into source_hash.""" @@ -24,28 +19,19 @@ def clone_token(retouch) -> str: return "|clone" + hashlib.sha1(repr(strokes).encode()).hexdigest()[:12] -def _cover(points, radius: float, x0: int, y0: int, shape: Tuple[int, int], w: int, h: int) -> np.ndarray: +def _path(points, x0: int, y0: int, w: int, h: int) -> np.ndarray: + """The stroke's path, smoothed past two points, in crop pixels.""" chain = [(float(p[0]) * w, float(p[1]) * h) for p in points] if len(chain) >= 3: chain = smooth_polyline(chain, closed=False) - local = np.round(np.array(chain, dtype=np.float32) - (x0, y0)).astype(np.int32) - cover = np.zeros(shape, dtype=np.uint8) - r = max(1, int(round(radius))) - if len(local) > 1: - cv2.polylines(cover, [local], False, 1, thickness=2 * r) - for cx, cy in local: - cv2.circle(cover, (int(cx), int(cy)), r, 1, -1) - return cover - - -def _local_mean(plane: np.ndarray, weight: np.ndarray, sigma: float) -> np.ndarray: - """Gaussian mean of ``plane`` over the pixels ``weight`` admits (normalized convolution).""" - num = cv2.GaussianBlur(plane * weight[..., None], (0, 0), sigma) - den = cv2.GaussianBlur(weight, (0, 0), sigma)[..., None] - return num / np.maximum(den, _EPS) + return np.ascontiguousarray(np.round(np.array(chain, dtype=np.float32) - (x0, y0)).astype(np.int32)) def apply_clone_stroke(out: np.ndarray, stroke) -> None: + """One stroke in place: the source patch over the brush footprint (a thick path with round + caps), feathered by distance inside it. With match_tone the source keeps its texture and + takes the destination's low frequencies, both read only outside the brush, as the healing + brush does, so the covered defect never tints its patch.""" points, size, dx, dy, strength, feather, match_tone = stroke if not points or strength <= 0.0: return @@ -62,36 +48,11 @@ def apply_clone_stroke(out: np.ndarray, stroke) -> None: x1, y1 = min(w, int(max(xs)) + pad + 1), min(h, int(max(ys)) + pad + 1) if x1 <= x0 or y1 <= y0: return - - cover = _cover(points, radius, x0, y0, (y1 - y0, x1 - x0), w, h) - if not cover.any(): - return - # Source pixels off the frame clamp to the edge and carry no weight. - yy = np.arange(y0, y1) + sy - xx = np.arange(x0, x1) + sx - valid = ((yy >= 0) & (yy < h))[:, None] & ((xx >= 0) & (xx < w))[None, :] - src = out[np.clip(yy, 0, h - 1)[:, None], np.clip(xx, 0, w - 1)[None, :]].astype(np.float32) - dst = out[y0:y1, x0:x1].astype(np.float32) - - if feather > 0.0: - # The ramp ends at the brush core at most, so full feather still covers the center. - depth = cv2.distanceTransform(cover, cv2.DIST_L2, 3) - alpha = np.clip(depth / max(float(feather) * min(radius, float(depth.max())), _EPS), 0.0, 1.0) - else: - alpha = cover.astype(np.float32) - alpha *= float(strength) * valid - - patch = src - if match_tone: - # Healing-brush tone match: the source keeps its texture and takes the destination's low frequencies. - # Both means read only pixels outside the brush, so the covered defect never tints its patch. - ring = ((cover == 0) & valid).astype(np.float32) - if ring.any(): - gain = _local_mean(dst, ring, sigma) / np.maximum(_local_mean(src, ring, sigma), _EPS) - patch = src * np.clip(gain, _MATCH_GAIN_MIN, _MATCH_GAIN_MAX) - - a = alpha[..., None] - out[y0:y1, x0:x1] = dst * (1.0 - a) + patch * a + brush = max(1, int(round(radius))) + path = _path(points, x0, y0, w, h) + negpy_core.clone_stroke( + out, (x0, y0, x1, y1), path, brush, (radius, sigma), (sx, sy), (float(feather), float(strength), bool(match_tone)) + ) def apply_clone_strokes(img: np.ndarray, strokes: Iterable) -> np.ndarray: diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 8bb497eb0..593e20f39 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -317,19 +317,16 @@ def exclusion_cover(strokes: List[Tuple], shape: Tuple[int, int]) -> np.ndarray: loose dabs would leave the film between them repaired. Smoothed past two points and round-capped, so the band matches the one the overlay draws.""" h, w = shape[:2] - cover = np.zeros((h, w), dtype=np.uint8) scale = max(w, h) / HEAL_SIZE_REF + bands = [] for points, size in strokes: radius = max(1, int(round(float(size) * scale * 0.5))) chain = [(float(p[0]) * w, float(p[1]) * h) for p in points] if len(chain) >= 3: chain = smooth_polyline(chain, closed=False) - pts = np.round(np.array(chain, dtype=np.float32)).astype(np.int32) - if len(pts) > 1: - cv2.polylines(cover, [pts], False, 1, thickness=max(1, 2 * radius)) - for cx, cy in pts: # round caps and joins, as a thick polyline alone ends flat - cv2.circle(cover, (int(cx), int(cy)), radius, 1, -1) - return cover + pts = np.ascontiguousarray(np.round(np.array(chain, dtype=np.float32)).astype(np.int32)) + bands.append((pts, radius, max(1, 2 * radius))) + return negpy_core.stroke_cover((h, w), bands) def drop_exclusions( @@ -349,10 +346,8 @@ def drop_exclusions( if score is not None: # Feather the rim, as the manual heal does its own: the score is a ramp, and cutting # one at the brush edge prints the edge as a step. - d = cv2.distanceTransform(cover, cv2.DIST_L2, 3) - alpha = np.clip(d / _MANUAL_RIM_PX, 0.0, 1.0) - score = (score + alpha * (1.0 - score)).astype(np.float32) - if not (score < 1.0).any(): + score = np.array(score, dtype=np.float32) + if not negpy_core.release_score(score, cover, _MANUAL_RIM_PX): score = None if hair_mask is not None: # Binary, so it takes the footprint unfeathered. @@ -415,50 +410,25 @@ def strokes_to_score( if x1 <= x0 or y1 <= y0: continue - cover = np.zeros((y1 - y0, x1 - x0), dtype=np.uint8) - local = np.round(pts - (x0, y0)).astype(np.int32) - if len(local) > 1: - cv2.polylines(cover, [local], False, 1, thickness=max(1, int(round(2.0 * radius)))) - for cx, cy in local: # round caps and joins — a thick polyline alone leaves flat ends - cv2.circle(cover, (int(cx), int(cy)), max(1, int(round(radius))), 1, -1) - if not cover.any(): + local = np.ascontiguousarray(np.round(pts - (x0, y0)).astype(np.int32)) + brush = (max(1, int(round(radius))), max(1, int(round(2.0 * radius)))) + # Inside the brush a pixel repairs by how far it stands out from the film around it: + # MAD about zero over the whole crop, so the defect stays a minority in its own noise + # estimate, then hysteresis from the core, because grain is isolated and never joins. + constants = ( + win, + _MANUAL_Z_MIN, + _MANUAL_Z_HI, + _MANUAL_Z_GROW, + _MANUAL_Z_GROW_FRAC, + _DETECT_PAD_PX * film_scale((h, w)), + _IR_SCORE_FLOOR, + _MANUAL_RIM_PX, + ) + region = negpy_core.heal_region(_density(img[y0:y1, x0:x1]), local, brush, constants) + if region is None: continue - - crop = _density(img[y0:y1, x0:x1]) - detail = cv2.blur(crop - cv2.blur(crop, (win, win)), (3, 3)) - # MAD about zero over the whole crop, so the defect stays a minority in its own - # noise estimate. 0.6745 is the half-normal median, which turns it into a σ. - sigma = float(np.median(np.abs(detail))) / 0.6745 - z = np.abs(detail) / max(sigma, 1e-9) - - inside = cover > 0 - peak = float(z[inside].max()) - if peak < _MANUAL_Z_MIN: - continue # the brush found clean film; repairing it would only smooth grain - # Self-normalize a faint defect against its own peak, so a real mark under the - # absolute bar still repairs. - hi = _MANUAL_Z_HI if peak >= _MANUAL_Z_HI else peak * 0.9 - # Hysteresis, because a single bar cannot do this job: a defect's core clears any bar - # but its soft skirt does not, and a bar low enough to catch the skirt catches grain - # everywhere. Grow from the core to the low bar through connected pixels only. Grain - # is isolated, so it never joins. - lo = max(_MANUAL_Z_GROW, hi * _MANUAL_Z_GROW_FRAC) - strong = inside & (z >= hi) - if not strong.any(): - continue - _n, lab = cv2.connectedComponents((inside & (z >= lo)).astype(np.uint8), connectivity=8) - seeded = np.unique(lab[strong]) - keep = np.isin(lab, seeded[seeded > 0]).astype(np.uint8) - - # Pad past the defect, like the detector does: the soft PSF skirt falls under any bar - # that keeps grain out, and left unrepaired it prints as a dark outline. - region = _mask_to_score(keep, _DETECT_PAD_PX * film_scale((h, w))) - # Soften the search area's rim, or a defect crossing it repairs to a hard line. - d = cv2.distanceTransform(cover, cv2.DIST_L2, 3) - alpha = np.clip(d / _MANUAL_RIM_PX, 0.0, 1.0) - - region = 1.0 - alpha * (1.0 - region) - np.minimum(score[y0:y1, x0:x1], region.astype(np.float32), out=score[y0:y1, x0:x1]) + np.minimum(score[y0:y1, x0:x1], region, out=score[y0:y1, x0:x1]) touched = True return score if touched else None diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 2df8cfedd..2c0b1c354 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1324,8 +1324,8 @@ depends = ["grid_corners"] ["features/process/hue.py::apply_hue_trim"] phase = 4 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.hue_trim"] depends = [] ["features/process/logic.py::_corr"] @@ -1390,27 +1390,15 @@ status = "todo" calls = ["negpy_core.resize_area", "np.median", "np.percentile", "np.quantile"] depends = [] -["features/retouch/clone.py::_cover"] -phase = 4 -status = "todo" -calls = ["cv2.circle", "cv2.polylines"] -depends = [] - -["features/retouch/clone.py::_local_mean"] -phase = 4 -status = "todo" -calls = ["cv2.GaussianBlur"] -depends = [] - ["features/retouch/clone.py::apply_clone_stroke"] phase = 4 -status = "todo" -calls = ["cv2.distanceTransform"] -depends = ["_cover", "_local_mean"] +status = "ported" +calls = ["negpy_core.clone_stroke"] +depends = [] ["features/retouch/clone.py::apply_clone_strokes"] phase = 4 -status = "todo" +status = "ported" calls = [] depends = ["apply_clone_stroke"] @@ -1554,14 +1542,14 @@ depends = ["_erode_resize_bounded"] ["features/retouch/logic.py::drop_exclusions"] phase = 4 -status = "todo" -calls = ["cv2.distanceTransform"] +status = "ported" +calls = ["negpy_core.release_score"] depends = ["exclusion_cover"] ["features/retouch/logic.py::exclusion_cover"] phase = 4 -status = "todo" -calls = ["cv2.circle", "cv2.polylines"] +status = "ported" +calls = ["negpy_core.stroke_cover"] depends = [] ["features/retouch/logic.py::ir_defect_score"] @@ -1614,9 +1602,9 @@ depends = [] ["features/retouch/logic.py::strokes_to_score"] phase = 4 -status = "todo" -calls = ["cv2.blur", "cv2.circle", "cv2.connectedComponents", "cv2.distanceTransform", "cv2.polylines", "np.median", "np.unique"] -depends = ["_density", "_mask_to_score"] +status = "ported" +calls = ["negpy_core.heal_region"] +depends = ["_density"] ["features/retouch/logic.py::trace_scratch"] phase = 4 diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs index 69d9b44db..a1722a881 100644 --- a/negpy_core/src/lab.rs +++ b/negpy_core/src/lab.rs @@ -460,3 +460,29 @@ pub fn split_toning( color::lab_to_rgb(&lab, dst, to_rgb); dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); } + +/// `apply_hue_trim` of an HxWx3 image for a nonzero angle given as its (cos, sin): Lab, a*b* +/// rotated in float32, back to working RGB clipped to 0..1. +pub fn hue_trim( + src: &[f32], + (cos, sin): (f32, f32), + spaces: (LabSpace, LabSpace), + dst: &mut [f32], +) { + let mut lab = vec![0f32; src.len()]; + color::rgb_to_lab(src, &mut lab, spaces.0); + lab.par_chunks_mut(3 * 4096).for_each(move |run| { + for p in run.as_chunks_mut::<3>().0 { + let (a, b) = (p[1], p[2]); + p[1] = a * cos - b * sin; + p[2] = a * sin + b * cos; + } + }); + color::lab_to_rgb(&lab, dst, spaces.1); + dst.par_chunks_mut(4096).for_each(|run| { + for v in run { + let c = if v.is_nan() || *v > 0.0 { *v } else { 0.0 }; + *v = if c.is_nan() || c < 1.0 { c } else { 1.0 }; + } + }); +} diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index 63a8a5eb0..617b0cc4e 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -207,7 +207,29 @@ fn local_maps<'py>( Ok(out) } +/// Brush footprints drawn into one new uint8 mask: for each (points, radius, thickness), +/// `cv2.polylines(mask, [pts], False, 1, thickness)` when there are two points or more, then +/// `cv2.circle(mask, p, radius, 1, -1)` at every point. +#[pyfunction] +fn stroke_cover<'py>( + py: Python<'py>, + shape: (usize, usize), + strokes: Vec<(numpy::PyReadonlyArrayDyn<'py, i32>, i64, i64)>, +) -> PyResult>> { + let (h, w) = shape; + let mut mask = vec![0u8; h * w]; + for (pts, radius, thickness) in &strokes { + let v: Vec<(i64, i64)> = int_points(pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + crate::shapes::stroke_cover(&mut mask, w, h, &v, *radius, *thickness); + } + numpy::PyArray1::from_vec(py, mask).reshape([h, w]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(stroke_cover, m)?)?; m.add_function(wrap_pyfunction!(local_maps, m)?)?; m.add_function(wrap_pyfunction!(rasterise_mask, m)?)?; m.add_function(wrap_pyfunction!(fill_poly, m)?)?; diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs index 3df27a5f1..abaf09fb4 100644 --- a/negpy_core/src/py/lab.rs +++ b/negpy_core/src/py/lab.rs @@ -244,7 +244,43 @@ fn split_toning<'py>( Ok(out) } +/// `apply_hue_trim` of an HxWx3 float32 image with (cos, sin) of a nonzero angle, then the +/// working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn hue_trim<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + rotation: (f32, f32), + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || lab::hue_trim(src, rotation, spaces, dst)); + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(hue_trim, m)?)?; m.add_function(wrap_pyfunction!(split_toning, m)?)?; m.add_function(wrap_pyfunction!(rl_sharpen, m)?)?; m.add_function(wrap_pyfunction!(clahe, m)?)?; diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index f269b7c6d..be787b0b3 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -622,7 +622,126 @@ fn scratch_slope_search<'py>( Ok(found.map(|(s, i, k, row)| (s, i, k, row.into_pyarray(py)))) } +/// `apply_clone_stroke` in place: the crop (x0, y0, x1, y1), the smoothed path in crop +/// pixels, the brush radius in whole pixels, then (radius, sigma), the source shift and +/// (feather, strength, match tone). +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn clone_stroke( + py: Python<'_>, + mut out: PyReadwriteArrayDyn<'_, f32>, + rect: (usize, usize, usize, usize), + path: numpy::PyReadonlyArrayDyn<'_, i32>, + brush: i64, + sizes: (f64, f64), + shift: (i64, i64), + brush_settings: (f64, f64, bool), +) -> PyResult<()> { + use numpy::PyUntypedArrayMethods; + let [h, w, 3] = out.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let (x0, y0, x1, y1) = rect; + if x1 > w || y1 > h || x0 >= x1 || y0 >= y1 { + return Err(PyValueError::new_err("crop outside the image")); + } + let path = path_points(&path)?; + let (radius, sigma) = sizes; + let (feather, strength, match_tone) = brush_settings; + let s = retouch::CloneStroke { + rect, + path: &path, + brush, + radius, + shift, + sigma, + feather, + strength, + match_tone, + }; + let o = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + super::detach(py, (x1 - x0) * (y1 - y0), || { + retouch::clone_stroke(o, (h, w), &s) + }) + .ok_or_else(|| PyValueError::new_err("invalid clone stroke")) +} + +fn path_points(path: &numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult> { + let pts = path + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous path"))?; + Ok(pts + .as_chunks::<2>() + .0 + .iter() + .map(|p| (p[0] as i64, p[1] as i64)) + .collect()) +} + +/// `drop_exclusions`' score release in place under a uint8 footprint, with the rim in pixels; +/// returns whether anything stays under 1. +#[pyfunction] +fn release_score( + py: Python<'_>, + mut score: PyReadwriteArrayDyn<'_, f32>, + cover: numpy::PyReadonlyArray2<'_, u8>, + rim: f32, +) -> PyResult { + let [h, w] = score.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW score")); + }; + if cover.shape() != [h, w] { + return Err(PyValueError::new_err( + "needs a footprint of the score's shape", + )); + } + let c = cover + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous footprint"))?; + let s = score + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous score"))?; + Ok(super::detach(py, s.len(), || { + retouch::release_score(s, c, (h, w), rim) + })) +} + +/// One stroke of `strokes_to_score`: the density crop, the smoothed path in crop pixels, (cap +/// radius, line thickness), then (window, z min, z hi, z grow, grow fraction, pad, floor, rim). +#[pyfunction] +fn heal_region<'py>( + py: Python<'py>, + dens: PyReadonlyArrayDyn<'py, f32>, + path: numpy::PyReadonlyArrayDyn<'py, i32>, + brush: (i64, i64), + constants: (usize, f64, f64, f64, f64, f64, f64, f32), +) -> PyResult>> { + let (d, dims) = plane2(&dens)?; + let path = path_points(&path)?; + let (win, z_min, z_hi, z_grow, z_grow_frac, pad_px, score_floor, rim) = constants; + let c = retouch::HealSearch { + win, + z_min, + z_hi, + z_grow, + z_grow_frac, + pad_px, + score_floor, + rim, + }; + super::detach(py, d.len(), || { + retouch::heal_region(d, dims, &path, brush, &c) + }) + .map(|r| plane_array(py, r, dims)) + .transpose() +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(release_score, m)?)?; + m.add_function(wrap_pyfunction!(heal_region, m)?)?; + m.add_function(wrap_pyfunction!(clone_stroke, m)?)?; m.add_function(wrap_pyfunction!(scratch_ridge, m)?)?; m.add_function(wrap_pyfunction!(scratch_slope_search, m)?)?; m.add_function(wrap_pyfunction!(split_hairs, m)?)?; diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 884fda268..4d3f52eb0 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -1339,3 +1339,298 @@ pub fn scratch_slope_search( } best } + +/// One clone stroke's geometry, as `apply_clone_stroke` derives it. +pub struct CloneStroke<'a> { + /// The crop it works in: x0, y0, x1, y1. + pub rect: (usize, usize, usize, usize), + /// Its smoothed path, in crop pixels. + pub path: &'a [(i64, i64)], + pub brush: i64, + pub radius: f64, + pub shift: (i64, i64), + pub sigma: f64, + pub feather: f64, + pub strength: f64, + pub match_tone: bool, +} + +/// `_local_mean(plane, weight, sigma)` of a 3-channel crop: a Gaussian mean over the pixels the +/// weight admits. +fn local_mean( + plane: &[f32], + weight: &[f32], + (h, w): (usize, usize), + sigma: f64, +) -> Option> { + use crate::filter::{Border, gaussian_blur}; + let weighted: Vec = plane + .as_chunks::<3>() + .0 + .iter() + .zip(weight) + .flat_map(|(p, &k)| [p[0] * k, p[1] * k, p[2] * k]) + .collect(); + let (mut num, mut den) = (vec![0f32; 3 * h * w], vec![0f32; h * w]); + gaussian_blur( + &weighted, + &mut num, + (h, w, 3), + (0, 0), + (sigma, sigma), + Border::Reflect101, + )?; + gaussian_blur( + weight, + &mut den, + (h, w, 1), + (0, 0), + (sigma, sigma), + Border::Reflect101, + )?; + for (n, &d) in num.as_chunks_mut::<3>().0.iter_mut().zip(&den) { + let d = if d.is_nan() || d > 1e-6 { d } else { 1e-6 }; + for v in n { + *v /= d; + } + } + Some(num) +} + +/// `apply_clone_stroke` in place on an HxWx3 image: the source patch, tone-matched to the +/// destination's surround when asked, blended over the brush footprint. +pub fn clone_stroke(out: &mut [f32], (h, w): (usize, usize), s: &CloneStroke) -> Option<()> { + use crate::distance::{Metric, distance_transform}; + let (x0, y0, x1, y1) = s.rect; + let (ch, cw) = (y1 - y0, x1 - x0); + let mut cover = vec![0u8; ch * cw]; + crate::shapes::stroke_cover(&mut cover, cw, ch, s.path, s.brush, 2 * s.brush); + if !cover.iter().any(|&c| c != 0) { + return Some(()); + } + let (sx, sy) = s.shift; + let (hi, wi) = (h as i64, w as i64); + let mut valid = vec![false; ch * cw]; + let (mut src, mut dst) = (vec![0f32; 3 * ch * cw], vec![0f32; 3 * ch * cw]); + for y in 0..ch { + let yy = (y0 + y) as i64 + sy; + let ys = yy.clamp(0, hi - 1) as usize; + for x in 0..cw { + let xx = (x0 + x) as i64 + sx; + let xs = xx.clamp(0, wi - 1) as usize; + let p = y * cw + x; + valid[p] = (0..hi).contains(&yy) && (0..wi).contains(&xx); + src[3 * p..3 * p + 3].copy_from_slice(&out[3 * (ys * w + xs)..3 * (ys * w + xs) + 3]); + let d = 3 * ((y0 + y) * w + x0 + x); + dst[3 * p..3 * p + 3].copy_from_slice(&out[d..d + 3]); + } + } + let mut alpha: Vec = if s.feather > 0.0 { + let depth = distance_transform(&cover, ch, cw, Metric::L2Mask3); + let deepest = depth.iter().fold(0f32, |m, &v| m.max(v)) as f64; + // Python's min and max: the first argument unless the second is strictly past it. + let reach = if deepest < s.radius { + deepest + } else { + s.radius + }; + let denom = s.feather * reach; + let denom = (if 1e-6 > denom { 1e-6 } else { denom }) as f32; + depth + .iter() + .map(|&d| { + let v = d / denom; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + if v.is_nan() || v < 1.0 { v } else { 1.0 } + }) + .collect() + } else { + cover.iter().map(|&c| c as f32).collect() + }; + // `alpha *= strength * valid` multiplies by a float64 array. + for (a, &v) in alpha.iter_mut().zip(&valid) { + *a = (*a as f64 * (s.strength * v as u8 as f64)) as f32; + } + let mut patch = src.clone(); + if s.match_tone { + let ring: Vec = cover + .iter() + .zip(&valid) + .map(|(&c, &v)| (c == 0 && v) as u8 as f32) + .collect(); + if ring.iter().any(|&r| r != 0.0) { + let near = local_mean(&dst, &ring, (ch, cw), s.sigma)?; + let far = local_mean(&src, &ring, (ch, cw), s.sigma)?; + for ((p, &n), &f) in patch.iter_mut().zip(&near).zip(&far) { + let gain = n / if f.is_nan() || f > 1e-6 { f } else { 1e-6 }; + let gain = if gain.is_nan() || gain > 1.0 / 64.0 { + gain + } else { + 1.0 / 64.0 + }; + let gain = if gain.is_nan() || gain < 64.0 { + gain + } else { + 64.0 + }; + *p *= gain; + } + } + } + for y in 0..ch { + for x in 0..cw { + let p = y * cw + x; + let a = alpha[p]; + let d = 3 * ((y0 + y) * w + x0 + x); + for c in 0..3 { + out[d + c] = dst[3 * p + c] * (1.0 - a) + patch[3 * p + c] * a; + } + } + } + Some(()) +} + +/// `drop_exclusions`' release of a score under the excluded footprint: the score lifted to 1 +/// over `rim` pixels of distance inside the footprint. Returns whether anything stays under 1. +pub fn release_score(score: &mut [f32], cover: &[u8], (h, w): (usize, usize), rim: f32) -> bool { + // The distance only matters inside the footprint, and a one-pixel ring of zeros around its + // box holds every pixel's nearest zero, so the transform runs on that box alone. + let rows: Vec = (0..h) + .filter(|&y| cover[y * w..(y + 1) * w].iter().any(|&c| c != 0)) + .collect(); + if let (Some(&top), Some(&bottom)) = (rows.first(), rows.last()) { + let cols: Vec = (0..w) + .filter(|&x| (top..=bottom).any(|y| cover[y * w + x] != 0)) + .collect(); + let (y0, y1) = (top.saturating_sub(1), (bottom + 2).min(h)); + let (x0, x1) = (cols[0].saturating_sub(1), (cols[cols.len() - 1] + 2).min(w)); + let (ch, cw) = (y1 - y0, x1 - x0); + let crop: Vec = (y0..y1) + .flat_map(|y| cover[y * w + x0..y * w + x1].iter().copied()) + .collect(); + let d = + crate::distance::distance_transform(&crop, ch, cw, crate::distance::Metric::L2Mask3); + for y in 0..ch { + for x in 0..cw { + let a = d[y * cw + x] / rim; + let a = if a.is_nan() || a > 0.0 { a } else { 0.0 }; + let a = if a.is_nan() || a < 1.0 { a } else { 1.0 }; + let s = &mut score[(y0 + y) * w + x0 + x]; + *s += a * (1.0 - *s); + } + } + } + score.par_iter().any(|&s| s < 1.0) +} + +/// The scalars of `strokes_to_score`, as Python floats. +pub struct HealSearch { + pub win: usize, + pub z_min: f64, + pub z_hi: f64, + pub z_grow: f64, + pub z_grow_frac: f64, + pub pad_px: f64, + pub score_floor: f64, + pub rim: f32, +} + +/// One stroke of `strokes_to_score` on its crop: the score of what stands out from the film +/// inside the brush footprint (a two-sided local z-score on density, grown by hysteresis), +/// softened at the footprint's rim. `None` when the brush found nothing. +pub fn heal_region( + dens: &[f32], + (h, w): (usize, usize), + path: &[(i64, i64)], + (radius, thickness): (i64, i64), + c: &HealSearch, +) -> Option> { + use crate::filter::{Border, box_filter}; + let n = h * w; + let mut cover = vec![0u8; n]; + crate::shapes::stroke_cover(&mut cover, w, h, path, radius, thickness); + if !cover.iter().any(|&v| v != 0) { + return None; + } + let mut local = vec![0f32; n]; + box_filter( + dens, + &mut local, + (h, w, 1), + (c.win, c.win), + true, + Border::Reflect101, + )?; + let diff: Vec = dens.iter().zip(&local).map(|(&d, &l)| d - l).collect(); + let mut detail = vec![0f32; n]; + box_filter( + &diff, + &mut detail, + (h, w, 1), + (3, 3), + true, + Border::Reflect101, + )?; + let abs: Vec = detail.iter().map(|v| v.abs()).collect(); + let sigma = crate::stats::median(&abs) as f64 / 0.6745; + let sigma = (if 1e-9 > sigma { 1e-9 } else { sigma }) as f32; + let z: Vec = abs.iter().map(|&a| a / sigma).collect(); + let peak = + z.iter() + .zip(&cover) + .filter(|&(_, &c)| c != 0) + .fold( + f32::NEG_INFINITY, + |m, (&z, _)| if z > m || z.is_nan() { z } else { m }, + ) as f64; + if peak < c.z_min { + return None; + } + let hi = if peak >= c.z_hi { c.z_hi } else { peak * 0.9 }; + let lo = if hi * c.z_grow_frac > c.z_grow { + hi * c.z_grow_frac + } else { + c.z_grow + }; + let (hi32, lo32) = (hi as f32, lo as f32); + let strong: Vec = z + .iter() + .zip(&cover) + .map(|(&z, &c)| c != 0 && z >= hi32) + .collect(); + if !strong.iter().any(|&s| s) { + return None; + } + let grow: Vec = z + .iter() + .zip(&cover) + .map(|(&z, &c)| (c != 0 && z >= lo32) as u8) + .collect(); + let cc = crate::components::connected_components(&grow, h, w, 8); + let mut seeded = vec![false; cc.count]; + for (p, &s) in strong.iter().enumerate() { + if s { + seeded[cc.labels[p] as usize] = true; + } + } + seeded[0] = false; + let keep: Vec = cc + .labels + .iter() + .map(|&l| seeded[l as usize] as u8) + .collect(); + let region = crate::distance::mask_to_score(&keep, h, w, c.pad_px, c.score_floor); + let d = crate::distance::distance_transform(&cover, h, w, crate::distance::Metric::L2Mask3); + Some( + region + .iter() + .zip(&d) + .map(|(&r, &d)| { + let a = d / c.rim; + let a = if a.is_nan() || a > 0.0 { a } else { 0.0 }; + let a = if a.is_nan() || a < 1.0 { a } else { 1.0 }; + 1.0 - a * (1.0 - r) + }) + .collect(), + ) +} diff --git a/negpy_core/src/shapes.rs b/negpy_core/src/shapes.rs index 8735aadf8..fe94a28e1 100644 --- a/negpy_core/src/shapes.rs +++ b/negpy_core/src/shapes.rs @@ -462,13 +462,27 @@ fn draw_line(mask: &mut [u8], w: usize, h: usize, pt1: (i64, i64), pt2: (i64, i6 /// `cv2.fillConvexPoly(mask, pts, color)` (8-connected outline, shift 0) on a w x h uint8 mask. pub fn fill_convex_poly(mask: &mut [u8], w: usize, h: usize, v: &[(i64, i64)], color: u8) { + fill_convex_poly_shift(mask, w, h, v, color, 0); +} + +/// OpenCV's FillConvexPoly with points of `shift` fractional bits, 8-connected. +fn fill_convex_poly_shift( + mask: &mut [u8], + w: usize, + h: usize, + v: &[(i64, i64)], + color: u8, + shift: i64, +) { let npts = v.len(); if npts == 0 { return; } let delta1 = XY_ONE >> 1; let delta2 = XY_ONE >> 1; - let mut p0 = (v[npts - 1].0 << XY_SHIFT, v[npts - 1].1 << XY_SHIFT); + let delta = (1i64 << shift) >> 1; + let up = XY_SHIFT - shift; + let mut p0 = (v[npts - 1].0 << up, v[npts - 1].1 << up); let (mut xmin, mut xmax, mut ymin, mut ymax) = (v[0].0, v[0].0, v[0].1, v[0].1); let mut imin = 0usize; for (i, &q) in v.iter().enumerate() { @@ -479,17 +493,23 @@ pub fn fill_convex_poly(mask: &mut [u8], w: usize, h: usize, v: &[(i64, i64)], c ymax = ymax.max(q.1); xmax = xmax.max(q.0); xmin = xmin.min(q.0); - let p = (q.0 << XY_SHIFT, q.1 << XY_SHIFT); - draw_line( - mask, - w, - h, - (p0.0 >> XY_SHIFT, p0.1 >> XY_SHIFT), - (p.0 >> XY_SHIFT, p.1 >> XY_SHIFT), - color, - ); + let p = (q.0 << up, q.1 << up); + if shift == 0 { + draw_line( + mask, + w, + h, + (p0.0 >> XY_SHIFT, p0.1 >> XY_SHIFT), + (p.0 >> XY_SHIFT, p.1 >> XY_SHIFT), + color, + ); + } else { + line2(mask, w, h, p0, p, color); + } p0 = p; } + let (xmin, xmax) = ((xmin + delta) >> shift, (xmax + delta) >> shift); + let (ymin, ymax) = ((ymin + delta) >> shift, (ymax + delta) >> shift); if npts < 3 || xmax < 0 || ymax < 0 || xmin >= w as i64 || ymin >= h as i64 { return; } @@ -531,10 +551,10 @@ pub fn fill_convex_poly(mask: &mut [u8], w: usize, h: usize, v: &[(i64, i64)], c if !go { break; } - let ty = v[idx].1; + let ty = (v[idx].1 + delta) >> shift; if ty > y { - let xs = v[idx0].0 << XY_SHIFT; - let xe = v[idx].0 << XY_SHIFT; + let xs = v[idx0].0 << up; + let xe = v[idx].0 << up; e.ye = ty; e.dx = ((xe - xs) * 2 + (ty - y)) / (2 * (ty - y)); e.x = xs; @@ -873,3 +893,243 @@ pub fn local_maps( } Some(()) } + +/// OpenCV's Line2: an 8-connected line between points of XY_SHIFT fractional bits. +fn line2(mask: &mut [u8], w: usize, h: usize, pt1: (i64, i64), pt2: (i64, i64), color: u8) { + let (mut p1, mut p2) = (pt1, pt2); + if !clip_line( + (w as i64) << XY_SHIFT, + (h as i64) << XY_SHIFT, + &mut p1, + &mut p2, + ) { + return; + } + let (mut dx, mut dy) = (p2.0 - p1.0, p2.1 - p1.1); + let j = if dx < 0 { -1i64 } else { 0 }; + let ax = (dx ^ j) - j; + let i = if dy < 0 { -1i64 } else { 0 }; + let ay = (dy ^ i) - i; + let mut put = |x: i64, y: i64| { + if 0 <= x && x < w as i64 && 0 <= y && y < h as i64 { + mask[y as usize * w + x as usize] = color; + } + }; + let (x_step, y_step, mut ecount); + let swap = |p1: &mut (i64, i64), p2: &mut (i64, i64), m: i64| { + p1.0 ^= p2.0 & m; + p2.0 ^= p1.0 & m; + p1.0 ^= p2.0 & m; + p1.1 ^= p2.1 & m; + p2.1 ^= p1.1 & m; + p1.1 ^= p2.1 & m; + }; + if ax > ay { + dy = (dy ^ j) - j; + swap(&mut p1, &mut p2, j); + x_step = XY_ONE; + y_step = dy * (1 << XY_SHIFT) / (ax | 1); + ecount = (p2.0 - p1.0) >> XY_SHIFT; + } else { + dx = (dx ^ i) - i; + swap(&mut p1, &mut p2, i); + x_step = dx * (1 << XY_SHIFT) / (ay | 1); + y_step = XY_ONE; + ecount = (p2.1 - p1.1) >> XY_SHIFT; + } + p1.0 += XY_ONE >> 1; + p1.1 += XY_ONE >> 1; + put( + (p2.0 + (XY_ONE >> 1)) >> XY_SHIFT, + (p2.1 + (XY_ONE >> 1)) >> XY_SHIFT, + ); + if ax > ay { + p1.0 >>= XY_SHIFT; + while ecount >= 0 { + put(p1.0, p1.1 >> XY_SHIFT); + p1.0 += 1; + p1.1 += y_step; + ecount -= 1; + } + } else { + p1.1 >>= XY_SHIFT; + while ecount >= 0 { + put(p1.0 >> XY_SHIFT, p1.1); + p1.0 += x_step; + p1.1 += 1; + ecount -= 1; + } + } +} + +/// OpenCV's Circle with `fill`: the midpoint circle, filled with spans when asked. +pub fn circle( + mask: &mut [u8], + w: usize, + h: usize, + center: (i64, i64), + radius: i64, + color: u8, + fill: bool, +) { + let (wi, hi) = (w as i64, h as i64); + let (mut err, mut dx, mut dy, mut plus, mut minus) = + (0i64, radius, 0i64, 1i64, (radius << 1) - 1); + let hline = |m: &mut [u8], y: i64, x1: i64, x2: i64| { + let (x1, x2) = (x1.max(0), x2.min(wi - 1)); + if (0..hi).contains(&y) && x1 <= x2 { + let row = y as usize * w; + m[row + x1 as usize..=row + x2 as usize].fill(color); + } + }; + let point = |m: &mut [u8], y: i64, x: i64| { + if (0..hi).contains(&y) && (0..wi).contains(&x) { + m[y as usize * w + x as usize] = color; + } + }; + while dx >= dy { + let (y11, y12, y21, y22) = (center.1 - dy, center.1 + dy, center.1 - dx, center.1 + dx); + let (x11, x12, x21, x22) = (center.0 - dx, center.0 + dx, center.0 - dy, center.0 + dy); + // OpenCV draws the spans unclipped when the circle is inside and clips them otherwise; + // both write the same pixels. + if x11 < wi && x12 >= 0 && y21 < hi && y22 >= 0 { + if fill { + hline(mask, y11, x11, x12); + hline(mask, y12, x11, x12); + if x21 < wi && x22 >= 0 { + hline(mask, y21, x21, x22); + hline(mask, y22, x21, x22); + } + } else { + for (y, x) in [(y11, x11), (y11, x12), (y12, x11), (y12, x12)] { + point(mask, y, x); + } + if x21 < wi && x22 >= 0 { + for (y, x) in [(y21, x21), (y21, x22), (y22, x21), (y22, x22)] { + point(mask, y, x); + } + } + } + } + dy += 1; + err += plus; + plus += 2; + let m = ((err <= 0) as i64) - 1; + err -= minus & m; + dx += m; + minus -= m & 2; + } +} + +/// OpenCV's ThickLine for shift 0 and 8-connected lines; `flags` bit 0 caps `p0`, bit 1 `p1`. +#[allow(clippy::too_many_arguments)] +fn thick_line( + mask: &mut [u8], + w: usize, + h: usize, + p0: (i64, i64), + p1: (i64, i64), + color: u8, + thickness: i64, + flags: u8, +) { + let (wi, hi) = (w as i64, h as i64); + let (mut p0, mut p1) = (p0, p1); + let inside = |p: (i64, i64)| p.0 >= 0 && p.0 < wi && p.1 >= 0 && p.1 < hi; + if thickness > 1 && (!inside(p0) || !inside(p1)) { + let m = thickness; + let (mut a, mut b) = ((p0.0 + m, p0.1 + m), (p1.0 + m, p1.1 + m)); + clip_line(wi + 2 * m, hi + 2 * m, &mut a, &mut b); + p0 = (a.0 - m, a.1 - m); + p1 = (b.0 - m, b.1 - m); + } + let mut p0 = (p0.0 << XY_SHIFT, p0.1 << XY_SHIFT); + let p1 = (p1.0 << XY_SHIFT, p1.1 << XY_SHIFT); + if thickness <= 1 { + let round = |p: (i64, i64)| { + ( + (p.0 + (XY_ONE >> 1)) >> XY_SHIFT, + (p.1 + (XY_ONE >> 1)) >> XY_SHIFT, + ) + }; + draw_line(mask, w, h, round(p0), round(p1), color); + return; + } + let inv = 1.0 / XY_ONE as f64; + let dx = (p0.0 - p1.0) as f64 * inv; + let dy = (p1.1 - p0.1) as f64 * inv; + let r = dx * dx + dy * dy; + let odd = thickness & 1; + let thickness = thickness << (XY_SHIFT - 1); + if r.abs() > f64::EPSILON { + let r = (thickness as f64 + odd as f64 * XY_ONE as f64 * 0.5) / r.sqrt(); + let dp = ( + (dy * r).round_ties_even() as i64, + (dx * r).round_ties_even() as i64, + ); + let pt = [ + (p0.0 + dp.0, p0.1 + dp.1), + (p0.0 - dp.0, p0.1 - dp.1), + (p1.0 - dp.0, p1.1 - dp.1), + (p1.0 + dp.0, p1.1 + dp.1), + ]; + fill_convex_poly_shift(mask, w, h, &pt, color, XY_SHIFT); + } + for i in 0..2 { + if flags & (i + 1) != 0 { + let center = ( + (p0.0 + (XY_ONE >> 1)) >> XY_SHIFT, + (p0.1 + (XY_ONE >> 1)) >> XY_SHIFT, + ); + circle( + mask, + w, + h, + center, + (thickness + (XY_ONE >> 1)) >> XY_SHIFT, + color, + true, + ); + } + p0 = p1; + } +} + +/// `cv2.polylines(mask, [pts], False, color, thickness)`, 8-connected, shift 0. +pub fn polyline_open( + mask: &mut [u8], + w: usize, + h: usize, + v: &[(i64, i64)], + color: u8, + thickness: i64, +) { + let Some(&first) = v.first() else { + return; + }; + let mut p0 = first; + let mut flags = 3; + for &p in &v[1..] { + thick_line(mask, w, h, p0, p, color, thickness, flags); + p0 = p; + flags = 2; + } +} + +/// A brush footprint: the open polyline of `pts` at twice `radius` and a filled circle of +/// `radius` at every point, as retouch's `_cover` and `exclusion_cover` draw it. +pub fn stroke_cover( + mask: &mut [u8], + w: usize, + h: usize, + pts: &[(i64, i64)], + radius: i64, + thickness: i64, +) { + if pts.len() > 1 { + polyline_open(mask, w, h, pts, 1, thickness); + } + for &p in pts { + circle(mask, w, h, p, radius, 1, true); + } +} diff --git a/tests/test_core_local.py b/tests/test_core_local.py index c2cd03378..75120ff1c 100644 --- a/tests/test_core_local.py +++ b/tests/test_core_local.py @@ -53,3 +53,18 @@ def test_rasterise_matches_cv2(): for shape, pts, sigma, invert in cases: want = _rasterise_cv2(shape, pts, 150, 210, sigma, invert) assert rasterise(shape, pts, 150, 210, sigma, invert).tobytes() == want.tobytes(), shape + + +def test_stroke_cover_matches_cv2_on_random_strokes(): + rng = np.random.default_rng(1) + for _ in range(800): + h, w = (int(v) for v in rng.integers(4, 90, 2)) + n = int(rng.integers(1, 7)) + pts = np.stack([rng.integers(-30, w + 30, n), rng.integers(-30, h + 30, n)], 1).astype(np.int32) + radius, thickness = int(rng.integers(0, 25)), int(rng.integers(1, 50)) + want = np.zeros((h, w), np.uint8) + if n > 1: + cv2.polylines(want, [pts], False, 1, thickness=thickness) + for cx, cy in pts: + cv2.circle(want, (int(cx), int(cy)), radius, 1, -1) + np.testing.assert_array_equal(negpy_core.stroke_cover((h, w), [(pts, radius, thickness)]), want) diff --git a/tests/test_manual_bake_incremental.py b/tests/test_manual_bake_incremental.py index bd5d4f269..ffae54433 100644 --- a/tests/test_manual_bake_incremental.py +++ b/tests/test_manual_bake_incremental.py @@ -140,27 +140,22 @@ def counting(image, s, spots): assert seen_counts == [1, 1, 1], f"expected one new stroke scored per call, got {seen_counts}" def test_appending_a_stroke_only_fills_the_new_component(self): - """apply_score_repair does the actual pixel fill per connected component; a - previously-filled, untouched component must not be redone. repair_components - looks the name up in its own defining module (retouch.logic), not image_processor's - imported reference to it, so that is what has to be patched.""" + """repair_components re-fills only the components that hold a dirty pixel; an + untouched one keeps what base_out already holds there.""" from negpy.features.retouch import logic as retouch_logic img, strokes = _scene() - proc = ImageProcessor() - call_counts: list[int] = [] - real = retouch_logic.apply_score_repair - - def counting(*a, **k): - call_counts.append(1) - return real(*a, **k) - - with patch.object(retouch_logic, "apply_score_repair", side_effect=counting): - for n in range(1, len(strokes) + 1): - call_counts.clear() - settings = _config(strokes[:n]) - proc._manual_bake(img, settings, _source_key(settings)) - assert call_counts == [1], f"expected exactly one fill for the new component at {n} strokes, got {len(call_counts)}" + score = retouch_logic.strokes_to_score(img, strokes, []) + assert score is not None + base = np.full_like(img, 7.0) + dirty = np.zeros(img.shape[:2], dtype=bool) + dirty[188:196, 58:66] = True # the newest stroke only + out = np.asarray(retouch_logic.repair_components(img, score, floor=False, base_out=base, dirty=dirty)) + fresh = np.asarray(retouch_logic.repair_components(img, score, floor=False)) + for y, x in ((31, 31), (121, 121)): + np.testing.assert_array_equal(out[y, x], base[y, x]) + np.testing.assert_array_equal(out[191, 61], fresh[191, 61]) + assert not np.array_equal(out[191, 61], base[191, 61]) def test_dust_removal_on_detects_once_and_stays_incremental(self): img, strokes = _scene() From 729caaa484e67bd337919e720cb181b09237684d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 15:39:42 +0200 Subject: [PATCH 095/133] perf(core): ir_ratio_and_gain's visible logs and gain in Rust The visible-channel logs take mathf::np_logf, so the ratio, the degenerate flag and the gammas stay bit-exact. The gain's float32 power is mathf's where NumPy runs SVML: under 0.1% of gain values differ, by 1 ULP (ledger, ulp=1). _density stays NumPy (its log10 is SVML) and its callers take it as input. ir_ratio_and_gain 0.55-0.57x of the recorded Python. --- negpy/features/retouch/logic.py | 8 +--- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 8 ++-- negpy_core/src/py/retouch.rs | 35 ++++++++++++++++ negpy_core/src/retouch.rs | 71 +++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 593e20f39..19a8279b7 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -952,7 +952,7 @@ def ir_ratio_and_gain(ir_det: np.ndarray, img_det: np.ndarray) -> Tuple[np.ndarr if img_det.shape[:2] != ratio.shape[:2]: img_det = negpy_core.resize_area(np.ascontiguousarray(img_det, dtype=np.float32), (ratio.shape[1], ratio.shape[0])) - vis_log = np.stack([np.log(np.clip(normalize_ir(img_det[:, :, c]), 1e-4, 1.0)) for c in range(3)], axis=-1) + vis_log = negpy_core.ir_vis_log(img_det, _ir_win(_IR_BASE_WIN, _ir_detect_scale(img_det))) ratio, ghost = _ir_decontaminate(ratio, vis_log) # On the fitted exponent, not on how far the ratio dips. A few percent of IR noise, # deepened by the min-preserving downsample, reads as silver on clean C41 rolls. @@ -962,14 +962,10 @@ def ir_ratio_and_gain(ir_det: np.ndarray, img_det: np.ndarray) -> Tuple[np.ndarr ratio = _ir_normalize_ratio(ratio, live) gammas = _fit_refraction_gammas(ratio, vis_log, img_det) - base = np.clip(ratio / _IR_GAIN_IDENTITY, 1e-4, 1.0) - gain = np.empty(ratio.shape + (3,), dtype=np.float32) - for c in range(3): - gain[:, :, c] = np.minimum(_IR_GAIN_CLAMP, base ** (-gammas[c])) # Never lift a pixel past its own local clean base (see _IR_CAP_WIN). Floored at 1, so # the cap only holds the bake back and never darkens a pixel. clean = _ir_clean_base(img_det, ratio) - np.minimum(gain, np.maximum(clean / np.maximum(img_det, 1e-5), 1.0), out=gain) + gain = negpy_core.ir_gain(ratio, img_det, np.ascontiguousarray(clean, dtype=np.float32), list(gammas), (_IR_GAIN_IDENTITY, _IR_GAIN_CLAMP)) return ratio, gain, degenerate, gammas diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 664789e1e..3ea0f901a 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -26,3 +26,4 @@ tolerance listed for each entry. | `features/sabattier/logic.py::apply_sabattier`, `features/cyanotype/logic.py::apply_cyanotype`, `features/lith/logic.py::apply_lith` | log10, exp, log1p, 10^x and the cube root are `mathf`'s, correctly rounded (or within an ULP of it); NumPy's float32 versions run SVML or its own SIMD code. With correctly rounded transcendentals each port matches bit for bit. | abs=1e-6 | | `features/finish/logic.py::apply_carrier` | The rebate tone lookup takes a correctly rounded cube root; NumPy's float32 `np.cbrt` runs SVML here. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | | `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | +| `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 2c0b1c354..0eb2e5543 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1422,9 +1422,10 @@ depends = [] ["features/retouch/logic.py::_density"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = [] +note = "NumPy float32 log10 (SVML on AVX-512); the detection ports take its output as input" ["features/retouch/logic.py::_erode_resize_bounded"] phase = 4 @@ -1560,8 +1561,9 @@ depends = [] ["features/retouch/logic.py::ir_ratio_and_gain"] phase = 4 -status = "todo" -calls = ["negpy_core.resize_area"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.ir_gain", "negpy_core.ir_vis_log", "negpy_core.resize_area"] depends = ["_fit_refraction_gammas", "_ir_clean_base", "_ir_decontaminate", "_ir_glare", "_ir_live", "_ir_normalize_ratio", "normalize_ir"] ["features/retouch/logic.py::lines_to_score"] diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index be787b0b3..20ddf2ff4 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -738,7 +738,42 @@ fn heal_region<'py>( .transpose() } +/// `ir_ratio_and_gain`'s visible logs of an HxWx3 image, with the base window in pixels. +#[pyfunction] +fn ir_vis_log<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + win: usize, +) -> PyResult>> { + let (src, dims) = image3(&img)?; + new_image(py, img.shape(), |dst| { + retouch::ir_vis_log(src, dims, win, dst) + }) +} + +/// `ir_ratio_and_gain`'s gain from the ratio, the image, its clean base and the gammas, with +/// (identity, clamp). +#[pyfunction] +fn ir_gain<'py>( + py: Python<'py>, + ratio: PyReadonlyArrayDyn<'py, f32>, + img: PyReadonlyArrayDyn<'py, f32>, + clean: PyReadonlyArrayDyn<'py, f32>, + gammas: [f64; 3], + constants: (f32, f32), +) -> PyResult>> { + let (src, (h, w)) = image3(&img)?; + let r = plane(&ratio, h * w)?; + let c = plane(&clean, 3 * h * w)?; + new_image(py, img.shape(), |dst| { + retouch::ir_gain(r, src, c, gammas, constants, dst); + Some(()) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(ir_vis_log, m)?)?; + m.add_function(wrap_pyfunction!(ir_gain, m)?)?; m.add_function(wrap_pyfunction!(release_score, m)?)?; m.add_function(wrap_pyfunction!(heal_region, m)?)?; m.add_function(wrap_pyfunction!(clone_stroke, m)?)?; diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 4d3f52eb0..1d96ad3e6 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -1634,3 +1634,74 @@ pub fn heal_region( .collect(), ) } + +/// `np.log(np.clip(normalize_ir(img[:, :, c]), 1e-4, 1.0))` stacked over the three channels, +/// NumPy's float32 log included. +pub fn ir_vis_log(img: &[f32], (h, w): (usize, usize), win: usize, dst: &mut [f32]) -> Option<()> { + let n = h * w; + let mut plane = vec![0f32; n]; + let mut norm = vec![0f32; n]; + for c in 0..3 { + plane + .par_iter_mut() + .enumerate() + .for_each(|(p, v)| *v = img[3 * p + c]); + normalize_ir(&plane, None, (h, w), win, &mut norm)?; + dst.par_chunks_mut(3 * BLOCK) + .zip(norm.par_chunks(BLOCK)) + .for_each(|(o, r)| { + for (o, &v) in o.as_chunks_mut::<3>().0.iter_mut().zip(r) { + let v = if v.is_nan() || v > 1e-4 { v } else { 1e-4 }; + let v = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + o[c] = crate::mathf::np_logf(v); + } + }); + } + Some(()) +} + +/// `ir_ratio_and_gain`'s gain: `min(clamp, (clip(ratio / identity, 1e-4, 1)) ** -gamma)` per +/// channel, then held at the local clean base over the pixel, floored at 1. The power is +/// `mathf`'s; NumPy's float32 power runs SVML on an AVX-512 CPU. +pub fn ir_gain( + ratio: &[f32], + img: &[f32], + clean: &[f32], + gammas: [f64; 3], + (identity, clamp): (f32, f32), + dst: &mut [f32], +) { + let neg = gammas.map(|g| -g as f32); + dst.par_chunks_mut(3 * BLOCK) + .zip(ratio.par_chunks(BLOCK)) + .zip(img.par_chunks(3 * BLOCK).zip(clean.par_chunks(3 * BLOCK))) + .for_each(|((o, r), (px, cl))| { + for (i, &r) in r.iter().enumerate() { + let b = r / identity; + let b = if b.is_nan() || b > 1e-4 { b } else { 1e-4 }; + let b = if b.is_nan() || b < 1.0 { b } else { 1.0 }; + for (c, &e) in neg.iter().enumerate() { + let j = 3 * i + c; + let p = crate::mathf::pow_f64(b as f64, e as f64) as f32; + let g = if clamp.is_nan() || p.is_nan() { + f32::NAN + } else if p < clamp { + p + } else { + clamp + }; + let d = px[j]; + let d = if d.is_nan() || d > 1e-5 { d } else { 1e-5 }; + let cap = cl[j] / d; + let cap = if cap.is_nan() || cap > 1.0 { cap } else { 1.0 }; + o[j] = if g.is_nan() || cap.is_nan() { + f32::NAN + } else if cap < g { + cap + } else { + g + }; + } + } + }); +} From ecfd03eea8a7911ff8b39c48a2cfe2fa23a83da0 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 15:45:02 +0200 Subject: [PATCH 096/133] perf(core): film_base in Rust; the sensor unmix runs without cv2 film_base takes NumPy's float32 log (mathf::np_logf) and float64 percentile, quantile and median rules; its final exp of three medians is libm's where NumPy runs SVML (ledger, ulp=1: one channel of one frame, 1 ULP in float64). The unmix that consumes it replays bit for bit. _two_scale_unmix's small Gaussian is negpy_core's. film_base 0.41-0.44x; the unmix paths 0.86-0.92x. --- negpy/features/process/sensor.py | 17 ++------ negpy/features/retouch/logic.py | 4 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 16 ++++--- negpy_core/src/py/sensor.rs | 26 ++++++++++++ negpy_core/src/sensor.rs | 72 ++++++++++++++++++++++++++++++++ negpy_core/src/stats.rs | 51 ++++++++++++++++++++++ 7 files changed, 166 insertions(+), 21 deletions(-) diff --git a/negpy/features/process/sensor.py b/negpy/features/process/sensor.py index d610f134d..b56ecac8d 100644 --- a/negpy/features/process/sensor.py +++ b/negpy/features/process/sensor.py @@ -6,7 +6,6 @@ from typing import Optional -import cv2 import negpy_core import numpy as np @@ -151,19 +150,9 @@ def film_base(x: np.ndarray) -> Optional[np.ndarray]: together. One shared cell set, so a colored object cannot pass for the base in a single channel. Only the color is meaningful. None when the thin end is clipped in the capture.""" sample = np.ascontiguousarray(get_analysis_crop(x[::4, ::4, :3], _BASE_BUFFER), dtype=np.float32) - flat = sample.reshape(-1, 3) - if flat.shape[0] == 0 or np.any(np.percentile(flat, _BASE_PERCENTILE, axis=0) >= _CLIP_PLATEAU * flat.max(axis=0)): - return None - h, w = sample.shape[:2] - scale = _BASE_GRID_EDGE / max(h, w) - if scale < 1.0: - sample = negpy_core.resize_area( - np.ascontiguousarray(sample, dtype=np.float32), (max(1, round(w * scale)), max(1, round(h * scale))) - ) - logs = np.log(np.maximum(sample.reshape(-1, 3), _LOG_FLOOR)).astype(np.float64) # A cell is as thin as its least-thin channel, each channel against its own thin end. - thin = (logs - np.percentile(logs, _BASE_PERCENTILE, axis=0)).min(axis=1) - return np.exp(np.median(logs[thin >= np.quantile(thin, 1.0 - _BASE_SHARE)], axis=0)) + base = negpy_core.film_base(sample, (_BASE_PERCENTILE, _CLIP_PLATEAU, float(_BASE_GRID_EDGE), _LOG_FLOOR, _BASE_SHARE)) + return None if base is None else np.array(base) def _density_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: @@ -196,7 +185,7 @@ def _two_scale_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: # The color layer and the weight are smooth by construction, so they are built small. step = max(1, int(sigma // 2)) small = x if step == 1 else negpy_core.resize_area(np.ascontiguousarray(x, dtype=np.float32), (-(-w // step), -(-h // step))) - small = cv2.GaussianBlur(small, (0, 0), sigma / step) + small = negpy_core.gaussian_blur(np.ascontiguousarray(small, dtype=np.float32), (0, 0), sigma / step) color = np.empty_like(small) weight = np.empty(small.shape[:2], np.float32) _two_scale_layer_kernel(small, m, own, COLOR_FLOOR, _LOG_FLOOR, TWO_SCALE_START, TWO_SCALE_FULL, color, weight) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 19a8279b7..e0083407d 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -965,7 +965,9 @@ def ir_ratio_and_gain(ir_det: np.ndarray, img_det: np.ndarray) -> Tuple[np.ndarr # Never lift a pixel past its own local clean base (see _IR_CAP_WIN). Floored at 1, so # the cap only holds the bake back and never darkens a pixel. clean = _ir_clean_base(img_det, ratio) - gain = negpy_core.ir_gain(ratio, img_det, np.ascontiguousarray(clean, dtype=np.float32), list(gammas), (_IR_GAIN_IDENTITY, _IR_GAIN_CLAMP)) + gain = negpy_core.ir_gain( + ratio, img_det, np.ascontiguousarray(clean, dtype=np.float32), list(gammas), (_IR_GAIN_IDENTITY, _IR_GAIN_CLAMP) + ) return ratio, gain, degenerate, gammas diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 3ea0f901a..2d918f299 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -27,3 +27,4 @@ tolerance listed for each entry. | `features/finish/logic.py::apply_carrier` | The rebate tone lookup takes a correctly rounded cube root; NumPy's float32 `np.cbrt` runs SVML here. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | | `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | | `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | +| `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 0eb2e5543..058a82b0d 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1342,10 +1342,11 @@ depends = ["_corr"] ["features/process/sensor.py::_density_unmix"] phase = 4 -status = "todo" +status = "ported" compare = "ulp=16" calls = [] depends = ["_log_unmix_kernel", "film_base"] +note = "the 3x3 setup stays NumPy" ["features/process/sensor.py::_log_unmix_kernel"] phase = 4 @@ -1367,9 +1368,10 @@ depends = [] ["features/process/sensor.py::_two_scale_unmix"] phase = 4 -status = "todo" -calls = ["cv2.GaussianBlur", "negpy_core.resize_area", "negpy_core.resize_linear"] +status = "ported" +calls = ["negpy_core.gaussian_blur", "negpy_core.resize_area", "negpy_core.resize_linear"] depends = ["_two_scale_kernel", "_two_scale_layer_kernel", "film_base"] +note = "the 3x3 setup stays NumPy" ["features/process/sensor.py::_unmix_kernel"] phase = 4 @@ -1379,15 +1381,17 @@ depends = [] ["features/process/sensor.py::apply_sensor_correction"] phase = 4 -status = "todo" +status = "ported" compare = "ulp=16" calls = [] depends = ["_density_unmix", "_two_scale_unmix", "_unmix_kernel"] +note = "dispatch only" ["features/process/sensor.py::film_base"] phase = 4 -status = "todo" -calls = ["negpy_core.resize_area", "np.median", "np.percentile", "np.quantile"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.film_base"] depends = [] ["features/retouch/clone.py::apply_clone_stroke"] diff --git a/negpy_core/src/py/sensor.rs b/negpy_core/src/py/sensor.rs index 0741daf3c..9aa41ca47 100644 --- a/negpy_core/src/py/sensor.rs +++ b/negpy_core/src/py/sensor.rs @@ -141,7 +141,33 @@ fn sensor_two_scale( Ok(()) } +/// `film_base` of its HxWx3 analysis crop with (percentile, clip plateau, grid edge, log +/// floor, share): the base color, or None. +#[pyfunction] +fn film_base( + py: Python<'_>, + sample: PyReadonlyArrayDyn<'_, f32>, + constants: (f64, f64, f64, f32, f64), +) -> PyResult> { + let [h, w, 3] = sample.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 crop")); + }; + let s = data(&sample)?; + let (percentile, clip_plateau, grid_edge, log_floor, share) = constants; + let c = sensor::FilmBase { + percentile, + clip_plateau, + grid_edge, + log_floor, + share, + }; + Ok(super::detach(py, s.len(), || { + sensor::film_base(s, (h, w), &c) + })) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(film_base, m)?)?; m.add_function(wrap_pyfunction!(sensor_unmix, m)?)?; m.add_function(wrap_pyfunction!(sensor_log_unmix, m)?)?; m.add_function(wrap_pyfunction!(sensor_two_scale_layer, m)?)?; diff --git a/negpy_core/src/sensor.rs b/negpy_core/src/sensor.rs index 872194961..c8502f1f3 100644 --- a/negpy_core/src/sensor.rs +++ b/negpy_core/src/sensor.rs @@ -209,3 +209,75 @@ fn two_scale_block( }); } } + +/// The scalars of `film_base`, as Python floats. +pub struct FilmBase { + pub percentile: f64, + pub clip_plateau: f64, + pub grid_edge: f64, + pub log_floor: f32, + pub share: f64, +} + +/// `film_base` of its analysis crop (h x w x 3): the median log of the cells thinnest in all +/// three channels together, back through `exp`. `None` when the thin end is clipped. The +/// final `exp` of the three medians is libm's; NumPy's float64 exp runs SVML on AVX-512. +pub fn film_base(sample: &[f32], (h, w): (usize, usize), c: &FilmBase) -> Option<[f64; 3]> { + if h * w == 0 { + return None; + } + let clipped = + (0..3).into_par_iter().any(|ch| { + let vals: Vec = sample.iter().skip(ch).step_by(3).copied().collect(); + let Some(p) = crate::stats::np_percentiles(&vals, &[c.percentile]) else { + return true; + }; + let max = vals.iter().copied().fold(f32::NEG_INFINITY, |m, v| { + if v > m || v.is_nan() { v } else { m } + }); + p[0] >= c.clip_plateau as f32 * max + }); + if clipped { + return None; + } + let scale = c.grid_edge / h.max(w) as f64; + let small; + let (cells, (sh, sw)) = if scale < 1.0 { + let dw = ((w as f64 * scale).round_ties_even() as usize).max(1); + let dh = ((h as f64 * scale).round_ties_even() as usize).max(1); + small = crate::resize::resize_area(sample, h, w, 3, dh, dw)?; + (&small[..], (dh, dw)) + } else { + (sample, (h, w)) + }; + let n = sh * sw; + let floor = c.log_floor; + let logs: Vec = cells + .par_iter() + .map(|&v| crate::mathf::np_logf(if v.is_nan() || v > floor { v } else { floor }) as f64) + .collect(); + let q = c.percentile / 100.0; + let thin_end: Vec = (0..3) + .into_par_iter() + .map(|ch| { + let mut col: Vec = logs.iter().skip(ch).step_by(3).copied().collect(); + crate::stats::quantile_f64(&mut col, q).unwrap_or(f64::NAN) + }) + .collect(); + let thin: Vec = (0..n) + .map(|i| { + (0..3).fold(f64::INFINITY, |m, ch| { + let v = logs[3 * i + ch] - thin_end[ch]; + if v < m || v.is_nan() { v } else { m } + }) + }) + .collect(); + let bar = crate::stats::quantile_f64(&mut thin.clone(), 1.0 - c.share)?; + Some(std::array::from_fn(|ch| { + let mut sel: Vec = (0..n) + .filter(|&i| thin[i] >= bar) + .map(|i| logs[3 * i + ch]) + .collect(); + libm::exp(crate::stats::median_f64(&mut sel)) + })) +} diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index 9854f51a5..e3b7b13f0 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -295,6 +295,57 @@ pub fn np_percentiles(values: &[f32], qs: &[f64]) -> Option> { Some(out) } +/// NumPy's linear interpolation between two float64 samples for a float64 weight: the far +/// half counts back from the upper sample. +fn lerp_f64(a: f64, b: f64, t: f64) -> f64 { + let diff = b - a; + if t >= 0.5 { + b - diff * (1.0 - t) + } else { + a + diff * t + } +} + +/// `np.quantile(values, q)` of float64 values for q in 0..1 (`np.percentile` passes q / 100). +/// Reorders `values`; `None` when empty. +pub fn quantile_f64(values: &mut [f64], q: f64) -> Option { + let n = values.len(); + if n == 0 { + return None; + } + let virtual_index = (n - 1) as f64 * q; + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let hi = (lo + 1).min(n - 1); + let a = *values.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = if hi == lo { + a + } else { + values[lo + 1..] + .iter() + .copied() + .fold(f64::INFINITY, f64::min) + }; + Some(lerp_f64(a, b, virtual_index - lo as f64)) +} + +/// `np.median` of float64 values: the middle one, or the mean of the two middle ones. +/// Reorders `values`; NaN when empty. +pub fn median_f64(values: &mut [f64]) -> f64 { + let n = values.len(); + if n == 0 { + return f64::NAN; + } + let hi = *values.select_nth_unstable_by(n / 2, f64::total_cmp).1; + if n % 2 == 1 { + return hi; + } + let lo = values[..n / 2] + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + (lo + hi) / 2.0 +} + #[cfg(test)] mod tests { use super::*; From a2bd49fd4a173de3db0d70cc5e00e768f430280d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 15:47:44 +0200 Subject: [PATCH 097/133] perf(core): flat-field remainders in Rust; one general multiply apply_flatfield, self_corrected and apply_ir_attenuation share negpy_core.multiply; evenness_view maps the ratio in place. Bit-exact on both corpora; evenness_view 0.17x. --- negpy/features/flatfield/logic.py | 8 ++++---- negpy/features/retouch/logic.py | 2 +- negpy_core/manifest.toml | 14 +++++++------- negpy_core/src/convert.rs | 12 ++++++++++++ negpy_core/src/flatfield.rs | 11 +++++++++++ negpy_core/src/py/convert.rs | 15 +++++++++++++++ negpy_core/src/py/flatfield.rs | 15 +++++++++++++++ negpy_core/src/py/retouch.rs | 21 --------------------- negpy_core/src/retouch.rs | 11 ----------- 9 files changed, 65 insertions(+), 44 deletions(-) diff --git a/negpy/features/flatfield/logic.py b/negpy/features/flatfield/logic.py index 619d4bf56..d7db4e009 100644 --- a/negpy/features/flatfield/logic.py +++ b/negpy/features/flatfield/logic.py @@ -151,8 +151,8 @@ def evenness(image: ImageBuffer) -> Evenness: def evenness_view(image: ImageBuffer, span: float = EVENNESS_RANGE) -> Tuple[np.ndarray, Evenness]: """Each channel over its lit-area median, ±span mapped to black..white; carrier black.""" ratio, result = _evenness(image) - view = np.clip(0.5 + (ratio - 1.0) / (2.0 * span), 0.0, 1.0) - return view.astype(np.float32), result + negpy_core.evenness_view(ratio, 2.0 * span) + return ratio, result def check_reference(reference: ImageBuffer, gain: np.ndarray) -> Evenness: @@ -168,7 +168,7 @@ def check_reference(reference: ImageBuffer, gain: np.ndarray) -> Evenness: def self_corrected(reference: np.ndarray, gain: np.ndarray) -> np.ndarray: if gain.shape[:2] != reference.shape[:2]: gain = negpy_core.resize_linear(np.ascontiguousarray(gain, dtype=np.float32), (reference.shape[1], reference.shape[0])) - return reference.astype(np.float32) * gain + return negpy_core.multiply(np.ascontiguousarray(reference, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32)) def _is_clipped(image: np.ndarray) -> bool: @@ -225,4 +225,4 @@ def apply_flatfield( gain = gain_crop(gain) if gain.shape[:2] != image.shape[:2]: gain = negpy_core.resize_linear(np.ascontiguousarray(gain, dtype=np.float32), (image.shape[1], image.shape[0])) - return (image * gain).astype(np.float32) + return negpy_core.multiply(np.ascontiguousarray(image, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32)) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index e0083407d..753162a8c 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -975,7 +975,7 @@ def apply_ir_attenuation(img: ImageBuffer, gain_det: np.ndarray) -> ImageBuffer: """Visible buffer × upsampled per-channel IR gain map (new array — buffers are read-only).""" h, w = img.shape[:2] gain = gain_det if gain_det.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(gain_det, dtype=np.float32), (w, h)) - return ensure_image(negpy_core.ir_attenuate(np.ascontiguousarray(img, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32))) + return ensure_image(negpy_core.multiply(np.ascontiguousarray(img, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32))) def luma_bake_token(retouch) -> str: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 058a82b0d..c30c0e969 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1160,8 +1160,8 @@ depends = [] ["features/flatfield/logic.py::apply_flatfield"] phase = 4 -status = "todo" -calls = ["negpy_core.resize_linear"] +status = "ported" +calls = ["negpy_core.multiply", "negpy_core.resize_linear"] depends = [] ["features/flatfield/logic.py::check_copy"] @@ -1191,15 +1191,15 @@ depends = ["_evenness"] ["features/flatfield/logic.py::evenness_view"] phase = 4 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.evenness_view"] depends = ["_evenness"] ["features/flatfield/logic.py::self_corrected"] phase = 4 -status = "todo" +status = "ported" compare = "ulp=4" -calls = ["negpy_core.resize_linear"] +calls = ["negpy_core.multiply", "negpy_core.resize_linear"] depends = [] ["features/flatfield/logic.py::work_copy"] @@ -1518,7 +1518,7 @@ depends = [] ["features/retouch/logic.py::apply_ir_attenuation"] phase = 4 status = "ported" -calls = ["negpy_core.ir_attenuate", "negpy_core.resize_linear"] +calls = ["negpy_core.multiply", "negpy_core.resize_linear"] depends = [] ["features/retouch/logic.py::apply_score_repair"] diff --git a/negpy_core/src/convert.rs b/negpy_core/src/convert.rs index 492ec296f..3b6167b5c 100644 --- a/negpy_core/src/convert.rs +++ b/negpy_core/src/convert.rs @@ -121,6 +121,18 @@ pub fn rgb_to_rgba(src: &[f32], cn: usize, dst: &mut [f32]) { }); } +/// `a * b` of two float32 arrays of one shape. +pub fn multiply(a: &[f32], b: &[f32], dst: &mut [f32]) { + const BLOCK: usize = 1 << 14; + dst.par_chunks_mut(BLOCK) + .zip(a.par_chunks(BLOCK).zip(b.par_chunks(BLOCK))) + .for_each(|(o, (a, b))| { + for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { + *o = a * b; + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/flatfield.rs b/negpy_core/src/flatfield.rs index e419c1c1e..451b5b8a8 100644 --- a/negpy_core/src/flatfield.rs +++ b/negpy_core/src/flatfield.rs @@ -253,3 +253,14 @@ pub fn compute_gain( } Some(dark) } + +/// `evenness_view`'s map, in place on the ratio: ±`span` about 1 onto 0..1. +pub fn evenness_view(ratio: &mut [f32], span: f32) { + ratio.par_chunks_mut(BLOCK).for_each(move |run| { + for v in run { + let m = 0.5 + (*v - 1.0) / span; + let m = if m.is_nan() || m > 0.0 { m } else { 0.0 }; + *v = if m.is_nan() || m < 1.0 { m } else { 1.0 }; + } + }); +} diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs index 86f018bf6..9bca7729b 100644 --- a/negpy_core/src/py/convert.rs +++ b/negpy_core/src/py/convert.rs @@ -179,7 +179,22 @@ fn rgba_to_rgb_into( Ok(()) } +/// `a * b` of two float32 arrays of one shape (`cv2.multiply` without the copies NumPy makes). +#[pyfunction] +fn multiply<'py>( + py: Python<'py>, + a: PyReadonlyArrayDyn<'py, f32>, + b: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + if a.shape() != b.shape() { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let (sa, sb) = (contiguous(&a)?, contiguous(&b)?); + filled(py, a.shape(), "float32", |d| convert::multiply(sa, sb, d)) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(multiply, m)?)?; m.add_function(wrap_pyfunction!(to_uint8, m)?)?; m.add_function(wrap_pyfunction!(to_uint16, m)?)?; m.add_function(wrap_pyfunction!(uint8_to_float32, m)?)?; diff --git a/negpy_core/src/py/flatfield.rs b/negpy_core/src/py/flatfield.rs index 8c8c61a40..aa845c0e2 100644 --- a/negpy_core/src/py/flatfield.rs +++ b/negpy_core/src/py/flatfield.rs @@ -138,7 +138,22 @@ fn fill_from_nearest<'py>( numpy::PyArray1::from_vec(py, v).reshape(vec![h, w, cn]) } +/// `evenness_view`'s map in place on a ratio from `evenness`, with twice the span. +#[pyfunction] +fn evenness_view( + py: Python<'_>, + mut ratio: numpy::PyReadwriteArrayDyn<'_, f32>, + span: f32, +) -> PyResult<()> { + let r = ratio + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous ratio"))?; + super::detach(py, r.len(), || flatfield::evenness_view(r, span)); + Ok(()) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(evenness_view, m)?)?; m.add_function(wrap_pyfunction!(compute_gain, m)?)?; m.add_function(wrap_pyfunction!(fill_from_nearest, m)?)?; m.add_function(wrap_pyfunction!(lit_mask, m)?)?; diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 20ddf2ff4..52f052dc6 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -428,26 +428,6 @@ fn ir_defect_score<'py>( }) } -/// `apply_ir_attenuation`'s product of an HxWx3 image and a gain map of its shape. -#[pyfunction] -fn ir_attenuate<'py>( - py: Python<'py>, - img: PyReadonlyArrayDyn<'py, f32>, - gain: PyReadonlyArrayDyn<'py, f32>, -) -> PyResult>> { - let (src, (h, w)) = image3(&img)?; - if gain.shape() != img.shape() { - return Err(PyValueError::new_err( - "needs a gain map of the image's shape", - )); - } - let g = plane(&gain, 3 * h * w)?; - new_image(py, img.shape(), |dst| { - retouch::ir_attenuate(src, g, dst); - Some(()) - }) -} - type Plane<'py> = Bound<'py, numpy::PyArray2>; fn plane_array<'py>(py: Python<'py>, v: Vec, (h, w): (usize, usize)) -> PyResult> { @@ -783,7 +763,6 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(detect_luma_score, m)?)?; m.add_function(wrap_pyfunction!(dust_stats, m)?)?; m.add_function(wrap_pyfunction!(ir_defect_score, m)?)?; - m.add_function(wrap_pyfunction!(ir_attenuate, m)?)?; m.add_function(wrap_pyfunction!(fit_refraction_gammas, m)?)?; m.add_function(wrap_pyfunction!(route_wide_defects, m)?)?; m.add_function(wrap_pyfunction!(repair_components, m)?)?; diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index 1d96ad3e6..d51d6d73e 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -870,17 +870,6 @@ pub fn ir_defect_score( ) } -/// `apply_ir_attenuation`'s product of the image and a gain map of its size. -pub fn ir_attenuate(img: &[f32], gain: &[f32], dst: &mut [f32]) { - dst.par_chunks_mut(3 * BLOCK) - .zip(img.par_chunks(3 * BLOCK).zip(gain.par_chunks(3 * BLOCK))) - .for_each(|(o, (a, b))| { - for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { - *o = a * b; - } - }); -} - /// `_box_mean_std(plane, win)`: the box mean and the standard deviation over `win` x `win`, /// `sqrt(max(blur(p * p) - mean * mean, 0))` in float32. pub fn box_mean_std( From f5b39afcaab2bd86b65009c05980c642e1fecc8f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 15:56:54 +0200 Subject: [PATCH 098/133] perf(core): lens maps in Rust, with an exact cv2.undistortPointsIter The rectilinear (DNG), Panasonic and Sony inverse maps are built in negpy_core in NumPy's dtypes and order: float32 coordinates divided by a float64 radius, NumPy's correctly rounded float32 hypot, its interp rules, the Panasonic per-block convergence test. The CA-only path ports OpenCV's damped fixed-point undistortion. Bit-exact against a new lens recording in ~/negpy-corpus-p4 (synthetic warps of every kind, no sample RAW carries real ones). RectilinearWarp.remap 0.13-0.14x, apply_lens 0.42-0.43x. The fill-scale search stays Python orchestration (cached per frame). --- negpy/features/lens/warps.py | 85 +++------- negpy_core/manifest.toml | 16 +- negpy_core/src/lens.rs | 309 +++++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/lens.rs | 128 +++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_lens.py | 113 +++++++++++++ 7 files changed, 588 insertions(+), 66 deletions(-) create mode 100644 negpy_core/src/lens.rs create mode 100644 negpy_core/src/py/lens.rs create mode 100644 tests/test_core_lens.py diff --git a/negpy/features/lens/warps.py b/negpy/features/lens/warps.py index 415f053b0..9973293f7 100644 --- a/negpy/features/lens/warps.py +++ b/negpy/features/lens/warps.py @@ -1,8 +1,9 @@ """Inverse lens maps in unrotated sensor coordinates.""" from dataclasses import dataclass +from functools import lru_cache -import cv2 +import negpy_core import numpy as np from negpy.features.lens.models import LensCorrections, LensMetadata @@ -10,23 +11,16 @@ IDENTITY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0) -def _rectilinear_xy(x: np.ndarray, y: np.ndarray, coefficients: tuple[float, ...]) -> tuple[np.ndarray, np.ndarray]: - k0, k1, k2, k3, t0, t1 = coefficients - r2 = x * x + y * y - factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) - return x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x), y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y) - - -def _coordinates(lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, center: tuple[float, float]) -> tuple: +@lru_cache(maxsize=64) +def _frame(lens: LensMetadata, shape: tuple[int, ...], center: tuple[float, float]) -> tuple: + """(buffer origin, buffer-to-image scale, warp center, radius, fill scale) of the frame.""" h, w = shape[:2] t, left, b, r = lens.active_area or (0, 0, h, w) bt, bl, bb, br = lens.buffer_area or (t, left, b, r) sx, sy = (br - bl) / w, (bb - bt) / h cx, cy = left + center[0] * (r - left - 1), t + center[1] * (b - t - 1) radius = np.hypot(max(cx - left, r - 1 - cx), max(cy - t, b - 1 - cy)) - x = (bl + (np.arange(w, dtype=np.float32)[None, :] + 0.5) * sx - 0.5 - cx) / radius * lens.fill_scale - y = (bt + (np.arange(start, stop, dtype=np.float32)[:, None] + 0.5) * sy - 0.5 - cy) / radius * lens.fill_scale - return x, y, cx, cy, radius, sx, sy, bl, bt + return (float(bl), float(bt)), (sx, sy), (cx, cy), float(radius), float(lens.fill_scale) @dataclass(frozen=True) @@ -53,37 +47,19 @@ def remap( channel: int, corrections: LensCorrections = LensCorrections(True, True), ) -> tuple[np.ndarray, np.ndarray]: - x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, shape, start, stop, self.center) common = self.coefficients[0 if len(self.coefficients) == 1 else 1] selected = self.coefficients[channel] if corrections.ca and len(self.coefficients) == 3 else common - target_x, target_y = x, y - if not corrections.distortion: - if selected == common: - my, mx = np.mgrid[start:stop, : shape[1]].astype(np.float32) - return mx, my - # CA-only maps to the original green geometry: channel(green_inverse(x, y)). - g0, g1, g2, g3, t0, t1 = common - points = np.stack(np.broadcast_arrays(x, y), axis=-1) - unwarped = cv2.undistortPointsIter( - points.reshape(-1, 1, 2), - np.diag([g0, g0, 1.0]), - np.array([g1, g2, t0, t1, g3]) / g0, - None, - None, - (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 50, 1e-8), - ).reshape(points.shape) - x, y = unwarped[..., 0], unwarped[..., 1] - # Outside the calibrated radius, extend the edge's relative CA displacement. - limit = np.maximum(np.hypot(x, y), 1.0) - x, y = x / limit, y / limit - wx, wy = _rectilinear_xy(x, y, selected) - if not corrections.distortion: - gx, gy = _rectilinear_xy(x, y, common) - scale = np.hypot(target_x, target_y) / np.maximum(np.hypot(gx, gy), 1e-8) - wx, wy = target_x + (wx - gx) * scale, target_y + (wy - gy) * scale - mx = (cx + radius * wx - left + 0.5) / sx - 0.5 - my = (cy + radius * wy - top + 0.5) / sy - 0.5 - return mx.astype(np.float32), my.astype(np.float32) + if corrections.distortion: + frame = _frame(lens, tuple(shape[:2]), self.center) + return negpy_core.rectilinear_map(int(shape[1]), (start, stop), [float(v) for v in selected], frame) + if selected == common: + my, mx = np.mgrid[start:stop, : shape[1]].astype(np.float32) + return mx, my + # CA-only maps to the original green geometry: channel(green_inverse(x, y)); outside the + # calibrated radius the edge's relative CA displacement extends. + frame = _frame(lens, tuple(shape[:2]), self.center) + coefficients = ([float(v) for v in selected], [float(v) for v in common]) + return negpy_core.rectilinear_ca_map(int(shape[1]), (start, stop), coefficients, frame) @dataclass(frozen=True) @@ -112,25 +88,12 @@ def remap( ) -> tuple[np.ndarray, np.ndarray]: # Model from darktable's embedded-metadata reader (GPL-3.0+) and https://github.com/trou/panasonic-rw2: # Ru = Rd + scale*(a*Rd^3 + b*Rd^5 + c*Rd^7), Rd (source) and Ru (corrected) normalized to the half-diagonal. - h, w = shape[:2] - x = (np.arange(w, dtype=np.float32)[None, :] - w * 0.5) * lens.fill_scale - y = (np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5) * lens.fill_scale - ru = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) if not corrections.distortion: my, mx = np.mgrid[start:stop, : shape[1]].astype(np.float32) return mx, my - # Invert Ru -> Rd by fixed-point iteration; dr = Rd/Ru is the source-sampling multiplier. - rd = ru - for _ in range(8): - r2 = rd * rd - f = 1.0 + self.scale * (self.a * r2 + self.b * r2 * r2 + self.c * r2 * r2 * r2) - f = np.where(f > 0.0, f, 1.0) - new = ru / f - if np.abs(new - rd).max() < 1e-7: - break - rd = new - dr = np.divide(rd, ru, out=np.ones_like(ru), where=ru > 0.0) - return (x * dr + w * 0.5).astype(np.float32), (y * dr + h * 0.5).astype(np.float32) + # Ru -> Rd by fixed-point iteration; Rd/Ru is the source-sampling multiplier. + model = (float(self.scale), float(self.a), float(self.b), float(self.c)) + return negpy_core.panasonic_map((int(shape[0]), int(shape[1])), (start, stop), model, float(lens.fill_scale)) @dataclass(frozen=True) @@ -158,17 +121,11 @@ def remap( ) -> tuple[np.ndarray, np.ndarray]: # Sony's knot positions and units follow darktable's embedded-metadata model (GPL-3.0+). # https://github.com/darktable-org/darktable/blob/master/src/iop/lens.cc - h, w = shape[:2] - x = (np.arange(w, dtype=np.float32)[None, :] - w * 0.5) * lens.fill_scale - y = (np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5) * lens.fill_scale - radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) n = len(self.distortion) or len(self.ca_red) or len(self.ca_blue) - knots = (np.arange(n) + 0.5) / (n - 1) factors = np.ones(n) if corrections.distortion and self.distortion: factors += np.asarray(self.distortion) / 16384.0 ca = self.ca_red if channel == 0 else self.ca_blue if channel == 2 else () if corrections.ca and ca: factors *= 1 + np.asarray(ca) / 2097152.0 - factor = np.interp(radius, knots, factors).astype(np.float32) - return (x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32) + return negpy_core.sony_map((int(shape[0]), int(shape[1])), (start, stop), factors.tolist(), float(lens.fill_scale)) diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index c30c0e969..c084298fa 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1228,7 +1228,7 @@ depends = ["_edge_reads"] ["features/lens/logic.py::apply_lens"] phase = 4 -status = "todo" +status = "ported" calls = ["negpy_core.remap"] depends = ["fill_scale"] @@ -1238,10 +1238,22 @@ status = "todo" calls = [] depends = ["_search_fill_scale"] +["features/lens/warps.py::PanasonicWarp.remap"] +phase = 4 +status = "todo" +calls = ["negpy_core.panasonic_map"] +depends = [] + ["features/lens/warps.py::RectilinearWarp.remap"] phase = 4 +status = "ported" +calls = ["negpy_core.rectilinear_ca_map", "negpy_core.rectilinear_map"] +depends = [] + +["features/lens/warps.py::SonyWarp.remap"] +phase = 4 status = "todo" -calls = ["cv2.undistortPointsIter"] +calls = ["negpy_core.sony_map"] depends = [] ["features/local/logic.py::_tinted_area"] diff --git a/negpy_core/src/lens.rs b/negpy_core/src/lens.rs new file mode 100644 index 000000000..f382f0f5b --- /dev/null +++ b/negpy_core/src/lens.rs @@ -0,0 +1,309 @@ +//! The embedded lens warps of negpy/features/lens/warps.py: inverse maps, in NumPy's operation +//! order and dtypes. + +use rayon::prelude::*; + +/// The frame geometry of `_coordinates`, its scalars as Python computes them. +#[derive(Clone, Copy)] +pub struct Frame { + /// Buffer-area origin and the buffer-to-image scale per axis. + pub origin: (f64, f64), + pub scale: (f64, f64), + /// The warp center and radius in buffer pixels. + pub center: (f64, f64), + pub radius: f64, + pub fill: f64, +} + +/// A normalized coordinate of `_coordinates`: float32 up to the division by the float64 radius. +#[inline(always)] +fn normalized(i: usize, origin: f64, scale: f64, center: f64, radius: f64, fill: f64) -> f64 { + let v = ((origin as f32 + (i as f32 + 0.5) * scale as f32) - 0.5) - center as f32; + v as f64 / radius * fill +} + +/// `RectilinearWarp.remap` with the distortion applied: the map of rows `rows` of a w-wide +/// frame through `coefficients` (k0..k3, t0, t1), into `mx` and `my`. +pub fn rectilinear_map( + w: usize, + rows: std::ops::Range, + coefficients: [f64; 6], + f: Frame, + mx: &mut [f32], + my: &mut [f32], +) { + let [k0, k1, k2, k3, t0, t1] = coefficients; + let xs: Vec = (0..w) + .map(|i| normalized(i, f.origin.0, f.scale.0, f.center.0, f.radius, f.fill)) + .collect(); + let start = rows.start; + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let y = normalized( + start + r, + f.origin.1, + f.scale.1, + f.center.1, + f.radius, + f.fill, + ); + for ((ox, oy), &x) in ox.iter_mut().zip(oy.iter_mut()).zip(&xs) { + let r2 = x * x + y * y; + let factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let wx = x * factor + 2.0 * t0 * x * y + t1 * (r2 + 2.0 * x * x); + let wy = y * factor + 2.0 * t1 * x * y + t0 * (r2 + 2.0 * y * y); + *ox = + ((((f.center.0 + f.radius * wx) - f.origin.0) + 0.5) / f.scale.0 - 0.5) as f32; + *oy = + ((((f.center.1 + f.radius * wy) - f.origin.1) + 0.5) / f.scale.1 - 0.5) as f32; + } + }); +} + +/// `np.hypot` of float32, which is correctly rounded. +#[inline(always)] +fn hypotf(x: f32, y: f32) -> f32 { + let (x, y) = (x as f64, y as f64); + (x * x + y * y).sqrt() as f32 +} + +/// The float32 centered coordinates of the Panasonic and Sony models. +fn centered(n: usize, start: usize, len: usize, fill: f32) -> Vec { + let half = (n as f64 * 0.5) as f32; + (start..start + len) + .map(|i| (i as f32 - half) * fill) + .collect() +} + +/// `PanasonicWarp.remap` with the distortion applied, for rows `rows` of an h x w frame: +/// Ru -> Rd by fixed-point iteration over the whole block, stopping when no pixel moves 1e-7. +pub fn panasonic_map( + (h, w): (usize, usize), + rows: std::ops::Range, + (scale, a, b, c): (f64, f64, f64, f64), + fill: f64, + mx: &mut [f32], + my: &mut [f32], +) { + let xs = centered(w, 0, w, fill as f32); + let ys = centered(h, rows.start, rows.len(), fill as f32); + let half_diag = (w as f64 * 0.5).hypot(h as f64 * 0.5); + let ru: Vec = ys + .iter() + .flat_map(|&y| xs.iter().map(move |&x| hypotf(x, y) as f64 / half_diag)) + .collect(); + let mut rd = ru.clone(); + for _ in 0..8 { + let new: Vec = rd + .par_iter() + .zip(&ru) + .map(|(&rd, &ru)| { + let r2 = rd * rd; + let f = 1.0 + scale * (a * r2 + b * r2 * r2 + c * r2 * r2 * r2); + ru / if f > 0.0 { f } else { 1.0 } + }) + .collect(); + let moved = new + .par_iter() + .zip(&rd) + .map(|(&n, &r)| (n - r).abs()) + .reduce( + || f64::NEG_INFINITY, + |p, q| if q > p || q.is_nan() { q } else { p }, + ); + if moved < 1e-7 { + break; + } + rd = new; + } + let (cx, cy) = (w as f64 * 0.5, h as f64 * 0.5); + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let y = ys[r] as f64; + for (i, (ox, oy)) in ox.iter_mut().zip(oy.iter_mut()).enumerate() { + let (ru, rd) = (ru[r * w + i], rd[r * w + i]); + let dr = if ru > 0.0 { rd / ru } else { 1.0 }; + *ox = (xs[i] as f64 * dr + cx) as f32; + *oy = (y * dr + cy) as f32; + } + }); +} + +/// `np.interp(x, xp, fp)` of float64, with NumPy's rules at the anchors and its slope order. +fn interp(x: f64, xp: &[f64], fp: &[f64], slopes: &[f64]) -> f64 { + let n = xp.len(); + if x.is_nan() { + return x; + } + if x < xp[0] { + return fp[0]; + } + if x > xp[n - 1] { + return fp[n - 1]; + } + let j = xp.partition_point(|&v| v <= x) - 1; + if j == n - 1 || xp[j] == x { + return fp[j]; + } + let v = slopes[j] * (x - xp[j]) + fp[j]; + if !v.is_nan() { + return v; + } + let v = slopes[j] * (x - xp[j + 1]) + fp[j + 1]; + if v.is_nan() && fp[j] == fp[j + 1] { + fp[j] + } else { + v + } +} + +/// `SonyWarp.remap` for rows `rows` of an h x w frame, its knot factors already combined. +pub fn sony_map( + (h, w): (usize, usize), + rows: std::ops::Range, + factors: &[f64], + fill: f64, + mx: &mut [f32], + my: &mut [f32], +) { + let n = factors.len(); + let knots: Vec = (0..n).map(|i| (i as f64 + 0.5) / (n - 1) as f64).collect(); + let slopes: Vec = (0..n.saturating_sub(1)) + .map(|j| (factors[j + 1] - factors[j]) / (knots[j + 1] - knots[j])) + .collect(); + let xs = centered(w, 0, w, fill as f32); + let ys = centered(h, rows.start, rows.len(), fill as f32); + let half_diag = (w as f64 * 0.5).hypot(h as f64 * 0.5); + let (cx, cy) = ((w as f64 * 0.5) as f32, (h as f64 * 0.5) as f32); + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let y = ys[r]; + for ((ox, oy), &x) in ox.iter_mut().zip(oy.iter_mut()).zip(&xs) { + let radius = hypotf(x, y) as f64 / half_diag; + let factor = interp(radius, &knots, factors, &slopes) as f32; + *ox = x * factor + cx; + *oy = y * factor + cy; + } + }); +} + +/// `_rectilinear_xy` for one float64 point. +#[inline(always)] +fn rectilinear_xy(x: f64, y: f64, [k0, k1, k2, k3, t0, t1]: [f64; 6]) -> (f64, f64) { + let r2 = x * x + y * y; + let factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + ( + x * factor + 2.0 * t0 * x * y + t1 * (r2 + 2.0 * x * x), + y * factor + 2.0 * t1 * x * y + t0 * (r2 + 2.0 * y * y), + ) +} + +/// `cv2.undistortPointsIter` of one point with camera matrix diag(f, f, 1), distortion +/// (k1, k2, p1, p2, k3), no R or P, and (COUNT | EPS, `max_count`, `eps`): OpenCV's damped +/// fixed-point iteration, its operation order kept. +fn undistort_point(u: f64, v: f64, f: f64, k: [f64; 5], max_count: usize, eps: f64) -> (f64, f64) { + let [k1, k2, p1, p2, k3] = k; + let ifx = 1.0 / f; + let (x0, y0) = ((u - 0.0) * ifx, (v - 0.0) * ify(f)); + let (mut x, mut y) = (x0, y0); + let (mut error, mut prev_error) = (f64::MAX, f64::MAX); + let mut alpha = 1.0; + let mut j = 0; + loop { + if j >= max_count || error < eps { + break; + } + let r2 = x * x + y * y; + let icdist = + (1.0 + ((0.0 * r2 + 0.0) * r2 + 0.0) * r2) / (1.0 + ((k3 * r2 + k2) * r2 + k1) * r2); + if icdist < 0.0 { + return ((u - 0.0) * ifx, (v - 0.0) * ify(f)); + } + let dx = 2.0 * p1 * x * y + p2 * (r2 + 2.0 * x * x) + 0.0 * r2 + 0.0 * r2 * r2; + let dy = p1 * (r2 + 2.0 * y * y) + 2.0 * p2 * x * y + 0.0 * r2 + 0.0 * r2 * r2; + let nx = (1.0 - alpha) * x + alpha * (x0 - dx) * icdist; + let ny = (1.0 - alpha) * y + alpha * (y0 - dy) * icdist; + let r2 = nx * nx + ny * ny; + let r4 = r2 * r2; + let r6 = r4 * r2; + let a1 = 2.0 * nx * ny; + let a2 = r2 + 2.0 * nx * nx; + let a3 = r2 + 2.0 * ny * ny; + let cdist = 1.0 + k1 * r2 + k2 * r4 + k3 * r6; + let icdist2 = 1.0 / (1.0 + 0.0 * r2 + 0.0 * r4 + 0.0 * r6); + let xd0 = nx * cdist * icdist2 + p1 * a1 + p2 * a2 + 0.0 * r2 + 0.0 * r4; + let yd0 = ny * cdist * icdist2 + p1 * a3 + p2 * a1 + 0.0 * r2 + 0.0 * r4; + // The identity tilt: 1 * v + 0 * w + 0 * 1, then over 0 * . + 0 * . + 1 * 1. + let xd = 1.0 * (1.0 * xd0 + 0.0 * yd0 + 0.0); + let yd = 1.0 * (0.0 * xd0 + 1.0 * yd0 + 0.0); + let (xp, yp) = (xd * f + 0.0, yd * f + 0.0); + error = ((xp - u) * (xp - u) + (yp - v) * (yp - v)).sqrt(); + if error > prev_error { + alpha *= 0.5; + } else { + x = nx; + y = ny; + } + prev_error = error; + j += 1; + } + (x, y) +} + +#[inline(always)] +fn ify(f: f64) -> f64 { + 1.0 / f +} + +/// `RectilinearWarp.remap` with lateral CA only: each point taken back through the green +/// (`common`) model, clamped to the calibrated radius, through the channel's model, and the +/// channel's offset from green scaled onto the point's own radius. +pub fn rectilinear_ca_map( + w: usize, + rows: std::ops::Range, + (selected, common): ([f64; 6], [f64; 6]), + f: Frame, + mx: &mut [f32], + my: &mut [f32], +) { + let [g0, g1, g2, g3, t0, t1] = common; + let k = [g1 / g0, g2 / g0, t0 / g0, t1 / g0, g3 / g0]; + let xs: Vec = (0..w) + .map(|i| normalized(i, f.origin.0, f.scale.0, f.center.0, f.radius, f.fill)) + .collect(); + let start = rows.start; + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let ty = normalized( + start + r, + f.origin.1, + f.scale.1, + f.center.1, + f.radius, + f.fill, + ); + for ((ox, oy), &tx) in ox.iter_mut().zip(oy.iter_mut()).zip(&xs) { + let (x, y) = undistort_point(tx, ty, g0, k, 50, 1e-8); + let h = x.hypot(y); + let limit = if h.is_nan() || h > 1.0 { h } else { 1.0 }; + let (x, y) = (x / limit, y / limit); + let (wx, wy) = rectilinear_xy(x, y, selected); + let (gx, gy) = rectilinear_xy(x, y, common); + let gh = gx.hypot(gy); + let scale = tx.hypot(ty) / if gh.is_nan() || gh > 1e-8 { gh } else { 1e-8 }; + let (wx, wy) = (tx + (wx - gx) * scale, ty + (wy - gy) * scale); + *ox = + ((((f.center.0 + f.radius * wx) - f.origin.0) + 0.5) / f.scale.0 - 0.5) as f32; + *oy = + ((((f.center.1 + f.radius * wy) - f.origin.1) + 0.5) / f.scale.1 - 0.5) as f32; + } + }); +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 1b95a7abd..864ba6544 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -15,6 +15,7 @@ pub mod gray; pub mod hdr; pub mod histogram; pub mod lab; +pub mod lens; pub mod mathf; pub mod median; pub mod meter; @@ -67,6 +68,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::analysis::register(m)?; py::warp::register(m)?; py::lab::register(m)?; + py::lens::register(m)?; py::altprocess::register(m)?; py::finish::register(m)?; py::flatfield::register(m)?; diff --git a/negpy_core/src/py/lens.rs b/negpy_core/src/py/lens.rs new file mode 100644 index 000000000..46d15146f --- /dev/null +++ b/negpy_core/src/py/lens.rs @@ -0,0 +1,128 @@ +use numpy::{IntoPyArray, PyArray2, PyArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::lens::{self, Frame}; + +type Maps<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray2>); +type FrameArgs = ((f64, f64), (f64, f64), (f64, f64), f64, f64); + +/// `RectilinearWarp.remap`'s maps with the distortion applied, for rows (start, stop) of a +/// w-wide frame: the coefficients (k0..k3, t0, t1), then the frame's buffer origin, scale, +/// warp center, radius and fill scale. +#[pyfunction] +fn rectilinear_map<'py>( + py: Python<'py>, + w: usize, + rows: (usize, usize), + coefficients: [f64; 6], + frame: FrameArgs, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || w == 0 { + return Err(PyValueError::new_err("invalid rows")); + } + let (origin, scale, center, radius, fill) = frame; + let f = Frame { + origin, + scale, + center, + radius, + fill, + }; + let n = (stop - start) * w; + let (mut mx, mut my) = (vec![0f32; n], vec![0f32; n]); + super::detach(py, n, || { + lens::rectilinear_map(w, start..stop, coefficients, f, &mut mx, &mut my) + }); + Ok(( + mx.into_pyarray(py).reshape([stop - start, w])?, + my.into_pyarray(py).reshape([stop - start, w])?, + )) +} + +fn maps<'py>( + py: Python<'py>, + (rows, w): (usize, usize), + fill: impl FnOnce(&mut [f32], &mut [f32]) + Send, +) -> PyResult> { + let n = rows * w; + let (mut mx, mut my) = (vec![0f32; n], vec![0f32; n]); + super::detach(py, n, || fill(&mut mx, &mut my)); + Ok(( + mx.into_pyarray(py).reshape([rows, w])?, + my.into_pyarray(py).reshape([rows, w])?, + )) +} + +/// `RectilinearWarp.remap`'s maps with lateral CA only, for rows (start, stop) of a w-wide +/// frame: the channel's and the green coefficients, then the frame as for `rectilinear_map`. +#[pyfunction] +fn rectilinear_ca_map<'py>( + py: Python<'py>, + w: usize, + rows: (usize, usize), + coefficients: ([f64; 6], [f64; 6]), + frame: FrameArgs, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || w == 0 { + return Err(PyValueError::new_err("invalid rows")); + } + let (origin, scale, center, radius, fill) = frame; + let f = Frame { + origin, + scale, + center, + radius, + fill, + }; + maps(py, (stop - start, w), |mx, my| { + lens::rectilinear_ca_map(w, start..stop, coefficients, f, mx, my) + }) +} + +/// `PanasonicWarp.remap`'s maps with the distortion applied, for rows (start, stop) of an h x w +/// frame, with (scale, a, b, c) and the fill scale. +#[pyfunction] +fn panasonic_map<'py>( + py: Python<'py>, + shape: (usize, usize), + rows: (usize, usize), + model: (f64, f64, f64, f64), + fill: f64, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || shape.1 == 0 { + return Err(PyValueError::new_err("invalid rows")); + } + maps(py, (stop - start, shape.1), |mx, my| { + lens::panasonic_map(shape, start..stop, model, fill, mx, my) + }) +} + +/// `SonyWarp.remap`'s maps for rows (start, stop) of an h x w frame, with the combined knot +/// factors and the fill scale. +#[pyfunction] +fn sony_map<'py>( + py: Python<'py>, + shape: (usize, usize), + rows: (usize, usize), + factors: Vec, + fill: f64, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || shape.1 == 0 || factors.len() < 2 { + return Err(PyValueError::new_err("invalid rows or knots")); + } + maps(py, (stop - start, shape.1), |mx, my| { + lens::sony_map(shape, start..stop, &factors, fill, mx, my) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rectilinear_map, m)?)?; + m.add_function(wrap_pyfunction!(rectilinear_ca_map, m)?)?; + m.add_function(wrap_pyfunction!(panasonic_map, m)?)?; + m.add_function(wrap_pyfunction!(sony_map, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 817908216..bba3dd06d 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -13,6 +13,7 @@ pub mod gray; pub mod hdr; pub mod histogram; pub mod lab; +pub mod lens; pub mod meter; pub mod morph; pub mod print_curve; diff --git a/tests/test_core_lens.py b/tests/test_core_lens.py new file mode 100644 index 000000000..3340fc6cf --- /dev/null +++ b/tests/test_core_lens.py @@ -0,0 +1,113 @@ +"""The embedded lens maps against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +from negpy.features.lens.models import LensCorrections, LensMetadata +from negpy.features.lens.warps import PanasonicWarp, RectilinearWarp, SonyWarp + +pytestmark = cv2_oracle + +_BARREL = (1.0, -0.06, 0.012, -0.002, 0.0004, -0.0003) +_RED = (1.0006, -0.0598, 0.0119, -0.002, 0.0004, -0.0003) +_BLUE = (0.9993, -0.0603, 0.0121, -0.002, 0.0004, -0.0003) +_SHAPE = (120, 170, 3) + + +def _rectilinear_xy(x, y, coefficients): + k0, k1, k2, k3, t0, t1 = coefficients + r2 = x * x + y * y + factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) + return x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x), y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y) + + +def _coordinates(lens, shape, start, stop, center): + h, w = shape[:2] + t, left, b, r = lens.active_area or (0, 0, h, w) + bt, bl, bb, br = lens.buffer_area or (t, left, b, r) + sx, sy = (br - bl) / w, (bb - bt) / h + cx, cy = left + center[0] * (r - left - 1), t + center[1] * (b - t - 1) + radius = np.hypot(max(cx - left, r - 1 - cx), max(cy - t, b - 1 - cy)) + x = (bl + (np.arange(w, dtype=np.float32)[None, :] + 0.5) * sx - 0.5 - cx) / radius * lens.fill_scale + y = (bt + (np.arange(start, stop, dtype=np.float32)[:, None] + 0.5) * sy - 0.5 - cy) / radius * lens.fill_scale + return x, y, cx, cy, radius, sx, sy, bl, bt + + +def _rectilinear_numpy(warp, lens, start, stop, channel, corrections): + x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, _SHAPE, start, stop, warp.center) + common = warp.coefficients[0 if len(warp.coefficients) == 1 else 1] + selected = warp.coefficients[channel] if corrections.ca and len(warp.coefficients) == 3 else common + target_x, target_y = x, y + if not corrections.distortion: + g0, g1, g2, g3, t0, t1 = common + points = np.stack(np.broadcast_arrays(x, y), axis=-1) + unwarped = cv2.undistortPointsIter( + points.reshape(-1, 1, 2), + np.diag([g0, g0, 1.0]), + np.array([g1, g2, t0, t1, g3]) / g0, + None, + None, + (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 50, 1e-8), + ).reshape(points.shape) + x, y = unwarped[..., 0], unwarped[..., 1] + limit = np.maximum(np.hypot(x, y), 1.0) + x, y = x / limit, y / limit + wx, wy = _rectilinear_xy(x, y, selected) + if not corrections.distortion: + gx, gy = _rectilinear_xy(x, y, common) + scale = np.hypot(target_x, target_y) / np.maximum(np.hypot(gx, gy), 1e-8) + wx, wy = target_x + (wx - gx) * scale, target_y + (wy - gy) * scale + mx = (cx + radius * wx - left + 0.5) / sx - 0.5 + my = (cy + radius * wy - top + 0.5) / sy - 0.5 + return mx.astype(np.float32), my.astype(np.float32) + + +def test_rectilinear_maps_match_numpy_and_cv2(): + cases = [ + (RectilinearWarp((_BARREL,)), LensMetadata("DNG", fill_scale=0.93), LensCorrections(True, True), 0), + ( + RectilinearWarp((_RED, _BARREL, _BLUE), (0.51, 0.49)), + LensMetadata("DNG", active_area=(3, 5, 118, 166)), + LensCorrections(True, True), + 2, + ), + (RectilinearWarp((_RED, _BARREL, _BLUE)), LensMetadata("DNG"), LensCorrections(False, True), 0), + ] + for warp, lens, corrections, channel in cases: + got = warp.remap(lens, _SHAPE, 10, 50, channel, corrections) + want = _rectilinear_numpy(warp, lens, 10, 50, channel, corrections) + for g, wv in zip(got, want): + assert g.tobytes() == wv.tobytes() + + +def test_panasonic_and_sony_maps_match_numpy(): + h, w = _SHAPE[:2] + lens = LensMetadata("x", fill_scale=0.97) + x = (np.arange(w, dtype=np.float32)[None, :] - w * 0.5) * lens.fill_scale + y = (np.arange(5, 40, dtype=np.float32)[:, None] - h * 0.5) * lens.fill_scale + radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) + + pana = PanasonicWarp(0.9, 0.04, -0.01, 0.002) + rd = radius + for _ in range(8): + r2 = rd * rd + f = 1.0 + pana.scale * (pana.a * r2 + pana.b * r2 * r2 + pana.c * r2 * r2 * r2) + f = np.where(f > 0.0, f, 1.0) + new = radius / f + if np.abs(new - rd).max() < 1e-7: + break + rd = new + dr = np.divide(rd, radius, out=np.ones_like(radius), where=radius > 0.0) + want = ((x * dr + w * 0.5).astype(np.float32), (y * dr + h * 0.5).astype(np.float32)) + got = pana.remap(lens, _SHAPE, 5, 40, 1) + assert all(g.tobytes() == wv.tobytes() for g, wv in zip(got, want)) + + knots = tuple(float(v) for v in range(-60, 100, 10)) + sony = SonyWarp(knots, tuple(float(v) for v in range(16)), ()) + n = len(knots) + factors = (np.ones(n) + np.asarray(knots) / 16384.0) * (1 + np.arange(16.0) / 2097152.0) + factor = np.interp(radius, (np.arange(n) + 0.5) / (n - 1), factors).astype(np.float32) + want = ((x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32)) + got = sony.remap(lens, _SHAPE, 5, 40, 0) + assert all(g.tobytes() == wv.tobytes() for g, wv in zip(got, want)) From 0a3e813b86bd014e017a126035870a1ff5ac2837 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:05:37 +0200 Subject: [PATCH 099/133] perf(core): HDR measurement, ratio and render exposure in Rust measure_exposure's level, pair_ratio, the merge's final divide and apply_render_exposure run in negpy_core, bit-exact against a new Phase 5 corpus (~/negpy-corpus-p5: an ARW bracket, two RAF RGB triplets, a two-part NEF panorama). The bracket alignment keeps cv2's phase correlation. pair_ratio 0.13x, measure_exposure 0.41x, render exposure 0.25x warm. The bench now makes one untimed call first: the first call in a process starts the thread pools. --- negpy/features/hdr/logic.py | 29 +++++----- negpy_core/manifest.toml | 14 ++--- negpy_core/src/hdr.rs | 89 +++++++++++++++++++++++++++++++ negpy_core/src/py/hdr.rs | 102 ++++++++++++++++++++++++++++++++++++ scripts/replay.py | 3 ++ 5 files changed, 214 insertions(+), 23 deletions(-) diff --git a/negpy/features/hdr/logic.py b/negpy/features/hdr/logic.py index 89c9a6c56..ccb0929ce 100644 --- a/negpy/features/hdr/logic.py +++ b/negpy/features/hdr/logic.py @@ -64,10 +64,15 @@ def subsample(frame: np.ndarray) -> np.ndarray: A stride, not a resize: averaging neighbours would blend back in the very samples these measurements exclude — saturated ones, noise-floor ones. """ - step = int(np.sqrt(frame.shape[0] * frame.shape[1] / MEASURE_PIXELS)) + step = _step(frame) return frame[::step, ::step] if step > 1 else frame +def _step(frame: np.ndarray) -> int: + """The stride that leaves about MEASURE_PIXELS pixels.""" + return max(1, int(np.sqrt(frame.shape[0] * frame.shape[1] / MEASURE_PIXELS))) + + def _count_clipped_rows(values: np.ndarray, out: np.ndarray) -> None: """Saturated-pixel count per row.""" negpy_core.count_clipped_rows(np.ascontiguousarray(values, dtype=np.float32), SATURATION, out) @@ -91,8 +96,8 @@ def measure_exposure(frame: np.ndarray) -> Tuple[float, float]: It saturates too, only later: a frame clipping more than 1% has level exactly 1.0, so level alone cannot order the long end of a bracket. Use `exposure_order_key`. """ - f = to_float(frame) - return float(np.percentile(subsample(f), 99.0)), float(clipped_fraction(f)) + f = np.ascontiguousarray(to_float(frame), dtype=np.float32) + return float(negpy_core.exposure_level(f, _step(f), 99.0)), float(clipped_fraction(f)) def exposure_order_key(level: float, clipped: float) -> Tuple[float, float]: @@ -146,12 +151,8 @@ def pair_ratio(short: np.ndarray, long_: np.ndarray) -> Optional[float]: s, lo = to_float(short), to_float(long_) if s.shape != lo.shape: raise ValueError(f"bracket frames differ in shape: {s.shape}, {lo.shape}") - s, lo = subsample(s), subsample(lo) - valid = (s >= RATIO_FLOOR) & (lo < SATURATION) - if int(valid.sum()) < 1000: - return None - ratio = float(np.median(lo[valid] / s[valid])) - return ratio if np.isfinite(ratio) and ratio > 0.0 else None + s, lo = (np.ascontiguousarray(a, dtype=np.float32) for a in (s, lo)) + return negpy_core.pair_ratio(s, lo, _step(s), (RATIO_FLOOR, SATURATION)) def solve_ratios(frames: Sequence[np.ndarray], reference: int) -> Tuple[float, ...]: @@ -366,11 +367,7 @@ def merge_providers( # Every frame saturated in a channel leaves no measurement there. It is a genuine # highlight, so take the reference's own clipped value rather than a hole. - empty = den <= 0.0 - if empty.any(): - num[empty] = ref[empty] - den[empty] = 1.0 - return np.ascontiguousarray(num / den, dtype=np.float32) + return negpy_core.finish_merge(num, den, np.ascontiguousarray(ref, dtype=np.float32)) def apply_render_exposure(merged: np.ndarray, ratios: Sequence[float], anchor: Optional[float] = None) -> np.ndarray: @@ -379,8 +376,8 @@ def apply_render_exposure(merged: np.ndarray, ratios: Sequence[float], anchor: O Split from the merge because it is cheap and the merge is not: one full-res multiply against seconds of decode. That is what lets the render exposure be dragged. """ - scale = np.float32(output_scale(ratios, anchor)) - return np.clip(merged * scale, 0.0, 1.0).astype(np.float32) + scale = float(np.float32(output_scale(ratios, anchor))) + return negpy_core.render_exposure(np.ascontiguousarray(merged, dtype=np.float32), scale) def _align(ref_gray: np.ndarray, mov: np.ndarray, max_shift: float) -> np.ndarray: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index c084298fa..392360a40 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1650,8 +1650,8 @@ depends = [] ["features/hdr/logic.py::apply_render_exposure"] phase = 5 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.render_exposure"] depends = ["output_scale"] ["features/hdr/logic.py::clipped_fraction"] @@ -1662,8 +1662,8 @@ depends = ["_count_clipped_rows"] ["features/hdr/logic.py::measure_exposure"] phase = 5 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.exposure_level"] depends = ["clipped_fraction"] ["features/hdr/logic.py::merge_bracket"] @@ -1681,7 +1681,7 @@ depends = ["apply_render_exposure", "merge_providers"] ["features/hdr/logic.py::merge_providers"] phase = 5 status = "todo" -calls = [] +calls = ["negpy_core.finish_merge"] depends = ["_accumulate", "_align"] ["features/hdr/logic.py::output_scale"] @@ -1692,8 +1692,8 @@ depends = [] ["features/hdr/logic.py::pair_ratio"] phase = 5 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.pair_ratio"] depends = [] ["features/hdr/logic.py::probe_exposures"] diff --git a/negpy_core/src/hdr.rs b/negpy_core/src/hdr.rs index 9cd78756a..e0d14084f 100644 --- a/negpy_core/src/hdr.rs +++ b/negpy_core/src/hdr.rs @@ -3,6 +3,8 @@ use multiversion::multiversion; use rayon::prelude::*; +const BLOCK: usize = 1 << 14; + /// `_count_clipped_rows`: per row, the pixels with any channel at or above `saturation`, /// compared in float64 as Numba compares a float32 with a Python float. pub fn count_clipped_rows( @@ -81,3 +83,90 @@ fn accumulate_block( *d += w; } } + +/// The first three channels of every `step`-th pixel of every `step`-th row, as +/// `frame[::step, ::step, :3]` reads them, flattened; the frame itself when that is all of it. +fn subsample( + frame: &[f32], + (h, w, cn): (usize, usize, usize), + step: usize, +) -> std::borrow::Cow<'_, [f32]> { + let step = step.max(1); + if step == 1 && cn == 3 { + return frame.into(); + } + let rows: Vec = (0..h).step_by(step).collect(); + rows.par_iter() + .flat_map_iter(|&y| { + (0..w) + .step_by(step) + .flat_map(move |x| (0..3).map(move |c| frame[(y * w + x) * cn + c])) + }) + .collect::>() + .into() +} + +/// `measure_exposure`'s level: the 99th percentile of the subsampled frame. +pub fn exposure_level( + frame: &[f32], + dims: (usize, usize, usize), + step: usize, + q: f64, +) -> Option { + let s = subsample(frame, dims, step); + crate::stats::np_percentiles(&s, &[q]).map(|v| v[0]) +} + +/// `pair_ratio`: the median of long / short over the subsampled samples that clear `floor` +/// in the short frame and stay under `saturation` in the long one; `None` under 1000 such +/// samples or for a ratio that is not finite and positive. +pub fn pair_ratio( + short: &[f32], + long: &[f32], + dims: (usize, usize, usize), + step: usize, + (floor, saturation): (f32, f32), +) -> Option { + let (s, l) = (subsample(short, dims, step), subsample(long, dims, step)); + let ratios: Vec = s + .par_chunks(BLOCK) + .zip(l.par_chunks(BLOCK)) + .flat_map_iter(|(s, l)| { + s.iter() + .zip(l) + .filter(move |&(&s, &l)| s >= floor && l < saturation) + .map(|(&s, &l)| l / s) + }) + .collect(); + if ratios.len() < 1000 { + return None; + } + let r = crate::stats::median(&ratios) as f64; + (r.is_finite() && r > 0.0).then_some(r) +} + +/// The end of `merge_providers`: `num / den`, the reference's own value where no frame left a +/// measurement (`den <= 0`). +pub fn finish_merge(num: &[f32], den: &[f32], reference: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(num.par_chunks(BLOCK)) + .zip(den.par_chunks(BLOCK).zip(reference.par_chunks(BLOCK))) + .for_each(|((o, n), (d, r))| { + for (((o, &n), &d), &r) in o.iter_mut().zip(n).zip(d).zip(r) { + *o = if d <= 0.0 { r / 1.0 } else { n / d }; + } + }); +} + +/// `apply_render_exposure`: the merge times `scale`, clipped to 0..1. +pub fn render_exposure(merged: &[f32], scale: f32, dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(merged.par_chunks(BLOCK)) + .for_each(move |(o, m)| { + for (o, &m) in o.iter_mut().zip(m) { + let v = m * scale; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + *o = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + } + }); +} diff --git a/negpy_core/src/py/hdr.rs b/negpy_core/src/py/hdr.rs index 2b5b536a3..cfd4b7615 100644 --- a/negpy_core/src/py/hdr.rs +++ b/negpy_core/src/py/hdr.rs @@ -62,7 +62,109 @@ fn hdr_accumulate( Ok(()) } +type Dims = (usize, usize, usize); + +fn frame3<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], Dims)> { + let [h, w, cn] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC frame")); + }; + if cn < 3 { + return Err(PyValueError::new_err("needs three channels or more")); + } + Ok((contiguous(a)?, (h, w, cn))) +} + +fn new_like<'py>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [f32]) + Send, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "float32"))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(dst)); + } + Ok(out) +} + +/// `measure_exposure`'s level of a float32 frame: the q-th percentile of `frame[::step, ::step, :3]`. +#[pyfunction] +fn exposure_level( + py: Python<'_>, + frame: PyReadonlyArrayDyn<'_, f32>, + step: usize, + q: f64, +) -> PyResult { + let (f, dims) = frame3(&frame)?; + super::detach(py, f.len(), || hdr::exposure_level(f, dims, step, q)) + .ok_or_else(|| PyValueError::new_err("percentile of an empty frame")) +} + +/// `pair_ratio` of two float32 frames of one shape, with the subsample step and (floor, +/// saturation). +#[pyfunction] +fn pair_ratio( + py: Python<'_>, + short: PyReadonlyArrayDyn<'_, f32>, + long: PyReadonlyArrayDyn<'_, f32>, + step: usize, + limits: (f32, f32), +) -> PyResult> { + let (s, dims) = frame3(&short)?; + if long.shape() != short.shape() { + return Err(PyValueError::new_err("bracket frames differ in shape")); + } + let l = contiguous(&long)?; + Ok(super::detach(py, s.len(), || { + hdr::pair_ratio(s, l, dims, step, limits) + })) +} + +/// The end of `merge_providers`: num / den, the reference where den <= 0. +#[pyfunction] +fn finish_merge<'py>( + py: Python<'py>, + num: PyReadonlyArrayDyn<'py, f32>, + den: PyReadonlyArrayDyn<'py, f32>, + reference: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + if den.shape() != num.shape() || reference.shape() != num.shape() { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let (n, d, r) = ( + contiguous(&num)?, + contiguous(&den)?, + contiguous(&reference)?, + ); + new_like(py, num.shape(), |dst| hdr::finish_merge(n, d, r, dst)) +} + +/// `apply_render_exposure`: merged times scale, clipped to 0..1. +#[pyfunction] +fn render_exposure<'py>( + py: Python<'py>, + merged: PyReadonlyArrayDyn<'py, f32>, + scale: f32, +) -> PyResult>> { + let m = contiguous(&merged)?; + new_like(py, merged.shape(), |dst| { + hdr::render_exposure(m, scale, dst) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(count_clipped_rows, m)?)?; + m.add_function(wrap_pyfunction!(exposure_level, m)?)?; + m.add_function(wrap_pyfunction!(pair_ratio, m)?)?; + m.add_function(wrap_pyfunction!(finish_merge, m)?)?; + m.add_function(wrap_pyfunction!(render_exposure, m)?)?; m.add_function(wrap_pyfunction!(hdr_accumulate, m)?) } diff --git a/scripts/replay.py b/scripts/replay.py index 42a3cf63a..13f205bb8 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -67,6 +67,9 @@ def _time(fn, args, kwargs) -> float: import cv2 cv2.setNumThreads(-1) + # One untimed call first: the first call in a process starts the thread pools. + a, kw = copy.deepcopy((args, kwargs)) + fn(*a, **kw) best = float("inf") for _ in range(TIMING_REPEATS): a, kw = copy.deepcopy((args, kwargs)) From 906ac9d06151491ad5a16230606a9b2f65ef203d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:10:27 +0200 Subject: [PATCH 100/133] perf(core): stitch gain compensation in Rust gain_compensate's float64 overlap means run in negpy_core, bit-exact on the Phase 5 corpus. The seam and feather blends stay Python: their L2 3x3 distance transform on canvas-size masks needs cv2's (IPP) bits, which negpy_core's does not yet match far from the edge. --- negpy/features/stitch/logic.py | 19 +++++++------ negpy_core/manifest.toml | 8 +++++- negpy_core/src/lib.rs | 2 ++ negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/stitch.rs | 50 ++++++++++++++++++++++++++++++++++ negpy_core/src/stitch.rs | 31 +++++++++++++++++++++ 6 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 negpy_core/src/py/stitch.rs create mode 100644 negpy_core/src/stitch.rs diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 67100e73f..0b5befea0 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -162,16 +162,17 @@ def warp_into_canvas( def gain_compensate(ref: np.ndarray, ref_mask: np.ndarray, mov: np.ndarray, mov_mask: np.ndarray) -> np.ndarray: """Scale ``mov`` (in place) so its overlap with ``ref`` matches per channel — removes light-source drift between shots. No-op on tiny overlap.""" - overlap = ref_mask & mov_mask - if int(overlap.sum()) < _MIN_OVERLAP_PX: - return mov - # float64: a full-res overlap is tens of millions of rows, and a float32 axis-0 sum + # The means are float64: a full-res overlap is tens of millions of rows, and a float32 sum # drifts per channel, so export gains would differ from the preview's. - ref_mean = ref[overlap].mean(axis=0, dtype=np.float64) - mov_mean = mov[overlap].mean(axis=0, dtype=np.float64) - if np.any(mov_mean < 1e-6): - return mov - mov *= (ref_mean / mov_mean).astype(np.float32) + gains = negpy_core.compensation_gains( + np.ascontiguousarray(ref, dtype=np.float32), + np.ascontiguousarray(ref_mask), + np.ascontiguousarray(mov, dtype=np.float32), + np.ascontiguousarray(mov_mask), + _MIN_OVERLAP_PX, + ) + if gains is not None: + mov *= np.array(gains, dtype=np.float32) return mov diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 392360a40..3d409eb2f 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1786,6 +1786,12 @@ status = "todo" calls = ["cv2.distanceTransform"] depends = [] +["features/stitch/logic.py::gain_compensate"] +phase = 5 +status = "todo" +calls = ["negpy_core.compensation_gains"] +depends = [] + ["features/stitch/logic.py::register_parts"] phase = 5 status = "todo" @@ -1802,7 +1808,7 @@ depends = ["_seam_weight"] phase = 5 status = "todo" calls = [] -depends = ["blend_ir", "feather_blend", "find_seams", "seam_blend", "warp_into_canvas"] +depends = ["blend_ir", "feather_blend", "find_seams", "gain_compensate", "seam_blend", "warp_into_canvas"] ["features/stitch/logic.py::warp_into_canvas"] phase = 5 diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 864ba6544..c41a81be9 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -27,6 +27,7 @@ pub mod sensor; pub mod shapes; pub mod skew; pub mod stats; +pub mod stitch; pub mod toning; pub mod warp; @@ -72,5 +73,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::altprocess::register(m)?; py::finish::register(m)?; py::flatfield::register(m)?; + py::stitch::register(m)?; py::skew::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index bba3dd06d..139bef03f 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -22,6 +22,7 @@ pub mod retouch; pub mod sensor; pub mod skew; pub mod stats; +pub mod stitch; pub mod toning; pub mod warp; diff --git a/negpy_core/src/py/stitch.rs b/negpy_core/src/py/stitch.rs new file mode 100644 index 000000000..3dfbe4dcc --- /dev/null +++ b/negpy_core/src/py/stitch.rs @@ -0,0 +1,50 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::stitch; + +fn slices<'a, T: numpy::Element>( + arrays: &'a [PyReadonlyArrayDyn<'_, T>], + n: usize, +) -> PyResult> { + arrays + .iter() + .map(|a| { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + if s.len() != n { + return Err(PyValueError::new_err("array sizes disagree")); + } + Ok(s) + }) + .collect() +} + +/// `gain_compensate`'s gains for `moving` against `reference`, or None to leave it. +#[pyfunction] +fn compensation_gains( + py: Python<'_>, + reference: PyReadonlyArrayDyn<'_, f32>, + ref_mask: PyReadonlyArrayDyn<'_, bool>, + moving: PyReadonlyArrayDyn<'_, f32>, + mov_mask: PyReadonlyArrayDyn<'_, bool>, + min_overlap: usize, +) -> PyResult> { + let [h, w, 3] = reference.shape()[..] else { + return Err(PyValueError::new_err("needs HxWx3 parts")); + }; + let n = h * w; + let images = [reference, moving]; + let coverage = [ref_mask, mov_mask]; + let imgs = slices(&images, 3 * n)?; + let masks = slices(&coverage, n)?; + Ok(super::detach(py, n, || { + stitch::compensation_gains(imgs[0], masks[0], imgs[1], masks[1], min_overlap) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(compensation_gains, m)?) +} diff --git a/negpy_core/src/stitch.rs b/negpy_core/src/stitch.rs new file mode 100644 index 000000000..6afef3702 --- /dev/null +++ b/negpy_core/src/stitch.rs @@ -0,0 +1,31 @@ +//! The panorama gain compensation of negpy/features/stitch/logic.py. + +/// `gain_compensate`'s per-channel gains: the float64 mean of `reference` over the overlap +/// over that of `moving`, rows added in order. `None` under `min_overlap` pixels or for a +/// mean under 1e-6, where the part stays as it is. +pub fn compensation_gains( + reference: &[f32], + ref_mask: &[bool], + moving: &[f32], + mov_mask: &[bool], + min_overlap: usize, +) -> Option<[f32; 3]> { + let (mut a, mut b, mut n) = ([0f64; 3], [0f64; 3], 0usize); + for (p, (&r, &m)) in ref_mask.iter().zip(mov_mask).enumerate() { + if r && m { + for c in 0..3 { + a[c] += reference[3 * p + c] as f64; + b[c] += moving[3 * p + c] as f64; + } + n += 1; + } + } + if n < min_overlap { + return None; + } + let (a, b) = (a.map(|v| v / n as f64), b.map(|v| v / n as f64)); + if b.iter().any(|&v| v < 1e-6) { + return None; + } + Some([0, 1, 2].map(|c| (a[c] / b[c]) as f32)) +} From 2941303b290c94c67053d37ff500369fbf78ebd8 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:20:11 +0200 Subject: [PATCH 101/133] build(core): replay compare mode "quality" for ports gated by their own test --- negpy_core/manifest.toml | 2 +- scripts/core_manifest.py | 2 +- scripts/replay.py | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 3d409eb2f..819f7e3ac 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1,6 +1,6 @@ # One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes # calls and depends; phase, status (todo, ported, stays), owner, compare (replay: exact, rows, -# ulp=N; see negpy_core/LEDGER.md) and note are kept by hand. +# ulp=N, abs=X, quality: a pytest gate; see negpy_core/LEDGER.md) and note are kept by hand. ["kernel/image/logic.py::percentiles"] phase = 1 diff --git a/scripts/core_manifest.py b/scripts/core_manifest.py index 3d289298e..29ad8faf5 100644 --- a/scripts/core_manifest.py +++ b/scripts/core_manifest.py @@ -144,7 +144,7 @@ def dump(manifest: dict[str, dict]) -> str: lines = [ "# One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes", "# calls and depends; phase, status (todo, ported, stays), owner, compare (replay: exact, rows,", - "# ulp=N; see negpy_core/LEDGER.md) and note are kept by hand.", + "# ulp=N, abs=X, quality: a pytest gate; see negpy_core/LEDGER.md) and note are kept by hand.", ] for key in sorted(manifest, key=lambda k: (manifest[k]["phase"], k)): lines.append("") diff --git a/scripts/replay.py b/scripts/replay.py index 13f205bb8..4ae7673cb 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -275,10 +275,12 @@ def replay(phases: list[int] | None) -> int: bad = [] for f in files: rec = pickle.loads(f.read_bytes()) - if not same(fn(*rec["args"], **rec["kwargs"]), rec["result"], compare): + result = fn(*rec["args"], **rec["kwargs"]) + # A "quality" port has its own gate against the replaced cv2/NumPy call in pytest. + if compare != "quality" and not same(result, rec["result"], compare): bad.append(f"{f.name} ({rec['frame']})") failed += bool(bad) - print(f"{'DIFF' if bad else 'same'} {len(files) - len(bad):3d}/{len(files):<3d} {key} {' '.join(bad[:5])}") + print(f"{'DIFF' if bad else 'qual' if compare == 'quality' else 'same'}{len(files) - len(bad):3d}/{len(files):<3d} {key} {' '.join(bad[:5])}") return 1 if failed else 0 From 33d089e4e54395c78da188337123f7a185547178 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:36:37 +0200 Subject: [PATCH 102/133] perf(core): line segment detector and _coarse_angles in Rust negpy_core.coarse_angles replaces the percentile stretch, cv2's LSD, the length-squared angle histogram, its smoothing and the two modes. lsd.rs ports OpenCV 4.13's lsd.cpp (Apache-2.0) with the defaults of createLineSegmentDetector(), including the fixed-point 8-bit Gaussian and the INTER_LINEAR_EXACT downscale. The gate is quality (tests/test_core_lsd.py), not bits. Gate results: - 8 synthetic frames (-9.5 to 9.8 degrees) and 42 recorded planes: 100 % of the long cv2 segments matched; every segment list is equal to cv2's bit for bit. - _coarse_angles modes: worst difference 0.0 degrees. - measure_frame_skew on 42 recordings: fine_rotation and converge differences 0.0, same None results. - replay --key skew: every recorded skew function is the same. Speed on the 42 recorded planes, all cores, loaded machine, best of 3 runs of 7: cv2 77.5 to 94.6 ms per frame, Rust 41.5 to 42.7 ms per frame (ratio 0.45 to 0.55). --- NOTICE.md | 3 +- negpy/features/geometry/skew.py | 26 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 5 +- negpy_core/src/lib.rs | 1 + negpy_core/src/lsd.rs | 463 ++++++++++++++++++++++++++++++++ negpy_core/src/py/skew.rs | 52 +++- negpy_core/src/skew.rs | 71 +++++ tests/test_core_lsd.py | 136 ++++++++++ 9 files changed, 727 insertions(+), 31 deletions(-) create mode 100644 negpy_core/src/lsd.rs create mode 100644 tests/test_core_lsd.py diff --git a/NOTICE.md b/NOTICE.md index d89ac4e95..562d9f160 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -69,5 +69,6 @@ negpy_core/Cargo.lock lists the exact versions. negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area resize, separable filter engine and bit-exact Gaussian kernel (including its -softfloat exp table) so their results match cv2 bit for bit. OpenCV, Apache +softfloat exp table) so their results match cv2 bit for bit. negpy_core/src/lsd.rs +ports OpenCV's line segment detector (`modules/imgproc/src/lsd.cpp`). OpenCV, Apache License 2.0. https://github.com/opencv/opencv diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 7c4e553d6..c07e5dc93 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -91,31 +91,7 @@ class FrameSkew: def _coarse_angles(lum: np.ndarray) -> list[float]: """Dominant folded angles of long segments, strongest first. The runner-up is a second start: picture lines at their own tilt can outvote the frame.""" - h, w = lum.shape - lo, hi = np.percentile(lum, (1.0, 99.0)) - u8 = (np.clip((lum - lo) / max(hi - lo, 1e-6), 0.0, 1.0) * 255.0).astype(np.uint8) - segments = cv2.createLineSegmentDetector().detect(u8)[0] - if segments is None: - return [] - seg = segments.reshape(-1, 4).astype(np.float64) - dx, dy = seg[:, 2] - seg[:, 0], seg[:, 3] - seg[:, 1] - length = np.hypot(dx, dy) - angle = np.degrees(np.arctan2(dy, dx)) - angle = (angle + 45.0) % 90.0 - 45.0 - keep = (length >= _MIN_SEGMENT * max(h, w)) & (np.abs(angle) <= _MAX_SKEW_DEGREES) - if not np.any(keep): - return [] - bins = np.arange(-_MAX_SKEW_DEGREES, _MAX_SKEW_DEGREES + _ANGLE_BIN, _ANGLE_BIN) - hist, _ = np.histogram(angle[keep], bins=bins, weights=length[keep] ** 2) - hist = negpy_core.gaussian_blur(hist.astype(np.float32).reshape(1, -1), (0, 0), 3.0).ravel() - first = int(np.argmax(hist)) - modes = [float(bins[first] + _ANGLE_BIN / 2)] - rest = hist.copy() - rest[max(0, first - 10) : first + 11] = 0.0 - second = int(np.argmax(rest)) - if rest[second] >= 0.25 * hist[first]: - modes.append(float(bins[second] + _ANGLE_BIN / 2)) - return modes + return negpy_core.coarse_angles(np.ascontiguousarray(lum, dtype=np.float32), _MIN_SEGMENT, _MAX_SKEW_DEGREES, _ANGLE_BIN) def _ransac_line(xs: np.ndarray, ys: np.ndarray, expected: float, half: float, span: float) -> np.ndarray | None: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 2d918f299..523320669 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -28,3 +28,4 @@ tolerance listed for each entry. | `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | | `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | | `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | +| `features/geometry/skew.py::_coarse_angles` (the line segment detector) | The detector copies OpenCV's `fastAtan2`, its float32 region angles and its fixed-point Gaussian and INTER_LINEAR_EXACT downscale. OpenCV's build can contract the float32 polynomial and call a different `cosf`, and one changed angle can change a region. The recorded planes and the test frames match cv2 bit for bit. | quality: `tests/test_core_lsd.py` (95 % of the cv2 segments longer than 4 % of the long side matched within 1 px; angle modes and `measure_frame_skew` within 0.05) | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 819f7e3ac..c3ec5e3e2 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2479,8 +2479,9 @@ depends = [] ["features/geometry/skew.py::_coarse_angles"] phase = 9 -status = "todo" -calls = ["cv2.createLineSegmentDetector", "negpy_core.gaussian_blur", "np.histogram", "np.percentile"] +status = "ported" +compare = "quality" +calls = ["negpy_core.coarse_angles"] depends = [] ["features/retouch/logic.py::apply_hair_inpaint"] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index c41a81be9..2c98f888c 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -16,6 +16,7 @@ pub mod hdr; pub mod histogram; pub mod lab; pub mod lens; +pub mod lsd; pub mod mathf; pub mod median; pub mod meter; diff --git a/negpy_core/src/lsd.rs b/negpy_core/src/lsd.rs new file mode 100644 index 000000000..687f1e838 --- /dev/null +++ b/negpy_core/src/lsd.rs @@ -0,0 +1,463 @@ +//! `cv2.createLineSegmentDetector().detect(u8)` with its defaults (LSD_REFINE_STD, scale 0.8), +//! ported from OpenCV 4.13's `modules/imgproc/src/lsd.cpp` (Apache-2.0). The Gaussian and the +//! INTER_LINEAR_EXACT downscale copy OpenCV's fixed-point 8-bit code. + +use rayon::prelude::*; + +use crate::filter::{self, Border}; + +const SCALE: f64 = 0.8; +const SIGMA_SCALE: f64 = 0.6; +const QUANT: f64 = 2.0; +const ANG_TH: f64 = 22.5; +const DENSITY_TH: f64 = 0.7; +const N_BINS: usize = 1024; +const NOTDEF: f64 = -1024.0; +const DEG_TO_RADS: f64 = std::f64::consts::PI / 180.0; + +/// OpenCV's `fastAtan2`, in degrees on [0, 360): a polynomial, about 0.3 degrees off. +fn fast_atan2(y: f32, x: f32) -> f32 { + const K: f32 = (180.0 / std::f64::consts::PI) as f32; + let (p1, p3, p5, p7) = ( + 0.9997878412794807_f64 as f32 * K, + -0.3258083974640975_f64 as f32 * K, + 0.1555786518463281_f64 as f32 * K, + -0.04432655554792128_f64 as f32 * K, + ); + let (ax, ay) = (x.abs(), y.abs()); + let poly = |c: f32| { + let c2 = c * c; + (((p7 * c2 + p5) * c2 + p3) * c2 + p1) * c + }; + let mut a = if ax >= ay { + poly(ay / (ax + f64::EPSILON as f32)) + } else { + 90.0 - poly(ax / (ay + f64::EPSILON as f32)) + }; + if x < 0.0 { + a = 180.0 - a; + } + if y < 0.0 { + a = 360.0 - a; + } + a +} + +/// `getGaussianKernelFixedPoint_ED` with 8 fraction bits: the kernel rounded with error +/// diffusion, its centre taking what makes the sum exactly 256. +fn fixed_kernel(n: usize, sigma: f64) -> Vec { + let k = filter::gaussian_kernel_f64(n, sigma); + let mut out = vec![0u32; n]; + let (mut err, mut sum) = (0f64, 0i64); + for i in 0..n / 2 { + let adj = k[i] * 256.0 + err; + let v = adj.round_ties_even() as i64; + err = adj - v as f64; + out[i] = v as u32; + out[n - 1 - i] = v as u32; + sum += v; + } + out[n / 2] = (256 - 2 * sum) as u32; + out +} + +/// `cv2.GaussianBlur(u8, (n, n), sigma)` (reflect-101): exact integer rows in 8.8, columns in +/// 16.16, rounded half up. +fn gaussian_blur_u8(src: &[u8], (h, w): (usize, usize), n: usize, sigma: f64) -> Vec { + let k = fixed_kernel(n, sigma); + let r = (n / 2) as isize; + let idx = + |p: isize, len: usize| filter::border_interpolate(p, len, Border::Reflect101).unwrap_or(0); + let xs: Vec> = (0..w as isize) + .map(|x| (-r..=r).map(|d| idx(x + d, w)).collect()) + .collect(); + let mut rows = vec![0u32; h * w]; + rows.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let line = &src[y * w..(y + 1) * w]; + for (o, taps) in out.iter_mut().zip(&xs) { + *o = taps.iter().zip(&k).map(|(&t, &c)| line[t] as u32 * c).sum(); + } + }); + let mut dst = vec![0u8; h * w]; + dst.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let taps: Vec<&[u32]> = (-r..=r) + .map(|d| &rows[idx(y as isize + d, h) * w..][..w]) + .collect(); + for (x, o) in out.iter_mut().enumerate() { + let s: u32 = taps.iter().zip(&k).map(|(t, &c)| t[x] * c).sum(); + *o = ((s + (1 << 15)) >> 16).min(255) as u8; + } + }); + dst +} + +/// `interpolationLinear::getCoeffs` for every destination index: (offset, c0, c1) in 8.8, with +/// c0 = 256 and c1 = 0 past either end. +fn linear_exact_axis(src: usize, dst: usize, inv_scale: f64) -> Vec<(usize, u32, u32)> { + let scale = 1.0 / inv_scale; + (0..dst) + .map(|d| { + let f = scale * (d as f64 + 0.5) - 0.5; + let i = f.floor() as i64; + if i < 0 || src <= 1 { + (0, 256, 0) + } else if (i as usize) < src - 1 { + let c1 = ((f - i as f64) * 256.0).round_ties_even() as u32; + (i as usize, 256 - c1, c1) + } else { + (src - 1, 256, 0) + } + }) + .collect() +} + +/// `cv2.resize(u8, None, fx=SCALE, fy=SCALE, interpolation=cv2.INTER_LINEAR_EXACT)`. +fn resize_exact_u8(src: &[u8], (h, w): (usize, usize)) -> (Vec, usize, usize) { + let dw = (w as f64 * SCALE).round_ties_even() as usize; + let dh = (h as f64 * SCALE).round_ties_even() as usize; + let xs = linear_exact_axis(w, dw, SCALE); + let ys = linear_exact_axis(h, dh, SCALE); + let hline = |sy: usize, out: &mut [u32]| { + let row = &src[sy * w..(sy + 1) * w]; + for (o, &(i, c0, c1)) in out.iter_mut().zip(&xs) { + let b = if c1 == 0 { 0 } else { row[i + 1] as u32 * c1 }; + *o = row[i] as u32 * c0 + b; + } + }; + let mut dst = vec![0u8; dh * dw]; + dst.par_chunks_mut(dw) + .zip(&ys) + .for_each(|(out, &(i, c0, c1))| { + let (mut a, mut b) = (vec![0u32; dw], vec![0u32; dw]); + hline(i, &mut a); + if c1 != 0 { + hline(i + 1, &mut b); + } + for ((o, &a), &b) in out.iter_mut().zip(&a).zip(&b) { + *o = ((a * c0 + b * c1 + (1 << 15)) >> 16).min(255) as u8; + } + }); + (dst, dh, dw) +} + +struct Point { + x: usize, + y: usize, + angle: f64, + modgrad: f64, +} + +struct Rect { + x1: f64, + y1: f64, + x2: f64, + y2: f64, + width: f64, +} + +struct Detector { + w: usize, + h: usize, + angles: Vec, + modgrad: Vec, + used: Vec, +} + +fn dist(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { + ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)).sqrt() +} + +fn angle_diff_signed(a: f64, b: f64) -> f64 { + let mut d = a - b; + while d <= -std::f64::consts::PI { + d += 2.0 * std::f64::consts::PI; + } + while d > std::f64::consts::PI { + d -= 2.0 * std::f64::consts::PI; + } + d +} + +impl Detector { + /// `ll_angle`: gradient angle and norm per pixel, and the pixels pseudo-ordered by norm. + fn new(img: &[u8], h: usize, w: usize, threshold: f64) -> (Self, Vec) { + let mut angles = vec![NOTDEF; h * w]; + let mut modgrad = vec![0f64; h * w]; + angles + .par_chunks_mut(w) + .zip(modgrad.par_chunks_mut(w)) + .enumerate() + .take(h.saturating_sub(1)) + .for_each(|(y, (ang, md))| { + let (r0, r1) = (&img[y * w..(y + 1) * w], &img[(y + 1) * w..(y + 2) * w]); + for x in 0..w - 1 { + let da = r1[x + 1] as i32 - r0[x] as i32; + let bc = r0[x + 1] as i32 - r1[x] as i32; + let (gx, gy) = (da + bc, da - bc); + let norm = ((gx * gx + gy * gy) as f64 / 4.0).sqrt(); + md[x] = norm; + if norm > threshold { + ang[x] = fast_atan2(gx as f32, -gy as f32) as f64 * DEG_TO_RADS; + } + } + }); + let max_grad = angles + .iter() + .zip(&modgrad) + .filter(|(a, _)| **a != NOTDEF) + .map(|(_, m)| *m) + .fold(-1f64, f64::max); + let coef = if max_grad > 0.0 { + (N_BINS - 1) as f64 / max_grad + } else { + 0.0 + }; + // A stable sort by descending bin is a bucket sort that keeps raster order in a bin. + let bins: Vec = (0..h.saturating_sub(1) * w) + .map(|i| { + if i % w == w - 1 { + u16::MAX + } else { + (modgrad[i] * coef) as u16 + } + }) + .collect(); + let mut start = vec![0usize; N_BINS + 1]; + for &b in bins.iter().filter(|&&b| b != u16::MAX) { + start[N_BINS - 1 - b as usize + 1] += 1; + } + for i in 0..N_BINS { + start[i + 1] += start[i]; + } + let mut order = vec![0u32; start[N_BINS]]; + for (i, &b) in bins.iter().enumerate().filter(|&(_, &b)| b != u16::MAX) { + let s = &mut start[N_BINS - 1 - b as usize]; + order[*s] = i as u32; + *s += 1; + } + let det = Detector { + w, + h, + angles, + modgrad, + used: vec![false; h * w], + }; + (det, order) + } + + fn is_aligned(&self, x: usize, y: usize, theta: f64, prec: f64) -> bool { + let a = self.angles[y * self.w + x]; + if a == NOTDEF { + return false; + } + let mut n = (theta - a).abs(); + if n > 1.5 * std::f64::consts::PI { + n = (n - 2.0 * std::f64::consts::PI).abs(); + } + n <= prec + } + + /// Returns the region's mean angle. + fn region_grow(&mut self, sx: usize, sy: usize, reg: &mut Vec, prec: f64) -> f64 { + reg.clear(); + let i = sy * self.w + sx; + let mut reg_angle = self.angles[i]; + reg.push(Point { + x: sx, + y: sy, + angle: reg_angle, + modgrad: self.modgrad[i], + }); + let mut sumdx = reg_angle.cos() as f32; + let mut sumdy = reg_angle.sin() as f32; + self.used[i] = true; + let mut k = 0; + while k < reg.len() { + let (px, py) = (reg[k].x, reg[k].y); + for yy in py.saturating_sub(1)..=(py + 1).min(self.h - 1) { + for xx in px.saturating_sub(1)..=(px + 1).min(self.w - 1) { + let j = yy * self.w + xx; + if !self.used[j] && self.is_aligned(xx, yy, reg_angle, prec) { + let angle = self.angles[j]; + self.used[j] = true; + reg.push(Point { + x: xx, + y: yy, + angle, + modgrad: self.modgrad[j], + }); + sumdx += libm::cosf(angle as f32); + sumdy += libm::sinf(angle as f32); + reg_angle = fast_atan2(sumdy, sumdx) as f64 * DEG_TO_RADS; + } + } + } + k += 1; + } + reg_angle + } + + fn region2rect(reg: &[Point], reg_angle: f64, prec: f64) -> Rect { + let (mut x, mut y, mut sum) = (0.0, 0.0, 0.0); + for p in reg { + x += p.x as f64 * p.modgrad; + y += p.y as f64 * p.modgrad; + sum += p.modgrad; + } + x /= sum; + y /= sum; + let theta = Self::get_theta(reg, x, y, reg_angle, prec); + let (dx, dy) = (theta.cos(), theta.sin()); + let (mut l_min, mut l_max, mut w_min, mut w_max) = (0f64, 0f64, 0f64, 0f64); + for p in reg { + let (rx, ry) = (p.x as f64 - x, p.y as f64 - y); + let l = rx * dx + ry * dy; + let w = -rx * dy + ry * dx; + if l > l_max { + l_max = l; + } else if l < l_min { + l_min = l; + } + if w > w_max { + w_max = w; + } else if w < w_min { + w_min = w; + } + } + Rect { + x1: x + l_min * dx, + y1: y + l_min * dy, + x2: x + l_max * dx, + y2: y + l_max * dy, + width: (w_max - w_min).max(1.0), + } + } + + fn get_theta(reg: &[Point], x: f64, y: f64, reg_angle: f64, prec: f64) -> f64 { + let (mut ixx, mut iyy, mut ixy) = (0.0, 0.0, 0.0); + for p in reg { + let (dx, dy) = (p.x as f64 - x, p.y as f64 - y); + ixx += dy * dy * p.modgrad; + iyy += dx * dx * p.modgrad; + ixy -= dx * dy * p.modgrad; + } + let lambda = 0.5 * (ixx + iyy - ((ixx - iyy) * (ixx - iyy) + 4.0 * ixy * ixy).sqrt()); + let deg = if ixx.abs() > iyy.abs() { + fast_atan2((lambda - ixx) as f32, ixy as f32) + } else { + fast_atan2(ixy as f32, (lambda - iyy) as f32) + }; + let theta = deg as f64 * DEG_TO_RADS; + if angle_diff_signed(theta, reg_angle).abs() > prec { + theta + std::f64::consts::PI + } else { + theta + } + } + + fn density(reg: &[Point], r: &Rect) -> f64 { + reg.len() as f64 / (dist(r.x1, r.y1, r.x2, r.y2) * r.width) + } + + fn release(&mut self, p: &Point) { + self.used[p.y * self.w + p.x] = false; + } + + fn refine(&mut self, reg: &mut Vec, prec: f64, rec: &mut Rect) -> bool { + if Self::density(reg, rec) >= DENSITY_TH { + return true; + } + let (xc, yc, ang_c) = (reg[0].x as f64, reg[0].y as f64, reg[0].angle); + let (seed_x, seed_y) = (reg[0].x, reg[0].y); + let (mut sum, mut s_sum, mut n) = (0.0, 0.0, 0usize); + for p in reg.iter() { + self.release(p); + if dist(xc, yc, p.x as f64, p.y as f64) < rec.width { + let d = angle_diff_signed(p.angle, ang_c); + sum += d; + s_sum += d * d; + n += 1; + } + } + let mean = sum / n as f64; + let tau = 2.0 * ((s_sum - 2.0 * mean * sum) / n as f64 + mean * mean).sqrt(); + let reg_angle = self.region_grow(seed_x, seed_y, reg, tau); + if reg.len() < 2 { + return false; + } + *rec = Self::region2rect(reg, reg_angle, prec); + let density = Self::density(reg, rec); + if density >= DENSITY_TH { + return true; + } + self.reduce_region_radius(reg, reg_angle, prec, rec, density) + } + + fn reduce_region_radius( + &mut self, + reg: &mut Vec, + reg_angle: f64, + prec: f64, + rec: &mut Rect, + mut density: f64, + ) -> bool { + let (xc, yc) = (reg[0].x as f64, reg[0].y as f64); + let d2 = |x: f64, y: f64| (x - xc) * (x - xc) + (y - yc) * (y - yc); + let mut rad = d2(rec.x1, rec.y1).max(d2(rec.x2, rec.y2)); + while density < DENSITY_TH { + rad *= 0.75 * 0.75; + let mut i = 0; + while i < reg.len() { + if d2(reg[i].x as f64, reg[i].y as f64) > rad { + let p = reg.swap_remove(i); + self.release(&p); + } else { + i += 1; + } + } + if reg.len() < 2 { + return false; + } + *rec = Self::region2rect(reg, reg_angle, prec); + density = Self::density(reg, rec); + } + true + } +} + +/// Segments (x1, y1, x2, y2) of an HxW 8-bit image, in pixels of that image. +pub fn line_segments(img: &[u8], (h, w): (usize, usize)) -> Vec<[f32; 4]> { + let sigma = SIGMA_SCALE / SCALE; + let half = (sigma * (2.0 * 3.0 * 10f64.ln()).sqrt()).ceil() as usize; + let blurred = gaussian_blur_u8(img, (h, w), 1 + 2 * half, sigma); + let (scaled, sh, sw) = resize_exact_u8(&blurred, (h, w)); + if sh < 2 || sw < 2 { + return Vec::new(); + } + let prec = std::f64::consts::PI * ANG_TH / 180.0; + let p = ANG_TH / 180.0; + let rho = QUANT / prec.sin(); + let (mut det, order) = Detector::new(&scaled, sh, sw, rho); + let log_nt = 5.0 * ((sw as f64).log10() + (sh as f64).log10()) / 2.0 + 11f64.log10(); + let min_reg_size = (-log_nt / p.log10()) as usize; + let mut reg = Vec::new(); + let mut lines = Vec::new(); + for &i in &order { + let i = i as usize; + if det.used[i] || det.angles[i] == NOTDEF { + continue; + } + let (x, y) = (i % sw, i / sw); + let reg_angle = det.region_grow(x, y, &mut reg, prec); + if reg.len() < min_reg_size { + continue; + } + let mut rec = Detector::region2rect(®, reg_angle, prec); + if !det.refine(&mut reg, prec, &mut rec) { + continue; + } + let s = |v: f64| ((v + 0.5) / SCALE) as f32; + lines.push([s(rec.x1), s(rec.y1), s(rec.x2), s(rec.y2)]); + } + lines +} diff --git a/negpy_core/src/py/skew.rs b/negpy_core/src/py/skew.rs index 8bbca9fca..5a8cac2e1 100644 --- a/negpy_core/src/py/skew.rs +++ b/negpy_core/src/py/skew.rs @@ -1,8 +1,11 @@ -use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods}; +use numpy::{ + IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, + PyUntypedArrayMethods, +}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use crate::skew; +use crate::{lsd, skew}; type Samples<'py> = (Bound<'py, PyArray1>, Bound<'py, PyArray1>); @@ -34,6 +37,49 @@ fn skew_sample<'py>( Ok((xs.into_pyarray(py), ys.into_pyarray(py))) } +/// `_coarse_angles` on an HxW float32 luminance plane: the dominant folded segment angles in +/// degrees, strongest first. +#[pyfunction] +fn coarse_angles( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + min_segment: f64, + max_skew: f64, + angle_bin: f64, +) -> PyResult> { + let (h, w) = (lum.shape()[0], lum.shape()[1]); + let img = lum + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + if img.is_empty() || angle_bin <= 0.0 || max_skew <= 0.0 { + return Err(PyValueError::new_err( + "needs a non-empty plane and positive bins", + )); + } + Ok(super::detach(py, img.len(), || { + skew::coarse_angles(img, (h, w), min_segment, max_skew, angle_bin) + })) +} + +/// `cv2.createLineSegmentDetector().detect(img)[0]` for an HxW uint8 image, as an (N, 4) +/// float32 array of (x1, y1, x2, y2). +#[pyfunction] +fn line_segments<'py>( + py: Python<'py>, + img: PyReadonlyArray2<'py, u8>, +) -> PyResult>> { + let (h, w) = (img.shape()[0], img.shape()[1]); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let lines = super::detach(py, src.len(), || lsd::line_segments(src, (h, w))); + let n = lines.len(); + let flat: Vec = lines.into_iter().flatten().collect(); + flat.into_pyarray(py).reshape([n, 4]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(skew_sample, m)?) + m.add_function(wrap_pyfunction!(skew_sample, m)?)?; + m.add_function(wrap_pyfunction!(coarse_angles, m)?)?; + m.add_function(wrap_pyfunction!(line_segments, m)?) } diff --git a/negpy_core/src/skew.rs b/negpy_core/src/skew.rs index fa4e9b0b4..cc24d6e20 100644 --- a/negpy_core/src/skew.rs +++ b/negpy_core/src/skew.rs @@ -124,3 +124,74 @@ pub fn sample( } (xs, ys) } + +/// `_coarse_angles`: the dominant folded angles (degrees) of long line segments on an HxW +/// luminance plane, strongest first, with a runner-up at a quarter of its weight or more. +pub fn coarse_angles( + lum: &[f32], + (h, w): (usize, usize), + min_segment: f64, + max_skew: f64, + angle_bin: f64, +) -> Vec { + let q = crate::stats::np_percentiles_f64(lum, &[1.0, 99.0]); + let (lo, span) = (q[0], (q[1] - q[0]).max(1e-6)); + let mut u8s = vec![0u8; lum.len()]; + crate::par_zip(&mut u8s, lum, move |o, &v| { + *o = (((v as f64 - lo) / span).clamp(0.0, 1.0) * 255.0) as u8; + }); + let min_len = min_segment * h.max(w) as f64; + let found: Vec<(f64, f64)> = crate::lsd::line_segments(&u8s, (h, w)) + .iter() + .map(|s| { + let (dx, dy) = ((s[2] - s[0]) as f64, (s[3] - s[1]) as f64); + let angle = libm::atan2(dy, dx).to_degrees(); + ((angle + 45.0).rem_euclid(90.0) - 45.0, dx.hypot(dy)) + }) + .filter(|&(a, l)| l >= min_len && a.abs() <= max_skew) + .collect(); + if found.is_empty() { + return Vec::new(); + } + // np.arange(-max_skew, max_skew + angle_bin, angle_bin), filled as NumPy fills it. + let start = -max_skew; + let delta = (start + angle_bin) - start; + let n = (((max_skew + angle_bin) - start) / angle_bin).ceil() as usize; + let edges: Vec = (0..n).map(|i| start + i as f64 * delta).collect(); + let mut hist = vec![0f64; n - 1]; + for &(a, l) in &found { + if a < edges[0] || a > edges[n - 1] { + continue; + } + let bin = (edges.partition_point(|&e| e <= a) - 1).min(n - 2); + hist[bin] += l * l; + } + let hist32: Vec = hist.iter().map(|&v| v as f32).collect(); + let mut smooth = vec![0f32; n - 1]; + if filter::gaussian_blur( + &hist32, + &mut smooth, + (1, n - 1, 1), + (0, 0), + (3.0, 0.0), + Border::Reflect101, + ) + .is_none() + { + return Vec::new(); + } + let argmax = |v: &[f32]| { + v.iter() + .enumerate() + .fold(0, |b, (i, &x)| if x > v[b] { i } else { b }) + }; + let first = argmax(&smooth); + let mut modes = vec![edges[first] + angle_bin / 2.0]; + let mut rest = smooth.clone(); + rest[first.saturating_sub(10)..(first + 11).min(n - 1)].fill(0.0); + let second = argmax(&rest); + if rest[second] >= 0.25 * smooth[first] { + modes.push(edges[second] + angle_bin / 2.0); + } + modes +} diff --git a/tests/test_core_lsd.py b/tests/test_core_lsd.py new file mode 100644 index 000000000..b4f6448f8 --- /dev/null +++ b/tests/test_core_lsd.py @@ -0,0 +1,136 @@ +"""negpy_core's line segment detector and `_coarse_angles` against cv2's LSD. The port is gated on +quality, not bits: long segments must land on cv2's and the angle modes must agree.""" + +import glob +import os +import pickle + +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.geometry import skew +from negpy.features.geometry.logic import AUTOCROP_DETECT_RES, _detection_luma, _normalize_detection_input + +pytestmark = cv2_oracle + +_CORPUS = os.path.expanduser("~/negpy-corpus/features__geometry__skew.py--measure_frame_skew") +_RECORDINGS = sorted(glob.glob(os.path.join(_CORPUS, "*.pkl"))) + + +def _to_u8(lum): + lo, hi = np.percentile(lum, (1.0, 99.0)) + return (np.clip((lum - lo) / max(hi - lo, 1e-6), 0.0, 1.0) * 255.0).astype(np.uint8) + + +def _coarse_angles_cv2(lum): + """The cv2-based `_coarse_angles` that negpy_core.coarse_angles replaces.""" + h, w = lum.shape + segments = cv2.createLineSegmentDetector().detect(_to_u8(lum))[0] + if segments is None: + return [] + seg = segments.reshape(-1, 4).astype(np.float64) + dx, dy = seg[:, 2] - seg[:, 0], seg[:, 3] - seg[:, 1] + length = np.hypot(dx, dy) + angle = np.degrees(np.arctan2(dy, dx)) + angle = (angle + 45.0) % 90.0 - 45.0 + keep = (length >= skew._MIN_SEGMENT * max(h, w)) & (np.abs(angle) <= skew._MAX_SKEW_DEGREES) + if not np.any(keep): + return [] + bins = np.arange(-skew._MAX_SKEW_DEGREES, skew._MAX_SKEW_DEGREES + skew._ANGLE_BIN, skew._ANGLE_BIN) + hist, _ = np.histogram(angle[keep], bins=bins, weights=length[keep] ** 2) + hist = cv2.GaussianBlur(hist.astype(np.float32).reshape(1, -1), (0, 0), 3.0).ravel() + first = int(np.argmax(hist)) + modes = [float(bins[first] + skew._ANGLE_BIN / 2)] + rest = hist.copy() + rest[max(0, first - 10) : first + 11] = 0.0 + second = int(np.argmax(rest)) + if rest[second] >= 0.25 * hist[first]: + modes.append(float(bins[second] + skew._ANGLE_BIN / 2)) + return modes + + +def _matched_fraction(lum): + """Fraction of cv2's segments longer than `_MIN_SEGMENT` of the long side that a negpy_core + segment matches with both endpoints within 1 px, in either direction.""" + u8 = _to_u8(lum) + ref = cv2.createLineSegmentDetector().detect(u8)[0] + ref = np.empty((0, 4), np.float32) if ref is None else ref.reshape(-1, 4) + got = negpy_core.line_segments(u8) + long = ref[np.hypot(ref[:, 2] - ref[:, 0], ref[:, 3] - ref[:, 1]) >= skew._MIN_SEGMENT * max(u8.shape)] + if long.size == 0: + return 1.0 + if got.size == 0: + return 0.0 + + def far(a, b): + return np.maximum(np.hypot(got[:, 0] - a[0], got[:, 1] - a[1]), np.hypot(got[:, 2] - b[0], got[:, 3] - b[1])) + + hits = [min(far(s[:2], s[2:]).min(), far(s[2:], s[:2]).min()) <= 1.0 for s in long] + return float(np.mean(hits)) + + +def _assert_same_modes(lum): + want, got = _coarse_angles_cv2(lum), skew._coarse_angles(lum) + assert len(want) == len(got) + assert np.allclose(want, got, rtol=0.0, atol=0.05) + + +def _synthetic(angle, seed): + """A light bed, a dark film frame and a picture with a few hard lines, turned by `angle`.""" + rng = np.random.default_rng(seed) + h, w = 900, 1300 + img = np.full((h, w), 0.92, np.float32) + img[150:750, 180:1120] = 0.08 + img[190:710, 230:1070] = 0.55 + img[300:330, 300:1000] = 0.3 + img[420:640, 600:615] = 0.8 + m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + return img + rng.normal(0.0, 0.02, img.shape).astype(np.float32) + + +@pytest.mark.parametrize("angle", [-9.5, -4.2, -0.7, 0.0, 0.3, 2.6, 6.1, 9.8]) +def test_synthetic_frames_match_cv2(angle): + lum = _synthetic(angle, int(abs(angle) * 10)) + assert _matched_fraction(lum) >= 0.95 + _assert_same_modes(lum) + + +def test_flat_and_tiny_planes(): + flat = np.full((64, 80), 0.5, np.float32) + assert negpy_core.line_segments(_to_u8(flat)).shape == (0, 4) + assert skew._coarse_angles(flat) == [] + assert negpy_core.line_segments(np.zeros((1, 1), np.uint8)).shape == (0, 4) + + +def _recorded_luma(rec): + det, _ = _normalize_detection_input(rec["args"][0], AUTOCROP_DETECT_RES) + return _detection_luma(np.ascontiguousarray(det, dtype=np.float32)) + + +@pytest.mark.skipif(not _RECORDINGS, reason="needs the replay corpus") +def test_recorded_planes_match_cv2(): + for path in _RECORDINGS: + with open(path, "rb") as f: + rec = pickle.load(f) + lum = _recorded_luma(rec) + assert _matched_fraction(lum) >= 0.95, path + _assert_same_modes(lum) + + +@pytest.mark.skipif(not _RECORDINGS, reason="needs the replay corpus") +def test_recorded_frame_skew_unchanged(): + for path in _RECORDINGS: + with open(path, "rb") as f: + rec = pickle.load(f) + want, got = rec["result"], skew.measure_frame_skew(*rec["args"], **rec["kwargs"]) + assert (want is None) == (got is None), path + if want is None or got is None: + continue + assert abs(want.fine_rotation - got.fine_rotation) <= 0.05, path + for a, b in ((want.converge_v, got.converge_v), (want.converge_h, got.converge_h)): + assert (a is None) == (b is None), path + assert a is None or abs(a - b) <= 0.05, path From b5a773f115f2635d7691a3c0b0c4522602f50d29 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:39:17 +0200 Subject: [PATCH 103/133] perf(core): SIFT registration of stitch pairs in Rust register_pair calls negpy_core.register_pair. It replaces cv2's SIFT, BFMatcher ratio test and estimateAffinePartial2D (RANSAC, then Levenberg-Marquardt). The port copies OpenCV 4.13 with the cv2 defaults, including the bilinear upscale that cv2.SIFT_create uses. It also copies cv::RNG with its fixed seed. The port is not bit-exact. tests/test_core_sift.py is its quality gate. Largest corner error of the moved part against the true similarity (rotation -2.8 to +3.0 deg, scale 0.975 to 1.03, 4 pairs per source): - synthetic texture: Rust 0.073 px, cv2 0.073 px - real scan, preview size: Rust 0.082 px, cv2 0.082 px - real scan, full size (6048x4032 TIFF): Rust 0.094 px, cv2 0.094 px Recorded panorama (register_parts, corpus p5): 0.0019 px from cv2. Keypoints: 5859 against 5860 (preview), 6732 against 6735 (full), 13734 against 13735 (synthetic). 99.7 % of descriptors are within 2 of cv2's values, and 97.5 % are identical. Speed, best of 7, 24 cores under load from other builds: register_pair on the 1600 px panorama proxies takes 209-222 ms, cv2 takes 325-356 ms (1.4-1.6x faster). SIFT alone is at parity with cv2 (0.96-1.19x). --- NOTICE.md | 6 + negpy/features/stitch/logic.py | 23 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 6 +- negpy_core/src/lib.rs | 3 + negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/register.rs | 63 +++ negpy_core/src/register.rs | 374 ++++++++++++++++++ negpy_core/src/sift.rs | 681 +++++++++++++++++++++++++++++++++ tests/test_core_sift.py | 146 +++++++ 10 files changed, 1285 insertions(+), 19 deletions(-) create mode 100644 negpy_core/src/py/register.rs create mode 100644 negpy_core/src/register.rs create mode 100644 negpy_core/src/sift.rs create mode 100644 tests/test_core_sift.py diff --git a/NOTICE.md b/NOTICE.md index d89ac4e95..ec2e8eea9 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -71,3 +71,9 @@ negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area resize, separable filter engine and bit-exact Gaussian kernel (including its softfloat exp table) so their results match cv2 bit for bit. OpenCV, Apache License 2.0. https://github.com/opencv/opencv + +negpy_core/src/sift.rs and negpy_core/src/register.rs port OpenCV 4.13's SIFT +detector and descriptor (sift.dispatch.cpp, sift.simd.hpp), its RANSAC +similarity estimate and Levenberg-Marquardt refinement (ptsetreg.cpp, +levmarq.cpp). OpenCV, Apache License 2.0. SIFT is by David G. Lowe; its patent +expired in March 2020. diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 0b5befea0..0537ee297 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -48,24 +48,13 @@ def register_pair(ref_proxy: np.ndarray, mov_proxy: np.ndarray, min_inliers: int SIFT + ratio test + RANSAC; ORB and phase correlation both fail on film negatives (feature-poor orange-mask content, sub-degree rotations). """ - sift = cv2.SIFT_create(nfeatures=6000) - ref_kp, ref_desc = sift.detectAndCompute(ref_proxy, None) - mov_kp, mov_desc = sift.detectAndCompute(mov_proxy, None) - if ref_desc is None or mov_desc is None or len(ref_kp) < 2 or len(mov_kp) < 2: - raise StitchError(_NO_OVERLAP_MSG) - - pairs = cv2.BFMatcher().knnMatch(mov_desc, ref_desc, k=2) - good = [m for m, n in (p for p in pairs if len(p) == 2) if m.distance < 0.75 * n.distance] - if len(good) < min_inliers: - raise StitchError(_NO_OVERLAP_MSG) - - mov_pts = np.float32([mov_kp[m.queryIdx].pt for m in good]) - ref_pts = np.float32([ref_kp[m.trainIdx].pt for m in good]) - matrix, inlier_mask = cv2.estimateAffinePartial2D(mov_pts, ref_pts, method=cv2.RANSAC, ransacReprojThreshold=3.0) - inliers = int(inlier_mask.sum()) if inlier_mask is not None else 0 - if matrix is None or inliers < min_inliers: + found = negpy_core.register_pair( + np.ascontiguousarray(ref_proxy, dtype=np.uint8), np.ascontiguousarray(mov_proxy, dtype=np.uint8), min_inliers + ) + if found is None: raise StitchError(_NO_OVERLAP_MSG) - return matrix.astype(np.float64), inliers + matrix, inliers = found + return np.array(matrix, np.float64).reshape(2, 3), inliers def scale_affine(matrix: np.ndarray, ref_scale: float, mov_scale: float) -> np.ndarray: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 2d918f299..f6d46e986 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -28,3 +28,4 @@ tolerance listed for each entry. | `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | | `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | | `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | +| `features/stitch/logic.py::register_pair` | SIFT, the ratio test and RANSAC copy OpenCV 4.13, but the sub-pixel solve, `exp`, `fastAtan2` and the float32 sums run in another order. A keypoint position can move by a float32 unit, which can change a descriptor byte, a match or a RANSAC draw. | quality: `tests/test_core_sift.py` asks for each corner of the moved part within 0.1 px of the true similarity on synthetic pairs, within 0.25 px of cv2 on the recorded panorama, and keypoint counts within 10 % of cv2's | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 819f7e3ac..18c969ded 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2503,9 +2503,11 @@ depends = [] ["features/stitch/logic.py::register_pair"] phase = 9 -status = "todo" -calls = ["cv2.BFMatcher", "cv2.SIFT_create", "cv2.estimateAffinePartial2D"] +status = "ported" +compare = "quality" +calls = ["negpy_core.register_pair"] depends = [] +note = "negpy_core::register::register_pair, SIFT in negpy_core::sift" ["infrastructure/capture/raw_demosaic.py::_plateau_clip_fraction"] phase = 9 diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index c41a81be9..e6c2a5ddf 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -21,10 +21,12 @@ pub mod median; pub mod meter; pub mod morph; pub mod print_curve; +pub mod register; pub mod resize; pub mod retouch; pub mod sensor; pub mod shapes; +pub mod sift; pub mod skew; pub mod stats; pub mod stitch; @@ -74,5 +76,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::finish::register(m)?; py::flatfield::register(m)?; py::stitch::register(m)?; + py::register::register(m)?; py::skew::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 139bef03f..170a141e4 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -17,6 +17,7 @@ pub mod lens; pub mod meter; pub mod morph; pub mod print_curve; +pub mod register; pub mod resize; pub mod retouch; pub mod sensor; diff --git a/negpy_core/src/py/register.rs b/negpy_core/src/py/register.rs new file mode 100644 index 000000000..2db52548f --- /dev/null +++ b/negpy_core/src/py/register.rs @@ -0,0 +1,63 @@ +use numpy::{PyArray2, PyReadonlyArray2, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::{register, sift}; + +fn gray<'a>(img: &'a PyReadonlyArray2<'_, u8>) -> PyResult<(&'a [u8], usize, usize)> { + let (h, w) = (img.shape()[0], img.shape()[1]); + let data = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous uint8 image"))?; + Ok((data, h, w)) +} + +/// `register_pair(ref_proxy, mov_proxy, min_inliers)`: (2x3 matrix as 6 floats, inliers), or +/// None when the pair has no usable overlap. +#[pyfunction] +fn register_pair( + py: Python<'_>, + ref_proxy: PyReadonlyArray2<'_, u8>, + mov_proxy: PyReadonlyArray2<'_, u8>, + min_inliers: usize, +) -> PyResult> { + let (r, m) = (gray(&ref_proxy)?, gray(&mov_proxy)?); + Ok(super::detach(py, r.0.len() + m.0.len(), || { + register::register_pair(r, m, min_inliers) + })) +} + +type Detected<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray2>); + +/// `cv2.SIFT_create(nfeatures).detectAndCompute(gray, None)`: an Nx2 float32 array of keypoint +/// positions and the Nx128 uint8 descriptors. +#[pyfunction] +fn sift_detect<'py>( + py: Python<'py>, + img: PyReadonlyArray2<'py, u8>, + nfeatures: usize, +) -> PyResult> { + let (data, h, w) = gray(&img)?; + let (kp, desc) = super::detach(py, data.len(), || { + sift::detect_and_compute(data, h, w, nfeatures) + }); + let pts: Vec> = kp.iter().map(|k| vec![k.x, k.y]).collect(); + let desc: Vec> = desc.iter().map(|d| d.to_vec()).collect(); + let err = |e: numpy::FromVecError| PyValueError::new_err(e.to_string()); + let pts = if pts.is_empty() { + PyArray2::zeros(py, [0, 2], false) + } else { + PyArray2::from_vec2(py, &pts).map_err(err)? + }; + let desc = if desc.is_empty() { + PyArray2::zeros(py, [0, sift::DESCR_LEN], false) + } else { + PyArray2::from_vec2(py, &desc).map_err(err)? + }; + Ok((pts, desc)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(register_pair, m)?)?; + m.add_function(wrap_pyfunction!(sift_detect, m)?) +} diff --git a/negpy_core/src/register.rs b/negpy_core/src/register.rs new file mode 100644 index 000000000..206d58b5e --- /dev/null +++ b/negpy_core/src/register.rs @@ -0,0 +1,374 @@ +//! `register_pair` of negpy/features/stitch/logic.py: SIFT features, a brute-force L2 ratio +//! test and OpenCV 4.13's `estimateAffinePartial2D` (`ptsetreg.cpp`, `levmarq.cpp`, +//! Apache-2.0): RANSAC over 2-point similarity models, then Levenberg-Marquardt on the inliers. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::sift::{self, DESCR_LEN}; + +const NFEATURES: usize = 6000; +const RATIO: f64 = 0.75; +const REPROJ_THRESHOLD: f64 = 3.0; +const CONFIDENCE: f64 = 0.99; +const MAX_ITERS: usize = 2000; +const REFINE_ITERS: usize = 10; + +/// The squared L2 distance of two descriptors; exact, as OpenCV's float32 sum of integers under +/// 2^24 is. +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn dist2(a: &[u8; DESCR_LEN], b: &[u8; DESCR_LEN]) -> i32 { + a.iter() + .zip(b) + .map(|(&x, &y)| { + let d = x as i16 - y as i16; + d as i32 * d as i32 + }) + .sum() +} + +/// `BFMatcher().knnMatch(query, train, k=2)` and Lowe's ratio test: (query, train) index pairs +/// whose nearest train descriptor is nearer than RATIO times the second nearest. A tie keeps the +/// lower train index first, as OpenCV does. +pub fn ratio_matches(query: &[[u8; DESCR_LEN]], train: &[[u8; DESCR_LEN]]) -> Vec<(usize, usize)> { + if train.len() < 2 { + return Vec::new(); + } + query + .par_iter() + .enumerate() + .filter_map(|(qi, q)| { + let (mut best, mut second) = ((i32::MAX, 0usize), i32::MAX); + for (ti, t) in train.iter().enumerate() { + let d = dist2(q, t); + if d < best.0 { + second = best.0; + best = (d, ti); + } else if d < second { + second = d; + } + } + let (d1, d2) = ((best.0 as f32).sqrt() as f64, (second as f32).sqrt() as f64); + (d1 < RATIO * d2).then_some((qi, best.1)) + }) + .collect() +} + +/// OpenCV's `cv::RNG`, a multiply-with-carry generator. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u32 { + self.0 = (self.0 as u32 as u64) + .wrapping_mul(4_164_903_690) + .wrapping_add(self.0 >> 32); + self.0 as u32 + } + + fn uniform(&mut self, n: usize) -> usize { + (self.next() % n as u32) as usize + } +} + +/// `RANSACUpdateNumIters`. +fn update_num_iters(p: f64, ep: f64, model_points: i32, max_iters: usize) -> usize { + let (p, ep) = (p.clamp(0.0, 1.0), ep.clamp(0.0, 1.0)); + let num = (1.0 - p).max(f64::MIN_POSITIVE); + let denom = 1.0 - libm::pow(1.0 - ep, model_points as f64); + if denom < f64::MIN_POSITIVE { + return 0; + } + let (num, denom) = (libm::log(num), libm::log(denom)); + if denom >= 0.0 || -num >= max_iters as f64 * -denom { + max_iters + } else { + (num / denom).round_ties_even() as usize + } +} + +/// The 2-point similarity of `AffinePartial2DEstimatorCallback::runKernel`, as (a, b, tx, ty) +/// for the matrix [a -b tx; b a ty]. +fn similarity(from: [[f32; 2]; 2], to: [[f32; 2]; 2]) -> [f64; 4] { + let [[x1, y1], [x2, y2]] = from.map(|p| p.map(f64::from)); + let [[bx1, by1], [bx2, by2]] = to.map(|p| p.map(f64::from)); + let d = 1.0 / ((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)); + let s0 = d * ((bx1 - bx2) * (x1 - x2) + (by1 - by2) * (y1 - y2)); + let s1 = d * ((by1 - by2) * (x1 - x2) - (bx1 - bx2) * (y1 - y2)); + let s2 = d + * ((by1 - by2) * (x1 * y2 - x2 * y1) + - (bx1 * y2 - bx2 * y1) * (y1 - y2) + - (bx1 * x2 - bx2 * x1) * (x1 - x2)); + let s3 = d + * (-(bx1 - bx2) * (x1 * y2 - x2 * y1) + - (by1 * x2 - by2 * x1) * (x1 - x2) + - (by1 * y2 - by2 * y1) * (y1 - y2)); + [s0, s1, s2, s3] +} + +/// `findInliers`: the float32 squared reprojection error against the threshold. +fn inliers(from: &[[f32; 2]], to: &[[f32; 2]], h: [f64; 4], mask: &mut [bool]) -> usize { + let (a, b, tx, ty) = (h[0] as f32, h[1] as f32, h[2] as f32, h[3] as f32); + let t = (REPROJ_THRESHOLD * REPROJ_THRESHOLD) as f32; + let mut n = 0; + for ((f, p), m) in from.iter().zip(to).zip(mask.iter_mut()) { + let ex = a * f[0] - b * f[1] + tx - p[0]; + let ey = b * f[0] + a * f[1] + ty - p[1]; + *m = ex * ex + ey * ey <= t; + n += *m as usize; + } + n +} + +/// Gauss-Jordan solve of a 4x4 system with partial pivoting; None when singular. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for i in 0..4 { + let p = (i..4).max_by(|&x, &y| a[x][i].abs().total_cmp(&a[y][i].abs()))?; + if a[p][i].abs() < f64::EPSILON * 1e-6 { + return None; + } + a.swap(i, p); + b.swap(i, p); + let (pivot, pb) = (a[i], b[i]); + for (j, (row, bj)) in a.iter_mut().zip(b.iter_mut()).enumerate() { + if j != i { + let f = row[i] / pivot[i]; + for (x, &p) in row.iter_mut().zip(&pivot).skip(i) { + *x -= f * p; + } + *bj -= f * pb; + } + } + } + Some(std::array::from_fn(|i| b[i] / a[i][i])) +} + +/// `LMSolver` (`levmarq.cpp`) over (a, b, tx, ty) with `AffinePartial2DRefineCallback`. +fn refine(from: &[[f32; 2]], to: &[[f32; 2]], x0: [f64; 4]) -> [f64; 4] { + let residuals = |h: &[f64; 4]| -> Vec { + from.iter() + .zip(to) + .flat_map(|(f, p)| { + let (mx, my) = (f[0] as f64, f[1] as f64); + [ + h[0] * mx - h[1] * my + h[2] - p[0] as f64, + h[1] * mx + h[0] * my + h[3] - p[1] as f64, + ] + }) + .collect() + }; + let jac_rows = |i: usize| -> [f64; 4] { + let (mx, my) = (from[i / 2][0] as f64, from[i / 2][1] as f64); + if i.is_multiple_of(2) { + [mx, -my, 1.0, 0.0] + } else { + [my, mx, 0.0, 1.0] + } + }; + let n = 2 * from.len(); + let mut a = [[0f64; 4]; 4]; + for i in 0..n { + let j = jac_rows(i); + for r in 0..4 { + for c in 0..4 { + a[r][c] += j[r] * j[c]; + } + } + } + let jt_r = |r: &[f64]| -> [f64; 4] { + let mut v = [0f64; 4]; + for (i, &e) in r.iter().enumerate() { + let j = jac_rows(i); + for k in 0..4 { + v[k] += j[k] * e; + } + } + v + }; + let norm2 = |r: &[f64]| r.iter().map(|e| e * e).sum::(); + let inf = |r: &[f64]| r.iter().fold(0f64, |m, e| m.max(e.abs())); + let eps = f32::EPSILON as f64; + + let mut x = x0; + let mut r = residuals(&x); + let mut s = norm2(&r); + let mut v = jt_r(&r); + let diag: [f64; 4] = std::array::from_fn(|i| a[i][i]); + let (rlo, rhi) = (0.25, 0.75); + let (mut lambda, mut lc) = (1.0f64, 0.75f64); + let mut iter = 0; + loop { + let mut ap = a; + for i in 0..4 { + ap[i][i] += lambda * diag[i]; + } + let Some(d) = solve4(ap, v) else { break }; + let xd: [f64; 4] = std::array::from_fn(|i| x[i] - d[i]); + let rd = residuals(&xd); + let sd = norm2(&rd); + let ad: [f64; 4] = std::array::from_fn(|i| (0..4).map(|k| a[i][k] * d[k]).sum::()); + let ds: f64 = (0..4).map(|i| d[i] * (2.0 * v[i] - ad[i])).sum(); + let ratio = (s - sd) / if ds.abs() > f64::EPSILON { ds } else { 1.0 }; + if ratio > rhi { + lambda *= 0.5; + if lambda < lc { + lambda = 0.0; + } + } else if ratio < rlo { + let t: f64 = (0..4).map(|i| d[i] * v[i]).sum(); + let mut nu = (sd - s) / if t.abs() > f64::EPSILON { t } else { 1.0 } + 2.0; + nu = nu.clamp(2.0, 10.0); + if lambda == 0.0 { + let maxval = (0..4) + .filter_map(|i| { + let mut e = [0f64; 4]; + e[i] = 1.0; + solve4(a, e).map(|col| col[i].abs()) + }) + .fold(f64::EPSILON, f64::max); + lambda = 1.0 / maxval; + lc = lambda; + nu *= 0.5; + } + lambda *= nu; + } + if sd < s { + s = sd; + x = xd; + r = rd; + v = jt_r(&r); + } + iter += 1; + if !(iter < REFINE_ITERS && inf(&d) >= eps && inf(&r) >= eps) { + break; + } + } + x +} + +/// `cv2.estimateAffinePartial2D(from, to, method=RANSAC, ransacReprojThreshold=3)`: the matrix +/// as (a, b, tx, ty) and the inlier mask, or None when no model is found. +pub fn estimate_affine_partial( + from: &[[f32; 2]], + to: &[[f32; 2]], +) -> Option<([f64; 4], Vec)> { + let count = from.len(); + if count < 2 || to.len() != count { + return None; + } + if count == 2 { + return Some(( + similarity([from[0], from[1]], [to[0], to[1]]), + vec![true; 2], + )); + } + let mut rng = Rng(u64::MAX); + let (mut best, mut best_mask) = (None, vec![false; count]); + let mut mask = vec![false; count]; + let (mut niters, mut max_good) = (MAX_ITERS, 0usize); + let mut iter = 0; + while iter < niters { + let i0 = rng.uniform(count); + let mut i1 = rng.uniform(count); + while i1 == i0 { + i1 = rng.uniform(count); + } + let h = similarity([from[i0], from[i1]], [to[i0], to[i1]]); + let good = inliers(from, to, h, &mut mask); + if good > max_good.max(1) { + std::mem::swap(&mut mask, &mut best_mask); + best = Some(h); + max_good = good; + niters = update_num_iters(CONFIDENCE, (count - good) as f64 / count as f64, 2, niters); + } + iter += 1; + } + let h = best?; + let (src, dst): (Vec<[f32; 2]>, Vec<[f32; 2]>) = from + .iter() + .zip(to) + .zip(&best_mask) + .filter(|(_, m)| **m) + .map(|((f, t), _)| (*f, *t)) + .unzip(); + Some((refine(&src, &dst, h), best_mask)) +} + +/// `register_pair`: the 2x3 similarity mapping `mov` coordinates to `ref_` coordinates and its +/// RANSAC inlier count, or None when the pair has fewer than `min_inliers` good matches or +/// inliers. +pub fn register_pair( + ref_: (&[u8], usize, usize), + mov: (&[u8], usize, usize), + min_inliers: usize, +) -> Option<([f64; 6], usize)> { + let ((ref_kp, ref_desc), (mov_kp, mov_desc)) = rayon::join( + || sift::detect_and_compute(ref_.0, ref_.1, ref_.2, NFEATURES), + || sift::detect_and_compute(mov.0, mov.1, mov.2, NFEATURES), + ); + if ref_kp.len() < 2 || mov_kp.len() < 2 { + return None; + } + let good = ratio_matches(&mov_desc, &ref_desc); + if good.len() < min_inliers { + return None; + } + let mov_pts: Vec<[f32; 2]> = good + .iter() + .map(|&(q, _)| [mov_kp[q].x, mov_kp[q].y]) + .collect(); + let ref_pts: Vec<[f32; 2]> = good + .iter() + .map(|&(_, t)| [ref_kp[t].x, ref_kp[t].y]) + .collect(); + let (h, mask) = estimate_affine_partial(&mov_pts, &ref_pts)?; + let n = mask.iter().filter(|&&m| m).count(); + if n < min_inliers { + return None; + } + Some(([h[0], -h[1], h[2], h[1], h[0], h[3]], n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rng_matches_opencv() { + // cv::RNG((uint64)-1): the first draws of rng.next(). + let mut rng = Rng(u64::MAX); + let first = rng.next(); + assert_eq!( + first, + (u32::MAX as u64 * 4_164_903_690 + u32::MAX as u64) as u32 + ); + } + + #[test] + fn ransac_recovers_a_similarity_among_outliers() { + let (a, b, tx, ty) = (0.99f64, 0.03f64, 12.5f64, -7.25f64); + let mut from = Vec::new(); + let mut to = Vec::new(); + for i in 0..200 { + let (x, y) = ((i * 37 % 400) as f64, (i * 91 % 300) as f64); + from.push([x as f32, y as f32]); + let (u, v) = if i % 4 == 0 { + ((i * 53 % 400) as f64, (i * 17 % 300) as f64) + } else { + (a * x - b * y + tx, b * x + a * y + ty) + }; + to.push([u as f32, v as f32]); + } + let (h, mask) = estimate_affine_partial(&from, &to).unwrap(); + assert_eq!(mask.iter().filter(|&&m| m).count(), 150); + for (got, want) in h.iter().zip([a, b, tx, ty]) { + assert!((got - want).abs() < 1e-3, "{h:?}"); + } + } + + #[test] + fn iteration_count_follows_opencv() { + assert_eq!(update_num_iters(0.99, 0.0, 2, 2000), 0); + assert_eq!(update_num_iters(0.99, 0.5, 2, 2000), 16); + assert_eq!(update_num_iters(0.99, 1.0, 2, 2000), 2000); + } +} diff --git a/negpy_core/src/sift.rs b/negpy_core/src/sift.rs new file mode 100644 index 000000000..2f73892b2 --- /dev/null +++ b/negpy_core/src/sift.rs @@ -0,0 +1,681 @@ +//! OpenCV 4.13's SIFT detector and float descriptor (`sift.dispatch.cpp`, `sift.simd.hpp`, +//! Apache-2.0) with its default parameters. OpenCV rounds each float descriptor element to an +//! integer in 0..=255, so the descriptors here are bytes of the same values. + +use rayon::prelude::*; + +use crate::filter::{self, Border}; +use crate::resize; + +const LAYERS: usize = 3; +const CONTRAST: f32 = 0.04; +const EDGE: f32 = 10.0; +const SIGMA: f32 = 1.6; +const INIT_SIGMA: f32 = 0.5; +const IMG_BORDER: usize = 5; +const MAX_INTERP_STEPS: usize = 5; +const ORI_BINS: usize = 36; +const ORI_SIG_FCTR: f32 = 1.5; +const ORI_RADIUS: f32 = 4.5; +const ORI_PEAK_RATIO: f32 = 0.8; +const DESCR_WIDTH: usize = 4; +const DESCR_BINS: usize = 8; +const DESCR_SCL_FCTR: f32 = 3.0; +const DESCR_MAG_THR: f32 = 0.2; +const INT_DESCR_FCTR: f32 = 512.0; +pub const DESCR_LEN: usize = DESCR_WIDTH * DESCR_WIDTH * DESCR_BINS; +/// The first octave is the input upscaled two times. +const FIRST_OCTAVE: i32 = -1; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Keypoint { + pub x: f32, + pub y: f32, + pub size: f32, + pub angle: f32, + pub response: f32, + /// OpenCV's packed octave: octave in bits 0..8, layer in 8..16, sub-layer in 16..24. + pub octave: i32, +} + +struct Plane { + data: Vec, + h: usize, + w: usize, +} + +impl Plane { + #[inline(always)] + fn row(&self, r: usize) -> &[f32] { + &self.data[r * self.w..(r + 1) * self.w] + } + + #[inline(always)] + fn at(&self, r: usize, c: usize) -> f32 { + self.data[r * self.w + c] + } +} + +#[inline(always)] +fn cv_round(v: f32) -> i32 { + v.round_ties_even() as i32 +} + +/// OpenCV's `fastAtan2` in degrees, 0..360. +#[inline(always)] +fn fast_atan2(y: f32, x: f32) -> f32 { + const R2D: f32 = (180.0 / std::f64::consts::PI) as f32; + let (p1, p3, p5, p7) = ( + 0.999_787_87_f32 * R2D, + -0.325_808_4_f32 * R2D, + 0.155_578_66_f32 * R2D, + -0.044_326_555_f32 * R2D, + ); + let (ax, ay) = (x.abs(), y.abs()); + let poly = |c: f32| { + let c2 = c * c; + (((p7 * c2 + p5) * c2 + p3) * c2 + p1) * c + }; + let mut a = if ax >= ay { + poly(ay / (ax + f64::EPSILON as f32)) + } else { + 90.0 - poly(ax / (ay + f64::EPSILON as f32)) + }; + if x < 0.0 { + a = 180.0 - a; + } + if y < 0.0 { + a = 360.0 - a; + } + a +} + +fn blur(src: &Plane, sigma: f64) -> Plane { + let mut data = vec![0f32; src.data.len()]; + filter::gaussian_blur( + &src.data, + &mut data, + (src.h, src.w, 1), + (0, 0), + (sigma, sigma), + Border::Reflect101, + ) + .expect("an odd Gaussian kernel"); + Plane { + data, + h: src.h, + w: src.w, + } +} + +/// `createInitialImage` as `cv2.SIFT_create` runs it, with `enable_precise_upscale` off: a +/// bilinear `resize` to twice the size, then the blur that brings the assumed input blur up to +/// SIGMA. +fn initial_image(gray: &[u8], h: usize, w: usize) -> Plane { + let src: Vec = gray.iter().map(|&v| v as f32).collect(); + let (dh, dw) = (2 * h, 2 * w); + let mut dbl = vec![0f32; dh * dw]; + resize::resize_linear(&src, (h, w, 1), (dh, dw), false, &mut dbl).expect("a non-empty image"); + let sig_diff = (SIGMA * SIGMA - INIT_SIGMA * INIT_SIGMA * 4.0) + .max(0.01) + .sqrt(); + blur( + &Plane { + data: dbl, + h: dh, + w: dw, + }, + sig_diff as f64, + ) +} + +fn gaussian_pyramid(base: Plane, octaves: usize) -> Vec { + let k = 2f64.powf(1.0 / LAYERS as f64); + let sig: Vec = (0..LAYERS + 3) + .map(|i| { + if i == 0 { + return SIGMA as f64; + } + let prev = k.powi(i as i32 - 1) * SIGMA as f64; + let total = prev * k; + (total * total - prev * prev).sqrt() + }) + .collect(); + let mut pyr: Vec = Vec::with_capacity(octaves * (LAYERS + 3)); + pyr.push(base); + for o in 0..octaves { + for (i, &s) in sig.iter().enumerate() { + if o == 0 && i == 0 { + continue; + } + let next = if i == 0 { + let src = &pyr[(o - 1) * (LAYERS + 3) + LAYERS]; + let (dh, dw) = (src.h / 2, src.w / 2); + let mut data = vec![0f32; dh * dw]; + resize::resize_nearest(&src.data, (src.h, src.w, 1), (dh, dw), &mut data); + Plane { data, h: dh, w: dw } + } else { + blur(&pyr[o * (LAYERS + 3) + i - 1], s) + }; + pyr.push(next); + } + } + pyr +} + +fn dog_pyramid(gpyr: &[Plane], octaves: usize) -> Vec { + (0..octaves * (LAYERS + 2)) + .map(|a| { + let (o, i) = (a / (LAYERS + 2), a % (LAYERS + 2)); + let (lo, hi) = (&gpyr[o * (LAYERS + 3) + i], &gpyr[o * (LAYERS + 3) + i + 1]); + let mut data = hi.data.clone(); + crate::par_zip(&mut data, &lo.data, move |d, &s| *d -= s); + Plane { + data, + h: lo.h, + w: lo.w, + } + }) + .collect() +} + +/// OpenCV's 3x3 `Matx::solve` (DECOMP_LU): Cramer's rule; zeros for a singular matrix. +fn solve3(a: [[f32; 3]; 3], b: [f32; 3]) -> [f32; 3] { + let d = a[0][0] as f64 * (a[1][1] as f64 * a[2][2] as f64 - a[1][2] as f64 * a[2][1] as f64) + - a[0][1] as f64 * (a[1][0] as f64 * a[2][2] as f64 - a[1][2] as f64 * a[2][0] as f64) + + a[0][2] as f64 * (a[1][0] as f64 * a[2][1] as f64 - a[1][1] as f64 * a[2][0] as f64); + let d = d as f32; + if d == 0.0 { + return [0.0; 3]; + } + let d = 1.0 / d; + [ + d * (b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) + - a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2]) + + a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2])), + d * (a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2]) + - b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0])), + d * (a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1]) + - a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0]) + + b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])), + ] +} + +/// `adjustLocalExtrema`: the sub-pixel, sub-layer extremum and its contrast and edge tests. +fn adjust_local_extrema( + dog: &[Plane], + octv: usize, + layer: &mut usize, + r: &mut usize, + c: &mut usize, +) -> Option { + const IMG_SCALE: f32 = 1.0 / 255.0; + const DERIV: f32 = IMG_SCALE * 0.5; + const SECOND: f32 = IMG_SCALE; + const CROSS: f32 = IMG_SCALE * 0.25; + let (mut xi, mut xr, mut xc) = (0f32, 0f32, 0f32); + let mut step = 0; + while step < MAX_INTERP_STEPS { + let idx = octv * (LAYERS + 2) + *layer; + let (img, prev, next) = (&dog[idx], &dog[idx - 1], &dog[idx + 1]); + let (r0, c0) = (*r, *c); + let d_d = [ + (img.at(r0, c0 + 1) - img.at(r0, c0 - 1)) * DERIV, + (img.at(r0 + 1, c0) - img.at(r0 - 1, c0)) * DERIV, + (next.at(r0, c0) - prev.at(r0, c0)) * DERIV, + ]; + let v2 = img.at(r0, c0) * 2.0; + let dxx = (img.at(r0, c0 + 1) + img.at(r0, c0 - 1) - v2) * SECOND; + let dyy = (img.at(r0 + 1, c0) + img.at(r0 - 1, c0) - v2) * SECOND; + let dss = (next.at(r0, c0) + prev.at(r0, c0) - v2) * SECOND; + let dxy = (img.at(r0 + 1, c0 + 1) - img.at(r0 + 1, c0 - 1) - img.at(r0 - 1, c0 + 1) + + img.at(r0 - 1, c0 - 1)) + * CROSS; + let dxs = (next.at(r0, c0 + 1) - next.at(r0, c0 - 1) - prev.at(r0, c0 + 1) + + prev.at(r0, c0 - 1)) + * CROSS; + let dys = (next.at(r0 + 1, c0) - next.at(r0 - 1, c0) - prev.at(r0 + 1, c0) + + prev.at(r0 - 1, c0)) + * CROSS; + let x = solve3([[dxx, dxy, dxs], [dxy, dyy, dys], [dxs, dys, dss]], d_d); + xi = -x[2]; + xr = -x[1]; + xc = -x[0]; + if xi.abs() < 0.5 && xr.abs() < 0.5 && xc.abs() < 0.5 { + break; + } + let far = (i32::MAX / 3) as f32; + if xi.abs() > far || xr.abs() > far || xc.abs() > far { + return None; + } + let nc = c0 as i64 + cv_round(xc) as i64; + let nr = r0 as i64 + cv_round(xr) as i64; + let nl = *layer as i64 + cv_round(xi) as i64; + if nl < 1 + || nl > LAYERS as i64 + || nc < IMG_BORDER as i64 + || nc >= (img.w - IMG_BORDER) as i64 + || nr < IMG_BORDER as i64 + || nr >= (img.h - IMG_BORDER) as i64 + { + return None; + } + (*c, *r, *layer) = (nc as usize, nr as usize, nl as usize); + step += 1; + } + if step >= MAX_INTERP_STEPS { + return None; + } + let idx = octv * (LAYERS + 2) + *layer; + let (img, prev, next) = (&dog[idx], &dog[idx - 1], &dog[idx + 1]); + let (r0, c0) = (*r, *c); + let d_d = [ + (img.at(r0, c0 + 1) - img.at(r0, c0 - 1)) * DERIV, + (img.at(r0 + 1, c0) - img.at(r0 - 1, c0)) * DERIV, + (next.at(r0, c0) - prev.at(r0, c0)) * DERIV, + ]; + let t = d_d[0] * xc + d_d[1] * xr + d_d[2] * xi; + let contr = img.at(r0, c0) * IMG_SCALE + t * 0.5; + if contr.abs() * (LAYERS as f32) < CONTRAST { + return None; + } + let v2 = img.at(r0, c0) * 2.0; + let dxx = (img.at(r0, c0 + 1) + img.at(r0, c0 - 1) - v2) * SECOND; + let dyy = (img.at(r0 + 1, c0) + img.at(r0 - 1, c0) - v2) * SECOND; + let dxy = (img.at(r0 + 1, c0 + 1) - img.at(r0 + 1, c0 - 1) - img.at(r0 - 1, c0 + 1) + + img.at(r0 - 1, c0 - 1)) + * CROSS; + let tr = dxx + dyy; + let det = dxx * dyy - dxy * dxy; + if det <= 0.0 || tr * tr * EDGE >= (EDGE + 1.0) * (EDGE + 1.0) * det { + return None; + } + let scale = (1u32 << octv) as f32; + Some(Keypoint { + x: (c0 as f32 + xc) * scale, + y: (r0 as f32 + xr) * scale, + size: SIGMA * libm::powf(2.0, (*layer as f32 + xi) / LAYERS as f32) * scale * 2.0, + angle: 0.0, + response: contr.abs(), + octave: octv as i32 + + ((*layer as i32) << 8) + + ((((xi as f64 + 0.5) * 255.0).round_ties_even() as i32) << 16), + }) +} + +/// `calcOrientationHist`: the smoothed gradient orientation histogram around (r, c); returns its +/// maximum. +fn orientation_hist( + img: &Plane, + (r, c): (usize, usize), + radius: i32, + sigma: f32, + hist: &mut [f32; ORI_BINS], +) -> f32 { + let n = ORI_BINS; + let expf_scale = -1.0 / (2.0 * sigma * sigma); + let mut temp = [0f32; ORI_BINS + 4]; + for i in -radius..=radius { + let y = r as i32 + i; + if y <= 0 || y >= img.h as i32 - 1 { + continue; + } + for j in -radius..=radius { + let x = c as i32 + j; + if x <= 0 || x >= img.w as i32 - 1 { + continue; + } + let (y, x) = (y as usize, x as usize); + let dx = img.at(y, x + 1) - img.at(y, x - 1); + let dy = img.at(y - 1, x) - img.at(y + 1, x); + let w = libm::expf((i * i + j * j) as f32 * expf_scale); + let ori = fast_atan2(dy, dx); + let mag = (dx * dx + dy * dy).sqrt(); + let mut bin = cv_round((n as f32 / 360.0) * ori); + if bin >= n as i32 { + bin -= n as i32; + } + if bin < 0 { + bin += n as i32; + } + temp[bin as usize + 2] += w * mag; + } + } + temp[1] = temp[n + 1]; + temp[0] = temp[n]; + temp[n + 2] = temp[2]; + temp[n + 3] = temp[3]; + let mut maxval = f32::MIN; + for (i, h) in hist.iter_mut().enumerate() { + let t = |k: usize| temp[i + k]; + *h = (t(0) + t(4)) * (1.0 / 16.0) + (t(1) + t(3)) * (4.0 / 16.0) + t(2) * (6.0 / 16.0); + maxval = maxval.max(*h); + } + maxval +} + +/// The keypoints of one DoG row: local extrema in the 3x3x3 neighborhood, refined, then one +/// keypoint per dominant orientation. +fn row_extrema(gpyr: &[Plane], dog: &[Plane], o: usize, i: usize, r: usize) -> Vec { + let threshold = (0.5 * CONTRAST as f64 / LAYERS as f64 * 255.0).floor() as f32; + let idx = o * (LAYERS + 2) + i; + let (img, prev, next) = (&dog[idx], &dog[idx - 1], &dog[idx + 1]); + let w = img.w; + let (cur, up, down) = (img.row(r), img.row(r - 1), img.row(r + 1)); + let nb = |p: &Plane, c: usize| { + let (a, b, d) = (p.row(r - 1), p.row(r), p.row(r + 1)); + [ + a[c - 1], + a[c], + a[c + 1], + b[c - 1], + b[c + 1], + d[c - 1], + d[c], + d[c + 1], + ] + }; + let mut out = Vec::new(); + for c in IMG_BORDER..w - IMG_BORDER { + let val = cur[c]; + if val.abs() <= threshold { + continue; + } + let ring = [ + up[c - 1], + up[c], + up[c + 1], + cur[c - 1], + cur[c + 1], + down[c - 1], + down[c], + down[c + 1], + ]; + let is_ext = if val > 0.0 { + let mx = |v: [f32; 8]| v.iter().fold(f32::MIN, |m, &x| m.max(x)); + val >= mx(ring) + && val >= mx(nb(prev, c)) + && val >= mx(nb(next, c)) + && val >= prev.at(r, c).max(next.at(r, c)) + } else { + let mn = |v: [f32; 8]| v.iter().fold(f32::MAX, |m, &x| m.min(x)); + val <= mn(ring) + && val <= mn(nb(prev, c)) + && val <= mn(nb(next, c)) + && val <= prev.at(r, c).min(next.at(r, c)) + }; + if !is_ext { + continue; + } + let (mut r1, mut c1, mut layer) = (r, c, i); + let Some(kpt) = adjust_local_extrema(dog, o, &mut layer, &mut r1, &mut c1) else { + continue; + }; + let scl_octv = kpt.size * 0.5 / (1u32 << o) as f32; + let mut hist = [0f32; ORI_BINS]; + let omax = orientation_hist( + &gpyr[o * (LAYERS + 3) + layer], + (r1, c1), + cv_round(ORI_RADIUS * scl_octv), + ORI_SIG_FCTR * scl_octv, + &mut hist, + ); + let mag_thr = omax * ORI_PEAK_RATIO; + let n = ORI_BINS; + for j in 0..n { + let l = if j > 0 { j - 1 } else { n - 1 }; + let r2 = if j < n - 1 { j + 1 } else { 0 }; + if hist[j] > hist[l] && hist[j] > hist[r2] && hist[j] >= mag_thr { + let mut bin = + j as f32 + 0.5 * (hist[l] - hist[r2]) / (hist[l] - 2.0 * hist[j] + hist[r2]); + bin = if bin < 0.0 { + n as f32 + bin + } else if bin >= n as f32 { + bin - n as f32 + } else { + bin + }; + let mut angle = 360.0 - (360.0 / n as f32) * bin; + if (angle - 360.0).abs() < f32::EPSILON { + angle = 0.0; + } + out.push(Keypoint { angle, ..kpt }); + } + } + } + out +} + +/// `calcSIFTDescriptor`: the 4x4x8 trilinear gradient histogram, clipped at 0.2 of its norm and +/// scaled to bytes. +fn descriptor(img: &Plane, (px, py): (f32, f32), ori: f32, scl: f32) -> [u8; DESCR_LEN] { + let (d, n) = (DESCR_WIDTH as i32, DESCR_BINS as i32); + let (pxi, pyi) = (cv_round(px), cv_round(py)); + let rad = ori * (std::f64::consts::PI / 180.0) as f32; + let bins_per_rad = n as f32 / 360.0; + let exp_scale = -1.0 / (d as f32 * d as f32 * 0.5); + let hist_width = DESCR_SCL_FCTR * scl; + let radius = cv_round(hist_width * std::f32::consts::SQRT_2 * (d + 1) as f32 * 0.5); + let diag = ((img.w as f64).powi(2) + (img.h as f64).powi(2)).sqrt() as i32; + let radius = radius.min(diag); + let cos_t = libm::cosf(rad) / hist_width; + let sin_t = libm::sinf(rad) / hist_width; + let (dp, np) = ((d + 2) as usize, (n + 2) as usize); + let mut hist = vec![0f32; dp * dp * np]; + let (rows, cols) = (img.h as i32, img.w as i32); + for i in -radius..=radius { + for j in -radius..=radius { + let c_rot = j as f32 * cos_t - i as f32 * sin_t; + let r_rot = j as f32 * sin_t + i as f32 * cos_t; + let rbin = r_rot + (d / 2) as f32 - 0.5; + let cbin = c_rot + (d / 2) as f32 - 0.5; + let (r, c) = (pyi + i, pxi + j); + if !(rbin > -1.0 + && rbin < d as f32 + && cbin > -1.0 + && cbin < d as f32 + && r > 0 + && r < rows - 1 + && c > 0 + && c < cols - 1) + { + continue; + } + let (ru, cu) = (r as usize, c as usize); + let dx = img.at(ru, cu + 1) - img.at(ru, cu - 1); + let dy = img.at(ru - 1, cu) - img.at(ru + 1, cu); + let w = libm::expf((c_rot * c_rot + r_rot * r_rot) * exp_scale); + let o = fast_atan2(dy, dx); + let mag = (dx * dx + dy * dy).sqrt() * w; + let obin = (o - ori) * bins_per_rad; + let (r0, c0, mut o0) = ( + rbin.floor() as i32, + cbin.floor() as i32, + obin.floor() as i32, + ); + let (rbin, cbin, obin) = (rbin - r0 as f32, cbin - c0 as f32, obin - o0 as f32); + if o0 < 0 { + o0 += n; + } + if o0 >= n { + o0 -= n; + } + let v_r1 = mag * rbin; + let v_r0 = mag - v_r1; + let v_rc11 = v_r1 * cbin; + let v_rc10 = v_r1 - v_rc11; + let v_rc01 = v_r0 * cbin; + let v_rc00 = v_r0 - v_rc01; + let v_rco111 = v_rc11 * obin; + let v_rco110 = v_rc11 - v_rco111; + let v_rco101 = v_rc10 * obin; + let v_rco100 = v_rc10 - v_rco101; + let v_rco011 = v_rc01 * obin; + let v_rco010 = v_rc01 - v_rco011; + let v_rco001 = v_rc00 * obin; + let v_rco000 = v_rc00 - v_rco001; + let idx = (((r0 + 1) as usize * dp) + (c0 + 1) as usize) * np + o0 as usize; + hist[idx] += v_rco000; + hist[idx + 1] += v_rco001; + hist[idx + np] += v_rco010; + hist[idx + np + 1] += v_rco011; + hist[idx + dp * np] += v_rco100; + hist[idx + dp * np + 1] += v_rco101; + hist[idx + (dp + 1) * np] += v_rco110; + hist[idx + (dp + 1) * np + 1] += v_rco111; + } + } + let (du, nu) = (d as usize, n as usize); + let mut raw = [0f32; DESCR_LEN]; + for i in 0..du { + for j in 0..du { + let idx = ((i + 1) * dp + (j + 1)) * np; + hist[idx] += hist[idx + nu]; + hist[idx + 1] += hist[idx + nu + 1]; + raw[(i * du + j) * nu..(i * du + j + 1) * nu].copy_from_slice(&hist[idx..idx + nu]); + } + } + let nrm2: f32 = raw.iter().map(|v| v * v).sum(); + let thr = nrm2.sqrt() * DESCR_MAG_THR; + let mut nrm2 = 0f32; + for v in raw.iter_mut() { + *v = v.min(thr); + nrm2 += *v * *v; + } + let nrm2 = INT_DESCR_FCTR / nrm2.sqrt().max(f32::EPSILON); + raw.map(|v| (v * nrm2).round_ties_even().clamp(0.0, 255.0) as u8) +} + +/// `KeypointGreater`, the order `removeDuplicatedSorted` sorts by. +fn keypoint_order(a: &Keypoint, b: &Keypoint) -> std::cmp::Ordering { + a.x.total_cmp(&b.x) + .then(a.y.total_cmp(&b.y)) + .then(b.size.total_cmp(&a.size)) + .then(a.angle.total_cmp(&b.angle)) + .then(b.response.total_cmp(&a.response)) + .then(b.octave.cmp(&a.octave)) +} + +/// `SIFT_create(nfeatures).detectAndCompute(gray, None)` on an 8-bit image: the keypoints and +/// their descriptors, row for row. `nfeatures` 0 keeps every keypoint. +pub fn detect_and_compute( + gray: &[u8], + h: usize, + w: usize, + nfeatures: usize, +) -> (Vec, Vec<[u8; DESCR_LEN]>) { + if h == 0 || w == 0 || gray.len() != h * w { + return (Vec::new(), Vec::new()); + } + let base = initial_image(gray, h, w); + let octaves = ((base.w.min(base.h) as f64).ln() / 2f64.ln() - 2.0).round_ties_even() as i32 + - FIRST_OCTAVE; + if octaves < 1 { + return (Vec::new(), Vec::new()); + } + let octaves = octaves as usize; + let gpyr = gaussian_pyramid(base, octaves); + let dog = dog_pyramid(&gpyr, octaves); + let mut kpts: Vec = Vec::new(); + for o in 0..octaves { + for i in 1..=LAYERS { + let rows = dog[o * (LAYERS + 2) + i].h; + if rows <= 2 * IMG_BORDER || dog[o * (LAYERS + 2) + i].w <= 2 * IMG_BORDER { + continue; + } + let found: Vec = (IMG_BORDER..rows - IMG_BORDER) + .into_par_iter() + .flat_map_iter(|r| row_extrema(&gpyr, &dog, o, i, r)) + .collect(); + kpts.extend(found); + } + } + kpts.sort_by(keypoint_order); + kpts.dedup_by(|b, a| a.x == b.x && a.y == b.y && a.size == b.size && a.angle == b.angle); + if nfeatures > 0 && kpts.len() > nfeatures { + // retainBest: the nfeatures strongest, and every keypoint tied with the last of them. + let mut by_response = kpts.clone(); + by_response.sort_by(|a, b| b.response.total_cmp(&a.response)); + let cut = by_response[nfeatures - 1].response; + kpts.retain(|k| k.response >= cut); + } + let scale = 1.0 / (1u32 << -FIRST_OCTAVE) as f32; + for k in kpts.iter_mut() { + k.octave = (k.octave & !255) | ((k.octave + FIRST_OCTAVE) & 255); + k.x *= scale; + k.y *= scale; + k.size *= scale; + } + let desc = kpts + .par_iter() + .map(|k| { + let mut octave = k.octave & 255; + let layer = ((k.octave >> 8) & 255) as usize; + if octave >= 128 { + octave |= -128; + } + let s = if octave >= 0 { + 1.0 / (1u32 << octave) as f32 + } else { + (1u32 << -octave) as f32 + }; + let img = &gpyr[(octave - FIRST_OCTAVE) as usize * (LAYERS + 3) + layer]; + let mut angle = 360.0 - k.angle; + if (angle - 360.0).abs() < f32::EPSILON { + angle = 0.0; + } + descriptor(img, (k.x * s, k.y * s), angle, k.size * s * 0.5) + }) + .collect(); + (kpts, desc) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blobs(h: usize, w: usize) -> Vec { + (0..h * w) + .map(|p| { + let (y, x) = ((p / w) as f32, (p % w) as f32); + let v = (x * 0.21).sin() * (y * 0.13).cos() + (x * 0.05 + y * 0.07).sin(); + (127.0 + 60.0 * v) as u8 + }) + .collect() + } + + #[test] + fn fast_atan2_matches_atan2() { + for k in 0..360 { + let t = (k as f32 + 0.5).to_radians(); + let (y, x) = (t.sin(), t.cos()); + let want = y.atan2(x).to_degrees().rem_euclid(360.0); + assert!((fast_atan2(y, x) - want).abs() < 0.02, "{k}"); + } + } + + #[test] + fn detects_keypoints_with_unit_norm_descriptors() { + let (h, w) = (160, 200); + let (kp, desc) = detect_and_compute(&blobs(h, w), h, w, 0); + assert!(kp.len() > 20); + assert_eq!(kp.len(), desc.len()); + for k in &kp { + assert!(k.x >= 0.0 && k.x < w as f32 && k.y >= 0.0 && k.y < h as f32); + } + for d in &desc { + let n: f32 = d.iter().map(|&v| (v as f32).powi(2)).sum::().sqrt(); + assert!((n - 512.0).abs() < 8.0, "{n}"); + } + } + + #[test] + fn nfeatures_keeps_the_strongest() { + let (h, w) = (160, 200); + let (all, _) = detect_and_compute(&blobs(h, w), h, w, 0); + let (best, desc) = detect_and_compute(&blobs(h, w), h, w, 10); + assert!(best.len() >= 10 && best.len() < all.len()); + assert_eq!(desc.len(), best.len()); + } +} diff --git a/tests/test_core_sift.py b/tests/test_core_sift.py new file mode 100644 index 000000000..e2840ee10 --- /dev/null +++ b/tests/test_core_sift.py @@ -0,0 +1,146 @@ +"""The quality gate of the SIFT registration port: negpy_core.register_pair against the true +transform of synthetic pairs and against cv2's SIFT + BFMatcher + estimateAffinePartial2D.""" + +import os +import pickle +from pathlib import Path + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.stitch import logic as L + +pytestmark = cv2_oracle + +ROOT = Path(__file__).resolve().parents[1] +_TIF = ROOT / "samples" / "panorama" / "Img428 - NX Studio Tiff.TIF" +_RECORDING = "features__stitch__logic.py--register_parts" +_CASES = [(1.5, 1.01, 0.2), (-2.8, 0.975, 0.25), (3.0, 1.03, 0.15), (-1.0, 0.99, 0.3)] + + +def _corpus_recordings(): + dirs = [Path(os.environ.get("NEGPY_CORPUS", Path.home() / "negpy-corpus")), Path.home() / "negpy-corpus-p5"] + return sorted(p for d in dirs for p in (d / _RECORDING).glob("*.pkl")) + + +def _synthetic(): + rng = np.random.default_rng(5) + base = cv2.GaussianBlur(rng.random((1100, 1600), dtype=np.float32), (0, 0), 2.0) + grad = np.linspace(0.0, 0.3, 1600, dtype=np.float32)[None, :] + mono = np.clip(0.1 + 0.6 * base + grad, 0.0, 1.0) + return np.stack([mono, mono * 0.9, mono * 1.1], axis=-1).clip(0.0, 1.0) + + +def _source(name): + if name == "synthetic": + return _synthetic() + if name == "preview": + recs = _corpus_recordings() + if not recs: + pytest.skip("no register_parts recording in the corpus") + return pickle.load(recs[0].open("rb"))["args"][0][0] + if not _TIF.exists(): + pytest.skip("panorama samples not present") + import tifffile + + return tifffile.imread(_TIF).astype(np.float32) / 65535.0 + + +def _similarity(angle, scale, tx, ty): + a = np.deg2rad(angle) + c, s = scale * np.cos(a), scale * np.sin(a) + return np.array([[c, -s, tx], [s, c, ty]]) + + +def _cut(src, angle, scale, shift): + """Part A at the top left of ``src``; part B moved along the long side by a known similarity. + Returns (A, B, t) with t mapping B coordinates to A coordinates.""" + big_h, big_w = src.shape[:2] + if big_w >= big_h: + h, w = int(big_h * 0.9), int(big_w * 0.62) + t = _similarity(angle, scale, shift * big_w, 0.02 * big_h) + else: + h, w = int(big_h * 0.62), int(big_w * 0.9) + t = _similarity(angle, scale, 0.02 * big_w, shift * big_h) + b = cv2.warpAffine(src, t, (w, h), flags=cv2.INTER_LINEAR | cv2.WARP_INVERSE_MAP) + return np.ascontiguousarray(src[:h, :w]), b, t + + +def _corner_error(m, t, w, h): + box = np.array([[0, 0], [w, 0], [w, h], [0, h]], np.float64) + return float(np.max(np.hypot(*((box @ m[:, :2].T + m[:, 2]) - (box @ t[:, :2].T + t[:, 2])).T))) + + +def _cv2_register(ref, mov): + sift = cv2.SIFT_create(nfeatures=6000) + ref_kp, ref_desc = sift.detectAndCompute(ref, None) + mov_kp, mov_desc = sift.detectAndCompute(mov, None) + pairs = cv2.BFMatcher().knnMatch(mov_desc, ref_desc, k=2) + good = [m for m, n in (p for p in pairs if len(p) == 2) if m.distance < 0.75 * n.distance] + mov_pts = np.float32([mov_kp[m.queryIdx].pt for m in good]) + ref_pts = np.float32([ref_kp[m.trainIdx].pt for m in good]) + matrix, mask = cv2.estimateAffinePartial2D(mov_pts, ref_pts, method=cv2.RANSAC, ransacReprojThreshold=3.0) + return matrix, int(mask.sum()) + + +@pytest.mark.parametrize("name", ["synthetic", "preview", "full"]) +def test_register_pair_recovers_a_known_similarity(name): + src = _source(name) + for angle, scale, shift in _CASES: + a, b, t = _cut(src, angle, scale, shift) + (pa, sa), (pb, sb) = L.build_proxy(a), L.build_proxy(b) + matrix, inliers = L.register_pair(pa, pb) + ref_matrix, ref_inliers = _cv2_register(pa, pb) + h, w = b.shape[:2] + err = _corner_error(L.scale_affine(matrix, sa, sb), t, w, h) + ref_err = _corner_error(L.scale_affine(ref_matrix, sa, sb), t, w, h) + print(f"{name} {angle:+.1f} deg x{scale}: corner error {err:.4f} px ({inliers} inliers), cv2 {ref_err:.4f} px ({ref_inliers})") + assert err < 0.1, (angle, scale, err, ref_err) + + +@pytest.mark.parametrize("name", ["synthetic", "preview", "full"]) +def test_keypoint_count_matches_cv2(name): + proxy, _ = L.build_proxy(_source(name)) + for nfeatures in (0, 6000): + want = len(cv2.SIFT_create(nfeatures=nfeatures).detect(proxy, None)) + got = negpy_core.sift_detect(proxy, nfeatures)[0].shape[0] + print(f"{name} nfeatures={nfeatures}: {got} keypoints, cv2 {want}") + assert abs(got - want) <= 0.1 * want + + +def test_descriptors_match_cv2(): + proxy, _ = L.build_proxy(_synthetic()) + kp, want = cv2.SIFT_create().detectAndCompute(proxy, None) + pts, got = negpy_core.sift_detect(proxy, 0) + by_pos = {} + for k, d in zip(kp, want.astype(np.int32)): + by_pos.setdefault((round(k.pt[0], 2), round(k.pt[1], 2)), []).append(d) + far = [np.full(128, 999, np.int32)] + diffs = np.array( + [ + min(np.abs(d - w).max() for w in by_pos.get((round(float(x), 2), round(float(y), 2)), far)) + for (x, y), d in zip(pts, got.astype(np.int32)) + ] + ) + print(f"descriptors within 2 of cv2's: {np.mean(diffs <= 2):.4f}, identical: {np.mean(diffs == 0):.4f}") + assert np.mean(diffs <= 2) >= 0.95 + + +def test_real_panorama_matches_cv2_recording(): + recs = _corpus_recordings() + if not recs: + pytest.skip("no register_parts recording in the corpus") + for path in recs: + rec = pickle.load(path.open("rb")) + parts = rec["args"][0] + want_transforms, want_canvas = rec["result"] + transforms, canvas = L.register_parts(parts) + assert canvas == tuple(want_canvas) + for part, m, want in zip(parts, transforms, want_transforms): + h, w = part.shape[:2] + err = _corner_error(m, want, w, h) + print(f"{path.name}: corner error against cv2 {err:.4f} px") + assert err < 0.25 From 7ab4e6e0dcad1eb49c2b91fc6ab17cd9e3463baa Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:32:14 +0200 Subject: [PATCH 104/133] perf(core): hair inpaint in Rust Port cv2.inpaint (INPAINT_NS) from OpenCV 4.13 to negpy_core::inpaint and move the tile loop of apply_hair_inpaint to Rust. The tiles of all components run in parallel, because their written pixels never overlap. Gate tests/test_core_inpaint.py: inside the mask a mean difference of at most 1 and a maximum of 4 in cv2's 8-bit values, identical bytes outside. Measured: 549 846 inpainted values on 14 recorded crops and synthetic tiles match cv2 bit for bit. The final image differs from the cv2 version by at most 1.2e-7. Speed, best of 3, all cores: 300 hairs on a 24 MP frame 817 ms -> 303 ms; three long hairs on a 13.5 MP frame 236 ms -> 171 ms. One single 1088 px tile alone is 29.6 ms against cv2's 27.6 ms. --- NOTICE.md | 3 +- negpy/features/retouch/logic.py | 75 ++------ negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 7 +- negpy_core/src/inpaint.rs | 296 ++++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 1 + negpy_core/src/py/retouch.rs | 64 +++++++ tests/test_core_inpaint.py | 156 +++++++++++++++++ 8 files changed, 536 insertions(+), 67 deletions(-) create mode 100644 negpy_core/src/inpaint.rs create mode 100644 tests/test_core_inpaint.py diff --git a/NOTICE.md b/NOTICE.md index d89ac4e95..89751d89d 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -69,5 +69,6 @@ negpy_core/Cargo.lock lists the exact versions. negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area resize, separable filter engine and bit-exact Gaussian kernel (including its -softfloat exp table) so their results match cv2 bit for bit. OpenCV, Apache +softfloat exp table) so their results match cv2 bit for bit. +negpy_core/src/inpaint.rs ports OpenCV's Navier-Stokes inpaint. OpenCV, Apache License 2.0. https://github.com/opencv/opencv diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 753162a8c..427f8cb59 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -209,9 +209,9 @@ # the inpaint follows. Detection-scale px. See is_hair in negpy_core/src/retouch.rs. _HAIR_MIN_AREA = 20 _HAIR_MIN_ELONG = 8.0 # area/thickness² ≈ length/thickness; round specks measure 1–3 -# cv2.inpaint fill: the dilate covers the PSF skirt at 1:1 (apply_hair_inpaint widens it to +# Inpaint fill: the dilate covers the PSF skirt at 1:1 (apply_hair_inpaint widens it to # track the mask's upsample), then the NS radius, then gamma to give the 8-bit encode a -# perceptual spread (cv2.inpaint is 8-bit only). Navier-Stokes propagates outward from the +# perceptual spread (the inpaint is 8-bit). Navier-Stokes propagates outward from the # mask boundary only, so each defect is filled in its own bbox + _HAIR_INPAINT_PAD. Same # pixels, without gamma-encoding the whole frame to serve a hairline. _HAIR_DILATE_PX = 1 @@ -999,21 +999,16 @@ def ir_bake_token(retouch, has_ir: bool) -> str: return f"|ir{int(retouch.ir_attenuation)}r{round(float(retouch.ir_threshold), 3)}" -def _hair_encode(crop, lo, inv_span, out_u8): - """``clip((crop - lo) / span, 0, 1) ** (1/γ)`` to 8-bit, for cv2.inpaint.""" - negpy_core.hair_encode(crop, lo, inv_span, np.float32(1.0 / _HAIR_INPAINT_GAMMA), out_u8) - - def apply_hair_inpaint( img: ImageBuffer, hair_masks: List[np.ndarray], radius: int = _HAIR_INPAINT_RADIUS, dilate_px: Optional[int] = None, ) -> ImageBuffer: - """Structure-following fill of long/twisted defects (``cv2.inpaint``, Navier–Stokes) + """Structure-following fill of long/twisted defects (Navier–Stokes inpaint, ``negpy_core::inpaint``) baked into the linear source. Each detection-scale mask is upsampled to the buffer, unioned and dilated to cover the PSF skirt; only masked pixels are overwritten (the - rest stay byte-identical — the 8-bit encode cv2.inpaint requires touches only the + rest stay byte-identical — the 8-bit encode of the inpaint touches only the fabricated hairline). Returns a new array (buffers are read-only).""" h, w = img.shape[:2] masks = [hm for hm in hair_masks if hm is not None] @@ -1032,61 +1027,15 @@ def apply_hair_inpaint( return img if dilate_px > 0: k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate_px + 1, 2 * dilate_px + 1)) - m = cv2.dilate(m, k) + m = negpy_core.morphology_ex(m, cv2.MORPH_DILATE, k) src = np.ascontiguousarray(img, dtype=np.float32) - out = src.copy() - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) - for i in range(1, n_lbl): - bx = int(stats[i, cv2.CC_STAT_LEFT]) - by = int(stats[i, cv2.CC_STAT_TOP]) - x0, y0 = max(0, bx - _HAIR_INPAINT_PAD), max(0, by - _HAIR_INPAINT_PAD) - x1 = min(w, bx + int(stats[i, cv2.CC_STAT_WIDTH]) + _HAIR_INPAINT_PAD) - y1 = min(h, by + int(stats[i, cv2.CC_STAT_HEIGHT]) + _HAIR_INPAINT_PAD) - # Mask the whole crop, not just this component: a neighbour reaching into the bbox - # must stay unknown, or it becomes clone source and its dust is filled back in. - sub_m = np.ascontiguousarray(m[y0:y1, x0:x1]) - crop = np.ascontiguousarray(src[y0:y1, x0:x1]) - # Encode against the crop's clean range: clip(0,1) posterizes fills in dark regions. - # A hair can join enough specks that one component spans the frame, so the range is - # read off a strided sample of the clean pixels rather than all of them. - step = max(1, int(math.isqrt(max(1, crop.shape[0] * crop.shape[1] // _HAIR_RANGE_SAMPLE_PX)))) - ctx = crop[::step, ::step][sub_m[::step, ::step] == 0] - lo = float(np.percentile(ctx, 0.5)) if ctx.size else 0.0 - hi = float(np.percentile(ctx, 99.5)) if ctx.size else 1.0 - span = max(hi - lo, 1e-4) - # The fill is 8-bit, so its decode is a 256-entry table. - dec_lut = ((np.arange(256, dtype=np.float32) / 255.0) ** _HAIR_INPAINT_GAMMA) * span + lo - ch, cw = crop.shape[:2] - # The inpaint walks the whole image it is handed, and a hair joining specks can make - # this crop the frame: tile it, skip tiles without this component, keep a halo so - # the fill at a tile's core sees the same neighbourhood. - for ty0 in range(0, ch, _HAIR_TILE_PX): - for tx0 in range(0, cw, _HAIR_TILE_PX): - ty1, tx1 = min(ch, ty0 + _HAIR_TILE_PX), min(cw, tx0 + _HAIR_TILE_PX) - if not (labels[y0 + ty0 : y0 + ty1, x0 + tx0 : x0 + tx1] == i).any(): - continue - ay0, ay1 = max(0, ty0 - _HAIR_TILE_HALO), min(ch, ty1 + _HAIR_TILE_HALO) - ax0, ax1 = max(0, tx0 - _HAIR_TILE_HALO), min(cw, tx1 + _HAIR_TILE_HALO) - tile = np.ascontiguousarray(crop[ay0:ay1, ax0:ax1]) - enc = np.empty(tile.shape, dtype=np.uint8) - _hair_encode(tile, np.float32(lo), np.float32(1.0 / span), enc) - filled = cv2.inpaint(enc, np.ascontiguousarray(sub_m[ay0:ay1, ax0:ax1]), radius, cv2.INPAINT_NS) - # ...but keep only this component, alpha-feathered across the dilate band: full - # fill on the detected defect, ramp over the skirt. A neighbour clipped by the - # bbox fills badly here and gets its own padded crop anyway. dilate_px=0 means - # no feather. Only the tile's core is written; the halo belongs to its neighbours. - mb = labels[y0 + ay0 : y0 + ay1, x0 + ax0 : x0 + ax1] == i - d = cv2.distanceTransform(mb.astype(np.uint8), cv2.DIST_C, 3) - core = np.zeros(mb.shape, dtype=bool) - core[ty0 - ay0 : ty1 - ay0, tx0 - ax0 : tx1 - ax0] = True - sel = np.flatnonzero(mb & core) - a = np.minimum(d.ravel()[sel] / float(dilate_px + 1), 1.0)[:, None] - # Written flat into the frame: the crop view is not contiguous, so a reshape - # of it would write to a copy. - dec = dec_lut[filled.reshape(-1, 3)[sel]] - tile_px = tile.reshape(-1, 3)[sel] - ry, rx = np.divmod(sel, ax1 - ax0) - out.reshape(-1, 3)[(y0 + ay0 + ry) * w + (x0 + ax0 + rx)] = tile_px * (1.0 - a) + dec * a + # Each component fills in its padded box, tiled with a halo; a neighbour reaching into the + # box stays unknown, or it becomes clone source and its dust is filled back in. + out = negpy_core.hair_fill( + src, + m, + (float(radius), dilate_px, _HAIR_INPAINT_PAD, _HAIR_TILE_PX, _HAIR_TILE_HALO, _HAIR_RANGE_SAMPLE_PX, _HAIR_INPAINT_GAMMA), + ) # Navier-Stokes propagates a smooth field, so the fill lands grainless. See # apply_score_repair, which fills the same kind of hole by a different route. negpy_core.add_clean_grain(out, np.ascontiguousarray(src, dtype=np.float32), m == 0, max(1.0, factor)) diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 2d918f299..6ae0fb78b 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -28,3 +28,4 @@ tolerance listed for each entry. | `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | | `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | | `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | +| `features/retouch/logic.py::apply_hair_inpaint` | `cv2.inpaint` (Navier-Stokes) is `negpy_core::inpaint::inpaint_ns`, and the tiles run in parallel. The decode table takes `mathf::pow_f64`, not NumPy's float32 power. OpenCV reads past the image on a one-pixel-wide tile; negpy_core clamps there. Gate `tests/test_core_inpaint.py`: inside the mask a mean difference of at most 1 and a maximum of 4 in cv2's 8-bit values, identical bytes outside. Measured: 549 846 inpainted values on 14 recorded crops and synthetic tiles match cv2 bit for bit; the final image differs by at most 1.2e-7. | quality | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 819f7e3ac..d096c328b 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2485,9 +2485,10 @@ depends = [] ["features/retouch/logic.py::apply_hair_inpaint"] phase = 9 -status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.dilate", "cv2.distanceTransform", "cv2.getStructuringElement", "cv2.inpaint", "negpy_core.add_clean_grain", "negpy_core.resize_linear", "np.percentile"] -depends = ["_hair_encode"] +status = "ported" +compare = "quality" +calls = ["cv2.getStructuringElement", "negpy_core.add_clean_grain", "negpy_core.hair_fill", "negpy_core.morphology_ex", "negpy_core.resize_linear"] +depends = [] ["features/rgbscan/logic.py::estimate_shift"] phase = 9 diff --git a/negpy_core/src/inpaint.rs b/negpy_core/src/inpaint.rs new file mode 100644 index 000000000..f9a66206e --- /dev/null +++ b/negpy_core/src/inpaint.rs @@ -0,0 +1,296 @@ +//! `cv2.inpaint(..., cv2.INPAINT_NS)` of an 8-bit 3-channel image (OpenCV 4.13 +//! `modules/photo/src/inpaint.cpp`, Apache-2.0), and the hair fill of +//! negpy/features/retouch/logic.py that runs it tile by tile. + +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +use rayon::prelude::*; + +const KNOWN: u8 = 0; +const BAND: u8 = 1; +const INSIDE: u8 = 2; + +/// OpenCV's `FastMarching_solve`, in float64. +fn solve(f: &[u8], t: &[f32], a: usize, b: usize) -> f32 { + let (a11, a22) = (t[a] as f64, t[b] as f64); + let m12 = a11.min(a22); + let sol = match (f[a] != INSIDE, f[b] != INSIDE) { + (true, true) if (a11 - a22).abs() >= 1.0 => 1.0 + m12, + (true, true) => (a11 + a22 + (2.0 - (a11 - a22) * (a11 - a22)).sqrt()) * 0.5, + (true, false) => 1.0 + a11, + (false, true) => 1.0 + a22, + (false, false) => 1.0 + m12, + }; + sol as f32 +} + +/// `cv2.inpaint(img, mask, radius, cv2.INPAINT_NS)` of an HxWx3 uint8 image, in place: the +/// fast-marching order and the float32 weights of OpenCV, ties popped in insertion order. +pub fn inpaint_ns(img: &mut [u8], mask: &[u8], (h, w): (usize, usize), radius: f64) { + let range = (radius.round_ties_even() as i64).clamp(1, 100) as isize; + let (rows, cols) = (h + 2, w + 2); + let mut f = vec![KNOWN; rows * cols]; + for y in 0..h { + for x in 0..w { + if mask[y * w + x] != 0 { + f[(y + 1) * cols + x + 1] = INSIDE; + } + } + } + let mut t = vec![1.0e6f32; rows * cols]; + let mut heap = BinaryHeap::new(); + let mut order = 0u64; + for y in 1..rows - 1 { + for x in 1..cols - 1 { + let p = y * cols + x; + let cross = [p - cols, p - 1, p + 1, p + cols]; + if f[p] == KNOWN && cross.iter().any(|&q| f[q] == INSIDE) { + heap.push(Reverse((0u32, order, p))); + order += 1; + } + } + } + for &Reverse((_, _, p)) in heap.iter() { + f[p] = BAND; + t[p] = 0.0; + } + let (ri, ci) = (rows as isize, cols as isize); + while let Some(Reverse((_, _, p))) = heap.pop() { + f[p] = KNOWN; + let (ii, jj) = ((p / cols) as isize, (p % cols) as isize); + for (i, j) in [(ii - 1, jj), (ii, jj - 1), (ii + 1, jj), (ii, jj + 1)] { + if i <= 0 || j <= 0 || i > ri - 1 || j > ci - 1 { + continue; + } + let q = (i * ci + j) as usize; + if f[q] != INSIDE { + continue; + } + let (up, dn, lf, rt) = (q - cols, q + cols, q - 1, q + 1); + let dist = solve(&f, &t, up, lf) + .min(solve(&f, &t, dn, lf)) + .min(solve(&f, &t, up, rt).min(solve(&f, &t, dn, rt))); + t[q] = dist; + let mut ia = [0f32; 3]; + let mut s = [1.0e-20f32; 3]; + for k in i - range..=i + range { + if k <= 0 || k >= ri - 1 { + continue; + } + let km = k - 1 + (k == 1) as isize; + let kp = k - 1 - (k == ri - 2) as isize; + for l in j - range..=j + range { + if l <= 0 || l >= ci - 1 { + continue; + } + let kl = (k * ci + l) as usize; + if f[kl] == INSIDE || (l - j) * (l - j) + (k - i) * (k - i) > range * range { + continue; + } + let lm = l - 1 + (l == 1) as isize; + let lp = l - 1 - (l == ci - 2) as isize; + let (ry, rx) = ((k - i) as f32, (l - j) as f32); + let rl = rx * rx + ry * ry; + let dst = 1.0 / (rl * rl + 1.0); + let (dn_k, up_k) = (f[kl + cols] != INSIDE, f[kl - cols] != INSIDE); + let (rt_l, lf_l) = (f[kl + 1] != INSIDE, f[kl - 1] != INSIDE); + // Clamped: on a one-pixel-wide image OpenCV reads out of bounds here. + let at = |y: isize, x: isize| { + 3 * (y.clamp(0, h as isize - 1) as usize * w + + x.clamp(0, w as isize - 1) as usize) + }; + let (below, mid, above) = (at(kp + 1, lm), at(kp, lm), at(km - 1, lm)); + let (right, centre, left) = (at(km, lp + 1), at(km, lm), at(km, lm - 1)); + let here = at(k - 1, l - 1); + for c in 0..3 { + let v = |o: usize| img[o + c] as i32; + let gx = match (dn_k, up_k) { + (true, true) => { + ((v(below) - v(mid)).abs() + (v(mid) - v(above)).abs()) as f32 + } + (true, false) => (v(below) - v(mid)).abs() as f32 * 2.0, + (false, true) => (v(mid) - v(above)).abs() as f32 * 2.0, + (false, false) => 0.0, + }; + let gy = match (rt_l, lf_l) { + (true, true) => { + ((v(right) - v(centre)).abs() + (v(centre) - v(left)).abs()) as f32 + } + (true, false) => (v(right) - v(centre)).abs() as f32 * 2.0, + (false, true) => (v(centre) - v(left)).abs() as f32 * 2.0, + (false, false) => 0.0, + }; + let gx = -gx; + let dot = rx * gx + ry * gy; + let dir = if (dot as f64).abs() <= 0.01 { + 0.000001f32 + } else { + (dot / (rl * (gx * gx + gy * gy)).sqrt()).abs() + }; + let wgt = dst * dir; + ia[c] += wgt * v(here) as f32; + s[c] += wgt; + } + } + } + let o = ((i - 1) as usize * w + (j - 1) as usize) * 3; + for c in 0..3 { + img[o + c] = (ia[c] as f64 / s[c] as f64) + .round_ties_even() + .clamp(0.0, 255.0) as u8; + } + f[q] = BAND; + heap.push(Reverse((dist.to_bits(), order, q))); + order += 1; + } + } +} + +/// The constants of `apply_hair_inpaint`. +pub struct HairFill { + pub radius: f64, + pub dilate_px: usize, + pub pad: usize, + pub tile: usize, + pub halo: usize, + pub sample_px: usize, + pub gamma: f64, +} + +/// One tile of one component: the frame rectangle the inpaint sees and the core it writes. +struct Job { + label: i32, + lo: f32, + inv_span: f32, + lut: [f32; 256], + around: [usize; 4], + core: [usize; 4], +} + +/// `apply_hair_inpaint` of an HxWx3 image and its dilated hair mask, up to the clean grain: +/// each 8-connected component is filled in its padded box, encoded against the box's clean +/// range, in tiles with a halo, and feathered over the dilate band. Writes only the filled +/// pixels of `out`; tiles run in parallel, as their written pixels never overlap. +pub fn hair_fill(src: &[f32], (h, w): (usize, usize), m: &[u8], c: &HairFill, out: &mut [f32]) { + let comps = crate::components::connected_components(m, h, w, 8); + let labels = &comps.labels; + let jobs: Vec = (1..comps.count) + .into_par_iter() + .flat_map_iter(|i| component_jobs(src, (h, w), m, labels, i as i32, comps.stats[i], c)) + .collect(); + let writes: Vec> = jobs + .par_iter() + .map(|j| fill_tile(src, (h, w), m, labels, j, c)) + .collect(); + for (p, v) in writes.into_iter().flatten() { + out[3 * p..3 * p + 3].copy_from_slice(&v); + } +} + +fn component_jobs( + src: &[f32], + (h, w): (usize, usize), + m: &[u8], + labels: &[i32], + label: i32, + [bx, by, bw, bh, _]: [i32; 5], + c: &HairFill, +) -> Vec { + let (bx, by, bw, bh) = (bx as usize, by as usize, bw as usize, bh as usize); + let (x0, y0) = (bx.saturating_sub(c.pad), by.saturating_sub(c.pad)); + let (x1, y1) = (w.min(bx + bw + c.pad), h.min(by + bh + c.pad)); + let (ch, cw) = (y1 - y0, x1 - x0); + // A hair can join enough specks to span the frame: the range comes off a strided sample. + let step = ((ch * cw / c.sample_px).max(1)).isqrt().max(1); + let mut ctx = Vec::new(); + for y in (y0..y1).step_by(step) { + for x in (x0..x1).step_by(step) { + if m[y * w + x] == 0 { + ctx.extend_from_slice(&src[3 * (y * w + x)..3 * (y * w + x) + 3]); + } + } + } + let (lo, hi) = match crate::stats::percentiles(&ctx, &[0.5, 99.5]) { + Some(p) => (p[0] as f64, p[1] as f64), + None => (0.0, 1.0), + }; + let span = (hi - lo).max(1e-4); + let lut = std::array::from_fn(|v| { + let e = crate::mathf::pow_f64((v as f32 / 255.0) as f64, c.gamma as f32 as f64) as f32; + e * span as f32 + lo as f32 + }); + let (nty, ntx) = (ch.div_ceil(c.tile), cw.div_ceil(c.tile)); + let mut used = vec![false; nty * ntx]; + for y in by..by + bh { + for x in bx..bx + bw { + if labels[y * w + x] == label { + used[(y - y0) / c.tile * ntx + (x - x0) / c.tile] = true; + } + } + } + let mut jobs = Vec::new(); + for (t, _) in used.iter().enumerate().filter(|(_, u)| **u) { + let (ty0, tx0) = (y0 + t / ntx * c.tile, x0 + t % ntx * c.tile); + let (ty1, tx1) = (y1.min(ty0 + c.tile), x1.min(tx0 + c.tile)); + jobs.push(Job { + label, + lo: lo as f32, + inv_span: (1.0 / span) as f32, + lut, + around: [ + y0.max(ty0.saturating_sub(c.halo)), + y1.min(ty1 + c.halo), + x0.max(tx0.saturating_sub(c.halo)), + x1.min(tx1 + c.halo), + ], + core: [ty0, ty1, tx0, tx1], + }); + } + jobs +} + +fn fill_tile( + src: &[f32], + (_, w): (usize, usize), + m: &[u8], + labels: &[i32], + j: &Job, + c: &HairFill, +) -> Vec<(usize, [f32; 3])> { + let [ay0, ay1, ax0, ax1] = j.around; + let (th, tw) = (ay1 - ay0, ax1 - ax0); + let mut tile = Vec::with_capacity(3 * th * tw); + let mut sub = Vec::with_capacity(th * tw); + let mut mine = Vec::with_capacity(th * tw); + for y in ay0..ay1 { + tile.extend_from_slice(&src[3 * (y * w + ax0)..3 * (y * w + ax1)]); + sub.extend_from_slice(&m[y * w + ax0..y * w + ax1]); + mine.extend( + labels[y * w + ax0..y * w + ax1] + .iter() + .map(|&l| (l == j.label) as u8), + ); + } + let mut enc = vec![0u8; 3 * th * tw]; + crate::retouch::hair_encode(&tile, 3, j.lo, j.inv_span, (1.0 / c.gamma) as f32, &mut enc); + inpaint_ns(&mut enc, &sub, (th, tw), c.radius); + let d = crate::distance::distance_transform(&mine, th, tw, crate::distance::Metric::Chessboard); + let feather = (c.dilate_px + 1) as f32; + let [ty0, ty1, tx0, tx1] = j.core; + let mut writes = Vec::new(); + for y in ty0..ty1 { + for x in tx0..tx1 { + let p = (y - ay0) * tw + x - ax0; + if mine[p] == 0 { + continue; + } + let a = (d[p] / feather).min(1.0); + let v = std::array::from_fn(|k| { + tile[3 * p + k] * (1.0 - a) + j.lut[enc[3 * p + k] as usize] * a + }); + writes.push((y * w + x, v)); + } + } + writes +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index c41a81be9..978f61ee4 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -14,6 +14,7 @@ pub mod flatfield; pub mod gray; pub mod hdr; pub mod histogram; +pub mod inpaint; pub mod lab; pub mod lens; pub mod mathf; diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs index 52f052dc6..10021afff 100644 --- a/negpy_core/src/py/retouch.rs +++ b/negpy_core/src/py/retouch.rs @@ -751,7 +751,71 @@ fn ir_gain<'py>( }) } +/// `cv2.inpaint(img, mask, radius, cv2.INPAINT_NS)` of an HxWx3 uint8 image. +#[pyfunction] +fn inpaint_ns<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, + mask: PyReadonlyArrayDyn<'py, u8>, + radius: f64, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + if mask.shape() != [h, w] { + return Err(PyValueError::new_err("needs an HxW mask")); + } + let (src, m) = match (img.as_slice(), mask.as_slice()) { + (Ok(s), Ok(m)) => (s, m), + _ => return Err(PyValueError::new_err("needs contiguous arrays")), + }; + let out = super::detach(py, src.len(), || { + let mut out = src.to_vec(); + crate::inpaint::inpaint_ns(&mut out, m, (h, w), radius); + out + }); + out.into_pyarray(py).reshape(vec![h, w, 3]) +} + +/// `apply_hair_inpaint` of an HxWx3 image and its dilated hair mask, before the clean grain, with +/// (radius, dilate px, pad, tile, halo, range sample px, gamma). +#[pyfunction] +fn hair_fill<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + mask: PyReadonlyArrayDyn<'py, u8>, + constants: (f64, usize, usize, usize, usize, usize, f64), +) -> PyResult>> { + let (src, dims) = image3(&img)?; + let m = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous mask"))?; + let (radius, dilate_px, pad, tile, halo, sample_px, gamma) = constants; + if m.len() != dims.0 * dims.1 || tile == 0 || sample_px == 0 { + return Err(PyValueError::new_err( + "needs an HxW mask and nonzero tile and sample", + )); + } + let c = crate::inpaint::HairFill { + radius, + dilate_px, + pad, + tile, + halo, + sample_px, + gamma, + }; + new_image(py, img.shape(), |dst| { + dst.copy_from_slice(src); + crate::inpaint::hair_fill(src, dims, m, &c, dst); + Some(()) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(inpaint_ns, m)?)?; + m.add_function(wrap_pyfunction!(hair_fill, m)?)?; m.add_function(wrap_pyfunction!(ir_vis_log, m)?)?; m.add_function(wrap_pyfunction!(ir_gain, m)?)?; m.add_function(wrap_pyfunction!(release_score, m)?)?; diff --git a/tests/test_core_inpaint.py b/tests/test_core_inpaint.py new file mode 100644 index 000000000..cfffdc58f --- /dev/null +++ b/tests/test_core_inpaint.py @@ -0,0 +1,156 @@ +import math +import pickle +from pathlib import Path + +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.retouch import logic +from negpy.features.retouch.logic import apply_hair_inpaint + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(23) +_CORPUS = Path("~/negpy-corpus/features__retouch__logic.py--_hair_encode").expanduser() + + +def _corpus_crops(): + """Real scan crops from the recorded hair encodes; empty when the corpus is absent.""" + return [pickle.loads(p.read_bytes())["args"][:3] for p in sorted(_CORPUS.glob("*.pkl"))] + + +def _textured(h, w): + img = cv2.GaussianBlur(_RNG.random((h, w, 3), dtype=np.float32), (0, 0), 2.5) + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + img += (0.3 * np.sin(xx / 9.0) * np.cos(yy / 13.0))[..., None] + return np.ascontiguousarray((img - img.min()) / (img.max() - img.min()), dtype=np.float32) + + +def _encode(crop, lo, inv_span): + enc = np.empty(crop.shape, np.uint8) + negpy_core.hair_encode(np.ascontiguousarray(crop), np.float32(lo), np.float32(inv_span), np.float32(1 / 2.2), enc) + return enc + + +def _strokes(h, w, n, width): + m = np.zeros((h, w), np.uint8) + for _ in range(n): + pts = np.cumsum(_RNG.normal(0, max(2.0, min(h, w) / 12), (40, 2)), 0) + (_RNG.random(2) * (w, h)) + cv2.polylines(m, [pts.astype(np.int32)], False, 1, width) + return m + + +def _masks(h, w): + return [ + (_RNG.random((h, w)) < 0.15).astype(np.uint8), + _strokes(h, w, 3, 1), + _strokes(h, w, 2, 4), + cv2.dilate(_strokes(h, w, 1, 2), np.ones((3, 3), np.uint8)), + ] + + +def _gate(want, got, mask): + d = np.abs(want.astype(np.int16) - got.astype(np.int16)) + assert np.array_equal(want[mask == 0], got[mask == 0]) + if mask.any(): + inside = d[mask > 0] + assert inside.mean() <= 1.0 and inside.max() <= 4, (inside.mean(), inside.max()) + + +def _tiles(): + tiles = [_encode(crop, lo, inv) for crop, lo, inv in _corpus_crops()] + tiles += [_encode(_textured(h, w), 0.0, 1.0) for h, w in ((64, 80), (2, 2), (5, 300), (257, 190))] + return tiles + + +@pytest.mark.parametrize("radius", [1, 3, 7]) +def test_inpaint_ns_matches_cv2(radius): + # A one-pixel-wide image is left out: OpenCV reads past the image there. + for enc in _tiles(): + h, w = enc.shape[:2] + for mask in _masks(h, w): + want = cv2.inpaint(enc, mask, radius, cv2.INPAINT_NS) + _gate(want, negpy_core.inpaint_ns(enc, mask, float(radius)), mask) + + +def test_inpaint_ns_without_mask_is_identity(): + enc = _encode(_textured(30, 40), 0.0, 1.0) + assert np.array_equal(negpy_core.inpaint_ns(enc, np.zeros((30, 40), np.uint8), 3.0), enc) + + +def _hair_inpaint_cv2(img, hair_masks, radius=logic._HAIR_INPAINT_RADIUS, dilate_px=None): + """`apply_hair_inpaint` as it ran on cv2.inpaint.""" + h, w = img.shape[:2] + masks = [hm for hm in hair_masks if hm is not None] + factor = max(1.0, h / masks[0].shape[0], w / masks[0].shape[1]) + if dilate_px is None: + dilate_px = max(logic._HAIR_DILATE_PX, round(factor)) + m = np.zeros((h, w), dtype=np.uint8) + for hm in masks: + r = hm if hm.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(hm, dtype=np.float32), (w, h)) + m |= (np.asarray(r) > 0.5).astype(np.uint8) + if dilate_px > 0: + m = cv2.dilate(m, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate_px + 1, 2 * dilate_px + 1))) + src = np.ascontiguousarray(img, dtype=np.float32) + out = src.copy() + pad, tile_px, halo = logic._HAIR_INPAINT_PAD, logic._HAIR_TILE_PX, logic._HAIR_TILE_HALO + n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) + for i in range(1, n_lbl): + bx, by, bw, bh = (int(v) for v in stats[i, :4]) + x0, y0 = max(0, bx - pad), max(0, by - pad) + x1, y1 = min(w, bx + bw + pad), min(h, by + bh + pad) + sub_m = np.ascontiguousarray(m[y0:y1, x0:x1]) + crop = np.ascontiguousarray(src[y0:y1, x0:x1]) + step = max(1, int(math.isqrt(max(1, crop.shape[0] * crop.shape[1] // logic._HAIR_RANGE_SAMPLE_PX)))) + ctx = crop[::step, ::step][sub_m[::step, ::step] == 0] + lo = float(np.percentile(ctx, 0.5)) if ctx.size else 0.0 + hi = float(np.percentile(ctx, 99.5)) if ctx.size else 1.0 + span = max(hi - lo, 1e-4) + dec_lut = ((np.arange(256, dtype=np.float32) / 255.0) ** logic._HAIR_INPAINT_GAMMA) * span + lo + ch, cw = crop.shape[:2] + for ty0 in range(0, ch, tile_px): + for tx0 in range(0, cw, tile_px): + ty1, tx1 = min(ch, ty0 + tile_px), min(cw, tx0 + tile_px) + if not (labels[y0 + ty0 : y0 + ty1, x0 + tx0 : x0 + tx1] == i).any(): + continue + ay0, ay1 = max(0, ty0 - halo), min(ch, ty1 + halo) + ax0, ax1 = max(0, tx0 - halo), min(cw, tx1 + halo) + tile = np.ascontiguousarray(crop[ay0:ay1, ax0:ax1]) + enc = _encode(tile, lo, 1.0 / span) + filled = cv2.inpaint(enc, np.ascontiguousarray(sub_m[ay0:ay1, ax0:ax1]), radius, cv2.INPAINT_NS) + mb = labels[y0 + ay0 : y0 + ay1, x0 + ax0 : x0 + ax1] == i + d = cv2.distanceTransform(mb.astype(np.uint8), cv2.DIST_C, 3) + core = np.zeros(mb.shape, dtype=bool) + core[ty0 - ay0 : ty1 - ay0, tx0 - ax0 : tx1 - ax0] = True + sel = np.flatnonzero(mb & core) + a = np.minimum(d.ravel()[sel] / float(dilate_px + 1), 1.0)[:, None] + dec = dec_lut[filled.reshape(-1, 3)[sel]] + tile_px_v = tile.reshape(-1, 3)[sel] + ry, rx = np.divmod(sel, ax1 - ax0) + out.reshape(-1, 3)[(y0 + ay0 + ry) * w + (x0 + ax0 + rx)] = tile_px_v * (1.0 - a) + dec * a + negpy_core.add_clean_grain(out, src, m == 0, max(1.0, factor)) + return out, m + + +def _frames(): + frames = [crop for crop, _, _ in _corpus_crops() if min(crop.shape[:2]) >= 8] + return frames + [_textured(220, 340), _textured(90, 2100)] + + +def test_hair_inpaint_matches_cv2_version(monkeypatch): + # A small tile makes the larger frames run the tiled path with its halo. + monkeypatch.setattr(logic, "_HAIR_TILE_PX", 96) + worst = 0.0 + for img in _frames(): + h, w = img.shape[:2] + for hair in (_strokes(h, w, 2, 1), _strokes(h // 2, w // 2, 3, 2)): + want, m = _hair_inpaint_cv2(img, [hair]) + got = np.asarray(apply_hair_inpaint(img, [hair])) + assert np.array_equal(want[m == 0], got[m == 0]) + # One 8-bit step of the encode, on the crop's range. + span = max(float(img.max() - img.min()), 1e-4) + worst = max(worst, float(np.abs(want - got).max()) / span) + assert worst <= 4 / 255, worst From 4362626d3f8b5c89f218b314978b3ef742e613f6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:45:23 +0200 Subject: [PATCH 105/133] perf(core): panorama seam finder in Rust Port cv2.detail_DpSeamFinder("COLOR") from OpenCV 4.13 to negpy_core::seam for parts on one canvas. The bad-region cost is the stitching module's squared norm (195075), as in OpenCV: the float32 path costs absorb small steps beside it, so a plain norm finds different seams. The flood fill, the contours, the step costs and the mask update run over rows in parallel; the cut itself stays sequential. Gate tests/test_core_seam.py: the color cost along the cut at most 1 % above cv2's, identical masks outside the overlap. Measured on synthetic overlaps of 2 to 4 parts (100 small random cuts included) and the recorded panorama: 0 % of overlap pixels differ from cv2. Speed, best of 5, all cores, recorded panorama at 1024 px: 18.9 ms -> 9.7 ms and 17.5 ms -> 9.9 ms. --- NOTICE.md | 3 +- negpy/features/stitch/logic.py | 10 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 5 +- negpy_core/src/lib.rs | 1 + negpy_core/src/py/stitch.rs | 27 ++ negpy_core/src/seam.rs | 695 +++++++++++++++++++++++++++++++++ tests/test_core_seam.py | 99 +++++ 8 files changed, 831 insertions(+), 10 deletions(-) create mode 100644 negpy_core/src/seam.rs create mode 100644 tests/test_core_seam.py diff --git a/NOTICE.md b/NOTICE.md index 89751d89d..36e56b7dd 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -70,5 +70,6 @@ negpy_core/Cargo.lock lists the exact versions. negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area resize, separable filter engine and bit-exact Gaussian kernel (including its softfloat exp table) so their results match cv2 bit for bit. -negpy_core/src/inpaint.rs ports OpenCV's Navier-Stokes inpaint. OpenCV, Apache +negpy_core/src/inpaint.rs ports OpenCV's Navier-Stokes inpaint, and +negpy_core/src/seam.rs its dynamic-programming seam finder. OpenCV, Apache License 2.0. https://github.com/opencv/opencv diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 0b5befea0..a922051a8 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -225,12 +225,8 @@ def find_seams(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray], canvas try: small = [_seam_cost_image(negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), size)) for img in warped] small_masks = [negpy_core.resize_nearest(m.astype(np.uint8), size) for m in masks] - corners = [(0, 0)] * len(small) - cut = cv2.detail_DpSeamFinder("COLOR").find(small, corners, [cv2.UMat(m) for m in small_masks]) - seams = [ - (negpy_core.resize_nearest(np.ascontiguousarray(cv2.UMat.get(m), dtype=np.uint8), (w, h)) > 0) & valid - for m, valid in zip(cut, masks) - ] + cut = negpy_core.dp_seams(small, small_masks) + seams = [(negpy_core.resize_nearest(m, (w, h)) > 0) & valid for m, valid in zip(cut, masks)] # Nearest-neighbour upscaling can leave a covered pixel assigned to no part, and # an unassigned pixel has no weight to normalize against. Give each one back to a # part that actually covers it. @@ -244,7 +240,7 @@ def find_seams(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray], canvas claim = orphan & mask seam |= claim orphan &= ~claim - except (cv2.error, MemoryError): + except MemoryError: return None return seams diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 6ae0fb78b..646c0cc76 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -29,3 +29,4 @@ tolerance listed for each entry. | `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | | `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | | `features/retouch/logic.py::apply_hair_inpaint` | `cv2.inpaint` (Navier-Stokes) is `negpy_core::inpaint::inpaint_ns`, and the tiles run in parallel. The decode table takes `mathf::pow_f64`, not NumPy's float32 power. OpenCV reads past the image on a one-pixel-wide tile; negpy_core clamps there. Gate `tests/test_core_inpaint.py`: inside the mask a mean difference of at most 1 and a maximum of 4 in cv2's 8-bit values, identical bytes outside. Measured: 549 846 inpainted values on 14 recorded crops and synthetic tiles match cv2 bit for bit; the final image differs by at most 1.2e-7. | quality | +| `features/stitch/logic.py::find_seams` | `cv2.detail_DpSeamFinder("COLOR")` is `negpy_core::seam::dp_seams`, for parts on one canvas only. The pairs run in reverse order of OpenCV's sort, which keeps equal pairs in order up to 16 pairs, that is 6 parts; OpenCV's introsort can reorder more. Gate `tests/test_core_seam.py`: the color cost along the cut at most 1 % above cv2's, identical masks outside the overlap. Measured on synthetic overlaps of 2 to 4 parts and the recorded panorama: 0 % of overlap pixels differ, so the cut costs are equal. | quality | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index d096c328b..b92043374 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2498,8 +2498,9 @@ depends = [] ["features/stitch/logic.py::find_seams"] phase = 9 -status = "todo" -calls = ["cv2.UMat", "cv2.detail_DpSeamFinder", "negpy_core.resize_area", "negpy_core.resize_nearest"] +status = "ported" +compare = "quality" +calls = ["negpy_core.dp_seams", "negpy_core.resize_area", "negpy_core.resize_nearest"] depends = [] ["features/stitch/logic.py::register_pair"] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 978f61ee4..21478677d 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -24,6 +24,7 @@ pub mod morph; pub mod print_curve; pub mod resize; pub mod retouch; +pub mod seam; pub mod sensor; pub mod shapes; pub mod skew; diff --git a/negpy_core/src/py/stitch.rs b/negpy_core/src/py/stitch.rs index 3dfbe4dcc..10830b0f0 100644 --- a/negpy_core/src/py/stitch.rs +++ b/negpy_core/src/py/stitch.rs @@ -45,6 +45,33 @@ fn compensation_gains( })) } +/// `cv2.detail_DpSeamFinder("COLOR").find(images, [(0, 0)] * n, masks)` of HxWx3 float32 parts +/// on one canvas: the cut masks, as new uint8 arrays. +#[pyfunction] +fn dp_seams<'py>( + py: Python<'py>, + images: Vec>, + masks: Vec>, +) -> PyResult>>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let Some(&[h, w, 3]) = images.first().map(|a| a.shape()) else { + return Err(PyValueError::new_err("needs HxWx3 parts")); + }; + if images.len() != masks.len() { + return Err(PyValueError::new_err("needs one mask per part")); + } + let imgs = slices(&images, 3 * h * w)?; + let mut cut: Vec> = slices(&masks, h * w)? + .into_iter() + .map(<[u8]>::to_vec) + .collect(); + super::detach(py, h * w, || crate::seam::dp_seams(&imgs, &mut cut, (h, w))); + cut.into_iter() + .map(|m| m.into_pyarray(py).reshape(vec![h, w])) + .collect() +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(dp_seams, m)?)?; m.add_function(wrap_pyfunction!(compensation_gains, m)?) } diff --git a/negpy_core/src/seam.rs b/negpy_core/src/seam.rs new file mode 100644 index 000000000..4c5151c52 --- /dev/null +++ b/negpy_core/src/seam.rs @@ -0,0 +1,695 @@ +//! `cv2.detail_DpSeamFinder("COLOR").find(...)` of float32 RGB parts that share one canvas +//! (corners all at the origin), after OpenCV 4.13 `modules/stitching/src/seam_finders.cpp` +//! (Apache-2.0). + +use std::collections::BTreeSet; + +use rayon::prelude::*; + +const FIRST: u8 = 1; +const SECOND: u8 = 2; +const INTERS: u8 = 4; + +/// (x, y) as OpenCV's `Point`. +type Pt = (i32, i32); + +/// The state of one pair: labels over the canvas and, per component, its state, bounding box +/// and contour. +struct Pair<'a> { + w: i32, + h: i32, + img1: &'a [f32], + img2: &'a [f32], + labels: Vec, + contour1: Vec, + contour2: Vec, + states: Vec, + tls: Vec, + brs: Vec, + contours: Vec>, + edges: BTreeSet<(i32, i32)>, +} + +/// `floodFill(img, seed, new_val)` with 4-connectivity and zero tolerance, a row span at a time. +fn flood_fill(img: &mut [i32], (w, h): (i32, i32), seed: Pt, new_val: i32) { + let old = img[(seed.1 * w + seed.0) as usize]; + if old == new_val { + return; + } + let mut stack = vec![seed]; + while let Some((x, y)) = stack.pop() { + let row = &mut img[(y * w) as usize..((y + 1) * w) as usize]; + if row[x as usize] != old { + continue; + } + let (mut x0, mut x1) = (x as usize, x as usize); + while x0 > 0 && row[x0 - 1] == old { + x0 -= 1; + } + while x1 + 1 < w as usize && row[x1 + 1] == old { + x1 += 1; + } + row[x0..=x1].fill(new_val); + for ny in [y - 1, y + 1] { + if ny < 0 || ny >= h { + continue; + } + let next = &img[(ny * w) as usize..((ny + 1) * w) as usize]; + let mut inside = false; + for (nx, &v) in next.iter().enumerate().take(x1 + 1).skip(x0) { + if v == old && !inside { + stack.push((nx as i32, ny)); + } + inside = v == old; + } + } + } +} + +/// The contour of a mask: set pixels with a 4-neighbour unset or off the canvas. +fn contour(mask: &[u8], w: i32, h: i32) -> Vec { + let at = |x: i32, y: i32| mask[(y * w + x) as usize] != 0; + let mut out = vec![false; (w * h) as usize]; + out.par_chunks_mut(w as usize) + .enumerate() + .for_each(|(y, row)| { + let y = y as i32; + for (x, o) in (0..w).zip(row.iter_mut()) { + *o = at(x, y) + && (x == 0 + || !at(x - 1, y) + || x == w - 1 + || !at(x + 1, y) + || y == 0 + || !at(x, y - 1) + || y == h - 1 + || !at(x, y + 1)); + } + }); + out +} + +/// The stitching module's `normL2(Point3f(255, 255, 255), Point3f(0, 0, 0))`, which is the +/// squared norm: the cost of a step outside the component. +fn bad_region_cost() -> f32 { + 3.0 * 255.0 * 255.0 +} + +fn diff(a: &[f32], pa: usize, b: &[f32], pb: usize) -> f32 { + let d = |c: usize| { + let v = a[3 * pa + c] - b[3 * pb + c]; + v * v + }; + d(0) + d(1) + d(2) +} + +/// OpenCV's `cv::partition` with `ClosePoints(min_dist)`: classes numbered in the order of their +/// first point. +fn partition(points: &[Pt], min_dist: i32) -> Vec { + let n = points.len(); + let mut label = vec![usize::MAX; n]; + let mut next = 0; + for i in 0..n { + if label[i] != usize::MAX { + continue; + } + label[i] = next; + let mut stack = vec![i]; + while let Some(a) = stack.pop() { + for b in 0..n { + let (dx, dy) = (points[a].0 - points[b].0, points[a].1 - points[b].1); + if label[b] == usize::MAX && dx * dx + dy * dy < min_dist * min_dist { + label[b] = next; + stack.push(b); + } + } + } + next += 1; + } + label +} + +/// `min_element` of OpenCV's (cost, control) steps that apply: the cheapest, then the lowest +/// control. +fn best_step(steps: &[(bool, f32, u8)]) -> Option<(f32, u8)> { + steps + .iter() + .filter(|s| s.0) + .map(|s| (s.1, s.2)) + .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))) +} + +fn round_div(sum: i32, n: usize) -> f64 { + (sum as f64 / n as f64).round_ties_even() +} + +impl<'a> Pair<'a> { + fn lab(&self, x: i32, y: i32) -> i32 { + self.labels[(y * self.w + x) as usize] + } + + fn on_contour(&self, x: i32, y: i32, l: i32) -> bool { + let (w, h) = (self.w, self.h); + x == 0 + || self.lab(x - 1, y) != l + || x == w - 1 + || self.lab(x + 1, y) != l + || y == 0 + || self.lab(x, y - 1) != l + || y == h - 1 + || self.lab(x, y + 1) != l + } + + fn touches(&self, x: i32, y: i32, l: i32) -> bool { + (x > 0 && self.lab(x - 1, y) == l) + || (y > 0 && self.lab(x, y - 1) == l) + || (x < self.w - 1 && self.lab(x + 1, y) == l) + || (y < self.h - 1 && self.lab(x, y + 1) == l) + } + + fn find_components(&mut self, m1: &[u8], m2: &[u8]) { + let (w, h) = (self.w, self.h); + self.labels = m1 + .iter() + .zip(m2) + .map(|(&a, &b)| match (a != 0, b != 0) { + (true, true) => i32::MAX, + (true, false) => i32::MAX - 1, + (false, true) => i32::MAX - 2, + _ => 0, + }) + .collect(); + let mut ncomps = 0; + for y in 0..h { + for x in 0..w { + let v = self.lab(x, y); + if v >= i32::MAX - 2 { + self.states.push(match v { + i32::MAX => INTERS, + v if v == i32::MAX - 1 => FIRST, + _ => SECOND, + }); + ncomps += 1; + flood_fill(&mut self.labels, (w, h), (x, y), ncomps); + } + } + } + let n = ncomps as usize; + (self.tls, self.brs) = (vec![(i32::MAX, i32::MAX); n], vec![(i32::MIN, i32::MIN); n]); + self.contours = vec![Vec::new(); n]; + for (l, x, y) in self.contour_points(0..h, 0..w, None) { + self.add_contour_point((l - 1) as usize, x, y); + } + } + + /// The contour pixels of every component (or of label `only`) in the box, in raster order; + /// a component's box is the box of its contour. + fn contour_points( + &self, + ys: std::ops::Range, + xs: std::ops::Range, + only: Option, + ) -> Vec<(i32, i32, i32)> { + let rows: Vec> = ys + .into_par_iter() + .map(|y| { + xs.clone() + .filter_map(|x| { + let l = self.lab(x, y); + let wanted = match only { + Some(o) => l == o, + None => l != 0, + }; + (wanted && self.on_contour(x, y, l)).then_some((l, x, y)) + }) + .collect() + }) + .collect(); + rows.concat() + } + + fn add_contour_point(&mut self, ci: usize, x: i32, y: i32) { + self.tls[ci] = (self.tls[ci].0.min(x), self.tls[ci].1.min(y)); + self.brs[ci] = (self.brs[ci].0.max(x + 1), self.brs[ci].1.max(y + 1)); + self.contours[ci].push((x, y)); + } + + fn find_edges(&mut self) { + for ci in 0..self.contours.len() { + let l = ci as i32 + 1; + for &(x, y) in &self.contours[ci] { + let mut near = [0; 4]; + if x > 0 { + near[0] = self.lab(x - 1, y); + } + if y > 0 { + near[1] = self.lab(x, y - 1); + } + if x < self.w - 1 { + near[2] = self.lab(x + 1, y); + } + if y < self.h - 1 { + near[3] = self.lab(x, y + 1); + } + for n in near { + if n != 0 && n != l { + self.edges.insert((ci as i32, n - 1)); + self.edges.insert((n - 1, ci as i32)); + } + } + } + } + } + + fn has_only_one_neighbor(&self, comp: i32) -> bool { + self.edges + .range((comp, i32::MIN)..=(comp, i32::MAX)) + .count() + == 1 + } + + fn close_to_contour(&self, x: i32, y: i32, mask: &[bool]) -> bool { + (-2..=2).any(|dy| { + let yy = y + dy; + yy >= 0 + && yy < self.h + && (-2..=2).any(|dx| { + let xx = x + dx; + xx >= 0 && xx < self.w && mask[(yy * self.w + xx) as usize] + }) + }) + } + + fn seam_tips(&self, comp1: usize, comp2: usize) -> Option<(Pt, Pt)> { + let l2 = comp2 as i32 + 1; + let special: Vec = self.contours[comp1] + .iter() + .copied() + .filter(|&(x, y)| { + self.close_to_contour(x, y, &self.contour1) + && self.close_to_contour(x, y, &self.contour2) + && self.touches(x, y, l2) + }) + .collect(); + if special.len() < 2 { + return None; + } + let labels = partition(&special, 10); + let n = labels.iter().max()? + 1; + if n < 2 { + return None; + } + let mut sum = vec![(0i32, 0i32); n]; + let mut points: Vec> = vec![Vec::new(); n]; + for (&p, &l) in special.iter().zip(&labels) { + sum[l] = (sum[l].0 + p.0, sum[l].1 + p.1); + points[l].push(p); + } + let centre = |i: usize| { + ( + round_div(sum[i].0, points[i].len()), + round_div(sum[i].1, points[i].len()), + ) + }; + let (mut idx, mut max_dist) = ([0, 0], f64::MIN); + for i in 0..n - 1 { + for j in i + 1..n { + let ((x1, y1), (x2, y2)) = (centre(i), centre(j)); + let d = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); + if d > max_dist { + max_dist = d; + idx = [i, j]; + } + } + } + let tip = |i: usize| { + let (cx, cy) = centre(i); + let mut best = (f64::MAX, points[i][0]); + for &p in &points[i] { + let d = + (p.0 as f64 - cx) * (p.0 as f64 - cx) + (p.1 as f64 - cy) * (p.1 as f64 - cy); + if d < best.0 { + best = (d, p); + } + } + best.1 + }; + Some((tip(idx[0]), tip(idx[1]))) + } + + /// The vertical and horizontal step costs over the component's box: the mean squared color + /// difference of the two parts across the step, or the bad-region cost. + fn costs(&self, comp: usize) -> (Vec, Vec) { + let l = comp as i32 + 1; + let ((x0, y0), (x1, y1)) = (self.tls[comp], self.brs[comp]); + let bad = bad_region_cost(); + let w = self.w; + let px = |x: i32, y: i32| (y * w + x) as usize; + let (img1, img2) = (self.img1, self.img2); + let step = |x: i32, y: i32, (xb, yb): Pt| { + (diff(img1, px(xb, yb), img2, px(x, y)) + diff(img1, px(x, y), img2, px(xb, yb))) / 2.0 + }; + let cost_v: Vec = (y0..y1) + .into_par_iter() + .flat_map_iter(|y| { + (x0..x1 + 1).map(move |x| { + if x > 0 && x < w && self.lab(x, y) == l && self.lab(x - 1, y) == l { + step(x, y, (x - 1, y)) + } else { + bad + } + }) + }) + .collect(); + let cost_h: Vec = (y0..y1 + 1) + .into_par_iter() + .flat_map_iter(|y| { + (x0..x1).map(move |x| { + if y > 0 && y < self.h && self.lab(x, y) == l && self.lab(x, y - 1) == l { + step(x, y, (x, y - 1)) + } else { + bad + } + }) + }) + .collect(); + (cost_v, cost_h) + } + + /// The minimum-cost path between the tips through the component, or None when the far tip + /// cannot be reached; and whether it runs horizontally. + fn estimate_seam(&self, comp: usize, p1: Pt, p2: Pt) -> Option<(Vec, bool)> { + let (cost_v, cost_h) = self.costs(comp); + let l = comp as i32 + 1; + let (tl, br) = (self.tls[comp], self.brs[comp]); + let (rw, rh) = (br.0 - tl.0, br.1 - tl.1); + let (mut src, mut dst) = ((p1.0 - tl.0, p1.1 - tl.1), (p2.0 - tl.0, p2.1 - tl.1)); + let horizontal = (dst.0 - src.0).abs() > (dst.1 - src.1).abs(); + let swapped = if horizontal { + src.0 > dst.0 + } else { + src.1 > dst.1 + }; + if swapped { + std::mem::swap(&mut src, &mut dst); + } + let n = (rw * rh) as usize; + let at = |x: i32, y: i32| (y * rw + x) as usize; + let cv = |x: i32, y: i32| cost_v[(y * (rw + 1) + x) as usize]; + let ch = |x: i32, y: i32| cost_h[(y * rw + x) as usize]; + let mut control = vec![0u8; n]; + let mut reachable = vec![false; n]; + let mut cost = vec![0f32; n]; + reachable[at(src.0, src.1)] = true; + if horizontal { + for x in src.0 + 1..=dst.0 { + for y in 0..rh { + if self.lab(x + tl.0, y + tl.1) != l { + continue; + } + let (r, c) = (&reachable, &cost); + let steps = [ + (r[at(x - 1, y)], c[at(x - 1, y)] + ch(x - 1, y), 1), + ( + y > 0 && r[at(x - 1, (y - 1).max(0))], + c[at(x - 1, (y - 1).max(0))] + + ch(x - 1, (y - 1).max(0)) + + cv(x, (y - 1).max(0)), + 2, + ), + ( + y < rh - 1 && r[at(x - 1, (y + 1).min(rh - 1))], + c[at(x - 1, (y + 1).min(rh - 1))] + + ch(x - 1, (y + 1).min(rh - 1)) + + cv(x, y), + 3, + ), + ]; + if let Some((c, k)) = best_step(&steps) { + cost[at(x, y)] = c; + control[at(x, y)] = k; + reachable[at(x, y)] = true; + } + } + } + } else { + for y in src.1 + 1..=dst.1 { + for x in 0..rw { + if self.lab(x + tl.0, y + tl.1) != l { + continue; + } + let (r, c) = (&reachable, &cost); + let steps = [ + (r[at(x, y - 1)], c[at(x, y - 1)] + cv(x, y - 1), 1), + ( + x > 0 && r[at((x - 1).max(0), y - 1)], + c[at((x - 1).max(0), y - 1)] + + cv((x - 1).max(0), y - 1) + + ch((x - 1).max(0), y), + 2, + ), + ( + x < rw - 1 && r[at((x + 1).min(rw - 1), y - 1)], + c[at((x + 1).min(rw - 1), y - 1)] + + cv((x + 1).min(rw - 1), y - 1) + + ch(x, y), + 3, + ), + ]; + if let Some((c, k)) = best_step(&steps) { + cost[at(x, y)] = c; + control[at(x, y)] = k; + reachable[at(x, y)] = true; + } + } + } + } + if !reachable[at(dst.0, dst.1)] { + return None; + } + let mut p = dst; + let mut seam = vec![(p.0 + tl.0, p.1 + tl.1)]; + while if horizontal { + p.0 != src.0 + } else { + p.1 != src.1 + } { + let k = control[at(p.0, p.1)]; + if horizontal { + p.1 += (k == 3) as i32 - (k == 2) as i32; + p.0 -= 1; + } else { + p.0 += (k == 3) as i32 - (k == 2) as i32; + p.1 -= 1; + } + seam.push((p.0 + tl.0, p.1 + tl.1)); + } + if !swapped { + seam.reverse(); + } + Some((seam, horizontal)) + } + + fn update_labels_using_seam( + &mut self, + comp1: usize, + comp2: usize, + seam: &[Pt], + horizontal: bool, + ) { + let (tl, br) = (self.tls[comp1], self.brs[comp1]); + let (mw, mh) = (br.0 - tl.0, br.1 - tl.1); + let mut mask = vec![0i32; (mw * mh) as usize]; + let at = |x: i32, y: i32| (y * mw + x) as usize; + for &(x, y) in self.contours[comp1].iter().chain(seam) { + mask[at(x - tl.0, y - tl.1)] = 255; + } + let (l1, l2) = (comp1 as i32 + 1, comp2 as i32 + 1); + let mut ncomps = 0; + for y in 0..mh { + for x in 0..mw { + if mask[at(x, y)] == 0 && self.lab(x + tl.0, y + tl.1) == l1 { + ncomps += 1; + flood_fill(&mut mask, (mw, mh), (x, y), ncomps); + } + } + } + const DX: [i32; 8] = [-1, 1, 0, 0, -1, 1, -1, 1]; + const DY: [i32; 8] = [0, 0, -1, 1, -1, -1, 1, 1]; + for &(cx, cy) in &self.contours[comp1] { + let (x, y) = (cx - tl.0, cy - tl.1); + let mut ok = false; + for j in 0..8 { + let (c, r) = (x + DX[j], y + DY[j]); + if c >= 0 + && c < mw + && r >= 0 + && r < mh + && mask[at(c, r)] != 0 + && mask[at(c, r)] != 255 + { + ok = true; + mask[at(x, y)] = mask[at(c, r)]; + } + } + if !ok { + mask[at(x, y)] = 0; + } + } + for &(sx, sy) in seam { + let (x, y) = (sx - tl.0, sy - tl.1); + let (nx, ny) = if horizontal { (x, y + 1) } else { (x + 1, y) }; + mask[at(x, y)] = + if nx < mw && ny < mh && mask[at(nx, ny)] != 0 && mask[at(nx, ny)] != 255 { + mask[at(nx, ny)] + } else { + 0 + }; + } + let mut connect2 = vec![0usize; ncomps as usize + 1]; + let mut connect_other = vec![0usize; ncomps as usize + 1]; + let (w, h) = (self.w, self.h); + let other = |s: &Self, x: i32, y: i32| { + let v = s.lab(x, y); + v != l1 && v != l2 + }; + for &(x, y) in &self.contours[comp1] { + // A mask value of 255 or 0 here counts nowhere, as in OpenCV's maps. + let m = mask[at(x - tl.0, y - tl.1)]; + let slot = (1..=ncomps).contains(&m).then_some(m as usize); + if self.touches(x, y, l2) + && let Some(s) = slot + { + connect2[s] += 1; + } + let near_other = (x > 0 && other(self, x - 1, y)) + || (y > 0 && other(self, x, y - 1)) + || (x < w - 1 && other(self, x + 1, y)) + || (y < h - 1 && other(self, x, y + 1)); + if near_other && let Some(s) = slot { + connect_other[s] += 1; + } + } + let len = self.contours[comp1].len() as f64; + let adjacent: Vec = (0..=ncomps as usize) + .map(|i| { + i > 0 && connect2[i] as f64 / len > 0.05 && (connect_other[i] as f64 / len) < 0.1 + }) + .collect(); + for y in 0..mh { + for x in 0..mw { + let m = mask[at(x, y)]; + if m > 0 && m <= ncomps && adjacent[m as usize] { + self.labels[((y + tl.1) * w + x + tl.0) as usize] = l2; + } + } + } + } + + fn refresh(&mut self, comp: usize) { + let l = comp as i32 + 1; + let ((x0, y0), (x1, y1)) = (self.tls[comp], self.brs[comp]); + self.tls[comp] = (i32::MAX, i32::MAX); + self.brs[comp] = (i32::MIN, i32::MIN); + self.contours[comp].clear(); + for (_, x, y) in self.contour_points(y0..y1, x0..x1, Some(l)) { + self.add_contour_point(comp, x, y); + } + } + + fn resolve_conflicts(&mut self) { + loop { + let conflict = self.edges.iter().copied().find(|&(c1, c2)| { + let s1 = self.states[c1 as usize]; + s1 & INTERS != 0 && s1 & !INTERS != self.states[c2 as usize] + }); + let Some((c1, c2)) = conflict else { break }; + let (u1, u2) = (c1 as usize, c2 as usize); + if self.has_only_one_neighbor(c1) { + let ((x0, y0), (x1, y1)) = (self.tls[u1], self.brs[u1]); + for y in y0..y1 { + for x in x0..x1 { + let p = (y * self.w + x) as usize; + if self.labels[p] == c1 + 1 { + self.labels[p] = c2 + 1; + } + } + } + self.states[u1] = if self.states[u2] == FIRST { + SECOND + } else { + FIRST + }; + } else { + if let Some((p1, p2)) = self.seam_tips(u1, u2) + && let Some((seam, horizontal)) = self.estimate_seam(u1, p1, p2) + { + self.update_labels_using_seam(u1, u2, &seam, horizontal); + } + self.states[u1] = if self.states[u2] == FIRST { + INTERS | SECOND + } else { + INTERS | FIRST + }; + } + self.refresh(u1); + self.refresh(u2); + self.edges.remove(&(c1, c2)); + self.edges.remove(&(c2, c1)); + } + } +} + +/// One pair of `DpSeamFinder::process`: cut the overlap of `m1` and `m2` between the parts. +fn process(img1: &[f32], img2: &[f32], m1: &mut [u8], m2: &mut [u8], (h, w): (usize, usize)) { + let (w, h) = (w as i32, h as i32); + let mut pair = Pair { + w, + h, + img1, + img2, + labels: Vec::new(), + contour1: contour(m1, w, h), + contour2: contour(m2, w, h), + states: Vec::new(), + tls: Vec::new(), + brs: Vec::new(), + contours: Vec::new(), + edges: BTreeSet::new(), + }; + pair.find_components(m1, m2); + pair.find_edges(); + pair.resolve_conflicts(); + let won = |l: i32, side: u8| l > 0 && pair.states[(l - 1) as usize] & side != 0; + m2.par_iter_mut() + .zip(m1.par_iter()) + .zip(pair.labels.par_iter()) + .for_each(|((b, &a), &l)| { + if won(l, FIRST) && a != 0 { + *b = 0; + } + }); + m1.par_iter_mut() + .zip(m2.par_iter()) + .zip(pair.labels.par_iter()) + .for_each(|((a, &b), &l)| { + if won(l, SECOND) && b != 0 { + *a = 0; + } + }); +} + +/// `DpSeamFinder("COLOR").find(images, [(0, 0)] * n, masks)` of HxWx3 float32 parts on one +/// canvas: each pair cuts its overlap in place, the pairs in reverse order of OpenCV's sort, +/// which leaves pairs of equal centre distance in order for up to 16 pairs. +pub fn dp_seams(images: &[&[f32]], masks: &mut [Vec], (h, w): (usize, usize)) { + let n = images.len(); + let mut pairs: Vec<(usize, usize)> = (0..n) + .flat_map(|i| (i + 1..n).map(move |j| (i, j))) + .collect(); + pairs.reverse(); + for (i, j) in pairs { + let (lo, hi) = masks.split_at_mut(j); + process(images[i], images[j], &mut lo[i], &mut hi[0], (h, w)); + } +} diff --git a/tests/test_core_seam.py b/tests/test_core_seam.py new file mode 100644 index 000000000..d47c8b47a --- /dev/null +++ b/tests/test_core_seam.py @@ -0,0 +1,99 @@ +import pickle +from pathlib import Path + +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.stitch import logic + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(31) +_CORPUS = Path("~/negpy-corpus-p5/features__stitch__logic.py--seam_blend").expanduser() + + +def _recorded(): + """(warped, masks, seams, feather) of the recorded panorama blends; empty without the corpus.""" + return [pickle.loads(p.read_bytes())["args"] for p in sorted(_CORPUS.glob("*.pkl"))] + + +def _parts(h, w, n, shift, angle): + """`n` noisy, slightly rotated views of one scene, each covering a shifted window.""" + wide = w + (n - 1) * shift + scene = cv2.GaussianBlur(_RNG.random((h, wide, 3), dtype=np.float32), (0, 0), 4) + imgs, masks = [], [] + for i in range(n): + img = np.zeros((h, wide, 3), np.float32) + x0 = i * shift + img[:, x0 : x0 + w] = scene[:, x0 : x0 + w] + _RNG.normal(0, 0.01, (h, w, 3)).astype(np.float32) + m = np.zeros((h, wide), np.uint8) + m[:, x0 : x0 + w] = 1 + rot = cv2.getRotationMatrix2D((wide / 2, h / 2), float(_RNG.normal(0, angle)), 1.0) + imgs.append(np.ascontiguousarray(cv2.warpAffine(img, rot, (wide, h)))) + masks.append(np.ascontiguousarray(cv2.warpAffine(m, rot, (wide, h), flags=cv2.INTER_NEAREST))) + return imgs, masks + + +def _cut_cost(imgs, cut): + """DpSeamFinder's color cost summed over every step where the owning part changes.""" + owner = np.full(cut[0].shape, -1) + for i, c in enumerate(cut): + owner[(owner < 0) & (c > 0)] = i + stack = np.stack(imgs) + total = 0.0 + for a, b in ( + ((slice(None, -1), slice(None)), (slice(1, None), slice(None))), + ((slice(None), slice(None, -1)), (slice(None), slice(1, None))), + ): + oa, ob = owner[a], owner[b] + ys, xs = np.nonzero((oa >= 0) & (ob >= 0) & (oa != ob)) + ia, ib = oa[ys, xs], ob[ys, xs] + pa = stack[:, a[0], a[1]][:, ys, xs] + pb = stack[:, b[0], b[1]][:, ys, xs] + n = np.arange(len(ys)) + d1 = ((pa[ia, n] - pb[ib, n]) ** 2).sum(-1) + d2 = ((pb[ia, n] - pa[ib, n]) ** 2).sum(-1) + total += float(((d1 + d2) / 2).sum()) + return total + + +def _check_seams(imgs, masks): + want = [cv2.UMat.get(m) for m in cv2.detail_DpSeamFinder("COLOR").find(imgs, [(0, 0)] * len(imgs), [cv2.UMat(m.copy()) for m in masks])] + got = negpy_core.dp_seams(imgs, masks) + overlap = np.sum([m > 0 for m in masks], 0) >= 2 + for w, g in zip(want, got): + assert np.array_equal(w[~overlap] > 0, g[~overlap] > 0) + assert _cut_cost(imgs, got) <= 1.01 * _cut_cost(imgs, want) + 1e-6 + + +@pytest.mark.parametrize("n", [2, 3, 4]) +@pytest.mark.parametrize("angle", [0.0, 3.0]) +def test_dp_seams_match_cv2_on_synthetic_overlaps(n, angle): + for h, w, shift in ((40, 60, 25), (128, 128, 60), (300, 400, 250)): + _check_seams(*_parts(h, w, n, shift, angle)) + + +def test_dp_seams_match_cv2_on_small_random_cuts(): + for _ in range(100): + h, w = (int(v) for v in _RNG.integers(8, 40, 2)) + _check_seams(*_parts(h, w, 2, int(_RNG.integers(2, w)), 5.0)) + + +def test_dp_seams_match_cv2_on_the_recorded_panorama(): + for warped, masks, _, _ in _recorded(): + h, w = masks[0].shape + scale = min(1.0, logic._SEAM_MAX_EDGE / max(w, h)) + size = (max(1, round(w * scale)), max(1, round(h * scale))) + small = [logic._seam_cost_image(negpy_core.resize_area(np.ascontiguousarray(i, dtype=np.float32), size)) for i in warped] + _check_seams(small, [negpy_core.resize_nearest(m.astype(np.uint8), size) for m in masks]) + + +def test_find_seams_partitions_the_coverage(): + imgs, masks = _parts(90, 120, 3, 50, 2.0) + seams = logic.find_seams(imgs, [m > 0 for m in masks], (imgs[0].shape[1], imgs[0].shape[0])) + assert seams is not None + covered = np.any([m > 0 for m in masks], 0) + assert np.array_equal(np.sum(seams, 0) >= 1, covered) From 13901cd10f766c39940326d223d08633deb0e139 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:45:01 +0200 Subject: [PATCH 106/133] perf(core): skew fits, batch autocrop profile reads and rotation matrices in Rust - `_fit_side` (RANSAC on NumPy's own PCG64 stream, iterated line fit), `_profile_lines` and `_at_canvas_edges` are one Rust call each. - `_top_edge_slope`, `_rect_from_edge_profile` and `_place_window_by_occupancy` are in Rust. - `negpy_core.rotation_matrix` and `perspective_transform` copy `cv2.getRotationMatrix2D` and `cv2.getPerspectiveTransform` bit for bit (20000 random cases each); `invertAffineTransform` is copied in Python. The geometry and skew modules no longer need them from cv2. - Phase 2 functions that only route config and call Rust are "stays" (orchestration). Replay now also runs "stays" entries. - New corpus ~/negpy-corpus-p2 (record_p2.py on main): zone, scope and loupe analysis, Contrast Mask/Diffusion planes, profile-led skew, batch roll. Quality: the line fits are centered least squares, not np.polyfit's SVD. Every recorded measure_frame_skew lands within 4e-13 degrees (42 + 36 calls); all geometry replays pass on both corpora (abs=1e-6 where a fit feeds the result). Bench: _fit_side 0.03x, _profile_lines 0.09x, _at_canvas_edges 0.83x, _top_edge_slope 0.07-0.16x, _rect_from_edge_profile 0.12-0.21x. --- negpy/features/geometry/batch_autocrop.py | 124 +------ negpy/features/geometry/logic.py | 26 +- negpy/features/geometry/skew.py | 155 +------- negpy/services/view/coordinate_mapping.py | 14 +- negpy_core/LEDGER.md | 2 + negpy_core/manifest.toml | 196 ++++++---- negpy_core/src/autocrop.rs | 255 +++++++++++++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/autocrop.rs | 73 ++++ negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/skew.rs | 59 ++- negpy_core/src/py/warp.rs | 39 +- negpy_core/src/skew.rs | 424 ++++++++++++++++++++++ negpy_core/src/warp.rs | 80 ++++ scripts/replay.py | 6 +- tests/test_core_autocrop.py | 164 +++++++++ tests/test_core_skew.py | 107 ++++++ 17 files changed, 1380 insertions(+), 349 deletions(-) create mode 100644 negpy_core/src/autocrop.rs create mode 100644 negpy_core/src/py/autocrop.rs create mode 100644 tests/test_core_autocrop.py diff --git a/negpy/features/geometry/batch_autocrop.py b/negpy/features/geometry/batch_autocrop.py index dec5a85b0..daf80ff79 100644 --- a/negpy/features/geometry/batch_autocrop.py +++ b/negpy/features/geometry/batch_autocrop.py @@ -10,7 +10,6 @@ from dataclasses import dataclass, field, replace from typing import Sequence -import cv2 import negpy_core import numpy as np @@ -39,7 +38,6 @@ _TRUSTED_CONFIDENCE = 0.58 _MAX_AUTOMATIC_DESKEW = 8.0 _DEFAULT_SAFETY_BORDER = 0.01 -_MIN_PROFILE_CONTRAST = 0.06 # The rebate width is a roll property, so require enough agreeing frames that a few # bright frame edges cannot move the median. _MIN_BORDER_SAMPLES = 5 @@ -53,11 +51,6 @@ # because a fit can still lock onto the wrong edge, and one that disagrees with every other # frame by more than this probably has. _CONFIRMED_ANGLE_TOL = 2.5 -# How near the roll's film width a pair of edge-profile peaks must span before their -# agreement replaces raw peak strength. Both peaks are already constrained to a window around -# where the roll puts each edge, so this is a second, independent check: they must also be -# the right distance apart. -_EDGE_PAIR_WIDTH_TOL = 0.02 # Confidence given to a frame whose rect is the threshold box rather than a detected film # box. Low: the box is the frame, and every real measurement on it came from the border walk. _FALLBACK_CONFIDENCE = 0.30 @@ -151,49 +144,10 @@ def _vertical_edge_profile(img: ImageBuffer) -> tuple[np.ndarray, float]: def _top_edge_slope(lum: np.ndarray, roi: ROI) -> float | None: - """Residual tilt of the film box's top edge, in degrees, or None when unreadable. - - Sign matches the film detector's quad angle: the measured image-coordinate residual is - already the additive fix. - """ - y1, _, x1, x2 = roi - height = lum.shape[0] - left, right = x1 + 20, x2 - 20 - columns = np.arange(left, right, 3) - min_points = max(20, round((right - left) * 0.08)) - if columns.size < min_points: - return None - - radius = max(12, round(height * 0.035)) - low, high = max(0, y1 - radius), min(height, y1 + radius) - if high - low < 4: - return None - - gradient = np.abs(negpy_core.sobel(negpy_core.gaussian_blur(lum.astype(np.float32), (0, 0), 1.5), 0, 1)) - band = gradient[low:high, columns] - rows = low + np.argmax(band, axis=0) - strength = band.max(axis=0) - # With no edge under the band every column peaks on noise and the fit still comes back - # clean: a straight line through nothing, reported as a confident zero. - if float(np.median(strength)) < max(1e-6, 3.0 * float(np.median(band))): - return None - points = np.column_stack([columns, rows])[strength >= np.quantile(strength, 0.60)] - if len(points) < min_points: - return None - - residuals = np.zeros(len(points)) - slope = 0.0 - for _ in range(5): - slope, intercept = np.polyfit(points[:, 0], points[:, 1], 1) - residuals = np.abs(points[:, 1] - (slope * points[:, 0] + intercept)) - keep = residuals <= max(2.0, float(np.median(residuals)) * 2.5) - if keep.all() or keep.sum() < min_points: - break - points = points[keep] - - if float(np.median(residuals)) > 2.0: - return None - return float(np.degrees(np.arctan(slope))) + """Residual tilt of the film box's top edge, in degrees, or None when unreadable; negpy_core + holds the fit. Sign matches the film detector's quad angle: the measured image-coordinate + residual is already the additive fix.""" + return negpy_core.top_edge_slope(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in roi)) def _side_border(lum: np.ndarray, roi: ROI) -> dict[str, float]: @@ -575,7 +529,7 @@ def _map_rect_between_rotations( ], dtype=np.float64, ) - matrix = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), delta, 1.0) + matrix = negpy_core.rotation_matrix((w / 2.0, h / 2.0), delta, 1.0) mapped = points @ matrix[:, :2].T + matrix[:, 2] # Quantize outward before normalizing. Later conversion back to a half-open # pixel ROI must not round an extremum inward, especially when one edge touches @@ -634,62 +588,20 @@ def _calibrate_detected_rect( return _clamp_rect((x1, y1, x2, y2)), calibrated -def _peak_near(profile: np.ndarray, expected: float, radius: float) -> tuple[float, float]: - if profile.size == 0: - return expected, 0.0 - center = int(round(expected * (profile.size - 1))) - half = max(2, int(round(radius * profile.size))) - lo, hi = max(0, center - half), min(profile.size, center + half + 1) - if hi <= lo: - return expected, 0.0 - local = profile[lo:hi] - offset = int(np.argmax(local)) - idx = lo + offset - return idx / max(profile.size - 1, 1), float(local[offset]) - - def _rect_from_edge_profile(item: CropEvidence, template: RollCropTemplate) -> tuple[float, float, float, float] | None: - profile = np.asarray(item.vertical_edge_profile, dtype=np.float32) - if item.vertical_edge_contrast < _MIN_PROFILE_CONTRAST or profile.size < 8 or not np.any(np.isfinite(profile)): - return None - - baseline_level = float(np.percentile(profile, 50)) - peak_level = float(np.percentile(profile, 99.5)) - if peak_level - baseline_level < 0.20 or peak_level < 1.35 * max(baseline_level, 0.05): - return None - - left_expected = template.center_x - template.width * 0.5 - right_expected = template.center_x + template.width * 0.5 - radius = max(0.045, 4.0 * template.center_mad + 2.0 * template.width_mad) - left, left_strength = _peak_near(profile, left_expected, radius) - right, right_strength = _peak_near(profile, right_expected, radius) - - baseline = float(np.percentile(profile, 70)) - spread = float(np.percentile(profile, 90) - np.percentile(profile, 50)) - threshold = max(0.24, baseline + 0.55 * spread) - left_ok, right_ok = left_strength >= threshold, right_strength >= threshold - - # Strength is measured against the frame's own edges, so a busy picture raises the bar - # above the film edge that is plainly there. Where both peaks land on the width the roll - # expects, that agreement stands in for it: a profile with no film edge in it does not - # produce two independently located edges spanning the right distance. - if not (left_ok and right_ok) and abs((right - left) - template.width) <= _EDGE_PAIR_WIDTH_TOL: - left_ok = right_ok = True - - if not left_ok and not right_ok: - return None - if left_ok and right_ok: - if right - left < 0.65 * template.width or right - left > 1.35 * template.fallback_width: - return None - x1, x2 = left, right - elif left_ok and left_strength >= threshold + 0.12: - x1, x2 = left, left + template.fallback_width - elif right_ok and right_strength >= threshold + 0.12: - x1, x2 = right - template.fallback_width, right - else: - return None - - return _clamp_rect((x1, template.top, x2, template.top + template.height)) + """A film box from the frame's vertical-edge profile where the roll puts the edges; negpy_core + holds the rules.""" + fields = ( + template.center_x, + template.width, + template.fallback_width, + template.top, + template.height, + template.center_mad, + template.width_mad, + ) + profile = np.ascontiguousarray(item.vertical_edge_profile, dtype=np.float32).ravel() + return negpy_core.rect_from_edge_profile(profile, float(item.vertical_edge_contrast), fields) def _resolve_border( diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index d2403037f..1a40e31be 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -379,7 +379,7 @@ def apply_fine_rotation(img: ImageBuffer, angle: float) -> ImageBuffer: return img h, w = img.shape[:2] - m_mat = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + m_mat = negpy_core.rotation_matrix((w / 2.0, h / 2.0), angle, 1.0) return negpy_core.warp_affine(np.ascontiguousarray(img, dtype=np.float32), m_mat, (w, h)) @@ -498,7 +498,7 @@ def keystone_matrix_normalized(converge_v: float, converge_h: float) -> np.ndarr """ src = keystone_quad(converge_v, converge_h) dst = np.float32([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) - return cv2.getPerspectiveTransform(src, dst).astype(np.float64) + return negpy_core.perspective_transform(src, dst).astype(np.float64) def _norm_to_index(w: int, h: int) -> np.ndarray: @@ -561,7 +561,7 @@ def compute_geometry_crop_rect( return 0.0, 0.0, 1.0, 1.0 cx, cy = w / 2.0, h / 2.0 - rot = cv2.getRotationMatrix2D((cx, cy), fine_rotation, 1.0) + rot = negpy_core.rotation_matrix((cx, cy), fine_rotation, 1.0) m_rot = np.vstack([rot, [0.0, 0.0, 1.0]]) m_combined = keystone_matrix(converge_v, converge_h, w, h) @ m_rot m_inv = np.linalg.inv(m_combined) @@ -713,21 +713,9 @@ def _place_window_by_occupancy(start: int, end: int, target_len: float, occupanc centered position, so uniform occupancy reproduces plain centering. Returns the new window start in input coords. """ - d_start = max(0, int(round(start * scale))) - d_end = min(occupancy.size, int(round(end * scale))) - d_len = max(1, int(round(target_len * scale))) - if d_len >= d_end - d_start: - return start - - cs = np.concatenate(([0.0], np.cumsum(occupancy[d_start:d_end], dtype=np.float64))) - n_pos = (d_end - d_start) - d_len + 1 - scores = cs[d_len : d_len + n_pos] - cs[:n_pos] - candidates = np.where(scores >= float(np.max(scores)) - 1e-9)[0] - centered = ((d_end - d_start) - d_len) / 2.0 - k = int(candidates[np.argmin(np.abs(candidates - centered))]) - - new_start = round((d_start + k) / scale) - return int(min(max(start, new_start), end - target_len)) + return negpy_core.place_window_by_occupancy( + int(start), int(end), float(target_len), np.ascontiguousarray(occupancy, dtype=np.float32), float(scale) + ) def _enforce_ratio_by_occupancy( @@ -987,7 +975,7 @@ def _pre_keystone_point( if fine_rotation != 0.0: center = (w / 2.0, h / 2.0) - m_mat = cv2.getRotationMatrix2D(center, fine_rotation, 1.0) + m_mat = negpy_core.rotation_matrix(center, fine_rotation, 1.0) pt = np.array([px, py, 1.0]) res_pt = m_mat @ pt px, py = float(res_pt[0]), float(res_pt[1]) diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 7c4e553d6..dfe4b855c 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -36,21 +36,10 @@ _ANGLE_BIN = 0.05 # Sprocket holes, edge print and most picture detail run shorter than this. _MIN_SEGMENT = 0.04 -_MIN_LINE_SPAN = 0.25 -# Admits the bow a scanning lens or film curl puts on a frame edge; its chord stays level. -_MAX_LINE_RMS = 2.0 -_MIN_INLIER_FRACTION = 0.5 # A line that disagrees with the rest by more than this after the solve is not the frame. _MAX_LINE_RESIDUAL = 0.15 # Opposite edges this close together cannot separate keystone from noise. _MIN_PAIR_SEPARATION = 0.3 -# A box bounds its edge to within the film detector's snap window (`snap_film_bounds` in negpy_core). -_BOX_TOLERANCE = 0.02 -_BOX_TOLERANCE_PX = 16 -# A tight scan's border lines sit within this fraction of the cross extent from the canvas edge. -_CANVAS_EDGE_BAND = 0.12 -# Film base or bed outside a border never reads this dark; an opaque holder does. -_OPAQUE_RING = 0.04 # Must admit a clean two-line pair: a strip across the canvas shows two film edges, not four. _SKEW_TRUSTED_CONFIDENCE = 0.5 @@ -118,81 +107,12 @@ def _coarse_angles(lum: np.ndarray) -> list[float]: return modes -def _ransac_line(xs: np.ndarray, ys: np.ndarray, expected: float, half: float, span: float) -> np.ndarray | None: - """The straight run of peaks with the most distinct stations, preferring one near `expected`.""" - if xs.size < 12: - return None - rng = np.random.default_rng(0) - a, b = rng.integers(0, xs.size, (2, 300)) - ok = np.abs(xs[a] - xs[b]) >= 0.3 * span - a, b = a[ok], b[ok] - if a.size == 0: - return None - slope = (ys[b] - ys[a]) / (xs[b] - xs[a]) - ok = np.abs(slope) <= math.tan(math.radians(4.0)) - a, b, slope = a[ok], b[ok], slope[ok] - if a.size == 0: - return None - intercept = ys[a] - slope * xs[a] - res = np.abs(ys[None, :] - (slope[:, None] * xs[None, :] + intercept[:, None])) - inlier = res <= 1.5 - stations = np.array([np.unique(xs[row]).size for row in inlier]) - mid = slope * (0.5 * (xs.min() + xs.max())) + intercept - score = stations * (1.0 - 0.5 * np.minimum(1.0, np.abs(mid - expected) / half)) - best = int(np.argmax(score)) - if stations[best] < 12: - return None - return np.polyfit(xs[inlier[best]], ys[inlier[best]], 1) - - def _fit_side(lum: np.ndarray, horizontal: bool, at: float, start: float, stop: float, source: str, outward: int = 0) -> EdgeLine | None: """Fit the edge running along one axis near `at`, between `start` and `stop`. `outward` is - the sign of the box's outside across the axis, or 0 when `at` is the edge itself.""" - img = lum if horizontal else lum.T - h, w = img.shape - length = stop - start - if length < _MIN_LINE_SPAN * w or not _off_border(at, at, h): - return None - stations = np.linspace(start + 0.1 * length, stop - 0.1 * length, int(min(240, max(24, length / 4)))) - - plane = np.ascontiguousarray(lum, dtype=np.float32) - - def sample(center: np.ndarray, half: float, nearest: bool) -> tuple[np.ndarray, np.ndarray]: - """Per station, the gradient steps of the 3-column mean profile within `half` of `center`.""" - return negpy_core.skew_sample(plane, horizontal, stations, np.ascontiguousarray(center, dtype=np.float64), float(half), nearest) - - # Keystone leaves the far end of an edge a degree or two inside the box; a stronger edge - # outside it (the holder, the next frame) sits past the detector's error. - inside = max(10.0, 0.035 * max(h, w)) - outside = inside if outward == 0 else max(float(_BOX_TOLERANCE_PX), _BOX_TOLERANCE * h) - center = float(at) + 0.5 * outward * (outside - inside) - half = 0.5 * (inside + outside) - xs, ys = sample(np.full(stations.size, center), half, nearest=False) - fit = _ransac_line(xs, ys, float(at), half, length) - if fit is None: - return None - xs, ys = sample(np.polyval(fit, stations), max(6.0, 0.3 * inside), nearest=True) - if xs.size < 12: - return None - keep = np.ones(xs.size, bool) - for _ in range(8): - fit = np.polyfit(xs[keep], ys[keep], 1) - res = ys - np.polyval(fit, xs) - mad = 1.4826 * float(np.median(np.abs(res[keep] - np.median(res[keep])))) - new = np.abs(res) <= max(1.0, 2.5 * mad) - if new.sum() < 12 or np.array_equal(new, keep): - break - keep = new - inliers = float(keep.sum()) / stations.size - rms = float(np.sqrt(np.mean((ys[keep] - np.polyval(fit, xs[keep])) ** 2))) - if inliers < _MIN_INLIER_FRACTION or rms > _MAX_LINE_RMS: - return None - x1, x2 = float(xs[keep].min()), float(xs[keep].max()) - if x2 - x1 < _MIN_LINE_SPAN * w: - return None - y1, y2 = float(np.polyval(fit, x1)), float(np.polyval(fit, x2)) - p1, p2 = ((x1, y1), (x2, y2)) if horizontal else ((y1, x1), (y2, x2)) - return EdgeLine(p1, p2, horizontal, source, inliers, rms) + the sign of the box's outside across the axis, or 0 when `at` is the edge itself; negpy_core + holds the sampling and the fit.""" + found = negpy_core.skew_fit_side(np.ascontiguousarray(lum, dtype=np.float32), horizontal, at, start, stop, outward) + return None if found is None else EdgeLine(found[0], found[1], found[2], source, found[3], found[4]) def _off_border(lo: float, hi: float, extent: int) -> bool: @@ -202,7 +122,7 @@ def _off_border(lo: float, hi: float, extent: int) -> bool: def _unrotate(line: EdgeLine, angle: float, w: int, h: int) -> EdgeLine: """Map a line found on the image turned by `angle` back to the unturned image.""" - m = np.vstack([cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0), [0.0, 0.0, 1.0]]) + m = np.vstack([negpy_core.rotation_matrix((w / 2.0, h / 2.0), angle, 1.0), [0.0, 0.0, 1.0]]) inv = np.linalg.inv(m) p1 = inv @ [line.p1[0], line.p1[1], 1.0] p2 = inv @ [line.p2[0], line.p2[1], 1.0] @@ -256,50 +176,18 @@ def _line_position(line: EdgeLine) -> float: return 0.5 * (line.p1[idx] + line.p2[idx]) -def _outer_peaks(coverage: np.ndarray, floor: float) -> tuple[int, int] | None: - """First and last local maxima of `coverage` at or above `floor`, off the canvas border.""" - n = coverage.size - i = np.arange(3, n - 3) - peaks = i[(coverage[i] >= floor) & (coverage[i] >= coverage[i - 1]) & (coverage[i] >= coverage[i + 1])] - if peaks.size < 2 or peaks[-1] - peaks[0] < _MIN_PAIR_SEPARATION * n: - return None - return int(peaks[0]), int(peaks[-1]) - - def _profile_lines(lum: np.ndarray) -> list[EdgeLine]: - """Outermost long edges by row and column coverage, for frames the film detector cannot box. - The spread keeps an edge a degree off axis inside one band.""" - h, w = lum.shape - lines: list[EdgeLine] = [] - spread = max(3, int(0.02 * max(h, w))) - gy = np.abs(negpy_core.sobel(lum.astype(np.float32), 0, 1)) - strong = (gy >= 0.25 * float(np.percentile(gy, 99.5))).astype(np.uint8) - strong = negpy_core.morphology_ex(strong, cv2.MORPH_DILATE, np.ones((2 * spread + 1, 1), np.uint8)) - rows = _outer_peaks(strong[:, int(0.05 * w) : int(0.95 * w)].mean(axis=1), 0.35) - top, bottom = rows if rows is not None else (0, h) - if rows is not None: - for y in rows: - line = _fit_side(lum, True, float(y), 0.0, float(w), "profile-horizontal") - if line is not None: - lines.append(line) - - gx = np.abs(negpy_core.sobel(lum.astype(np.float32), 1, 0)) - strong = (gx >= 0.25 * float(np.percentile(gx, 99.5))).astype(np.uint8) - strong = negpy_core.morphology_ex(strong, cv2.MORPH_DILATE, np.ones((1, 2 * spread + 1), np.uint8)) - lo, hi = top + int(0.05 * (bottom - top)), bottom - int(0.05 * (bottom - top)) - cols = _outer_peaks(strong[lo:hi].mean(axis=0), 0.35) if hi - lo > 10 else None - if cols is not None: - for x in cols: - line = _fit_side(lum, False, float(x), float(top), float(bottom), "profile-vertical") - if line is not None: - lines.append(line) - # A frame shows its edges in opposite pairs; a lone long line is picture content. - return [line for line in lines if sum(other.horizontal == line.horizontal for other in lines) >= 2] + """Outermost long edges by row and column coverage, for frames the film detector cannot box.""" + found = negpy_core.skew_profile_lines(np.ascontiguousarray(lum, dtype=np.float32)) + return [ + EdgeLine(p1, p2, horizontal, f"profile-{'horizontal' if horizontal else 'vertical'}", inliers, rms) + for p1, p2, horizontal, inliers, rms in found + ] def _map(points: np.ndarray, angle: float, cv: float, ch: float, w: int, h: int) -> np.ndarray: """Where input points land after fine rotation then keystone, as the pipeline applies them.""" - m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + m = negpy_core.rotation_matrix((w / 2.0, h / 2.0), angle, 1.0) out = points @ m[:, :2].T + m[:, 2] if abs(cv) < 1e-6 and abs(ch) < 1e-6: return out @@ -340,23 +228,8 @@ def _solve(lines: list[EdgeLine], w: int, h: int, fit_v: bool, fit_h: bool) -> t def _at_canvas_edges(lines: list[EdgeLine], lum: np.ndarray) -> bool: """Whether every line hugs the canvas edge, paired on both axes, with no opaque holder outside.""" - if not lines: - return False - h, w = lum.shape - for line in lines: - extent = h if line.horizontal else w - position = _line_position(line) - if min(position, extent - position) > _CANVAS_EDGE_BAND * extent: - return False - index = int(position) - outside = ( - (lum[: max(0, index - 2)] if position < extent / 2 else lum[index + 3 :]) - if line.horizontal - else (lum[:, : max(0, index - 2)] if position < extent / 2 else lum[:, index + 3 :]) - ) - if outside.size and float(np.median(outside)) < _OPAQUE_RING: - return False - return _pair_spread(lines, True, h) >= _MIN_PAIR_SEPARATION and _pair_spread(lines, False, w) >= _MIN_PAIR_SEPARATION + placed = [(line.horizontal, _line_position(line)) for line in lines] + return negpy_core.skew_at_canvas_edges(np.ascontiguousarray(lum, dtype=np.float32), placed) def _pair_spread(lines: list[EdgeLine], horizontal: bool, extent: int) -> float: diff --git a/negpy/services/view/coordinate_mapping.py b/negpy/services/view/coordinate_mapping.py index c7504f60d..82be83856 100644 --- a/negpy/services/view/coordinate_mapping.py +++ b/negpy/services/view/coordinate_mapping.py @@ -1,8 +1,16 @@ +import negpy_core import numpy as np -import cv2 from typing import Tuple, Optional +def _invert_affine(m: np.ndarray) -> np.ndarray: + """cv2.invertAffineTransform in its operation order.""" + d = m[0, 0] * m[1, 1] - m[0, 1] * m[1, 0] + d = 1.0 / d if d != 0 else 0.0 + a11, a22, a12, a21 = m[1, 1] * d, m[0, 0] * d, -m[0, 1] * d, -m[1, 0] * d + return np.array([[a11, a12, -a11 * m[0, 2] - a12 * m[1, 2]], [a21, a22, -a21 * m[0, 2] - a22 * m[1, 2]]]) + + class CoordinateMapping: """ Raw <-> Viewport coordinate transforms. @@ -49,7 +57,7 @@ def create_uv_grid( # The grid is affine in pixel position, so the rotated grid is solved exactly. A # warpAffine fills the corner wedges with 0, which sends a click there to (0, 0). h_r, w_r = uv_grid.shape[:2] - m_inv = cv2.invertAffineTransform(cv2.getRotationMatrix2D((w_r / 2.0, h_r / 2.0), fine_rot, 1.0)) + m_inv = _invert_affine(negpy_core.rotation_matrix((w_r / 2.0, h_r / 2.0), fine_rot, 1.0)) o = uv_grid[0, 0].astype(np.float64) du = (uv_grid[0, -1] - o) / max(w_r - 1, 1) dv = (uv_grid[-1, 0] - o) / max(h_r - 1, 1) @@ -96,7 +104,7 @@ def _grid_homography(uv_grid: np.ndarray) -> np.ndarray: ] ) dst = np.float32([uv_grid[y0, x0], uv_grid[y0, x1], uv_grid[y1, x1], uv_grid[y1, x0]]) - return cv2.getPerspectiveTransform(src, dst).astype(np.float64) + return negpy_core.perspective_transform(src, dst).astype(np.float64) @staticmethod def _apply_homography(m: np.ndarray, nx: float, ny: float) -> Tuple[float, float]: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 2d918f299..1e88f0484 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -28,3 +28,5 @@ tolerance listed for each entry. | `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | | `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | | `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | +| `features/geometry/skew.py::_fit_side`, `_profile_lines` (and `measure_frame_skew` through them) | The line fits are centered least-squares sums, not `np.polyfit`'s LAPACK SVD; the RANSAC draws copy NumPy's PCG64 stream exactly. Every recorded skew lands within 4e-13 degrees, with the same lines and the same decisions. | abs=1e-6 | +| `features/geometry/batch_autocrop.py::_top_edge_slope` | The same centered least-squares fit in place of `np.polyfit`. | abs=1e-9 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 819f7e3ac..13a4a8d6b 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -392,68 +392,84 @@ depends = [] ["features/geometry/batch_autocrop.py::_border_without_a_film_box"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_bright_side_border", "_side_border"] +note = "orchestration of negpy_core kernels" ["features/geometry/batch_autocrop.py::_bright_side_border"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration of negpy_core kernels" ["features/geometry/batch_autocrop.py::_edge_fit"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = [] +note = "orchestration of negpy_core kernels" ["features/geometry/batch_autocrop.py::_mad"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = ["np.median"] depends = [] +note = "scalar or roll-size math" ["features/geometry/batch_autocrop.py::_map_rect_between_rotations"] phase = 2 -status = "todo" -calls = ["cv2.getRotationMatrix2D"] +status = "stays" +compare = "abs=1e-6" +calls = ["negpy_core.rotation_matrix"] depends = [] +note = "scalar or roll-size math" ["features/geometry/batch_autocrop.py::_no_box_evidence"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = ["_border_without_a_film_box"] +note = "orchestration of negpy_core kernels" ["features/geometry/batch_autocrop.py::_rect_from_edge_profile"] phase = 2 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.rect_from_edge_profile"] depends = [] ["features/geometry/batch_autocrop.py::_roll_border"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = ["np.median"] depends = [] +note = "scalar or roll-size math" ["features/geometry/batch_autocrop.py::_side_border"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration of negpy_core kernels" ["features/geometry/batch_autocrop.py::_threshold_fallback_frames"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = ["np.median"] depends = [] +note = "scalar or roll-size math" ["features/geometry/batch_autocrop.py::_top_edge_slope"] phase = 2 -status = "todo" -calls = ["negpy_core.gaussian_blur", "negpy_core.sobel", "np.median", "np.quantile"] +status = "ported" +compare = "abs=1e-9" +calls = ["negpy_core.top_edge_slope"] depends = [] ["features/geometry/batch_autocrop.py::_vertical_edge_profile"] @@ -464,21 +480,27 @@ depends = [] ["features/geometry/batch_autocrop.py::build_roll_template"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = ["np.median", "np.percentile"] depends = ["_mad", "_roll_border", "_threshold_fallback_frames"] +note = "scalar or roll-size math" ["features/geometry/batch_autocrop.py::detect_crop_candidate"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = ["_bright_side_border", "_edge_fit", "_no_box_evidence", "_side_border", "_top_edge_slope", "_vertical_edge_profile"] +note = "orchestration of negpy_core kernels" ["features/geometry/batch_autocrop.py::resolve_roll_crops"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = ["_map_rect_between_rotations", "_rect_from_edge_profile", "build_roll_template"] +note = "scalar or roll-size math" ["features/geometry/logic.py::_adaptive_threshold_levels"] phase = 2 @@ -520,9 +542,10 @@ note = "negpy_core::analysis::dark_region_bounds (Python deleted)" ["features/geometry/logic.py::_detect_film_bounds"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["detect_film_bounds_with_confidence"] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::_detection_luma"] phase = 2 @@ -540,9 +563,10 @@ note = "negpy_core::analysis::edge_border_depth (Python deleted)" ["features/geometry/logic.py::_enforce_ratio_by_occupancy"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_place_window_by_occupancy"] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::_estimate_tier_levels"] phase = 2 @@ -559,9 +583,10 @@ depends = [] ["features/geometry/logic.py::_find_autocrop_roi_from_contours"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_detect_film_bounds", "_refine_roi_to_image"] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::_find_rebate_level"] phase = 2 @@ -596,9 +621,10 @@ depends = [] ["features/geometry/logic.py::_normalize_detection_input"] phase = 2 -status = "todo" +status = "stays" calls = ["negpy_core.resize_area"] depends = [] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::_normalize_to_uint8"] phase = 2 @@ -615,8 +641,8 @@ depends = ["_polygon_values"] ["features/geometry/logic.py::_place_window_by_occupancy"] phase = 2 -status = "todo" -calls = ["np.cumsum"] +status = "ported" +calls = ["negpy_core.place_window_by_occupancy"] depends = [] ["features/geometry/logic.py::_polygon_values"] @@ -634,9 +660,10 @@ note = "negpy_core::analysis::pool_border_depth (Python deleted)" ["features/geometry/logic.py::_pre_keystone_point"] phase = 2 -status = "todo" -calls = ["cv2.getRotationMatrix2D"] +status = "stays" +calls = ["negpy_core.rotation_matrix"] depends = [] +note = "scalar or roll-size math" ["features/geometry/logic.py::_refine_film_roi_by_tiers"] phase = 2 @@ -680,7 +707,7 @@ depends = ["_weighted_median"] ["features/geometry/logic.py::_smooth_signal"] phase = 2 -status = "todo" +status = "ported" calls = ["negpy_core.smooth_signal"] depends = [] @@ -732,7 +759,7 @@ depends = [] ["features/geometry/logic.py::apply_fine_rotation"] phase = 2 status = "ported" -calls = ["cv2.getRotationMatrix2D", "negpy_core.warp_affine"] +calls = ["negpy_core.rotation_matrix", "negpy_core.warp_affine"] depends = [] ["features/geometry/logic.py::apply_keystone"] @@ -749,15 +776,17 @@ depends = [] ["features/geometry/logic.py::compute_geometry_crop_rect"] phase = 2 -status = "todo" -calls = ["cv2.getRotationMatrix2D"] +status = "stays" +calls = ["negpy_core.rotation_matrix"] depends = ["keystone_matrix"] +note = "scalar or roll-size math" ["features/geometry/logic.py::detect_closest_aspect_ratio"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_find_autocrop_roi_from_contours", "_get_threshold_autocrop_coords", "_normalize_detection_input"] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::detect_film_bounds_with_confidence"] phase = 2 @@ -773,39 +802,45 @@ depends = [] ["features/geometry/logic.py::get_autocrop_coords"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_detect_film_bounds", "_detection_luma", "_enforce_ratio_by_occupancy", "_get_threshold_autocrop_coords", "_normalize_detection_input", "_refine_roi_to_image", "_trim_film_edges"] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::keystone_inverse_normalized"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["keystone_matrix_normalized"] +note = "scalar or roll-size math" ["features/geometry/logic.py::keystone_matrix"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["keystone_matrix_normalized"] +note = "scalar or roll-size math" ["features/geometry/logic.py::keystone_matrix_normalized"] phase = 2 -status = "todo" -calls = ["cv2.getPerspectiveTransform"] +status = "stays" +calls = ["negpy_core.perspective_transform"] depends = [] +note = "scalar or roll-size math; the perspective solve is negpy_core's" ["features/geometry/logic.py::map_coords_to_geometry"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_pre_keystone_point", "map_point_keystone"] +note = "scalar or roll-size math" ["features/geometry/logic.py::map_point_keystone"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["keystone_matrix"] +note = "scalar or roll-size math" ["features/geometry/logic.py::measure_film_border"] phase = 2 @@ -823,129 +858,140 @@ note = "negpy_core::analysis::measure_film_edges" ["features/geometry/logic.py::resolve_autocrop_rect"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_detect_film_bounds", "_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion", "get_autocrop_coords"] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::resolve_meter_rect"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["resolve_autocrop_rect"] +note = "orchestration of negpy_core kernels" ["features/geometry/logic.py::solve_keystone_from_edges"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_pre_keystone_point", "map_point_keystone", "residuals", "slope_h", "slope_v"] +note = "scalar or roll-size math" ["features/geometry/logic.py::solve_keystone_from_edges.residuals"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["slope_h", "slope_v"] +note = "scalar or roll-size math" ["features/geometry/logic.py::solve_keystone_from_edges.slope_h"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["map_point_keystone"] +note = "scalar or roll-size math" ["features/geometry/logic.py::solve_keystone_from_edges.slope_v"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["map_point_keystone"] +note = "scalar or roll-size math" ["features/geometry/processor.py::GeometryProcessor.process"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration of negpy_core kernels" ["features/geometry/skew.py::_at_canvas_edges"] phase = 2 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.skew_at_canvas_edges"] depends = [] ["features/geometry/skew.py::_fit_side"] phase = 2 -status = "todo" -calls = ["negpy_core.skew_sample", "np.mean", "np.median"] -depends = ["_ransac_line", "sample"] - -["features/geometry/skew.py::_fit_side.sample"] -phase = 2 -status = "todo" -calls = ["negpy_core.skew_sample"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.skew_fit_side"] depends = [] ["features/geometry/skew.py::_frame_lines"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = ["_fit_side"] +note = "orchestration of negpy_core kernels" ["features/geometry/skew.py::_line_angles"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_map"] +note = "scalar line geometry; the rotation matrix is negpy_core's" ["features/geometry/skew.py::_map"] phase = 2 -status = "todo" -calls = ["cv2.getRotationMatrix2D"] +status = "stays" +calls = ["negpy_core.rotation_matrix"] depends = [] +note = "scalar line geometry; the rotation matrix is negpy_core's" ["features/geometry/skew.py::_measure_from"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = ["np.mean"] depends = ["_at_canvas_edges", "_frame_lines", "_profile_lines", "_solve", "_unrotate"] +note = "orchestration of negpy_core kernels" ["features/geometry/skew.py::_profile_lines"] phase = 2 -status = "todo" -calls = ["negpy_core.morphology_ex", "negpy_core.sobel", "np.percentile"] -depends = ["_fit_side"] - -["features/geometry/skew.py::_ransac_line"] -phase = 2 -status = "todo" -calls = ["np.unique"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.skew_profile_lines"] depends = [] ["features/geometry/skew.py::_solve"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_line_angles"] +note = "scalar line geometry; the rotation matrix is negpy_core's" ["features/geometry/skew.py::_unrotate"] phase = 2 -status = "todo" -calls = ["cv2.getRotationMatrix2D"] +status = "stays" +calls = ["negpy_core.rotation_matrix"] depends = [] +note = "scalar line geometry; the rotation matrix is negpy_core's" ["features/geometry/skew.py::measure_frame_skew"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = ["_coarse_angles", "_measure_from"] +note = "orchestration of negpy_core kernels" ["features/geometry/skew.py::squared_geometry"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = ["trusted_frame_skew"] +note = "orchestration of negpy_core kernels" ["features/geometry/skew.py::trusted_frame_skew"] phase = 2 -status = "todo" +status = "stays" +compare = "abs=1e-6" calls = [] depends = ["measure_frame_skew"] +note = "orchestration of negpy_core kernels" ["infrastructure/loaders/logluv.py::normalize_linear"] phase = 3 @@ -2468,13 +2514,13 @@ depends = [] ["services/view/coordinate_mapping.py::CoordinateMapping._grid_homography"] phase = 7 status = "todo" -calls = ["cv2.getPerspectiveTransform"] +calls = ["negpy_core.perspective_transform"] depends = [] ["services/view/coordinate_mapping.py::CoordinateMapping.create_uv_grid"] phase = 7 status = "todo" -calls = ["cv2.getRotationMatrix2D", "cv2.invertAffineTransform"] +calls = ["negpy_core.rotation_matrix"] depends = [] ["features/geometry/skew.py::_coarse_angles"] diff --git a/negpy_core/src/autocrop.rs b/negpy_core/src/autocrop.rs new file mode 100644 index 000000000..6f76033e8 --- /dev/null +++ b/negpy_core/src/autocrop.rs @@ -0,0 +1,255 @@ +//! The profile reads of negpy/features/geometry/batch_autocrop.py and the occupancy window of +//! geometry/logic.py. + +use crate::filter::{Border, gaussian_blur, sep_filter2d}; +use crate::stats::{median_f64, np_median, np_percentiles}; + +/// `round` of a float64 as Python rounds it: ties to even. +fn py_round(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +/// Least-squares line through the points, as `skew::polyfit1`. +fn polyfit1(xs: &[f64], ys: &[f64]) -> (f64, f64) { + let n = xs.len() as f64; + let mx = xs.iter().sum::() / n; + let my = ys.iter().sum::() / n; + let (mut sxy, mut sxx) = (0.0, 0.0); + for (&x, &y) in xs.iter().zip(ys) { + sxy += (x - mx) * (y - my); + sxx += (x - mx) * (x - mx); + } + let slope = sxy / sxx; + (slope, my - slope * mx) +} + +/// `_top_edge_slope(lum, roi)`: the residual tilt of the film box's top edge in degrees, from +/// the strongest vertical gradient per column near it; None when unreadable. +pub fn top_edge_slope( + lum: &[f32], + (h, w): (usize, usize), + (y1, x1, x2): (i64, i64, i64), +) -> Option { + let (left, right) = (x1 + 20, x2 - 20); + let columns: Vec = (left..right).step_by(3).collect(); + let min_points = 20i64.max(py_round((right - left) as f64 * 0.08)) as usize; + if columns.len() < min_points { + return None; + } + let radius = 12i64.max(py_round(h as f64 * 0.035)); + let (low, high) = ( + (y1 - radius).max(0) as usize, + (y1 + radius).min(h as i64) as usize, + ); + if high < low + 4 { + return None; + } + // The blur and the 3-tap derivative reach 7 rows; a slab 8 rows wider reads like the frame. + let (s0, s1) = (low.saturating_sub(8), (high + 8).min(h)); + let slab = &lum[s0 * w..s1 * w]; + let rows = s1 - s0; + let mut blur = vec![0f32; slab.len()]; + gaussian_blur( + slab, + &mut blur, + (rows, w, 1), + (0, 0), + (1.5, 0.0), + Border::Reflect101, + )?; + let mut grad = vec![0f32; slab.len()]; + sep_filter2d( + &blur, + &mut grad, + (rows, w, 1), + (&[1.0, 2.0, 1.0], &[-1.0, 0.0, 1.0]), + Border::Reflect101, + )?; + let at = |y: usize, x: i64| grad[(y - s0) * w + x as usize].abs(); + let mut band = Vec::with_capacity((high - low) * columns.len()); + let mut peaks = Vec::with_capacity(columns.len()); + let mut strength = Vec::with_capacity(columns.len()); + for y in low..high { + band.extend(columns.iter().map(|&x| at(y, x))); + } + for (j, _) in columns.iter().enumerate() { + let (mut best, mut row) = (f32::NEG_INFINITY, 0usize); + for (i, y) in (low..high).enumerate() { + let v = band[i * columns.len() + j]; + if v > best { + best = v; + row = y; + } + } + peaks.push(row as f64); + strength.push(best); + } + let med_strength = np_median(&mut strength.clone()) as f64; + if med_strength < 1e-6f64.max(3.0 * np_median(&mut band) as f64) { + return None; + } + let bar = np_percentiles(&strength, &[60.0])?[0]; + let (mut xs, mut ys): (Vec, Vec) = columns + .iter() + .zip(&peaks) + .zip(&strength) + .filter(|&(_, &s)| s >= bar) + .map(|((&x, &y), _)| (x as f64, y)) + .unzip(); + if xs.len() < min_points { + return None; + } + let mut residuals = Vec::new(); + let mut slope = 0.0; + for _ in 0..5 { + let intercept; + (slope, intercept) = polyfit1(&xs, &ys); + residuals = xs + .iter() + .zip(&ys) + .map(|(&x, &y)| (y - (slope * x + intercept)).abs()) + .collect(); + let bar = 2f64.max(median_f64(&mut residuals.clone()) * 2.5); + let keep: Vec = residuals.iter().map(|&r| r <= bar).collect(); + let kept = keep.iter().filter(|&&k| k).count(); + if kept == keep.len() || kept < min_points { + break; + } + (xs, ys) = (0..xs.len()) + .filter(|&i| keep[i]) + .map(|i| (xs[i], ys[i])) + .unzip(); + } + if median_f64(&mut residuals) > 2.0 { + return None; + } + Some(slope.atan().to_degrees()) +} + +/// The roll template fields `_rect_from_edge_profile` reads. +pub struct Template { + pub center_x: f64, + pub width: f64, + pub fallback_width: f64, + pub top: f64, + pub height: f64, + pub center_mad: f64, + pub width_mad: f64, +} + +const MIN_PROFILE_CONTRAST: f64 = 0.06; +/// How near the roll's film width a pair of edge-profile peaks must span before their +/// agreement replaces raw peak strength. +const EDGE_PAIR_WIDTH_TOL: f64 = 0.02; + +fn clamp_rect((x1, y1, x2, y2): (f64, f64, f64, f64)) -> (f64, f64, f64, f64) { + let c = |v: f64| v.clamp(0.0, 1.0); + let (x1, x2) = (c(x1).min(c(x2)), c(x1).max(c(x2))); + let (y1, y2) = (c(y1).min(c(y2)), c(y1).max(c(y2))); + (x1, y1, x2, y2) +} + +/// `_peak_near`: the strongest profile sample within `radius` of `expected`, as (position, value). +fn peak_near(profile: &[f32], expected: f64, radius: f64) -> (f64, f64) { + let n = profile.len(); + if n == 0 { + return (expected, 0.0); + } + let center = py_round(expected * (n - 1) as f64); + let half = 2i64.max(py_round(radius * n as f64)); + let (lo, hi) = ((center - half).max(0), (center + half + 1).min(n as i64)); + if hi <= lo { + return (expected, 0.0); + } + let local = &profile[lo as usize..hi as usize]; + // np.argmax: the first NaN, else the first maximum. + let offset = local.iter().position(|v| v.is_nan()).unwrap_or_else(|| { + local + .iter() + .enumerate() + .fold(0, |b, (i, &v)| if v > local[b] { i } else { b }) + }); + let idx = lo as usize + offset; + (idx as f64 / 1usize.max(n - 1) as f64, local[offset] as f64) +} + +/// `_rect_from_edge_profile`: a film box from the frame's vertical-edge profile at the edges the +/// roll expects, or None when the profile cannot place one. +pub fn rect_from_edge_profile( + profile: &[f32], + contrast: f64, + t: &Template, +) -> Option<(f64, f64, f64, f64)> { + if contrast < MIN_PROFILE_CONTRAST + || profile.len() < 8 + || !profile.iter().any(|v| v.is_finite()) + { + return None; + } + let q = np_percentiles(profile, &[50.0, 99.5, 70.0, 90.0])?; + let (baseline_level, peak_level) = (q[0] as f64, q[1] as f64); + if peak_level - baseline_level < 0.20 || peak_level < 1.35 * baseline_level.max(0.05) { + return None; + } + let radius = 0.045f64.max(4.0 * t.center_mad + 2.0 * t.width_mad); + let (left, left_strength) = peak_near(profile, t.center_x - t.width * 0.5, radius); + let (right, right_strength) = peak_near(profile, t.center_x + t.width * 0.5, radius); + let baseline = q[2] as f64; + let spread = (q[3] - q[0]) as f64; + let threshold = 0.24f64.max(baseline + 0.55 * spread); + let (mut left_ok, mut right_ok) = (left_strength >= threshold, right_strength >= threshold); + if !(left_ok && right_ok) && ((right - left) - t.width).abs() <= EDGE_PAIR_WIDTH_TOL { + (left_ok, right_ok) = (true, true); + } + let (x1, x2) = if left_ok && right_ok { + if right - left < 0.65 * t.width || right - left > 1.35 * t.fallback_width { + return None; + } + (left, right) + } else if left_ok && left_strength >= threshold + 0.12 { + (left, left + t.fallback_width) + } else if right_ok && right_strength >= threshold + 0.12 { + (right - t.fallback_width, right) + } else { + return None; + }; + Some(clamp_rect((x1, t.top, x2, t.top + t.height))) +} + +/// `_place_window_by_occupancy`: the start, in input pixels, of the `target_len` window within +/// [start, end) that holds the most occupancy (detection pixels, `scale` = detection/input). +/// Ties go to the centered position. +pub fn place_window_by_occupancy( + start: i64, + end: i64, + target_len: f64, + occupancy: &[f32], + scale: f64, +) -> i64 { + let d_start = py_round(start as f64 * scale).max(0); + let d_end = py_round(end as f64 * scale).min(occupancy.len() as i64); + let d_len = py_round(target_len * scale).max(1); + if d_len >= d_end - d_start { + return start; + } + let mut cs = vec![0f64]; + let mut sum = 0f64; + for &v in &occupancy[d_start as usize..d_end as usize] { + sum += v as f64; + cs.push(sum); + } + let n_pos = ((d_end - d_start) - d_len + 1) as usize; + let d_len = d_len as usize; + let scores: Vec = (0..n_pos).map(|i| cs[i + d_len] - cs[i]).collect(); + let top = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max) - 1e-9; + let centered = ((d_end - d_start) as f64 - d_len as f64) / 2.0; + let k = (0..n_pos) + .filter(|&i| scores[i] >= top) + .fold(None::, |b, i| match b { + Some(j) if (j as f64 - centered).abs() <= (i as f64 - centered).abs() => Some(j), + _ => Some(i), + }) + .unwrap_or(0); + let new_start = py_round((d_start as f64 + k as f64) / scale); + (start.max(new_start) as f64).min(end as f64 - target_len) as i64 +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index c41a81be9..24e300d8e 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -1,5 +1,6 @@ pub mod altprocess; pub mod analysis; +pub mod autocrop; pub mod bounds; pub mod color; pub mod components; @@ -74,5 +75,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::finish::register(m)?; py::flatfield::register(m)?; py::stitch::register(m)?; - py::skew::register(m) + py::skew::register(m)?; + py::autocrop::register(m) } diff --git a/negpy_core/src/py/autocrop.rs b/negpy_core/src/py/autocrop.rs new file mode 100644 index 000000000..e7df53dbd --- /dev/null +++ b/negpy_core/src/py/autocrop.rs @@ -0,0 +1,73 @@ +use numpy::{PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::autocrop::{self, Template}; + +/// `_top_edge_slope(lum, (y1, y2, x1, x2))` on an HxW float32 luminance plane: degrees or None. +#[pyfunction] +fn top_edge_slope( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + roi: (i64, i64, i64, i64), +) -> PyResult> { + let dims = (lum.shape()[0], lum.shape()[1]); + let img = lum + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous plane"))?; + let (y1, _, x1, x2) = roi; + Ok(super::detach(py, img.len(), || { + autocrop::top_edge_slope(img, dims, (y1, x1, x2)) + })) +} + +/// `_rect_from_edge_profile` for a float32 profile, its contrast and the roll template's +/// (center_x, width, fallback_width, top, height, center_mad, width_mad). +#[pyfunction] +fn rect_from_edge_profile( + py: Python<'_>, + profile: PyReadonlyArray1<'_, f32>, + contrast: f64, + template: (f64, f64, f64, f64, f64, f64, f64), +) -> PyResult> { + let p = profile + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous profile"))?; + let (center_x, width, fallback_width, top, height, center_mad, width_mad) = template; + let t = Template { + center_x, + width, + fallback_width, + top, + height, + center_mad, + width_mad, + }; + Ok(super::detach(py, p.len(), || { + autocrop::rect_from_edge_profile(p, contrast, &t) + })) +} + +/// `_place_window_by_occupancy(start, end, target_len, occupancy, scale)` for a float32 profile. +#[pyfunction] +fn place_window_by_occupancy( + py: Python<'_>, + start: i64, + end: i64, + target_len: f64, + occupancy: PyReadonlyArray1<'_, f32>, + scale: f64, +) -> PyResult { + let occ = occupancy + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous profile"))?; + Ok(super::detach(py, occ.len(), || { + autocrop::place_window_by_occupancy(start, end, target_len, occ, scale) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(top_edge_slope, m)?)?; + m.add_function(wrap_pyfunction!(rect_from_edge_profile, m)?)?; + m.add_function(wrap_pyfunction!(place_window_by_occupancy, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 139bef03f..ec1b32996 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -1,5 +1,6 @@ pub mod altprocess; pub mod analysis; +pub mod autocrop; pub mod bounds; pub mod color; pub mod components; diff --git a/negpy_core/src/py/skew.rs b/negpy_core/src/py/skew.rs index 8bbca9fca..702ee50ad 100644 --- a/negpy_core/src/py/skew.rs +++ b/negpy_core/src/py/skew.rs @@ -34,6 +34,63 @@ fn skew_sample<'py>( Ok((xs.into_pyarray(py), ys.into_pyarray(py))) } +type PyLine = ((f64, f64), (f64, f64), bool, f64, f64); + +fn py_line(l: skew::Line) -> PyLine { + (l.p1, l.p2, l.horizontal, l.inliers, l.rms) +} + +fn plane<'a>(lum: &'a PyReadonlyArray2<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let dims = (lum.shape()[0], lum.shape()[1]); + let img = lum + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous plane"))?; + Ok((img, dims)) +} + +/// `_fit_side(lum, horizontal, at, start, stop, outward)` on an HxW float32 luminance plane: +/// (p1, p2, horizontal, inlier fraction, rms) or None. +#[pyfunction] +fn skew_fit_side( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + horizontal: bool, + at: f64, + start: f64, + stop: f64, + outward: i32, +) -> PyResult> { + let (img, dims) = plane(&lum)?; + Ok(super::detach(py, img.len(), || { + skew::fit_side(img, dims, horizontal, (at, start, stop), outward) + }) + .map(py_line)) +} + +/// `_profile_lines(lum)`: the outermost long edges, each (p1, p2, horizontal, inliers, rms). +#[pyfunction] +fn skew_profile_lines(py: Python<'_>, lum: PyReadonlyArray2<'_, f32>) -> PyResult> { + let (img, dims) = plane(&lum)?; + let lines = super::detach(py, img.len(), || skew::profile_lines(img, dims)); + Ok(lines.into_iter().map(py_line).collect()) +} + +/// `_at_canvas_edges` for lines given as (horizontal, position). +#[pyfunction] +fn skew_at_canvas_edges( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + lines: Vec<(bool, f64)>, +) -> PyResult { + let (img, dims) = plane(&lum)?; + Ok(super::detach(py, img.len(), || { + skew::at_canvas_edges(img, dims, &lines) + })) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(skew_sample, m)?) + m.add_function(wrap_pyfunction!(skew_sample, m)?)?; + m.add_function(wrap_pyfunction!(skew_fit_side, m)?)?; + m.add_function(wrap_pyfunction!(skew_profile_lines, m)?)?; + m.add_function(wrap_pyfunction!(skew_at_canvas_edges, m)?) } diff --git a/negpy_core/src/py/warp.rs b/negpy_core/src/py/warp.rs index 36fb524e0..5bb5e6dea 100644 --- a/negpy_core/src/py/warp.rs +++ b/negpy_core/src/py/warp.rs @@ -1,5 +1,6 @@ use numpy::{ - PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, PyUntypedArrayMethods, + PyArray2, PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -154,7 +155,43 @@ fn warp_perspective<'py>( }) } +/// `cv2.getRotationMatrix2D(center, angle, scale)` as a 2x3 float64 array. +#[pyfunction] +fn rotation_matrix<'py>( + py: Python<'py>, + center: (f32, f32), + angle: f64, + scale: f64, +) -> Bound<'py, PyArray2> { + let m = warp::rotation_matrix(center, angle, scale); + PyArray2::from_vec2(py, &[m[..3].to_vec(), m[3..].to_vec()]).expect("two rows of three") +} + +/// `cv2.getPerspectiveTransform(src, dst)` for two 4x2 float32 point arrays, as 3x3 float64. +#[pyfunction] +fn perspective_transform<'py>( + py: Python<'py>, + src: PyReadonlyArray2<'py, f32>, + dst: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let quad = |p: &PyReadonlyArray2<'_, f32>| -> PyResult<[[f32; 2]; 4]> { + let a = p.as_array(); + if a.shape() != [4, 2] { + return Err(PyValueError::new_err("needs 4x2 points")); + } + Ok(std::array::from_fn(|i| [a[[i, 0]], a[[i, 1]]])) + }; + let m = warp::perspective_transform(&quad(&src)?, &quad(&dst)?) + .ok_or_else(|| PyValueError::new_err("the points do not define a perspective transform"))?; + Ok( + PyArray2::from_vec2(py, &[m[..3].to_vec(), m[3..6].to_vec(), m[6..].to_vec()]) + .expect("3x3"), + ) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rotation_matrix, m)?)?; + m.add_function(wrap_pyfunction!(perspective_transform, m)?)?; m.add_function(wrap_pyfunction!(remap, m)?)?; m.add_function(wrap_pyfunction!(remap_radial, m)?)?; m.add_function(wrap_pyfunction!(warp_affine, m)?)?; diff --git a/negpy_core/src/skew.rs b/negpy_core/src/skew.rs index fa4e9b0b4..426386ec3 100644 --- a/negpy_core/src/skew.rs +++ b/negpy_core/src/skew.rs @@ -124,3 +124,427 @@ pub fn sample( } (xs, ys) } + +/// NumPy's PCG64 as `np.random.default_rng(0)` seeds it, and `integers(0, n)` for n < 2^32 +/// (Lemire's bounded draw on the 32-bit halves of each 64-bit output). +struct Pcg64 { + state: u128, + inc: u128, + half: Option, +} + +impl Pcg64 { + const MULT: u128 = (2549297995355413924u128 << 64) | 4865540595714422341u128; + + fn seed0() -> Self { + Pcg64 { + state: (0x1aa1b5345996452du128 << 64) | 0x09585eb7a69561e3, + inc: (0x418ddadb3af71a82u128 << 64) | 0x588133bc447873a9, + half: None, + } + } + + fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_mul(Self::MULT).wrapping_add(self.inc); + let s = self.state; + (((s >> 64) as u64) ^ (s as u64)).rotate_right((s >> 122) as u32) + } + + fn next_u32(&mut self) -> u32 { + if let Some(v) = self.half.take() { + return v; + } + let v = self.next_u64(); + self.half = Some((v >> 32) as u32); + v as u32 + } + + /// One draw of `integers(0, n)`, n >= 1. + fn below(&mut self, n: u32) -> u32 { + let rng = n - 1; + if rng == 0 { + return 0; + } + if rng == u32::MAX { + return self.next_u32(); + } + let excl = rng + 1; + let mut m = self.next_u32() as u64 * excl as u64; + let mut left = m as u32; + if left < excl { + let threshold = (u32::MAX - rng) % excl; + while left < threshold { + m = self.next_u32() as u64 * excl as u64; + left = m as u32; + } + } + (m >> 32) as u32 + } +} + +/// Least-squares line `y = slope * x + intercept` through the points (`np.polyfit(x, y, 1)` up +/// to rounding: centered sums, not LAPACK's SVD). +fn polyfit1(xs: &[f64], ys: &[f64]) -> (f64, f64) { + let n = xs.len() as f64; + let mx = xs.iter().sum::() / n; + let my = ys.iter().sum::() / n; + let (mut sxy, mut sxx) = (0.0, 0.0); + for (&x, &y) in xs.iter().zip(ys) { + sxy += (x - mx) * (y - my); + sxx += (x - mx) * (x - mx); + } + let slope = sxy / sxx; + (slope, my - slope * mx) +} + +/// `_ransac_line`: the straight run of peaks with the most distinct stations, preferring one +/// near `expected`. +fn ransac_line(xs: &[f64], ys: &[f64], expected: f64, half: f64, span: f64) -> Option<(f64, f64)> { + let n = xs.len(); + if n < 12 { + return None; + } + let mut rng = Pcg64::seed0(); + let draws: Vec = (0..600).map(|_| rng.below(n as u32) as usize).collect(); + let (a, b) = draws.split_at(300); + let max_slope = 4.0f64.to_radians().tan(); + let (lo, hi) = xs + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &x| { + (l.min(x), h.max(x)) + }); + let mid_x = 0.5 * (lo + hi); + let mut best: Option<(f64, Vec)> = None; + let mut best_stations = 0usize; + for (&a, &b) in a.iter().zip(b) { + if (xs[a] - xs[b]).abs() < 0.3 * span { + continue; + } + let slope = (ys[b] - ys[a]) / (xs[b] - xs[a]); + if slope.abs() > max_slope { + continue; + } + let intercept = ys[a] - slope * xs[a]; + let inlier: Vec = xs + .iter() + .zip(ys) + .map(|(&x, &y)| (y - (slope * x + intercept)).abs() <= 1.5) + .collect(); + let mut seen: Vec = (0..n).filter(|&i| inlier[i]).map(|i| xs[i]).collect(); + seen.sort_unstable_by(f64::total_cmp); + seen.dedup(); + let stations = seen.len(); + let mid = slope * mid_x + intercept; + let score = stations as f64 * (1.0 - 0.5 * ((mid - expected).abs() / half).min(1.0)); + if best.as_ref().is_none_or(|(s, _)| score > *s) { + best = Some((score, inlier)); + best_stations = stations; + } + } + let (_, inlier) = best?; + if best_stations < 12 { + return None; + } + let (px, py): (Vec, Vec) = (0..n) + .filter(|&i| inlier[i]) + .map(|i| (xs[i], ys[i])) + .unzip(); + Some(polyfit1(&px, &py)) +} + +/// A fitted frame edge in pixels of the detection buffer. +pub struct Line { + pub p1: (f64, f64), + pub p2: (f64, f64), + pub horizontal: bool, + pub inliers: f64, + pub rms: f64, +} + +/// A box bounds its edge to within the film detector's snap window (`snap_film_bounds`). +const BOX_TOLERANCE: f64 = 0.02; +const BOX_TOLERANCE_PX: f64 = 16.0; +const MIN_LINE_SPAN: f64 = 0.25; +/// Admits the bow a scanning lens or film curl puts on a frame edge; its chord stays level. +const MAX_LINE_RMS: f64 = 2.0; +const MIN_INLIER_FRACTION: f64 = 0.5; + +/// `_fit_side`: the edge running along one axis near `at`, between `start` and `stop`, on an +/// HxW luminance plane. `outward` is the sign of the box's outside across the axis, or 0 when +/// `at` is the edge itself. +pub fn fit_side( + lum: &[f32], + dims: (usize, usize), + horizontal: bool, + (at, start, stop): (f64, f64, f64), + outward: i32, +) -> Option { + let (h, w) = if horizontal { dims } else { (dims.1, dims.0) }; + let length = stop - start; + if length < MIN_LINE_SPAN * w as f64 || !(at >= 2.0 && at <= h as f64 - 3.0) { + return None; + } + let num = (240f64.min(24f64.max(length / 4.0))) as usize; + let (first, last) = (start + 0.1 * length, stop - 0.1 * length); + let step = (last - first) / (num - 1) as f64; + let mut stations: Vec = (0..num).map(|i| i as f64 * step + first).collect(); + stations[num - 1] = last; + + let inside = 10f64.max(0.035 * h.max(w) as f64); + let outside = if outward == 0 { + inside + } else { + BOX_TOLERANCE_PX.max(BOX_TOLERANCE * h as f64) + }; + let center = at + 0.5 * outward as f64 * (outside - inside); + let half = 0.5 * (inside + outside); + let (xs, ys) = sample( + lum, + dims, + horizontal, + (&stations, &vec![center; num]), + half, + false, + ); + let (mut slope, mut intercept) = ransac_line(&xs, &ys, at, half, length)?; + let centers: Vec = stations.iter().map(|&x| slope * x + intercept).collect(); + let (xs, ys) = sample( + lum, + dims, + horizontal, + (&stations, ¢ers), + 6f64.max(0.3 * inside), + true, + ); + if xs.len() < 12 { + return None; + } + let mut keep = vec![true; xs.len()]; + for _ in 0..8 { + let (kx, ky): (Vec, Vec) = (0..xs.len()) + .filter(|&i| keep[i]) + .map(|i| (xs[i], ys[i])) + .unzip(); + (slope, intercept) = polyfit1(&kx, &ky); + let res: Vec = xs + .iter() + .zip(&ys) + .map(|(&x, &y)| y - (slope * x + intercept)) + .collect(); + let mut kept: Vec = (0..xs.len()).filter(|&i| keep[i]).map(|i| res[i]).collect(); + let med = crate::stats::median_f64(&mut kept.clone()); + for v in &mut kept { + *v = (*v - med).abs(); + } + let mad = 1.4826 * crate::stats::median_f64(&mut kept); + let bar = 1f64.max(2.5 * mad); + let new: Vec = res.iter().map(|r| r.abs() <= bar).collect(); + if new.iter().filter(|&&v| v).count() < 12 || new == keep { + break; + } + keep = new; + } + let kept: Vec = (0..xs.len()).filter(|&i| keep[i]).collect(); + let inliers = kept.len() as f64 / num as f64; + let sq: f64 = kept + .iter() + .map(|&i| { + let e = ys[i] - (slope * xs[i] + intercept); + e * e + }) + .sum(); + let rms = (sq / kept.len() as f64).sqrt(); + if inliers < MIN_INLIER_FRACTION || rms > MAX_LINE_RMS { + return None; + } + let (x1, x2) = kept + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &i| { + (l.min(xs[i]), h.max(xs[i])) + }); + if x2 - x1 < MIN_LINE_SPAN * w as f64 { + return None; + } + let (y1, y2) = (slope * x1 + intercept, slope * x2 + intercept); + let (p1, p2) = if horizontal { + ((x1, y1), (x2, y2)) + } else { + ((y1, x1), (y2, x2)) + }; + Some(Line { + p1, + p2, + horizontal, + inliers, + rms, + }) +} + +/// `_outer_peaks`: first and last local maxima of `coverage` at or above `floor`, off the +/// canvas border, at least `_MIN_PAIR_SEPARATION` of the extent apart. +fn outer_peaks(coverage: &[f64], floor: f64) -> Option<(usize, usize)> { + let n = coverage.len(); + let peaks: Vec = (3..n.saturating_sub(3)) + .filter(|&i| { + coverage[i] >= floor && coverage[i] >= coverage[i - 1] && coverage[i] >= coverage[i + 1] + }) + .collect(); + let (&a, &b) = (peaks.first()?, peaks.last()?); + (peaks.len() >= 2 && (b - a) as f64 >= 0.3 * n as f64).then_some((a, b)) +} + +/// Pixels of `|sobel(lum)|` at a quarter of its 99.5th percentile or more, dilated along the +/// edge's normal by `spread`. +fn strong_edges(lum: &[f32], (h, w): (usize, usize), dx: bool, spread: usize) -> Option> { + use crate::filter::{Border, sep_filter2d}; + let (smooth, deriv) = ([1.0f32, 2.0, 1.0], [-1.0f32, 0.0, 1.0]); + let (kx, ky) = if dx { (deriv, smooth) } else { (smooth, deriv) }; + let mut grad = vec![0f32; h * w]; + sep_filter2d(lum, &mut grad, (h, w, 1), (&kx, &ky), Border::Reflect101)?; + grad.iter_mut().for_each(|v| *v = v.abs()); + let bar = (0.25 * crate::stats::np_percentiles(&grad, &[99.5])?[0] as f64) as f32; + let strong: Vec = grad.iter().map(|&g| (g >= bar) as u8).collect(); + let mut out = vec![0u8; h * w]; + let k = 2 * spread + 1; + let ksize = if dx { (k, 1) } else { (1, k) }; + crate::morph::morphology( + &strong, + &mut out, + (h, w, 1), + crate::morph::Op::Dilate, + ksize, + 1, + crate::filter::Border::Constant, + )?; + Some(out) +} + +/// `_profile_lines`: the outermost long edges by row and column coverage, for frames the film +/// detector cannot box. Lines come in opposite pairs or not at all, per axis. +pub fn profile_lines(lum: &[f32], (h, w): (usize, usize)) -> Vec { + let spread = 3usize.max((0.02 * h.max(w) as f64) as usize); + let mut lines = Vec::new(); + let Some(gy) = strong_edges(lum, (h, w), false, spread) else { + return lines; + }; + let (c0, c1) = ((0.05 * w as f64) as usize, (0.95 * w as f64) as usize); + let rows: Vec = (0..h) + .map(|y| { + gy[y * w + c0..y * w + c1] + .iter() + .map(|&v| v as u64) + .sum::() as f64 + / (c1 - c0) as f64 + }) + .collect(); + let found_rows = outer_peaks(&rows, 0.35); + let (top, bottom) = found_rows.unwrap_or((0, h)); + if let Some((a, b)) = found_rows { + for y in [a, b] { + lines.extend(fit_side(lum, (h, w), true, (y as f64, 0.0, w as f64), 0)); + } + } + if let Some(gx) = strong_edges(lum, (h, w), true, spread) { + let inset = (0.05 * (bottom - top) as f64) as usize; + let (lo, hi) = (top + inset, bottom - inset); + if hi > lo + 10 { + let cols: Vec = (0..w) + .map(|x| { + (lo..hi).map(|y| gx[y * w + x] as u64).sum::() as f64 / (hi - lo) as f64 + }) + .collect(); + if let Some((a, b)) = outer_peaks(&cols, 0.35) { + for x in [a, b] { + lines.extend(fit_side( + lum, + (h, w), + false, + (x as f64, top as f64, bottom as f64), + 0, + )); + } + } + } + } + let count = |horizontal: bool, lines: &[Line]| { + lines.iter().filter(|l| l.horizontal == horizontal).count() + }; + let (nh, nv) = (count(true, &lines), count(false, &lines)); + lines.retain(|l| if l.horizontal { nh >= 2 } else { nv >= 2 }); + lines +} + +/// `_at_canvas_edges`: whether every line (horizontal, position) hugs the canvas edge, paired on +/// both axes, with no opaque holder outside it. +pub fn at_canvas_edges(lum: &[f32], (h, w): (usize, usize), lines: &[(bool, f64)]) -> bool { + // A tight scan's border lines sit within this fraction of the cross extent from the edge. + const CANVAS_EDGE_BAND: f64 = 0.12; + // Film base or bed outside a border never reads this dark; an opaque holder does. + const OPAQUE_RING: f64 = 0.04; + const MIN_PAIR_SEPARATION: f64 = 0.3; + let extent = |horizontal: bool| if horizontal { h } else { w }; + if lines.is_empty() + || lines.iter().any(|&(horizontal, position)| { + let e = extent(horizontal) as f64; + position.min(e - position) > CANVAS_EDGE_BAND * e + }) + { + return false; + } + for &(horizontal, position) in lines { + let extent = extent(horizontal); + let index = position as usize; + let (lo, hi) = if position < extent as f64 / 2.0 { + (0, index.saturating_sub(2)) + } else { + ((index + 3).min(extent), extent) + }; + let mut outside: Vec = if horizontal { + lum[lo * w..hi * w].to_vec() + } else { + lum.chunks_exact(w) + .flat_map(|row| row[lo..hi].iter().copied()) + .collect() + }; + if !outside.is_empty() && (crate::stats::np_median(&mut outside) as f64) < OPAQUE_RING { + return false; + } + } + let spread = |horizontal: bool, extent: usize| { + let pos: Vec = lines + .iter() + .filter(|l| l.0 == horizontal) + .map(|l| l.1) + .collect(); + if pos.len() < 2 { + return 0.0; + } + let (lo, hi) = pos + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &p| { + (l.min(p), h.max(p)) + }); + (hi - lo) / extent as f64 + }; + spread(true, h) >= MIN_PAIR_SEPARATION && spread(false, w) >= MIN_PAIR_SEPARATION +} + +#[cfg(test)] +mod tests { + use super::Pcg64; + + fn draws(n: u32, count: usize) -> Vec { + let mut rng = Pcg64::seed0(); + (0..count).map(|_| rng.below(n)).collect() + } + + #[test] + fn default_rng_0_integers_match_numpy() { + assert_eq!(draws(17, 12), [14, 10, 8, 4, 5, 0, 1, 0, 2, 13, 11, 15]); + assert_eq!( + draws(1_000_003, 6), + [850626, 636963, 511138, 269787, 307830, 40973] + ); + assert_eq!(draws(2, 8), [1, 1, 1, 0, 0, 0, 0, 0]); + } +} diff --git a/negpy_core/src/warp.rs b/negpy_core/src/warp.rs index 897b11f06..85499257a 100644 --- a/negpy_core/src/warp.rs +++ b/negpy_core/src/warp.rs @@ -446,6 +446,86 @@ fn persp_coords(start: [f64; 3], step: [f64; 3], xs: &mut [i32], ys: &mut [i32]) } } +/// `cv2.getRotationMatrix2D(center, angle, scale)`: the 2x3 matrix that turns about `center` +/// by `angle` degrees counter-clockwise. OpenCV takes the center as float32. +pub fn rotation_matrix((cx, cy): (f32, f32), angle: f64, scale: f64) -> [f64; 6] { + let angle = angle * (std::f64::consts::PI / 180.0); + let alpha = angle.cos() * scale; + let beta = angle.sin() * scale; + let (cx, cy) = (cx as f64, cy as f64); + [ + alpha, + beta, + (1.0 - alpha) * cx - beta * cy, + -beta, + alpha, + beta * cx + (1.0 - alpha) * cy, + ] +} + +/// `cv2.getPerspectiveTransform(src, dst)`: the 3x3 matrix mapping four float32 points onto +/// four, solved with c22 = 1 by OpenCV's LU with partial pivoting. None for a degenerate quad. +pub fn perspective_transform(src: &[[f32; 2]; 4], dst: &[[f32; 2]; 4]) -> Option<[f64; 9]> { + let mut a = [[0f64; 8]; 8]; + let mut b = [0f64; 8]; + for i in 0..4 { + let ([x, y], [u, v]) = (src[i], dst[i]); + a[i][0] = x as f64; + a[i + 4][3] = x as f64; + a[i][1] = y as f64; + a[i + 4][4] = y as f64; + a[i][2] = 1.0; + a[i + 4][5] = 1.0; + // float32 products, as OpenCV multiplies two Point2f fields. + a[i][6] = (-x * u) as f64; + a[i][7] = (-y * u) as f64; + a[i + 4][6] = (-x * v) as f64; + a[i + 4][7] = (-y * v) as f64; + b[i] = u as f64; + b[i + 4] = v as f64; + } + let (a0, b0) = (a, b); + for i in 0..8 { + let mut k = i; + for j in i + 1..8 { + if a[j][i].abs() > a[k][i].abs() { + k = j; + } + } + if a[k][i].abs() < f64::EPSILON * 100.0 { + return None; + } + if k != i { + a.swap(i, k); + b.swap(i, k); + } + let d = -1.0 / a[i][i]; + for j in i + 1..8 { + let alpha = a[j][i] * d; + let pivot = a[i]; + for (v, p) in a[j][i + 1..].iter_mut().zip(&pivot[i + 1..]) { + *v += alpha * p; + } + b[j] += alpha * b[i]; + } + } + for i in (0..8).rev() { + let mut s = b[i]; + for k in i + 1..8 { + s -= a[i][k] * b[k]; + } + b[i] = s / a[i][i]; + } + let residual = (0..8) + .map(|r| { + let e = (0..8).map(|c| a0[r][c] * b[c]).sum::() - b0[r]; + e * e + }) + .sum::() + .sqrt(); + (residual < 1e-8).then(|| [b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], 1.0]) +} + #[cfg(test)] mod tests { use super::*; diff --git a/scripts/replay.py b/scripts/replay.py index 4ae7673cb..99d9bba3f 100644 --- a/scripts/replay.py +++ b/scripts/replay.py @@ -270,7 +270,7 @@ def _calls(keys: list[str]): def replay(phases: list[int] | None) -> int: failed = 0 manifest = load_manifest() - for key, fn, files in _calls(_targets(phases, ("todo", "ported"))): + for key, fn, files in _calls(_targets(phases, ("todo", "ported", "stays"))): compare = manifest[key].get("compare", "exact") bad = [] for f in files: @@ -280,7 +280,9 @@ def replay(phases: list[int] | None) -> int: if compare != "quality" and not same(result, rec["result"], compare): bad.append(f"{f.name} ({rec['frame']})") failed += bool(bad) - print(f"{'DIFF' if bad else 'qual' if compare == 'quality' else 'same'}{len(files) - len(bad):3d}/{len(files):<3d} {key} {' '.join(bad[:5])}") + print( + f"{'DIFF' if bad else 'qual' if compare == 'quality' else 'same'}{len(files) - len(bad):3d}/{len(files):<3d} {key} {' '.join(bad[:5])}" + ) return 1 if failed else 0 diff --git a/tests/test_core_autocrop.py b/tests/test_core_autocrop.py new file mode 100644 index 000000000..407ae8942 --- /dev/null +++ b/tests/test_core_autocrop.py @@ -0,0 +1,164 @@ +"""The batch autocrop profile reads and the occupancy window against the NumPy code they replace.""" + +import numpy as np +import negpy_core +import pytest + +from negpy.features.geometry.batch_autocrop import CropEvidence, RollCropTemplate, _rect_from_edge_profile, _top_edge_slope +from negpy.features.geometry.logic import _place_window_by_occupancy + + +def _top_edge_slope_numpy(lum, roi): + y1, _, x1, x2 = roi + height = lum.shape[0] + left, right = x1 + 20, x2 - 20 + columns = np.arange(left, right, 3) + min_points = max(20, round((right - left) * 0.08)) + if columns.size < min_points: + return None + radius = max(12, round(height * 0.035)) + low, high = max(0, y1 - radius), min(height, y1 + radius) + if high - low < 4: + return None + gradient = np.abs(negpy_core.sobel(negpy_core.gaussian_blur(lum.astype(np.float32), (0, 0), 1.5), 0, 1)) + band = gradient[low:high, columns] + rows = low + np.argmax(band, axis=0) + strength = band.max(axis=0) + if float(np.median(strength)) < max(1e-6, 3.0 * float(np.median(band))): + return None + points = np.column_stack([columns, rows])[strength >= np.quantile(strength, 0.60)] + if len(points) < min_points: + return None + residuals = np.zeros(len(points)) + slope = 0.0 + for _ in range(5): + slope, intercept = np.polyfit(points[:, 0], points[:, 1], 1) + residuals = np.abs(points[:, 1] - (slope * points[:, 0] + intercept)) + keep = residuals <= max(2.0, float(np.median(residuals)) * 2.5) + if keep.all() or keep.sum() < min_points: + break + points = points[keep] + if float(np.median(residuals)) > 2.0: + return None + return float(np.degrees(np.arctan(slope))) + + +def _tilted_box(tilt, seed, size=(400, 600)): + h, w = size + y, x = np.mgrid[0:h, 0:w].astype(np.float64) + top = 100 + np.tan(np.radians(tilt)) * (x - w / 2) + lum = np.where(y >= top, 0.8, 0.1) + np.random.default_rng(seed).normal(0, 0.02, (h, w)) + return lum.astype(np.float32) + + +@pytest.mark.parametrize("tilt", [-2.0, -0.7, 0.0, 0.3, 1.6]) +def test_top_edge_slope_matches_numpy(tilt): + for seed in range(4): + lum = _tilted_box(tilt, seed) + for roi in ((100, 300, 50, 550), (8, 300, 0, 600), (100, 300, 300, 330)): + want, got = _top_edge_slope_numpy(lum, roi), _top_edge_slope(lum, roi) + assert (want is None) == (got is None) + if want is not None: + assert got == pytest.approx(want, abs=1e-9) + + +def _peak_near(profile, expected, radius): + if profile.size == 0: + return expected, 0.0 + center = int(round(expected * (profile.size - 1))) + half = max(2, int(round(radius * profile.size))) + lo, hi = max(0, center - half), min(profile.size, center + half + 1) + if hi <= lo: + return expected, 0.0 + local = profile[lo:hi] + offset = int(np.argmax(local)) + return (lo + offset) / max(profile.size - 1, 1), float(local[offset]) + + +def _clamp(rect): + x1, y1, x2, y2 = rect + x1, x2 = sorted((float(np.clip(x1, 0.0, 1.0)), float(np.clip(x2, 0.0, 1.0)))) + y1, y2 = sorted((float(np.clip(y1, 0.0, 1.0)), float(np.clip(y2, 0.0, 1.0)))) + return x1, y1, x2, y2 + + +def _rect_numpy(profile, contrast, t): + if contrast < 0.06 or profile.size < 8 or not np.any(np.isfinite(profile)): + return None + base, peak = float(np.percentile(profile, 50)), float(np.percentile(profile, 99.5)) + if peak - base < 0.20 or peak < 1.35 * max(base, 0.05): + return None + radius = max(0.045, 4.0 * t.center_mad + 2.0 * t.width_mad) + left, ls = _peak_near(profile, t.center_x - t.width * 0.5, radius) + right, rs = _peak_near(profile, t.center_x + t.width * 0.5, radius) + baseline = float(np.percentile(profile, 70)) + spread = float(np.percentile(profile, 90) - np.percentile(profile, 50)) + threshold = max(0.24, baseline + 0.55 * spread) + lok, rok = ls >= threshold, rs >= threshold + if not (lok and rok) and abs((right - left) - t.width) <= 0.02: + lok = rok = True + if not lok and not rok: + return None + if lok and rok: + if right - left < 0.65 * t.width or right - left > 1.35 * t.fallback_width: + return None + x1, x2 = left, right + elif lok and ls >= threshold + 0.12: + x1, x2 = left, left + t.fallback_width + elif rok and rs >= threshold + 0.12: + x1, x2 = right - t.fallback_width, right + else: + return None + return _clamp((x1, t.top, x2, t.top + t.height)) + + +def test_rect_from_edge_profile_matches_numpy(): + rng = np.random.default_rng(7) + hits = 0 + for _ in range(3000): + n = int(rng.integers(8, 400)) + profile = (rng.random(n) * rng.uniform(0.05, 0.6)).astype(np.float32) + t = RollCropTemplate( + width=float(rng.uniform(0.5, 0.95)), fallback_width=float(rng.uniform(0.5, 1.0)), height=0.8, center_x=float(rng.uniform(0.4, 0.6)), + top=0.1, correction_angle=0.0, width_mad=float(rng.uniform(0, 0.02)), center_mad=float(rng.uniform(0, 0.02)), top_mad=0.0, + angle_mad=0.0, confidence=0.7, sample_count=5, + ) # fmt: skip + for edge in (t.center_x - t.width / 2, t.center_x + t.width / 2): + if rng.random() < 0.8: + profile[int(np.clip(round((edge + rng.normal(0, 0.01)) * (n - 1)), 0, n - 1))] = rng.uniform(0.5, 2.0) + if rng.random() < 0.05: + profile[int(rng.integers(n))] = np.nan + item = CropEvidence( + "k", (100, 150), None, 0.0, 0.0, vertical_edge_contrast=float(rng.uniform(0, 0.3)), vertical_edge_profile=profile + ) + want = _rect_numpy(profile, item.vertical_edge_contrast, t) + assert _rect_from_edge_profile(item, t) == want + hits += want is not None + assert hits > 100 + + +def _window_numpy(start, end, target_len, occupancy, scale): + d_start = max(0, int(round(start * scale))) + d_end = min(occupancy.size, int(round(end * scale))) + d_len = max(1, int(round(target_len * scale))) + if d_len >= d_end - d_start: + return start + cs = np.concatenate(([0.0], np.cumsum(occupancy[d_start:d_end], dtype=np.float64))) + n_pos = (d_end - d_start) - d_len + 1 + scores = cs[d_len : d_len + n_pos] - cs[:n_pos] + candidates = np.where(scores >= float(np.max(scores)) - 1e-9)[0] + centered = ((d_end - d_start) - d_len) / 2.0 + k = int(candidates[np.argmin(np.abs(candidates - centered))]) + return int(min(max(start, round((d_start + k) / scale)), end - target_len)) + + +def test_place_window_by_occupancy_matches_numpy(): + rng = np.random.default_rng(3) + for _ in range(5000): + n = int(rng.integers(5, 300)) + occ = (rng.random(n) > rng.random()).astype(np.float32) * rng.choice([1.0, 0.5], n).astype(np.float32) + scale = float(rng.choice([1.0, 0.5, 0.37, 0.25])) + end = int(rng.integers(2, int(n / scale) + 2)) + start = int(rng.integers(0, end)) + target = float(rng.uniform(1, end - start + 1)) if rng.random() < 0.5 else float(rng.integers(1, end - start + 1)) + assert _place_window_by_occupancy(start, end, target, occ, scale) == _window_numpy(start, end, target, occ, scale) diff --git a/tests/test_core_skew.py b/tests/test_core_skew.py index a900b6ba4..8880d074f 100644 --- a/tests/test_core_skew.py +++ b/tests/test_core_skew.py @@ -54,3 +54,110 @@ def test_skew_sample_matches_numpy(horizontal, nearest): got = negpy_core.skew_sample(lum, horizontal, stations, center, 14.0, nearest) assert want[0].tobytes() == got[0].tobytes() assert want[1].tobytes() == got[1].tobytes() + + +def _ransac_line_numpy(xs, ys, expected, half, span): + if xs.size < 12: + return None + rng = np.random.default_rng(0) + a, b = rng.integers(0, xs.size, (2, 300)) + ok = np.abs(xs[a] - xs[b]) >= 0.3 * span + a, b = a[ok], b[ok] + if a.size == 0: + return None + slope = (ys[b] - ys[a]) / (xs[b] - xs[a]) + ok = np.abs(slope) <= math.tan(math.radians(4.0)) + a, b, slope = a[ok], b[ok], slope[ok] + if a.size == 0: + return None + intercept = ys[a] - slope * xs[a] + inlier = np.abs(ys[None, :] - (slope[:, None] * xs[None, :] + intercept[:, None])) <= 1.5 + stations = np.array([np.unique(xs[row]).size for row in inlier]) + mid = slope * (0.5 * (xs.min() + xs.max())) + intercept + score = stations * (1.0 - 0.5 * np.minimum(1.0, np.abs(mid - expected) / half)) + best = int(np.argmax(score)) + if stations[best] < 12: + return None + return np.polyfit(xs[inlier[best]], ys[inlier[best]], 1) + + +def _fit_side_numpy(lum, horizontal, at, start, stop, outward=0): + """skew.py's NumPy _fit_side, the reference for negpy_core.skew_fit_side.""" + h, w = lum.shape if horizontal else lum.T.shape + length = stop - start + if length < 0.25 * w or not (at >= 2 and at <= h - 3): + return None + stations = np.linspace(start + 0.1 * length, stop - 0.1 * length, int(min(240, max(24, length / 4)))) + plane = np.ascontiguousarray(lum, dtype=np.float32) + + def sample(center, half, nearest): + return negpy_core.skew_sample(plane, horizontal, stations, np.ascontiguousarray(center, dtype=np.float64), float(half), nearest) + + inside = max(10.0, 0.035 * max(h, w)) + outside = inside if outward == 0 else max(16.0, 0.02 * h) + center = float(at) + 0.5 * outward * (outside - inside) + half = 0.5 * (inside + outside) + xs, ys = sample(np.full(stations.size, center), half, False) + fit = _ransac_line_numpy(xs, ys, float(at), half, length) + if fit is None: + return None + xs, ys = sample(np.polyval(fit, stations), max(6.0, 0.3 * inside), True) + if xs.size < 12: + return None + keep = np.ones(xs.size, bool) + for _ in range(8): + fit = np.polyfit(xs[keep], ys[keep], 1) + res = ys - np.polyval(fit, xs) + mad = 1.4826 * float(np.median(np.abs(res[keep] - np.median(res[keep])))) + new = np.abs(res) <= max(1.0, 2.5 * mad) + if new.sum() < 12 or np.array_equal(new, keep): + break + keep = new + inliers = float(keep.sum()) / stations.size + rms = float(np.sqrt(np.mean((ys[keep] - np.polyval(fit, xs[keep])) ** 2))) + if inliers < 0.5 or rms > 2.0: + return None + x1, x2 = float(xs[keep].min()), float(xs[keep].max()) + if x2 - x1 < 0.25 * w: + return None + y1, y2 = float(np.polyval(fit, x1)), float(np.polyval(fit, x2)) + p1, p2 = ((x1, y1), (x2, y2)) if horizontal else ((y1, x1), (y2, x2)) + return p1, p2, horizontal, inliers, rms + + +def _frame(angle=1.2, size=(420, 600), seed=5): + """A bright film box on a dark bed, turned by `angle` degrees, with grain.""" + h, w = size + y, x = np.mgrid[0:h, 0:w].astype(np.float64) + t = math.radians(angle) + u = (x - w / 2) * math.cos(t) + (y - h / 2) * math.sin(t) + v = -(x - w / 2) * math.sin(t) + (y - h / 2) * math.cos(t) + lum = np.where((np.abs(u) < 0.38 * w) & (np.abs(v) < 0.36 * h), 0.85, 0.08) + return (lum + np.random.default_rng(seed).normal(0, 0.01, lum.shape)).astype(np.float32) + + +def _close(a, b, tol=1e-6): + if a is None or b is None: + return a is None and b is None + flat = lambda line: np.array([*line[0], *line[1], line[3], line[4]]) # noqa: E731 + return a[2] == b[2] and np.allclose(flat(a), flat(b), rtol=0, atol=tol) + + +@pytest.mark.parametrize("angle", [-3.0, -0.4, 0.0, 0.9, 2.5]) +@pytest.mark.parametrize("horizontal", [True, False]) +def test_fit_side_matches_numpy(angle, horizontal): + lum = _frame(angle, seed=int(angle * 10) + 50) + h, w = lum.shape + sides = ( + [(0.14 * h, 0.12 * w, 0.88 * w, -1), (0.86 * h, 0.12 * w, 0.88 * w, 1)] + if horizontal + else [(0.12 * w, 0.14 * h, 0.86 * h, -1), (0.88 * w, 0.14 * h, 0.86 * h, 1)] + ) + found = 0 + for at, start, stop, outward in sides: + for out in (outward, 0): + want = _fit_side_numpy(lum, horizontal, at, start, stop, out) + got = negpy_core.skew_fit_side(lum, horizontal, at, start, stop, out) + assert _close(got, want), (at, out, got, want) + found += want is not None + assert found > 0 From 2eb5651a3b2f0a74e382b56dd0b7c1945e5e66a6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:54:51 +0200 Subject: [PATCH 107/133] perf(core): seam and feather blends in Rust Move seam_blend, feather_blend and the seam weight to negpy_core::stitch. The distances come from negpy_core.distance_transform instead of IPP's float32 chamfer. The two distances to the cut run at once, and each part is added to the mean in one parallel pass without NumPy temporaries. Gate tests/test_core_seam.py: distances within 0.005 of cv2 on masks up to 1600 px, blends within 1e-4 of the cv2 version. Measured on the recorded panorama and synthetic overlaps: distances within 0.0042, seam_blend within 1.2e-7, feather_blend within 4.2e-7, stitch_composite within 1.2e-7 of its recording (replay compare abs=1e-4). On a 6000 px mask the distances differ by up to 0.085 far from the edge; the blends stay within 3.3e-7. Speed, best of 5, all cores: seam_blend 62 ms -> 52 ms on the recorded panorama, 1047 ms -> 715 ms at 6000 px; feather_blend 33.5 ms -> 26 ms and 532 ms -> 385 ms. make bench PHASE=5: seam_blend 0.745x. --- negpy/features/stitch/logic.py | 53 +++-------------- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 19 +++--- negpy_core/src/py/stitch.rs | 51 ++++++++++++++++ negpy_core/src/stitch.rs | 105 +++++++++++++++++++++++++++++++++ tests/test_core_seam.py | 53 +++++++++++++++++ 6 files changed, 231 insertions(+), 51 deletions(-) diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index a922051a8..63e2a0fc0 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -182,13 +182,11 @@ def feather_blend(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray]) -> Averaging draws misregistered detail twice, so this is the fallback for when a seam cannot be cut, not the normal path. See ``seam_blend``. """ - acc = np.zeros_like(warped[0]) - weight_sum = np.zeros(warped[0].shape[:2], np.float32) - for img, mask in zip(warped, masks): - weight = cv2.distanceTransform(mask.astype(np.uint8), cv2.DIST_L2, 3) - acc += img * weight[..., None] - weight_sum += weight - return acc / np.maximum(weight_sum, 1e-6)[..., None] + return negpy_core.feather_blend(_contiguous(warped, np.float32), _contiguous(masks, bool)) + + +def _contiguous(arrays: Sequence[np.ndarray], dtype) -> List[np.ndarray]: + return [np.ascontiguousarray(a, dtype=dtype) for a in arrays] _SEAM_MAX_EDGE = 1024 @@ -256,44 +254,11 @@ def seam_blend( Weight ramps on the *signed* distance to the cut, so it reaches across into the neighbour's side and the two parts actually mix there. A weight that only ramped inside a part's own side would leave the halves disjoint and the cut hard. + + Each part's weight is damped to zero at its own coverage edge, so an overlap narrower + than the band does not end on a step in the weight sum. """ - acc = np.zeros_like(warped[0]) - weight_sum = np.zeros(warped[0].shape[:2], np.float32) - for img, seam, valid in zip(warped, seams, masks): - weight = _seam_weight(seam, valid, max(feather_px, 1.0)) - acc += img * weight[..., None] - weight_sum += weight - return acc / np.maximum(weight_sum, 1e-6)[..., None] - - -def _seam_weight(seam: np.ndarray, valid: np.ndarray, feather_px: float) -> np.ndarray: - """Cross-fade weight for one part: a ramp across the cut, damped to zero at the - part's own coverage edge. - - The damping is what keeps an overlap narrower than the band from ending on a step: - truncating the ramp at the coverage edge instead leaves the two parts' weights - summing to a discontinuity there. Each distance transform is released before the - next is taken, because at export these are full-canvas float32 planes. - """ - span = 2.0 * feather_px - owned = seam.astype(np.uint8) - weight = cv2.distanceTransform(owned, cv2.DIST_L2, 3) - weight /= span - weight += 0.5 - np.clip(weight, 0.0, 1.0, out=weight) - - beyond = cv2.distanceTransform(1 - owned, cv2.DIST_L2, 3) - beyond /= span - np.subtract(0.5, beyond, out=beyond) - np.clip(beyond, 0.0, 1.0, out=beyond) - np.copyto(weight, beyond, where=~seam) - del beyond - - edge = cv2.distanceTransform(valid.astype(np.uint8), cv2.DIST_L2, 3) - edge /= feather_px - np.clip(edge, 0.0, 1.0, out=edge) - weight *= edge - return weight + return negpy_core.seam_blend(_contiguous(warped, np.float32), _contiguous(masks, bool), _contiguous(seams, bool), max(feather_px, 1.0)) def blend_ir( diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 646c0cc76..cb681d2ec 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -30,3 +30,4 @@ tolerance listed for each entry. | `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | | `features/retouch/logic.py::apply_hair_inpaint` | `cv2.inpaint` (Navier-Stokes) is `negpy_core::inpaint::inpaint_ns`, and the tiles run in parallel. The decode table takes `mathf::pow_f64`, not NumPy's float32 power. OpenCV reads past the image on a one-pixel-wide tile; negpy_core clamps there. Gate `tests/test_core_inpaint.py`: inside the mask a mean difference of at most 1 and a maximum of 4 in cv2's 8-bit values, identical bytes outside. Measured: 549 846 inpainted values on 14 recorded crops and synthetic tiles match cv2 bit for bit; the final image differs by at most 1.2e-7. | quality | | `features/stitch/logic.py::find_seams` | `cv2.detail_DpSeamFinder("COLOR")` is `negpy_core::seam::dp_seams`, for parts on one canvas only. The pairs run in reverse order of OpenCV's sort, which keeps equal pairs in order up to 16 pairs, that is 6 parts; OpenCV's introsort can reorder more. Gate `tests/test_core_seam.py`: the color cost along the cut at most 1 % above cv2's, identical masks outside the overlap. Measured on synthetic overlaps of 2 to 4 parts and the recorded panorama: 0 % of overlap pixels differ, so the cut costs are equal. | quality | +| `features/stitch/logic.py::seam_blend`, `feather_blend` (and `_seam_weight`, now inside `seam_blend`) | The distances come from `negpy_core.distance_transform`, not IPP's float32 chamfer (see its entry above); the error grows with the distance from the edge, and the weights clip it away beyond the blend band. Gate `tests/test_core_seam.py`: distances within 0.005 of cv2 on masks up to 1600 px, blends within 1e-4 of the cv2 version. Measured on the recorded panorama and synthetic overlaps: distances within 0.0042, `seam_blend` within 1.2e-7, `feather_blend` within 4.2e-7. On a 6000 px mask the distances differ by up to 0.085 far from the edge, and the blends still within 3.3e-7. | quality | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index b92043374..f75efbd86 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1758,9 +1758,11 @@ depends = [] ["features/stitch/logic.py::_seam_weight"] phase = 5 -status = "todo" -calls = ["cv2.distanceTransform"] +status = "ported" +compare = "quality" +calls = [] depends = [] +note = "negpy_core::stitch::seam_weight, inside seam_blend" ["features/stitch/logic.py::blend_ir"] phase = 5 @@ -1782,8 +1784,9 @@ depends = [] ["features/stitch/logic.py::feather_blend"] phase = 5 -status = "todo" -calls = ["cv2.distanceTransform"] +status = "ported" +compare = "quality" +calls = ["negpy_core.feather_blend"] depends = [] ["features/stitch/logic.py::gain_compensate"] @@ -1800,13 +1803,15 @@ depends = ["build_proxy", "compute_canvas", "register_pair"] ["features/stitch/logic.py::seam_blend"] phase = 5 -status = "todo" -calls = [] -depends = ["_seam_weight"] +status = "ported" +compare = "quality" +calls = ["negpy_core.seam_blend"] +depends = [] ["features/stitch/logic.py::stitch_composite"] phase = 5 status = "todo" +compare = "abs=1e-4" calls = [] depends = ["blend_ir", "feather_blend", "find_seams", "gain_compensate", "seam_blend", "warp_into_canvas"] diff --git a/negpy_core/src/py/stitch.rs b/negpy_core/src/py/stitch.rs index 10830b0f0..b286ef826 100644 --- a/negpy_core/src/py/stitch.rs +++ b/negpy_core/src/py/stitch.rs @@ -71,7 +71,58 @@ fn dp_seams<'py>( .collect() } +type BlendInputs<'a> = (Vec<&'a [f32]>, Vec<&'a [bool]>, (usize, usize)); + +fn blend_inputs<'a>( + warped: &'a [PyReadonlyArrayDyn<'_, f32>], + masks: &'a [PyReadonlyArrayDyn<'_, bool>], +) -> PyResult> { + let Some(&[h, w, 3]) = warped.first().map(|a| a.shape()) else { + return Err(PyValueError::new_err("needs HxWx3 parts")); + }; + if warped.len() != masks.len() { + return Err(PyValueError::new_err("needs one mask per part")); + } + Ok((slices(warped, 3 * h * w)?, slices(masks, h * w)?, (h, w))) +} + +/// `feather_blend(warped, masks)` of HxWx3 float32 parts and HxW bool masks. +#[pyfunction] +fn feather_blend<'py>( + py: Python<'py>, + warped: Vec>, + masks: Vec>, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let (imgs, m, (h, w)) = blend_inputs(&warped, &masks)?; + let out = super::detach(py, h * w, || stitch::feather_blend(&imgs, &m, (h, w))); + out.into_pyarray(py).reshape(vec![h, w, 3]) +} + +/// `seam_blend(warped, masks, seams, feather_px)` with the band already floored at 1 px. +#[pyfunction] +fn seam_blend<'py>( + py: Python<'py>, + warped: Vec>, + masks: Vec>, + seams: Vec>, + feather: f64, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let (imgs, m, (h, w)) = blend_inputs(&warped, &masks)?; + if seams.len() != masks.len() { + return Err(PyValueError::new_err("needs one seam per part")); + } + let s = slices(&seams, h * w)?; + let out = super::detach(py, h * w, || { + stitch::seam_blend(&imgs, &m, &s, (h, w), feather) + }); + out.into_pyarray(py).reshape(vec![h, w, 3]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(feather_blend, m)?)?; + m.add_function(wrap_pyfunction!(seam_blend, m)?)?; m.add_function(wrap_pyfunction!(dp_seams, m)?)?; m.add_function(wrap_pyfunction!(compensation_gains, m)?) } diff --git a/negpy_core/src/stitch.rs b/negpy_core/src/stitch.rs index 6afef3702..06e617b5b 100644 --- a/negpy_core/src/stitch.rs +++ b/negpy_core/src/stitch.rs @@ -29,3 +29,108 @@ pub fn compensation_gains( } Some([0, 1, 2].map(|c| (a[c] / b[c]) as f32)) } + +/// One part's `seam_blend` weight: a cross-fade ramp of width `2 * feather` on the signed +/// distance to the cut, times a ramp of width `feather` up from the part's coverage edge, in +/// float32. The two distances to the cut run at once; the coverage distance runs after them. +pub fn seam_weight( + seam: &[bool], + valid: &[bool], + (h, w): (usize, usize), + feather: f64, +) -> Vec { + use crate::distance::{Metric, distance_transform}; + use rayon::prelude::*; + const BLOCK: usize = 1 << 14; + let dist = |m: &[bool], inside: bool| { + let src: Vec = m.par_iter().map(|&v| (v == inside) as u8).collect(); + distance_transform(&src, h, w, Metric::L2Mask3) + }; + let span = (2.0 * feather) as f32; + let (mut weight, beyond) = rayon::join(|| dist(seam, true), || dist(seam, false)); + weight + .par_chunks_mut(BLOCK) + .zip(beyond.par_chunks(BLOCK).zip(seam.par_chunks(BLOCK))) + .for_each(move |(wt, (b, s))| { + for ((wt, &b), &s) in wt.iter_mut().zip(b).zip(s) { + *wt = if s { + (*wt / span + 0.5).clamp(0.0, 1.0) + } else { + (0.5 - b / span).clamp(0.0, 1.0) + }; + } + }); + drop(beyond); + let edge = dist(valid, true); + let feather = feather as f32; + crate::par_zip(&mut weight, &edge, move |wt, &e| { + *wt *= (e / feather).clamp(0.0, 1.0) + }); + weight +} + +/// The weighted mean of HxWx3 parts, the weight of part `i` from `weight(i)`: parts added in +/// order as NumPy adds `img * weight[..., None]`, over `max(weight sum, 1e-6)`. One weight plane +/// is alive at a time. +fn weighted_mean(images: &[&[f32]], n: usize, weight: impl Fn(usize) -> Vec) -> Vec { + use rayon::prelude::*; + const BLOCK: usize = 1 << 13; + let mut acc = vec![0f32; 3 * n]; + let mut sum = vec![0f32; n]; + for (i, img) in images.iter().enumerate() { + let wt = weight(i); + acc.par_chunks_mut(3 * BLOCK) + .zip(sum.par_chunks_mut(BLOCK)) + .zip(img.par_chunks(3 * BLOCK).zip(wt.par_chunks(BLOCK))) + .for_each(move |((a, s), (px, wt))| { + for (((a, s), px), &wt) in a + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(s) + .zip(px.as_chunks::<3>().0) + .zip(wt) + { + for c in 0..3 { + a[c] += px[c] * wt; + } + *s += wt; + } + }); + } + let floor = 1e-6f32; + acc.par_chunks_mut(3 * BLOCK) + .zip(sum.par_chunks(BLOCK)) + .for_each(move |(a, s)| { + for (a, &s) in a.as_chunks_mut::<3>().0.iter_mut().zip(s) { + let d = if floor > s { floor } else { s }; + for v in a { + *v /= d; + } + } + }); + acc +} + +/// `feather_blend`: the parts weighted by the distance to their own coverage edge. +pub fn feather_blend(images: &[&[f32]], masks: &[&[bool]], (h, w): (usize, usize)) -> Vec { + use crate::distance::{Metric, distance_transform}; + use rayon::prelude::*; + weighted_mean(images, h * w, |i| { + let src: Vec = masks[i].par_iter().map(|&v| v as u8).collect(); + distance_transform(&src, h, w, Metric::L2Mask3) + }) +} + +/// `seam_blend`: the parts weighted by `seam_weight` with a band of `feather`. +pub fn seam_blend( + images: &[&[f32]], + masks: &[&[bool]], + seams: &[&[bool]], + (h, w): (usize, usize), + feather: f64, +) -> Vec { + weighted_mean(images, h * w, |i| { + seam_weight(seams[i], masks[i], (h, w), feather) + }) +} diff --git a/tests/test_core_seam.py b/tests/test_core_seam.py index d47c8b47a..40c7d95f5 100644 --- a/tests/test_core_seam.py +++ b/tests/test_core_seam.py @@ -1,3 +1,4 @@ +import functools import pickle from pathlib import Path @@ -15,6 +16,7 @@ _CORPUS = Path("~/negpy-corpus-p5/features__stitch__logic.py--seam_blend").expanduser() +@functools.cache def _recorded(): """(warped, masks, seams, feather) of the recorded panorama blends; empty without the corpus.""" return [pickle.loads(p.read_bytes())["args"] for p in sorted(_CORPUS.glob("*.pkl"))] @@ -97,3 +99,54 @@ def test_find_seams_partitions_the_coverage(): assert seams is not None covered = np.any([m > 0 for m in masks], 0) assert np.array_equal(np.sum(seams, 0) >= 1, covered) + + +def _seam_weight_cv2(seam, valid, feather_px): + span = 2.0 * feather_px + owned = seam.astype(np.uint8) + weight = cv2.distanceTransform(owned, cv2.DIST_L2, 3) / np.float32(span) + np.float32(0.5) + beyond = np.float32(0.5) - cv2.distanceTransform(1 - owned, cv2.DIST_L2, 3) / np.float32(span) + weight = np.where(seam, np.clip(weight, 0.0, 1.0), np.clip(beyond, 0.0, 1.0)) + edge = np.clip(cv2.distanceTransform(valid.astype(np.uint8), cv2.DIST_L2, 3) / np.float32(feather_px), 0.0, 1.0) + return weight * edge + + +def _mean_cv2(warped, weights): + acc = np.zeros_like(warped[0]) + weight_sum = np.zeros(warped[0].shape[:2], np.float32) + for img, weight in zip(warped, weights): + acc += img * weight[..., None] + weight_sum += weight + return acc / np.maximum(weight_sum, 1e-6)[..., None] + + +@functools.cache +def _blend_cases(): + cases = [(w, m, s, f) for w, m, s, f in _recorded()] + for n, size in ((2, (120, 160, 70)), (3, (200, 260, 150)), (4, (64, 90, 40))): + imgs, masks = _parts(*size, n, 3.0) + seams = [c > 0 for c in negpy_core.dp_seams(imgs, masks)] + cases.append((imgs, [m > 0 for m in masks], seams, 12.0)) + return cases + + +def test_distance_transform_stays_near_cv2(): + masks = [m for _, ms, ss, _ in _blend_cases() for m in (*ms, *ss)] + masks += [_RNG.random((1500, 1600)) > 0.0005, np.pad(np.ones((1400, 1500), bool), 50)] + for m in masks: + for src in (m.astype(np.uint8), (~m).astype(np.uint8)): + want = cv2.distanceTransform(src, cv2.DIST_L2, 3) + assert np.abs(negpy_core.distance_transform(src, cv2.DIST_L2, 3) - want).max() <= 0.005 + + +def test_seam_blend_matches_cv2_version(): + for warped, masks, seams, feather in _blend_cases(): + f = max(feather, 1.0) + want = _mean_cv2(warped, [_seam_weight_cv2(s, m, f) for s, m in zip(seams, masks)]) + assert np.abs(logic.seam_blend(warped, masks, seams, feather) - want).max() <= 1e-4 + + +def test_feather_blend_matches_cv2_version(): + for warped, masks, _, _ in _blend_cases(): + want = _mean_cv2(warped, [cv2.distanceTransform(m.astype(np.uint8), cv2.DIST_L2, 3) for m in masks]) + assert np.abs(logic.feather_blend(warped, masks) - want).max() <= 1e-4 From 23a039dd97ad3d42a956d3dd6f52cc29c6d30870 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 16:51:05 +0200 Subject: [PATCH 108/133] perf(core): stitch canvas corners without cv2.transform The four corners take cv2.transform's own order (its dispatched build fuses the first product): 3000 random transforms match bit for bit. register_parts and compute_canvas are orchestration ("stays"); the recorded panorama transforms land within 0.002 px of cv2's (abs=0.01). --- negpy/features/stitch/logic.py | 6 ++++-- negpy_core/manifest.toml | 12 ++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 0537ee297..cd04459fa 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -5,6 +5,7 @@ buffers decoded the same way. """ +import math from typing import Callable, List, Optional, Sequence, Tuple import cv2 @@ -76,8 +77,9 @@ def compute_canvas(sizes: Sequence[Tuple[int, int]], transforms: Sequence[np.nda """Union bounding box of the warped parts. Returns ((W, H), transforms shifted to it).""" corners = [] for (w, h), matrix in zip(sizes, transforms): - box = np.array([[[0, 0], [w, 0], [w, h], [0, h]]], np.float64) - corners.append(cv2.transform(box, matrix)[0]) + # cv2.transform's order: its dispatched build fuses the first product. + m = matrix.tolist() + corners.append([[math.fma(r[0], x, r[1] * y) + r[2] for r in m] for x, y in ((0, 0), (w, 0), (w, h), (0, h))]) points = np.vstack(corners) lo = np.floor(points.min(axis=0)) hi = np.ceil(points.max(axis=0)) diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index bf63f60f5..07127cf16 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1820,12 +1820,6 @@ status = "todo" calls = ["negpy_core.resize_area", "np.percentile"] depends = [] -["features/stitch/logic.py::compute_canvas"] -phase = 5 -status = "todo" -calls = ["cv2.transform"] -depends = [] - ["features/stitch/logic.py::feather_blend"] phase = 5 status = "todo" @@ -1840,9 +1834,11 @@ depends = [] ["features/stitch/logic.py::register_parts"] phase = 5 -status = "todo" +status = "stays" +compare = "abs=0.01" calls = [] -depends = ["build_proxy", "compute_canvas", "register_pair"] +depends = ["build_proxy", "register_pair"] +note = "orchestration of negpy_core kernels" ["features/stitch/logic.py::seam_blend"] phase = 5 From c4e34ae72506e39c0f96dd9cd68b9f181602bb59 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 17:02:17 +0200 Subject: [PATCH 109/133] perf(core): zone, loupe and vectorscope display reads in Rust zone_field, zone_grid, zone_region_labels, zone_shares, zone_occupancy, loupe_acutance, the vectorscope lookup and its density map are one Rust call each; analysis.py imports no cv2. The zone pipeline copies cv2's INTER_AREA, Gaussian and median filters bit for bit. zone_anchors runs under a quality gate: its inset comes from negpy_core.distance_transform, one float32 unit from opencv-python's IPP chamfer at rare pixels, so a tied deepest point can move (1 of 12 recorded calls by one pixel). The anchor stays at a pixel cv2 rates as deep. Bench (p2 corpus, all cores): zone_field 0.48x, zone_grid 0.80x, zone_anchors 0.52x, region labels 0.05x, shares 0.57x, occupancy 0.11x, loupe 0.45x, vectorscope density 0.55x; the uncached scope lookup 0.9 ms against 5.9 ms. --- negpy/features/exposure/analysis.py | 98 +------ negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 60 +++-- negpy_core/src/lib.rs | 4 +- negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/zones.rs | 192 ++++++++++++++ negpy_core/src/zones.rs | 388 ++++++++++++++++++++++++++++ tests/test_core_zones.py | 96 +++++++ tests/test_vectorscope.py | 9 +- 9 files changed, 732 insertions(+), 117 deletions(-) create mode 100644 negpy_core/src/py/zones.rs create mode 100644 negpy_core/src/zones.rs create mode 100644 tests/test_core_zones.py diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index 6864e3038..a1e6084a7 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -3,7 +3,6 @@ from functools import lru_cache from typing import Any, List, Optional, Sequence, Tuple -import cv2 import negpy_core import numpy as np @@ -149,13 +148,6 @@ def scope_chroma(radius: Any, log: bool) -> Any: return r * SCOPE_MAX_CHROMA -def _scope_bins(a: np.ndarray, b: np.ndarray) -> np.ndarray: - hue = np.floor((np.arctan2(b, a) + np.pi) / (2 * np.pi) * SCOPE_HUE_BINS).astype(np.int64) % SCOPE_HUE_BINS - radius = scope_radius(np.hypot(a, b), log=True) - chroma = np.clip(np.floor(radius * SCOPE_CHROMA_BINS), 0, SCOPE_CHROMA_BINS - 1).astype(np.int64) - return hue * SCOPE_CHROMA_BINS + chroma - - def vectorscope_histogram(buffer: Any) -> Optional[np.ndarray]: """(SCOPE_HUE_BINS, SCOPE_CHROMA_BINS) a*b* counts of an H×W×3 encoded buffer. CPU counterpart of vectorscope.wgsl.""" @@ -188,35 +180,14 @@ def scope_point(rgb: Sequence[float]) -> Tuple[float, float, float]: def _scope_lookup(size: int, log: bool) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Polar bin of each display pixel (-1 outside the disc), each bin's pixel count, and for the bins no pixel samples (near neutral, finer than a pixel) the flat pixel they fold into.""" - u = (np.arange(size) + 0.5) / size * 2.0 - 1.0 - x, y = np.meshgrid(u, u[::-1]) - radius = np.hypot(x, y) - chroma = scope_chroma(np.minimum(radius, 1.0), log) - hue = np.arctan2(y, x) - idx = _scope_bins(chroma * np.cos(hue), chroma * np.sin(hue)) - idx[radius > 1.0] = -1 - area = np.bincount(idx[idx >= 0], minlength=SCOPE_HUE_BINS * SCOPE_CHROMA_BINS).astype(float) - - orphans = np.flatnonzero(area == 0) - centre_hue = ((orphans // SCOPE_CHROMA_BINS) + 0.5) / SCOPE_HUE_BINS * 2 * np.pi - np.pi - centre_c = scope_chroma(((orphans % SCOPE_CHROMA_BINS) + 0.5) / SCOPE_CHROMA_BINS, log=True) - r = np.minimum(scope_radius(centre_c, log), 1.0) - col = np.clip(((r * np.cos(centre_hue) + 1.0) / 2.0 * size).astype(np.int64), 0, size - 1) - row = np.clip(((1.0 - r * np.sin(centre_hue)) / 2.0 * size).astype(np.int64), 0, size - 1) - return idx, area, orphans, row * size + col + return negpy_core.scope_lookup(int(size), bool(log)) def vectorscope_density(counts: Any, size: int, log: bool) -> np.ndarray: """(size, size) pixels per display pixel, +a right and +b up. Each polar bin's count is spread over the display pixels it covers, so a bin's on-screen area does not change its brightness.""" - idx, area, orphans, orphan_px = _scope_lookup(size, log) - flat = np.asarray(counts, dtype=float).ravel() - out = np.zeros(idx.shape) - inside = idx >= 0 - picked = idx[inside] - out[inside] = flat[picked] / np.maximum(area[picked], 1.0) - np.add.at(out.reshape(-1), orphan_px, flat[orphans]) - return out + flat = np.ascontiguousarray(np.asarray(counts, dtype=float).ravel()) + return negpy_core.vectorscope_density(flat, *_scope_lookup(size, log)) def output_clip_fractions(bins: np.ndarray) -> Tuple[float, float]: @@ -258,15 +229,7 @@ def encoded_of_zone(zone: float) -> float: def zone_occupancy(l_bins: np.ndarray) -> np.ndarray: """Fold display-encoded luma histogram bins into the share of each zone 0..X, rounded to the nearest zone as zone_field rounds, so the strip and the Zone Overlay count alike.""" - l_bins = np.asarray(l_bins, dtype=float) - out = np.zeros(ZONE_COUNT + 1) - total = float(l_bins.sum()) - if total <= 0.0: - return out - centers = (np.arange(l_bins.size) + 0.5) / l_bins.size - cells = np.clip(np.rint(np.asarray(zone_of_encoded(centers))), 0, ZONE_COUNT).astype(int) - np.add.at(out, cells, l_bins) - return out / total + return negpy_core.zone_occupancy(np.ascontiguousarray(l_bins, dtype=np.float64).ravel(), _mid_gray_encoded()) def zone_warnings(occ: np.ndarray) -> Tuple[bool, bool]: @@ -311,15 +274,7 @@ def zone_grid(rgb: Any, cells: int = ZONE_GRID_CELLS) -> Optional[np.ndarray]: rgb = np.asarray(rgb) if rgb.ndim != 3 or rgb.shape[-1] < 3 or min(rgb.shape[:2]) < 2: return None - h, w = rgb.shape[:2] - # Slice first: INTER_AREA straight off a full-res HQ buffer costs ~50 ms. - step = max(1, min(h, w) // (4 * cells)) - small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) - scale = cells / max(small.shape[:2]) - sh, sw = small.shape[:2] - small = negpy_core.resize_area(np.ascontiguousarray(small, dtype=np.float32), (max(1, round(sw * scale)), max(1, round(sh * scale)))) - z = np.nan_to_num(zone_of_encoded(get_luminance(small))) - return np.rint(z).astype(np.int32) + return negpy_core.zone_grid(np.ascontiguousarray(rgb, dtype=np.float32), int(cells), _mid_gray_encoded()) def zone_region_labels(zones: np.ndarray) -> List[Tuple[int, int, int]]: @@ -328,15 +283,7 @@ def zone_region_labels(zones: np.ndarray) -> List[Tuple[int, int, int]]: Anchors are snapped to a cell the region actually owns, so a concave region's numeral can't land outside it (its centroid can). """ - out: List[Tuple[int, int, int]] = [] - for zone in np.unique(zones): - count, comp, _, centroids = cv2.connectedComponentsWithStats((zones == zone).astype(np.uint8), connectivity=4) - for i in range(1, count): # 0 is the background (everything not this zone) - ys, xs = np.nonzero(comp == i) - cx, cy = centroids[i] - j = int(np.argmin((xs - cx) ** 2 + (ys - cy) ** 2)) - out.append((int(xs[j]), int(ys[j]), int(zone))) - return out + return negpy_core.zone_region_labels(np.ascontiguousarray(zones, dtype=np.int32)) ZONE_FIELD_MAX_EDGE = 768 # drawn scaled to the view; finer only sharpens grain @@ -349,43 +296,19 @@ def zone_field(rgb: Any, max_edge: int = ZONE_FIELD_MAX_EDGE) -> Optional[np.nda rgb = np.asarray(rgb) if rgb.ndim != 3 or rgb.shape[-1] < 3 or min(rgb.shape[:2]) < 2: return None - h, w = rgb.shape[:2] - # Slice first: INTER_AREA straight off a full-res HQ buffer is slow. - step = max(1, max(h, w) // (2 * max_edge)) - small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) - scale = min(1.0, max_edge / max(small.shape[:2])) - sh, sw = small.shape[:2] - lum = get_luminance( - negpy_core.resize_area(np.ascontiguousarray(small, dtype=np.float32), (max(1, round(sw * scale)), max(1, round(sh * scale)))) - ) - lum = negpy_core.gaussian_blur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), 2.0) - zones = np.clip(np.rint(np.nan_to_num(zone_of_encoded(lum))), 0, ZONE_COUNT).astype(np.uint8) - return cv2.medianBlur(zones, 5) + return negpy_core.zone_field(np.ascontiguousarray(rgb, dtype=np.float32), int(max_edge), _mid_gray_encoded()) def zone_shares(zones: np.ndarray) -> np.ndarray: """Fraction of the frame in each zone 0..10.""" - return np.bincount(zones.ravel(), minlength=ZONE_COUNT + 1)[: ZONE_COUNT + 1] / max(1, zones.size) + return negpy_core.zone_shares(np.ascontiguousarray(zones, dtype=np.uint8)) def zone_anchors(zones: np.ndarray, min_share: float = 0.01) -> List[Tuple[int, int, int, float]]: """(x, y, zone, inset) per region holding at least `min_share` of the frame, at its most interior pixel; `inset` is that pixel's distance to the region's edge. The frame edge counts as an edge, so a numeral never sits half off the picture.""" - out: List[Tuple[int, int, int, float]] = [] - min_area = min_share * zones.size - for zone in np.unique(zones): - count, comp, stats, _ = cv2.connectedComponentsWithStats((zones == zone).astype(np.uint8), connectivity=4) - for i in range(1, count): # 0 is the background (everything not this zone) - if stats[i, cv2.CC_STAT_AREA] < min_area: - continue - mask = (comp == i).astype(np.uint8) - mask[0, :] = mask[-1, :] = 0 - mask[:, 0] = mask[:, -1] = 0 - dist = cv2.distanceTransform(mask, cv2.DIST_L2, 3) - y, x = np.unravel_index(int(np.argmax(dist)), dist.shape) - out.append((int(x), int(y), int(zone), float(dist[y, x]))) - return out + return negpy_core.zone_anchors(np.ascontiguousarray(zones, dtype=np.uint8), float(min_share)) # Absolute ladders, centred on the defaults and inside the sliders' travel (density 0-2, @@ -612,8 +535,7 @@ def loupe_acutance(patch: Any) -> float: patch = np.asarray(patch) if patch.ndim != 3 or patch.shape[-1] < 3 or min(patch.shape[:2]) < 3: return 0.0 - lum = get_luminance(np.ascontiguousarray(patch[..., :3], dtype=np.float32)) - return float(np.sqrt(np.mean(cv2.Laplacian(lum, cv2.CV_32F) ** 2)) * 100.0) + return negpy_core.loupe_acutance(np.ascontiguousarray(patch, dtype=np.float32)) def density_histogram(normalized_log: np.ndarray, roi: Optional[Tuple[int, int, int, int]] = None) -> np.ndarray: diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 1bed0ccb7..8b17bc9e7 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -32,3 +32,4 @@ tolerance listed for each entry. | `features/geometry/batch_autocrop.py::_top_edge_slope` | The same centered least-squares fit in place of `np.polyfit`. | abs=1e-9 | | `features/geometry/skew.py::_coarse_angles` (the line segment detector) | The detector copies OpenCV's `fastAtan2`, its float32 region angles and its fixed-point Gaussian and INTER_LINEAR_EXACT downscale. OpenCV's build can contract the float32 polynomial and call a different `cosf`, and one changed angle can change a region. The recorded planes and the test frames match cv2 bit for bit. | quality: `tests/test_core_lsd.py` (95 % of the cv2 segments longer than 4 % of the long side matched within 1 px; angle modes and `measure_frame_skew` within 0.05) | | `features/stitch/logic.py::register_pair` | SIFT, the ratio test and RANSAC copy OpenCV 4.13, but the sub-pixel solve, `exp`, `fastAtan2` and the float32 sums run in another order. A keypoint position can move by a float32 unit, which can change a descriptor byte, a match or a RANSAC draw. | quality: `tests/test_core_sift.py` asks for each corner of the moved part within 0.1 px of the true similarity on synthetic pairs, within 0.25 px of cv2 on the recorded panorama, and keypoint counts within 10 % of cv2's | +| `features/exposure/analysis.py::zone_anchors` | The inset is `negpy_core.distance_transform`'s, which sits one float32 unit from opencv-python's IPP chamfer at rare pixels (the `distance_transform` row above); where two pixels tie for the deepest point, the anchor can take the other one. | quality: `tests/test_core_zones.py` (same regions; the inset within 1e-4 of cv2's deepest distance, at a pixel cv2 also rates that deep) | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 07127cf16..f8b51dd1d 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -11,19 +11,20 @@ note = "negpy_core::stats::percentiles" ["features/exposure/analysis.py::_mid_gray_encoded"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = [] +note = "scalar or roll-size math" ["features/exposure/analysis.py::_scope_lookup"] phase = 2 -status = "todo" -calls = ["np.bincount"] +status = "ported" +calls = ["negpy_core.scope_lookup"] depends = [] ["features/exposure/analysis.py::_waveform"] phase = 2 -status = "todo" +status = "ported" calls = ["negpy_core.waveform"] depends = [] @@ -53,14 +54,15 @@ depends = [] ["features/exposure/analysis.py::encoded_of_zone"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_mid_gray_encoded"] +note = "scalar or roll-size math" ["features/exposure/analysis.py::loupe_acutance"] phase = 2 -status = "todo" -calls = ["cv2.Laplacian", "np.mean"] +status = "ported" +calls = ["negpy_core.loupe_acutance"] depends = [] ["features/exposure/analysis.py::output_histogram"] @@ -71,9 +73,10 @@ depends = [] ["features/exposure/analysis.py::paper_grid_mosaic"] phase = 2 -status = "todo" +status = "stays" calls = ["negpy_core.resize_area"] depends = [] +note = "orchestration of negpy_core kernels" ["features/exposure/analysis.py::scan_full_scale_columns"] phase = 2 @@ -83,14 +86,15 @@ depends = [] ["features/exposure/analysis.py::scope_point"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = [] +note = "scalar or roll-size math" ["features/exposure/analysis.py::vectorscope_density"] phase = 2 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.vectorscope_density"] depends = ["_scope_lookup"] ["features/exposure/analysis.py::vectorscope_histogram"] @@ -119,44 +123,46 @@ depends = ["_waveform"] ["features/exposure/analysis.py::zone_anchors"] phase = 2 -status = "todo" -calls = ["cv2.connectedComponentsWithStats", "cv2.distanceTransform", "np.unique"] +status = "ported" +compare = "quality" +calls = ["negpy_core.zone_anchors"] depends = [] ["features/exposure/analysis.py::zone_field"] phase = 2 -status = "todo" -calls = ["cv2.medianBlur", "negpy_core.gaussian_blur", "negpy_core.resize_area"] -depends = ["zone_of_encoded"] +status = "ported" +calls = ["negpy_core.zone_field"] +depends = ["_mid_gray_encoded"] ["features/exposure/analysis.py::zone_grid"] phase = 2 -status = "todo" -calls = ["negpy_core.resize_area"] -depends = ["zone_of_encoded"] +status = "ported" +calls = ["negpy_core.zone_grid"] +depends = ["_mid_gray_encoded"] ["features/exposure/analysis.py::zone_occupancy"] phase = 2 -status = "todo" -calls = [] -depends = ["zone_of_encoded"] +status = "ported" +calls = ["negpy_core.zone_occupancy"] +depends = ["_mid_gray_encoded"] ["features/exposure/analysis.py::zone_of_encoded"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_mid_gray_encoded"] +note = "scalar or roll-size math" ["features/exposure/analysis.py::zone_region_labels"] phase = 2 -status = "todo" -calls = ["cv2.connectedComponentsWithStats", "np.unique"] +status = "ported" +calls = ["negpy_core.zone_region_labels"] depends = [] ["features/exposure/analysis.py::zone_shares"] phase = 2 -status = "todo" -calls = ["np.bincount"] +status = "ported" +calls = ["negpy_core.zone_shares"] depends = [] ["features/exposure/normalization.py::_base_blocks"] diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 6fe09d57b..8bbf23fc1 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -34,6 +34,7 @@ pub mod stats; pub mod stitch; pub mod toning; pub mod warp; +pub mod zones; /// `f(dst[i], src[i])` for every i, in parallel blocks: a rayon task per element costs more than /// the work. Pass a `move` closure: a capture read through a reference may alias `dst`, so the @@ -80,5 +81,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::stitch::register(m)?; py::skew::register(m)?; py::register::register(m)?; - py::autocrop::register(m) + py::autocrop::register(m)?; + py::zones::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 262ea1411..5f14045ae 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -27,6 +27,7 @@ pub mod stats; pub mod stitch; pub mod toning; pub mod warp; +pub mod zones; use pyo3::Python; use pyo3::marker::Ungil; diff --git a/negpy_core/src/py/zones.rs b/negpy_core/src/py/zones.rs new file mode 100644 index 000000000..5ec3f77e2 --- /dev/null +++ b/negpy_core/src/py/zones.rs @@ -0,0 +1,192 @@ +use numpy::{ + IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, + PyReadonlyArray3, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::zones; + +type Frame<'a> = (&'a [f32], (usize, usize, usize)); + +fn frame<'a>(rgb: &'a PyReadonlyArray3<'_, f32>) -> PyResult> { + let s = rgb.shape(); + let dims = (s[0], s[1], s[2]); + if dims.0 < 1 || dims.1 < 1 || dims.2 < 3 { + return Err(PyValueError::new_err("needs an HxWx3 frame")); + } + let data = rgb + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous frame"))?; + Ok((data, dims)) +} + +fn grid<'py, T: numpy::Element>( + py: Python<'py>, + v: Vec, + h: usize, + w: usize, +) -> PyResult>> { + v.into_pyarray(py).reshape([h, w]) +} + +/// `zone_field(rgb, max_edge)` for an HxWxC float32 display frame and the encoded mid gray. +#[pyfunction] +fn zone_field<'py>( + py: Python<'py>, + rgb: PyReadonlyArray3<'py, f32>, + max_edge: usize, + mid: f64, +) -> PyResult>> { + let (data, dims) = frame(&rgb)?; + let (z, h, w) = super::detach(py, data.len(), || { + zones::zone_field(data, dims, max_edge, mid) + }) + .ok_or_else(|| PyValueError::new_err("invalid frame"))?; + grid(py, z, h, w) +} + +/// `zone_grid(rgb, cells)` for an HxWxC float32 display frame and the encoded mid gray. +#[pyfunction] +fn zone_grid<'py>( + py: Python<'py>, + rgb: PyReadonlyArray3<'py, f32>, + cells: usize, + mid: f64, +) -> PyResult>> { + let (data, dims) = frame(&rgb)?; + let step = (dims.0.min(dims.1) / (4 * cells.max(1))).max(1); + let sampled = dims.0.div_ceil(step) * dims.1.div_ceil(step) * 3; + let (z, h, w) = super::detach(py, sampled, || zones::zone_grid(data, dims, cells, mid)) + .ok_or_else(|| PyValueError::new_err("invalid frame"))?; + grid(py, z, h, w) +} + +/// `zone_anchors(zones, min_share)` for a uint8 zone map: [(x, y, zone, inset)]. +#[pyfunction] +fn zone_anchors( + py: Python<'_>, + zones: PyReadonlyArray2<'_, u8>, + min_share: f64, +) -> PyResult> { + let dims = (zones.shape()[0], zones.shape()[1]); + let z = zones + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous map"))?; + Ok(super::detach(py, z.len(), || { + zones::zone_anchors(z, dims, min_share) + })) +} + +/// `zone_region_labels(zones)` for an int32 zone grid: [(col, row, zone)]. +#[pyfunction] +fn zone_region_labels( + py: Python<'_>, + zones: PyReadonlyArray2<'_, i32>, +) -> PyResult> { + let dims = (zones.shape()[0], zones.shape()[1]); + let z = zones + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous grid"))?; + Ok(super::detach(py, z.len(), || { + zones::zone_region_labels(z, dims) + })) +} + +/// `zone_shares(zones)` for a uint8 zone map. +#[pyfunction] +fn zone_shares<'py>( + py: Python<'py>, + zones: PyReadonlyArray2<'py, u8>, +) -> PyResult>> { + let z = zones + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous map"))?; + Ok(super::detach(py, z.len(), || zones::zone_shares(z)).into_pyarray(py)) +} + +/// `zone_occupancy(l_bins)` for float64 bins and the encoded mid gray. +#[pyfunction] +fn zone_occupancy<'py>( + py: Python<'py>, + bins: PyReadonlyArray1<'py, f64>, + mid: f64, +) -> PyResult>> { + let b = bins + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous bins"))?; + Ok(zones::zone_occupancy(b, mid).into_pyarray(py)) +} + +/// `loupe_acutance(patch)` for an HxWxC float32 display patch, at least 3x3. +#[pyfunction] +fn loupe_acutance(py: Python<'_>, patch: PyReadonlyArray3<'_, f32>) -> PyResult { + let (data, dims) = frame(&patch)?; + Ok(super::detach(py, data.len(), || { + zones::loupe_acutance(data, dims) + })) +} + +type Lookup<'py> = ( + Bound<'py, PyArray2>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +/// `_scope_lookup(size, log)`: (bin per pixel, pixels per bin, unsampled bins, their pixels). +#[pyfunction] +fn scope_lookup(py: Python<'_>, size: usize, log: bool) -> PyResult> { + let (idx, area, orphans, px) = + super::detach(py, size * size, || zones::scope_lookup(size, log)); + Ok(( + grid(py, idx, size, size)?, + area.into_pyarray(py), + orphans.into_pyarray(py), + px.into_pyarray(py), + )) +} + +/// `vectorscope_density(counts, size, log)` from float64 counts and a `scope_lookup`. +#[pyfunction] +fn vectorscope_density<'py>( + py: Python<'py>, + counts: PyReadonlyArray1<'py, f64>, + idx: PyReadonlyArray2<'py, i64>, + area: PyReadonlyArray1<'py, f64>, + orphans: PyReadonlyArray1<'py, i64>, + px: PyReadonlyArray1<'py, i64>, +) -> PyResult>> { + let bad = |_| PyValueError::new_err("needs contiguous arrays"); + let (c, i, a, o, p) = ( + counts.as_slice().map_err(bad)?, + idx.as_slice().map_err(bad)?, + area.as_slice().map_err(bad)?, + orphans.as_slice().map_err(bad)?, + px.as_slice().map_err(bad)?, + ); + let n = i.len(); + if a.len() > c.len() + || i.iter().any(|&v| v >= a.len() as i64) + || o.len() != p.len() + || o.iter().any(|&v| v < 0 || v >= c.len() as i64) + || p.iter().any(|&v| v < 0 || v >= n as i64) + { + return Err(PyValueError::new_err("counts and lookup do not match")); + } + let out = super::detach(py, n, || zones::vectorscope_density(c, (i, a, o, p))); + let size = idx.shape()[0]; + grid(py, out, size, idx.shape()[1]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(zone_field, m)?)?; + m.add_function(wrap_pyfunction!(zone_grid, m)?)?; + m.add_function(wrap_pyfunction!(zone_anchors, m)?)?; + m.add_function(wrap_pyfunction!(zone_region_labels, m)?)?; + m.add_function(wrap_pyfunction!(zone_shares, m)?)?; + m.add_function(wrap_pyfunction!(zone_occupancy, m)?)?; + m.add_function(wrap_pyfunction!(loupe_acutance, m)?)?; + m.add_function(wrap_pyfunction!(scope_lookup, m)?)?; + m.add_function(wrap_pyfunction!(vectorscope_density, m)?) +} diff --git a/negpy_core/src/zones.rs b/negpy_core/src/zones.rs new file mode 100644 index 000000000..bca4f9d2c --- /dev/null +++ b/negpy_core/src/zones.rs @@ -0,0 +1,388 @@ +//! The display-buffer reads of negpy/features/exposure/analysis.py: print zones, their regions +//! and label anchors, the loupe's acutance and the vectorscope's display map. + +use rayon::prelude::*; + +use crate::components::connected_components; +use crate::distance::{Metric, distance_transform}; +use crate::filter::{Border, gaussian_blur}; + +pub const ZONE_COUNT: u8 = 10; + +fn py_round(v: f64) -> usize { + v.round_ties_even() as usize +} + +/// `zone_of_encoded` of a float32 lightness against the encoded mid gray `mid` (a Python +/// float, so it joins the arithmetic as float32). +#[inline] +fn zone_f32(enc: f32, mid: f32, upper: f32) -> f32 { + let enc = if enc.is_nan() { + enc + } else { + enc.clamp(0.0, 1.0) + }; + if enc <= mid { + 5.0 * enc / mid + } else { + 5.0 + 5.0 * (enc - mid) / upper + } +} + +/// `np.rint(np.nan_to_num(z))` clipped to the zone range. +#[inline] +fn zone_index(z: f32) -> f32 { + let z = if z.is_nan() { 0.0 } else { z }; + z.round_ties_even().clamp(0.0, ZONE_COUNT as f32) +} + +/// The first three channels of every `step`-th row and column, then INTER_AREA to `scale` of +/// that, as luminance. +fn sampled_luma( + rgb: &[f32], + (h, w, cn): (usize, usize, usize), + step: usize, + scale: impl Fn(usize, usize) -> f64, +) -> Option<(Vec, usize, usize)> { + let (sh, sw) = (h.div_ceil(step), w.div_ceil(step)); + let copy; + let small: &[f32] = if step == 1 && cn == 3 { + rgb + } else { + copy = (0..sh) + .into_par_iter() + .flat_map_iter(|r| { + let row = &rgb[r * step * w * cn..]; + (0..sw).flat_map(move |c| row[c * step * cn..c * step * cn + 3].iter().copied()) + }) + .collect::>(); + © + }; + let s = scale(sh, sw); + let (dw, dh) = ( + py_round(sw as f64 * s).max(1), + py_round(sh as f64 * s).max(1), + ); + let resized = crate::resize::resize_area(small, sh, sw, 3, dh, dw)?; + let mut lum = vec![0f32; dh * dw]; + crate::analysis::luminance(&resized, &mut lum); + Some((lum, dh, dw)) +} + +/// `zone_field(rgb, max_edge)`: the zone (0..10) per pixel of an HxWxC display frame, at most +/// `max_edge` on the long side, smoothed before and median-filtered after the rounding. +pub fn zone_field( + rgb: &[f32], + dims: (usize, usize, usize), + max_edge: usize, + mid: f64, +) -> Option<(Vec, usize, usize)> { + let (h, w, _) = dims; + let step = (h.max(w) / (2 * max_edge)).max(1); + let (lum, dh, dw) = sampled_luma(rgb, dims, step, |sh, sw| { + 1f64.min(max_edge as f64 / sh.max(sw) as f64) + })?; + let mut blur = vec![0f32; lum.len()]; + gaussian_blur( + &lum, + &mut blur, + (dh, dw, 1), + (0, 0), + (2.0, 0.0), + Border::Reflect101, + )?; + let (m, upper) = (mid as f32, (1.0 - mid) as f32); + let zones: Vec = blur + .par_iter() + .map(move |&v| zone_index(zone_f32(v, m, upper)) as u8) + .collect(); + let mut out = vec![0u8; zones.len()]; + crate::median::median_blur_u8(&zones, &mut out, dh, dw, 5)?; + Some((out, dh, dw)) +} + +/// `zone_grid(rgb, cells)`: the zone per cell of a grid `cells` long on the frame's long side. +pub fn zone_grid( + rgb: &[f32], + dims: (usize, usize, usize), + cells: usize, + mid: f64, +) -> Option<(Vec, usize, usize)> { + let (h, w, _) = dims; + let step = (h.min(w) / (4 * cells)).max(1); + let (lum, dh, dw) = sampled_luma(rgb, dims, step, |sh, sw| cells as f64 / sh.max(sw) as f64)?; + let (m, upper) = (mid as f32, (1.0 - mid) as f32); + Some(( + lum.par_iter() + .map(move |&v| zone_index(zone_f32(v, m, upper)) as i32) + .collect(), + dh, + dw, + )) +} + +/// The distinct values of a zone map, ascending. +fn unique(zones: &[T]) -> Vec { + let mut u = zones.to_vec(); + u.sort_unstable(); + u.dedup(); + u +} + +/// `zone_anchors(zones, min_share)`: (x, y, zone, inset) per 4-connected region of at least +/// `min_share` of the frame, at its most interior pixel; the frame edge counts as an edge. +pub fn zone_anchors( + zones: &[u8], + (h, w): (usize, usize), + min_share: f64, +) -> Vec<(usize, usize, u8, f32)> { + let min_area = min_share * zones.len() as f64; + let mut out = Vec::new(); + for zone in unique(zones) { + let mask: Vec = zones.iter().map(|&z| (z == zone) as u8).collect(); + let cc = connected_components(&mask, h, w, 4); + for i in 1..cc.count { + let [left, top, cw, ch, area] = cc.stats[i]; + if (area as f64) < min_area { + continue; + } + // Zeros ring the region's box, so its distances need nothing outside it. + let (y0, x0) = ( + (top as usize).saturating_sub(1), + (left as usize).saturating_sub(1), + ); + let (y1, x1) = ((top + ch) as usize + 1, (left + cw) as usize + 1); + let (y1, x1) = (y1.min(h), x1.min(w)); + let (bh, bw) = (y1 - y0, x1 - x0); + let mut sub = vec![0u8; bh * bw]; + for y in y0.max(1)..y1.min(h - 1) { + for x in x0.max(1)..x1.min(w - 1) { + sub[(y - y0) * bw + x - x0] = (cc.labels[y * w + x] == i as i32) as u8; + } + } + let dist = distance_transform(&sub, bh, bw, Metric::L2Mask3); + let best = dist + .iter() + .enumerate() + .fold(0, |b, (j, &d)| if d > dist[b] { j } else { b }); + if dist[best] > 0.0 { + out.push((x0 + best % bw, y0 + best / bw, zone, dist[best])); + } else { + out.push((0, 0, zone, 0.0)); + } + } + } + out +} + +/// `zone_region_labels(zones)`: (col, row, zone) per 4-connected region, at the region's cell +/// nearest its centroid. +pub fn zone_region_labels(zones: &[i32], (h, w): (usize, usize)) -> Vec<(usize, usize, i32)> { + let mut out = Vec::new(); + for zone in unique(zones) { + let mask: Vec = zones.iter().map(|&z| (z == zone) as u8).collect(); + let cc = connected_components(&mask, h, w, 4); + let mut best = vec![(f64::INFINITY, 0usize); cc.count]; + for (p, &label) in cc.labels.iter().enumerate() { + if label <= 0 { + continue; + } + let [cx, cy] = cc.centroids[label as usize]; + let (dx, dy) = ((p % w) as f64 - cx, (p / w) as f64 - cy); + let d = dx * dx + dy * dy; + if d < best[label as usize].0 { + best[label as usize] = (d, p); + } + } + out.extend(best[1..].iter().map(|&(_, p)| (p % w, p / w, zone))); + } + out +} + +/// `zone_shares(zones)`: the fraction of the frame in each zone 0..10. +pub fn zone_shares(zones: &[u8]) -> Vec { + let mut count = [0usize; ZONE_COUNT as usize + 1]; + for &z in zones { + if (z as usize) < count.len() { + count[z as usize] += 1; + } + } + let n = zones.len().max(1) as f64; + count.iter().map(|&c| c as f64 / n).collect() +} + +/// `zone_occupancy(l_bins)`: display-luma histogram bins folded into the share of each zone, +/// each bin at the zone its center rounds to. +pub fn zone_occupancy(bins: &[f64], mid: f64) -> Vec { + let mut out = vec![0f64; ZONE_COUNT as usize + 1]; + let total: f64 = bins.iter().sum(); + if total <= 0.0 { + return out; + } + let n = bins.len() as f64; + for (i, &b) in bins.iter().enumerate() { + let enc = ((i as f64 + 0.5) / n).clamp(0.0, 1.0); + let z = if enc <= mid { + 5.0 * enc / mid + } else { + 5.0 + 5.0 * (enc - mid) / (1.0 - mid) + }; + out[z.round_ties_even().clamp(0.0, ZONE_COUNT as f64) as usize] += b; + } + out.iter().map(|&v| v / total).collect() +} + +/// `loupe_acutance(patch)`: 100 x the RMS of the luminance's 3x3 Laplacian (cv2.Laplacian, +/// ksize 1, reflect-101 border) of an HxWxC display patch. +pub fn loupe_acutance(rgb: &[f32], (h, w, cn): (usize, usize, usize)) -> f64 { + let mut lum = vec![0f32; h * w]; + if cn == 3 { + crate::analysis::luminance_chunk(rgb, &mut lum); + } else { + let three: Vec = rgb + .chunks_exact(cn) + .flat_map(|p| [p[0], p[1], p[2]]) + .collect(); + crate::analysis::luminance_chunk(&three, &mut lum); + } + // Reflect-101 padding, one pixel wide, so every tap reads inside the buffer. + let reflect = |i: isize, n: usize| -> usize { + if n == 1 { + 0 + } else if i < 0 { + (-i) as usize + } else if i as usize >= n { + 2 * (n - 1) - i as usize + } else { + i as usize + } + }; + let pw = w + 2; + let mut pad = vec![0f32; (h + 2) * pw]; + for (y, out) in pad.chunks_exact_mut(pw).enumerate() { + let src = &lum[reflect(y as isize - 1, h) * w..][..w]; + out[1..=w].copy_from_slice(src); + out[0] = src[reflect(-1, w)]; + out[w + 1] = src[reflect(w as isize, w)]; + } + let mut sq = vec![0f32; h * w]; + for (y, out) in sq.chunks_exact_mut(w).enumerate() { + let (up, row, down) = ( + &pad[y * pw + 1..][..w], + &pad[(y + 1) * pw..][..w + 2], + &pad[(y + 2) * pw + 1..][..w], + ); + for (x, o) in out.iter_mut().enumerate() { + // OpenCV's float filter sums the kernel's nonzero taps in row-major order from 0. + let v = ((((0.0 + up[x]) + row[x]) + -4.0 * row[x + 1]) + row[x + 2]) + down[x]; + *o = v * v; + } + } + let mean = crate::stats::pairwise_sum_f32(&sq) / sq.len() as f32; + // np.sqrt of the float32 mean, times a Python float, stays float32. + (mean.sqrt() * 100.0) as f64 +} + +const SCOPE_HUE_BINS: usize = crate::histogram::SCOPE_HUE_BINS; +const SCOPE_CHROMA_BINS: usize = crate::histogram::SCOPE_CHROMA_BINS; +const SCOPE_MAX_CHROMA: f64 = 60.0; +const SCOPE_LOG_KNEE: f64 = 4.0; + +fn scope_radius(c: f64, log: bool) -> f64 { + if log { + libm::log1p(c / SCOPE_LOG_KNEE) / libm::log1p(SCOPE_MAX_CHROMA / SCOPE_LOG_KNEE) + } else { + c / SCOPE_MAX_CHROMA + } +} + +fn scope_chroma(r: f64, log: bool) -> f64 { + if log { + libm::expm1(r * libm::log1p(SCOPE_MAX_CHROMA / SCOPE_LOG_KNEE)) * SCOPE_LOG_KNEE + } else { + r * SCOPE_MAX_CHROMA + } +} + +/// `_scope_bins` of one a*b* point. +fn scope_bin(a: f64, b: f64) -> i64 { + use std::f64::consts::PI; + let hue = (((libm::atan2(b, a) + PI) / (2.0 * PI) * SCOPE_HUE_BINS as f64).floor() as i64) + .rem_euclid(SCOPE_HUE_BINS as i64); + let radius = scope_radius(libm::hypot(a, b), true); + let chroma = (radius * SCOPE_CHROMA_BINS as f64) + .floor() + .clamp(0.0, (SCOPE_CHROMA_BINS - 1) as f64) as i64; + hue * SCOPE_CHROMA_BINS as i64 + chroma +} + +/// `_scope_lookup(size, log)`: the polar bin of each display pixel (-1 outside the disc), each +/// bin's pixel count, the bins no pixel samples, and the pixel each of those folds into. +pub fn scope_lookup(size: usize, log: bool) -> (Vec, Vec, Vec, Vec) { + let u: Vec = (0..size) + .map(|i| (i as f64 + 0.5) / size as f64 * 2.0 - 1.0) + .collect(); + let idx: Vec = (0..size) + .into_par_iter() + .flat_map_iter(|r| { + let y = u[size - 1 - r]; + u.iter().map(move |&x| { + let radius = libm::hypot(x, y); + let chroma = scope_chroma(radius.min(1.0), log); + let hue = libm::atan2(y, x); + let bin = scope_bin(chroma * libm::cos(hue), chroma * libm::sin(hue)); + if radius > 1.0 { -1 } else { bin } + }) + }) + .collect(); + let mut area = vec![0f64; SCOPE_HUE_BINS * SCOPE_CHROMA_BINS]; + for &i in idx.iter().filter(|&&i| i >= 0) { + area[i as usize] += 1.0; + } + let orphans: Vec = (0..area.len() as i64) + .filter(|&i| area[i as usize] == 0.0) + .collect(); + let px = orphans + .iter() + .map(|&o| { + use std::f64::consts::PI; + let centre_hue = + ((o / SCOPE_CHROMA_BINS as i64) as f64 + 0.5) / SCOPE_HUE_BINS as f64 * 2.0 * PI + - PI; + let centre_c = scope_chroma( + ((o % SCOPE_CHROMA_BINS as i64) as f64 + 0.5) / SCOPE_CHROMA_BINS as f64, + true, + ); + let r = scope_radius(centre_c, log).min(1.0); + let last = (size - 1) as i64; + let col = + (((r * libm::cos(centre_hue) + 1.0) / 2.0 * size as f64) as i64).clamp(0, last); + let row = + (((1.0 - r * libm::sin(centre_hue)) / 2.0 * size as f64) as i64).clamp(0, last); + row * size as i64 + col + }) + .collect(); + (idx, area, orphans, px) +} + +/// `vectorscope_density`: each polar bin's count spread over the display pixels it covers, and +/// the bins finer than a pixel added to the pixel they fold into. +pub fn vectorscope_density( + counts: &[f64], + (idx, area, orphans, px): (&[i64], &[f64], &[i64], &[i64]), +) -> Vec { + let mut out: Vec = idx + .iter() + .map(|&i| { + if i >= 0 { + counts[i as usize] / area[i as usize].max(1.0) + } else { + 0.0 + } + }) + .collect(); + for (&o, &p) in orphans.iter().zip(px) { + out[p as usize] += counts[o as usize]; + } + out +} diff --git a/tests/test_core_zones.py b/tests/test_core_zones.py new file mode 100644 index 000000000..74e4e9b90 --- /dev/null +++ b/tests/test_core_zones.py @@ -0,0 +1,96 @@ +"""The zone, loupe and vectorscope reads against the cv2 and NumPy code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.exposure import analysis as A +from negpy.kernel.image.logic import get_luminance + +pytestmark = cv2_oracle + + +def _frame(seed, shape=(900, 1300)): + rng = np.random.default_rng(seed) + h, w = shape + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + base = 0.5 + 0.45 * np.sin(x / (w / 3.0) + seed) * np.cos(y / (h / 2.5)) + rgb = np.stack([base, base * 0.9, base * 1.05], axis=2) + rng.normal(0, 0.02, (h, w, 3)) + return np.clip(rgb, 0, 1).astype(np.float32) + + +def _zone_field_cv2(rgb, max_edge=A.ZONE_FIELD_MAX_EDGE): + h, w = rgb.shape[:2] + step = max(1, max(h, w) // (2 * max_edge)) + small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) + scale = min(1.0, max_edge / max(small.shape[:2])) + sh, sw = small.shape[:2] + lum = get_luminance(cv2.resize(small, (max(1, round(sw * scale)), max(1, round(sh * scale))), interpolation=cv2.INTER_AREA)) + lum = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), 2.0) + zones = np.clip(np.rint(np.nan_to_num(A.zone_of_encoded(lum))), 0, A.ZONE_COUNT).astype(np.uint8) + return cv2.medianBlur(zones, 5) + + +def _anchors_cv2(zones, min_share=0.01): + out = [] + for zone in np.unique(zones): + count, comp, stats, _ = cv2.connectedComponentsWithStats((zones == zone).astype(np.uint8), connectivity=4) + for i in range(1, count): + if stats[i, cv2.CC_STAT_AREA] < min_share * zones.size: + continue + mask = (comp == i).astype(np.uint8) + mask[0, :] = mask[-1, :] = 0 + mask[:, 0] = mask[:, -1] = 0 + dist = cv2.distanceTransform(mask, cv2.DIST_L2, 3) + y, x = np.unravel_index(int(np.argmax(dist)), dist.shape) + out.append((int(x), int(y), int(zone), float(dist[y, x]), dist)) + return out + + +def test_zone_field_matches_cv2(): + for seed in range(3): + rgb = _frame(seed) + assert A.zone_field(rgb).tobytes() == _zone_field_cv2(rgb).tobytes() + + +def test_zone_anchors_sit_at_a_most_interior_pixel(): + """Quality gate: cv2's distances run through IPP here, so an anchor may take another pixel + of the same depth; it must sit within 1e-4 of cv2's deepest point, with the same regions.""" + for seed in range(4): + zones = A.zone_field(_frame(seed)) + want = _anchors_cv2(zones) + got = A.zone_anchors(zones) + assert [g[2] for g in got] == [w[2] for w in want] + for (x, y, _, inset), (_, _, _, depth, dist) in zip(got, want): + assert abs(inset - depth) <= 1e-4 * max(1.0, depth) + assert dist[y, x] >= depth - 1e-4 * max(1.0, depth) + + +def test_loupe_and_labels_match_numpy(): + for seed in range(3): + rgb = _frame(seed, (60, 90)) + lum = get_luminance(rgb) + want = float(np.sqrt(np.mean(cv2.Laplacian(lum, cv2.CV_32F) ** 2)) * 100.0) + assert A.loupe_acutance(rgb) == want + cells = A.zone_grid(_frame(seed)) + labels = [] + for zone in np.unique(cells): + count, comp, _, centroids = cv2.connectedComponentsWithStats((cells == zone).astype(np.uint8), connectivity=4) + for i in range(1, count): + ys, xs = np.nonzero(comp == i) + j = int(np.argmin((xs - centroids[i][0]) ** 2 + (ys - centroids[i][1]) ** 2)) + labels.append((int(xs[j]), int(ys[j]), int(zone))) + assert A.zone_region_labels(cells) == labels + + +def test_vectorscope_density_matches_numpy(): + counts = np.random.default_rng(2).integers(0, 50, (A.SCOPE_HUE_BINS, A.SCOPE_CHROMA_BINS)).astype(float) + for size, log in ((256, True), (180, False)): + idx, area, orphans, px = negpy_core.scope_lookup(size, log) + flat = counts.ravel() + want = np.zeros(idx.shape) + inside = idx >= 0 + want[inside] = flat[idx[inside]] / np.maximum(area[idx[inside]], 1.0) + np.add.at(want.reshape(-1), px, flat[orphans]) + assert A.vectorscope_density(counts, size, log).tobytes() == want.tobytes() diff --git a/tests/test_vectorscope.py b/tests/test_vectorscope.py index 589b4dc52..2cbb384ff 100644 --- a/tests/test_vectorscope.py +++ b/tests/test_vectorscope.py @@ -12,7 +12,6 @@ from negpy.features.exposure.analysis import ( SCOPE_CHROMA_BINS, SCOPE_HUE_BINS, - _scope_bins, scope_chroma, scope_radius, vectorscope_density, @@ -23,6 +22,14 @@ from negpy.infrastructure.gpu.device import GPUDevice +def _scope_bins(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """The NumPy polar binning vectorscope_histogram copies.""" + hue = np.floor((np.arctan2(b, a) + np.pi) / (2 * np.pi) * SCOPE_HUE_BINS).astype(np.int64) % SCOPE_HUE_BINS + radius = scope_radius(np.hypot(a, b), log=True) + chroma = np.clip(np.floor(radius * SCOPE_CHROMA_BINS), 0, SCOPE_CHROMA_BINS - 1).astype(np.int64) + return hue * SCOPE_CHROMA_BINS + chroma + + def test_counts_match_the_numpy_binning() -> None: rng = np.random.default_rng(3) frame = rng.random((300, 400, 3), dtype=np.float32) * 1.2 - 0.1 From 9021e7a4185c5e695880533b54957bc8e1259ad3 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 17:05:27 +0200 Subject: [PATCH 110/133] fix(core): drop the stitch module's last cv2 import --- negpy/features/stitch/logic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 610f8e835..c3712699f 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -8,7 +8,6 @@ import math from typing import Callable, List, Optional, Sequence, Tuple -import cv2 import negpy_core import numpy as np From 3648ac065ac4218efc1d458267d5b0b8d09d2b71 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 17:11:24 +0200 Subject: [PATCH 111/133] fix(core): drop skew.py's cv2 import --- negpy/features/geometry/skew.py | 1 - 1 file changed, 1 deletion(-) diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 4fee8bc61..9b95af509 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -5,7 +5,6 @@ import math from dataclasses import dataclass, replace -import cv2 import negpy_core import numpy as np From 9cee4a02d81397cf094ba024634448ebe4b55d3a Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 17:33:00 +0200 Subject: [PATCH 112/133] perf(core): normalization planes and clip metrics in Rust measure_clip_fractions, density_above_base's median, the contrast mask tail, diffusion_grid and diffusion_plane call negpy_core. Replay: same on 12 frames (contrast mask and diffusion within 1e-6, through the ledgered log10). Bench: 0.11x to 0.71x of the recorded times. sorted_channel_grid stays np.sort: NumPy's SIMD sort beats a radix port about 4x. The dead _score_contour goes, and with it geometry/logic.py's and normalization.py's cv2 imports. --- negpy/features/exposure/normalization.py | 37 +++---- negpy/features/geometry/logic.py | 26 ----- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 82 +++++++++------ negpy_core/src/analysis.rs | 125 +++++++++++++++++++++++ negpy_core/src/py/analysis.rs | 113 +++++++++++++++++++- tests/test_core_normalization.py | 75 ++++++++++++++ 7 files changed, 379 insertions(+), 80 deletions(-) create mode 100644 tests/test_core_normalization.py diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 90563ac85..6fe838487 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -1,6 +1,5 @@ from typing import Any, Dict, Optional, TYPE_CHECKING, Tuple -import cv2 import negpy_core import numpy as np @@ -135,17 +134,16 @@ def measure_clip_fractions( """ from negpy.features.exposure.models import EXPOSURE_CONSTANTS - img = image - if roi: - y1, y2, x1, x2 = roi - img = img[y1:y2, x1:x2] + h, w = image.shape[:2] + y1, y2, x1, x2 = roi if roi else (0, h, 0, w) + y2, x2 = min(y2, h), min(x2, w) + y1, x1 = min(y1, y2), min(x1, x2) if analysis_buffer > 0: - img = get_analysis_crop(img, analysis_buffer) - # Stride-subsampled: a warning metric doesn't need every pixel. - img = img[::4, ::4] + b = min(max(analysis_buffer, 0.0), 0.3) + cut_h, cut_w = int((y2 - y1) * b), int((x2 - x1) * b) + y1, y2, x1, x2 = y1 + cut_h, y2 - cut_h, x1 + cut_w, x2 - cut_w level = float(EXPOSURE_CONSTANTS["scan_clip_level"]) - clipped = np.mean(img >= level, axis=(0, 1)) - return (float(clipped[0]), float(clipped[1]), float(clipped[2])) + return negpy_core.clip_fractions(np.ascontiguousarray(image, dtype=np.float32), (y1, y2, x1, x2), level) def effective_crosstalk_matrix(process: "ProcessConfig", process_mode: Optional[str]) -> Optional[np.ndarray]: @@ -297,7 +295,7 @@ def density_above_base( base = float(np.dot(w, np.asarray(base_rgb, dtype=np.float64))) thin = float(np.dot(w, np.asarray(bounds.ceils, dtype=np.float64))) dense = float(np.dot(w, np.asarray(bounds.floors, dtype=np.float64))) - median = float(np.median(np.asarray(prefiltered, dtype=np.float64).reshape(-1, 3) @ w)) + median = negpy_core.luma_median(np.ascontiguousarray(prefiltered, dtype=np.float32)) return (base - thin, base - median, base - dense) @@ -581,11 +579,9 @@ def contrast_mask_plane( return np.zeros(image.shape[:2], dtype=np.float32), 0.5 val = normalize_log_image(unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), bounds) - lum = LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] - sigma = min(max(spacer, MASK_SPACER_MIN), MASK_SPACER_MAX) * 0.01 * min(lum.shape[:2]) - blurred = negpy_core.gaussian_blur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), sigma, 0, cv2.BORDER_REPLICATE) - centre = float(blurred.mean()) - return blurred - centre, centre + sigma = min(max(spacer, MASK_SPACER_MIN), MASK_SPACER_MAX) * 0.01 * min(val.shape[:2]) + plane, centre = negpy_core.contrast_mask_tail(np.ascontiguousarray(val, dtype=np.float32), sigma) + return plane, float(centre) def diffusion_grid( @@ -603,9 +599,9 @@ def diffusion_grid( """The unmixed light blurred by `DIFFUSION_SPREAD`, as log density on `analysis_grid`. It takes no bounds: the print's normalization is a per-channel gain on that light.""" image = analysis_grid(image, rotation, fine_rotation, flip_horizontal, flip_vertical, distortion_k1, converge_v, converge_h, roi_norm) - light = np.ascontiguousarray(10.0 ** unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), dtype=np.float32) - sigma = DIFFUSION_SPREAD * 0.01 * min(light.shape[:2]) - return np.log10(negpy_core.gaussian_blur(light, (0, 0), sigma, 0, cv2.BORDER_REPLICATE)) + unmixed = np.ascontiguousarray(unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), dtype=np.float32) + sigma = DIFFUSION_SPREAD * 0.01 * min(unmixed.shape[:2]) + return negpy_core.diffusion_tail(unmixed, sigma) def diffusion_plane(grid: np.ndarray, bounds: LogNegativeBounds, panchromatic: bool = False) -> Optional[np.ndarray]: @@ -615,8 +611,7 @@ def diffusion_plane(grid: np.ndarray, bounds: LogNegativeBounds, panchromatic: b return None val = normalize_log_image(grid, bounds) if panchromatic: - lum = LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] - val = np.dstack([lum, lum, lum]) + val = negpy_core.luma_planes(np.ascontiguousarray(val, dtype=np.float32)) return np.ascontiguousarray(val, dtype=np.float32) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 1a40e31be..dee666d3e 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -3,7 +3,6 @@ from functools import lru_cache from typing import Dict, List, Optional, Tuple -import cv2 import negpy_core import numpy as np @@ -112,31 +111,6 @@ def _refine_frame_bounds(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int, return image[top:bottom, left:right], (left, top, right, bottom) -def _score_contour(contour: np.ndarray, image_area: float) -> tuple[float, np.ndarray] | None: - rect = cv2.minAreaRect(contour) - width, height = rect[1] - rect_area = float(width * height) - if rect_area <= 0: - return None - - contour_area = float(cv2.contourArea(contour)) - area_ratio = rect_area / image_area - fill_ratio = contour_area / rect_area if rect_area else 0.0 - short_side = min(width, height) - long_side = max(width, height) - aspect_ratio = long_side / max(short_side, 1.0) - - if area_ratio < 0.08: - return None - if short_side < 40: - return None - if aspect_ratio > 8.0: - return None - - score = area_ratio * 1.5 + min(fill_ratio, 1.0) - return score, cv2.boxPoints(rect) - - def _empty_autocrop_detection( vertical_edge_profile: np.ndarray, vertical_edge_contrast: float = 0.0, diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 85ed5b785..28a6243a3 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -36,3 +36,4 @@ tolerance listed for each entry. | `features/retouch/logic.py::apply_hair_inpaint` | `cv2.inpaint` (Navier-Stokes) is `negpy_core::inpaint::inpaint_ns`, and the tiles run in parallel. The decode table takes `mathf::pow_f64`, not NumPy's float32 power. OpenCV reads past the image on a one-pixel-wide tile; negpy_core clamps there. Gate `tests/test_core_inpaint.py`: inside the mask a mean difference of at most 1 and a maximum of 4 in cv2's 8-bit values, identical bytes outside. Measured: 549 846 inpainted values on 14 recorded crops and synthetic tiles match cv2 bit for bit; the final image differs by at most 1.2e-7. | quality | | `features/stitch/logic.py::find_seams` | `cv2.detail_DpSeamFinder("COLOR")` is `negpy_core::seam::dp_seams`, for parts on one canvas only. The pairs run in reverse order of OpenCV's sort, which keeps equal pairs in order up to 16 pairs, that is 6 parts; OpenCV's introsort can reorder more. Gate `tests/test_core_seam.py`: the color cost along the cut at most 1 % above cv2's, identical masks outside the overlap. Measured on synthetic overlaps of 2 to 4 parts and the recorded panorama: 0 % of overlap pixels differ, so the cut costs are equal. | quality | | `features/stitch/logic.py::seam_blend`, `feather_blend` (and `_seam_weight`, now inside `seam_blend`) | The distances come from `negpy_core.distance_transform`, not IPP's float32 chamfer (see its entry above); the error grows with the distance from the edge, and the weights clip it away beyond the blend band. Gate `tests/test_core_seam.py`: distances within 0.005 of cv2 on masks up to 1600 px, blends within 1e-4 of the cv2 version. Measured on the recorded panorama and synthetic overlaps: distances within 0.0042, `seam_blend` within 1.2e-7, `feather_blend` within 4.2e-7. On a 6000 px mask the distances differ by up to 0.085 far from the edge, and the blends still within 3.3e-7. | quality | +| `features/exposure/normalization.py::contrast_mask_plane`, `diffusion_grid` | Both read `prefilter_log_grid`, whose log10 is `mathf::log10f` (the `to_log_density` row). Diffusion's 10^x and log10 are `mathf`'s, not SVML's. Given the same log grid, `contrast_mask_plane` matches bit for bit. | abs=1e-6 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 2444fac59..8281b9d59 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -217,75 +217,89 @@ depends = [] ["features/exposure/normalization.py::_smooth_along_scan_order"] phase = 2 -status = "todo" +status = "stays" calls = ["np.median"] depends = [] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::_weighted_median"] phase = 2 -status = "todo" +status = "stays" calls = ["np.argsort", "np.cumsum"] depends = [] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::analysis_grid"] phase = 2 -status = "todo" +status = "stays" calls = ["negpy_core.resize_area"] depends = [] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::analyze_log_exposure_bounds"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["analyze_log_exposure_bounds_from_log", "to_log_density"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::analyze_log_exposure_bounds_from_log"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_block_median_grid", "_same_pixel_color_floor_refs", "_sample_log_bounds"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::contrast_mask_plane"] phase = 2 -status = "todo" -calls = ["negpy_core.gaussian_blur"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.contrast_mask_tail"] depends = ["analysis_grid", "normalize_log_image", "prefilter_log_grid"] +note = "negpy_core::analysis::contrast_mask_tail" ["features/exposure/normalization.py::density_above_base"] phase = 2 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.luma_median"] depends = [] +note = "negpy_core::analysis" ["features/exposure/normalization.py::detect_light_groups"] phase = 2 -status = "todo" +status = "stays" calls = ["np.median"] depends = ["_smooth_along_scan_order", "pool_neutral_axis"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::diffusion_grid"] phase = 2 -status = "todo" -calls = ["negpy_core.gaussian_blur"] +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.diffusion_tail"] depends = ["analysis_grid", "prefilter_log_grid"] +note = "negpy_core::analysis::diffusion_tail" ["features/exposure/normalization.py::diffusion_plane"] phase = 2 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.luma_planes"] depends = ["normalize_log_image"] +note = "negpy_core::analysis" ["features/exposure/normalization.py::effective_crosstalk_matrix"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["resolve_crosstalk_matrix"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::measure_anchor"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["measure_anchor_from_log", "to_log_density"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::measure_anchor_from_log"] phase = 2 @@ -301,9 +315,10 @@ depends = [] ["features/exposure/normalization.py::measure_clip_fractions"] phase = 2 -status = "todo" -calls = ["np.mean"] +status = "ported" +calls = ["negpy_core.clip_fractions"] depends = [] +note = "negpy_core::analysis" ["features/exposure/normalization.py::measure_highlight_point_from_log"] phase = 2 @@ -313,9 +328,10 @@ depends = ["_block_median_grid"] ["features/exposure/normalization.py::measure_neutral_axis"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["measure_neutral_axis_from_log", "to_log_density"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::measure_neutral_axis_from_log"] phase = 2 @@ -337,9 +353,10 @@ depends = ["_block_median_grid"] ["features/exposure/normalization.py::measure_textural_range"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["measure_textural_range_from_log", "to_log_density"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::measure_textural_range_from_log"] phase = 2 @@ -355,21 +372,24 @@ depends = [] ["features/exposure/normalization.py::pool_frame_bounds"] phase = 2 -status = "todo" +status = "stays" calls = ["np.median"] depends = [] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::pool_neutral_axis"] phase = 2 -status = "todo" +status = "stays" calls = ["np.median"] depends = ["_weighted_median", "band"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::pool_neutral_axis.band"] phase = 2 -status = "todo" +status = "stays" calls = [] depends = ["_weighted_median"] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::prefilter_log_grid"] phase = 2 @@ -380,21 +400,25 @@ depends = ["_block_median_grid", "to_log_density"] ["features/exposure/normalization.py::resolve_crosstalk_matrix"] phase = 2 -status = "todo" +status = "stays" calls = ["np.sum"] depends = [] +note = "orchestration or roll-size math" ["features/exposure/normalization.py::sorted_channel_grid"] phase = 2 -status = "todo" +status = "stays" calls = ["np.sort"] depends = [] +note = "np.sort (SIMD sort) is faster than a radix port" ["features/exposure/normalization.py::to_log_density"] phase = 2 -status = "todo" +status = "ported" +compare = "ulp=3" calls = ["negpy_core.to_log_density"] depends = [] +note = "negpy_core::analysis::to_log_density" ["features/geometry/batch_autocrop.py::_border_without_a_film_box"] phase = 2 @@ -699,12 +723,6 @@ calls = ["negpy_core.roi_from_measured_border"] depends = [] note = "negpy_core::analysis::roi_from_measured_border" -["features/geometry/logic.py::_score_contour"] -phase = 2 -status = "todo" -calls = ["cv2.boxPoints", "cv2.contourArea", "cv2.minAreaRect"] -depends = [] - ["features/geometry/logic.py::_select_consensus_cluster"] phase = 2 status = "ported" diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index c4987e2ff..0214b7019 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -1549,6 +1549,120 @@ pub fn refine_roi_to_image( Some((out, None)) } +/// `measure_clip_fractions`: per channel, the share of every 4th pixel of rows y1..y2 and +/// columns x1..x2 of an HxWx3 image at or above `level`. NaN for an empty window. +pub fn clip_fractions( + img: &[f32], + w: usize, + (y1, y2, x1, x2): (usize, usize, usize, usize), + level: f32, +) -> [f64; 3] { + let rows: Vec = (y1..y2).step_by(4).collect(); + let cols = (x1..x2).step_by(4).len(); + let counts = rows + .par_iter() + .map(|&y| { + let mut c = [0u64; 3]; + let row = &img[(y * w + x1) * 3..(y * w + x2) * 3]; + for p in row.as_chunks::<3>().0.iter().step_by(4) { + for ch in 0..3 { + c[ch] += (p[ch] >= level) as u64; + } + } + c + }) + .reduce(|| [0; 3], |a, b| [a[0] + b[0], a[1] + b[1], a[2] + b[2]]); + let n = (rows.len() * cols) as f64; + counts.map(|c| c as f64 / n) +} + +/// `grid @ w` in float64 for Rec. 709 luma, in the order OpenBLAS's dgemv sums the three terms. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn luma_f64_chunk(grid: &[f32], dst: &mut [f64]) { + for (d, p) in dst.iter_mut().zip(grid.as_chunks::<3>().0) { + let t = LUMA_R.mul_add(p[0] as f64, LUMA_G * p[1] as f64); + *d = LUMA_B.mul_add(p[2] as f64, t); + } +} + +/// `density_above_base`'s metered median: `np.median` of an Nx3 grid's float64 Rec. 709 luma. +pub fn luma_median(grid: &[f32]) -> f64 { + let mut lum = vec![0f64; grid.len() / 3]; + lum.par_chunks_mut(4096) + .zip(grid.par_chunks(4096 * 3)) + .for_each(|(d, p)| luma_f64_chunk(p, d)); + crate::stats::median_f64(&mut lum) +} + +/// Rec. 709 luma in float32, NumPy's order for `LUMA_R * r + LUMA_G * g + LUMA_B * b` (NEP 50). +#[inline(always)] +fn luma_f32(p: &[f32; 3]) -> f32 { + LUMA_R as f32 * p[0] + LUMA_G as f32 * p[1] + LUMA_B as f32 * p[2] +} + +/// `diffusion_plane`'s panchromatic collapse: each pixel of an HxWx3 plane set to its float32 luma. +pub fn luma_planes(val: &[f32], dst: &mut [f32]) { + crate::par_zip( + dst.as_chunks_mut::<3>().0, + val.as_chunks::<3>().0, + move |d, p| *d = [luma_f32(p); 3], + ); +} + +/// `contrast_mask_plane` after normalization: the float32 luma of an HxWx3 plane, blurred by +/// `sigma` with a replicated border, minus its float32 mean. Returns the plane and the mean. +pub fn contrast_mask_tail(val: &[f32], h: usize, w: usize, sigma: f64) -> Option<(Vec, f32)> { + let mut lum = vec![0f32; h * w]; + crate::par_zip(&mut lum, val.as_chunks::<3>().0, move |d, p| { + *d = luma_f32(p) + }); + let mut blurred = vec![0f32; h * w]; + crate::filter::gaussian_blur( + &lum, + &mut blurred, + (h, w, 1), + (0, 0), + (sigma, 0.0), + Border::Replicate, + )?; + let centre = pairwise_sum_f32(&blurred) / blurred.len() as f32; + blurred.par_chunks_mut(1 << 14).for_each(|d| { + for v in d { + *v -= centre; + } + }); + Some((blurred, centre)) +} + +/// `diffusion_grid` after the unmix: 10^x of an HxWx3 log plane, blurred by `sigma` with a +/// replicated border, back to log10. +pub fn diffusion_tail( + unmixed: &[f32], + h: usize, + w: usize, + sigma: f64, + dst: &mut [f32], +) -> Option<()> { + let mut light = vec![0f32; unmixed.len()]; + crate::par_zip(&mut light, unmixed, move |d, &v| { + *d = crate::mathf::exp10_f64(v as f64) as f32; + }); + crate::filter::gaussian_blur( + &light, + dst, + (h, w, 3), + (0, 0), + (sigma, 0.0), + Border::Replicate, + )?; + dst.par_chunks_mut(1 << 14).for_each(|d| { + for v in d { + *v = crate::mathf::log10f(*v); + } + }); + Some(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1569,4 +1683,15 @@ mod tests { normalize_to_uint8(&img, &mut out); assert!(out.iter().all(|&v| v == 0)); } + + #[test] + fn clip_fractions_count_every_fourth_pixel() { + let (h, w) = (8, 8); + let mut img = vec![0f32; h * w * 3]; + img[0] = 1.0; + img[(4 * w + 5) * 3 + 1] = 1.0; + img[(4 * w + 4) * 3 + 2] = 0.99; + let f = clip_fractions(&img, w, (0, h, 0, w), 0.99); + assert_eq!(f, [0.25, 0.0, 0.25]); + } } diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index acae371f2..5f6efeb0f 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -646,6 +646,112 @@ fn film_surround_is_plausible( Ok(crate::detect::film_surround_is_plausible(data, h, w, &roi)) } +fn rgb_dims(a: &PyReadonlyArrayDyn<'_, f32>, name: &str) -> PyResult<(usize, usize)> { + match a.shape()[..] { + [h, w, 3] => Ok((h, w)), + _ => Err(PyValueError::new_err(format!( + "{name} needs an HxWx3 array" + ))), + } +} + +fn filled_f32<'py>( + py: Python<'py>, + shape: &[usize], + n: usize, + f: impl FnOnce(&mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + let out = empty::(py, shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, n, || f(dst)) + .ok_or_else(|| PyValueError::new_err("blur sigma must be positive"))?; + } + Ok(out) +} + +/// `measure_clip_fractions` of an HxWx3 float32 image over the window (y1, y2, x1, x2). +#[pyfunction] +fn clip_fractions( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + window: (usize, usize, usize, usize), + level: f32, +) -> PyResult<(f64, f64, f64)> { + let (h, w) = rgb_dims(&img, "clip_fractions")?; + let (y1, y2, x1, x2) = window; + if y1 > y2 || x1 > x2 || y2 > h || x2 > w { + return Err(PyValueError::new_err("window outside the image")); + } + let data = slice(&img)?; + let f = super::detach(py, (y2 - y1) * (x2 - x1) * 3 / 16, || { + analysis::clip_fractions(data, w, window, level) + }); + Ok((f[0], f[1], f[2])) +} + +/// The median of a float32 grid's float64 Rec. 709 luma, any shape that ends in 3. +#[pyfunction] +fn luma_median(py: Python<'_>, grid: PyReadonlyArrayDyn<'_, f32>) -> PyResult { + if grid.shape().last() != Some(&3) { + return Err(PyValueError::new_err("luma_median needs an ...x3 array")); + } + let data = slice(&grid)?; + Ok(super::detach(py, data.len(), || { + analysis::luma_median(data) + })) +} + +/// Each pixel of an HxWx3 float32 plane set to its float32 Rec. 709 luma. +#[pyfunction] +fn luma_planes<'py>( + py: Python<'py>, + val: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let (h, w) = rgb_dims(&val, "luma_planes")?; + let data = slice(&val)?; + filled_f32(py, &[h, w, 3], data.len(), |dst| { + analysis::luma_planes(data, dst); + Some(()) + }) +} + +/// `contrast_mask_plane`'s blurred zero-mean luma of a normalized HxWx3 plane, and its mean. +#[pyfunction] +fn contrast_mask_tail<'py>( + py: Python<'py>, + val: PyReadonlyArrayDyn<'py, f32>, + sigma: f64, +) -> PyResult<(Bound<'py, PyArrayDyn>, f32)> { + let (h, w) = rgb_dims(&val, "contrast_mask_tail")?; + let data = slice(&val)?; + let (plane, centre) = super::detach(py, data.len(), || { + analysis::contrast_mask_tail(data, h, w, sigma) + }) + .ok_or_else(|| PyValueError::new_err("blur sigma must be positive"))?; + Ok(( + numpy::PyArray1::from_vec(py, plane).reshape(vec![h, w])?, + centre, + )) +} + +/// `diffusion_grid`'s blurred light of an unmixed HxWx3 log plane, as log10. +#[pyfunction] +fn diffusion_tail<'py>( + py: Python<'py>, + unmixed: PyReadonlyArrayDyn<'py, f32>, + sigma: f64, +) -> PyResult>> { + let (h, w) = rgb_dims(&unmixed, "diffusion_tail")?; + let data = slice(&unmixed)?; + filled_f32(py, &[h, w, 3], data.len(), |dst| { + analysis::diffusion_tail(data, h, w, sigma, dst) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(to_log_density, m)?)?; @@ -672,5 +778,10 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(film_and_picture, m)?)?; m.add_function(wrap_pyfunction!(measure_base_log, m)?)?; m.add_function(wrap_pyfunction!(film_box_covers_enough, m)?)?; - m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?) + m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?)?; + m.add_function(wrap_pyfunction!(clip_fractions, m)?)?; + m.add_function(wrap_pyfunction!(luma_median, m)?)?; + m.add_function(wrap_pyfunction!(luma_planes, m)?)?; + m.add_function(wrap_pyfunction!(contrast_mask_tail, m)?)?; + m.add_function(wrap_pyfunction!(diffusion_tail, m)?) } diff --git a/tests/test_core_normalization.py b/tests/test_core_normalization.py new file mode 100644 index 000000000..79e4092d9 --- /dev/null +++ b/tests/test_core_normalization.py @@ -0,0 +1,75 @@ +"""The normalization planes and metrics against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R +from negpy.features.exposure import normalization as N +from negpy.features.exposure.models import EXPOSURE_CONSTANTS + + +def _rgb(seed, shape=(301, 457)): + rng = np.random.default_rng(seed) + return rng.uniform(0.0, 1.0, (*shape, 3)).astype(np.float32) + + +def _clip_fractions_numpy(image, roi, buffer): + img = image + if roi: + y1, y2, x1, x2 = roi + img = img[y1:y2, x1:x2] + if buffer > 0: + img = N.get_analysis_crop(img, buffer) + clipped = np.mean(img[::4, ::4] >= float(EXPOSURE_CONSTANTS["scan_clip_level"]), axis=(0, 1)) + return tuple(float(v) for v in clipped) + + +@pytest.mark.parametrize( + "roi,buffer", + [(None, 0.0), (None, 0.1), ((13, 250, 7, 401), 0.0), ((13, 250, 7, 401), 0.05), ((0, 500, 0, 900), 0.0)], +) +def test_clip_fractions_match_numpy(roi, buffer): + img = _rgb(1) + img[img > 0.97] = 1.0 + assert N.measure_clip_fractions(img, roi, buffer) == _clip_fractions_numpy(img, roi, buffer) + + +def _lum32(val): + return LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] + + +def test_luma_planes_match_numpy(): + val = _rgb(2) * 2.0 - 0.5 + lum = _lum32(val) + np.testing.assert_array_equal(negpy_core.luma_planes(val), np.dstack([lum, lum, lum])) + + +@cv2_oracle +def test_luma_median_matches_numpy(): + grid = -3.0 * _rgb(3) + w = np.array([LUMA_R, LUMA_G, LUMA_B], dtype=np.float64) + assert negpy_core.luma_median(grid) == float(np.median(grid.astype(np.float64).reshape(-1, 3) @ w)) + + +@cv2_oracle +@pytest.mark.parametrize("sigma", [3.0, 12.4]) +def test_contrast_mask_tail_matches_numpy(sigma): + val = _rgb(4) + blurred = cv2.GaussianBlur( + np.ascontiguousarray(_lum32(val), dtype=np.float32), (0, 0), sigma, sigmaY=0, borderType=cv2.BORDER_REPLICATE + ) + centre = float(blurred.mean()) + plane, got = negpy_core.contrast_mask_tail(val, sigma) + assert got == np.float32(centre) + np.testing.assert_array_equal(plane, blurred - centre) + + +@cv2_oracle +def test_diffusion_tail_matches_numpy(): + unmixed = -2.5 * _rgb(5) + light = np.ascontiguousarray(10.0**unmixed, dtype=np.float32) + want = np.log10(cv2.GaussianBlur(light, (0, 0), 6.0, sigmaY=0, borderType=cv2.BORDER_REPLICATE)) + np.testing.assert_allclose(negpy_core.diffusion_tail(unmixed, 6.0), want, rtol=0, atol=1e-6) From d86b5ce77f4848c91d97ee0dfb2bfbf42a2a4594 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 17:37:13 +0200 Subject: [PATCH 113/133] perf(core): Phase 3 loaders without cv2 normalize_linear (LogLuv) is negpy_core::loaders::normalize_linear, bit for bit with np.percentile and np.clip. The streamed LinearRaw preview takes negpy_core's resize_area, morphology_ex and a new ellipse_kernel (cv2.getStructuringElement MORPH_ELLIPSE), checked against cv2 on 3- and 4-sample tiled TIFFs. negpy_core exports OpenCV's border and morphology codes so callers need no cv2 import. PakonLoader._looks_interleaved stays: a 6000-sample probe. --- negpy/infrastructure/loaders/logluv.py | 8 +- negpy/infrastructure/loaders/rawpy_loader.py | 14 ++-- negpy_core/manifest.toml | 13 +-- negpy_core/src/lib.rs | 2 + negpy_core/src/loaders.rs | 47 +++++++++++ negpy_core/src/morph.rs | 32 +++++++ negpy_core/src/py/filter.rs | 5 ++ negpy_core/src/py/loaders.rs | 42 ++++++++++ negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/morph.rs | 27 +++++- tests/test_core_loaders.py | 88 ++++++++++++++++++++ 11 files changed, 260 insertions(+), 19 deletions(-) create mode 100644 negpy_core/src/loaders.rs create mode 100644 negpy_core/src/py/loaders.rs create mode 100644 tests/test_core_loaders.py diff --git a/negpy/infrastructure/loaders/logluv.py b/negpy/infrastructure/loaders/logluv.py index c79d8b901..1b63497df 100644 --- a/negpy/infrastructure/loaders/logluv.py +++ b/negpy/infrastructure/loaders/logluv.py @@ -10,6 +10,7 @@ from typing import Tuple +import negpy_core import numpy as np _M_LN2 = 0.69314718055994530942 @@ -322,12 +323,7 @@ def normalize_linear(lin: np.ndarray) -> np.ndarray: per channel, correcting the per-channel black/gain offset inherent in Flextight CCD data (worst on red — weak CCD response, shallow C-41 cyan). """ - flat = lin.reshape(-1, lin.shape[-1]) - lo = np.percentile(flat, 0.2, axis=0) - hi = np.percentile(flat, 99.8, axis=0) - denom = hi - lo - denom = np.where(denom > 0, denom, 1.0) - return np.clip((lin - lo) / denom, 0.0, 1.0) + return negpy_core.normalize_linear(np.ascontiguousarray(lin, dtype=np.float64)) def xyz_to_linear_rgb(xyz: np.ndarray) -> np.ndarray: diff --git a/negpy/infrastructure/loaders/rawpy_loader.py b/negpy/infrastructure/loaders/rawpy_loader.py index 1c3cbacb5..a216328e2 100644 --- a/negpy/infrastructure/loaders/rawpy_loader.py +++ b/negpy/infrastructure/loaders/rawpy_loader.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any, ContextManager, Optional, Tuple -import cv2 +import negpy_core import numpy as np import rawpy import tifffile @@ -115,7 +115,7 @@ def tag(name: str) -> Optional[Any]: if min_preserve_ir and samples == 4 and scale < 1.0: size = max(1, int(round(1.0 / scale)) | 1) if size > 1: - ir_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (size, size)) + ir_kernel = negpy_core.ellipse_kernel((size, size)) for decoded, position, _shape in page.segments(maxworkers=1): if should_cancel is not None and should_cancel(): @@ -150,14 +150,14 @@ def tag(name: str) -> Optional[Any]: continue target = (right - left, bottom - top) if min_preserve_ir and samples == 4: - output[top:bottom, left:right, :3] = cv2.resize(data[:, :, :3], target, interpolation=cv2.INTER_AREA) - ir = data[:, :, 3] + output[top:bottom, left:right, :3] = negpy_core.resize_area(np.ascontiguousarray(data[:, :, :3], dtype=np.float32), target) + ir = np.ascontiguousarray(data[:, :, 3], dtype=np.float32) if ir_kernel is not None: # A boundary defect needs its inward footprint without decoding adjacent segments. - ir = cv2.erode(ir, ir_kernel, borderType=cv2.BORDER_REPLICATE) - output[top:bottom, left:right, 3] = cv2.resize(ir, target, interpolation=cv2.INTER_AREA) + ir = negpy_core.morphology_ex(ir, negpy_core.MORPH_ERODE, ir_kernel, border_type=negpy_core.BORDER_REPLICATE) + output[top:bottom, left:right, 3] = negpy_core.resize_area(ir, target) else: - output[top:bottom, left:right] = cv2.resize(data, target, interpolation=cv2.INTER_AREA) + output[top:bottom, left:right] = negpy_core.resize_area(np.ascontiguousarray(data, dtype=np.float32), target) full_dims = (height, width) if normalize_tags: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 8281b9d59..6df260430 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1019,21 +1019,24 @@ note = "orchestration of negpy_core kernels" ["infrastructure/loaders/logluv.py::normalize_linear"] phase = 3 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.normalize_linear"] depends = [] +note = "negpy_core::loaders::normalize_linear" ["infrastructure/loaders/pakon_loader.py::PakonLoader._looks_interleaved"] phase = 3 -status = "todo" +status = "stays" calls = ["np.mean"] depends = [] +note = "6000-sample layout probe" ["infrastructure/loaders/rawpy_loader.py::_stream_linearraw_preview"] phase = 3 -status = "todo" -calls = ["cv2.erode", "cv2.getStructuringElement", "cv2.resize"] +status = "ported" +calls = ["negpy_core.ellipse_kernel", "negpy_core.morphology_ex", "negpy_core.resize_area"] depends = [] +note = "negpy_core resize_area, morphology_ex, ellipse_kernel" ["kernel/image/logic.py::_float_to_uint16_luma_jit"] phase = 3 diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 54249e5df..61db88313 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -18,6 +18,7 @@ pub mod histogram; pub mod inpaint; pub mod lab; pub mod lens; +pub mod loaders; pub mod lsd; pub mod mathf; pub mod median; @@ -59,6 +60,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::resize::register(m)?; py::filter::register(m)?; py::morph::register(m)?; + py::loaders::register(m)?; py::print_curve::register(m)?; py::gray::register(m)?; py::contours::register(m)?; diff --git a/negpy_core/src/loaders.rs b/negpy_core/src/loaders.rs new file mode 100644 index 000000000..e425037e7 --- /dev/null +++ b/negpy_core/src/loaders.rs @@ -0,0 +1,47 @@ +//! Ports of the pixel work in negpy/infrastructure/loaders. + +use rayon::prelude::*; + +use crate::stats::quantile_f64; + +/// `normalize_linear`: each channel of a float64 image with `c` channels stretched from its +/// 0.2nd to its 99.8th percentile onto 0..1 and clipped. A flat channel divides by 1. +pub fn normalize_linear(lin: &[f64], c: usize, dst: &mut [f64]) { + let bounds: Vec<(f64, f64)> = (0..c) + .into_par_iter() + .map(|ch| { + let mut v: Vec = lin.iter().skip(ch).step_by(c).copied().collect(); + let lo = quantile_f64(&mut v, 0.2 / 100.0).unwrap_or(f64::NAN); + let hi = quantile_f64(&mut v, 99.8 / 100.0).unwrap_or(f64::NAN); + let denom = hi - lo; + (lo, if denom > 0.0 { denom } else { 1.0 }) + }) + .collect(); + dst.par_chunks_mut(c * 4096) + .zip(lin.par_chunks(c * 4096)) + .for_each(|(d, s)| { + for (d, s) in d.chunks_mut(c).zip(s.chunks(c)) { + for ch in 0..c { + let (lo, denom) = bounds[ch]; + d[ch] = ((s[ch] - lo) / denom).clamp(0.0, 1.0); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_channel_maps_to_zero() { + let lin: Vec = (0..3000) + .map(|i| if i % 3 == 1 { 5.0 } else { (i / 3) as f64 }) + .collect(); + let mut out = vec![0f64; lin.len()]; + normalize_linear(&lin, 3, &mut out); + assert!(out.iter().skip(1).step_by(3).all(|&v| v == 0.0)); + assert_eq!(out[0], 0.0); + assert_eq!(out[2997], 1.0); + } +} diff --git a/negpy_core/src/morph.rs b/negpy_core/src/morph.rs index aa28ca1d3..2dde98f5c 100644 --- a/negpy_core/src/morph.rs +++ b/negpy_core/src/morph.rs @@ -455,6 +455,24 @@ pub fn morphology_mask( Some(()) } +/// `cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (w, h))`: `h` rows of `w` cells. +pub fn ellipse_kernel(w: usize, h: usize) -> Vec { + let (r, c) = ((h / 2) as i64, (w / 2) as i64); + let inv_r2 = if r > 0 { 1.0 / (r * r) as f64 } else { 0.0 }; + let mut out = vec![0u8; w * h]; + for (i, row) in out.chunks_mut(w.max(1)).enumerate() { + let dy = i as i64 - r; + if dy.abs() > r { + continue; + } + // OpenCV's saturate_cast rounds half to even. + let dx = (c as f64 * (((r * r - dy * dy) as f64) * inv_r2).sqrt()).round_ties_even() as i64; + let (j1, j2) = ((c - dx).max(0) as usize, ((c + dx + 1) as usize).min(w)); + row[j1..j2].fill(1); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -503,4 +521,18 @@ mod tests { .unwrap(); assert!(dst.iter().all(|&v| v == 0.0)); } + + #[test] + fn ellipse_matches_opencv() { + assert_eq!(ellipse_kernel(1, 1), vec![1]); + #[rustfmt::skip] + let want = vec![ + 0, 0, 1, 0, 0, + 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, + 0, 0, 1, 0, 0, + ]; + assert_eq!(ellipse_kernel(5, 5), want); + } } diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs index 2d4f39262..f8fbb274d 100644 --- a/negpy_core/src/py/filter.rs +++ b/negpy_core/src/py/filter.rs @@ -148,6 +148,11 @@ fn gaussian_kernel(n: usize, sigma: f64) -> Vec { } pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + // OpenCV's border codes, so callers need no cv2 import. + m.add("BORDER_CONSTANT", 0)?; + m.add("BORDER_REPLICATE", 1)?; + m.add("BORDER_REFLECT", 2)?; + m.add("BORDER_REFLECT_101", 4)?; m.add_function(wrap_pyfunction!(box_filter, m)?)?; m.add_function(wrap_pyfunction!(gaussian_blur, m)?)?; m.add_function(wrap_pyfunction!(sep_filter2d, m)?)?; diff --git a/negpy_core/src/py/loaders.rs b/negpy_core/src/py/loaders.rs new file mode 100644 index 000000000..c9e51b767 --- /dev/null +++ b/negpy_core/src/py/loaders.rs @@ -0,0 +1,42 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::loaders; + +/// `normalize_linear` of a float64 image whose last axis holds the channels. +#[pyfunction] +fn normalize_linear<'py>( + py: Python<'py>, + lin: PyReadonlyArrayDyn<'py, f64>, +) -> PyResult>> { + let shape = lin.shape().to_vec(); + let c = match shape.last() { + Some(&c) if c > 0 && shape.len() >= 2 => c, + _ => { + return Err(PyValueError::new_err( + "normalize_linear needs an ...xC array", + )); + } + }; + let data = lin + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape, "float64"))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || loaders::normalize_linear(data, c, dst)); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(normalize_linear, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 5f14045ae..4a56993cd 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -15,6 +15,7 @@ pub mod hdr; pub mod histogram; pub mod lab; pub mod lens; +pub mod loaders; pub mod meter; pub mod morph; pub mod print_curve; diff --git a/negpy_core/src/py/morph.rs b/negpy_core/src/py/morph.rs index f1fec4740..fbb2d9ba5 100644 --- a/negpy_core/src/py/morph.rs +++ b/negpy_core/src/py/morph.rs @@ -84,6 +84,31 @@ fn morphology_ex<'py>( } } +/// `cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (w, h))` as an h x w uint8 array. +#[pyfunction] +fn ellipse_kernel<'py>( + py: Python<'py>, + ksize: (usize, usize), +) -> PyResult>> { + let (w, h) = ksize; + if w == 0 || h == 0 { + return Err(PyValueError::new_err("kernel size must be positive")); + } + numpy::PyArray1::from_vec(py, morph::ellipse_kernel(w, h)).reshape([h, w]) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(morphology_ex, m)?) + for (name, code) in [ + ("MORPH_ERODE", 0), + ("MORPH_DILATE", 1), + ("MORPH_OPEN", 2), + ("MORPH_CLOSE", 3), + ("MORPH_GRADIENT", 4), + ("MORPH_TOPHAT", 5), + ("MORPH_BLACKHAT", 6), + ] { + m.add(name, code)?; + } + m.add_function(wrap_pyfunction!(morphology_ex, m)?)?; + m.add_function(wrap_pyfunction!(ellipse_kernel, m)?) } diff --git a/tests/test_core_loaders.py b/tests/test_core_loaders.py new file mode 100644 index 000000000..4174dc557 --- /dev/null +++ b/tests/test_core_loaders.py @@ -0,0 +1,88 @@ +"""The loader kernels against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.infrastructure.loaders.logluv import normalize_linear + + +def _normalize_linear_numpy(lin): + flat = lin.reshape(-1, lin.shape[-1]) + lo = np.percentile(flat, 0.2, axis=0) + hi = np.percentile(flat, 99.8, axis=0) + denom = hi - lo + denom = np.where(denom > 0, denom, 1.0) + return np.clip((lin - lo) / denom, 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(1, 1, 3), (7, 5, 3), (311, 517, 3), (40, 30, 4)]) +def test_normalize_linear_matches_numpy(shape): + rng = np.random.default_rng(sum(shape)) + lin = rng.lognormal(0.0, 1.5, shape) * np.array([0.3, 1.0, 2.0, 1.0][: shape[-1]]) + lin[..., -1] = 0.25 if shape[0] == 7 else lin[..., -1] + np.testing.assert_array_equal(normalize_linear(lin), _normalize_linear_numpy(lin)) + + +@cv2_oracle +def test_ellipse_kernel_matches_cv2(): + for w in range(1, 42): + for h in (1, 2, 3, 6, 9, 17, w): + want = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (w, h)) + np.testing.assert_array_equal(negpy_core.ellipse_kernel((w, h)), want, err_msg=f"{w}x{h}") + + +def test_constants_match_cv2(): + for name in ( + "BORDER_CONSTANT", + "BORDER_REPLICATE", + "BORDER_REFLECT", + "BORDER_REFLECT_101", + "MORPH_ERODE", + "MORPH_DILATE", + "MORPH_OPEN", + "MORPH_CLOSE", + "MORPH_GRADIENT", + "MORPH_TOPHAT", + "MORPH_BLACKHAT", + ): + assert getattr(negpy_core, name) == getattr(cv2, name), name + + +class _Cv2Core: + MORPH_ERODE = cv2.MORPH_ERODE + BORDER_REPLICATE = cv2.BORDER_REPLICATE + + @staticmethod + def ellipse_kernel(ksize): + return cv2.getStructuringElement(cv2.MORPH_ELLIPSE, ksize) + + @staticmethod + def resize_area(src, dsize): + return cv2.resize(src, dsize, interpolation=cv2.INTER_AREA) + + @staticmethod + def morphology_ex(src, op, kernel, border_type): + return cv2.morphologyEx(src, op, kernel, borderType=border_type) + + +@cv2_oracle +@pytest.mark.parametrize("samples,preserve_ir", [(3, False), (4, False), (4, True)]) +def test_streamed_linearraw_preview_matches_cv2(tmp_path, monkeypatch, samples, preserve_ir): + import tifffile + + from negpy.infrastructure.loaders import rawpy_loader + + rng = np.random.default_rng(samples) + data = rng.integers(0, 65535, (300, 470, samples), dtype=np.uint16) + path = tmp_path / "scan.tif" + tifffile.imwrite(path, data, tile=(64, 64), photometric="rgb", extrasamples=[0] * (samples - 3)) + with tifffile.TiffFile(path) as tf: + page = tf.pages[0] + got = rawpy_loader._stream_linearraw_preview(page, page, 100, normalize_tags=False, min_preserve_ir=preserve_ir) + monkeypatch.setattr(rawpy_loader, "negpy_core", _Cv2Core) + want = rawpy_loader._stream_linearraw_preview(page, page, 100, normalize_tags=False, min_preserve_ir=preserve_ir) + np.testing.assert_array_equal(got[0], want[0]) + assert got[1] == want[1] From 1d990c3cc39623012ec751b44dd9773540f839b3 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 17:52:16 +0200 Subject: [PATCH 114/133] perf(core): Phase 4 process stats, chart reads, mask overlap detect_process_mode reads its correlations and percentiles from negpy_core::process::process_mode_stats and casts only the cropped, strided sample to float32. lightbox_level, the chart patch medians (the working decode now runs on the patch boxes only) and the mask overlap test run in Rust; lines_to_score takes negpy_core.box_filter. Replay: same on every Phase 4 recording. Side by side with the old code: detect_process_mode 3.4x and sample_patches 3.3x faster, lines_to_score equal. The percentile helper selects directly up to 131072 values instead of 32768: its histogram costs more than a select below that size. The lens fill search, chart geometry and fit stay Python: orchestration over Rust maps and 24-patch math. --- negpy/features/local/logic.py | 7 +- negpy/features/process/capture_color.py | 9 +- negpy/features/process/chart.py | 25 +++-- negpy/features/process/logic.py | 19 +--- negpy/features/retouch/logic.py | 2 +- negpy_core/manifest.toml | 86 +++++++++------ negpy_core/src/lib.rs | 2 + negpy_core/src/process.rs | 136 ++++++++++++++++++++++++ negpy_core/src/py/contours.rs | 18 ++++ negpy_core/src/py/mod.rs | 1 + negpy_core/src/py/process.rs | 94 ++++++++++++++++ negpy_core/src/shapes.rs | 7 ++ negpy_core/src/stats.rs | 2 +- tests/test_core_process.py | 73 +++++++++++++ 14 files changed, 411 insertions(+), 70 deletions(-) create mode 100644 negpy_core/src/process.rs create mode 100644 negpy_core/src/py/process.rs create mode 100644 tests/test_core_process.py diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index 5511e3dfb..40a92497f 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -73,11 +73,10 @@ def rasterise( def _tinted_area(mask, grid: int) -> np.ndarray | None: - """The mask's alpha above the faint-tail threshold, or None if it has too few points.""" + """The mask's alpha on the overlap grid, or None if it has too few points.""" if len(mask.vertices) < min_points(mask.shape): return None - alpha = rasterise(mask.shape, mask.vertices, grid, grid, mask.feather * grid, mask.invert) - return alpha > _OVERLAP_ALPHA + return rasterise(mask.shape, mask.vertices, grid, grid, mask.feather * grid, mask.invert) def overlapping_masks(config: LocalAdjustmentsConfig, index: int, grid: int = _OVERLAP_GRID) -> frozenset: @@ -99,7 +98,7 @@ def overlapping_masks(config: LocalAdjustmentsConfig, index: int, grid: int = _O if i == index: continue other = _tinted_area(mask, grid) - if other is not None and bool((target & other).any()): + if other is not None and negpy_core.alphas_overlap(target, other, _OVERLAP_ALPHA): hits.add(i) return frozenset(hits) diff --git a/negpy/features/process/capture_color.py b/negpy/features/process/capture_color.py index bc7f08ae7..9f6ea4c19 100644 --- a/negpy/features/process/capture_color.py +++ b/negpy/features/process/capture_color.py @@ -21,6 +21,7 @@ from typing import Optional, Sequence +import negpy_core import numpy as np # Adobe RGB (1998) D65, the working space (see infrastructure.display.color_spaces). @@ -126,12 +127,10 @@ def lightbox_level(img: np.ndarray, matrix: Optional[np.ndarray]) -> Optional[fl olive. ``img`` is the whole frame before ``matrix`` and before any crop, so framing does not move the reference. None when there is no signal. """ - flat = img.reshape(-1, 3) + flat = np.ascontiguousarray(img, dtype=np.float32).reshape(-1, 1, 3) step = max(1, flat.shape[0] // 200_000) - sample = flat[::step].astype(np.float64) - if matrix is not None: - sample = sample @ np.asarray(matrix, dtype=np.float64).T - white = float(np.percentile(sample, _LIGHTBOX_PCT)) + m = None if matrix is None else np.asarray(matrix, dtype=np.float64).reshape(3, 3).tolist() + white = negpy_core.lightbox_white(flat, step, m, _LIGHTBOX_PCT) if not np.isfinite(white) or white <= 1e-6: return None return _LIGHTBOX_TARGET / white diff --git a/negpy/features/process/chart.py b/negpy/features/process/chart.py index a4cd6e76f..abf75e8a6 100644 --- a/negpy/features/process/chart.py +++ b/negpy/features/process/chart.py @@ -9,9 +9,10 @@ from dataclasses import dataclass from typing import Callable, Optional +import negpy_core import numpy as np -from negpy.kernel.image.logic import rgb_to_lab_working, working_oetf_decode +from negpy.kernel.image.logic import _WORKING_GAMMA, rgb_to_lab_working ROWS = 4 COLS = 6 @@ -167,23 +168,27 @@ def sample_radius(corners: np.ndarray) -> float: return float(0.5 * _SAMPLE_FRACTION * min(pitch_x, pitch_y)) -def sample_patches(img: np.ndarray, corners_px: np.ndarray) -> np.ndarray: - """(24, 3) median of each patch box. Median, so dust on the card does not move it.""" +def _patch_medians(img: np.ndarray, corners_px: np.ndarray, gamma: Optional[float]) -> np.ndarray: + """(24, 3) median of each patch box; with `gamma`, of the clipped image decoded from the working encoding.""" h, w = img.shape[:2] r = max(1, int(round(sample_radius(corners_px)))) - out = np.empty((ROWS * COLS, 3), dtype=np.float64) - for i, (x, y) in enumerate(patch_centers(corners_px)): - x0, x1 = int(np.clip(round(x) - r, 0, w - 1)), int(np.clip(round(x) + r + 1, 1, w)) - y0, y1 = int(np.clip(round(y) - r, 0, h - 1)), int(np.clip(round(y) + r + 1, 1, h)) - out[i] = np.median(img[y0:y1, x0:x1, :3].reshape(-1, 3), axis=0) - return out + boxes = [] + for x, y in patch_centers(corners_px): + cx, cy = int(round(x)), int(round(y)) + boxes.append((min(max(cx - r, 0), w - 1), min(max(cx + r + 1, 1), w), min(max(cy - r, 0), h - 1), min(max(cy + r + 1, 1), h))) + return np.array(negpy_core.chart_patches(np.ascontiguousarray(img, dtype=np.float32), boxes, gamma), dtype=np.float64) + + +def sample_patches(img: np.ndarray, corners_px: np.ndarray) -> np.ndarray: + """(24, 3) median of each patch box. Median, so dust on the card does not move it.""" + return _patch_medians(img, corners_px, None) def measure_lab(encoded: np.ndarray, corners_px: np.ndarray, chart: ChartSpec, order: np.ndarray) -> np.ndarray: """The chart's patches in printed order, as Lab, after one exposure gain that matches its mid grays. Exposure is the user's choice and the matrix keeps grays gray, so the gain is not a fit term.""" - linear = sample_patches(np.asarray(working_oetf_decode(np.clip(encoded, 0.0, 1.0))), corners_px) + linear = _patch_medians(encoded, corners_px, float(np.float32(_WORKING_GAMMA))) rgb = np.empty_like(linear) rgb[order] = linear ref = reference_lab(chart) diff --git a/negpy/features/process/logic.py b/negpy/features/process/logic.py index 73a4cb879..16615e8a2 100644 --- a/negpy/features/process/logic.py +++ b/negpy/features/process/logic.py @@ -5,6 +5,7 @@ from typing import Optional, Sequence +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer @@ -191,14 +192,6 @@ def _downsample(img: ImageBuffer, max_dim: int) -> ImageBuffer: return img[::step, ::step] -def _corr(a: np.ndarray, b: np.ndarray) -> float: - """Pearson correlation between two flattened channels.""" - a = a.ravel() - float(a.mean()) - b = b.ravel() - float(b.mean()) - denom = float(np.sqrt(np.sum(a * a) * np.sum(b * b))) + 1e-12 - return float(np.sum(a * b) / denom) - - def _has_purple_mask(r_v: float, g_v: float, b_v: float) -> bool: """True iff a single (r,g,b) triplet shows the purple-mask pattern (R≈B>>G).""" deficit = (r_v + b_v) / 2 - g_v @@ -214,24 +207,18 @@ def detect_process_mode(raw: Optional[ImageBuffer]) -> ProcessMode: if raw is None or raw.ndim != 3 or raw.shape[2] < 3: return ProcessMode.C41 - img = get_analysis_crop(raw[:, :, :3].astype(np.float32), _ANALYSIS_BUFFER) - img = _downsample(img, _MAX_ANALYSIS_DIM) + img = _downsample(get_analysis_crop(raw[:, :, :3], _ANALYSIS_BUFFER), _MAX_ANALYSIS_DIM).astype(np.float32) img = np.clip(np.nan_to_num(img, nan=0.0, posinf=1.0, neginf=0.0), 0.0, 1.0) if img.size == 0: return ProcessMode.C41 - r, g, b = img[:, :, 0], img[:, :, 1], img[:, :, 2] + min_corr, (r_mean, b_mean, r_p25, b_p25, r_p98, g_p98, b_p98) = negpy_core.process_mode_stats(np.ascontiguousarray(img)) # B&W: the channels stay near-perfectly correlated even with a color tint, while real # color (C41, E-6) has varied hues and lower correlation. - min_corr = min(_corr(r, g), _corr(g, b), _corr(r, b)) if min_corr > _BW_CORR_THRESHOLD: return ProcessMode.BW - r_mean, b_mean = float(np.mean(r)), float(np.mean(b)) - r_p25, b_p25 = float(np.percentile(r, 25)), float(np.percentile(b, 25)) - r_p98, g_p98, b_p98 = float(np.percentile(r, 98)), float(np.percentile(g, 98)), float(np.percentile(b, 98)) - # Orange mask (standard C41): R much greater than B. Scanners sometimes correct the mask # only in bright areas, so check across density levels. orange_score = max( diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 427f8cb59..4d4417e61 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -594,7 +594,7 @@ def lines_to_score(img: ImageBuffer, lines: List[Tuple], threshold: float = 0.5) along = sheared[row] sign = np.sign(along.mean()) or 1.0 on = (along * sign > bar).astype(np.float32) - run = cv2.blur(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC + run = negpy_core.box_filter(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC lo, hi = int(max(0, min(x0, x1))), int(min(w, max(x0, x1)) + 1) keep = np.zeros(w, dtype=bool) keep[lo:hi] = run[lo:hi] diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 6df260430..0570bc1e6 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1283,21 +1283,24 @@ depends = [] ["features/lens/logic.py::_edge_reads"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["_lookup"] +note = "orchestration over Rust warp maps, edge-size math" ["features/lens/logic.py::_lookup"] phase = 4 -status = "todo" +status = "stays" calls = ["np.unique"] depends = [] +note = "orchestration over Rust warp maps, edge-size math" ["features/lens/logic.py::_search_fill_scale"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["_edge_reads"] +note = "orchestration over Rust warp maps, edge-size math" ["features/lens/logic.py::apply_lens"] phase = 4 @@ -1307,15 +1310,17 @@ depends = ["fill_scale"] ["features/lens/logic.py::fill_scale"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["_search_fill_scale"] +note = "orchestration over Rust warp maps, edge-size math" ["features/lens/warps.py::PanasonicWarp.remap"] phase = 4 -status = "todo" +status = "ported" calls = ["negpy_core.panasonic_map"] depends = [] +note = "negpy_core::lens" ["features/lens/warps.py::RectilinearWarp.remap"] phase = 4 @@ -1325,15 +1330,17 @@ depends = [] ["features/lens/warps.py::SonyWarp.remap"] phase = 4 -status = "todo" +status = "ported" calls = ["negpy_core.sony_map"] depends = [] +note = "negpy_core::lens" ["features/local/logic.py::_tinted_area"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["rasterise"] +note = "calls the Rust rasteriser" ["features/local/logic.py::compute_local_maps"] phase = 4 @@ -1343,9 +1350,10 @@ depends = [] ["features/local/logic.py::overlapping_masks"] phase = 4 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.alphas_overlap"] depends = ["_tinted_area"] +note = "negpy_core::shapes::alphas_overlap" ["features/local/logic.py::rasterise"] phase = 4 @@ -1355,57 +1363,73 @@ depends = [] ["features/process/capture_color.py::lightbox_level"] phase = 4 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.lightbox_white"] depends = [] +note = "negpy_core::process::lightbox_white" + +["features/process/chart.py::_patch_medians"] +phase = 4 +status = "ported" +calls = ["negpy_core.chart_patches"] +depends = ["patch_centers", "sample_radius"] +note = "negpy_core::process::chart_patches" ["features/process/chart.py::detect_orientation"] phase = 4 -status = "todo" +status = "stays" calls = ["np.mean"] depends = ["measure_lab"] +note = "24-patch math and orchestration" ["features/process/chart.py::fit_chart_matrix"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["detect_orientation", "lab_for", "measure_lab"] +note = "24-patch math and orchestration" ["features/process/chart.py::fit_chart_matrix.lab_for"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["measure_lab"] +note = "24-patch math and orchestration" ["features/process/chart.py::grid_corners"] phase = 4 -status = "todo" +status = "stays" calls = ["np.argsort"] depends = [] +note = "24-patch math and orchestration" ["features/process/chart.py::measure_lab"] phase = 4 -status = "todo" +status = "ported" calls = ["np.mean"] -depends = ["sample_patches"] +depends = ["_patch_medians"] +note = "negpy_core::process::chart_patches" ["features/process/chart.py::patch_centers"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["grid_corners"] +note = "24-patch math and orchestration" ["features/process/chart.py::sample_patches"] phase = 4 -status = "todo" -calls = ["np.median"] -depends = ["patch_centers", "sample_radius"] +status = "ported" +calls = [] +depends = ["_patch_medians"] +note = "negpy_core::process::chart_patches" ["features/process/chart.py::sample_radius"] phase = 4 -status = "todo" +status = "stays" calls = [] depends = ["grid_corners"] +note = "24-patch math and orchestration" ["features/process/hue.py::apply_hue_trim"] phase = 4 @@ -1413,17 +1437,12 @@ status = "ported" calls = ["negpy_core.hue_trim"] depends = [] -["features/process/logic.py::_corr"] -phase = 4 -status = "todo" -calls = ["np.sum"] -depends = [] - ["features/process/logic.py::detect_process_mode"] phase = 4 -status = "todo" -calls = ["np.mean", "np.percentile"] -depends = ["_corr"] +status = "ported" +calls = ["negpy_core.process_mode_stats"] +depends = [] +note = "negpy_core::process::process_mode_stats" ["features/process/sensor.py::_density_unmix"] phase = 4 @@ -1657,9 +1676,10 @@ depends = ["_fit_refraction_gammas", "_ir_clean_base", "_ir_decontaminate", "_ir ["features/retouch/logic.py::lines_to_score"] phase = 4 -status = "todo" -calls = ["cv2.blur"] +status = "ported" +calls = ["negpy_core.box_filter"] depends = ["_mask_to_score", "_scratch_ridge_rows", "_shear_rows"] +note = "negpy_core.box_filter" ["features/retouch/logic.py::normalize_ir"] phase = 4 diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 61db88313..157fe0627 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -25,6 +25,7 @@ pub mod median; pub mod meter; pub mod morph; pub mod print_curve; +pub mod process; pub mod register; pub mod resize; pub mod retouch; @@ -61,6 +62,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::filter::register(m)?; py::morph::register(m)?; py::loaders::register(m)?; + py::process::register(m)?; py::print_curve::register(m)?; py::gray::register(m)?; py::contours::register(m)?; diff --git a/negpy_core/src/process.rs b/negpy_core/src/process.rs new file mode 100644 index 000000000..9cdb0c7bc --- /dev/null +++ b/negpy_core/src/process.rs @@ -0,0 +1,136 @@ +//! Ports of the scan statistics in negpy/features/process: process-mode detection, the +//! lightbox level and the chart patch reads. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::pow_f64; +use crate::stats::{np_median, np_percentiles, pairwise_sum_f32, quantile_f64}; + +/// `x @ m.T` for one pixel, in the order OpenBLAS's dgemm sums it. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn matmul_rows(px: &[f64], m: &[[f64; 3]; 3], dst: &mut [f64]) { + for (d, x) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(px.as_chunks::<3>().0) + { + for c in 0..3 { + d[c] = x[2].mul_add(m[c][2], x[1].mul_add(m[c][1], x[0] * m[c][0])); + } + } +} + +/// `lightbox_level`'s reference: the `pct` percentile of every value of every `step`th pixel +/// of an Nx3 image, through `matrix` in float64 when given. NaN when any value is NaN. +pub fn lightbox_white(img: &[f32], step: usize, matrix: Option<[[f64; 3]; 3]>, pct: f64) -> f64 { + let mut sample: Vec = img + .as_chunks::<3>() + .0 + .iter() + .step_by(step.max(1)) + .flat_map(|p| p.map(f64::from)) + .collect(); + if let Some(m) = matrix { + let src = sample.clone(); + sample + .par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(d, s)| matmul_rows(s, &m, d)); + } + if sample.iter().any(|v| v.is_nan()) { + return f64::NAN; + } + quantile_f64(&mut sample, pct / 100.0).unwrap_or(f64::NAN) +} + +/// `_corr`: Pearson correlation of two float32 channels in NumPy's float32 arithmetic. +fn corr(a: &[f32], b: &[f32]) -> f64 { + let n = a.len() as f32; + let ma = pairwise_sum_f32(a) / n; + let mb = pairwise_sum_f32(b) / n; + let da: Vec = a.iter().map(|v| v - ma).collect(); + let db: Vec = b.iter().map(|v| v - mb).collect(); + let sq = |x: &[f32], y: &[f32]| { + pairwise_sum_f32(&x.iter().zip(y).map(|(p, q)| p * q).collect::>()) + }; + let denom = ((sq(&da, &da) * sq(&db, &db)).sqrt() as f64 + 1e-12) as f32; + (sq(&da, &db) / denom) as f64 +} + +/// The statistics `detect_process_mode` reads off an HxWx3 float32 image: the lowest channel +/// correlation, the red and blue means, the red and blue 25th and the RGB 98th percentiles. +pub fn process_mode_stats(img: &[f32]) -> (f64, [f64; 7]) { + let ch: Vec> = (0..3) + .map(|c| img.iter().skip(c).step_by(3).copied().collect()) + .collect(); + let (r, g, b) = (&ch[0], &ch[1], &ch[2]); + // Each q interpolates in float32, as a separate np.percentile call with a Python number does. + let (min_corr, p) = rayon::join( + || corr(r, g).min(corr(g, b)).min(corr(r, b)), + || { + ch.par_iter() + .map(|v| np_percentiles(v, &[25.0, 98.0]).unwrap_or(vec![f32::NAN; 2])) + .collect::>() + }, + ); + let n = r.len() as f32; + let (mr, mb) = ( + (pairwise_sum_f32(r) / n) as f64, + (pairwise_sum_f32(b) / n) as f64, + ); + let p = |c: usize, i: usize| p[c][i] as f64; + ( + min_corr, + [mr, mb, p(0, 0), p(2, 0), p(0, 1), p(1, 1), p(2, 1)], + ) +} + +/// `sample_patches` of an HxWxC float32 image: the float32 median per channel of the first three +/// channels in each (x0, x1, y0, y1) box. With `gamma`, of `working_oetf_decode(np.clip(img, 0, 1))`. +pub fn chart_patches( + img: &[f32], + w: usize, + cn: usize, + boxes: &[(usize, usize, usize, usize)], + gamma: Option, +) -> Vec<[f64; 3]> { + boxes + .par_iter() + .map(|&(x0, x1, y0, y1)| { + std::array::from_fn(|c| { + let mut v: Vec = (y0..y1) + .flat_map(|yy| (x0..x1).map(move |xx| img[(yy * w + xx) * cn + c])) + .map(|v| { + let Some(g) = gamma else { return v }; + // np.clip keeps NaN; the decode maps negatives to 0. + let x = v.clamp(0.0, 1.0) as f64; + pow_f64(if x < 0.0 { 0.0 } else { x }, g as f64) as f32 + }) + .collect(); + np_median(&mut v) as f64 + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identical_channels_correlate_fully() { + let img: Vec = (0..300).flat_map(|i| [i as f32 / 300.0; 3]).collect(); + let (c, s) = process_mode_stats(&img); + assert!((c - 1.0).abs() < 1e-6); + assert_eq!(s[0], s[1]); + } + + #[test] + fn lightbox_white_reads_the_top_percentile() { + let img: Vec = (0..1000).flat_map(|i| [i as f32; 3]).collect(); + assert_eq!(lightbox_white(&img, 1, None, 100.0), 999.0); + assert!(lightbox_white(&[f32::NAN, 0.0, 0.0], 1, None, 50.0).is_nan()); + } +} diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index 617b0cc4e..09fc4e824 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -228,7 +228,25 @@ fn stroke_cover<'py>( numpy::PyArray1::from_vec(py, mask).reshape([h, w]) } +/// Whether two same-shape float32 alphas both exceed `threshold` at any pixel. +#[pyfunction] +fn alphas_overlap( + a: numpy::PyReadonlyArrayDyn<'_, f32>, + b: numpy::PyReadonlyArrayDyn<'_, f32>, + threshold: f32, +) -> PyResult { + use numpy::PyUntypedArrayMethods; + if a.shape() != b.shape() { + return Err(PyValueError::new_err("alphas differ in shape")); + } + let (Ok(a), Ok(b)) = (a.as_slice(), b.as_slice()) else { + return Err(PyValueError::new_err("needs contiguous arrays")); + }; + Ok(crate::shapes::alphas_overlap(a, b, threshold)) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(alphas_overlap, m)?)?; m.add_function(wrap_pyfunction!(stroke_cover, m)?)?; m.add_function(wrap_pyfunction!(local_maps, m)?)?; m.add_function(wrap_pyfunction!(rasterise_mask, m)?)?; diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 4a56993cd..4883fef1e 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -19,6 +19,7 @@ pub mod loaders; pub mod meter; pub mod morph; pub mod print_curve; +pub mod process; pub mod register; pub mod resize; pub mod retouch; diff --git a/negpy_core/src/py/process.rs b/negpy_core/src/py/process.rs new file mode 100644 index 000000000..ee9aeee2a --- /dev/null +++ b/negpy_core/src/py/process.rs @@ -0,0 +1,94 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::process; + +fn pixels<'a>( + a: &'a PyReadonlyArrayDyn<'_, f32>, + cn_min: usize, +) -> PyResult<(&'a [f32], usize, usize)> { + let shape = a.shape(); + let (w, cn) = match shape { + [_, w, c] if *c >= cn_min => (*w, *c), + _ => { + return Err(PyValueError::new_err(format!( + "needs an HxWxC array with C >= {cn_min}" + ))); + } + }; + let data = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok((data, w, cn)) +} + +/// `lightbox_level`'s float64 reference value of an HxWx3 float32 image. +#[pyfunction] +#[pyo3(signature = (img, step, matrix, pct))] +fn lightbox_white( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + step: usize, + matrix: Option<[[f64; 3]; 3]>, + pct: f64, +) -> PyResult { + let (data, _, cn) = pixels(&img, 3)?; + if cn != 3 { + return Err(PyValueError::new_err("lightbox_white needs 3 channels")); + } + Ok(super::detach(py, data.len() / step.max(1), || { + process::lightbox_white(data, step, matrix, pct) + })) +} + +/// `detect_process_mode`'s statistics of an HxWx3 float32 image: (min correlation, +/// (r mean, b mean, r p25, b p25, r p98, g p98, b p98)). +#[pyfunction] +fn process_mode_stats( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<(f64, [f64; 7])> { + let (data, _, cn) = pixels(&img, 3)?; + if cn != 3 || data.is_empty() { + return Err(PyValueError::new_err( + "process_mode_stats needs a non-empty HxWx3 array", + )); + } + Ok(super::detach(py, data.len(), || { + process::process_mode_stats(data) + })) +} + +/// `sample_patches` of an HxWxC float32 image, one (x0, x1, y0, y1) box per patch; with `gamma`, +/// of the clipped image decoded from the working encoding. +#[pyfunction] +fn chart_patches( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + boxes: Vec<(usize, usize, usize, usize)>, + gamma: Option, +) -> PyResult> { + let (data, w, cn) = pixels(&img, 3)?; + let h = img.shape()[0]; + if boxes + .iter() + .any(|&(x0, x1, y0, y1)| x0 >= x1 || y0 >= y1 || x1 > w || y1 > h) + { + return Err(PyValueError::new_err("patch box outside the image")); + } + let n = boxes + .iter() + .map(|b| (b.1 - b.0) * (b.3 - b.2)) + .sum::() + * 3; + Ok(super::detach(py, n, || { + process::chart_patches(data, w, cn, &boxes, gamma) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(lightbox_white, m)?)?; + m.add_function(wrap_pyfunction!(process_mode_stats, m)?)?; + m.add_function(wrap_pyfunction!(chart_patches, m)?) +} diff --git a/negpy_core/src/shapes.rs b/negpy_core/src/shapes.rs index fe94a28e1..cc92c3756 100644 --- a/negpy_core/src/shapes.rs +++ b/negpy_core/src/shapes.rs @@ -860,6 +860,13 @@ pub struct LocalMask { } /// `compute_local_maps`' planes (h x w x planes float32) from masks in pixel space. +/// `overlapping_masks`' test: whether any pixel has both alphas above `threshold`. +pub fn alphas_overlap(a: &[f32], b: &[f32], threshold: f32) -> bool { + a.iter() + .zip(b) + .any(|(&p, &q)| p > threshold && q > threshold) +} + pub fn local_maps( masks: &[LocalMask], (h, w): (usize, usize), diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index e3b7b13f0..20d91e890 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -3,7 +3,7 @@ use rayon::prelude::*; const BUCKETS: usize = 1 << 16; const CHUNK: usize = 1 << 16; /// Below this size one `select_nth_unstable` beats the histogram passes. -const DIRECT_MAX: usize = 1 << 15; +const DIRECT_MAX: usize = 1 << 17; /// Total-order key of an f32. Every NaN maps to the top, where `np.partition` puts it. fn order_key(v: f32) -> u32 { diff --git a/tests/test_core_process.py b/tests/test_core_process.py new file mode 100644 index 000000000..141cbf7b5 --- /dev/null +++ b/tests/test_core_process.py @@ -0,0 +1,73 @@ +"""The process statistics, chart reads and mask overlap against the NumPy code they replace.""" + +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.local.logic import _OVERLAP_ALPHA +from negpy.features.process import chart +from negpy.features.process.capture_color import _LIGHTBOX_PCT +from negpy.kernel.image.logic import working_oetf_decode + + +def _scan(seed, shape=(380, 251)): + rng = np.random.default_rng(seed) + return rng.uniform(0.0, 1.2, (*shape, 3)).astype(np.float32) * np.array([0.9, 0.5, 0.3], dtype=np.float32) + + +@cv2_oracle +@pytest.mark.parametrize("matrix", [None, [[1.1, -0.05, -0.05], [-0.1, 1.2, -0.1], [0.02, -0.3, 1.28]]]) +def test_lightbox_white_matches_numpy(matrix): + img = _scan(1) + flat = img.reshape(-1, 3) + step = 7 + sample = flat[::step].astype(np.float64) + if matrix is not None: + sample = sample @ np.asarray(matrix, dtype=np.float64).T + want = float(np.percentile(sample, _LIGHTBOX_PCT)) + assert negpy_core.lightbox_white(img, step, matrix, _LIGHTBOX_PCT) == want + + +def _corr(a, b): + a = a.ravel() - float(a.mean()) + b = b.ravel() - float(b.mean()) + denom = float(np.sqrt(np.sum(a * a) * np.sum(b * b))) + 1e-12 + return float(np.sum(a * b) / denom) + + +@pytest.mark.parametrize("seed", [2, 3]) +def test_process_mode_stats_match_numpy(seed): + img = np.clip(_scan(seed), 0.0, 1.0) + r, g, b = img[:, :, 0], img[:, :, 1], img[:, :, 2] + want_corr = min(_corr(r, g), _corr(g, b), _corr(r, b)) + want = [float(np.mean(r)), float(np.mean(b))] + want += [float(np.percentile(r, 25)), float(np.percentile(b, 25))] + want += [float(np.percentile(c, 98)) for c in (r, g, b)] + got_corr, got = negpy_core.process_mode_stats(img) + assert got_corr == want_corr + assert list(got) == want + + +def test_chart_patches_match_numpy(): + encoded = _scan(4) - 0.05 + corners = np.array([[20.0, 30.0], [230.0, 28.0], [232.0, 350.0], [18.0, 352.0]]) + h, w = encoded.shape[:2] + linear = working_oetf_decode(np.clip(encoded, 0.0, 1.0)) + r = max(1, int(round(chart.sample_radius(corners)))) + want = np.empty((24, 3)) + for i, (x, y) in enumerate(chart.patch_centers(corners)): + x0, x1 = int(np.clip(round(x) - r, 0, w - 1)), int(np.clip(round(x) + r + 1, 1, w)) + y0, y1 = int(np.clip(round(y) - r, 0, h - 1)), int(np.clip(round(y) + r + 1, 1, h)) + want[i] = np.median(linear[y0:y1, x0:x1, :3].reshape(-1, 3), axis=0) + np.testing.assert_array_equal(chart._patch_medians(encoded, corners, float(np.float32(563.0 / 256.0))), want) + np.testing.assert_array_equal(chart.sample_patches(linear, corners), want) + + +def test_alphas_overlap_matches_numpy(): + rng = np.random.default_rng(5) + for _ in range(20): + a = rng.uniform(0, 0.3, (96, 96)).astype(np.float32) + b = rng.uniform(0, 0.27, (96, 96)).astype(np.float32) + want = bool(((a > _OVERLAP_ALPHA) & (b > _OVERLAP_ALPHA)).any()) + assert negpy_core.alphas_overlap(a, b, _OVERLAP_ALPHA) == want From 648b8005374a4c0b7696ae2d5d1515aeecce9898 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 18:40:17 +0200 Subject: [PATCH 115/133] perf(core): Phase 5 phase correlation in Rust, HDR/RGB Scan/stitch pixel loops cv2.phaseCorrelate is negpy_core::phase::phase_correlate (rustfft and realfft, float32 like OpenCV, both real inputs through real FFTs). cv2 measures the shift from n / 2.0 after a roll by n / 2, half a pixel off along an odd padded DFT size; the port measures from the roll's origin (ledger row, quality gate tests/test_core_phase.py). Side by side with cv2: 1024x683 17.3 -> 5.7 ms, 257x361 1.5 -> 1.2 ms. Also in Rust: mean(axis=2) for HDR and IR alignment, RGB Scan's channel assembly and Bayer channel means (0.96 s -> 0.07 s per raw, bit for bit), the stitch proxy stretch, IR composite, overlap count, gains and clip. rgbscan.py drops its cv2 import. Replay: same on every Phase 5 recording. The machine ran at a load of 3 to 7 during the bench, so the ratios against the recorded times read high: side by side, seam_blend is 120 ms against main's 155 ms. --- NOTICE.md | 3 +- negpy/features/hdr/logic.py | 4 +- negpy/features/rgbscan/logic.py | 20 +- negpy/features/stitch/logic.py | 20 +- negpy/infrastructure/scanners/sane_backend.py | 10 +- negpy_core/Cargo.lock | 50 ++++ negpy_core/Cargo.toml | 2 + negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 92 ++++--- negpy_core/src/convert.rs | 31 +++ negpy_core/src/lib.rs | 4 + negpy_core/src/phase.rs | 234 ++++++++++++++++++ negpy_core/src/py/convert.rs | 81 ++++++ negpy_core/src/py/mod.rs | 2 + negpy_core/src/py/phase.rs | 30 +++ negpy_core/src/py/rgbscan.rs | 25 ++ negpy_core/src/py/stitch.rs | 97 ++++++++ negpy_core/src/rgbscan.rs | 38 +++ negpy_core/src/stitch.rs | 68 +++++ tests/test_core_composite.py | 83 +++++++ tests/test_core_phase.py | 58 +++++ 21 files changed, 888 insertions(+), 65 deletions(-) create mode 100644 negpy_core/src/phase.rs create mode 100644 negpy_core/src/py/phase.rs create mode 100644 negpy_core/src/py/rgbscan.rs create mode 100644 negpy_core/src/rgbscan.rs create mode 100644 tests/test_core_composite.py create mode 100644 tests/test_core_phase.py diff --git a/NOTICE.md b/NOTICE.md index 29fe7520a..d16794516 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -64,7 +64,8 @@ The negpy_core extension module links these Rust crates: PyO3 and rust-numpy Apache-2.0), ndarray, num-traits, num-complex, num-integer, matrixmultiply and rawpointer (MIT OR Apache-2.0), libc, once_cell, either, portable-atomic and rustc-hash (MIT OR Apache-2.0), multiversion and target-features (MIT OR Apache-2.0), libm (MIT, a port of -musl's math library). +musl's math library), rustfft, realfft, primal-check, strength_reduce and transpose (MIT OR +Apache-2.0). negpy_core/Cargo.lock lists the exact versions. negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area diff --git a/negpy/features/hdr/logic.py b/negpy/features/hdr/logic.py index ccb0929ce..3033fa5d9 100644 --- a/negpy/features/hdr/logic.py +++ b/negpy/features/hdr/logic.py @@ -350,7 +350,7 @@ def merge_providers( raise ValueError("no frames to merge") ref = to_float(providers[reference]()) - ref_gray = ref.mean(axis=2) + ref_gray = negpy_core.mean_channels(np.ascontiguousarray(ref, dtype=np.float32)) max_shift = max(16.0, 0.02 * ref.shape[1]) num = np.zeros_like(ref) den = np.zeros_like(ref) @@ -384,7 +384,7 @@ def _align(ref_gray: np.ndarray, mov: np.ndarray, max_shift: float) -> np.ndarra """Register `mov` to the reference by sub-pixel translation; no-op on an implausible estimate. Translation only: the camera does not move between bracket frames, so a richer model would only fit noise.""" - dx, dy = estimate_shift(ref_gray, mov.mean(axis=2)) + dx, dy = estimate_shift(ref_gray, negpy_core.mean_channels(np.ascontiguousarray(mov, dtype=np.float32))) if max(abs(dx), abs(dy)) > max_shift: return mov h, w = mov.shape[:2] diff --git a/negpy/features/rgbscan/logic.py b/negpy/features/rgbscan/logic.py index 26dbd471a..24b2d0fbf 100644 --- a/negpy/features/rgbscan/logic.py +++ b/negpy/features/rgbscan/logic.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, replace from typing import Callable, Dict, List, Optional, Sequence, Tuple -import cv2 import negpy_core import numpy as np @@ -63,12 +62,10 @@ def classify_channel(means: Sequence[float]) -> int: def _channel_means(mosaic: np.ndarray, colors: np.ndarray, black: float) -> Tuple[float, float, float]: - def mean_of(*idx: int) -> float: - mask = np.isin(colors, idx) - return float(mosaic[mask].mean()) - black if mask.any() else 0.0 - # color_desc is RGBG: 0=R, 1=G, 2=B, 3=second green. - return mean_of(0), mean_of(1, 3), mean_of(2) + return negpy_core.channel_means( + np.ascontiguousarray(mosaic, dtype=np.float32), np.ascontiguousarray(colors, dtype=np.uint8), float(black) + ) def _scene_signature(mosaic: np.ndarray, black: float) -> np.ndarray: @@ -274,8 +271,7 @@ def estimate_shift(ref_gray: np.ndarray, mov_gray: np.ndarray) -> Tuple[float, f m = negpy_core.resize_area(np.ascontiguousarray(mov_gray, dtype=np.float32), sz) r = np.ascontiguousarray(r, dtype=np.float32) m = np.ascontiguousarray(m, dtype=np.float32) - win = cv2.createHanningWindow((r.shape[1], r.shape[0]), cv2.CV_32F) - (dx, dy), _resp = cv2.phaseCorrelate(r, m, win) + (dx, dy), _resp = negpy_core.phase_correlate(r, m) return dx * scale, dy * scale @@ -298,16 +294,12 @@ def assemble_rgb(r: np.ndarray, g: np.ndarray, b: np.ndarray, align: bool = True """ if not (r.shape == g.shape == b.shape): raise ValueError(f"RGB-scan exposures differ in shape: {r.shape}, {g.shape}, {b.shape}") - out = np.empty_like(r) - out[..., RED] = r[..., RED] if align: - ref = r[..., RED].astype(np.float32) + ref = negpy_core.extract_channel(np.ascontiguousarray(r, dtype=np.float32), RED) max_shift = max(16.0, 0.02 * r.shape[1]) g = _align_to(ref, g, GREEN, max_shift) b = _align_to(ref, b, BLUE, max_shift) - out[..., GREEN] = g[..., GREEN] - out[..., BLUE] = b[..., BLUE] - return out + return negpy_core.pick_channels(*(np.ascontiguousarray(x, dtype=r.dtype) for x in (r, g, b))) def merge_rgb_triplet( diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index c3712699f..77610d3a2 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -37,9 +37,7 @@ def build_proxy(rgb: np.ndarray) -> Tuple[np.ndarray, float]: if scale < 1.0: size = (max(1, round(gray.shape[1] * scale)), max(1, round(gray.shape[0] * scale))) gray = negpy_core.resize_area(np.ascontiguousarray(gray, dtype=np.float32), size) - lo, hi = np.percentile(gray, (1.0, 99.0)) - gray = np.clip((gray - lo) / max(hi - lo, 1e-9), 0.0, 1.0) - return (gray ** (1.0 / 2.2) * 255.0).astype(np.uint8), scale + return negpy_core.proxy_stretch(np.ascontiguousarray(gray, dtype=np.float32)), scale def register_pair(ref_proxy: np.ndarray, mov_proxy: np.ndarray, min_inliers: int = MIN_INLIERS) -> Tuple[np.ndarray, int]: @@ -162,7 +160,7 @@ def gain_compensate(ref: np.ndarray, ref_mask: np.ndarray, mov: np.ndarray, mov_ _MIN_OVERLAP_PX, ) if gains is not None: - mov *= np.array(gains, dtype=np.float32) + negpy_core.scale_channels(mov, gains) return mov @@ -270,12 +268,9 @@ def blend_ir( acc = np.full((h, w), -1.0, np.float32) for i, (ir, transform) in enumerate(zip(irs, transforms)): warped, mask = warp_into_canvas(ir, transform, canvas_wh, interpolation="linear") - if seams is not None: - owned = seams[i] - acc[owned] = warped[owned] - else: - acc[mask] = np.maximum(acc[mask], warped[mask]) - acc[acc < 0.0] = 1.0 + sel = mask if seams is None else seams[i] + negpy_core.ir_blend_into(acc, np.ascontiguousarray(warped, dtype=np.float32), np.ascontiguousarray(sel, dtype=bool), seams is None) + negpy_core.ir_finish(acc) return acc @@ -296,7 +291,7 @@ def stitch_composite( img, mask = warp_into_canvas(parts[i], transforms[i], canvas) parts[i] = None # type: ignore[call-overload] for j in range(len(warped)): - if (masks[j] & mask).sum() >= _MIN_OVERLAP_PX: + if negpy_core.mask_overlap(masks[j], mask) >= _MIN_OVERLAP_PX: gain_compensate(warped[j], masks[j], img, mask) break warped.append(img) @@ -307,7 +302,8 @@ def stitch_composite( else: decoded_scale = canvas[0] / max(config.stitch_canvas[0], 1) blended = seam_blend(warped, masks, seams, _SEAM_FEATHER_PX * decoded_scale) - rgb = np.clip(blended, 0.0, 1.0) + rgb = np.ascontiguousarray(blended, dtype=np.float32) + negpy_core.clip_unit(rgb) ir = None if irs and all(x is not None for x in irs): ir = blend_ir(irs, transforms, canvas, seams) # type: ignore[arg-type] diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index 344c51aef..af3d27e7b 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -5,6 +5,7 @@ from typing import Callable import cv2 +import negpy_core import numpy as np from negpy.kernel.system.text import count_of @@ -688,7 +689,7 @@ def _align_ir_to_rgb(rgb: np.ndarray, ir: np.ndarray) -> np.ndarray: return ir ref = rgb.astype(np.float32) if ref.ndim == 3: - ref = ref.mean(axis=2) + ref = negpy_core.mean_channels(ref) if ref.shape[2] == 3 else ref.mean(axis=2) mov = ir.astype(np.float32) if mov.ndim == 3: mov = mov[:, :, 0] @@ -696,12 +697,11 @@ def _align_ir_to_rgb(rgb: np.ndarray, ir: np.ndarray) -> np.ndarray: scale = max(1.0, w / _IR_ALIGN_PROBE_WIDTH) if scale > 1.0: sz = (_IR_ALIGN_PROBE_WIDTH, max(1, round(h / scale))) - r = cv2.resize(ref, sz, interpolation=cv2.INTER_AREA) - m = cv2.resize(mov, sz, interpolation=cv2.INTER_AREA) + r = negpy_core.resize_area(np.ascontiguousarray(ref), sz) + m = negpy_core.resize_area(np.ascontiguousarray(mov), sz) else: r, m = ref, mov - win = cv2.createHanningWindow((r.shape[1], r.shape[0]), cv2.CV_32F) - (dx, dy), _resp = cv2.phaseCorrelate(np.ascontiguousarray(r), np.ascontiguousarray(m), win) + (dx, dy), _resp = negpy_core.phase_correlate(np.ascontiguousarray(r), np.ascontiguousarray(m)) dx, dy = dx * scale, dy * scale if max(abs(dx), abs(dy)) > max(16.0, _IR_ALIGN_MAX_SHIFT_FRAC * w): return ir diff --git a/negpy_core/Cargo.lock b/negpy_core/Cargo.lock index aeac42dd5..f13707c69 100644 --- a/negpy_core/Cargo.lock +++ b/negpy_core/Cargo.lock @@ -113,6 +113,8 @@ dependencies = [ "numpy", "pyo3", "rayon", + "realfft", + "rustfft", ] [[package]] @@ -179,6 +181,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -280,12 +291,41 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "syn" version = "2.0.119" @@ -320,6 +360,16 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "unicode-ident" version = "1.0.27" diff --git a/negpy_core/Cargo.toml b/negpy_core/Cargo.toml index 7a92aa428..70ab2811e 100644 --- a/negpy_core/Cargo.toml +++ b/negpy_core/Cargo.toml @@ -18,3 +18,5 @@ pyo3 = { version = "0.30", features = ["abi3-py313"], optional = true } numpy = { version = "0.30", optional = true } multiversion = "0.8" libm = "0.2" +rustfft = "6" +realfft = "3" diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 28a6243a3..267998014 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -37,3 +37,4 @@ tolerance listed for each entry. | `features/stitch/logic.py::find_seams` | `cv2.detail_DpSeamFinder("COLOR")` is `negpy_core::seam::dp_seams`, for parts on one canvas only. The pairs run in reverse order of OpenCV's sort, which keeps equal pairs in order up to 16 pairs, that is 6 parts; OpenCV's introsort can reorder more. Gate `tests/test_core_seam.py`: the color cost along the cut at most 1 % above cv2's, identical masks outside the overlap. Measured on synthetic overlaps of 2 to 4 parts and the recorded panorama: 0 % of overlap pixels differ, so the cut costs are equal. | quality | | `features/stitch/logic.py::seam_blend`, `feather_blend` (and `_seam_weight`, now inside `seam_blend`) | The distances come from `negpy_core.distance_transform`, not IPP's float32 chamfer (see its entry above); the error grows with the distance from the edge, and the weights clip it away beyond the blend band. Gate `tests/test_core_seam.py`: distances within 0.005 of cv2 on masks up to 1600 px, blends within 1e-4 of the cv2 version. Measured on the recorded panorama and synthetic overlaps: distances within 0.0042, `seam_blend` within 1.2e-7, `feather_blend` within 4.2e-7. On a 6000 px mask the distances differ by up to 0.085 far from the edge, and the blends still within 3.3e-7. | quality | | `features/exposure/normalization.py::contrast_mask_plane`, `diffusion_grid` | Both read `prefilter_log_grid`, whose log10 is `mathf::log10f` (the `to_log_density` row). Diffusion's 10^x and log10 are `mathf`'s, not SVML's. Given the same log grid, `contrast_mask_plane` matches bit for bit. | abs=1e-6 | +| `features/rgbscan/logic.py::estimate_shift`, `infrastructure/scanners/sane_backend.py::_align_ir_to_rgb` (and the HDR and RGB Scan alignment through them) | `cv2.phaseCorrelate` is `negpy_core::phase::phase_correlate`, in float32 through rustfft and realfft, as OpenCV works. cv2 measures the shift from (n / 2.0, m / 2.0) after an fftShift that rolls by (n / 2, m / 2), so along an axis whose padded DFT size is odd it reads every shift half a pixel too far; negpy_core measures from the roll's origin. Gate `tests/test_core_phase.py`: within 0.2 px of the true shift on synthetic pairs (the 5x5 peak centroid's own bias), within 0.05 px of cv2 once its half pixel is taken off. The recorded HDR and RGB Scan merges match bit for bit. | quality | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 0570bc1e6..479fdd320 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1732,7 +1732,7 @@ depends = [] ["features/hdr/logic.py::_align"] phase = 5 status = "ported" -calls = ["negpy_core.warp_affine"] +calls = ["negpy_core.mean_channels", "negpy_core.warp_affine"] depends = [] ["features/hdr/logic.py::_count_clipped_rows"] @@ -1749,9 +1749,10 @@ depends = ["output_scale"] ["features/hdr/logic.py::clipped_fraction"] phase = 5 -status = "todo" +status = "ported" calls = [] depends = ["_count_clipped_rows"] +note = "negpy_core.count_clipped_rows" ["features/hdr/logic.py::measure_exposure"] phase = 5 @@ -1761,27 +1762,31 @@ depends = ["clipped_fraction"] ["features/hdr/logic.py::merge_bracket"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["apply_render_exposure", "merge_providers"] +note = "orchestration or bracket-size math" ["features/hdr/logic.py::merge_frames"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["apply_render_exposure", "merge_providers"] +note = "orchestration or bracket-size math" ["features/hdr/logic.py::merge_providers"] phase = 5 -status = "todo" -calls = ["negpy_core.finish_merge"] +status = "ported" +calls = ["negpy_core.finish_merge", "negpy_core.mean_channels"] depends = ["_accumulate", "_align"] +note = "negpy_core mean_channels, hdr_accumulate, finish_merge" ["features/hdr/logic.py::output_scale"] phase = 5 -status = "todo" +status = "stays" calls = ["np.median"] depends = [] +note = "orchestration or bracket-size math" ["features/hdr/logic.py::pair_ratio"] phase = 5 @@ -1791,63 +1796,80 @@ depends = [] ["features/hdr/logic.py::probe_exposures"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["measure_exposure"] +note = "orchestration or bracket-size math" ["features/hdr/logic.py::seed_shadow_density"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["shadow_reach_stops"] +note = "orchestration or bracket-size math" ["features/hdr/logic.py::shadow_reach_stops"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["output_scale"] +note = "orchestration or bracket-size math" ["features/hdr/logic.py::solve_ratios"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["measure_exposure", "pair_ratio", "step"] +note = "orchestration or bracket-size math" ["features/hdr/logic.py::solve_ratios.step"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["pair_ratio"] +note = "orchestration or bracket-size math" ["features/rgbscan/logic.py::_align_to"] phase = 5 -status = "todo" +status = "ported" calls = ["negpy_core.warp_affine"] depends = ["estimate_shift"] +note = "negpy_core phase_correlate, warp_affine" + +["features/rgbscan/logic.py::_channel_means"] +phase = 5 +status = "ported" +calls = ["negpy_core.channel_means"] +depends = [] +note = "negpy_core::rgbscan::channel_means" ["features/rgbscan/logic.py::assemble_rgb"] phase = 5 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.extract_channel", "negpy_core.pick_channels"] depends = ["_align_to"] +note = "negpy_core pick_channels, extract_channel" ["features/rgbscan/logic.py::merge_rgb_triplet"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["assemble_rgb"] +note = "orchestration" ["features/rgbscan/logic.py::probe_channel_means"] phase = 5 -status = "todo" +status = "stays" calls = [] depends = ["probe_frame"] +note = "orchestration" ["features/rgbscan/logic.py::probe_frame"] phase = 5 -status = "todo" +status = "ported" calls = ["np.mean"] -depends = [] +depends = ["_channel_means"] +note = "negpy_core::rgbscan::channel_means" ["features/stitch/logic.py::_seam_weight"] phase = 5 @@ -1859,15 +1881,17 @@ note = "negpy_core::stitch::seam_weight, inside seam_blend" ["features/stitch/logic.py::blend_ir"] phase = 5 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.ir_blend_into", "negpy_core.ir_finish"] depends = ["warp_into_canvas"] +note = "negpy_core::stitch" ["features/stitch/logic.py::build_proxy"] phase = 5 -status = "todo" -calls = ["negpy_core.resize_area", "np.percentile"] +status = "ported" +calls = ["negpy_core.proxy_stretch", "negpy_core.resize_area"] depends = [] +note = "negpy_core::stitch" ["features/stitch/logic.py::feather_blend"] phase = 5 @@ -1878,9 +1902,10 @@ depends = [] ["features/stitch/logic.py::gain_compensate"] phase = 5 -status = "todo" -calls = ["negpy_core.compensation_gains"] +status = "ported" +calls = ["negpy_core.compensation_gains", "negpy_core.scale_channels"] depends = [] +note = "negpy_core::stitch" ["features/stitch/logic.py::register_parts"] phase = 5 @@ -1899,10 +1924,11 @@ depends = [] ["features/stitch/logic.py::stitch_composite"] phase = 5 -status = "todo" +status = "ported" compare = "abs=1e-4" -calls = [] +calls = ["negpy_core.clip_unit", "negpy_core.mask_overlap"] depends = ["blend_ir", "feather_blend", "find_seams", "gain_compensate", "seam_blend", "warp_into_canvas"] +note = "negpy_core::stitch" ["features/stitch/logic.py::warp_into_canvas"] phase = 5 @@ -2587,9 +2613,11 @@ depends = [] ["features/rgbscan/logic.py::estimate_shift"] phase = 9 -status = "todo" -calls = ["cv2.createHanningWindow", "cv2.phaseCorrelate", "negpy_core.resize_area"] +status = "ported" +compare = "quality" +calls = ["negpy_core.phase_correlate", "negpy_core.resize_area"] depends = [] +note = "negpy_core::phase::phase_correlate" ["features/stitch/logic.py::find_seams"] phase = 9 @@ -2620,9 +2648,11 @@ depends = ["_plateau_clip_fraction"] ["infrastructure/scanners/sane_backend.py::_align_ir_to_rgb"] phase = 9 -status = "todo" -calls = ["cv2.createHanningWindow", "cv2.phaseCorrelate", "cv2.resize"] +status = "ported" +compare = "quality" +calls = ["negpy_core.mean_channels", "negpy_core.phase_correlate", "negpy_core.resize_area"] depends = [] +note = "negpy_core phase_correlate, mean_channels" ["infrastructure/scanners/sane_backend.py::_resample_rows_to_dpi"] phase = 9 diff --git a/negpy_core/src/convert.rs b/negpy_core/src/convert.rs index 3b6167b5c..333aacf35 100644 --- a/negpy_core/src/convert.rs +++ b/negpy_core/src/convert.rs @@ -133,6 +133,37 @@ pub fn multiply(a: &[f32], b: &[f32], dst: &mut [f32]) { }); } +/// `img.mean(axis=2)` of an HxWx3 float32 image: ((r + g) + b) / 3 in float32, NumPy's order. +pub fn mean_channels(src: &[f32], dst: &mut [f32]) { + crate::par_zip(dst, src.as_chunks::<3>().0, |d, p| { + *d = ((p[0] + p[1]) + p[2]) / 3.0; + }); +} + +/// Channel `c` of each pixel of every source into an HxWx3 image, one source per channel. +pub fn pick_channels(sources: [&[T]; 3], dst: &mut [T]) { + dst.par_chunks_mut(3 * BLOCK) + .enumerate() + .for_each(|(i, d)| { + let o = i * 3 * BLOCK; + for (j, p) in d.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let k = o + 3 * j; + *p = [sources[0][k], sources[1][k + 1], sources[2][k + 2]]; + } + }); +} + +/// Channel `c` of an image with `cn` channels, as a plane. +pub fn extract_channel(src: &[f32], cn: usize, c: usize, dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(cn * BLOCK)) + .for_each(|(d, s)| { + for (d, p) in d.iter_mut().zip(s.chunks_exact(cn)) { + *d = p[c]; + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 157fe0627..c9e121c41 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -24,11 +24,13 @@ pub mod mathf; pub mod median; pub mod meter; pub mod morph; +pub mod phase; pub mod print_curve; pub mod process; pub mod register; pub mod resize; pub mod retouch; +pub mod rgbscan; pub mod seam; pub mod sensor; pub mod shapes; @@ -63,6 +65,8 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::morph::register(m)?; py::loaders::register(m)?; py::process::register(m)?; + py::phase::register(m)?; + py::rgbscan::register(m)?; py::print_curve::register(m)?; py::gray::register(m)?; py::contours::register(m)?; diff --git a/negpy_core/src/phase.rs b/negpy_core/src/phase.rs new file mode 100644 index 000000000..f5b5fa367 --- /dev/null +++ b/negpy_core/src/phase.rs @@ -0,0 +1,234 @@ +//! `cv2.phaseCorrelate` with a `cv2.createHanningWindow` window, through rustfft and realfft. + +use rayon::prelude::*; +use rustfft::num_complex::Complex32; +use rustfft::{Fft, FftPlanner}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use realfft::RealFftPlanner; + +/// `cv2.getOptimalDFTSize`: the smallest 2^a 3^b 5^c at least `n`. +pub fn optimal_dft_size(n: usize) -> usize { + (n.max(1)..) + .find(|&m| { + let mut k = m; + for p in [2, 3, 5] { + while k % p == 0 { + k /= p; + } + } + k == 1 + }) + .unwrap_or(n) +} + +/// `cv2.createHanningWindow((w, h), cv2.CV_32F)` for h, w >= 2. +pub fn hanning_window(h: usize, w: usize) -> Vec { + let tau = 2.0 * std::f64::consts::PI; + let (c0, c1) = (tau / (w - 1) as f64, tau / (h - 1) as f64); + let wc: Vec = (0..w) + .map(|j| 0.5 * (1.0 - (c0 * j as f64).cos())) + .collect(); + let mut out = vec![0f32; h * w]; + out.par_chunks_mut(w).enumerate().for_each(|(i, row)| { + let wr = 0.5 * (1.0 - (c1 * i as f64).cos()); + for (d, &c) in row.iter_mut().zip(&wc) { + *d = ((wr * c) as f32).sqrt(); + } + }); + out +} + +type C32 = Complex32; + +/// One planner of each kind for every call: they keep the plans they have made. +fn plan(len: usize, inverse: bool) -> Arc> { + static PLANNER: OnceLock>> = OnceLock::new(); + let mut planner = PLANNER + .get_or_init(|| Mutex::new(FftPlanner::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()); + if inverse { + planner.plan_fft_inverse(len) + } else { + planner.plan_fft_forward(len) + } +} + +fn real_planner() -> MutexGuard<'static, RealFftPlanner> { + static PLANNER: OnceLock>> = OnceLock::new(); + PLANNER + .get_or_init(|| Mutex::new(RealFftPlanner::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +fn fft_rows(data: &mut [C32], len: usize, fft: &Arc>) { + data.par_chunks_mut(len).with_min_len(16).for_each_init( + || vec![C32::default(); fft.get_inplace_scratch_len()], + |scratch, row| fft.process_with_scratch(row, scratch), + ); +} + +/// `src` (rows x cols) transposed, in 32 x 32 tiles. +fn transpose(src: &[C32], rows: usize, cols: usize) -> Vec { + const T: usize = 32; + let mut dst = vec![C32::default(); src.len()]; + dst.par_chunks_mut(rows * T) + .enumerate() + .for_each(|(bx, band)| { + let x0 = bx * T; + let bw = band.len() / rows; + for y0 in (0..rows).step_by(T) { + for x in 0..bw { + for y in y0..(y0 + T).min(rows) { + band[x * rows + y] = src[y * cols + x0 + x]; + } + } + } + }); + dst +} + +/// The 2-D DFT of the windowed h x w plane zero-padded to m x n, as its n / 2 + 1 columns +/// (a real plane's spectrum holds the rest as conjugates), stored column after column. +fn forward(img: &[f32], win: &[f32], (h, w): (usize, usize), (m, n): (usize, usize)) -> Vec { + let k = n / 2 + 1; + let r2c = real_planner().plan_fft_forward(n); + let mut spec = vec![C32::default(); m * k]; + spec.par_chunks_mut(k).take(h).enumerate().for_each_init( + || (r2c.make_input_vec(), r2c.make_scratch_vec()), + |(input, scratch), (y, out)| { + let (src, wv) = (&img[y * w..][..w], &win[y * w..][..w]); + for (d, (&p, &q)) in input.iter_mut().zip(src.iter().zip(wv)) { + *d = p * q; + } + input[w..].fill(0.0); + // Fails only on buffer lengths, which make_*_vec sets. + let _ = r2c.process_with_scratch(input, out, scratch); + }, + ); + let mut cols = transpose(&spec, m, k); + fft_rows(&mut cols, m, &plan(m, false)); + cols +} + +/// The real m x n inverse DFT, unscaled, of n / 2 + 1 spectrum columns stored column after column. +fn inverse(mut cols: Vec, (m, n): (usize, usize)) -> Vec { + let k = n / 2 + 1; + fft_rows(&mut cols, m, &plan(m, true)); + let mut spec = transpose(&cols, k, m); + let c2r = real_planner().plan_fft_inverse(n); + let mut out = vec![0f32; m * n]; + out.par_chunks_mut(n) + .zip(spec.par_chunks_mut(k)) + .for_each_init( + || c2r.make_scratch_vec(), + |scratch, (o, s)| { + // Rounding leaves the DC and Nyquist bins a trace of imaginary part; theirs is 0. + s[0].im = 0.0; + if n % 2 == 0 { + s[k - 1].im = 0.0; + } + let _ = c2r.process_with_scratch(s, o, scratch); + }, + ); + out +} + +/// `cv2.phaseCorrelate(a, b, window)` of two h x w float32 planes with the Hanning window: +/// the shift of `b` against `a` as (dx, dy), and the peak response. cv2's is half a pixel off +/// along an axis whose padded size is odd. +pub fn phase_correlate(a: &[f32], b: &[f32], h: usize, w: usize) -> (f64, f64, f64) { + let win = hanning_window(h, w); + let (m, n) = (optimal_dft_size(h), optimal_dft_size(w)); + let (mut fa, fb) = rayon::join( + || forward(a, &win, (h, w), (m, n)), + || forward(b, &win, (h, w), (m, n)), + ); + fa.par_chunks_mut(m) + .zip(fb.par_chunks(m)) + .for_each(|(p, q)| { + for (p, q) in p.iter_mut().zip(q) { + let v = *p * q.conj(); + let sq = v.norm_sqr(); + *p = v * (sq.sqrt() / (sq + f32::EPSILON)); + } + }); + let corr = inverse(fa, (m, n)); + + // fftShift rolls by (n / 2, m / 2). cv2 then measures from (n / 2.0, m / 2.0), half a pixel + // off on an odd padded size; the roll's own origin is the true zero shift. + let (xm, ym) = (n / 2, m / 2); + let mut c = vec![0f64; m * n]; + c.par_chunks_mut(n).enumerate().for_each(|(y, row)| { + let src = &corr[((y + m - ym) % m) * n..][..n]; + let (head, tail) = row.split_at_mut(xm); + for (d, &v) in head.iter_mut().zip(&src[n - xm..]) { + *d = v as f64; + } + for (d, &v) in tail.iter_mut().zip(src) { + *d = v as f64; + } + }); + + let peak = c + .iter() + .enumerate() + .fold(0, |best, (i, &v)| if v > c[best] { i } else { best }); + let (py, px) = ((peak / n) as i64, (peak % n) as i64); + let (y0, y1) = ( + (py - 2).max(0) as usize, + (py + 2).min(m as i64 - 1) as usize, + ); + let (x0, x1) = ( + (px - 2).max(0) as usize, + (px + 2).min(n as i64 - 1) as usize, + ); + let (mut cx, mut cy, mut sum) = (0f64, 0f64, 0f64); + for y in y0..=y1 { + for x in x0..=x1 { + let v = c[y * n + x]; + cx += x as f64 * v; + cy += y as f64 * v; + sum += v; + } + } + let response = sum / (m * n) as f64; + let sum = sum + f64::EPSILON; + (xm as f64 - cx / sum, ym as f64 - cy / sum, response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn optimal_sizes() { + assert_eq!(optimal_dft_size(1024), 1024); + assert_eq!(optimal_dft_size(1025), 1080); + assert_eq!(optimal_dft_size(7), 8); + assert_eq!(optimal_dft_size(361), 375); + } + + #[test] + fn finds_an_integer_shift() { + let (h, w) = (64, 80); + let mut seed = 12345u64; + let mut noise = || { + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (seed >> 40) as f32 / (1u64 << 24) as f32 + }; + let field: Vec = (0..(h + 8) * (w + 8)).map(|_| noise()).collect(); + let at = |x: usize, y: usize| field[y * (w + 8) + x]; + let a: Vec = (0..h * w).map(|i| at(i % w + 4, i / w + 4)).collect(); + let b: Vec = (0..h * w).map(|i| at(i % w + 1, i / w + 6)).collect(); + let (dx, dy, _) = phase_correlate(&a, &b, h, w); + assert!( + (dx - 3.0).abs() < 0.1 && (dy + 2.0).abs() < 0.1, + "{dx} {dy}" + ); + } +} diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs index 9bca7729b..fc566ab0e 100644 --- a/negpy_core/src/py/convert.rs +++ b/negpy_core/src/py/convert.rs @@ -193,7 +193,88 @@ fn multiply<'py>( filled(py, a.shape(), "float32", |d| convert::multiply(sa, sb, d)) } +/// `img.mean(axis=2)` of an HxWx3 float32 image. +#[pyfunction] +fn mean_channels<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("mean_channels needs an HxWx3 array")); + }; + let src = contiguous(&img)?; + filled(py, &[h, w], "float32", |d| convert::mean_channels(src, d)) +} + +fn pick<'py, T: numpy::Element + Copy + Send + Sync>( + py: Python<'py>, + src: [&Bound<'py, PyAny>; 3], + dtype: &str, +) -> PyResult> { + let arrays: Vec> = + src.iter().map(|a| a.extract()).collect::>()?; + let shape = arrays[0].shape().to_vec(); + let [_, _, 3] = shape[..] else { + return Err(PyValueError::new_err("pick_channels needs HxWx3 arrays")); + }; + if arrays.iter().any(|a| a.shape() != shape.as_slice()) { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let s = [ + contiguous(&arrays[0])?, + contiguous(&arrays[1])?, + contiguous(&arrays[2])?, + ]; + Ok(filled(py, &shape, dtype, |d| convert::pick_channels(s, d))?.into_any()) +} + +/// An HxWx3 uint8, uint16 or float32 image whose channel c is channel c of the c-th source. +#[pyfunction] +fn pick_channels<'py>( + py: Python<'py>, + r: &Bound<'py, PyAny>, + g: &Bound<'py, PyAny>, + b: &Bound<'py, PyAny>, +) -> PyResult> { + let src = [r, g, b]; + if r.extract::>().is_ok() { + pick::(py, src, "float32") + } else if r.extract::>().is_ok() { + pick::(py, src, "uint16") + } else if r.extract::>().is_ok() { + pick::(py, src, "uint8") + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "pick_channels needs uint8, uint16 or float32 arrays", + )) + } +} + +/// Channel `c` of an HxWxC float32 image as a new HxW array. +#[pyfunction] +fn extract_channel<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + c: usize, +) -> PyResult>> { + let [h, w, cn] = img.shape()[..] else { + return Err(PyValueError::new_err( + "extract_channel needs an HxWxC array", + )); + }; + if c >= cn { + return Err(PyValueError::new_err("channel out of range")); + } + let src = contiguous(&img)?; + filled(py, &[h, w], "float32", |d| { + convert::extract_channel(src, cn, c, d) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(mean_channels, m)?)?; + m.add_function(wrap_pyfunction!(pick_channels, m)?)?; + m.add_function(wrap_pyfunction!(extract_channel, m)?)?; m.add_function(wrap_pyfunction!(multiply, m)?)?; m.add_function(wrap_pyfunction!(to_uint8, m)?)?; m.add_function(wrap_pyfunction!(to_uint16, m)?)?; diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 4883fef1e..125912e9b 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -18,11 +18,13 @@ pub mod lens; pub mod loaders; pub mod meter; pub mod morph; +pub mod phase; pub mod print_curve; pub mod process; pub mod register; pub mod resize; pub mod retouch; +pub mod rgbscan; pub mod sensor; pub mod skew; pub mod stats; diff --git a/negpy_core/src/py/phase.rs b/negpy_core/src/py/phase.rs new file mode 100644 index 000000000..8ae92da18 --- /dev/null +++ b/negpy_core/src/py/phase.rs @@ -0,0 +1,30 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// `cv2.phaseCorrelate(a, b, cv2.createHanningWindow(...))` of two same-size HxW float32 +/// planes: ((dx, dy), response). +#[pyfunction] +fn phase_correlate( + py: Python<'_>, + a: PyReadonlyArrayDyn<'_, f32>, + b: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<((f64, f64), f64)> { + let [h, w] = a.shape()[..] else { + return Err(PyValueError::new_err("phase_correlate needs 2-D arrays")); + }; + if b.shape() != a.shape() || h < 2 || w < 2 { + return Err(PyValueError::new_err( + "phase_correlate needs two same-size planes of at least 2x2", + )); + } + let (Ok(a), Ok(b)) = (a.as_slice(), b.as_slice()) else { + return Err(PyValueError::new_err("needs contiguous arrays")); + }; + let (dx, dy, r) = super::detach(py, h * w * 8, || crate::phase::phase_correlate(a, b, h, w)); + Ok(((dx, dy), r)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(phase_correlate, m)?) +} diff --git a/negpy_core/src/py/rgbscan.rs b/negpy_core/src/py/rgbscan.rs new file mode 100644 index 000000000..b2bc738e4 --- /dev/null +++ b/negpy_core/src/py/rgbscan.rs @@ -0,0 +1,25 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// `_channel_means` of a float32 mosaic and its uint8 CFA color indices. +#[pyfunction] +fn channel_means( + py: Python<'_>, + mosaic: PyReadonlyArrayDyn<'_, f32>, + colors: PyReadonlyArrayDyn<'_, u8>, + black: f64, +) -> PyResult<(f64, f64, f64)> { + if mosaic.shape() != colors.shape() { + return Err(PyValueError::new_err("mosaic and colors differ in shape")); + } + let (Ok(m), Ok(c)) = (mosaic.as_slice(), colors.as_slice()) else { + return Err(PyValueError::new_err("needs contiguous arrays")); + }; + let r = super::detach(py, m.len(), || crate::rgbscan::channel_means(m, c, black)); + Ok((r[0], r[1], r[2])) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(channel_means, m)?) +} diff --git a/negpy_core/src/py/stitch.rs b/negpy_core/src/py/stitch.rs index b286ef826..30f3b9a8a 100644 --- a/negpy_core/src/py/stitch.rs +++ b/negpy_core/src/py/stitch.rs @@ -120,7 +120,104 @@ fn seam_blend<'py>( out.into_pyarray(py).reshape(vec![h, w, 3]) } +fn rw<'a, T: numpy::Element>( + a: &'a mut numpy::PyReadwriteArrayDyn<'_, T>, +) -> PyResult<&'a mut [T]> { + a.as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `build_proxy`'s uint8 stretch of a float32 plane. +#[pyfunction] +fn proxy_stretch<'py>( + py: Python<'py>, + gray: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let shape = gray.shape().to_vec(); + let src = gray + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = super::detach(py, src.len(), || { + let mut d = vec![0u8; src.len()]; + stitch::proxy_stretch(src, &mut d); + d + }); + out.into_pyarray(py).reshape(shape) +} + +/// One part of `blend_ir` into `acc` in place: where `sel`, the part's value, or the larger +/// value with `take_max`. +#[pyfunction] +fn ir_blend_into( + py: Python<'_>, + mut acc: numpy::PyReadwriteArrayDyn<'_, f32>, + warped: PyReadonlyArrayDyn<'_, f32>, + sel: PyReadonlyArrayDyn<'_, bool>, + take_max: bool, +) -> PyResult<()> { + let n = acc.len(); + let w = slices(std::slice::from_ref(&warped), n)?[0]; + let s = slices(std::slice::from_ref(&sel), n)?[0]; + let a = rw(&mut acc)?; + super::detach(py, n, || stitch::ir_blend_into(a, w, s, take_max)); + Ok(()) +} + +/// `blend_ir`'s fill of uncovered canvas pixels (below 0) with 1.0, in place. +#[pyfunction] +fn ir_finish(py: Python<'_>, mut acc: numpy::PyReadwriteArrayDyn<'_, f32>) -> PyResult<()> { + let a = rw(&mut acc)?; + super::detach(py, a.len(), || stitch::ir_finish(a)); + Ok(()) +} + +/// `(a & b).sum()` of two same-shape bool masks. +#[pyfunction] +fn mask_overlap( + py: Python<'_>, + a: PyReadonlyArrayDyn<'_, bool>, + b: PyReadonlyArrayDyn<'_, bool>, +) -> PyResult { + let n = a.len(); + if b.shape() != a.shape() { + return Err(PyValueError::new_err("masks differ in shape")); + } + let masks = [a, b]; + let m = slices(&masks, n)?; + Ok(super::detach(py, n, || stitch::mask_overlap(m[0], m[1]))) +} + +/// `img *= gains` per channel of an HxWx3 float32 image, in place. +#[pyfunction] +fn scale_channels( + py: Python<'_>, + mut img: numpy::PyReadwriteArrayDyn<'_, f32>, + gains: [f32; 3], +) -> PyResult<()> { + let [_, _, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("scale_channels needs an HxWx3 array")); + }; + let d = rw(&mut img)?; + super::detach(py, d.len(), || stitch::scale_channels(d, gains)); + Ok(()) +} + +/// `np.clip(img, 0, 1, out=img)` of a float32 array. +#[pyfunction] +fn clip_unit(py: Python<'_>, mut img: numpy::PyReadwriteArrayDyn<'_, f32>) -> PyResult<()> { + let d = rw(&mut img)?; + super::detach(py, d.len(), || stitch::clip_unit(d)); + Ok(()) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(proxy_stretch, m)?)?; + m.add_function(wrap_pyfunction!(ir_blend_into, m)?)?; + m.add_function(wrap_pyfunction!(ir_finish, m)?)?; + m.add_function(wrap_pyfunction!(mask_overlap, m)?)?; + m.add_function(wrap_pyfunction!(scale_channels, m)?)?; + m.add_function(wrap_pyfunction!(clip_unit, m)?)?; m.add_function(wrap_pyfunction!(feather_blend, m)?)?; m.add_function(wrap_pyfunction!(seam_blend, m)?)?; m.add_function(wrap_pyfunction!(dp_seams, m)?)?; diff --git a/negpy_core/src/rgbscan.rs b/negpy_core/src/rgbscan.rs new file mode 100644 index 000000000..45618b845 --- /dev/null +++ b/negpy_core/src/rgbscan.rs @@ -0,0 +1,38 @@ +//! Ports of the raw probes in negpy/features/rgbscan/logic.py. + +use rayon::prelude::*; + +use crate::stats::pairwise_sum_f32; + +/// `_channel_means`: per CFA group (R = 0, G = 1 or 3, B = 2) the float32 mean of the mosaic +/// values in row-major order, NumPy's pairwise sum, minus `black` in float64. 0 for an empty group. +pub fn channel_means(mosaic: &[f32], colors: &[u8], black: f64) -> [f64; 3] { + let groups: [&[u8]; 3] = [&[0], &[1, 3], &[2]]; + let means: Vec = groups + .par_iter() + .map(|g| { + let picked: Vec = mosaic + .iter() + .zip(colors) + .filter(|(_, c)| g.contains(c)) + .map(|(&v, _)| v) + .collect(); + if picked.is_empty() { + return 0.0; + } + (pairwise_sum_f32(&picked) / picked.len() as f32) as f64 - black + }) + .collect(); + [means[0], means[1], means[2]] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn groups_greens_and_skips_empty() { + let mosaic = [10.0, 20.0, 30.0, 40.0]; + assert_eq!(channel_means(&mosaic, &[0, 1, 3, 1], 2.0), [8.0, 28.0, 0.0]); + } +} diff --git a/negpy_core/src/stitch.rs b/negpy_core/src/stitch.rs index 06e617b5b..4a3521545 100644 --- a/negpy_core/src/stitch.rs +++ b/negpy_core/src/stitch.rs @@ -1,5 +1,7 @@ //! The panorama gain compensation of negpy/features/stitch/logic.py. +use rayon::prelude::*; + /// `gain_compensate`'s per-channel gains: the float64 mean of `reference` over the overlap /// over that of `moving`, rows added in order. `None` under `min_overlap` pixels or for a /// mean under 1e-6, where the part stays as it is. @@ -134,3 +136,69 @@ pub fn seam_blend( seam_weight(seams[i], masks[i], (h, w), feather) }) } + +/// `build_proxy`'s stretch of a float32 plane: its 1st and 99th percentiles (float64, as for a +/// sequence of q) onto 0..1, clipped, raised to 1 / 2.2 and scaled to uint8 by truncation. +pub fn proxy_stretch(gray: &[f32], dst: &mut [u8]) { + let p = crate::stats::np_percentiles_f64(gray, &[1.0, 99.0]); + let (lo, hi) = (p[0], p[1]); + let span = (hi - lo).max(1e-9); + crate::par_zip(dst, gray, move |d, &v| { + let t = ((v as f64 - lo) / span).clamp(0.0, 1.0); + // NaN casts to 0, as NumPy's float to uint8 cast does on x86. + *d = (libm::pow(t, 1.0 / 2.2) * 255.0) as u8; + }); +} + +/// One part of `blend_ir`: where `sel` is set, the canvas takes the part's value, or the larger +/// of the two with `take_max`. +pub fn ir_blend_into(acc: &mut [f32], warped: &[f32], sel: &[bool], take_max: bool) { + acc.par_chunks_mut(1 << 14) + .zip(warped.par_chunks(1 << 14).zip(sel.par_chunks(1 << 14))) + .for_each(|(a, (w, s))| { + for ((a, &w), &s) in a.iter_mut().zip(w).zip(s) { + if s { + *a = if take_max { a.max(w) } else { w }; + } + } + }); +} + +/// `blend_ir`'s last step: every canvas pixel no part reached (below 0) becomes 1.0. +pub fn ir_finish(acc: &mut [f32]) { + acc.par_chunks_mut(1 << 14).for_each(|a| { + for v in a { + if *v < 0.0 { + *v = 1.0; + } + } + }); +} + +/// `(a & b).sum()` of two masks. +pub fn mask_overlap(a: &[bool], b: &[bool]) -> usize { + a.par_chunks(1 << 16) + .zip(b.par_chunks(1 << 16)) + .map(|(a, b)| a.iter().zip(b).filter(|(x, y)| **x & **y).count()) + .sum() +} + +/// `img *= gains` per channel of an HxWx3 float32 image, in place. +pub fn scale_channels(img: &mut [f32], gains: [f32; 3]) { + img.par_chunks_mut(3 << 14).for_each(|c| { + for p in c.as_chunks_mut::<3>().0 { + for k in 0..3 { + p[k] *= gains[k]; + } + } + }); +} + +/// `np.clip(img, 0, 1)` in place; NaN stays. +pub fn clip_unit(img: &mut [f32]) { + img.par_chunks_mut(1 << 14).for_each(|c| { + for v in c { + *v = v.clamp(0.0, 1.0); + } + }); +} diff --git a/tests/test_core_composite.py b/tests/test_core_composite.py new file mode 100644 index 000000000..f88f67bc8 --- /dev/null +++ b/tests/test_core_composite.py @@ -0,0 +1,83 @@ +"""The HDR, RGB Scan and stitch helpers against the NumPy code they replace.""" + +import numpy as np +import pytest + +import negpy_core +from negpy.features.rgbscan import logic as rgbscan +from negpy.features.stitch import logic as stitch + + +def _img(seed, shape=(123, 211, 3), dtype=np.float32): + return np.random.default_rng(seed).uniform(-0.1, 1.3, shape).astype(dtype) + + +def test_mean_channels_matches_numpy(): + img = _img(1) + np.testing.assert_array_equal(negpy_core.mean_channels(img), img.mean(axis=2)) + + +@pytest.mark.parametrize("dtype", [np.float32, np.uint16, np.uint8]) +def test_pick_channels_matches_numpy(dtype): + r, g, b = (np.random.default_rng(s).integers(0, 250, (31, 47, 3)).astype(dtype) for s in (2, 3, 4)) + want = np.empty_like(r) + want[..., 0], want[..., 1], want[..., 2] = r[..., 0], g[..., 1], b[..., 2] + np.testing.assert_array_equal(negpy_core.pick_channels(r, g, b), want) + np.testing.assert_array_equal(rgbscan.assemble_rgb(r, g, b, align=False), want) + + +def test_extract_channel_matches_numpy(): + img = _img(5, (20, 30, 4)) + np.testing.assert_array_equal(negpy_core.extract_channel(img, 2), img[..., 2]) + + +def test_proxy_stretch_matches_numpy(): + gray = _img(6, (600, 900)) + lo, hi = np.percentile(gray, (1.0, 99.0)) + want = (np.clip((gray - lo) / max(hi - lo, 1e-9), 0.0, 1.0) ** (1.0 / 2.2) * 255.0).astype(np.uint8) + np.testing.assert_array_equal(negpy_core.proxy_stretch(gray), want) + + +@pytest.mark.parametrize("take_max", [True, False]) +def test_ir_blend_matches_numpy(take_max): + rng = np.random.default_rng(7) + acc = np.full((40, 50), -1.0, np.float32) + want = acc.copy() + for _ in range(3): + warped = rng.uniform(0, 1, (40, 50)).astype(np.float32) + sel = rng.uniform(0, 1, (40, 50)) > 0.5 + negpy_core.ir_blend_into(acc, warped, sel, take_max) + want[sel] = np.maximum(want[sel], warped[sel]) if take_max else warped[sel] + negpy_core.ir_finish(acc) + want[want < 0.0] = 1.0 + np.testing.assert_array_equal(acc, want) + + +def test_mask_overlap_scale_and_clip_match_numpy(): + rng = np.random.default_rng(8) + a, b = rng.uniform(0, 1, (2, 64, 80)) > 0.4 + assert negpy_core.mask_overlap(a, b) == int((a & b).sum()) + img = _img(9) + want = img * np.array([0.9, 1.1, 1.3], dtype=np.float32) + negpy_core.scale_channels(img, [0.9, 1.1, 1.3]) + np.testing.assert_array_equal(img, want) + want = np.clip(img, 0.0, 1.0) + negpy_core.clip_unit(img) + np.testing.assert_array_equal(img, want) + + +def test_channel_means_match_numpy(): + rng = np.random.default_rng(10) + mosaic = rng.uniform(0, 4000, (300, 401)).astype(np.float32) + colors = np.tile(np.array([[0, 1], [3, 2]], np.uint8), (150, 201))[:, :401] + + def mean_of(*idx): + mask = np.isin(colors, idx) + return float(mosaic[mask].mean()) - 64.0 if mask.any() else 0.0 + + assert rgbscan._channel_means(mosaic, colors, 64.0) == (mean_of(0), mean_of(1, 3), mean_of(2)) + + +def test_build_proxy_keeps_its_contract(): + proxy, scale = stitch.build_proxy(_img(11, (2000, 3000, 3))) + assert proxy.dtype == np.uint8 and max(proxy.shape) == stitch.PROXY_MAX_EDGE and scale < 1.0 diff --git a/tests/test_core_phase.py b/tests/test_core_phase.py new file mode 100644 index 000000000..f2e5cb60d --- /dev/null +++ b/tests/test_core_phase.py @@ -0,0 +1,58 @@ +"""Quality gate for negpy_core.phase_correlate, the float64 port of cv2.phaseCorrelate.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core + +SHIFTS = [(3.0, -2.0), (0.4, 1.7), (-12.3, 5.5), (0.0, 0.0), (7.75, -0.25)] + + +def _texture(seed, shape): + rng = np.random.default_rng(seed) + return cv2.GaussianBlur(rng.uniform(0, 1, shape).astype(np.float32), (0, 0), 2.5) + + +def _shifted(img, dx, dy): + h, w = img.shape + m = np.float32([[1, 0, dx], [0, 1, dy]]) + return cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REFLECT) + + +@pytest.mark.parametrize("shape", [(300, 400), (257, 361), (512, 1024)]) +@pytest.mark.parametrize("dx,dy", SHIFTS) +def test_recovers_the_true_shift(shape, dx, dy): + """Within the bias of the 5x5 peak centroid, which cv2 shares.""" + base = _texture(sum(shape), shape) + (gx, gy), response = negpy_core.phase_correlate(base, _shifted(base, dx, dy)) + assert abs(gx - dx) < 0.2 and abs(gy - dy) < 0.2 + assert response > 0.5 + + +@pytest.mark.parametrize("shape", [(257, 361), (300, 401), (375, 270)]) +def test_an_odd_padded_size_reads_no_shift_as_zero(shape): + base = _texture(3, shape) + (gx, gy), _ = negpy_core.phase_correlate(base, base.copy()) + assert abs(gx) < 1e-6 and abs(gy) < 1e-6 + + +@cv2_oracle +@pytest.mark.parametrize("shape", [(300, 400), (257, 361)]) +@pytest.mark.parametrize("dx,dy", SHIFTS) +def test_stays_near_cv2(shape, dx, dy): + """cv2 measures from n / 2.0, half a pixel off along an odd padded axis.""" + h, w = shape + base = _texture(7, shape) + mov = _shifted(base, dx, dy) + (cx, cy), _ = cv2.phaseCorrelate(base, mov, cv2.createHanningWindow((w, h), cv2.CV_32F)) + cx -= 0.5 * (cv2.getOptimalDFTSize(w) % 2) + cy -= 0.5 * (cv2.getOptimalDFTSize(h) % 2) + (gx, gy), _ = negpy_core.phase_correlate(base, mov) + assert abs(gx - cx) < 0.05 and abs(gy - cy) < 0.05 + + +def test_rejects_mismatched_planes(): + with pytest.raises(ValueError): + negpy_core.phase_correlate(np.zeros((4, 4), np.float32), np.zeros((4, 5), np.float32)) From 5aa734220e5a3c896d748fb4f90c93b0c4eb2e25 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 18:56:50 +0200 Subject: [PATCH 116/133] perf(core): Phase 6 render-stage pixel work in Rust The normalization processors take their log density from negpy_core.log_density_open (mathf log10, ledgered: abs=2e-6 on the normalized image). The B&W stacks, the local map channels, the mask and dodge map sum, the Lab clip, the camera matrix (OpenBLAS's sgemm order, bit for bit) and the contrast mask expand (resize, place, edge pad and scale in one pass) run in Rust. The display, contact sheet and splash downsamples use resize_area; image_processor.py and preview_manager.py drop cv2. Replay: Phase 6 same except the known 1-ULP frame 0017 of PhotometricProcessor.process. contrast_mask_ev side by side on a 24 MP frame with a crop: 40.4 -> 15.8 ms (every recorded call has no plane). Placement solvers, densitometer, chart curve, carrier profiles and the processors' orchestration stay Python. --- negpy/features/exposure/logic.py | 23 +-- negpy/features/exposure/processor.py | 31 ++-- negpy/features/lab/logic.py | 7 +- negpy/features/lab/processor.py | 3 +- negpy/features/process/capture_color.py | 3 +- negpy/features/transparency/processor.py | 7 +- negpy/services/rendering/image_processor.py | 5 +- negpy/services/rendering/preview_manager.py | 3 +- negpy_core/LEDGER.md | 2 +- negpy_core/manifest.toml | 180 +++++++++++++------- negpy_core/src/analysis.rs | 21 +++ negpy_core/src/convert.rs | 42 +++++ negpy_core/src/py/analysis.rs | 20 +++ negpy_core/src/py/convert.rs | 71 ++++++++ negpy_core/src/py/resize.rs | 34 ++++ negpy_core/src/resize.rs | 26 +++ tests/test_core_render.py | 72 ++++++++ 17 files changed, 445 insertions(+), 105 deletions(-) create mode 100644 tests/test_core_render.py diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index ed878baa7..e8c668c65 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1255,23 +1255,21 @@ def expand_mask_plane( Split from `contrast_mask_ev` so a caller can cache this across slider values: only the scalar depends on the gamma. The GPU shader mirrors the bilinear mapping. """ + return _expanded(plane, out_shape, roi, 1.0) + + +def _expanded( + plane: Optional[np.ndarray], out_shape: Tuple[int, int], roi: Optional[Tuple[int, int, int, int]], scale: float +) -> Optional[np.ndarray]: if plane is None: return None h, w = out_shape - if roi is None: - if plane.shape[:2] != (h, w): - plane = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (w, h)) - return np.ascontiguousarray(plane, dtype=np.float32) - - y1, y2, x1, x2 = roi + y1, y2, x1, x2 = (0, h, 0, w) if roi is None else roi y1, x1 = max(0, y1), max(0, x1) y2, x2 = min(h, y2), min(w, x2) if y2 - y1 < 1 or x2 - x1 < 1: return None - inner = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (x2 - x1, y2 - y1)) - if (y1, x1, y2, x2) == (0, 0, h, w): - return np.ascontiguousarray(inner, dtype=np.float32) - return np.ascontiguousarray(np.pad(inner, ((y1, h - y2), (x1, w - x2)), mode="edge"), dtype=np.float32) + return negpy_core.expand_plane(np.ascontiguousarray(plane, dtype=np.float32), (h, w), (y1, y2, x1, x2), scale) def contrast_mask_scale(gamma: float, density_range: float) -> float: @@ -1297,10 +1295,7 @@ def contrast_mask_ev( """The contrast mask as print-exposure stops, ready to add to the dodge/burn map.""" if plane is None or abs(gamma) < 1e-6: return None - expanded = expand_mask_plane(plane, out_shape, roi) - if expanded is None: - return None - return (expanded * contrast_mask_scale(gamma, density_range)).astype(np.float32) + return _expanded(plane, out_shape, roi, contrast_mask_scale(gamma, density_range)) def cmy_to_density(val: float, log_range: float = 1.0) -> float: diff --git a/negpy/features/exposure/processor.py b/negpy/features/exposure/processor.py index f0539c692..c1383d13c 100644 --- a/negpy/features/exposure/processor.py +++ b/negpy/features/exposure/processor.py @@ -1,5 +1,6 @@ from typing import Any, Dict, Optional +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -62,10 +63,9 @@ def __init__(self, config: ProcessConfig): self.config = config def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: - epsilon = 1e-6 # No upper clamp, mirroring normalization.wgsl, which clamps only the low side. Values # above 1.0 occur only with flat-field gain and must match the GPU. - img_log = np.log10(np.clip(np.nan_to_num(image, nan=epsilon, posinf=1.0, neginf=epsilon), epsilon, None)) + img_log = negpy_core.log_density_open(np.ascontiguousarray(image, dtype=np.float32)) # Shared prefilter, once for all five meters, with the ROI and buffer applied here. A # freehand analysis_rect overrides the crop ROI and centered buffer for the metered region. an_roi, an_buffer = resolve_analysis_region( @@ -303,11 +303,12 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: if context.process_mode == ProcessMode.BW: # Panchromatic response: collapse to a single density BEFORE the curve, so the curve # shapes one channel instead of mixing three. - lum = get_luminance(image) - image = np.stack([lum, lum, lum], axis=-1) + image = negpy_core.gray_to_rgb(get_luminance(image)) local_maps = self._build_local_maps(image, context) - ev_map = None if local_maps is None else np.ascontiguousarray(local_maps[:, :, 0]) + if local_maps is not None: + local_maps = np.ascontiguousarray(local_maps, dtype=np.float32) + ev_map = None if local_maps is None else negpy_core.extract_channel(local_maps, 0) # The mask is a print-exposure input like a dodge, so it rides the same map. mask_ev = contrast_mask_ev( context.metrics.get("contrast_mask_plane"), @@ -317,13 +318,13 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: context.metrics.get("contrast_mask_roi"), ) if mask_ev is not None: - ev_map = mask_ev if ev_map is None else np.ascontiguousarray(ev_map + mask_ev) + ev_map = mask_ev if ev_map is None else negpy_core.add(ev_map, mask_ev) grade_map = None - if local_maps is not None and local_maps[:, :, 1].any(): - grade_map = local_grade_factor_map(np.ascontiguousarray(local_maps[:, :, 1]), self.config.grade) + if local_maps is not None and negpy_core.channel_any(local_maps, 1): + grade_map = local_grade_factor_map(negpy_core.extract_channel(local_maps, 1), self.config.grade) flash_map = None - if local_maps is not None and local_maps[:, :, 2].any(): - flash_map = np.ascontiguousarray(local_maps[:, :, 2]) + if local_maps is not None and negpy_core.channel_any(local_maps, 2): + flash_map = negpy_core.extract_channel(local_maps, 2) diff_kw: Dict[str, Any] = {} diff_plane = context.metrics.get("diffusion_plane") if self.config.diffusion > 0.0 and diff_plane is not None: @@ -405,9 +406,7 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: ) if context.process_mode == ProcessMode.BW: - res = get_luminance(img_pos) - res = np.stack([res, res, res], axis=-1) - return res + return negpy_core.gray_to_rgb(get_luminance(img_pos)) return img_pos @@ -432,14 +431,12 @@ def _process_flat(self, image: ImageBuffer, context: PipelineContext) -> ImageBu is_bw = context.process_mode == ProcessMode.BW if is_bw: - lum = get_luminance(image) - image = np.stack([lum, lum, lum], axis=-1) + image = negpy_core.gray_to_rgb(get_luminance(image)) stock_matrix = None if is_bw else resolve_flat_matrix(effective_paper_profile(self.config.paper_profile, context.process_mode)) img_pos = apply_flat_curve(image, gain, lift, cmy_offsets=cmy_offsets, stock_matrix=stock_matrix) if is_bw: - res = get_luminance(img_pos) - return np.stack([res, res, res], axis=-1) + return negpy_core.gray_to_rgb(get_luminance(img_pos)) return img_pos diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index cda0c8f49..8ea9a6504 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -176,13 +176,14 @@ def _saturation_block(img: ImageBuffer, saturation: float, skin_protection: floa if saturation == 1.0 and skin_protection <= 0.0: return img - lab = rgb_to_lab_working(img.astype(np.float32)) + lab = rgb_to_lab_working(np.ascontiguousarray(img, dtype=np.float32)) if saturation != 1.0: lab = gamut_aware_chroma_scale(lab, saturation) if skin_protection > 0.0: lab = skin_chroma_rein(lab, skin_protection) - res_rgb = lab_to_rgb_working(lab) - return ensure_image(np.clip(res_rgb, 0.0, 1.0)) + res_rgb = np.ascontiguousarray(lab_to_rgb_working(lab), dtype=np.float32) + negpy_core.clip_unit(res_rgb) + return ensure_image(res_rgb) # Below this blur sigma (px) both engines skip the stage. diff --git a/negpy/features/lab/processor.py b/negpy/features/lab/processor.py index 7e2247dd3..fdbd268a2 100644 --- a/negpy/features/lab/processor.py +++ b/negpy/features/lab/processor.py @@ -1,3 +1,4 @@ +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -40,4 +41,4 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: if self.config.glow_amount > 0 or self.config.halation_strength > 0: img = apply_glow_and_halation(img, self.config.glow_amount, self.config.halation_strength, context.scale_factor) - return np.clip(img, 0, 1) + return negpy_core.clip01(np.ascontiguousarray(img, dtype=np.float32)) diff --git a/negpy/features/process/capture_color.py b/negpy/features/process/capture_color.py index 9f6ea4c19..d49816fbd 100644 --- a/negpy/features/process/capture_color.py +++ b/negpy/features/process/capture_color.py @@ -107,8 +107,7 @@ def apply_camera_matrix(img: np.ndarray, matrix: Optional[np.ndarray]) -> np.nda """ if matrix is None: return img - flat = img.reshape(-1, 3).astype(np.float32, copy=False) - return (flat @ np.asarray(matrix, dtype=np.float32).T).reshape(img.shape) + return negpy_core.matmul_3x3(np.ascontiguousarray(img, dtype=np.float32), np.asarray(matrix, dtype=np.float32)) # Percentile of the whole frame taken as the light, and where it lands in the working diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index 02c9623a1..6354419ee 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -1,6 +1,7 @@ """The slide renders: a Slide as captured and a Positive frame. The engine routes here by process.path.render_path.""" +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -66,9 +67,9 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # continuous-spectrum scene, and there is no such scene to describe (see # should_fold_camera_wb). matrix = camera_to_working_matrix(context.cam_xyz, context.camera_wb if should_fold_camera_wb(self.config) else None) - linear = apply_camera_matrix(np.nan_to_num(image, nan=epsilon, posinf=1.0, neginf=epsilon), matrix) - - img_log = np.log10(np.clip(linear, epsilon, None)) + image = np.ascontiguousarray(image, dtype=np.float32) + linear = image if matrix is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), matrix) + img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 4a7bde085..10de509a1 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -2,7 +2,6 @@ import os import ctypes import threading -import cv2 import rawpy import imagecodecs import negpy_core @@ -289,7 +288,7 @@ def _downsample_to_long_edge(buf: np.ndarray, long_px: int) -> np.ndarray: if long_px <= 0 or long_edge <= long_px: return buf s = long_px / long_edge - return cv2.resize(buf, (max(1, int(round(w * s))), max(1, int(round(h * s)))), interpolation=cv2.INTER_AREA) + return negpy_core.resize_area(np.ascontiguousarray(buf, dtype=np.float32), (max(1, int(round(w * s))), max(1, int(round(h * s))))) def _detection_downsample(buf: np.ndarray) -> np.ndarray: @@ -1901,7 +1900,7 @@ def render_display_array( f32_buffer = _downsample_to_long_edge(f32_buffer, target_long_px) if ir_full is not None and ir_full.shape[:2] != f32_buffer.shape[:2]: th, tw = f32_buffer.shape[:2] - ir_full = cv2.resize(ir_full, (tw, th), interpolation=cv2.INTER_AREA) + ir_full = negpy_core.resize_area(np.ascontiguousarray(ir_full, dtype=np.float32), (tw, th)) # Keep the decode only for the other half of the same scan, rendered next; else it pins memory across the next decode. if not keep_source: self._source_cache_key = None diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index 52b2bde4b..e0c245664 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -3,7 +3,6 @@ from collections.abc import Callable from typing import Any, Optional, Sequence, Tuple -import cv2 import negpy_core import numpy as np @@ -271,7 +270,7 @@ def _try_splash_from_open_raw( if max(h, w) > APP_CONFIG.preview_render_size: scale = APP_CONFIG.preview_render_size / max(h, w) tw, th = int(w * scale), int(h * scale) - arr = ensure_image(cv2.resize(arr, (tw, th), interpolation=cv2.INTER_AREA).astype(np.float32)) + arr = ensure_image(negpy_core.resize_area(np.ascontiguousarray(arr, dtype=np.float32), (tw, th))) logger.debug("preview _try_splash_from_open_raw ok %.3fs for %s", time.perf_counter() - t0, file_path) return ensure_image(arr), full_dims diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 267998014..d92050414 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -13,7 +13,7 @@ tolerance listed for each entry. |---|---|---| | `features/exposure/normalization.py::_base_blocks` | Blocks of equal log brightness come out in block order, not in NumPy's SIMD argsort order. log10 is libm's, not SVML's, so a block on a group gap can change group in rare cases. | rows in any order | | `negpy_core.distance_transform` (DIST_L2, mask 3), once a port calls it | opencv-python runs IPP's float32 chamfer, whose 3x3 pass picks a path one float32 unit longer than the two-pass order at rare pixels; OpenCV without IPP (Apple Silicon wheels) returns its own fixed-point values. | ulp=1 | -| `features/exposure/normalization.py::to_log_density` (through `prefilter_log_grid`) | log10 is correctly rounded (`mathf::log10f`); NumPy's float32 log10 (SVML) is up to 3 ULP off on about half the inputs. | ulp=3 | +| `features/exposure/normalization.py::to_log_density` (through `prefilter_log_grid`), the normalization processors' log density (`negpy_core.log_density_open`, through `NormalizationProcessor.process` at abs=2e-6 on the normalized image, values up to 5.5) | log10 is correctly rounded (`mathf::log10f`); NumPy's float32 log10 (SVML) is up to 3 ULP off on about half the inputs. | ulp=3 | | `kernel/image/logic.py::_rgb_to_lab_kernel` (and `rgb_to_lab_working`) | The cube root is `mathf::pow_f64`, not glibc's correctly rounded pow; a* and b* near zero take the float64 difference in a few ULP. | abs=1e-6 | | `kernel/image/logic.py::_lab_to_rgb_kernel` (and `lab_to_rgb_working`) | One value in the corpus rounds the other way; the float64 operation order copies Numba's fastmath build. | ulp=1 | | `kernel/image/logic.py::_skin_chroma_rein_kernel` (and `skin_chroma_rein`) | `atan2f` and `exp` are `mathf`'s, not glibc's; Numba's own serial and parallel builds of the kernel already differ in the last bit. | ulp=1 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 479fdd320..288a54d62 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1361,6 +1361,12 @@ status = "ported" calls = ["negpy_core.rasterise_mask"] depends = [] +["features/process/capture_color.py::apply_camera_matrix"] +phase = 4 +status = "todo" +calls = ["negpy_core.matmul_3x3"] +depends = [] + ["features/process/capture_color.py::lightbox_level"] phase = 4 status = "ported" @@ -1945,15 +1951,17 @@ depends = [] ["features/cyanotype/processor.py::CyanotypeProcessor.process"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration" ["features/exposure/densitometer.py::compute_reading"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "one pixel" ["features/exposure/logic.py::_apply_print_curve_kernel"] phase = 6 @@ -1967,6 +1975,13 @@ status = "ported" calls = ["numba"] depends = [] +["features/exposure/logic.py::_expanded"] +phase = 6 +status = "ported" +calls = ["negpy_core.expand_plane"] +depends = [] +note = "negpy_core.expand_plane" + ["features/exposure/logic.py::_softplus"] phase = 6 status = "ported" @@ -1982,15 +1997,17 @@ depends = [] ["features/exposure/logic.py::contrast_mask_ev"] phase = 6 -status = "todo" +status = "ported" calls = [] -depends = ["expand_mask_plane"] +depends = ["_expanded"] +note = "negpy_core.expand_plane" ["features/exposure/logic.py::expand_mask_plane"] phase = 6 -status = "todo" -calls = ["negpy_core.resize_linear"] -depends = [] +status = "ported" +calls = [] +depends = ["_expanded"] +note = "negpy_core.expand_plane" ["features/exposure/logic.py::plane_taps"] phase = 6 @@ -2000,9 +2017,10 @@ depends = [] ["features/exposure/logic.py::print_curve_output"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "chart samples" ["features/exposure/logic.py::quadratic_core"] phase = 6 @@ -2018,87 +2036,101 @@ depends = [] ["features/exposure/placement.py::_pick_knee"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_solve_density"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_solve_density.residual"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_solve_grade_and_density"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_solve_density", "predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_solve_grade_and_density.light_residual"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_solve_density", "predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_solve_knee_field"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_solve_knee_field.residual"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_solve_with_knee"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_pick_knee", "_solve_grade_and_density", "_solve_knee_field", "predicted_zone"] +note = "scalar solver" ["features/exposure/placement.py::_zones_of"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "scalar solver" ["features/exposure/placement.py::key_edges"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_zones_of"] +note = "scalar solver" ["features/exposure/placement.py::limited_mask_params"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["key_edges"] +note = "scalar solver" ["features/exposure/placement.py::predicted_zone"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_zones_of"] +note = "scalar solver" ["features/exposure/placement.py::solve_placement"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_solve_density", "_solve_grade_and_density", "_solve_with_knee", "predicted_zone"] +note = "scalar solver" ["features/exposure/print_lut.py::lut_black_luminance"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["tetra_lookup"] +note = "one color" ["features/exposure/print_lut.py::tetra_lookup"] phase = 6 @@ -2108,33 +2140,39 @@ depends = [] ["features/exposure/processor.py::NormalizationProcessor.process"] phase = 6 -status = "todo" -calls = [] +status = "ported" +compare = "abs=2e-6" +calls = ["negpy_core.log_density_open"] depends = [] +note = "negpy_core.log_density_open" ["features/exposure/processor.py::NormalizationProcessor.process.analyze_base"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration" ["features/exposure/processor.py::PhotometricProcessor._build_local_maps"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "calls the Rust local maps" ["features/exposure/processor.py::PhotometricProcessor._process_flat"] phase = 6 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.gray_to_rgb"] depends = [] +note = "negpy_core.gray_to_rgb" ["features/exposure/processor.py::PhotometricProcessor.process"] phase = 6 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.add", "negpy_core.channel_any", "negpy_core.extract_channel", "negpy_core.gray_to_rgb"] depends = [] +note = "negpy_core gray_to_rgb, extract_channel, add, channel_any" ["features/finish/logic.py::_carrier_block"] phase = 6 @@ -2145,9 +2183,10 @@ depends = ["carrier_profiles"] ["features/finish/logic.py::_filed_edge"] phase = 6 -status = "todo" +status = "stays" calls = ["np.cumsum"] depends = [] +note = "one cached 1-D profile set" ["features/finish/logic.py::apply_carrier"] phase = 6 @@ -2158,27 +2197,31 @@ depends = ["carrier_profiles"] ["features/finish/logic.py::carrier_profiles"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_filed_edge"] +note = "one cached 1-D profile set" ["features/finish/processor.py::FinishProcessor.process"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration" ["features/finish/processor.py::rebate_tone"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "one 1-D strip" ["features/lab/logic.py::_saturation_block"] phase = 6 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.clip_unit"] depends = [] +note = "negpy_core Lab kernels, clip_unit" ["features/lab/logic.py::_usm_sharpening_block"] phase = 6 @@ -2206,15 +2249,17 @@ depends = [] ["features/lab/logic.py::apply_resize_sharpening"] phase = 6 -status = "todo" +status = "ported" calls = [] depends = ["_usm_sharpening_block"] +note = "negpy_core decode, usm, encode" ["features/lab/logic.py::apply_resize_sharpening.block"] phase = 6 -status = "todo" +status = "ported" calls = [] depends = ["_usm_sharpening_block"] +note = "negpy_core decode, usm, encode" ["features/lab/logic.py::apply_rl_sharpening"] phase = 6 @@ -2225,9 +2270,10 @@ depends = [] ["features/lab/logic.py::apply_saturation"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = ["_saturation_block"] +note = "row-block orchestration" ["features/lab/logic.py::apply_usm_sharpening"] phase = 6 @@ -2237,9 +2283,10 @@ depends = ["_usm_sharpening_block"] ["features/lab/processor.py::PhotoLabProcessor.process"] phase = 6 -status = "todo" -calls = [] +status = "ported" +calls = ["negpy_core.clip01"] depends = [] +note = "negpy_core.clip01" ["features/lith/logic.py::apply_lith"] phase = 6 @@ -2250,9 +2297,10 @@ depends = [] ["features/lith/processor.py::LithProcessor.process"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration" ["features/sabattier/logic.py::apply_sabattier"] phase = 6 @@ -2263,9 +2311,10 @@ depends = [] ["features/sabattier/processor.py::SabattierProcessor.process"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration" ["features/toning/logic.py::_apply_chemical_toning_jit"] phase = 6 @@ -2287,15 +2336,18 @@ depends = [] ["features/toning/processor.py::ToningProcessor.process"] phase = 6 -status = "todo" +status = "stays" calls = [] depends = [] +note = "orchestration" ["features/transparency/processor.py::TransparencyBaseProcessor.process"] phase = 6 -status = "todo" -calls = [] +status = "ported" +compare = "abs=2e-6" +calls = ["negpy_core.log_density_open", "negpy_core.nan_to_num"] depends = [] +note = "negpy_core nan_to_num, matmul_3x3, log_density_open" ["services/rendering/gpu_engine.py::GPUEngine._process_tiled"] phase = 6 @@ -2305,63 +2357,73 @@ depends = ["_downsample_for_analysis"] ["services/rendering/gpu_engine.py::_downsample_for_analysis"] phase = 6 -status = "todo" +status = "ported" calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["services/rendering/image_processor.py::ImageProcessor.render_display_array"] phase = 6 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +calls = ["negpy_core.resize_area"] depends = ["_downsample_to_long_edge"] +note = "negpy_core.resize_area" ["services/rendering/image_processor.py::_downsample_to_long_edge"] phase = 6 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["services/rendering/image_processor.py::_resize_mask"] phase = 6 -status = "todo" +status = "ported" calls = ["negpy_core.resize_nearest"] depends = [] +note = "negpy_core.resize_nearest" ["services/rendering/preview_manager.py::PreviewManager._load_from_open_raw"] phase = 6 -status = "todo" +status = "ported" calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["services/rendering/preview_manager.py::PreviewManager._try_splash_from_open_raw"] phase = 6 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr"] phase = 6 -status = "todo" +status = "ported" calls = ["negpy_core.resize_area"] depends = ["_load"] +note = "negpy_core.resize_area" ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr._load"] phase = 6 -status = "todo" +status = "ported" calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb"] phase = 6 -status = "todo" +status = "ported" calls = ["negpy_core.resize_area"] depends = ["_match"] +note = "negpy_core.resize_area" ["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb._match"] phase = 6 -status = "todo" +status = "ported" calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["desktop/controller.py::AppController._paint_flatfield_peek"] phase = 7 diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 0214b7019..4aabec06c 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -91,6 +91,27 @@ fn log_density_block(src: &[f32], dst: &mut [f32]) { } } +/// The normalization processors' log density: log10 of the value clamped below at 1e-6, with +/// NaN and -inf at 1e-6 and +inf at 1 (`np.nan_to_num`), no upper clamp. +pub fn log_density_open(img: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(1 << 14) + .zip(img.par_chunks(1 << 14)) + .for_each(|(d, s)| log_density_open_block(s, d)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn log_density_open_block(src: &[f32], dst: &mut [f32]) { + for (d, &v) in dst.iter_mut().zip(src) { + let v = if v == f32::INFINITY { 1.0 } else { v }; + // max drops NaN, as nan_to_num then np.clip do. + *d = crate::mathf::log10f(v.max(1e-6)); + } +} + /// `normalize_log_image`: `(v - floor) / (ceil - floor)` per channel of an HxWx3 log image, /// unclamped. A span under 1e-6 divides by +-1e-6, by the span's sign. pub fn normalize_log_image(img: &[f32], floors: [f32; 3], ceils: [f32; 3], dst: &mut [f32]) { diff --git a/negpy_core/src/convert.rs b/negpy_core/src/convert.rs index 333aacf35..780043b43 100644 --- a/negpy_core/src/convert.rs +++ b/negpy_core/src/convert.rs @@ -164,6 +164,48 @@ pub fn extract_channel(src: &[f32], cn: usize, c: usize, dst: &mut [f32]) { }); } +/// `np.stack([lum, lum, lum], axis=-1)`. +pub fn gray_to_rgb(src: &[f32], dst: &mut [f32]) { + crate::par_zip(dst.as_chunks_mut::<3>().0, src, |d, &v| *d = [v; 3]); +} + +/// `a + b` of two float32 arrays of one shape. +pub fn add(a: &[f32], b: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(a.par_chunks(BLOCK).zip(b.par_chunks(BLOCK))) + .for_each(|(o, (a, b))| { + for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { + *o = a + b; + } + }); +} + +/// `img[..., c].any()`: whether channel `c` holds a nonzero (NaN counts, as in NumPy). +pub fn channel_any(src: &[f32], cn: usize, c: usize) -> bool { + src.par_chunks(cn * BLOCK) + .any(|s| s.chunks_exact(cn).any(|p| p[c] != 0.0)) +} + +/// `np.clip(src, 0, 1)` into `dst`; NaN stays. +pub fn clip_unit(src: &[f32], dst: &mut [f32]) { + crate::par_zip(dst, src, |d, &v| *d = v.clamp(0.0, 1.0)); +} + +/// `np.nan_to_num(src, nan=nan, posinf=posinf, neginf=neginf)` into `dst`. +pub fn nan_to_num(src: &[f32], dst: &mut [f32], (nan, posinf, neginf): (f32, f32, f32)) { + crate::par_zip(dst, src, move |d, &v| { + *d = if v.is_nan() { + nan + } else if v == f32::INFINITY { + posinf + } else if v == f32::NEG_INFINITY { + neginf + } else { + v + }; + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 5f6efeb0f..3a27ddcd5 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -752,9 +752,29 @@ fn diffusion_tail<'py>( }) } +/// The normalization processors' open-ended log density of a float32 image, any shape. +#[pyfunction] +fn log_density_open<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = img.shape().to_vec(); + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || analysis::log_density_open(data, dst)); + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(luminance, m)?)?; m.add_function(wrap_pyfunction!(to_log_density, m)?)?; + m.add_function(wrap_pyfunction!(log_density_open, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; m.add_function(wrap_pyfunction!(base_blocks, m)?)?; diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs index fc566ab0e..6a1b0b8f4 100644 --- a/negpy_core/src/py/convert.rs +++ b/negpy_core/src/py/convert.rs @@ -271,7 +271,78 @@ fn extract_channel<'py>( }) } +/// `np.stack([lum, lum, lum], axis=-1)` of a float32 plane. +#[pyfunction] +fn gray_to_rgb<'py>( + py: Python<'py>, + lum: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let mut shape = lum.shape().to_vec(); + shape.push(3); + let src = contiguous(&lum)?; + filled(py, &shape, "float32", |d| convert::gray_to_rgb(src, d)) +} + +/// `a + b` of two float32 arrays of one shape. +#[pyfunction] +fn add<'py>( + py: Python<'py>, + a: PyReadonlyArrayDyn<'py, f32>, + b: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + if a.shape() != b.shape() { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let (sa, sb) = (contiguous(&a)?, contiguous(&b)?); + filled(py, a.shape(), "float32", |d| convert::add(sa, sb, d)) +} + +/// `img[..., c].any()` of an HxWxC float32 image. +#[pyfunction] +fn channel_any(py: Python<'_>, img: PyReadonlyArrayDyn<'_, f32>, c: usize) -> PyResult { + let [_, _, cn] = img.shape()[..] else { + return Err(PyValueError::new_err("channel_any needs an HxWxC array")); + }; + if c >= cn { + return Err(PyValueError::new_err("channel out of range")); + } + let src = contiguous(&img)?; + Ok(super::detach(py, src.len(), || { + convert::channel_any(src, cn, c) + })) +} + +/// `np.clip(img, 0, 1)` of a float32 array, as a new array. +#[pyfunction] +fn clip01<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| convert::clip_unit(src, d)) +} + +/// `np.nan_to_num(img, nan=nan, posinf=posinf, neginf=neginf)` of a float32 array. +#[pyfunction] +fn nan_to_num<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + nan: f32, + posinf: f32, + neginf: f32, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| { + convert::nan_to_num(src, d, (nan, posinf, neginf)) + }) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(nan_to_num, m)?)?; + m.add_function(wrap_pyfunction!(gray_to_rgb, m)?)?; + m.add_function(wrap_pyfunction!(add, m)?)?; + m.add_function(wrap_pyfunction!(channel_any, m)?)?; + m.add_function(wrap_pyfunction!(clip01, m)?)?; m.add_function(wrap_pyfunction!(mean_channels, m)?)?; m.add_function(wrap_pyfunction!(pick_channels, m)?)?; m.add_function(wrap_pyfunction!(extract_channel, m)?)?; diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index 03b741f81..f7a104c2e 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -125,7 +125,41 @@ fn resize_nearest<'py>( Ok(out) } +/// `expand_mask_plane(plane, (h, w), roi)` times `scale` for a float32 plane and a clamped, +/// non-empty roi (y1, y2, x1, x2). +#[pyfunction] +fn expand_plane<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + out_shape: (usize, usize), + roi: (usize, usize, usize, usize), + scale: f32, +) -> PyResult>> { + let [ph, pw] = plane.shape()[..] else { + return Err(PyValueError::new_err("expand_plane needs a 2-D plane")); + }; + let src = plane + .as_slice() + .map_err(|_| PyValueError::new_err("expand_plane needs a contiguous plane"))?; + let (h, w) = out_shape; + let out = PyArrayDyn::::zeros(py, vec![h, w], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + resize::expand_plane(src, (ph, pw), (h, w), roi, scale, dst) + }) + .ok_or_else(|| { + PyValueError::new_err("expand_plane needs a non-empty roi inside the output") + })?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(expand_plane, m)?)?; m.add_function(wrap_pyfunction!(resize_nearest, m)?)?; m.add_function(wrap_pyfunction!(resize_area, m)?)?; m.add_function(wrap_pyfunction!(resize_linear, m)?) diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs index a75e9d47b..713e2c39b 100644 --- a/negpy_core/src/resize.rs +++ b/negpy_core/src/resize.rs @@ -430,6 +430,32 @@ fn accumulate_any( } } +/// `expand_mask_plane` times `scale`: a ph x pw plane resized bilinearly onto rows y1..y2 and +/// columns x1..x2 of an oh x ow plane, the edges replicated outside, each value times `scale` +/// in float32. +pub fn expand_plane( + plane: &[f32], + (ph, pw): (usize, usize), + (oh, ow): (usize, usize), + (y1, y2, x1, x2): (usize, usize, usize, usize), + scale: f32, + dst: &mut [f32], +) -> Option<()> { + let (ih, iw) = (y2.checked_sub(y1)?, x2.checked_sub(x1)?); + if ih == 0 || iw == 0 || y2 > oh || x2 > ow || dst.len() != oh * ow { + return None; + } + let mut inner = vec![0f32; ih * iw]; + resize_linear(plane, (ph, pw, 1), (ih, iw), false, &mut inner)?; + dst.par_chunks_mut(ow).enumerate().for_each(|(y, row)| { + let src = &inner[(y.clamp(y1, y2 - 1) - y1) * iw..][..iw]; + for (x, d) in row.iter_mut().enumerate() { + *d = src[x.clamp(x1, x2 - 1) - x1] * scale; + } + }); + Some(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/test_core_render.py b/tests/test_core_render.py new file mode 100644 index 000000000..d178cb2d6 --- /dev/null +++ b/tests/test_core_render.py @@ -0,0 +1,72 @@ +"""The render-stage helpers against the NumPy code they replace.""" + +import numpy as np +import pytest + +import negpy_core +from negpy.features.exposure.logic import contrast_mask_ev, contrast_mask_scale, expand_mask_plane +from negpy.features.process.capture_color import apply_camera_matrix + + +def _img(seed, shape=(97, 131, 3)): + return np.random.default_rng(seed).uniform(-0.2, 1.4, shape).astype(np.float32) + + +def test_log_density_open_matches_numpy_within_the_ledger(): + img = _img(1) + img[0, 0] = [np.nan, np.inf, -np.inf] + want = np.log10(np.clip(np.nan_to_num(img, nan=1e-6, posinf=1.0, neginf=1e-6), 1e-6, None)) + np.testing.assert_allclose(negpy_core.log_density_open(img), want, rtol=4e-7, atol=0) + + +def test_small_helpers_match_numpy(): + img = _img(2) + lum = img[..., 1].copy() + np.testing.assert_array_equal(negpy_core.gray_to_rgb(lum), np.stack([lum, lum, lum], axis=-1)) + np.testing.assert_array_equal(negpy_core.add(lum, img[..., 0].copy()), lum + img[..., 0]) + np.testing.assert_array_equal(negpy_core.clip01(img), np.clip(img, 0, 1)) + zeros = np.zeros((5, 6, 3), np.float32) + assert not negpy_core.channel_any(zeros, 1) + zeros[4, 5, 1] = np.nan + assert negpy_core.channel_any(zeros, 1) == bool(zeros[..., 1].any()) + nan = _img(3) + nan[1, 2] = [np.nan, np.inf, -np.inf] + np.testing.assert_array_equal(negpy_core.nan_to_num(nan, 1e-6, 1.0, 1e-6), np.nan_to_num(nan, nan=1e-6, posinf=1.0, neginf=1e-6)) + + +def test_camera_matrix_matches_numpy(): + img = _img(4) + m = np.random.default_rng(5).uniform(-0.4, 1.3, (3, 3)) + want = (img.reshape(-1, 3).astype(np.float32) @ np.asarray(m, dtype=np.float32).T).reshape(img.shape) + np.testing.assert_array_equal(apply_camera_matrix(img, m), want) + + +def _expand_numpy(plane, out_shape, roi): + h, w = out_shape + if roi is None: + if plane.shape[:2] != (h, w): + plane = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (w, h)) + return np.ascontiguousarray(plane, dtype=np.float32) + y1, y2, x1, x2 = roi + y1, x1 = max(0, y1), max(0, x1) + y2, x2 = min(h, y2), min(w, x2) + if y2 - y1 < 1 or x2 - x1 < 1: + return None + inner = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (x2 - x1, y2 - y1)) + if (y1, x1, y2, x2) == (0, 0, h, w): + return inner + return np.pad(inner, ((y1, h - y2), (x1, w - x2)), mode="edge").astype(np.float32) + + +@pytest.mark.parametrize("roi", [None, (0, 300, 0, 400), (20, 250, 35, 390), (-5, 320, 10, 500), (100, 100, 0, 10)]) +@pytest.mark.parametrize("plane_shape", [(300, 400), (61, 87)]) +def test_expand_mask_plane_matches_numpy(roi, plane_shape): + plane = _img(6, plane_shape) + want = _expand_numpy(plane, (300, 400), roi) + got = expand_mask_plane(plane, (300, 400), roi) + if want is None: + assert got is None + return + np.testing.assert_array_equal(got, want) + scale = contrast_mask_scale(0.6, 1.8) + np.testing.assert_array_equal(contrast_mask_ev(plane, 0.6, 1.8, (300, 400), roi), (want * scale).astype(np.float32)) From c0e6dcb3b68760bb77a3a7ffdc9c465257926a4f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 19:36:25 +0200 Subject: [PATCH 117/133] perf(core): Lanczos4 and cubic resize in Rust for export layout negpy_core.resize_kernel copies OpenCV's INTER_LANCZOS4 and INTER_CUBIC taps and weights, in output-row bands that filter their own source rows, with AVX2/AVX-512 passes. PrintService.apply_layout and GPUEngine._process_tiled use it (and resize_area on a shrink); print.py and gpu_engine.py drop cv2. Within 4e-7 of cv2 for Lanczos4 (ledger, quality gate). Side by side with cv2: 6 MP x1.5 42 -> 29 ms, 24 MP x1.25 138 -> 121 ms. The contact sheet's uint8 cubic tile upscale stays on cv2 for now: a float path is 2 to 2.6x slower than cv2's 16-bit fixed point. --- negpy/services/export/print.py | 10 +- negpy/services/rendering/gpu_engine.py | 15 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 12 +- negpy_core/src/py/resize.rs | 46 +++++ negpy_core/src/resize.rs | 225 +++++++++++++++++++++++++ tests/test_core_resize_kernel.py | 29 ++++ 7 files changed, 318 insertions(+), 20 deletions(-) create mode 100644 tests/test_core_resize_kernel.py diff --git a/negpy/services/export/print.py b/negpy/services/export/print.py index e51f3bc8b..106b88103 100644 --- a/negpy/services/export/print.py +++ b/negpy/services/export/print.py @@ -1,5 +1,5 @@ from PIL import Image -import cv2 +import negpy_core import numpy as np from typing import Optional, Tuple from negpy.domain.models import ExportConfig, AspectRatio, ExportResolutionMode @@ -253,11 +253,9 @@ def apply_layout( # OpenCV gives INTER_LANCZOS4 no prefilter on a shrink, so it degrades to bilinear and # aliases. INTER_AREA is the only area-correct downscale here. shrinking = target_w < img_w or target_h < img_h - img_scaled = cv2.resize( - img, - (target_w, target_h), - interpolation=cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4, - ) + src = np.ascontiguousarray(img, dtype=np.float32) + size = (target_w, target_h) + img_scaled = negpy_core.resize_area(src, size) if shrinking else negpy_core.resize_kernel(src, size, lanczos=True) offset_x = (paper_w - target_w) // 2 offset_y = PrintService.weighted_offset_y(paper_h, target_h, border_px, border_bottom_px) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index a70c4745a..4c18b4fb0 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -5,7 +5,6 @@ import time from typing import Any, Dict, Optional, Tuple -import cv2 import negpy_core import numpy as np import wgpu # type: ignore @@ -3105,15 +3104,11 @@ def _meter_grid() -> np.ndarray: # Mirrors PrintService.apply_layout: only INTER_AREA is area-correct on a shrink. shrinking = content_w < crop_w or content_h < crop_h - scaled_content = ( - cv2.resize( - full_source_res, - (content_w, content_h), - interpolation=cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4, - ) - if (content_w != crop_w or content_h != crop_h) - else full_source_res - ) + scaled_content = full_source_res + if content_w != crop_w or content_h != crop_h: + src = np.ascontiguousarray(full_source_res, dtype=np.float32) + size = (content_w, content_h) + scaled_content = negpy_core.resize_area(src, size) if shrinking else negpy_core.resize_kernel(src, size, lanczos=True) # No border means the paper buffer is a full-res allocation, fill and copy that # reproduces the content exactly. if (paper_w, paper_h, off_x, off_y) == (content_w, content_h, 0, 0): diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index d92050414..2d1849aa0 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -38,3 +38,4 @@ tolerance listed for each entry. | `features/stitch/logic.py::seam_blend`, `feather_blend` (and `_seam_weight`, now inside `seam_blend`) | The distances come from `negpy_core.distance_transform`, not IPP's float32 chamfer (see its entry above); the error grows with the distance from the edge, and the weights clip it away beyond the blend band. Gate `tests/test_core_seam.py`: distances within 0.005 of cv2 on masks up to 1600 px, blends within 1e-4 of the cv2 version. Measured on the recorded panorama and synthetic overlaps: distances within 0.0042, `seam_blend` within 1.2e-7, `feather_blend` within 4.2e-7. On a 6000 px mask the distances differ by up to 0.085 far from the edge, and the blends still within 3.3e-7. | quality | | `features/exposure/normalization.py::contrast_mask_plane`, `diffusion_grid` | Both read `prefilter_log_grid`, whose log10 is `mathf::log10f` (the `to_log_density` row). Diffusion's 10^x and log10 are `mathf`'s, not SVML's. Given the same log grid, `contrast_mask_plane` matches bit for bit. | abs=1e-6 | | `features/rgbscan/logic.py::estimate_shift`, `infrastructure/scanners/sane_backend.py::_align_ir_to_rgb` (and the HDR and RGB Scan alignment through them) | `cv2.phaseCorrelate` is `negpy_core::phase::phase_correlate`, in float32 through rustfft and realfft, as OpenCV works. cv2 measures the shift from (n / 2.0, m / 2.0) after an fftShift that rolls by (n / 2, m / 2), so along an axis whose padded DFT size is odd it reads every shift half a pixel too far; negpy_core measures from the roll's origin. Gate `tests/test_core_phase.py`: within 0.2 px of the true shift on synthetic pairs (the 5x5 peak centroid's own bias), within 0.05 px of cv2 once its half pixel is taken off. The recorded HDR and RGB Scan merges match bit for bit. | quality | +| `services/export/print.py` (print layout) and `services/rendering/gpu_engine.py::GPUEngine._process_tiled`: the Lanczos4 upscale | `negpy_core.resize_kernel` copies OpenCV's taps and weights (`interpolateLanczos4` with libm's sin and cos) and sums each pass in float32 tap by tap; opencv-python's vector code orders the sums its own way. Gate `tests/test_core_resize_kernel.py`: within 1e-6 of cv2 (INTER_CUBIC within 1e-5). | quality | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 288a54d62..7a168f469 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2351,9 +2351,11 @@ note = "negpy_core nan_to_num, matmul_3x3, log_density_open" ["services/rendering/gpu_engine.py::GPUEngine._process_tiled"] phase = 6 -status = "todo" -calls = ["cv2.resize", "negpy_core.resize_linear"] +status = "ported" +compare = "quality" +calls = ["negpy_core.resize_area", "negpy_core.resize_kernel", "negpy_core.resize_linear"] depends = ["_downsample_for_analysis"] +note = "negpy_core resize_area, resize_kernel" ["services/rendering/gpu_engine.py::_downsample_for_analysis"] phase = 6 @@ -2637,9 +2639,11 @@ depends = [] ["services/export/print.py::PrintService.apply_layout"] phase = 7 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +compare = "quality" +calls = ["negpy_core.resize_area", "negpy_core.resize_kernel"] depends = [] +note = "negpy_core resize_area, resize_kernel" ["services/scanning/writer.py::_to_grey"] phase = 7 diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index f7a104c2e..28ef9f836 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -158,7 +158,53 @@ fn expand_plane<'py>( Ok(out) } +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_CUBIC)` (`lanczos=False`) or +/// `cv2.INTER_LANCZOS4` of a contiguous float32 image. +#[pyfunction] +#[pyo3(signature = (src, dsize, lanczos=false))] +fn resize_kernel<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dsize: (usize, usize), + lanczos: bool, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_kernel needs a 2-D or 3-D array", + )); + } + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_kernel needs a contiguous array"))?; + let (dw, dh) = dsize; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + let kernel = if lanczos { + resize::Kernel::Lanczos4 + } else { + resize::Kernel::Cubic + }; + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + resize::resize_kernel(data, (h, w, cn), (dh, dw), kernel, dst) + }) + .ok_or_else(|| PyValueError::new_err("resize_kernel needs a non-empty image and size"))?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(resize_kernel, m)?)?; m.add_function(wrap_pyfunction!(expand_plane, m)?)?; m.add_function(wrap_pyfunction!(resize_nearest, m)?)?; m.add_function(wrap_pyfunction!(resize_area, m)?)?; diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs index 713e2c39b..1736a95f9 100644 --- a/negpy_core/src/resize.rs +++ b/negpy_core/src/resize.rs @@ -430,6 +430,231 @@ fn accumulate_any( } } +/// The kernels of `cv2.INTER_CUBIC` and `cv2.INTER_LANCZOS4`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kernel { + Cubic, + Lanczos4, +} + +impl Kernel { + fn taps(self) -> usize { + match self { + Kernel::Cubic => 4, + Kernel::Lanczos4 => 8, + } + } + + /// OpenCV's `interpolateCubic` and `interpolateLanczos4` in their float arithmetic. + fn weights(self, x: f32) -> [f32; 8] { + let mut c = [0f32; 8]; + match self { + Kernel::Cubic => { + let a = -0.75f32; + c[0] = ((a * (x + 1.0) - 5.0 * a) * (x + 1.0) + 8.0 * a) * (x + 1.0) - 4.0 * a; + c[1] = ((a + 2.0) * x - (a + 3.0)) * x * x + 1.0; + c[2] = ((a + 2.0) * (1.0 - x) - (a + 3.0)) * (1.0 - x) * (1.0 - x) + 1.0; + c[3] = 1.0 - c[0] - c[1] - c[2]; + } + Kernel::Lanczos4 => { + let s45 = std::f64::consts::FRAC_1_SQRT_2; + let cs = [ + (1.0, 0.0), + (-s45, -s45), + (0.0, 1.0), + (s45, -s45), + (-1.0, 0.0), + (s45, s45), + (0.0, -1.0), + (-s45, s45), + ]; + let y0 = -(x as f64 + 3.0) * std::f64::consts::PI * 0.25; + let (s0, c0) = (libm::sin(y0), libm::cos(y0)); + let mut sum = 0f32; + for (i, (k, &(a, b))) in c.iter_mut().zip(&cs).enumerate() { + let t = x + 3.0 - i as f32; + *k = if t.abs() >= 1e-6 { + let y = -t as f64 * std::f64::consts::PI * 0.25; + ((a * s0 + b * c0) / (y * y)) as f32 + } else { + 1e30 + }; + sum += *k; + } + let inv = 1.0 / sum; + for k in &mut c { + *k *= inv; + } + } + } + c + } +} + +/// Per output position along one axis: the first source tap (before clamping) and its weights. +fn kernel_axis(src: usize, dst: usize, kernel: Kernel) -> Vec<(i64, [f32; 8])> { + let scale = 1.0 / (dst as f64 / src as f64); + let back = (kernel.taps() / 2 - 1) as i64; + (0..dst) + .map(|d| { + let f = ((d as f64 + 0.5) * scale - 0.5) as f32; + let s = f.floor(); + (s as i64 - back, kernel.weights(f - s)) + }) + .collect() +} + +/// One output row of the vertical pass: per element the float32 sum of its taps in order. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn column_pass(out: &mut [f32], rows: [&[f32]; N], wt: [f32; N]) { + for (i, d) in out.iter_mut().enumerate() { + let mut v = rows[0][i] * wt[0]; + for k in 1..N { + v += rows[k][i] * wt[k]; + } + *d = v; + } +} + +/// One row of the horizontal pass for `C` channels and `N` taps: each output the float32 sum of +/// its taps in order. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn row_pass( + s: &[f32], + taps: &[usize], + wts: &[f32], + out: &mut [f32], +) { + let px = s.as_chunks::().0; + let (taps, wts) = (taps.as_chunks::().0, wts.as_chunks::().0); + for ((o, t), wt) in out.as_chunks_mut::().0.iter_mut().zip(taps).zip(wts) { + let p = px[t[0]]; + let mut acc: [f32; C] = std::array::from_fn(|c| p[c] * wt[0]); + for k in 1..N { + let p = px[t[k]]; + for c in 0..C { + acc[c] += p[c] * wt[k]; + } + } + *o = acc; + } +} + +fn row_pass_any(s: &[f32], taps: &[usize], wts: &[f32], n: usize, cn: usize, out: &mut [f32]) { + for ((o, t), wt) in out + .chunks_exact_mut(cn) + .zip(taps.chunks_exact(n)) + .zip(wts.chunks_exact(n)) + { + o.fill(0.0); + for (&sx, &wk) in t.iter().zip(wt) { + for (d, &v) in o.iter_mut().zip(&s[sx * cn..][..cn]) { + *d += v * wk; + } + } + } +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_CUBIC or cv2.INTER_LANCZOS4)` of a float32 +/// image: OpenCV's taps and weights, edges replicated, each pass summed in float32 tap by tap. +pub fn resize_kernel( + src: &[f32], + dims: (usize, usize, usize), + dsize: (usize, usize), + kernel: Kernel, + dst: &mut [f32], +) -> Option<()> { + resize_kernel_as(src, dims, dsize, kernel, dst, |v| v, |v| v) +} + +/// The kernel resize, reading `S` and writing `D` through float32 inside one parallel pass. +fn resize_kernel_as( + src: &[S], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + kernel: Kernel, + dst: &mut [D], + load: impl Fn(S) -> f32 + Sync, + store: impl Fn(f32) -> D + Sync, +) -> Option<()> { + if h == 0 + || w == 0 + || dh == 0 + || dw == 0 + || cn == 0 + || src.len() != h * w * cn + || dst.len() != dh * dw * cn + { + return None; + } + let n = kernel.taps(); + let (xs, ys) = (kernel_axis(w, dw, kernel), kernel_axis(h, dh, kernel)); + let clamp = |i: i64, len: usize| i.clamp(0, len as i64 - 1) as usize; + // Source column of every tap, clamped once: replicated edges. + let taps: Vec = xs + .iter() + .flat_map(|&(x0, _)| (0..n).map(move |k| clamp(x0 + k as i64, w))) + .collect(); + let wts: Vec = xs + .iter() + .flat_map(|(_, wt)| wt[..n].iter().copied()) + .collect(); + let hrow = |y: usize, line: &mut Vec, out: &mut [f32]| { + line.clear(); + line.extend(src[y * w * cn..][..w * cn].iter().map(|&v| load(v))); + let s = line.as_slice(); + match (cn, n) { + (1, 4) => row_pass::<1, 4>(s, &taps, &wts, out), + (3, 4) => row_pass::<3, 4>(s, &taps, &wts, out), + (4, 4) => row_pass::<4, 4>(s, &taps, &wts, out), + (1, 8) => row_pass::<1, 8>(s, &taps, &wts, out), + (3, 8) => row_pass::<3, 8>(s, &taps, &wts, out), + (4, 8) => row_pass::<4, 8>(s, &taps, &wts, out), + _ => row_pass_any(s, &taps, &wts, n, cn, out), + } + }; + // Bands of output rows; each filters the source rows it reads into its own buffer, so a + // band's halo is filtered twice: bands as wide as the thread count allows. + let band = (dh / (4 * rayon::current_num_threads())).clamp(16, 128); + let row_len = dw * cn; + dst.par_chunks_mut(band * row_len) + .zip(ys.par_chunks(band)) + .for_each(|(band, ys)| { + let lo = clamp(ys[0].0, h); + let hi = clamp(ys[ys.len() - 1].0 + n as i64 - 1, h); + let mut rows = vec![0f32; (hi - lo + 1) * row_len]; + let mut line = Vec::with_capacity(w * cn); + for (i, r) in rows.chunks_mut(row_len).enumerate() { + hrow(lo + i, &mut line, r); + } + let mut out = vec![0f32; row_len]; + for (o, (y0, wt)) in band.chunks_mut(row_len).zip(ys) { + let r = |k: usize| &rows[(clamp(y0 + k as i64, h) - lo) * row_len..][..row_len]; + if n == 4 { + column_pass( + &mut out, + [r(0), r(1), r(2), r(3)], + [wt[0], wt[1], wt[2], wt[3]], + ); + } else { + column_pass(&mut out, std::array::from_fn::<_, 8, _>(r), *wt); + } + for (d, &v) in o.iter_mut().zip(&out) { + *d = store(v); + } + } + }); + Some(()) +} + /// `expand_mask_plane` times `scale`: a ph x pw plane resized bilinearly onto rows y1..y2 and /// columns x1..x2 of an oh x ow plane, the edges replicated outside, each value times `scale` /// in float32. diff --git a/tests/test_core_resize_kernel.py b/tests/test_core_resize_kernel.py new file mode 100644 index 000000000..489f38a36 --- /dev/null +++ b/tests/test_core_resize_kernel.py @@ -0,0 +1,29 @@ +"""Quality gate for negpy_core.resize_kernel against cv2's INTER_LANCZOS4 and INTER_CUBIC.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core + +pytestmark = cv2_oracle + +CASES = [ + ((120, 170, 3), (400, 290)), + ((300, 400, 3), (1003, 751)), + ((64, 80), (97, 61)), + ((200, 300, 4), (301, 201)), + ((50, 70, 3), (35, 25)), +] + + +@pytest.mark.parametrize("shape,dsize", CASES) +@pytest.mark.parametrize("lanczos,flag,tol", [(True, cv2.INTER_LANCZOS4, 1e-6), (False, cv2.INTER_CUBIC, 1e-5)]) +def test_resize_kernel_stays_near_cv2(shape, dsize, lanczos, flag, tol): + rng = np.random.default_rng(sum(shape)) + src = cv2.GaussianBlur(rng.uniform(0, 1, shape).astype(np.float32), (0, 0), 1.2) + want = cv2.resize(src, dsize, interpolation=flag) + got = negpy_core.resize_kernel(np.ascontiguousarray(src), dsize, lanczos=lanczos) + assert got.shape == want.shape + np.testing.assert_allclose(got, want, rtol=0, atol=tol) From 291e719efbb22902d81f2c2f005fe8587acade5b Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 20:22:33 +0200 Subject: [PATCH 118/133] perf(core): uint8 cubic and area resize in Rust for contact sheet tiles negpy_core.resize_u8 copies OpenCV's own uint8 cubic resize (11-bit fixed-point weights, integer rows, float32 column pass over 8-value blocks): bit for bit with cv2 when IPP is off; opencv-python's IPP differs by one level on a few per cent of values (ledger). The row pass sorts each 16-byte window by channel with pshufb and multiplies with pmaddwd; the column pass is AVX2 with saturating packs; scalar fallbacks keep the same integers on other CPUs. The area shrink matches cv2 bit for bit. contact_sheet.py drops cv2. 600x900 to 1100x733 on one pinned core: 1.9 ms against IPP's 2.15 ms. On all cores 0.6 ms against 0.38 ms: the job is short enough that rayon's wake-up latency shows, where cv2's pool spins. --- negpy/services/export/contact_sheet.py | 5 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 6 +- negpy_core/src/py/resize.rs | 41 ++++ negpy_core/src/resize.rs | 262 +++++++++++++++++++++++++ tests/test_core_resize_kernel.py | 29 +++ 6 files changed, 339 insertions(+), 5 deletions(-) diff --git a/negpy/services/export/contact_sheet.py b/negpy/services/export/contact_sheet.py index d30b27217..a1da53f09 100644 --- a/negpy/services/export/contact_sheet.py +++ b/negpy/services/export/contact_sheet.py @@ -4,7 +4,7 @@ from dataclasses import replace from typing import Optional, Sequence -import cv2 +import negpy_core import numpy as np from PIL import Image, ImageDraw, ImageFont @@ -54,8 +54,7 @@ def _place_tile(canvas: np.ndarray, tile: np.ndarray, box: tuple[int, int, int, cover = abs(math.log(tile_aspect / window_aspect)) <= math.log(1 + COVER_TOLERANCE) scale = max(ww / tw, wh / th) if cover else min(ww / tw, wh / th) rw, rh = max(1, int(round(tw * scale))), max(1, int(round(th * scale))) - interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC - resized = cv2.resize(np.ascontiguousarray(tile[..., :3]), (rw, rh), interpolation=interp) + resized = negpy_core.resize_u8(np.ascontiguousarray(tile[..., :3], dtype=np.uint8), (rw, rh), cubic=scale >= 1) if cover: cx, cy = (rw - ww) // 2, (rh - wh) // 2 resized = resized[max(0, cy) : max(0, cy) + wh, max(0, cx) : max(0, cx) + ww] diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 2d1849aa0..f36cf3530 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -39,3 +39,4 @@ tolerance listed for each entry. | `features/exposure/normalization.py::contrast_mask_plane`, `diffusion_grid` | Both read `prefilter_log_grid`, whose log10 is `mathf::log10f` (the `to_log_density` row). Diffusion's 10^x and log10 are `mathf`'s, not SVML's. Given the same log grid, `contrast_mask_plane` matches bit for bit. | abs=1e-6 | | `features/rgbscan/logic.py::estimate_shift`, `infrastructure/scanners/sane_backend.py::_align_ir_to_rgb` (and the HDR and RGB Scan alignment through them) | `cv2.phaseCorrelate` is `negpy_core::phase::phase_correlate`, in float32 through rustfft and realfft, as OpenCV works. cv2 measures the shift from (n / 2.0, m / 2.0) after an fftShift that rolls by (n / 2, m / 2), so along an axis whose padded DFT size is odd it reads every shift half a pixel too far; negpy_core measures from the roll's origin. Gate `tests/test_core_phase.py`: within 0.2 px of the true shift on synthetic pairs (the 5x5 peak centroid's own bias), within 0.05 px of cv2 once its half pixel is taken off. The recorded HDR and RGB Scan merges match bit for bit. | quality | | `services/export/print.py` (print layout) and `services/rendering/gpu_engine.py::GPUEngine._process_tiled`: the Lanczos4 upscale | `negpy_core.resize_kernel` copies OpenCV's taps and weights (`interpolateLanczos4` with libm's sin and cos) and sums each pass in float32 tap by tap; opencv-python's vector code orders the sums its own way. Gate `tests/test_core_resize_kernel.py`: within 1e-6 of cv2 (INTER_CUBIC within 1e-5). | quality | +| `services/export/contact_sheet.py::_place_tile`: the uint8 cubic tile upscale | `negpy_core.resize_u8(cubic=True)` copies OpenCV's own uint8 cubic resize (11-bit fixed-point weights, integer rows, the float32 column pass over 8-value blocks), bit for bit with cv2 when IPP is off. opencv-python on x86-64 runs IPP's resize, which differs by 1 level on a few per cent of values. The area shrink matches cv2 bit for bit. | ulp=1 (uint8 levels) | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 7a168f469..e156d5b82 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2627,9 +2627,11 @@ depends = [] ["services/export/contact_sheet.py::_place_tile"] phase = 7 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.resize_u8"] depends = [] +note = "negpy_core.resize_u8" ["services/export/linear_output.py::_apply_ice"] phase = 7 diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index 28ef9f836..1e75ed6ea 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -203,7 +203,48 @@ fn resize_kernel<'py>( Ok(out) } +/// `cv2.resize(src, dsize, interpolation=...)` of a contiguous uint8 image: `cubic` False is +/// INTER_AREA, True INTER_CUBIC as OpenCV computes it without IPP. +#[pyfunction] +#[pyo3(signature = (src, dsize, cubic=false))] +fn resize_u8<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + dsize: (usize, usize), + cubic: bool, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("resize_u8 needs a 2-D or 3-D array")), + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_u8 needs a contiguous array"))?; + let (dw, dh) = dsize; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + if cubic { + resize::resize_cubic_u8(data, (h, w, cn), (dh, dw), dst) + } else { + resize::resize_area_u8(data, (h, w, cn), (dh, dw), dst) + } + }) + .ok_or_else(|| PyValueError::new_err("resize_u8 needs a non-empty image and size"))?; + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(resize_u8, m)?)?; m.add_function(wrap_pyfunction!(resize_kernel, m)?)?; m.add_function(wrap_pyfunction!(expand_plane, m)?)?; m.add_function(wrap_pyfunction!(resize_nearest, m)?)?; diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs index 1736a95f9..27e945d91 100644 --- a/negpy_core/src/resize.rs +++ b/negpy_core/src/resize.rs @@ -655,6 +655,268 @@ fn resize_kernel_as( Some(()) } +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_AREA)` of a uint8 image: OpenCV's area +/// weights in float32, rounded half to even and saturated to uint8. +pub fn resize_area_u8( + src: &[u8], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + dst: &mut [u8], +) -> Option<()> { + let mut f = vec![0f32; src.len()]; + crate::par_zip(&mut f, src, |d, &v| *d = v as f32); + let out = resize_area(&f, h, w, cn, dh, dw)?; + // Adding 1.5 * 2^23 rounds half to even into the low mantissa bits. + crate::par_zip(dst, &out, |d, &v| { + *d = ((v.clamp(-1.0, 256.0) + 12_582_912.0).to_bits() as i32 - 0x4b40_0000).clamp(0, 255) + as u8; + }); + Some(()) +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_CUBIC)` of a uint8 image, as OpenCV +/// computes it without IPP: 11-bit fixed-point weights, integer rows, and a column pass that +/// runs in float32 over blocks of 8 values of each row and in fixed point over the rest. +pub fn resize_cubic_u8( + src: &[u8], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + dst: &mut [u8], +) -> Option<()> { + if h == 0 + || w == 0 + || dh == 0 + || dw == 0 + || cn == 0 + || src.len() != h * w * cn + || dst.len() != dh * dw * cn + { + return None; + } + // saturate_cast of the float weight: round half to even. + let fixed = |wt: [f32; 8]| { + std::array::from_fn::(|k| (wt[k] * 2048.0).round_ties_even() as i16) + }; + let xs = kernel_axis(w, dw, Kernel::Cubic); + let ys: Vec<(i64, [i16; 4])> = kernel_axis(h, dh, Kernel::Cubic) + .into_iter() + .map(|(y, wt)| (y, fixed(wt))) + .collect(); + // A row padded with PAD replicated pixels each side holds every tap window: x0 >= -2, x0 + 3 <= w + 1. + let starts: Vec = xs.iter().map(|&(x0, _)| (x0 + PAD as i64) as u32).collect(); + // Each pixel's four weights repeated per channel lane group, as the AVX2 row pass loads them. + let wts: Vec<[i16; 16]> = xs + .iter() + .map(|&(_, wt)| { + let a = fixed(wt); + std::array::from_fn(|i| a[i % 4]) + }) + .collect(); + let padded_len = (w + 2 * PAD) * cn + SLACK; + let windows_fit = starts + .iter() + .all(|&s| (s as usize + 4) * cn + 16 <= padded_len); + let clamp = |i: i64, len: usize| i.clamp(0, len as i64 - 1) as usize; + let row_len = dw * cn; + let vec_len = row_len / 8 * 8; + let band = dh + .div_ceil(BANDS_PER_THREAD * rayon::current_num_threads()) + .max(8); + dst.par_chunks_mut(band * row_len) + .zip(ys.par_chunks(band)) + .for_each(|(band, ys)| { + let lo = clamp(ys[0].0, h); + let hi = clamp(ys[ys.len() - 1].0 + 3, h); + BUFFERS.with_borrow_mut(|(rows, padded)| { + rows.resize((hi - lo + 1) * row_len, 0); + padded.resize(padded_len, 0); + for (r, out) in rows.chunks_mut(row_len).enumerate() { + let s = &src[(lo + r) * w * cn..][..w * cn]; + padded[PAD * cn..][..w * cn].copy_from_slice(s); + for p in 0..PAD { + padded[p * cn..][..cn].copy_from_slice(&s[..cn]); + padded[(PAD + w + p) * cn..][..cn].copy_from_slice(&s[(w - 1) * cn..]); + } + cubic_row_u8(padded, cn, &starts, &wts, out, windows_fit); + } + for (o, (y0, b)) in band.chunks_mut(row_len).zip(ys) { + let r = |k: usize| &rows[(clamp(y0 + k as i64, h) - lo) * row_len..][..row_len]; + cubic_column_u8(o, [r(0), r(1), r(2), r(3)], *b, vec_len); + } + }); + }); + Some(()) +} + +thread_local! { + /// Each worker's row buffers, kept across calls: a fresh large buffer page-faults on every call. + static BUFFERS: std::cell::RefCell<(Vec, Vec)> = const { std::cell::RefCell::new((Vec::new(), Vec::new())) }; +} + +const PAD: usize = 2; +const BANDS_PER_THREAD: usize = 4; +const SLACK: usize = 16; + +/// `HResizeCubic` of one padded row: output (dx, c) is the integer dot product of channel c of +/// the four pixels from `starts[dx]` with the weights of dx. `windows_fit` says each 16-byte +/// load at a window start lies inside `padded`. +fn cubic_row_u8( + padded: &[u8], + cn: usize, + starts: &[u32], + wts: &[[i16; 16]], + out: &mut [i32], + windows_fit: bool, +) { + #[cfg(target_arch = "x86_64")] + if windows_fit && (cn == 3 || cn == 4) && std::arch::is_x86_feature_detected!("avx2") { + // SAFETY: AVX2 is present, and every 16-byte load at a window start lies inside `padded`. + let done = unsafe { cubic_row_u8_avx2(padded, cn, starts, wts, out) }; + cubic_row_u8_scalar( + padded, + cn, + &starts[done..], + &wts[done..], + &mut out[done * cn..], + ); + return; + } + cubic_row_u8_scalar(padded, cn, starts, wts, out); +} + +fn cubic_row_u8_scalar( + padded: &[u8], + cn: usize, + starts: &[u32], + wts: &[[i16; 16]], + out: &mut [i32], +) { + for ((o, &s), a) in out.chunks_exact_mut(cn).zip(starts).zip(wts) { + let p = &padded[s as usize * cn..][..4 * cn]; + for (c, d) in o.iter_mut().enumerate() { + *d = (0..4).map(|k| p[k * cn + c] as i32 * a[k] as i32).sum(); + } + } +} + +/// Two pixels per step: `pshufb` sorts each 16-byte window by channel, `pmaddwd` multiplies the +/// four taps pairwise and `phaddd` adds the pairs. Returns how many pixels it wrote; a pixel whose +/// 8-lane store would pass the end of `out` is left to the caller. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn cubic_row_u8_avx2( + padded: &[u8], + cn: usize, + starts: &[u32], + wts: &[[i16; 16]], + out: &mut [i32], +) -> usize { + use std::arch::x86_64::*; + // Channel-major order of a window's bytes; 3 channels leave the last four lanes zero. + let order: [i8; 16] = if cn == 3 { + [0, 3, 6, 9, 1, 4, 7, 10, 2, 5, 8, 11, -1, -1, -1, -1] + } else { + [0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15] + }; + // Lanes of [p.c0, p.c1, q.c0, q.c1 | p.c2, p.c3, q.c2, q.c3] in pixel order. + let lanes = if cn == 3 { + [0, 1, 4, 2, 3, 6, 7, 7] + } else { + [0, 1, 4, 5, 2, 3, 6, 7] + }; + let n = starts.len(); + let mut dx = 0; + // SAFETY: every load at a window start is inside `padded` (the caller's check), and each + // store writes 8 lanes at dx * cn with (dx + 2) * cn + 8 - 2 * cn <= out.len() checked below. + unsafe { + let shuf = _mm_loadu_si128(order.as_ptr() as *const __m128i); + let perm = _mm256_loadu_si256(lanes.as_ptr() as *const __m256i); + let pixel = |d: usize| { + let v = _mm_loadu_si128(padded.as_ptr().add(starts[d] as usize * cn) as *const __m128i); + let taps = _mm256_cvtepu8_epi16(_mm_shuffle_epi8(v, shuf)); + let wv = _mm256_loadu_si256(wts[d].as_ptr() as *const __m256i); + _mm256_madd_epi16(taps, wv) + }; + while dx + 2 <= n && dx * cn + 8 <= out.len() { + let sums = _mm256_hadd_epi32(pixel(dx), pixel(dx + 1)); + let ordered = _mm256_permutevar8x32_epi32(sums, perm); + _mm256_storeu_si256(out.as_mut_ptr().add(dx * cn) as *mut __m256i, ordered); + dx += 2; + } + } + dx +} + +/// `VResizeCubic` with `VResizeCubicVec_32s8u` at 128-bit lanes for one plane: the first `split` +/// values in float32 (weights scaled by 2^-22, the last product added first, rounded half to +/// even), the rest `(sum + 2^21) >> 22`. Both saturate to uint8. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn cubic_column_u8(out: &mut [u8], r: [&[i32]; 4], b: [i16; 4], split: usize) { + let scale = 1.0f32 / (2048.0 * 2048.0); + let bf = b.map(|v| v as f32 * scale); + let n = out.len(); + let r = r.map(|x| &x[..n]); + let mut done = 0; + #[cfg(target_arch = "x86_64")] + if std::arch::is_x86_feature_detected!("avx2") { + // SAFETY: AVX2 is present; the kernel reads and writes only below `split <= n`. + done = unsafe { cubic_column_u8_avx2(&mut out[..split], r, bf) }; + } + let (vec, rest) = out.split_at_mut(split); + let vec = &mut vec[done..]; + let rows = r[0].iter().zip(r[1]).zip(r[2]).zip(r[3]); + for (d, (((&s0, &s1), &s2), &s3)) in vec.iter_mut().zip(rows.clone().skip(done)) { + let v = s3 as f32 * bf[3]; + let v = s2 as f32 * bf[2] + v; + let v = s1 as f32 * bf[1] + v; + let v = s0 as f32 * bf[0] + v; + // 1.5 * 2^23 rounds half to even into the low mantissa bits for |v| < 2^22. + let q = (v.clamp(-1.0, 256.0) + 12_582_912.0).to_bits() as i32 - 0x4b40_0000; + *d = q.clamp(0, 255) as u8; + } + let b = b.map(i32::from); + for (d, (((&s0, &s1), &s2), &s3)) in rest.iter_mut().zip(rows.skip(split)) { + let s = s0 * b[0] + s1 * b[1] + s2 * b[2] + s3 * b[3]; + *d = ((s + (1 << 21)) >> 22).clamp(0, 255) as u8; + } +} + +/// The float32 part of the column pass, 16 values per step: `cvtps2dq` rounds half to even as +/// `v_round` does, and the saturating packs clamp to uint8 as `v_pack` and `v_pack_u_store` do. +/// Returns how many values it wrote. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn cubic_column_u8_avx2(out: &mut [u8], r: [&[i32]; 4], bf: [f32; 4]) -> usize { + use std::arch::x86_64::*; + let n = out.len() / 16 * 16; + // SAFETY: each step reads 16 values of every row and writes 16 bytes below n <= out.len() + // and n <= r[k].len(). + unsafe { + let b = bf.map(|v| _mm256_set1_ps(v)); + let sum = |i: usize| { + let ld = |k: usize| { + _mm256_cvtepi32_ps(_mm256_loadu_si256(r[k].as_ptr().add(i) as *const __m256i)) + }; + let v = _mm256_mul_ps(ld(3), b[3]); + let v = _mm256_add_ps(_mm256_mul_ps(ld(2), b[2]), v); + let v = _mm256_add_ps(_mm256_mul_ps(ld(1), b[1]), v); + _mm256_cvtps_epi32(_mm256_add_ps(_mm256_mul_ps(ld(0), b[0]), v)) + }; + for i in (0..n).step_by(16) { + let words = + _mm256_permute4x64_epi64::<0b11_01_10_00>(_mm256_packs_epi32(sum(i), sum(i + 8))); + let bytes = _mm256_packus_epi16(words, words); + let low = _mm256_castsi256_si128(_mm256_permute4x64_epi64::<0b10_00_10_00>(bytes)); + _mm_storeu_si128(out.as_mut_ptr().add(i) as *mut __m128i, low); + } + } + n +} + /// `expand_mask_plane` times `scale`: a ph x pw plane resized bilinearly onto rows y1..y2 and /// columns x1..x2 of an oh x ow plane, the edges replicated outside, each value times `scale` /// in float32. diff --git a/tests/test_core_resize_kernel.py b/tests/test_core_resize_kernel.py index 489f38a36..d098ca399 100644 --- a/tests/test_core_resize_kernel.py +++ b/tests/test_core_resize_kernel.py @@ -27,3 +27,32 @@ def test_resize_kernel_stays_near_cv2(shape, dsize, lanczos, flag, tol): got = negpy_core.resize_kernel(np.ascontiguousarray(src), dsize, lanczos=lanczos) assert got.shape == want.shape np.testing.assert_allclose(got, want, rtol=0, atol=tol) + + +U8_CASES = [ + ((20, 30, 3), (45, 31)), + ((17, 23), (40, 33)), + ((200, 300, 3), (450, 300)), + ((300, 200, 4), (500, 333)), + ((64, 64, 3), (48, 40)), + ((9, 7, 3), (31, 29)), +] + + +@pytest.fixture +def no_ipp(): + cv2.ipp.setUseIPP(False) + yield + cv2.ipp.setUseIPP(True) + + +@pytest.mark.parametrize("shape,dsize", U8_CASES) +def test_uint8_cubic_matches_opencv_without_ipp(shape, dsize, no_ipp): + src = np.random.default_rng(sum(shape)).integers(0, 256, shape).astype(np.uint8) + np.testing.assert_array_equal(negpy_core.resize_u8(src, dsize, cubic=True), cv2.resize(src, dsize, interpolation=cv2.INTER_CUBIC)) + + +@pytest.mark.parametrize("shape,dsize", [((400, 600, 3), (240, 160)), ((600, 900, 3), (300, 200)), ((101, 77, 3), (40, 31))]) +def test_uint8_area_matches_cv2(shape, dsize): + src = np.random.default_rng(sum(shape)).integers(0, 256, shape).astype(np.uint8) + np.testing.assert_array_equal(negpy_core.resize_u8(src, dsize), cv2.resize(src, dsize, interpolation=cv2.INTER_AREA)) From 2169b453c8efdf2c12e75d5fc6aaa52e4f409269 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 20:44:40 +0200 Subject: [PATCH 119/133] perf(core): negpy imports no cv2 The last cv2 calls move to negpy_core: the overlay's zone outline (findContours RETR_LIST and OpenCV 4.13's approxPolyDP, bit for bit on 1500 fuzzed masks), the SANE row resize (OpenCV's own uint8/uint16 row-only bilinear, bit for bit without IPP), the interactive proxy, the chart fit and IR export downsamples (resize_area), and the retouch structuring elements, morphology codes and run box filter. The chart fit takes its area scale from the rounded output size, as cv2 did not (ledger). The simulated camera's preview JPEG comes from Pillow. Replay: the Phase 4 and IR corpora unchanged. --- negpy/desktop/controller.py | 3 +- negpy/desktop/view/canvas/overlay.py | 8 +- negpy/desktop/workers/chart_fit.py | 5 +- negpy/features/retouch/logic.py | 23 +- negpy/infrastructure/scanners/sane_backend.py | 7 +- negpy/infrastructure/simulated/gphoto.py | 7 +- negpy/services/export/linear_output.py | 5 +- negpy_core/LEDGER.md | 3 + negpy_core/manifest.toml | 68 +++--- negpy_core/src/contours.rs | 216 +++++++++++++++++- negpy_core/src/py/contours.rs | 23 ++ negpy_core/src/py/resize.rs | 56 +++++ negpy_core/src/resize.rs | 67 ++++++ tests/test_core_contours.py | 14 ++ tests/test_core_resize_kernel.py | 8 + 15 files changed, 451 insertions(+), 62 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index f0d728db5..bc66fea81 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -7,7 +7,6 @@ from dataclasses import dataclass, fields, replace from typing import Any, Callable, Collection, Dict, List, Optional, Set, Tuple, Union -import cv2 import negpy_core import numpy as np from PyQt6.QtCore import Q_ARG, QMetaObject, QObject, Qt, QThread, QTimer, pyqtSignal @@ -294,7 +293,7 @@ def _interactive_proxy(raw: Optional[Any]) -> Optional[Any]: return None scale = APP_CONFIG.preview_render_size / float(long_edge) w, h = max(1, round(raw.shape[1] * scale)), max(1, round(raw.shape[0] * scale)) - return cv2.resize(raw, (w, h), interpolation=cv2.INTER_AREA) + return negpy_core.resize_area(np.ascontiguousarray(raw, dtype=np.float32), (w, h)) def _interactive_ir_proxy(ir: Optional[Any], proxy: Optional[Any]) -> Optional[Any]: diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 1698115b9..03f15a384 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -7,7 +7,6 @@ from dataclasses import replace from typing import Any, Dict, List, Optional, Tuple -import cv2 import negpy_core import numpy as np import qtawesome as qta @@ -1714,11 +1713,10 @@ def _zone_map_image(self, zones: np.ndarray) -> QImage: def _zone_outline(self, zones: np.ndarray, zone: int) -> List[QPolygonF]: if zone not in self._zone_outlines: - contours, _ = cv2.findContours((zones == zone).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + mask = np.ascontiguousarray(zones == zone, dtype=np.uint8) self._zone_outlines[zone] = [ - QPolygonF([QPointF(float(x) + 0.5, float(y) + 0.5) for [[x, y]] in cv2.approxPolyDP(c, 1.0, True)]) - for c in contours - if cv2.contourArea(c) >= _ZONE_OUTLINE_MIN_AREA + QPolygonF([QPointF(float(x) + 0.5, float(y) + 0.5) for x, y in c]) + for c in negpy_core.contour_outlines(mask, float(_ZONE_OUTLINE_MIN_AREA), 1.0) ] return self._zone_outlines[zone] diff --git a/negpy/desktop/workers/chart_fit.py b/negpy/desktop/workers/chart_fit.py index 601e0d5ba..280fe8cb6 100644 --- a/negpy/desktop/workers/chart_fit.py +++ b/negpy/desktop/workers/chart_fit.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, replace from typing import Optional -import cv2 +import negpy_core import numpy as np from PyQt6.QtCore import QThread, pyqtSignal @@ -64,7 +64,8 @@ def __init__(self, source: ChartSource, parent=None) -> None: buf = np.asarray(source.buffer, dtype=np.float32) scale = FIT_LONG_EDGE / max(buf.shape[:2]) if scale < 1.0: - buf = cv2.resize(buf, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + h, w = buf.shape[:2] + buf = negpy_core.resize_area(np.ascontiguousarray(buf), (max(1, round(w * scale)), max(1, round(h * scale)))) self._buffer = buf self._source = source self._processor = ImageProcessor(use_gpu=False) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 4d4417e61..b50657829 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -2,7 +2,6 @@ import math from typing import List, Optional, Tuple -import cv2 import negpy_core import numpy as np @@ -22,7 +21,7 @@ _DETECT_PAD_PX = 2.5 # Optical detection: density excess over a robust background, averaged over 3x3, in units of # its local MAD σ. Under the average a speck keeps its full excess while grain drops by 3. -# The robust filters run on 8-bit planes (cv2.medianBlur is O(1) there); the excess is +# The robust filters run on 8-bit planes (the uint8 median blur is O(1) there); the excess is # scaled by _DETECT_MAD_GAIN first so grain σ spans enough levels. _DETECT_AVG_PX = 3 _DETECT_MAD_GAIN = 4.0 @@ -263,7 +262,7 @@ def compute_dust_stats(img: ImageBuffer, dust_size: int) -> Tuple[np.ndarray, .. # Median: the level of the film around a defect narrower than half the window. Opening: # identity on the soft ramp of a tonal edge and on anything wider than a speck, both of # which the median alone calls dense. Whichever is higher is the background. - disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(base_size)) + 1,) * 2) + disk = negpy_core.ellipse_kernel((2 * int(round(base_size)) + 1,) * 2) constants = (_PROXY_MIN_SPREAD, _DETECT_AVG_PX, _DETECT_MAD_GAIN, _DETECT_SIGMA_MIN) return negpy_core.dust_stats(_density(img), v_win, _texture_win(shape, dust_size), disk, constants) @@ -297,7 +296,7 @@ def detect_luma_score( scale = film_scale(proxy.shape) lo = max(_DETECT_Z_GROW, min(hi, hi_hair) * _DETECT_Z_GROW_FRAC) reach = int(round(_DETECT_GROW_REACH * max(1, dust_size) * scale)) - disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * reach + 1,) * 2) + disk = negpy_core.ellipse_kernel((2 * reach + 1,) * 2) planes = tuple(np.ascontiguousarray(a, dtype=np.float32) for a in (proxy, background, z, texture)) return negpy_core.detect_luma_score( planes, @@ -524,7 +523,7 @@ def trace_scratch(img: ImageBuffer, nx: float, ny: float, threshold: float = 0.5 # A transport scratch fades in and out, so measure its extent instead of assuming it # spans the frame. on = (along * np.sign(along.mean()) > bar).astype(np.float32) - run = cv2.blur(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC + run = negpy_core.box_filter(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC if not run.any(): return None cols = np.flatnonzero(run) @@ -672,7 +671,7 @@ def _erode_resize_bounded(plane: np.ndarray, dims: Tuple[int, int], kernel: np.n read_top = max(0, source_top - radius) read_bottom = min(h, source_bottom + radius) source = np.ascontiguousarray(plane[read_top:read_bottom], dtype=np.float32) - eroded = negpy_core.morphology_ex(source, cv2.MORPH_ERODE, kernel) + eroded = negpy_core.morphology_ex(source, negpy_core.MORPH_ERODE, kernel) core = np.ascontiguousarray(eroded[source_top - read_top : source_bottom - read_top]) horizontal = negpy_core.resize_area(core, (dw, len(core))) # Keep the full-image sampling grid across fractional block boundaries. @@ -711,16 +710,16 @@ def downsample_ir(plane: np.ndarray, target_long_edge: int, dims: Optional[Tuple dims = (max(1, int(round(w * s))), max(1, int(round(h * s)))) if dims == (w, h): return np.ascontiguousarray(plane) - # cv2 read an HxWx1 plane as single-channel and returned HxW; keep that shape. + # An HxWx1 plane comes back as HxW. if plane.ndim == 3 and plane.shape[2] == 1: plane = plane[:, :, 0] # Erode by the resample footprint: a 1.25x downsample must not fatten by a 4.5x kernel. k = max(1, int(round(long_edge / max(dims))) | 1) if k > 1: - kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) + kernel = negpy_core.ellipse_kernel((k, k)) if plane.nbytes > _IR_DOWNSAMPLE_WORK_BYTES: return _erode_resize_bounded(plane, dims, kernel) - plane = negpy_core.morphology_ex(np.ascontiguousarray(plane), cv2.MORPH_ERODE, kernel) + plane = negpy_core.morphology_ex(np.ascontiguousarray(plane), negpy_core.MORPH_ERODE, kernel) return negpy_core.resize_area(np.ascontiguousarray(plane), dims) @@ -859,7 +858,7 @@ def route_wide_defects(score: np.ndarray, *, budget: Optional[float] = _IR_ROUTE line clears it on its own, so it would refuse exactly the case that needs the inpaint. """ scale = _ir_detect_scale(score) - k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(_IR_ROUTE_DILATE * scale)) + 1,) * 2) + k = negpy_core.ellipse_kernel((2 * int(round(_IR_ROUTE_DILATE * scale)) + 1,) * 2) bar = np.float32(_IR_SCORE_FLOOR + 1e-6) routed = negpy_core.route_wide_defects(np.ascontiguousarray(score, dtype=np.float32), bar, int(round(_IR_ROUTE_RADIUS * scale)), k) if routed is None: @@ -1026,8 +1025,8 @@ def apply_hair_inpaint( if not m.any(): return img if dilate_px > 0: - k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate_px + 1, 2 * dilate_px + 1)) - m = negpy_core.morphology_ex(m, cv2.MORPH_DILATE, k) + k = negpy_core.ellipse_kernel((2 * dilate_px + 1, 2 * dilate_px + 1)) + m = negpy_core.morphology_ex(m, negpy_core.MORPH_DILATE, k) src = np.ascontiguousarray(img, dtype=np.float32) # Each component fills in its padded box, tiled with a halo; a neighbour reaching into the # box stays unknown, or it becomes clone source and its dust is filled back in. diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index af3d27e7b..818bba47d 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -4,7 +4,6 @@ import threading from typing import Callable -import cv2 import negpy_core import numpy as np @@ -722,14 +721,16 @@ def _resample_rows_to_dpi(array: np.ndarray, *, native_dpi: int, target_dpi: int caller): interpolating in more rows than the stepper actually moved is an honest resample of real data, never inventing detail a downsample would have thrown away. - Bilinear (cv2's default): plenty for reconciling a mechanically-limited stepper axis + Bilinear: plenty for reconciling a mechanically-limited stepper axis against a genuinely finer sensor axis. Not used anywhere IR is involved — see the capture_ir guard at the call site — since _align_ir_to_rgb documents that even interpolating IR data risks softening a thin dust defect's minimum. """ target_rows = max(1, round(array.shape[0] * target_dpi / native_dpi)) width = array.shape[1] - return cv2.resize(array, (width, target_rows), interpolation=cv2.INTER_LINEAR) + if array.dtype == np.float32: + return negpy_core.resize_linear(np.ascontiguousarray(array), (width, target_rows)) + return negpy_core.resize_rows_linear(np.ascontiguousarray(array), target_rows) def _validate_inline_rgbi_parameters( diff --git a/negpy/infrastructure/simulated/gphoto.py b/negpy/infrastructure/simulated/gphoto.py index 81b0a1a2c..ee56ec435 100644 --- a/negpy/infrastructure/simulated/gphoto.py +++ b/negpy/infrastructure/simulated/gphoto.py @@ -7,8 +7,8 @@ import io from typing import Optional -import cv2 import numpy as np +from PIL import Image import tifffile from negpy.infrastructure.capture.base import CAPTURE_ORDER @@ -221,8 +221,9 @@ def still_dng(self) -> bytes: def preview_jpeg(self) -> bytes: display = np.clip(self._signal(*_film(*_PREVIEW_HW, self._frame)), 0.0, 1.0) ** (1 / 2.2) - _ok, jpeg = cv2.imencode(".jpg", cv2.cvtColor((display * 255).astype(np.uint8), cv2.COLOR_RGB2BGR)) - return jpeg.tobytes() + out = io.BytesIO() + Image.fromarray((display * 255).astype(np.uint8)).save(out, "JPEG", quality=95) + return out.getvalue() @functools.lru_cache(maxsize=4) diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index 37bab5724..c073a9f9e 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -9,6 +9,7 @@ from typing import Any, Optional import imagecodecs +import negpy_core import numpy as np import rawpy import tifffile as _tifffile @@ -307,10 +308,8 @@ def _apply_ice(rgb: np.ndarray, ir: np.ndarray, retouch: RetouchConfig) -> np.nd ir_det = downsample_ir(np.ascontiguousarray(ir, dtype=np.float32), target) h, w = rgb.shape[:2] if max(h, w) > target: - import cv2 - s = target / max(h, w) - rgb_det = cv2.resize(rgb, (max(1, round(w * s)), max(1, round(h * s))), interpolation=cv2.INTER_AREA) + rgb_det = negpy_core.resize_area(np.ascontiguousarray(rgb, dtype=np.float32), (max(1, round(w * s)), max(1, round(h * s)))) else: rgb_det = rgb ratio_det, gain_det, degenerate, _ = ir_ratio_and_gain(ir_det, rgb_det) diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index f36cf3530..0a7f6ed99 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -40,3 +40,6 @@ tolerance listed for each entry. | `features/rgbscan/logic.py::estimate_shift`, `infrastructure/scanners/sane_backend.py::_align_ir_to_rgb` (and the HDR and RGB Scan alignment through them) | `cv2.phaseCorrelate` is `negpy_core::phase::phase_correlate`, in float32 through rustfft and realfft, as OpenCV works. cv2 measures the shift from (n / 2.0, m / 2.0) after an fftShift that rolls by (n / 2, m / 2), so along an axis whose padded DFT size is odd it reads every shift half a pixel too far; negpy_core measures from the roll's origin. Gate `tests/test_core_phase.py`: within 0.2 px of the true shift on synthetic pairs (the 5x5 peak centroid's own bias), within 0.05 px of cv2 once its half pixel is taken off. The recorded HDR and RGB Scan merges match bit for bit. | quality | | `services/export/print.py` (print layout) and `services/rendering/gpu_engine.py::GPUEngine._process_tiled`: the Lanczos4 upscale | `negpy_core.resize_kernel` copies OpenCV's taps and weights (`interpolateLanczos4` with libm's sin and cos) and sums each pass in float32 tap by tap; opencv-python's vector code orders the sums its own way. Gate `tests/test_core_resize_kernel.py`: within 1e-6 of cv2 (INTER_CUBIC within 1e-5). | quality | | `services/export/contact_sheet.py::_place_tile`: the uint8 cubic tile upscale | `negpy_core.resize_u8(cubic=True)` copies OpenCV's own uint8 cubic resize (11-bit fixed-point weights, integer rows, the float32 column pass over 8-value blocks), bit for bit with cv2 when IPP is off. opencv-python on x86-64 runs IPP's resize, which differs by 1 level on a few per cent of values. The area shrink matches cv2 bit for bit. | ulp=1 (uint8 levels) | +| `infrastructure/scanners/sane_backend.py::_resample_rows_to_dpi` | `negpy_core.resize_rows_linear` copies OpenCV's own row-only bilinear resize: integer for uint8, float32 for uint16. With IPP, cv2 matches for uint8 and differs by one level on some uint16 values. | ulp=1 (levels) | +| `desktop/workers/chart_fit.py::ChartFitWorker.__init__` | cv2 took the area scale from `fx` while it rounded the output size; negpy_core takes the scale from the rounded size, so the cells tile the frame exactly. | none (no recordings) | +| `infrastructure/simulated/gphoto.py::preview_jpeg` | The simulated camera's preview JPEG is Pillow's encoder at quality 95, not libjpeg through cv2. | none | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index e156d5b82..2b4aaed14 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1363,9 +1363,10 @@ depends = [] ["features/process/capture_color.py::apply_camera_matrix"] phase = 4 -status = "todo" +status = "ported" calls = ["negpy_core.matmul_3x3"] depends = [] +note = "negpy_core.matmul_3x3" ["features/process/capture_color.py::lightbox_level"] phase = 4 @@ -1640,19 +1641,19 @@ depends = [] ["features/retouch/logic.py::compute_dust_stats"] phase = 4 status = "ported" -calls = ["cv2.getStructuringElement", "negpy_core.dust_stats"] +calls = ["negpy_core.dust_stats", "negpy_core.ellipse_kernel"] depends = ["_density"] ["features/retouch/logic.py::detect_luma_score"] phase = 4 status = "ported" -calls = ["cv2.getStructuringElement", "negpy_core.detect_luma_score"] +calls = ["negpy_core.detect_luma_score", "negpy_core.ellipse_kernel"] depends = ["compute_dust_stats"] ["features/retouch/logic.py::downsample_ir"] phase = 4 status = "ported" -calls = ["cv2.getStructuringElement", "negpy_core.morphology_ex", "negpy_core.resize_area"] +calls = ["negpy_core.ellipse_kernel", "negpy_core.morphology_ex", "negpy_core.resize_area"] depends = ["_erode_resize_bounded"] ["features/retouch/logic.py::drop_exclusions"] @@ -1702,7 +1703,7 @@ depends = [] ["features/retouch/logic.py::route_wide_defects"] phase = 4 status = "ported" -calls = ["cv2.getStructuringElement", "negpy_core.route_wide_defects"] +calls = ["negpy_core.ellipse_kernel", "negpy_core.route_wide_defects"] depends = [] ["features/retouch/logic.py::score_weighted_fill"] @@ -1726,7 +1727,7 @@ depends = ["_density"] ["features/retouch/logic.py::trace_scratch"] phase = 4 status = "ported" -calls = ["cv2.blur", "negpy_core.scratch_slope_search", "np.median"] +calls = ["negpy_core.box_filter", "negpy_core.scratch_slope_search", "np.median"] depends = ["_scratch_ridge_rows", "_shear_rows"] ["features/hdr/logic.py::_accumulate"] @@ -2429,15 +2430,17 @@ note = "negpy_core.resize_area" ["desktop/controller.py::AppController._paint_flatfield_peek"] phase = 7 -status = "todo" +status = "ported" calls = ["negpy_core.resize_linear"] depends = [] +note = "calls negpy_core" ["desktop/controller.py::_interactive_proxy"] phase = 7 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["desktop/view/canvas/overlay.py::CanvasOverlay._ir_layer_qimage"] phase = 7 @@ -2453,9 +2456,10 @@ depends = [] ["desktop/view/canvas/overlay.py::CanvasOverlay._zone_outline"] phase = 7 -status = "todo" -calls = ["cv2.approxPolyDP", "cv2.contourArea", "cv2.findContours"] +status = "ported" +calls = ["negpy_core.contour_outlines"] depends = [] +note = "negpy_core.contour_outlines" ["desktop/view/sidebar/files.py::FileBrowser._measure_rebate"] phase = 7 @@ -2477,9 +2481,10 @@ depends = [] ["desktop/view/sidebar/scanlight.py::_RawClip.at"] phase = 7 -status = "todo" +status = "ported" calls = ["negpy_core.resize_nearest"] depends = [] +note = "calls negpy_core" ["desktop/view/widgets/charts.py::VectorscopeWidget._trace_image"] phase = 7 @@ -2513,9 +2518,10 @@ depends = [] ["desktop/workers/chart_fit.py::ChartFitWorker.__init__"] phase = 7 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["desktop/workers/export.py::_looks_border_crushed"] phase = 7 @@ -2543,21 +2549,24 @@ depends = [] ["infrastructure/display/icc_lut.py::apply_lut_f32"] phase = 7 -status = "todo" +status = "ported" calls = ["negpy_core.apply_lut_f32"] depends = [] +note = "calls negpy_core" ["infrastructure/display/icc_lut.py::apply_lut_u16"] phase = 7 -status = "todo" +status = "ported" calls = ["negpy_core.apply_lut_u16"] depends = [] +note = "calls negpy_core" ["infrastructure/display/icc_lut.py::apply_matrix_trc_u8"] phase = 7 -status = "todo" +status = "ported" calls = ["negpy_core.matrix_trc_u8"] depends = [] +note = "calls negpy_core" ["services/assets/half_frame.py::_edge_band"] phase = 7 @@ -2635,9 +2644,10 @@ note = "negpy_core.resize_u8" ["services/export/linear_output.py::_apply_ice"] phase = 7 -status = "todo" -calls = ["cv2.resize"] +status = "ported" +calls = ["negpy_core.resize_area"] depends = [] +note = "negpy_core.resize_area" ["services/export/print.py::PrintService.apply_layout"] phase = 7 @@ -2655,15 +2665,17 @@ depends = [] ["services/view/coordinate_mapping.py::CoordinateMapping._grid_homography"] phase = 7 -status = "todo" +status = "ported" calls = ["negpy_core.perspective_transform"] depends = [] +note = "calls negpy_core" ["services/view/coordinate_mapping.py::CoordinateMapping.create_uv_grid"] phase = 7 -status = "todo" +status = "ported" calls = ["negpy_core.rotation_matrix"] depends = [] +note = "calls negpy_core" ["features/geometry/skew.py::_coarse_angles"] phase = 9 @@ -2676,7 +2688,7 @@ depends = [] phase = 9 status = "ported" compare = "quality" -calls = ["cv2.getStructuringElement", "negpy_core.add_clean_grain", "negpy_core.hair_fill", "negpy_core.morphology_ex", "negpy_core.resize_linear"] +calls = ["negpy_core.add_clean_grain", "negpy_core.ellipse_kernel", "negpy_core.hair_fill", "negpy_core.morphology_ex", "negpy_core.resize_linear"] depends = [] ["features/rgbscan/logic.py::estimate_shift"] @@ -2724,12 +2736,8 @@ note = "negpy_core phase_correlate, mean_channels" ["infrastructure/scanners/sane_backend.py::_resample_rows_to_dpi"] phase = 9 -status = "todo" -calls = ["cv2.resize"] -depends = [] - -["infrastructure/simulated/gphoto.py::SimGphoto.preview_jpeg"] -phase = 9 -status = "stays" -calls = ["cv2.cvtColor", "cv2.imencode"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.resize_linear", "negpy_core.resize_rows_linear"] depends = [] +note = "negpy_core.resize_rows_linear" diff --git a/negpy_core/src/contours.rs b/negpy_core/src/contours.rs index fd022dfa6..daab80077 100644 --- a/negpy_core/src/contours.rs +++ b/negpy_core/src/contours.rs @@ -56,6 +56,7 @@ pub fn find_external_contours(src: &[u8], h: usize, w: usize) -> Vec Vec Vec> { + let (pw, ph) = (w + 2, h + 2); + let mut img = vec![0i8; pw * ph]; + for (line, row) in img.chunks_exact_mut(pw).skip(1).zip(src.chunks_exact(w)) { + for (d, &v) in line[1..=w].iter_mut().zip(row) { + *d = (v > 0) as i8; + } + } + let delta = |s: usize| DELTAS[s % 8].0 + DELTAS[s % 8].1 * pw as isize; + let mut found: Vec> = Vec::new(); + let (width, height) = (pw - 1, ph - 1); + for y in 1..height { + let mut prev: i8 = 0; + let mut x = 1; + while x < width { + match first_other(&img[y * pw + x..y * pw + width], prev) { + Some(skip) => x += skip, + None => break, + } + let p = img[y * pw + x]; + // contourScan: an outer border starts at 0 -> 1, a hole at an inside pixel -> 0. + let hole = if prev == 0 && p == 1 { + Some(false) + } else if p == 0 && prev >= 1 { + Some(true) + } else { + None + }; + match hole { + Some(hole) => { + let sx = x - hole as usize; + let s_end = if hole { 0 } else { 4 }; + found.push(trace( + &mut img, + y * pw + sx, + (sx as i32 - 1, y as i32 - 1), + s_end, + &delta, + )); + x += 1; + prev = img[y * pw + x - 1]; + } + None => { + prev = p; + x += 1; + } + } + } + } + found.reverse(); + found +} + +/// `cv2.approxPolyDP(curve, epsilon, closed)` of integer points, OpenCV 4.13's +/// Douglas-Peucker with its segment-distance test and final straight-line clean-up. +pub fn approx_poly_dp(src: &[(i32, i32)], eps: f64, closed: bool) -> Vec<(i32, i32)> { + let mut count = src.len(); + if count == 0 { + return Vec::new(); + } + let eps = eps * eps; + let mut dst: Vec<(i32, i32)> = Vec::with_capacity(count); + let mut stack: Vec<(usize, usize)> = Vec::new(); + let mut is_closed = closed; + let mut init_iters = 3; + let mut right = (0usize, 0usize); + let mut start_pt = (-1_000_000i32, -1_000_000i32); + let mut pos = 0usize; + let mut le_eps = false; + if !is_closed { + right.0 = count; + let (end_pt, first) = (src[0], src[count - 1]); + start_pt = first; + if start_pt != end_pt { + stack.push((0, count - 1)); + } else { + is_closed = true; + init_iters = 1; + } + } + if is_closed { + right.0 = 0; + for _ in 0..init_iters { + let mut max_dist = 0f64; + pos = (pos + right.0) % count; + start_pt = src[pos]; + pos = (pos + 1) % count; + for j in 1..count { + let pt = src[pos]; + pos = (pos + 1) % count; + let (dx, dy) = ((pt.0 - start_pt.0) as f64, (pt.1 - start_pt.1) as f64); + let dist = dx * dx + dy * dy; + if dist > max_dist { + max_dist = dist; + right.0 = j; + } + } + le_eps = max_dist <= eps; + } + if !le_eps { + let start = pos % count; + right.1 = start; + right.0 = (right.0 + start) % count; + stack.push(right); + stack.push((start, right.0)); + } else { + dst.push(start_pt); + } + } + while let Some(slice) = stack.pop() { + let end_pt = src[slice.1]; + start_pt = src[slice.0]; + pos = (slice.0 + 1) % count; + if pos != slice.1 { + let (dx, dy) = ( + (end_pt.0 - start_pt.0) as f64, + (end_pt.1 - start_pt.1) as f64, + ); + let seg = dx * dx + dy * dy; + let mut max_d = 0f64; + while pos != slice.1 { + let pt = src[pos]; + pos = (pos + 1) % count; + let (px, py) = (pt.0 - start_pt.0, pt.1 - start_pt.1); + let projection = px as f64 * dx + py as f64 * dy; + let d = if projection < 0.0 { + (px * px + py * py) as f64 * seg + } else if projection > seg { + let (ex, ey) = (pt.0 - end_pt.0, pt.1 - end_pt.1); + (ex * ex + ey * ey) as f64 * seg + } else { + let dist = py as f64 * dx - px as f64 * dy; + dist * dist + }; + if d > max_d { + max_d = d; + right.0 = (pos + count - 1) % count; + } + } + le_eps = max_d <= eps * seg; + } else { + le_eps = true; + } + if le_eps { + dst.push(start_pt); + } else { + stack.push((right.0, slice.1)); + stack.push((slice.0, right.0)); + } + } + if !is_closed { + dst.push(src[count - 1]); + } + // Clean-up: drop points on nearly straight runs. + let is_closed = closed; + count = dst.len(); + let mut new_count = count; + let mut pos = if is_closed { count - 1 } else { 0 }; + let read = |pos: &mut usize, d: &[(i32, i32)]| { + let p = d[*pos]; + *pos += 1; + if *pos >= count { + *pos = 0; + } + p + }; + let mut start_pt = read(&mut pos, &dst); + let mut wpos = pos; + let mut pt = read(&mut pos, &dst); + let open = !is_closed as usize; + let mut i = open; + while i < count - open && new_count > 2 { + let end_pt = read(&mut pos, &dst); + let (dx, dy) = ( + (end_pt.0 - start_pt.0) as f64, + (end_pt.1 - start_pt.1) as f64, + ); + let dist = ((pt.0 - start_pt.0) as f64 * dy - (pt.1 - start_pt.1) as f64 * dx).abs(); + let inner = ((pt.0 - start_pt.0) * (end_pt.0 - pt.0) + + (pt.1 - start_pt.1) * (end_pt.1 - pt.1)) as f64; + if dist * dist <= 0.5 * eps * (dx * dx + dy * dy) && dx != 0.0 && dy != 0.0 && inner >= 0.0 + { + new_count -= 1; + start_pt = end_pt; + dst[wpos] = end_pt; + wpos += 1; + if wpos >= count { + wpos = 0; + } + pt = read(&mut pos, &dst); + i += 2; + continue; + } + start_pt = pt; + dst[wpos] = pt; + wpos += 1; + if wpos >= count { + wpos = 0; + } + pt = end_pt; + i += 1; + } + if !is_closed { + dst[wpos] = pt; + } + dst.truncate(new_count); + dst +} + /// The index of the first byte that is not `v`, eight bytes at a time over the long runs. fn first_other(line: &[i8], v: i8) -> Option { let run = u64::from_ne_bytes([v as u8; 8]); @@ -89,19 +301,19 @@ fn first_other(line: &[i8], v: i8) -> Option { line[i..].iter().position(|&b| b != v).map(|p| i + p) } -/// `icvFetchContourEx` for an outer border from `start` (an index into the padded image), +/// `icvFetchContourEx` for a border from `start` (`s_end_outer` 4 for an outer border, 0 for a hole) (an index into the padded image), /// with CHAIN_APPROX_SIMPLE: a point wherever the chain direction changes. fn trace( img: &mut [i8], start: usize, origin: (i32, i32), + s_end_outer: usize, delta: &dyn Fn(usize) -> isize, ) -> Vec<(i32, i32)> { let at = |i: usize, d: isize| (i as isize + d) as usize; let mut pts = Vec::new(); let mut pt = origin; let i0 = start; - let s_end_outer = 4usize; let mut s = s_end_outer; let mut i1; loop { diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs index 09fc4e824..50ffd3733 100644 --- a/negpy_core/src/py/contours.rs +++ b/negpy_core/src/py/contours.rs @@ -245,7 +245,30 @@ fn alphas_overlap( Ok(crate::shapes::alphas_overlap(a, b, threshold)) } +/// The overlay's zone outline: `cv2.findContours(img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)`, +/// the contours of at least `min_area` (`cv2.contourArea`), each `cv2.approxPolyDP(c, epsilon, True)`. +#[pyfunction] +fn contour_outlines( + py: Python<'_>, + src: PyReadonlyArray2<'_, u8>, + min_area: f64, + epsilon: f64, +) -> PyResult>> { + let (h, w) = src.as_array().dim(); + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok(super::detach(py, data.len(), || { + contours::find_list_contours(data, h, w) + .into_iter() + .filter(|c| contours::contour_area(c) >= min_area) + .map(|c| contours::approx_poly_dp(&c, epsilon, true)) + .collect() + })) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(contour_outlines, m)?)?; m.add_function(wrap_pyfunction!(alphas_overlap, m)?)?; m.add_function(wrap_pyfunction!(stroke_cover, m)?)?; m.add_function(wrap_pyfunction!(local_maps, m)?)?; diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs index 1e75ed6ea..2d4f69aea 100644 --- a/negpy_core/src/py/resize.rs +++ b/negpy_core/src/py/resize.rs @@ -243,7 +243,63 @@ fn resize_u8<'py>( Ok(out) } +type RowResize = fn(&[T], (usize, usize, usize), usize, &mut [T]) -> Option<()>; + +/// `cv2.resize(src, (w, dh), interpolation=cv2.INTER_LINEAR)` of a contiguous uint8 or uint16 +/// image whose width stays, as OpenCV computes it without IPP. +#[pyfunction] +fn resize_rows_linear<'py>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + dh: usize, +) -> PyResult> { + fn run<'py, T: numpy::Element + Copy + Send + Sync + Default>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + dh: usize, + f: RowResize, + ) -> PyResult> { + let a: PyReadonlyArrayDyn<'py, T> = src.extract()?; + let shape = a.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_rows_linear needs a 2-D or 3-D array", + )); + } + }; + let data = a + .as_slice() + .map_err(|_| PyValueError::new_err("resize_rows_linear needs a contiguous array"))?; + let mut dims = shape.clone(); + dims[0] = dh; + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || f(data, (h, w, cn), dh, dst)).ok_or_else(|| { + PyValueError::new_err("resize_rows_linear needs a non-empty image and size") + })?; + } + Ok(out.into_any()) + } + if src.extract::>().is_ok() { + run::(py, src, dh, resize::resize_rows_linear_u8) + } else if src.extract::>().is_ok() { + run::(py, src, dh, resize::resize_rows_linear_u16) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "resize_rows_linear needs uint8 or uint16", + )) + } +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(resize_rows_linear, m)?)?; m.add_function(wrap_pyfunction!(resize_u8, m)?)?; m.add_function(wrap_pyfunction!(resize_kernel, m)?)?; m.add_function(wrap_pyfunction!(expand_plane, m)?)?; diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs index 27e945d91..5ddceed43 100644 --- a/negpy_core/src/resize.rs +++ b/negpy_core/src/resize.rs @@ -917,6 +917,73 @@ unsafe fn cubic_column_u8_avx2(out: &mut [u8], r: [&[i32]; 4], bf: [f32; 4]) -> n } +/// Per output row of OpenCV's linear axis: the two clipped source rows and the fraction toward the second. +fn linear_rows(h: usize, dh: usize) -> Vec<(usize, usize, f32)> { + let scale = 1.0 / (dh as f64 / h as f64); + (0..dh) + .map(|dy| { + let f = ((dy as f64 + 0.5) * scale - 0.5) as f32; + let s = f.floor(); + let clip = |y: i64| y.clamp(0, h as i64 - 1) as usize; + (clip(s as i64), clip(s as i64 + 1), f - s) + }) + .collect() +} + +/// `cv2.resize(src, (w, dh), interpolation=cv2.INTER_LINEAR)` of a uint8 image whose width stays, +/// as OpenCV computes it without IPP: 11-bit weights and `VResizeLinear`'s integer arithmetic. +pub fn resize_rows_linear_u8( + src: &[u8], + (h, w, cn): (usize, usize, usize), + dh: usize, + dst: &mut [u8], +) -> Option<()> { + let row = w * cn; + if h == 0 || row == 0 || dh == 0 || src.len() != h * row || dst.len() != dh * row { + return None; + } + let rows = linear_rows(h, dh); + dst.par_chunks_mut(row) + .zip(&rows) + .for_each(|(out, &(r0, r1, f))| { + // saturate_cast: round half to even. + let b0 = ((1.0 - f) * 2048.0).round_ties_even() as i32; + let b1 = (f * 2048.0).round_ties_even() as i32; + let (s0, s1) = (&src[r0 * row..][..row], &src[r1 * row..][..row]); + for ((d, &p0), &p1) in out.iter_mut().zip(s0).zip(s1) { + // A row holds pixel * 2048; the vertical pass takes it >> 4. + let v = ((b0 * (p0 as i32 * 128)) >> 16) + ((b1 * (p1 as i32 * 128)) >> 16); + *d = ((v + 2) >> 2).clamp(0, 255) as u8; + } + }); + Some(()) +} + +/// The uint16 counterpart: float32 weights, `S0 * b0 + S1 * b1` rounded half to even, saturated. +pub fn resize_rows_linear_u16( + src: &[u16], + (h, w, cn): (usize, usize, usize), + dh: usize, + dst: &mut [u16], +) -> Option<()> { + let row = w * cn; + if h == 0 || row == 0 || dh == 0 || src.len() != h * row || dst.len() != dh * row { + return None; + } + let rows = linear_rows(h, dh); + dst.par_chunks_mut(row) + .zip(&rows) + .for_each(|(out, &(r0, r1, f))| { + let (b0, b1) = (1.0 - f, f); + let (s0, s1) = (&src[r0 * row..][..row], &src[r1 * row..][..row]); + for ((d, &p0), &p1) in out.iter_mut().zip(s0).zip(s1) { + let v = p0 as f32 * b0 + p1 as f32 * b1; + *d = v.round_ties_even().clamp(0.0, 65535.0) as u16; + } + }); + Some(()) +} + /// `expand_mask_plane` times `scale`: a ph x pw plane resized bilinearly onto rows y1..y2 and /// columns x1..x2 of an oh x ow plane, the edges replicated outside, each value times `scale` /// in float32. diff --git a/tests/test_core_contours.py b/tests/test_core_contours.py index a4a92958e..d2b631709 100644 --- a/tests/test_core_contours.py +++ b/tests/test_core_contours.py @@ -34,3 +34,17 @@ def test_find_external_contours_matches_cv2(kind, shape): for g, w in zip(got, want): assert g.dtype == w.dtype and g.shape == w.shape and np.array_equal(g, w) assert (want_h is None and got_h is None) or np.array_equal(got_h, want_h) + + +@cv2_oracle +def test_contour_outlines_match_cv2_list_contours_and_approx(): + rng = np.random.default_rng(11) + for _ in range(120): + h, w = (int(v) for v in rng.integers(5, 90, 2)) + base = cv2.GaussianBlur(rng.uniform(0, 1, (h, w)).astype(np.float32), (0, 0), float(rng.uniform(0.5, 4))) + zones = np.digitize(base, np.quantile(base, [0.25, 0.5, 0.75])).astype(np.uint8) + for z in range(4): + m = (zones == z).astype(np.uint8) + cs, _ = cv2.findContours(m, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + want = [[tuple(p) for [p] in cv2.approxPolyDP(c, 1.0, True)] for c in cs if cv2.contourArea(c) >= 4] + assert [[tuple(p) for p in c] for c in negpy_core.contour_outlines(m, 4.0, 1.0)] == want diff --git a/tests/test_core_resize_kernel.py b/tests/test_core_resize_kernel.py index d098ca399..b53ce58a9 100644 --- a/tests/test_core_resize_kernel.py +++ b/tests/test_core_resize_kernel.py @@ -56,3 +56,11 @@ def test_uint8_cubic_matches_opencv_without_ipp(shape, dsize, no_ipp): def test_uint8_area_matches_cv2(shape, dsize): src = np.random.default_rng(sum(shape)).integers(0, 256, shape).astype(np.uint8) np.testing.assert_array_equal(negpy_core.resize_u8(src, dsize), cv2.resize(src, dsize, interpolation=cv2.INTER_AREA)) + + +@pytest.mark.parametrize("dtype,top", [(np.uint8, 256), (np.uint16, 65536)]) +@pytest.mark.parametrize("shape,dh", [((100, 37, 3), 173), ((50, 64), 101), ((40, 40, 4), 77)]) +def test_row_linear_matches_opencv_without_ipp(dtype, top, shape, dh, no_ipp): + src = np.random.default_rng(dh).integers(0, top, shape).astype(dtype) + want = cv2.resize(src, (shape[1], dh), interpolation=cv2.INTER_LINEAR) + np.testing.assert_array_equal(negpy_core.resize_rows_linear(src, dh), want) From d88815c16a9e3107369c394124b83f6e7dc90c1c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 20:56:58 +0200 Subject: [PATCH 120/133] perf(core): scan preview stretch and capture metering in Rust The scan preview stretches (preview_positive, _preview_u8), focus sharpness, the calibration patch percentile and clip fraction, the sensor calibration clip check and the scan writer's grey average run in negpy_core, each bit for bit with the NumPy code it replaces (uint16, float32 and float64 patches keep NumPy's percentile precision). The scope trace images, semantic ranking, shutter ladder and rebate tone average stay Python: bin grids, embeddings and per-frame values. --- negpy/desktop/view/widgets/prescan_dialog.py | 14 +- .../view/widgets/scan_preview_common.py | 15 +- .../view/widgets/sensor_calibration_dialog.py | 3 +- negpy/services/capture/calibration.py | 5 +- negpy/services/capture/focus_meter.py | 9 +- negpy/services/scanning/writer.py | 3 +- negpy_core/manifest.toml | 50 +++-- negpy_core/src/capture.rs | 120 ++++++++++++ negpy_core/src/lib.rs | 2 + negpy_core/src/py/capture.rs | 178 ++++++++++++++++++ negpy_core/src/py/mod.rs | 1 + tests/test_core_capture.py | 84 +++++++++ 12 files changed, 432 insertions(+), 52 deletions(-) create mode 100644 negpy_core/src/capture.rs create mode 100644 negpy_core/src/py/capture.rs create mode 100644 tests/test_core_capture.py diff --git a/negpy/desktop/view/widgets/prescan_dialog.py b/negpy/desktop/view/widgets/prescan_dialog.py index 4f993c03c..a47dc495f 100644 --- a/negpy/desktop/view/widgets/prescan_dialog.py +++ b/negpy/desktop/view/widgets/prescan_dialog.py @@ -5,6 +5,7 @@ from negpy.kernel.system.i18n import tr +import negpy_core import numpy as np from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import ( @@ -26,19 +27,10 @@ def _preview_u8(rgb: np.ndarray) -> np.ndarray: """Scale scan RGB to uint8 for the crop widget (percentile stretch).""" - a = np.asarray(rgb, dtype=np.float32) + a = np.ascontiguousarray(rgb, dtype=np.float32) if a.ndim == 2: a = a[:, :, None] - out = np.empty(a.shape[:2] + (3,), dtype=np.uint8) - channels = a.shape[2] - for c in range(3): - ch = a[..., min(c, channels - 1)] - lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) - if hi <= lo: - out[..., c] = 0 - else: - out[..., c] = np.clip((ch - lo) / (hi - lo) * 255.0, 0, 255).astype(np.uint8) - return out + return negpy_core.stretch_preview(a, "prescan") class PrescanCropDialog(QDialog): diff --git a/negpy/desktop/view/widgets/scan_preview_common.py b/negpy/desktop/view/widgets/scan_preview_common.py index 207f9e7a9..3cee43714 100644 --- a/negpy/desktop/view/widgets/scan_preview_common.py +++ b/negpy/desktop/view/widgets/scan_preview_common.py @@ -1,5 +1,6 @@ """Shared display helpers for scan-preview dialogs (StripPreviewDialog, QuickScanPreviewDialog).""" +import negpy_core import numpy as np from negpy.kernel.system.i18n import mark, tr @@ -78,17 +79,7 @@ def preview_positive(rgb: np.ndarray, film_type: str = FilmType.NEGATIVE.value) the stretch. Mirrors services.assets.thumbnails.preview_positive, which decides the same thing off the frame's stored process mode. """ - a = rgb.astype(np.float32) + a = np.ascontiguousarray(rgb, dtype=np.float32) if a.ndim == 2: a = a[:, :, None] - positive = film_reads_positive(film_type) - out = np.empty_like(a) - for c in range(a.shape[2]): - ch = a[..., c] - lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) - if hi <= lo: - out[..., c] = 0.0 - continue - scaled = (ch - lo) if positive else (hi - ch) - out[..., c] = np.clip(scaled / (hi - lo), 0.0, 1.0) * 255.0 - return out.astype(np.uint8) + return negpy_core.stretch_preview(a, "positive" if film_reads_positive(film_type) else "negative") diff --git a/negpy/desktop/view/widgets/sensor_calibration_dialog.py b/negpy/desktop/view/widgets/sensor_calibration_dialog.py index 3ae87cd01..3cf6e125d 100644 --- a/negpy/desktop/view/widgets/sensor_calibration_dialog.py +++ b/negpy/desktop/view/widgets/sensor_calibration_dialog.py @@ -6,6 +6,7 @@ from negpy.kernel.system.i18n import tr +import negpy_core import numpy as np from PyQt6.QtCore import QTimer, pyqtSignal from PyQt6.QtWidgets import ( @@ -165,7 +166,7 @@ def _compute_and_save(self) -> None: for band in ("R", "G", "B"): img = self._decode(self._paths[band]) measured[band] = measure_capture(img) - if float(np.mean(np.any(np.asarray(img[:, :, :3]) >= _CLIP_LEVEL, axis=2))) > _CLIP_FRACTION: + if negpy_core.any_channel_at_least(np.ascontiguousarray(img, dtype=np.float32), _CLIP_LEVEL) > _CLIP_FRACTION: clipped.append(band) matrix = build_sensor_matrix(measured["R"], measured["G"], measured["B"]) except Exception as exc: diff --git a/negpy/services/capture/calibration.py b/negpy/services/capture/calibration.py index ebb0fd418..b832c6fdc 100644 --- a/negpy/services/capture/calibration.py +++ b/negpy/services/capture/calibration.py @@ -11,6 +11,7 @@ from functools import lru_cache from typing import Callable, Optional +import negpy_core import numpy as np from negpy.features.process.sensor import build_sensor_matrix @@ -345,7 +346,7 @@ def meter_base(plane: np.ndarray, roi: Roi) -> float: patch = plane[y0:y1, x0:x1] if patch.size == 0: return 0.0 - return float(np.percentile(patch, 99.9)) + return negpy_core.patch_level_stats(np.ascontiguousarray(patch), 0.0, 99.9)[1] def clip_fraction(plane: np.ndarray, roi: Roi, saturation: float = SATURATION_VALUE) -> float: @@ -359,7 +360,7 @@ def clip_fraction(plane: np.ndarray, roi: Roi, saturation: float = SATURATION_VA patch = plane[y0:y1, x0:x1] if patch.size == 0: return 0.0 - return float(np.mean(patch >= saturation)) + return negpy_core.patch_level_stats(np.ascontiguousarray(patch), float(saturation), 50.0)[0] def nearest_shutter(label: str, candidates: tuple[str, ...]) -> str: diff --git a/negpy/services/capture/focus_meter.py b/negpy/services/capture/focus_meter.py index 49b8b1d37..9aaa5d2da 100644 --- a/negpy/services/capture/focus_meter.py +++ b/negpy/services/capture/focus_meter.py @@ -4,6 +4,7 @@ from typing import Optional +import negpy_core import numpy as np #: Mean level (0..255) below which a frame carries no usable contrast: the light is off. @@ -17,14 +18,10 @@ def sharpness(gray: np.ndarray) -> float: 0.0 for a frame too dark or too small. """ - img = np.asarray(gray, dtype=np.float32) + img = np.ascontiguousarray(gray, dtype=np.float32) if img.ndim != 2 or min(img.shape) < 3: return 0.0 - mean = float(img.mean()) - if mean < MIN_MEAN_LEVEL: - return 0.0 - lap = 4.0 * img[1:-1, 1:-1] - img[:-2, 1:-1] - img[2:, 1:-1] - img[1:-1, :-2] - img[1:-1, 2:] - return float(np.mean(lap * lap)) / (mean * mean) + return negpy_core.sharpness(img, MIN_MEAN_LEVEL) class FocusMeter: diff --git a/negpy/services/scanning/writer.py b/negpy/services/scanning/writer.py index 6703fd081..23a24fb66 100644 --- a/negpy/services/scanning/writer.py +++ b/negpy/services/scanning/writer.py @@ -1,6 +1,7 @@ import os import tempfile +import negpy_core import numpy as np import tifffile @@ -36,7 +37,7 @@ def _to_grey(rgb: np.ndarray) -> np.ndarray: """ if rgb.ndim == 2: return rgb - return ((np.sum(rgb, axis=-1, dtype=np.uint32) + 1) // 3).astype(rgb.dtype) + return negpy_core.to_grey(np.ascontiguousarray(rgb)) def write_tiff_16bit(result: ScanResult, path: str, *, mono: bool = False) -> str: diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 2b4aaed14..55b32cb84 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2463,9 +2463,10 @@ note = "negpy_core.contour_outlines" ["desktop/view/sidebar/files.py::FileBrowser._measure_rebate"] phase = 7 -status = "todo" +status = "stays" calls = ["np.mean"] depends = ["print_dmax"] +note = "per-frame tone list" ["desktop/view/sidebar/files.py::print_dmax"] phase = 7 @@ -2488,33 +2489,38 @@ note = "calls negpy_core" ["desktop/view/widgets/charts.py::VectorscopeWidget._trace_image"] phase = 7 -status = "todo" +status = "stays" calls = ["np.percentile"] depends = [] +note = "scope bin grid" ["desktop/view/widgets/charts.py::WaveformWidget._trace_images"] phase = 7 -status = "todo" +status = "stays" calls = ["np.percentile"] depends = [] +note = "scope bin grid" ["desktop/view/widgets/prescan_dialog.py::_preview_u8"] phase = 7 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.stretch_preview"] depends = [] +note = "negpy_core.stretch_preview" ["desktop/view/widgets/scan_preview_common.py::preview_positive"] phase = 7 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.stretch_preview"] depends = [] +note = "negpy_core.stretch_preview" ["desktop/view/widgets/sensor_calibration_dialog.py::SensorCalibrationDialog._compute_and_save"] phase = 7 -status = "todo" -calls = ["np.mean"] +status = "ported" +calls = ["negpy_core.any_channel_at_least"] depends = [] +note = "negpy_core.any_channel_at_least" ["desktop/workers/chart_fit.py::ChartFitWorker.__init__"] phase = 7 @@ -2582,9 +2588,10 @@ depends = [] ["services/assets/semantic_model.py::rank_by_similarity"] phase = 7 -status = "todo" +status = "stays" calls = ["np.argsort"] depends = [] +note = "embedding vectors" ["services/assets/thumbnails.py::preview_positive"] phase = 7 @@ -2594,27 +2601,31 @@ depends = [] ["services/capture/calibration.py::_ladder_stops"] phase = 7 -status = "todo" +status = "stays" calls = ["np.median"] depends = [] +note = "shutter ladder values" ["services/capture/calibration.py::clip_fraction"] phase = 7 -status = "todo" -calls = ["np.mean"] +status = "ported" +calls = ["negpy_core.patch_level_stats"] depends = [] +note = "negpy_core.patch_level_stats" ["services/capture/calibration.py::meter_base"] phase = 7 -status = "todo" -calls = ["np.percentile"] +status = "ported" +calls = ["negpy_core.patch_level_stats"] depends = [] +note = "negpy_core.patch_level_stats" ["services/capture/focus_meter.py::sharpness"] phase = 7 -status = "todo" -calls = ["np.mean"] +status = "ported" +calls = ["negpy_core.sharpness"] depends = [] +note = "negpy_core.sharpness" ["services/capture/meter.py::framing_mismatch"] phase = 7 @@ -2659,9 +2670,10 @@ note = "negpy_core resize_area, resize_kernel" ["services/scanning/writer.py::_to_grey"] phase = 7 -status = "todo" -calls = ["np.sum"] +status = "ported" +calls = ["negpy_core.to_grey"] depends = [] +note = "negpy_core.to_grey" ["services/view/coordinate_mapping.py::CoordinateMapping._grid_homography"] phase = 7 diff --git a/negpy_core/src/capture.rs b/negpy_core/src/capture.rs new file mode 100644 index 000000000..0e1f52f69 --- /dev/null +++ b/negpy_core/src/capture.rs @@ -0,0 +1,120 @@ +//! Ports of the preview and capture metering in negpy/services/capture, negpy/services/scanning +//! and the scan preview widgets. + +use rayon::prelude::*; + +use crate::stats::{np_percentiles, pairwise_sum_f32}; + +/// How `stretch_preview` maps a channel between its 1st and 99th percentiles. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Stretch { + /// `preview_positive` of a positive: `clip((v - lo) / (hi - lo), 0, 1) * 255`. + Positive, + /// `preview_positive` of a negative: `clip((hi - v) / (hi - lo), 0, 1) * 255`. + Negative, + /// `_preview_u8`: `clip((v - lo) / (hi - lo) * 255, 0, 255)`. + Prescan, +} + +/// The scan preview stretch of an HxWxC float32 image into `out_cn` uint8 channels, output channel +/// c reading input channel min(c, C - 1). A channel whose 99th percentile is not above its 1st is 0. +pub fn stretch_preview(img: &[f32], cn: usize, out_cn: usize, mode: Stretch, dst: &mut [u8]) { + let bounds: Vec> = (0..cn) + .into_par_iter() + .map(|c| { + let ch: Vec = img.iter().skip(c).step_by(cn).copied().collect(); + let p = np_percentiles(&ch, &[1.0, 99.0])?; + (p[1] > p[0]).then_some((p[0], p[1])) + }) + .collect(); + dst.par_chunks_mut(out_cn * 4096) + .zip(img.par_chunks(cn * 4096)) + .for_each(|(d, s)| { + for (o, p) in d.chunks_exact_mut(out_cn).zip(s.chunks_exact(cn)) { + for (c, v) in o.iter_mut().enumerate() { + let src = c.min(cn - 1); + let Some((lo, hi)) = bounds[src] else { + *v = 0; + continue; + }; + let x = p[src]; + // NumPy's float32 to uint8 cast truncates. + *v = match mode { + Stretch::Positive => ((x - lo) / (hi - lo)).clamp(0.0, 1.0) * 255.0, + Stretch::Negative => ((hi - x) / (hi - lo)).clamp(0.0, 1.0) * 255.0, + Stretch::Prescan => ((x - lo) / (hi - lo) * 255.0).clamp(0.0, 255.0), + } as u8; + } + } + }); +} + +/// `sharpness`: the float32 mean square of the 4-neighbour Laplacian over the squared float32 +/// mean level, or 0 below `min_mean`. +pub fn sharpness(img: &[f32], h: usize, w: usize, min_mean: f64) -> f64 { + let mean = (pairwise_sum_f32(img) / img.len() as f32) as f64; + if mean < min_mean { + return 0.0; + } + let mut sq = vec![0f32; (h - 2) * (w - 2)]; + sq.par_chunks_mut(w - 2).enumerate().for_each(|(i, row)| { + let y = i + 1; + let at = |yy: usize, x: usize| img[yy * w + x]; + for (j, d) in row.iter_mut().enumerate() { + let x = j + 1; + let lap = 4.0 * at(y, x) - at(y - 1, x) - at(y + 1, x) - at(y, x - 1) - at(y, x + 1); + *d = lap * lap; + } + }); + (pairwise_sum_f32(&sq) / sq.len() as f32) as f64 / (mean * mean) +} + +/// The share of pixels of an image with `cn` channels where any of the first three is at least `level`. +pub fn any_channel_at_least(img: &[f32], cn: usize, level: f32) -> f64 { + let n = img.len() / cn; + let hits: usize = img + .par_chunks(cn * 4096) + .map(|s| { + s.chunks_exact(cn) + .filter(|p| p[..cn.min(3)].iter().any(|&v| v >= level)) + .count() + }) + .sum(); + hits as f64 / n as f64 +} + +/// `_to_grey`: `(r + g + b + 1) // 3` per pixel, summed in uint32. +pub fn to_grey + TryFrom>( + rgb: &[T], + cn: usize, + dst: &mut [T], +) { + dst.par_chunks_mut(4096) + .zip(rgb.par_chunks(cn * 4096)) + .for_each(|(d, s)| { + for (o, p) in d.iter_mut().zip(s.chunks_exact(cn)) { + let sum: u32 = p.iter().map(|&v| v.into()).sum(); + // The average of the channels fits the type. + *o = T::try_from((sum + 1) / 3).unwrap_or(p[0]); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_frame_has_no_sharpness() { + let img = vec![10f32; 25]; + assert_eq!(sharpness(&img, 5, 5, 4.0), 0.0); + assert_eq!(sharpness(&[1f32; 25], 5, 5, 4.0), 0.0); + } + + #[test] + fn grey_rounds_up_from_two_thirds() { + let mut out = [0u16; 2]; + to_grey(&[1u16, 1, 0, 2, 2, 1], 3, &mut out); + assert_eq!(out, [1, 2]); + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index c9e121c41..372fb3510 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -2,6 +2,7 @@ pub mod altprocess; pub mod analysis; pub mod autocrop; pub mod bounds; +pub mod capture; pub mod color; pub mod components; pub mod contours; @@ -67,6 +68,7 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::process::register(m)?; py::phase::register(m)?; py::rgbscan::register(m)?; + py::capture::register(m)?; py::print_curve::register(m)?; py::gray::register(m)?; py::contours::register(m)?; diff --git a/negpy_core/src/py/capture.rs b/negpy_core/src/py/capture.rs new file mode 100644 index 000000000..8aa15fd03 --- /dev/null +++ b/negpy_core/src/py/capture.rs @@ -0,0 +1,178 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; + +use crate::capture::{self, Stretch}; +use crate::stats::{np_percentiles, np_percentiles_f64}; + +fn contiguous<'a, T: numpy::Element>(a: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +fn dims(shape: &[usize]) -> PyResult<(usize, usize, usize)> { + match shape { + [h, w] => Ok((*h, *w, 1)), + [h, w, c] if *c > 0 => Ok((*h, *w, *c)), + _ => Err(PyValueError::new_err("needs an HxW or HxWxC array")), + } +} + +/// The scan preview stretch of a float32 image: `mode` "positive", "negative" (`preview_positive`) +/// or "prescan" (`_preview_u8`, always three channels out). +#[pyfunction] +fn stretch_preview<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + mode: &str, +) -> PyResult>> { + let (h, w, cn) = dims(img.shape())?; + let mode = match mode { + "positive" => Stretch::Positive, + "negative" => Stretch::Negative, + "prescan" => Stretch::Prescan, + _ => { + return Err(PyValueError::new_err( + "mode must be positive, negative or prescan", + )); + } + }; + let out_cn = if mode == Stretch::Prescan { 3 } else { cn }; + let src = contiguous(&img)?; + let out = PyArrayDyn::::zeros(py, vec![h, w, out_cn], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || { + capture::stretch_preview(src, cn, out_cn, mode, dst) + }); + } + Ok(out) +} + +/// `sharpness` of an HxW float32 frame. +#[pyfunction] +fn sharpness(py: Python<'_>, gray: PyReadonlyArrayDyn<'_, f32>, min_mean: f64) -> PyResult { + let [h, w] = gray.shape()[..] else { + return Err(PyValueError::new_err("sharpness needs a 2-D array")); + }; + if h < 3 || w < 3 { + return Ok(0.0); + } + let src = contiguous(&gray)?; + Ok(super::detach(py, src.len(), || { + capture::sharpness(src, h, w, min_mean) + })) +} + +/// The share of pixels of an HxWxC float32 image where any of the first three channels is at least `level`. +#[pyfunction] +fn any_channel_at_least( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + level: f32, +) -> PyResult { + let (_, _, cn) = dims(img.shape())?; + let src = contiguous(&img)?; + if src.is_empty() { + return Ok(f64::NAN); + } + Ok(super::detach(py, src.len(), || { + capture::any_channel_at_least(src, cn, level) + })) +} + +/// `(np.mean(patch >= level), np.percentile(patch, q))` of a uint16, float32 or float64 patch, the +/// percentile in float32 for float32 and in float64 otherwise, as NumPy computes them. +#[pyfunction] +fn patch_level_stats( + py: Python<'_>, + patch: &Bound<'_, PyAny>, + level: f64, + q: f64, +) -> PyResult<(f64, f64)> { + fn stats(v: &[f32], level: f32, q: f64, wide: bool) -> (f64, f64) { + let hits = v.iter().filter(|&&x| x >= level).count(); + let p = if wide { + np_percentiles_f64(v, &[q])[0] + } else { + np_percentiles(v, &[q]).map_or(f64::NAN, |p| p[0] as f64) + }; + (hits as f64 / v.len() as f64, p) + } + if let Ok(a) = patch.extract::>() { + let src = contiguous(&a)?; + if src.is_empty() { + return Ok((0.0, 0.0)); + } + Ok(super::detach(py, src.len(), || { + let v: Vec = src.iter().map(|&x| x as f32).collect(); + stats(&v, level as f32, q, true) + })) + } else if let Ok(a) = patch.extract::>() { + let src = contiguous(&a)?; + if src.is_empty() { + return Ok((0.0, 0.0)); + } + Ok(super::detach(py, src.len(), || { + stats(src, level as f32, q, false) + })) + } else if let Ok(a) = patch.extract::>() { + let src = contiguous(&a)?; + if src.is_empty() { + return Ok((0.0, 0.0)); + } + Ok(super::detach(py, src.len(), || { + let hits = src.iter().filter(|&&x| x >= level).count(); + let p = if src.iter().any(|v| v.is_nan()) { + f64::NAN + } else { + crate::stats::quantile_f64(&mut src.to_vec(), q / 100.0).unwrap_or(f64::NAN) + }; + (hits as f64 / src.len() as f64, p) + })) + } else { + Err(PyTypeError::new_err( + "patch_level_stats needs uint16, float32 or float64", + )) + } +} + +/// `_to_grey` of a uint8 or uint16 HxWxC image. +#[pyfunction] +fn to_grey<'py>(py: Python<'py>, rgb: &Bound<'py, PyAny>) -> PyResult> { + fn run<'py, T: numpy::Element + Copy + Send + Sync + Into + TryFrom>( + py: Python<'py>, + rgb: &Bound<'py, PyAny>, + ) -> PyResult> { + let a: PyReadonlyArrayDyn<'py, T> = rgb.extract()?; + let (h, w, cn) = dims(a.shape())?; + let src = contiguous(&a)?; + let out = PyArrayDyn::::zeros(py, vec![h, w], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || capture::to_grey(src, cn, dst)); + } + Ok(out.into_any()) + } + if rgb.extract::>().is_ok() { + run::(py, rgb) + } else if rgb.extract::>().is_ok() { + run::(py, rgb) + } else { + Err(PyTypeError::new_err("to_grey needs uint8 or uint16")) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(stretch_preview, m)?)?; + m.add_function(wrap_pyfunction!(sharpness, m)?)?; + m.add_function(wrap_pyfunction!(any_channel_at_least, m)?)?; + m.add_function(wrap_pyfunction!(patch_level_stats, m)?)?; + m.add_function(wrap_pyfunction!(to_grey, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 125912e9b..cf8e4b7f2 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -2,6 +2,7 @@ pub mod altprocess; pub mod analysis; pub mod autocrop; pub mod bounds; +pub mod capture; pub mod color; pub mod components; pub mod contours; diff --git a/tests/test_core_capture.py b/tests/test_core_capture.py new file mode 100644 index 000000000..956e2ae95 --- /dev/null +++ b/tests/test_core_capture.py @@ -0,0 +1,84 @@ +"""The preview and capture helpers against the NumPy code they replace.""" + +import numpy as np +import pytest + +import negpy_core +from negpy.desktop.view.widgets.prescan_dialog import _preview_u8 +from negpy.desktop.view.widgets.scan_preview_common import preview_positive +from negpy.infrastructure.scanners.params import FilmType +from negpy.services.capture.focus_meter import MIN_MEAN_LEVEL, sharpness +from negpy.services.scanning.writer import _to_grey + + +def _scan(seed, shape=(151, 203, 3), dtype=np.uint16): + rng = np.random.default_rng(seed) + return (rng.uniform(0.05, 0.9, shape) * np.iinfo(dtype).max).astype(dtype) + + +def _preview_positive_numpy(rgb, positive): + a = rgb.astype(np.float32) + if a.ndim == 2: + a = a[:, :, None] + out = np.empty_like(a) + for c in range(a.shape[2]): + ch = a[..., c] + lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) + if hi <= lo: + out[..., c] = 0.0 + continue + scaled = (ch - lo) if positive else (hi - ch) + out[..., c] = np.clip(scaled / (hi - lo), 0.0, 1.0) * 255.0 + return out.astype(np.uint8) + + +def _preview_u8_numpy(rgb): + a = np.asarray(rgb, dtype=np.float32) + if a.ndim == 2: + a = a[:, :, None] + out = np.empty(a.shape[:2] + (3,), dtype=np.uint8) + for c in range(3): + ch = a[..., min(c, a.shape[2] - 1)] + lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) + out[..., c] = 0 if hi <= lo else np.clip((ch - lo) / (hi - lo) * 255.0, 0, 255).astype(np.uint8) + return out + + +@pytest.mark.parametrize("shape", [(151, 203, 3), (40, 50), (30, 20, 4)]) +def test_preview_stretches_match_numpy(shape): + rgb = _scan(1, shape) + if len(shape) == 3: + rgb[..., -1] = 7 # a flat channel + np.testing.assert_array_equal(preview_positive(rgb, "negative"), _preview_positive_numpy(rgb, False)) + np.testing.assert_array_equal(preview_positive(rgb, FilmType.POSITIVE.value), _preview_positive_numpy(rgb, True)) + np.testing.assert_array_equal(_preview_u8(rgb), _preview_u8_numpy(rgb)) + + +def test_sharpness_matches_numpy(): + img = _scan(2, (120, 160), np.uint8).astype(np.float32) + mean = float(img.mean()) + lap = 4.0 * img[1:-1, 1:-1] - img[:-2, 1:-1] - img[2:, 1:-1] - img[1:-1, :-2] - img[1:-1, 2:] + assert sharpness(img) == float(np.mean(lap * lap)) / (mean * mean) + assert sharpness(np.full((10, 10), MIN_MEAN_LEVEL / 2, np.float32)) == 0.0 + + +def test_level_stats_match_numpy(): + for patch in (_scan(3, (50, 70), np.uint16), _scan(4, (33, 41), np.uint16).astype(np.float32) / 65535): + level = 60000 if patch.dtype == np.uint16 else 0.9 + frac, p = negpy_core.patch_level_stats(np.ascontiguousarray(patch), float(level), 99.9) + assert frac == float(np.mean(patch >= level)) + assert p == float(np.percentile(patch, 99.9)) + img = _scan(5, (40, 60, 4)).astype(np.float32) / 65535 + assert negpy_core.any_channel_at_least(img, 0.85) == float(np.mean(np.any(img[:, :, :3] >= 0.85, axis=2))) + + +@pytest.mark.parametrize("dtype", [np.uint8, np.uint16]) +def test_to_grey_matches_numpy(dtype): + rgb = _scan(6, (31, 47, 3), dtype) + np.testing.assert_array_equal(_to_grey(rgb), ((np.sum(rgb, axis=-1, dtype=np.uint32) + 1) // 3).astype(dtype)) + + +def test_level_stats_of_float64_match_numpy(): + patch = np.random.default_rng(7).uniform(0, 65535, (37, 29)) + frac, p = negpy_core.patch_level_stats(patch, 60000.0, 99.9) + assert (frac, p) == (float(np.mean(patch >= 60000.0)), float(np.percentile(patch, 99.9))) From cc78b4a043cc7309d4064973a099df80c4474b21 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 21:17:25 +0200 Subject: [PATCH 121/133] perf(core): Phase 7 capture, thumbnail and half-frame pixel work in Rust The camera meter (frame normalization, picture clip statistics, peak map, grey thumbnails), the raw plateau and linearity clip fractions, the half-frame gutter's column profile, the export border check, the Film Strip's D-max and the placeholder thumbnail's log stretch run in negpy_core, bit for bit with the old NumPy code except where the ledger says. srgb_to_linear, which the manifest had missed and the JPEG and 8-bit TIFF loaders run on whole images, takes a power within an ULP of the correctly rounded value; NumPy's SVML one is up to 4 ULP off (ledger). Every manifest entry is now ported or stays. --- negpy/desktop/view/sidebar/files.py | 10 +- negpy/desktop/view/sidebar/scanlight.py | 4 +- negpy/desktop/workers/export.py | 9 +- negpy/infrastructure/capture/raw_demosaic.py | 62 ++--- negpy/kernel/image/logic.py | 2 +- negpy/services/assets/half_frame.py | 10 +- negpy/services/assets/thumbnails.py | 20 +- negpy/services/capture/meter.py | 37 ++- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 84 +++++-- negpy_core/src/capture.rs | 238 ++++++++++++++++++- negpy_core/src/color.rs | 21 ++ negpy_core/src/py/capture.rs | 204 ++++++++++++++++ negpy_core/src/py/color.rs | 10 + tests/test_capture_meter.py | 8 +- tests/test_core_capture.py | 98 ++++++++ 16 files changed, 687 insertions(+), 131 deletions(-) diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index 95c4868f8..3fb40a115 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -3,6 +3,7 @@ import os from typing import Any, List, Optional +import negpy_core import numpy as np import qtawesome as qta @@ -682,13 +683,8 @@ def _rgb_pixels(image: QImage) -> np.ndarray: def print_dmax(image: QImage) -> Optional[tuple[int, int, int]]: """A print's D-max tone: the mean color of the darkest pixels of a thumbnail, its padding left out.""" - pixels = _rgb_pixels(image.copy(picture_in_thumbnail(image))).reshape(-1, 3).astype(np.float32) - if not len(pixels): - return None - luma = pixels @ np.array([0.2126, 0.7152, 0.0722], np.float32) - darkest = pixels[luma <= np.quantile(luma, _DMAX_SHARE)] - red, green, blue = (int(round(v)) for v in darkest.mean(axis=0)) - return red, green, blue + pixels = np.ascontiguousarray(_rgb_pixels(image.copy(picture_in_thumbnail(image)))) + return negpy_core.print_dmax(pixels, _DMAX_SHARE) def picture_in_thumbnail(image: QImage) -> QRect: diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 4fd34f438..7b3e7c0ef 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -59,7 +59,7 @@ from negpy.features.exposure.analysis import output_histogram from negpy.services.capture.focus_meter import FocusMeter from negpy.services.capture.live_stats import clip_masks -from negpy.services.capture.meter import framing_mismatch, ranked_thumb, raw_clip_mask +from negpy.services.capture.meter import framing_mismatch, gray_of, ranked_thumb, raw_clip_mask from negpy.services.capture.calibration import ( CLIP_CEILING, REFERENCE_LEVELS, @@ -1477,7 +1477,7 @@ def _current_raw_clip(self, rgb: np.ndarray) -> "_RawClip | None": return clip # the metered shutter is on its way; the stepper may still lag if self._exposure_key() != clip.exposure: return None - thumb = ranked_thumb(rgb.mean(axis=2)) + thumb = ranked_thumb(gray_of(rgb)) if clip.reference is None: # The first preview at the metered exposure must frame the RAW the probe read, or # the map would land on the wrong pixels: drop it and say why. diff --git a/negpy/desktop/workers/export.py b/negpy/desktop/workers/export.py index 19605a303..de2110b24 100644 --- a/negpy/desktop/workers/export.py +++ b/negpy/desktop/workers/export.py @@ -10,6 +10,7 @@ import tempfile import threading +import negpy_core import numpy as np from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot from negpy.domain.models import ( @@ -178,12 +179,12 @@ def _looks_border_crushed(buffer, task: "ExportTask") -> bool: if task.params.geometry.crop_rect is not None: return False arr = buffer[:: max(1, buffer.shape[0] // 512), :: max(1, buffer.shape[1] // 512)] - arr = arr if arr.ndim == 2 else arr[..., :3].mean(axis=2) + arr = np.ascontiguousarray(arr, dtype=np.float32) + arr = arr if arr.ndim == 2 else negpy_core.mean_channels(np.ascontiguousarray(arr[..., :3])) h, w = arr.shape[:2] m = max(2, int(0.04 * min(h, w))) - ring = np.concatenate([arr[:m].ravel(), arr[-m:].ravel(), arr[:, :m].ravel(), arr[:, -m:].ravel()]) - inner = arr[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4] - return float(np.median(ring)) < 0.02 and float(np.median(inner)) < 0.12 + ring, inner = negpy_core.ring_inner_medians(arr, m) + return float(ring) < 0.02 and float(inner) < 0.12 def _companion_source_paths(task: "ExportTask") -> tuple: diff --git a/negpy/infrastructure/capture/raw_demosaic.py b/negpy/infrastructure/capture/raw_demosaic.py index 3870f7807..485a439fb 100644 --- a/negpy/infrastructure/capture/raw_demosaic.py +++ b/negpy/infrastructure/capture/raw_demosaic.py @@ -17,6 +17,7 @@ from typing import Optional, Sequence +import negpy_core import numpy as np @@ -116,40 +117,6 @@ def linear_demosaic(path: str, half_size: bool = False) -> np.ndarray: _MIN_PLATEAU_SITES = 4 # a plateau is a pile, never a handful of photosites on a small ROI -def _plateau_clip_fraction(values: np.ndarray, dark_frame_guard: int) -> float: - """Fraction of photosites pinned on a saturation plateau, found from the data's shape alone. - - A sensor can saturate below the white level its metadata publishes — or on a body with no - metadata at all. Saturation still has a shape: photosites pile up against the highest level - the sensor reaches, while noise above that pile falls off smoothly. The pile is not always the - ROI's own maximum — a handful of noise-elevated sites (dust, a hot pixel, ordinary shot noise) - routinely sit a few dozen counts above the true plateau, so anchoring on `values.max()` itself - can land in that sparse tail and miss the pile beneath it. Scanning the top `_PLATEAU_WINDOW` - counts for the densest bin, rather than assuming the maximum sample instead, finds it either - way. `dark_frame_guard` (typically the raw white level, loosely, or 0 to skip the guard) keeps - a frame nowhere near saturation from having its noise floor mistaken for a plateau. - """ - top = int(values.max()) - if dark_frame_guard > 0 and top * 2 < dark_frame_guard: - return 0.0 - lo = max(0, top - _PLATEAU_WINDOW) - counts = np.bincount(values[values >= lo] - lo, minlength=_PLATEAU_WINDOW + 1) - # Only codes the file holds count: a lossy raw encoding (Nikon compressed NEF) stores a sparse - # set of codes, and the gaps between them are not a falling tail. - populated = np.flatnonzero(counts) - peak_at = int(np.argmax(counts[populated])) - peak_bin = int(populated[peak_at]) - peak = int(counts[peak_bin]) - if peak < _MIN_PLATEAU_SITES: - return 0.0 - neighbors = counts[populated[peak_at + 1 : peak_at + 1 + _PLATEAU_NEIGHBOR_BINS]] - neighbor_density = float(neighbors.max()) if neighbors.size else 0.0 - if peak < _PLATEAU_DENSITY_RATIO * max(neighbor_density, 1.0): - return 0.0 - anchor = lo + peak_bin - return float(np.count_nonzero(values >= anchor)) / values.size - - def raw_channel_clip_fraction(path: str, channel_index: int, roi, saturation_margin: int = 16) -> tuple[float, float]: """Fraction of *raw Bayer* photosites for one channel past two independent points, inside the ROI: (linearity_fraction, plateau_fraction). @@ -161,7 +128,7 @@ def raw_channel_clip_fraction(path: str, channel_index: int, roi, saturation_mar number here: `linearity_fraction` (`_linearity_limit`, when the body publishes a calibrated ceiling) can overstate real clipping — a body's calibrated ceiling is often a conservative linearity limit, not where it actually saturates, so a channel legitimately using the top of - its range reads as partly "past" it. `plateau_fraction` (`_plateau_clip_fraction`) finds the + its range reads as partly "past" it. `plateau_fraction` (`negpy_core.raw_clip_fractions`) finds the true saturation pile from the data's shape, needs no metadata, and is what should gate a hard abort; the caller is expected to budget the two separately. `roi` is any object with a `.pixels(w, h)` method (duck-typed to avoid an infra→services import). channel_index: R=0, @@ -178,18 +145,17 @@ def raw_channel_clip_fraction(path: str, channel_index: int, roi, saturation_mar return 0.0, 0.0 h, w = img.shape[:2] x0, y0, x1, y1 = roi.pixels(w, h) - sub_img = img[y0:y1, x0:x1] - mask = np.isin(colors[y0:y1, x0:x1], wanted) - if not mask.any(): - return 0.0, 0.0 - values = sub_img[mask] - - linearity_fraction = 0.0 # raw_image_visible is absolute ADC counts (black not subtracted), matching the limit's scale. limit = _linearity_limit(raw, wanted) - if limit > 0: - threshold = max(0, limit - saturation_margin) - linearity_fraction = float(np.mean(values >= threshold)) - - plateau_fraction = _plateau_clip_fraction(values, int(raw.white_level or 0)) - return linearity_fraction, plateau_fraction + found = negpy_core.raw_clip_fractions( + np.ascontiguousarray(img[y0:y1, x0:x1], dtype=np.uint16), + np.ascontiguousarray(colors[y0:y1, x0:x1], dtype=np.uint8), + wanted, + max(0, limit - saturation_margin) if limit > 0 else None, + int(raw.white_level or 0), + _PLATEAU_WINDOW, + _MIN_PLATEAU_SITES, + _PLATEAU_NEIGHBOR_BINS, + _PLATEAU_DENSITY_RATIO, + ) + return (0.0, 0.0) if found is None else found diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index b27837986..149f625a9 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -55,7 +55,7 @@ def uint16_to_float32(img: np.ndarray) -> np.ndarray: def srgb_to_linear(img: np.ndarray) -> np.ndarray: """Convert sRGB gamma-encoded float32 image to linear light (IEC 61966-2-1).""" - return np.where(img <= 0.04045, img / 12.92, ((img + 0.055) / 1.055) ** 2.4).astype(np.float32) + return negpy_core.srgb_to_linear(np.ascontiguousarray(img, dtype=np.float32)) # Working-space output transform: the Adobe RGB (1998) TRC, a pure 563/256 power with no diff --git a/negpy/services/assets/half_frame.py b/negpy/services/assets/half_frame.py index 28ac7b9aa..276b30106 100644 --- a/negpy/services/assets/half_frame.py +++ b/negpy/services/assets/half_frame.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence +import negpy_core import numpy as np from negpy.kernel.system.logging import get_logger @@ -363,6 +364,9 @@ def diptych_configs(repo: Any, file_hash: Optional[str]) -> Optional[tuple[Any, def _luma(buf: np.ndarray) -> np.ndarray: a = np.asarray(buf) + if a.ndim == 3 and a.shape[2] == 3 and a.dtype in (np.uint8, np.uint16, np.float32): + # Integer channel sums are exact in float32, so the float32 mean matches NumPy's. + return negpy_core.mean_channels(np.ascontiguousarray(a, dtype=np.float32)) if a.ndim == 3: a = a.mean(axis=2) return a.astype(np.float32, copy=False) @@ -403,8 +407,8 @@ def _gutter_scan(a: np.ndarray) -> tuple[float, float, float]: peak_val = float(a.max()) if peak_val <= 0: return 0.5, 0.0, 0.0 - sub = a[:: max(1, h // 512)] / peak_val - col = sub.mean(axis=0) + step = max(1, h // 512) + col = negpy_core.column_means(np.ascontiguousarray(a, dtype=np.float32), step, peak_val) k = max(3, w // 150) sm = np.convolve(col, np.ones(k, np.float32) / k, mode="same") win = max(9, (w // 8) | 1) @@ -435,7 +439,7 @@ def _gutter_scan(a: np.ndarray) -> tuple[float, float, float]: if min(abs(d1), abs(d2)) < 0.04 or d1 * d2 <= 0: return 0.5, 0.0, 0.0 # Unexposed film is uniform top to bottom; a bright/dark in-scene feature isn't. - if float(sub[:, center].std()) > 0.10: + if float((a[::step, center] / peak_val).std()) > 0.10: return 0.5, 0.0, 0.0 thickness = min(_MAX_GUTTER_THICKNESS, max(0.0, (right_edge - left_edge) / w)) return float(center_f / w), float(thickness), min(abs(d1), abs(d2)) diff --git a/negpy/services/assets/thumbnails.py b/negpy/services/assets/thumbnails.py index 6f4becd13..b0e8f05f0 100644 --- a/negpy/services/assets/thumbnails.py +++ b/negpy/services/assets/thumbnails.py @@ -3,8 +3,9 @@ from PIL import Image import rawpy from negpy.kernel.system.config import APP_CONFIG +import negpy_core import numpy as np -from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, float_to_uint8, prepare_thumbnail, srgb_to_linear, uint8_to_float32 +from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, prepare_thumbnail, srgb_to_linear, uint8_to_float32 from negpy.infrastructure.capture.raw_demosaic import _user_sat from negpy.infrastructure.loaders.factory import loader_factory from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper, embedded_preview @@ -173,20 +174,9 @@ def preview_positive(img: Image.Image, process_mode: str = "") -> Image.Image: return img # A lone narrowband exposure carries its picture in one channel, and the other two hold - # only near-black noise that the log below would stretch into a solid color cast. - # Borrowing the channel that has the signal renders it as the monochrome it is. - peaks = np.percentile(linear, 99.0, axis=(0, 1)) - strongest = int(np.argmax(peaks)) - empty = peaks < peaks[strongest] * 0.05 - if empty.any(): - linear = linear.copy() - linear[:, :, empty] = linear[:, :, strongest : strongest + 1] - - density = -np.log10(np.clip(linear, 1e-4, None)) - lo = np.percentile(density, 1.0, axis=(0, 1)) - hi = np.percentile(density, 99.0, axis=(0, 1)) - positive = (density - lo) / np.maximum(hi - lo, 1e-6) - return Image.fromarray(float_to_uint8(np.clip(positive, 0.0, 1.0))) + # only near-black noise that the log would stretch into a solid color cast; negpy_core + # borrows the channel that has the signal, so it renders as the monochrome it is. + return Image.fromarray(negpy_core.log_stretch_u8(np.ascontiguousarray(linear, dtype=np.float32))) def get_thumbnail_worker( diff --git a/negpy/services/capture/meter.py b/negpy/services/capture/meter.py index a1ce3797e..803cf40f6 100644 --- a/negpy/services/capture/meter.py +++ b/negpy/services/capture/meter.py @@ -65,6 +65,11 @@ class MeterReading: ring_saturated: bool = False +def gray_of(rgb: np.ndarray) -> np.ndarray: + """`rgb.mean(axis=2)` as float32 for an integer or float32 frame.""" + return negpy_core.mean_channels(np.ascontiguousarray(rgb[..., :3], dtype=np.float32)) + + def ranked_thumb(gray: np.ndarray) -> np.ndarray: """A picture at thumbnail size as zero-mean, unit-variance ranks: two thumbnails' mean product is their rank correlation, which no monotonic tone curve and no exposure change can move.""" @@ -84,19 +89,16 @@ def framing_mismatch(reading: MeterReading, preview: np.ndarray) -> str | None: vh, vw = preview.shape[:2] if abs((pw / ph) / (vw / vh) - 1.0) > ASPECT_TOLERANCE: return f"the preview is {vw}:{vh} and the RAW {pw}:{ph}" - if float(np.mean(ranked_thumb(preview.mean(axis=2)) * reading.thumb)) < SAME_FRAMING: + if float(np.mean(ranked_thumb(gray_of(preview)) * reading.thumb)) < SAME_FRAMING: return "the preview does not frame the RAW the same way" return None -def _peak_map(brightest: np.ndarray) -> np.ndarray: - """Max-pool to a long edge of PEAK_MAP_EDGE. The frame is zero-padded to whole blocks, so - every pixel lands in a block and the edge blocks keep their own clips.""" - h, w = brightest.shape - f = max(1, math.ceil(max(h, w) / PEAK_MAP_EDGE)) - padded = np.pad(brightest, ((0, -h % f), (0, -w % f))) - hh, ww = padded.shape - return np.ascontiguousarray(padded.reshape(hh // f, f, ww // f, f).max(axis=(1, 3))) +def _peak_map(linear: np.ndarray) -> np.ndarray: + """The brightest channel max-pooled to a long edge of PEAK_MAP_EDGE. The frame is zero-padded + to whole blocks, so every pixel lands in a block and the edge blocks keep their own clips.""" + h, w = linear.shape[:2] + return negpy_core.peak_map(np.ascontiguousarray(linear, dtype=np.float32), max(1, math.ceil(max(h, w) / PEAK_MAP_EDGE))) def raw_clip_mask(reading: MeterReading) -> np.ndarray | None: @@ -126,7 +128,7 @@ def meter_frame( holds after its gain, while the clip test reads the sensor itself.""" candidates = tuple(candidates) or SHUTTER_CANDIDATES gain = np.asarray(wb, np.float32) / min(wb) if wb else np.ones(3, np.float32) - linear = np.clip(img.astype(np.float32) / CLIP_CEILING, 0.0, 1.0) + linear = negpy_core.normalized(np.ascontiguousarray(img), float(CLIP_CEILING)) if medium is None: medium = "positive" if detect_process_mode(linear) == ProcessMode.E6 else "negative" det, scale = _normalize_detection_input(linear, AUTOCROP_DETECT_RES) @@ -137,17 +139,10 @@ def meter_frame( # The picture at the probe's own resolution: a highlight narrower than a detection pixel # is averaged away at detection size, and it still clips. ly1, ly2, lx1, lx2 = _scale_roi(gate, scale, linear.shape[0], linear.shape[1]) - picture = linear[ly1:ly2, lx1:lx2].reshape(-1, 3) - frame_brightest = np.max(linear, axis=2) - brightest = frame_brightest[ly1:ly2, lx1:lx2] - clipped_fraction = float(np.mean(brightest >= _SATURATION)) + clipped_fraction, blown, thin = negpy_core.meter_picture(linear, (ly1, ly2, lx1, lx2), _SATURATION, _BLOWN_SHARE) + thinnest = np.array(thin, dtype=np.float32) # the picture's thin end, per channel ring = det[rebate_mask(det.shape[:2], film_roi, gate)] - # Saturated pixels are light unless they reach _BLOWN_SHARE of the picture; then the picture is blown. - saturated = picture.max(axis=1) >= _SATURATION - blown = bool(saturated.mean() >= _BLOWN_SHARE) - film_pixels = picture if blown or saturated.all() else picture[~saturated] - thinnest = np.percentile(film_pixels, 99.9, axis=0) # the picture's thin end, per channel # Only unclipped ring pixels are film: a gap, a sprocket hole and a clipped base all saturate. film_ring = ring[ring.max(axis=1) < _SATURATION] if ring.size else ring ring_saturated = medium == "negative" and bool(ring.size) and film_ring.shape[0] < 0.5 * ring.shape[0] @@ -175,8 +170,8 @@ def meter_frame( # The step down passes the fastest rung, which is still a step down: take it and meter again. recommended = candidates[0] stops = float(np.log2(true_seconds(recommended, candidates) / probe_seconds)) if recommended else needed - peaks = _peak_map(frame_brightest) - thumb = ranked_thumb(np.mean(linear, axis=2)) + peaks = _peak_map(linear) + thumb = ranked_thumb(gray_of(linear)) shape = (int(linear.shape[0]), int(linear.shape[1])) return MeterReading( medium, region, measured, clipped, clipped_fraction, shutter, recommended, stops, peaks, thumb, shape, ring_saturated=ring_saturated diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 0a7f6ed99..277ba0f41 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -43,3 +43,4 @@ tolerance listed for each entry. | `infrastructure/scanners/sane_backend.py::_resample_rows_to_dpi` | `negpy_core.resize_rows_linear` copies OpenCV's own row-only bilinear resize: integer for uint8, float32 for uint16. With IPP, cv2 matches for uint8 and differs by one level on some uint16 values. | ulp=1 (levels) | | `desktop/workers/chart_fit.py::ChartFitWorker.__init__` | cv2 took the area scale from `fx` while it rounded the output size; negpy_core takes the scale from the rounded size, so the cells tile the frame exactly. | none (no recordings) | | `infrastructure/simulated/gphoto.py::preview_jpeg` | The simulated camera's preview JPEG is Pillow's encoder at quality 95, not libjpeg through cv2. | none | +| `kernel/image/logic.py::srgb_to_linear` (JPEG and 8-bit TIFF loads, placeholder thumbnails) | The 2.4 power is `mathf::pow_f64` rounded to float32, within an ULP of the correctly rounded value; NumPy's float32 power here runs SVML, up to 4 ULP off on most inputs. The placeholder thumbnail's log stretch (`thumbnails.preview_positive`) also takes `mathf::log10f`, so a few values land one uint8 level apart. | ulp=4; uint8 levels within 1 | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index 55b32cb84..eb7dfe587 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -1183,6 +1183,14 @@ compare = "ulp=1" calls = [] depends = ["_skin_chroma_rein_kernel"] +["kernel/image/logic.py::srgb_to_linear"] +phase = 3 +status = "ported" +compare = "ulp=4" +calls = ["negpy_core.srgb_to_linear"] +depends = [] +note = "negpy_core::color::srgb_to_linear" + ["kernel/image/logic.py::uint16_to_float32"] phase = 3 status = "ported" @@ -2470,15 +2478,17 @@ note = "per-frame tone list" ["desktop/view/sidebar/files.py::print_dmax"] phase = 7 -status = "todo" -calls = ["np.quantile"] +status = "ported" +calls = ["negpy_core.print_dmax"] depends = [] +note = "negpy_core.print_dmax" ["desktop/view/sidebar/scanlight.py::ScanlightSidebar._current_raw_clip"] phase = 7 -status = "todo" +status = "ported" calls = ["np.mean"] depends = [] +note = "negpy_core.mean_channels" ["desktop/view/sidebar/scanlight.py::_RawClip.at"] phase = 7 @@ -2531,9 +2541,10 @@ note = "negpy_core.resize_area" ["desktop/workers/export.py::_looks_border_crushed"] phase = 7 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.mean_channels", "negpy_core.ring_inner_medians"] depends = [] +note = "negpy_core mean_channels, ring_inner_medians" ["infrastructure/display/icc_lut.py::_apply_lut_f32_jit"] phase = 7 @@ -2576,15 +2587,24 @@ note = "calls negpy_core" ["services/assets/half_frame.py::_edge_band"] phase = 7 -status = "todo" +status = "stays" calls = ["np.median"] depends = [] +note = "1-D edge profile" ["services/assets/half_frame.py::_gutter_scan"] phase = 7 -status = "todo" -calls = ["np.median"] +status = "ported" +calls = ["negpy_core.column_means", "np.median"] depends = [] +note = "negpy_core.column_means" + +["services/assets/half_frame.py::_luma"] +phase = 7 +status = "ported" +calls = ["negpy_core.mean_channels"] +depends = [] +note = "negpy_core.mean_channels" ["services/assets/semantic_model.py::rank_by_similarity"] phase = 7 @@ -2595,9 +2615,11 @@ note = "embedding vectors" ["services/assets/thumbnails.py::preview_positive"] phase = 7 -status = "todo" -calls = ["np.percentile"] +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.log_stretch_u8"] depends = [] +note = "negpy_core srgb_to_linear, log_stretch_u8" ["services/capture/calibration.py::_ladder_stops"] phase = 7 @@ -2627,23 +2649,40 @@ calls = ["negpy_core.sharpness"] depends = [] note = "negpy_core.sharpness" +["services/capture/meter.py::_peak_map"] +phase = 7 +status = "ported" +calls = ["negpy_core.peak_map"] +depends = [] +note = "negpy_core.peak_map" + ["services/capture/meter.py::framing_mismatch"] phase = 7 -status = "todo" +status = "ported" calls = ["np.mean"] -depends = ["ranked_thumb"] +depends = ["gray_of", "ranked_thumb"] +note = "negpy_core.mean_channels" + +["services/capture/meter.py::gray_of"] +phase = 7 +status = "ported" +calls = ["negpy_core.mean_channels"] +depends = [] +note = "negpy_core.mean_channels" ["services/capture/meter.py::meter_frame"] phase = 7 -status = "todo" -calls = ["np.mean", "np.median", "np.percentile"] -depends = ["ranked_thumb"] +status = "ported" +calls = ["negpy_core.meter_picture", "negpy_core.normalized", "np.median"] +depends = ["_peak_map", "gray_of", "ranked_thumb"] +note = "negpy_core normalized, meter_picture, peak_map" ["services/capture/meter.py::ranked_thumb"] phase = 7 -status = "todo" +status = "stays" calls = ["negpy_core.resize_area", "np.argsort"] depends = [] +note = "2752-value rank on a Rust resize" ["services/export/contact_sheet.py::_place_tile"] phase = 7 @@ -2726,17 +2765,12 @@ calls = ["negpy_core.register_pair"] depends = [] note = "negpy_core::register::register_pair, SIFT in negpy_core::sift" -["infrastructure/capture/raw_demosaic.py::_plateau_clip_fraction"] -phase = 9 -status = "todo" -calls = ["np.bincount"] -depends = [] - ["infrastructure/capture/raw_demosaic.py::raw_channel_clip_fraction"] phase = 9 -status = "todo" -calls = ["np.mean"] -depends = ["_plateau_clip_fraction"] +status = "ported" +calls = ["negpy_core.raw_clip_fractions"] +depends = [] +note = "negpy_core.raw_clip_fractions" ["infrastructure/scanners/sane_backend.py::_align_ir_to_rgb"] phase = 9 diff --git a/negpy_core/src/capture.rs b/negpy_core/src/capture.rs index 0e1f52f69..680bcd9a2 100644 --- a/negpy_core/src/capture.rs +++ b/negpy_core/src/capture.rs @@ -3,7 +3,7 @@ use rayon::prelude::*; -use crate::stats::{np_percentiles, pairwise_sum_f32}; +use crate::stats::{np_median, np_percentiles, np_quantile, pairwise_sum_f32}; /// How `stretch_preview` maps a channel between its 1st and 99th percentiles. #[derive(Clone, Copy, PartialEq, Eq)] @@ -100,6 +100,242 @@ pub fn to_grey + TryFrom>( }); } +/// `np.clip(img.astype(np.float32) / divisor, 0, 1)` in float32. +pub fn normalized>(img: &[T], divisor: f32, dst: &mut [f32]) { + crate::par_zip(dst, img, move |d, &v| { + // NaN stays, as np.clip keeps it. + let x: f64 = v.into(); + *d = (x as f32 / divisor).clamp(0.0, 1.0); + }); +} + +/// `meter_frame`'s picture statistics over rows y1..y2 and columns x1..x2 of an HxWx3 float32 +/// frame: the share whose brightest channel reaches `sat`, and the 99.9th percentile per channel +/// of the unsaturated pixels (of every pixel when at least `blown_share` saturate, or all do). +pub fn meter_picture( + img: &[f32], + w: usize, + (y1, y2, x1, x2): (usize, usize, usize, usize), + sat: f32, + blown_share: f64, +) -> (f64, bool, [f32; 3]) { + let picture: Vec<[f32; 3]> = (y1..y2) + .flat_map(|y| { + img[(y * w + x1) * 3..(y * w + x2) * 3] + .as_chunks::<3>() + .0 + .iter() + .copied() + }) + .collect(); + let saturated: Vec = picture + .iter() + .map(|p| p[0].max(p[1]).max(p[2]) >= sat) + .collect(); + let n = picture.len(); + let hits = saturated.iter().filter(|&&s| s).count(); + let clipped = hits as f64 / n as f64; + let blown = clipped >= blown_share; + let keep_all = blown || hits == n; + let thinnest = std::array::from_fn(|c| { + let v: Vec = picture + .iter() + .zip(&saturated) + .filter(|(_, s)| keep_all || !**s) + .map(|(p, _)| p[c]) + .collect(); + np_percentiles(&v, &[99.9]).map_or(f32::NAN, |p| p[0]) + }); + (clipped, blown, thinnest) +} + +/// `_peak_map(np.max(img, axis=2))`: the brightest channel of an HxWx3 float32 frame, max-pooled +/// in f x f blocks over the frame zero-padded to whole blocks. +pub fn peak_map(img: &[f32], h: usize, w: usize, f: usize) -> (Vec, usize, usize) { + let (ph, pw) = (h.div_ceil(f), w.div_ceil(f)); + let mut out = vec![0f32; ph * pw]; + out.par_chunks_mut(pw).enumerate().for_each(|(by, row)| { + for y in by * f..((by + 1) * f).min(h) { + for (x, p) in img[y * w * 3..(y + 1) * w * 3] + .as_chunks::<3>() + .0 + .iter() + .enumerate() + { + let o = &mut row[x / f]; + *o = o.max(p[0].max(p[1]).max(p[2])); + } + } + }); + (out, ph, pw) +} + +/// `raw_channel_clip_fraction`'s two shares of the photosites of one CFA colour: at or above +/// `threshold` (None to skip), and on the saturation plateau. A sensor can saturate below its +/// published white level, but its sites still pile up at the highest code it reaches: the densest +/// code in the top `window` counts, `ratio` times denser than each of the next codes, with a +/// few noisy sites allowed above it. `guard` (the white level, 0 for none) keeps a frame far +/// from saturation from reading its noise floor as a plateau. +pub fn raw_clip_fractions( + values: &[u16], + threshold: Option, + guard: u32, + (window, min_sites, neighbours, ratio): (usize, usize, usize, f64), +) -> (f64, f64) { + let n = values.len() as f64; + let linearity = threshold.map_or(0.0, |t| { + values.iter().filter(|&&v| v >= t).count() as f64 / n + }); + let top = values.iter().copied().max().unwrap_or(0) as u32; + if guard > 0 && top * 2 < guard { + return (linearity, 0.0); + } + let lo = top.saturating_sub(window as u32); + let mut counts = vec![0usize; (top - lo) as usize + 1]; + for &v in values { + if v as u32 >= lo { + counts[(v as u32 - lo) as usize] += 1; + } + } + // Only codes the file holds count: a lossy raw encoding stores a sparse set of codes. + let populated: Vec = (0..counts.len()).filter(|&i| counts[i] > 0).collect(); + let peak_at = populated.iter().enumerate().fold(0, |b, (i, &p)| { + if counts[p] > counts[populated[b]] { + i + } else { + b + } + }); + let peak = counts[populated[peak_at]]; + if peak < min_sites { + return (linearity, 0.0); + } + let density = populated[peak_at + 1..] + .iter() + .take(neighbours) + .map(|&p| counts[p]) + .max() + .unwrap_or(0); + if (peak as f64) < ratio * (density as f64).max(1.0) { + return (linearity, 0.0); + } + let anchor = lo + populated[peak_at] as u32; + ( + linearity, + values.iter().filter(|&&v| v as u32 >= anchor).count() as f64 / n, + ) +} + +/// `_gutter_scan`'s column profile: every `step`th row of an HxW float32 plane over `peak`, then +/// the float32 mean of each column, rows added in order as NumPy reduces axis 0. +pub fn column_means(a: &[f32], w: usize, step: usize, peak: f32) -> Vec { + let rows: Vec<&[f32]> = a.chunks_exact(w).step_by(step.max(1)).collect(); + let mut sum = vec![0f32; w]; + for r in &rows { + for (s, &v) in sum.iter_mut().zip(*r) { + *s += v / peak; + } + } + sum.iter().map(|&s| s / rows.len() as f32).collect() +} + +/// `_looks_border_crushed`'s two medians of an HxW float32 sample: of the border ring `m` wide +/// and of the central half. +pub fn ring_inner_medians(arr: &[f32], h: usize, w: usize, m: usize) -> (f32, f32) { + let at = |y: usize| &arr[y * w..][..w]; + let mut ring: Vec = Vec::new(); + for y in (0..m).chain(h - m..h) { + ring.extend_from_slice(at(y)); + } + for y in 0..h { + ring.extend_from_slice(&at(y)[..m]); + ring.extend_from_slice(&at(y)[w - m..]); + } + let mut inner: Vec = (h / 4..3 * h / 4) + .flat_map(|y| at(y)[w / 4..3 * w / 4].iter().copied()) + .collect(); + (np_median(&mut ring), np_median(&mut inner)) +} + +/// `print_dmax`: the float32 mean colour, each channel rounded half to even, of the pixels whose +/// Rec. 709 luma (OpenBLAS's sgemv order) is at most its `share` quantile. None for no pixels. +pub fn print_dmax(px: &[[u8; 3]], share: f64) -> Option<[i64; 3]> { + if px.is_empty() { + return None; + } + let (wr, wg, wb) = (0.2126f32, 0.7152f32, 0.0722f32); + let luma: Vec = px + .iter() + .map(|p| { + let p = p.map(f32::from); + p[2].mul_add(wb, p[0].mul_add(wr, p[1] * wg)) + }) + .collect(); + let cut = np_quantile(&luma, share)?; + let mut sum = [0f32; 3]; + let mut k = 0usize; + for (p, &l) in px.iter().zip(&luma) { + if l <= cut { + for c in 0..3 { + sum[c] += p[c] as f32; + } + k += 1; + } + } + Some(sum.map(|s| ((s / k as f32) as f64).round_ties_even() as i64)) +} + +/// `preview_positive`'s stretch of a linear HxWx3 float32 thumbnail: a channel whose 99th +/// percentile is under 5 % of the strongest borrows the strongest; then the density -log10 of +/// the value clipped at 1e-4, stretched between its 1st and 99th percentiles per channel and +/// quantized as `float_to_uint8`. +pub fn log_stretch_u8(linear: &[f32], dst: &mut [u8]) { + let ch = |c: usize| { + linear + .iter() + .skip(c) + .step_by(3) + .copied() + .collect::>() + }; + let peaks: Vec = (0..3) + .map(|c| np_percentiles(&ch(c), &[99.0]).map_or(f32::NAN, |p| p[0])) + .collect(); + let strongest = (0..3).fold(0, |b, c| if peaks[c] > peaks[b] { c } else { b }); + let source: [usize; 3] = std::array::from_fn(|c| { + if peaks[c] < peaks[strongest] * 0.05 { + strongest + } else { + c + } + }); + let mut density = vec![0f32; linear.len()]; + crate::par_zip( + density.as_chunks_mut::<3>().0, + linear.as_chunks::<3>().0, + move |d, p| { + // max drops NaN where np.clip keeps it; a thumbnail decoded from uint8 holds none. + *d = source.map(|s| -crate::mathf::log10f(p[s].max(1e-4))); + }, + ); + let bounds: Vec<(f32, f32)> = (0..3) + .map(|c| { + let v: Vec = density.iter().skip(c).step_by(3).copied().collect(); + let p = np_percentiles(&v, &[1.0, 99.0]).unwrap_or(vec![0.0, 0.0]); + (p[0], (p[1] - p[0]).max(1e-6)) + }) + .collect(); + let mut positive = vec![0f32; linear.len()]; + crate::par_zip( + positive.as_chunks_mut::<3>().0, + density.as_chunks::<3>().0, + move |d, p| { + *d = std::array::from_fn(|c| ((p[c] - bounds[c].0) / bounds[c].1).clamp(0.0, 1.0)); + }, + ); + crate::convert::to_uint(&positive, dst); +} + #[cfg(test)] mod tests { use super::*; diff --git a/negpy_core/src/color.rs b/negpy_core/src/color.rs index a4229145d..596fa9544 100644 --- a/negpy_core/src/color.rs +++ b/negpy_core/src/color.rs @@ -48,6 +48,27 @@ fn decode_block(src: &[f32], dst: &mut [f32], y: f64) { } } +/// `srgb_to_linear`: v / 12.92 up to 0.04045, else ((v + 0.055) / 1.055)^2.4, the power by +/// `mathf::pow_f64` rounded to float32. +pub fn srgb_to_linear(src: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| srgb_block(s, d)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn srgb_block(src: &[f32], dst: &mut [f32]) { + for (d, &v) in dst.iter_mut().zip(src) { + let curve = pow_f64(((v + 0.055) / 1.055) as f64, 2.4) as f32; + // np.where picks the linear branch for v <= 0.04045, NaN included in neither. + *d = if v <= 0.04045 { v / 12.92 } else { curve }; + } +} + /// The constants of the Lab kernels, as float64. #[derive(Clone, Copy)] pub struct LabSpace { diff --git a/negpy_core/src/py/capture.rs b/negpy_core/src/py/capture.rs index 8aa15fd03..158b1a417 100644 --- a/negpy_core/src/py/capture.rs +++ b/negpy_core/src/py/capture.rs @@ -169,7 +169,211 @@ fn to_grey<'py>(py: Python<'py>, rgb: &Bound<'py, PyAny>) -> PyResult( + py: Python<'py>, + img: &Bound<'py, PyAny>, + divisor: f32, +) -> PyResult>> { + fn run<'py, T: numpy::Element + Copy + Send + Sync + Into>( + py: Python<'py>, + img: &Bound<'py, PyAny>, + divisor: f32, + ) -> PyResult>> { + let a: PyReadonlyArrayDyn<'py, T> = img.extract()?; + let src = contiguous(&a)?; + let out = PyArrayDyn::::zeros(py, a.shape().to_vec(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || capture::normalized(src, divisor, dst)); + } + Ok(out) + } + if img.extract::>().is_ok() { + run::(py, img, divisor) + } else if img.extract::>().is_ok() { + run::(py, img, divisor) + } else if img.extract::>().is_ok() { + run::(py, img, divisor) + } else if img.extract::>().is_ok() { + run::(py, img, divisor) + } else { + Err(PyTypeError::new_err( + "normalized needs uint8, uint16, float32 or float64", + )) + } +} + +/// `meter_frame`'s picture statistics of an HxWx3 float32 frame over (y1, y2, x1, x2): +/// (clipped fraction, blown, 99.9th percentile per channel of the film pixels). +#[pyfunction] +fn meter_picture( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + roi: (usize, usize, usize, usize), + sat: f32, + blown_share: f64, +) -> PyResult<(f64, bool, [f32; 3])> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("meter_picture needs an HxWx3 array")); + }; + let (y1, y2, x1, x2) = roi; + if y1 >= y2 || x1 >= x2 || y2 > h || x2 > w { + return Err(PyValueError::new_err("roi outside the frame or empty")); + } + let src = contiguous(&img)?; + Ok(super::detach(py, (y2 - y1) * (x2 - x1) * 3, || { + capture::meter_picture(src, w, roi, sat, blown_share) + })) +} + +/// `raw_channel_clip_fraction` of a uint16 ROI and its uint8 CFA colours, keeping the colours in +/// `wanted`: (linearity fraction or 0 without a threshold, plateau fraction); None for no sites. +#[pyfunction] +#[pyo3(signature = (img, colors, wanted, threshold, guard, window, min_sites, neighbours, ratio))] +#[allow(clippy::too_many_arguments)] +fn raw_clip_fractions( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, u16>, + colors: PyReadonlyArrayDyn<'_, u8>, + wanted: Vec, + threshold: Option, + guard: u32, + window: usize, + min_sites: usize, + neighbours: usize, + ratio: f64, +) -> PyResult> { + if img.shape() != colors.shape() { + return Err(PyValueError::new_err("image and colours differ in shape")); + } + let (v, c) = (contiguous(&img)?, contiguous(&colors)?); + Ok(super::detach(py, v.len(), || { + let values: Vec = v + .iter() + .zip(c) + .filter(|(_, k)| wanted.contains(k)) + .map(|(&x, _)| x) + .collect(); + (!values.is_empty()).then(|| { + capture::raw_clip_fractions( + &values, + threshold, + guard, + (window, min_sites, neighbours, ratio), + ) + }) + })) +} + +/// `_gutter_scan`'s column profile of an HxW float32 plane: every `step`th row over `peak`, averaged per column. +#[pyfunction] +fn column_means<'py>( + py: Python<'py>, + a: PyReadonlyArrayDyn<'py, f32>, + step: usize, + peak: f32, +) -> PyResult>> { + let [_, w] = a.shape()[..] else { + return Err(PyValueError::new_err("column_means needs a 2-D array")); + }; + let src = contiguous(&a)?; + let out = super::detach(py, src.len() / step.max(1), || { + capture::column_means(src, w, step, peak) + }); + Ok(numpy::PyArray1::from_vec(py, out)) +} + +/// `_looks_border_crushed`'s medians of an HxW float32 sample: (ring `m` wide, central half). +#[pyfunction] +fn ring_inner_medians( + py: Python<'_>, + arr: PyReadonlyArrayDyn<'_, f32>, + m: usize, +) -> PyResult<(f32, f32)> { + let [h, w] = arr.shape()[..] else { + return Err(PyValueError::new_err( + "ring_inner_medians needs a 2-D array", + )); + }; + if 2 * m > h || 2 * m > w || h < 2 || w < 2 { + return Err(PyValueError::new_err("ring wider than the sample")); + } + let src = contiguous(&arr)?; + Ok(super::detach(py, src.len(), || { + capture::ring_inner_medians(src, h, w, m) + })) +} + +/// `print_dmax` of an HxWx3 uint8 thumbnail: its D-max colour, or None without pixels. +#[pyfunction] +fn print_dmax( + py: Python<'_>, + rgb: PyReadonlyArrayDyn<'_, u8>, + share: f64, +) -> PyResult> { + let src = contiguous(&rgb)?; + if src.len() % 3 != 0 { + return Err(PyValueError::new_err("print_dmax needs RGB pixels")); + } + let px = src.as_chunks::<3>().0; + Ok(super::detach(py, src.len(), || capture::print_dmax(px, share)).map(|c| (c[0], c[1], c[2]))) +} + +/// `_peak_map(np.max(img, axis=2))` of an HxWx3 float32 frame with blocks of f x f. +#[pyfunction] +fn peak_map<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + f: usize, +) -> PyResult> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("peak_map needs an HxWx3 array")); + }; + let src = contiguous(&img)?; + let (out, ph, pw) = super::detach(py, src.len(), || capture::peak_map(src, h, w, f.max(1))); + Ok(numpy::PyArray1::from_vec(py, out) + .reshape([ph, pw])? + .into_any()) +} + +/// `preview_positive`'s log stretch of a linear HxWx3 float32 thumbnail, as uint8. +#[pyfunction] +fn log_stretch_u8<'py>( + py: Python<'py>, + linear: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let [h, w, 3] = linear.shape()[..] else { + return Err(PyValueError::new_err("log_stretch_u8 needs an HxWx3 array")); + }; + let src = contiguous(&linear)?; + if src.is_empty() { + return Err(PyValueError::new_err("log_stretch_u8 needs pixels")); + } + let out = PyArrayDyn::::zeros(py, vec![h, w, 3], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || capture::log_stretch_u8(src, dst)); + } + Ok(out) +} + pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(log_stretch_u8, m)?)?; + m.add_function(wrap_pyfunction!(peak_map, m)?)?; + m.add_function(wrap_pyfunction!(normalized, m)?)?; + m.add_function(wrap_pyfunction!(meter_picture, m)?)?; + m.add_function(wrap_pyfunction!(raw_clip_fractions, m)?)?; + m.add_function(wrap_pyfunction!(column_means, m)?)?; + m.add_function(wrap_pyfunction!(ring_inner_medians, m)?)?; + m.add_function(wrap_pyfunction!(print_dmax, m)?)?; m.add_function(wrap_pyfunction!(stretch_preview, m)?)?; m.add_function(wrap_pyfunction!(sharpness, m)?)?; m.add_function(wrap_pyfunction!(any_channel_at_least, m)?)?; diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs index 77f118eee..9455309e8 100644 --- a/negpy_core/src/py/color.rs +++ b/negpy_core/src/py/color.rs @@ -46,6 +46,15 @@ fn oetf_decode<'py>( filled(py, &img, |s, d| color::oetf_decode(s, d, gamma)) } +/// `srgb_to_linear` of a float32 array, any shape. +#[pyfunction] +fn srgb_to_linear<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + filled(py, &img, color::srgb_to_linear) +} + pub(crate) fn space( m: &PyReadonlyArray2<'_, f32>, white: &PyReadonlyArray1<'_, f32>, @@ -165,5 +174,6 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(skin_chroma_rein, m)?)?; m.add_function(wrap_pyfunction!(skin_weight, m)?)?; m.add_function(wrap_pyfunction!(matmul_3x3, m)?)?; + m.add_function(wrap_pyfunction!(srgb_to_linear, m)?)?; m.add_function(wrap_pyfunction!(gamut_aware_chroma_scale, m)?) } diff --git a/tests/test_capture_meter.py b/tests/test_capture_meter.py index e08f60957..b9a6e6af1 100644 --- a/tests/test_capture_meter.py +++ b/tests/test_capture_meter.py @@ -196,11 +196,11 @@ def test_the_raw_clip_mask_predicts_the_metered_shutter(): def test_the_peak_map_keeps_the_edge_blocks(): from negpy.services.capture.meter import _peak_map - brightest = np.zeros((3001, 4003), np.float32) - brightest[-1, -1] = 1.0 # a clip in the last row and column, past a whole block - peaks = _peak_map(brightest) + frame = np.zeros((3001, 4003, 3), np.float32) + frame[-1, -1, 2] = 1.0 # a clip in the last row and column, past a whole block + peaks = _peak_map(frame) assert float(peaks[-1, -1]) == 1.0 - assert _peak_map(np.ones((2, 5000), np.float32)).shape[0] == 1 # a short edge under one block + assert _peak_map(np.ones((2, 5000, 3), np.float32)).shape[0] == 1 # a short edge under one block def _scene() -> np.ndarray: diff --git a/tests/test_core_capture.py b/tests/test_core_capture.py index 956e2ae95..5d17189c7 100644 --- a/tests/test_core_capture.py +++ b/tests/test_core_capture.py @@ -82,3 +82,101 @@ def test_level_stats_of_float64_match_numpy(): patch = np.random.default_rng(7).uniform(0, 65535, (37, 29)) frac, p = negpy_core.patch_level_stats(patch, 60000.0, 99.9) assert (frac, p) == (float(np.mean(patch >= 60000.0)), float(np.percentile(patch, 99.9))) + + +def test_meter_picture_matches_numpy(): + rng = np.random.default_rng(8) + linear = np.clip(rng.uniform(0.2, 1.05, (90, 120, 3)).astype(np.float32), 0, 1) + sat, roi = np.float32(0.998), (10, 80, 15, 110) + picture = linear[10:80, 15:110].reshape(-1, 3) + saturated = picture.max(axis=1) >= sat + film = picture if saturated.mean() >= 0.02 or saturated.all() else picture[~saturated] + clipped, blown, thin = negpy_core.meter_picture(linear, roi, float(sat), 0.02) + assert clipped == float(np.mean(saturated)) and blown == bool(saturated.mean() >= 0.02) + np.testing.assert_array_equal(np.array(thin, np.float32), np.percentile(film, 99.9, axis=0)) + + +def _plateau_clip_fraction(values, guard, R): + top = int(values.max()) + if guard > 0 and top * 2 < guard: + return 0.0 + lo = max(0, top - R._PLATEAU_WINDOW) + counts = np.bincount(values[values >= lo] - lo, minlength=R._PLATEAU_WINDOW + 1) + populated = np.flatnonzero(counts) + peak_at = int(np.argmax(counts[populated])) + peak_bin = int(populated[peak_at]) + peak = int(counts[peak_bin]) + if peak < R._MIN_PLATEAU_SITES: + return 0.0 + neighbors = counts[populated[peak_at + 1 : peak_at + 1 + R._PLATEAU_NEIGHBOR_BINS]] + density = float(neighbors.max()) if neighbors.size else 0.0 + if peak < R._PLATEAU_DENSITY_RATIO * max(density, 1.0): + return 0.0 + return float(np.count_nonzero(values >= lo + peak_bin)) / values.size + + +@pytest.mark.parametrize("plateau", [None, 15500, 15790]) +def test_raw_clip_fractions_match_numpy(plateau): + from negpy.infrastructure.capture import raw_demosaic as R + + rng = np.random.default_rng(9) + img = rng.integers(14000, 15800, (60, 80)).astype(np.uint16) + if plateau is not None: + img[rng.uniform(0, 1, img.shape) < 0.3] = plateau + colors = np.tile(np.array([[0, 1], [3, 2]], np.uint8), (30, 40)) + values = img[np.isin(colors, [1, 3])] + want = (float(np.mean(values >= 15000)), _plateau_clip_fraction(values, 16383, R)) + got = negpy_core.raw_clip_fractions( + img, colors, [1, 3], 15000, 16383, R._PLATEAU_WINDOW, R._MIN_PLATEAU_SITES, R._PLATEAU_NEIGHBOR_BINS, R._PLATEAU_DENSITY_RATIO + ) + assert got == want + + +def test_column_means_ring_medians_and_dmax_match_numpy(): + rng = np.random.default_rng(10) + a = rng.uniform(0, 1, (700, 300)).astype(np.float32) + peak = float(a.max()) + np.testing.assert_array_equal(negpy_core.column_means(a, 2, peak), (a[::2] / peak).mean(axis=0)) + arr = rng.uniform(0, 0.2, (256, 190)).astype(np.float32) + m, (h, w) = 10, arr.shape + ring = np.concatenate([arr[:m].ravel(), arr[-m:].ravel(), arr[:, :m].ravel(), arr[:, -m:].ravel()]) + inner = arr[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4] + assert negpy_core.ring_inner_medians(arr, m) == (np.median(ring), np.median(inner)) + px = rng.integers(0, 256, (80, 60, 3)).astype(np.uint8) + pixels = px.reshape(-1, 3).astype(np.float32) + luma = pixels @ np.array([0.2126, 0.7152, 0.0722], np.float32) + darkest = pixels[luma <= np.quantile(luma, 0.01)] + assert negpy_core.print_dmax(px, 0.01) == tuple(int(round(v)) for v in darkest.mean(axis=0)) + + +def _srgb_numpy(img): + return np.where(img <= 0.04045, img / 12.92, ((img + 0.055) / 1.055) ** 2.4).astype(np.float32) + + +def test_srgb_to_linear_stays_within_an_ulp(): + from negpy.kernel.image.logic import srgb_to_linear + + img = np.linspace(0, 1, 70001, dtype=np.float32) + base = ((img + np.float32(0.055)) / np.float32(1.055)).astype(np.float64) + exact = np.where(img <= 0.04045, img / np.float32(12.92), (base**2.4).astype(np.float32)).astype(np.float32) + got = srgb_to_linear(img) + assert np.max(np.abs(got.view(np.int32) - exact.view(np.int32))) <= 1 + assert np.max(np.abs(got.view(np.int32) - _srgb_numpy(img).view(np.int32))) <= 4 + + +def test_thumbnail_log_stretch_matches_numpy_within_a_level(): + rng = np.random.default_rng(12) + linear = _srgb_numpy((rng.integers(0, 256, (90, 130, 3)) / 255).astype(np.float32)) + linear[..., 2] *= 0.01 # a near-empty channel borrows the strongest + peaks = np.percentile(linear, 99.0, axis=(0, 1)) + strongest = int(np.argmax(peaks)) + empty = peaks < peaks[strongest] * 0.05 + work = linear.copy() + work[:, :, empty] = work[:, :, strongest : strongest + 1] + density = -np.log10(np.clip(work, 1e-4, None)) + lo, hi = np.percentile(density, 1.0, axis=(0, 1)), np.percentile(density, 99.0, axis=(0, 1)) + positive = np.clip((density - lo) / np.maximum(hi - lo, 1e-6), 0.0, 1.0) + want = (positive.astype(np.float64) * 255 + 0.5).astype(np.uint8) + got = negpy_core.log_stretch_u8(linear) + assert np.max(np.abs(got.astype(int) - want)) <= 1 + assert np.mean(got != want) < 0.01 From 98ddd340f465372ce25c36ab153ae9e08cfa76c4 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 21:21:56 +0200 Subject: [PATCH 122/133] build: opencv leaves the core dependencies; bump bounds and render versions negpy imports no cv2, so opencv-python-headless moves to the dev group (the oracle for the negpy_core tests and the replay tool) and the plustek extra and group: pyopticfilm aligns multi-pass and IR scans with cv2 and skips the alignment without it. Every module imports with cv2 blocked. BOUNDS_VERSION 1 -> 2 and THUMBNAIL_RENDER_VERSION 16 -> 17, the one bump for the whole negpy_core ledger. --- negpy/features/exposure/normalization.py | 2 +- negpy/services/assets/thumbnail_fingerprint.py | 2 +- pyproject.toml | 7 +++++-- tests/test_bounds_migration.py | 2 +- tests/thumbnail_fingerprint_defaults.json | 2 +- uv.lock | 12 +++++++++--- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 6fe838487..8c585f8d1 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -14,7 +14,7 @@ # Bump in any change that alters the bounds a frame measures for unchanged settings. Saved # per-frame bounds from an older version are dropped at startup (migrations/bounds.py). -BOUNDS_VERSION = 1 +BOUNDS_VERSION = 2 # Contrast-mask spacer, per-cent of the analysis grid's short side. Of the grid, not of # the render, so preview and export mask alike. Under the minimum the mask stops being diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index 31bb0dbad..e2566ab18 100644 --- a/negpy/services/assets/thumbnail_fingerprint.py +++ b/negpy/services/assets/thumbnail_fingerprint.py @@ -12,7 +12,7 @@ from negpy.domain.models import WorkspaceConfig # Bump in any change that alters rendered pixels for unchanged settings, a changed default included. -THUMBNAIL_RENDER_VERSION = 16 +THUMBNAIL_RENDER_VERSION = 17 # Marks a thumbnail made from the source preview, which runs none of the frame's settings. QUICK = "quick" diff --git a/pyproject.toml b/pyproject.toml index 839963fe0..3d8e0b089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ # Pinned below its latest release: 1.24.0+ dropped macOS x86_64 wheels entirely, # which breaks install on an Intel Mac. 1.23.2 is the last version with one. "onnxruntime==1.23.2", - "opencv-python-headless==4.13.0.92", "pillow==12.2.0", "pyqt6==6.11.0", "pyqt6-charts==6.11.0", @@ -44,12 +43,15 @@ classifiers = [ [project.optional-dependencies] nkscan = ["nkscan>=0.14.2"] -plustek = ["pyopticfilm>=1.3.7"] +# pyopticfilm aligns multi-pass and IR scans with cv2, and silently skips the alignment without it. +plustek = ["pyopticfilm>=1.3.7", "opencv-python-headless==4.13.0.92"] sane = ["python-sane>=2.9"] camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"] [dependency-groups] dev = [ + # The oracle the negpy_core tests and the replay tool compare against; the app needs none. + "opencv-python-headless==4.13.0.92", "pyinstaller>=6.18.0", "pytest==9.0.2", "pytest-cov>=7.0", @@ -61,6 +63,7 @@ sane = [ ] plustek = [ "pyopticfilm>=1.3.7", + "opencv-python-headless==4.13.0.92", ] camera = [ # Tethered camera scanning. libgphoto2 has no Windows build, so the wheels — and the diff --git a/tests/test_bounds_migration.py b/tests/test_bounds_migration.py index 1aa7327f2..c7341e6e7 100644 --- a/tests/test_bounds_migration.py +++ b/tests/test_bounds_migration.py @@ -15,7 +15,7 @@ # Bounds the analysis measures on _synthetic_negative() at each (luma_range_clip, color_range_clip), # under BOUNDS_VERSION. A change to them needs a bump; then record the new values here. -_RECORDED_VERSION = 1 +_RECORDED_VERSION = 2 _RECORDED = { (0.0, 1.0): ([-1.135058, -1.168505, -1.038996], [-0.247402, -0.451715, -0.607022]), (-0.3, 0.5): ([-1.512205, -1.48816, -1.244211], [0.135708, -0.13491, -0.408953]), diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index f7713be56..19dac495d 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -207,5 +207,5 @@ "vanadium_strength": "0.0" } }, - "render_version": 16 + "render_version": 17 } diff --git a/uv.lock b/uv.lock index aa150fc88..223ff1306 100644 --- a/uv.lock +++ b/uv.lock @@ -354,7 +354,6 @@ dependencies = [ { name = "negpy-core" }, { name = "numpy" }, { name = "onnxruntime" }, - { name = "opencv-python-headless" }, { name = "piexif" }, { name = "pillow" }, { name = "pyqt6" }, @@ -374,6 +373,7 @@ nkscan = [ { name = "nkscan" }, ] plustek = [ + { name = "opencv-python-headless" }, { name = "pyopticfilm" }, ] sane = [ @@ -385,6 +385,7 @@ camera = [ { name = "gphoto2", marker = "sys_platform != 'win32'" }, ] dev = [ + { name = "opencv-python-headless" }, { name = "pyinstaller" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -398,6 +399,7 @@ pieusb = [ { name = "pieusb" }, ] plustek = [ + { name = "opencv-python-headless" }, { name = "pyopticfilm" }, ] sane = [ @@ -414,7 +416,7 @@ requires-dist = [ { name = "nkscan", marker = "extra == 'nkscan'", specifier = ">=0.14.2" }, { name = "numpy", specifier = "==2.4.4" }, { name = "onnxruntime", specifier = "==1.23.2" }, - { name = "opencv-python-headless", specifier = "==4.13.0.92" }, + { name = "opencv-python-headless", marker = "extra == 'plustek'", specifier = "==4.13.0.92" }, { name = "piexif", specifier = "==1.1.3" }, { name = "pillow", specifier = "==12.2.0" }, { name = "pyopticfilm", marker = "extra == 'plustek'", specifier = ">=1.3.7" }, @@ -432,6 +434,7 @@ provides-extras = ["nkscan", "plustek", "sane", "camera"] [package.metadata.requires-dev] camera = [{ name = "gphoto2", marker = "sys_platform != 'win32'", specifier = ">=2.5" }] dev = [ + { name = "opencv-python-headless", specifier = "==4.13.0.92" }, { name = "pyinstaller", specifier = ">=6.18.0" }, { name = "pytest", specifier = "==9.0.2" }, { name = "pytest-cov", specifier = ">=7.0" }, @@ -440,7 +443,10 @@ dev = [ ] nkscan = [{ name = "nkscan", specifier = ">=0.15" }] pieusb = [{ name = "pieusb", specifier = ">=0.3.7" }] -plustek = [{ name = "pyopticfilm", specifier = ">=1.3.7" }] +plustek = [ + { name = "opencv-python-headless", specifier = "==4.13.0.92" }, + { name = "pyopticfilm", specifier = ">=1.3.7" }, +] sane = [{ name = "python-sane", specifier = ">=2.9" }] [[package]] From 43a8059e5fc9b1363e385df4afc079988ab49f00 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 21:22:11 +0200 Subject: [PATCH 123/133] test: guard that negpy imports no cv2 --- CLAUDE.md | 2 +- tests/test_no_cv2.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 tests/test_no_cv2.py diff --git a/CLAUDE.md b/CLAUDE.md index a7adf6931..799612136 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. -- `negpy_core` holds every CPU kernel; NegPy has no Numba. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). The app has no single-core mode. +- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is a dev dependency, the tests' oracle, and pyopticfilm's, whose pass alignment needs it. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). The app has no single-core mode. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. diff --git a/tests/test_no_cv2.py b/tests/test_no_cv2.py new file mode 100644 index 000000000..a8dbbde38 --- /dev/null +++ b/tests/test_no_cv2.py @@ -0,0 +1,12 @@ +"""negpy never imports cv2: negpy_core holds every kernel, and opencv is only the tests' oracle.""" + +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parents[1] / "negpy" + + +def test_negpy_imports_no_cv2(): + pattern = re.compile(r"^\s*(import cv2|from cv2\b)", re.MULTILINE) + offenders = [str(p.relative_to(ROOT)) for p in ROOT.rglob("*.py") if pattern.search(p.read_text(encoding="utf-8"))] + assert not offenders, f"cv2 imported in {offenders}; port the call to negpy_core" From aa161bed72827a7c276b6374bb12e6bad8721ad6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 21:26:56 +0200 Subject: [PATCH 124/133] build: opencv only for pyopticfilm opencv-python-headless leaves the dev group: it stays in the plustek extra and group, where pyopticfilm's pass alignment needs it. The oracle tests get it through the plustek group, which the native CI job now syncs; make install already syncs every group. --- .github/workflows/ci.yml | 3 ++- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- pyproject.toml | 2 -- tests/test_no_cv2.py | 2 +- uv.lock | 2 -- 6 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3811b6054..60a62051a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,8 @@ jobs: echo "CMAKE_ARGS=-DLLVM_DIR=${LLVM20}/lib/cmake/llvm -DCMAKE_PREFIX_PATH=${LLVM20}" >> "$GITHUB_ENV" - name: Install Dependencies - run: uv sync --group dev + # The plustek group brings opencv, the oracle the ported-function tests compare against. + run: uv sync --group dev --group plustek - name: Ported-function oracles run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py tests/test_core_altprocess.py tests/test_core_finish.py diff --git a/CLAUDE.md b/CLAUDE.md index 799612136..0bcda4d16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. -- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is a dev dependency, the tests' oracle, and pyopticfilm's, whose pass alignment needs it. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). The app has no single-core mode. +- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is only a dependency of the plustek extra and group, for pyopticfilm's pass alignment; the tests use it as their oracle through the plustek group. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). The app has no single-core mode. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 076ded443..d92164d9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ NegPy requires Python 3.13+, `uv` and a Rust toolchain. ### 1. Prerequisites Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and [rustup](https://rustup.rs/). `rustup` reads the toolchain version from `negpy_core/rust-toolchain.toml`. -The `negpy_core/` folder is a Rust library with NegPy's CPU kernels. The first `uv sync` compiles it. After you edit a Rust file, the next `uv run` or `make test` compiles it again. `negpy_core` builds no C or C++ code: `cargo-deny` (`negpy_core/deny.toml`) rejects a dependency that does. +The `negpy_core/` folder is a Rust library with NegPy's CPU kernels. The first `uv sync` compiles it. After you edit a Rust file, the next `uv run` or `make test` compiles it again. `negpy_core` builds no C or C++ code: `cargo-deny` (`negpy_core/deny.toml`) rejects a dependency that does. Its tests compare against opencv, which only the `plustek` group installs; `make install` (`uv sync --all-groups`) brings it. Scanner support (optional): diff --git a/pyproject.toml b/pyproject.toml index 3d8e0b089..f93ad1f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,8 +50,6 @@ camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"] [dependency-groups] dev = [ - # The oracle the negpy_core tests and the replay tool compare against; the app needs none. - "opencv-python-headless==4.13.0.92", "pyinstaller>=6.18.0", "pytest==9.0.2", "pytest-cov>=7.0", diff --git a/tests/test_no_cv2.py b/tests/test_no_cv2.py index a8dbbde38..058b8692e 100644 --- a/tests/test_no_cv2.py +++ b/tests/test_no_cv2.py @@ -1,4 +1,4 @@ -"""negpy never imports cv2: negpy_core holds every kernel, and opencv is only the tests' oracle.""" +"""negpy never imports cv2: negpy_core holds every kernel.""" import pathlib import re diff --git a/uv.lock b/uv.lock index 223ff1306..0fba01012 100644 --- a/uv.lock +++ b/uv.lock @@ -385,7 +385,6 @@ camera = [ { name = "gphoto2", marker = "sys_platform != 'win32'" }, ] dev = [ - { name = "opencv-python-headless" }, { name = "pyinstaller" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -434,7 +433,6 @@ provides-extras = ["nkscan", "plustek", "sane", "camera"] [package.metadata.requires-dev] camera = [{ name = "gphoto2", marker = "sys_platform != 'win32'", specifier = ">=2.5" }] dev = [ - { name = "opencv-python-headless", specifier = "==4.13.0.92" }, { name = "pyinstaller", specifier = ">=6.18.0" }, { name = "pytest", specifier = "==9.0.2" }, { name = "pytest-cov", specifier = ">=7.0" }, From bb15b306ffddb42f5ff231f4418f03e59656012c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 22:04:09 +0200 Subject: [PATCH 125/133] fix(core): review pass on the Rust port - get_luminance reads the first three channels of any HxWxC buffer, as the Numba kernel did - find_seams falls back to the average blend on a negpy_core ValueError, as it did on cv2.error - the border-crush check returns False for a buffer too small for its ring - print_dmax takes multiversion for its mul_add loop - percentiles() had no callers left; its tests call negpy_core.percentiles - rgb_to_rgba_into docstring states the constraint only Co-Authored-By: Claude --- negpy/desktop/workers/export.py | 2 ++ negpy/features/stitch/logic.py | 2 +- negpy/kernel/image/logic.py | 17 ++++------------- negpy_core/src/capture.rs | 2 ++ tests/test_percentiles.py | 12 ++++++++---- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/negpy/desktop/workers/export.py b/negpy/desktop/workers/export.py index de2110b24..067ce2e07 100644 --- a/negpy/desktop/workers/export.py +++ b/negpy/desktop/workers/export.py @@ -183,6 +183,8 @@ def _looks_border_crushed(buffer, task: "ExportTask") -> bool: arr = arr if arr.ndim == 2 else negpy_core.mean_channels(np.ascontiguousarray(arr[..., :3])) h, w = arr.shape[:2] m = max(2, int(0.04 * min(h, w))) + if 2 * m >= min(h, w): + return False ring, inner = negpy_core.ring_inner_medians(arr, m) return float(ring) < 0.02 and float(inner) < 0.12 diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 77610d3a2..c05ccc30e 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -226,7 +226,7 @@ def find_seams(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray], canvas claim = orphan & mask seam |= claim orphan &= ~claim - except MemoryError: + except (ValueError, MemoryError): return None return seams diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index 149f625a9..9411d0970 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -1,6 +1,6 @@ import hashlib import os -from typing import Any, Optional, Sequence +from typing import Any, Optional import negpy_core import numpy as np from negpy.domain.types import LUMA_R, LUMA_G, LUMA_B @@ -35,9 +35,8 @@ def uint8_to_float32(img: np.ndarray) -> np.ndarray: def rgb_to_rgba_into(src: np.ndarray, dst: np.ndarray) -> None: - """Copies the three colour planes of float32 ``src`` into the RGBA ``dst``; alpha is left - as is. numpy cannot vectorise a 3-of-4 strided copy, so a texture upload spent longer here - than on the GPU.""" + """Copies the three color planes of float32 ``src`` into the RGBA ``dst``; alpha is left + as is. numpy cannot vectorize a 3-of-4 strided copy.""" negpy_core.rgb_to_rgba_into(np.asarray(src, dtype=np.float32), dst) @@ -290,7 +289,7 @@ def get_luminance(img: np.ndarray) -> np.ndarray: Calculates relative luminance. Supports (H, W, 3) and (N, 3) arrays. """ if img.ndim == 3: - return negpy_core.luminance(np.ascontiguousarray(img, dtype=np.float32)) + return negpy_core.luminance(np.ascontiguousarray(img[..., :3], dtype=np.float32)) return LUMA_R * img[..., 0] + LUMA_G * img[..., 1] + LUMA_B * img[..., 2] @@ -378,11 +377,3 @@ def percentile_from_sorted(sorted_grid: np.ndarray, q: float) -> np.ndarray: a, b = sorted_grid[lo], sorted_grid[hi] diff = b - a return b - diff * (1 - t) if t >= 0.5 else a + diff * t - - -def percentiles(values: np.ndarray, qs: Sequence[float]) -> tuple: - """`np.percentile(values, q)` of a float32 array for each Python-number q, bit for bit.""" - if values.dtype != np.float32: - raise TypeError(f"percentiles needs float32, got {values.dtype}") - flat = np.ascontiguousarray(values).ravel() - return tuple(np.float32(v) for v in negpy_core.percentiles(flat, [float(q) for q in qs])) diff --git a/negpy_core/src/capture.rs b/negpy_core/src/capture.rs index 680bcd9a2..f071bc2df 100644 --- a/negpy_core/src/capture.rs +++ b/negpy_core/src/capture.rs @@ -1,6 +1,7 @@ //! Ports of the preview and capture metering in negpy/services/capture, negpy/services/scanning //! and the scan preview widgets. +use multiversion::multiversion; use rayon::prelude::*; use crate::stats::{np_median, np_percentiles, np_quantile, pairwise_sum_f32}; @@ -259,6 +260,7 @@ pub fn ring_inner_medians(arr: &[f32], h: usize, w: usize, m: usize) -> (f32, f3 /// `print_dmax`: the float32 mean colour, each channel rounded half to even, of the pixels whose /// Rec. 709 luma (OpenBLAS's sgemv order) is at most its `share` quantile. None for no pixels. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] pub fn print_dmax(px: &[[u8; 3]], share: f64) -> Option<[i64; 3]> { if px.is_empty() { return None; diff --git a/tests/test_percentiles.py b/tests/test_percentiles.py index 33c787e45..cc21102a0 100644 --- a/tests/test_percentiles.py +++ b/tests/test_percentiles.py @@ -1,7 +1,12 @@ import numpy as np import pytest -from negpy.kernel.image.logic import percentiles +import negpy_core + + +def percentiles(values, qs): + return negpy_core.percentiles(np.ascontiguousarray(values).ravel(), [float(q) for q in qs]) + _RNG = np.random.default_rng(3) _ARRAYS = { @@ -25,9 +30,8 @@ def test_percentiles_match_numpy_bit_for_bit(name, qs): got = percentiles(a, qs) for value, q in zip(got, qs): want = np.percentile(a, q) - assert value == want - assert type(value) is type(want) - assert value.dtype == want.dtype + assert np.float32(value) == want + assert want.dtype == np.float32 def test_percentiles_leave_the_input_unchanged(): From cf5df185087d6487bbc7c663f5b6ca81e27e223d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Fri, 23 Oct 2026 23:43:21 +0200 Subject: [PATCH 126/133] perf(core): cut repeated detection work and serial passes The film box was found twice per frame open (base-log meter, then auto crop), and the skew fit ran LSD twice on the same plane. A content-keyed memo (memo.rs: 128-bit key, LRU) now serves repeats of detect_film_bounds, refine_roi_to_image, the detection luma plane and coarse_angles. Other changes, all bit-exact (make replay: 102 functions same; the one PhotometricProcessor diff is also on HEAD): - detection kernels read the input directly instead of copying it - trim_film_edges copies only the lines the walk reads, sides in parallel - np_median takes the parallel histogram selection on large inputs - crop and other image-size collects allocate once - detect builds structural masks beside the threshold trials - LSD precomputes cos/sin and folds NOTDEF into `used` - mask_to_score runs in bands with a halo of the score's reach - neutral_axis maps and filters run in parallel Cache-fair bench, 8 frames, new vs mz/core HEAD: preview 1.45-1.91x, edit 1.81-2.63x, autocrop 1.73-2.58x, optical dust preview 1.23-1.47x, IR dust preview 1.21-1.24x, export 1.00-1.09x. No step slower. --- negpy_core/src/analysis.rs | 211 +++++++++++++++++++++++++----------- negpy_core/src/capture.rs | 20 ++-- negpy_core/src/detect.rs | 153 ++++++++++++++------------ negpy_core/src/distance.rs | 60 +++++++++- negpy_core/src/flatfield.rs | 5 +- negpy_core/src/lib.rs | 1 + negpy_core/src/lsd.rs | 27 ++++- negpy_core/src/memo.rs | 118 ++++++++++++++++++++ negpy_core/src/meter.rs | 13 ++- negpy_core/src/retouch.rs | 8 +- negpy_core/src/skew.rs | 16 +++ negpy_core/src/stats.rs | 7 +- negpy_core/src/zones.rs | 5 +- 13 files changed, 479 insertions(+), 165 deletions(-) create mode 100644 negpy_core/src/memo.rs diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 4aabec06c..5c01120ef 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -28,6 +28,37 @@ pub(crate) fn luminance_chunk(rgb: &[f32], dst: &mut [f32]) { } } +/// An HxW plane as HxWx3, or an HxWx3 image as it is. +pub fn rgb(img: &[f32], cn: usize) -> std::borrow::Cow<'_, [f32]> { + if cn == 1 { + img.iter() + .flat_map(|&v| [v, v, v]) + .collect::>() + .into() + } else { + img.into() + } +} + +/// `_detection_luma` of the luminance of an HxW or HxWx3 image whose `memo::key` is `key`; +/// None for an empty one. +pub fn detection_plane( + img: &[f32], + h: usize, + w: usize, + cn: usize, + key: u128, +) -> Option>> { + static LAST: crate::memo::Memo>>, 2> = crate::memo::Memo::new(); + LAST.get_or(key, || { + let mut lum = vec![0f32; h * w]; + luminance(&rgb(img, cn), &mut lum); + let mut det = vec![0f32; h * w]; + detection_luma(&lum, &mut det)?; + Some(det.into()) + }) +} + /// `_normalize_to_uint8` for a float32 image: the 1st and 99th percentiles of its finite /// values stretched to 0..255, NumPy's float32 arithmetic (NEP 50) and truncating cast. pub fn normalize_to_uint8(img: &[f32], dst: &mut [u8]) { @@ -278,10 +309,16 @@ pub fn base_log_from_blocks( base: &[[f32; 3]], ) -> Option<[f64; 3]> { let mean = |p: &[f32]| (p[0] + p[1] + p[2]) / 3.0; - let mut picture: Vec = (gy1..gy2) - .flat_map(|y| (gx1..gx2).map(move |x| (y * w + x) * 3)) - .map(|i| mean(&det[i..i + 3])) - .collect(); + let mut picture = Vec::with_capacity((gy2 - gy1) * (gx2 - gx1)); + for y in gy1..gy2 { + picture.extend( + det[(y * w + gx1) * 3..(y * w + gx2) * 3] + .as_chunks::<3>() + .0 + .iter() + .map(|p| mean(p)), + ); + } let mut base_means: Vec = base.iter().map(|p| mean(p)).collect(); // `<=` is false for NaN, so a NaN median keeps the base. if np_median(&mut base_means) <= np_median(&mut picture) { @@ -446,10 +483,16 @@ pub fn base_blocks(det: &[f32], band: &[u8], h: usize, w: usize) -> Option Vec { - (y1..y2) - .flat_map(|y| img[y * w + x1..y * w + x2].iter().copied()) - .collect() +pub(crate) fn crop( + img: &[f32], + w: usize, + (y1, y2, x1, x2): (usize, usize, usize, usize), +) -> Vec { + let mut out = Vec::with_capacity(y2.saturating_sub(y1) * x2.saturating_sub(x1)); + for y in y1..y2 { + out.extend_from_slice(&img[y * w + x1..y * w + x2]); + } + out } /// Python's round() of a float: half to even. @@ -776,18 +819,24 @@ enum Side { Right, } -/// `_EDGE_ORIENT[side](box)`, cropped to the core columns: lines run inward from the edge -/// (rows), one per position along it (columns). -fn edge_lines(boxed: &[f32], bh: usize, bw: usize, side: Side) -> (Vec, usize, usize) { +/// A film box inside an HxW plane: the plane's row stride, and the box's top, left, height +/// and width. +#[derive(Clone, Copy)] +struct BoxView<'a> { + lum: &'a [f32], + w: usize, + y1: usize, + x1: usize, + bh: usize, + bw: usize, +} + +/// `_EDGE_ORIENT[side](box)`, cropped to the core columns and to its first `keep` lines (rows), +/// each one position along the edge (columns); and the uncropped line count. +fn edge_lines(b: BoxView, side: Side, keep: usize) -> (Vec, usize, usize, usize) { let (rows, n) = match side { - Side::Top | Side::Bottom => (bh, bw), - Side::Left | Side::Right => (bw, bh), - }; - let at = |r: usize, c: usize| match side { - Side::Top => boxed[r * bw + c], - Side::Bottom => boxed[(bh - 1 - r) * bw + c], - Side::Left => boxed[c * bw + r], - Side::Right => boxed[c * bw + (bw - 1 - r)], + Side::Top | Side::Bottom => (b.bh, b.bw), + Side::Left | Side::Right => (b.bw, b.bh), }; let margin = py_round((1.0 - EDGE_CORE) * 0.5 * n as f64).max(0) as usize; let (c0, c1) = if n >= 2 * margin + 8 { @@ -795,32 +844,49 @@ fn edge_lines(boxed: &[f32], bh: usize, bw: usize, side: Side) -> (Vec, usi } else { (0, n) }; - let cols = c1 - c0; - let mut out = Vec::with_capacity(rows * cols); - for r in 0..rows { - for c in c0..c1 { - out.push(at(r, c)); + let (cols, kept) = (c1 - c0, keep.min(rows)); + let row = |y: usize| &b.lum[(b.y1 + y) * b.w + b.x1..][..b.bw]; + let mut out = vec![0f32; kept * cols]; + match side { + Side::Top | Side::Bottom => { + for (r, line) in out.chunks_exact_mut(cols).enumerate() { + let y = if matches!(side, Side::Top) { + r + } else { + b.bh - 1 - r + }; + line.copy_from_slice(&row(y)[c0..c1]); + } + } + // Read along the source rows; the box's columns are these lines. + Side::Left | Side::Right => { + for c in c0..c1 { + let src = row(c); + for r in 0..kept { + let x = if matches!(side, Side::Left) { + r + } else { + b.bw - 1 - r + }; + out[r * cols + c - c0] = src[x]; + } + } } } - (out, rows, cols) + (out, rows, kept, cols) } /// `_edge_border_depth(box, side, limit, deep_limit)`: the border depth on one side, per line /// (these frames are slightly tilted) and pooled. A first walk to `limit` is believed; a second /// to `deep_limit` is adopted only where it goes deeper and passes all four proofs. -fn edge_border_depth( - boxed: &[f32], - bh: usize, - bw: usize, - side: Side, - limit: i64, - deep_limit: i64, -) -> i64 { - let (lines, rows, cols) = edge_lines(boxed, bh, bw, side); +fn edge_border_depth(b: BoxView, side: Side, limit: i64, deep_limit: i64) -> i64 { + // The walks read no line past their limit plus the picture reference. + let keep = limit.max(deep_limit).max(0) as usize + EDGE_REBATE_REF; + let (lines, rows, kept, cols) = edge_lines(b, side, keep); if limit <= 0 || (rows as i64) < limit + 8 || cols < 8 { return 0; } - let Some(walk) = walk_border_depth(&lines, rows, cols, limit as usize, EDGE_BRIDGE, false) + let Some(walk) = walk_border_depth(&lines, kept, cols, limit as usize, EDGE_BRIDGE, false) else { return 0; }; @@ -830,7 +896,7 @@ fn edge_border_depth( } let Some(deep) = walk_border_depth( &lines, - rows, + kept, cols, deep_limit as usize, EDGE_DEEP_BRIDGE, @@ -874,17 +940,31 @@ pub fn measure_film_edges( if bh < 16 || bw < 16 { return [0.0; 4]; } - let boxed = crop(lum, w, (y1, y2, x1, x2)); + let view = BoxView { + lum, + w, + y1, + x1, + bh, + bw, + }; let ly = py_round(EDGE_TRIM_CAP * bh as f64); let lx = py_round(EDGE_TRIM_CAP * bw as f64); let dy = py_round(EDGE_DEEP_CAP * bh as f64); let dx = py_round(EDGE_DEEP_CAP * bw as f64); - [ - edge_border_depth(&boxed, bh, bw, Side::Top, ly, dy) as f64 / bh as f64, - edge_border_depth(&boxed, bh, bw, Side::Bottom, ly, dy) as f64 / bh as f64, - edge_border_depth(&boxed, bh, bw, Side::Left, lx, dx) as f64 / bw as f64, - edge_border_depth(&boxed, bh, bw, Side::Right, lx, dx) as f64 / bw as f64, - ] + let sides = [ + (Side::Top, ly, dy, bh), + (Side::Bottom, ly, dy, bh), + (Side::Left, lx, dx, bw), + (Side::Right, lx, dx, bw), + ]; + let mut out = [0.0; 4]; + out.par_iter_mut() + .zip(sides) + .for_each(|(o, (side, limit, deep, extent))| { + *o = edge_border_depth(view, side, limit, deep) as f64 / extent as f64; + }); + out } /// `_trim_film_edges(lum, film_roi)`: the box inset past the border on each side, or the box @@ -1180,6 +1260,7 @@ fn estimate_tier_levels( Some((bed, rebate, image, ring_spread)) } +#[derive(Clone)] pub struct TierRefinement { pub roi: (i64, i64, i64, i64), pub rows: Vec, @@ -1277,13 +1358,8 @@ pub fn refine_film_roi_by_tiers( /// The uint8 preview and its gray plane, as `_refine_frame_bounds` and `_dark_region_bounds` /// build them from an HxW or HxWx3 float32 image. fn preview_gray(img: &[f32], h: usize, w: usize, cn: usize) -> Vec { - let color: Vec = if cn == 1 { - img.iter().flat_map(|&v| [v, v, v]).collect() - } else { - img.to_vec() - }; let mut preview = vec![0u8; h * w * 3]; - normalize_to_uint8(&color, &mut preview); + normalize_to_uint8(&rgb(img, cn), &mut preview); let mut gray = vec![0u8; h * w]; crate::gray::bgr_to_gray(&preview, &mut gray); gray @@ -1531,32 +1607,45 @@ pub fn refine_roi_to_image( cn: usize, roi: Roi, ) -> Option<(Roi, Option)> { - let color: Vec = if cn == 1 { - img.iter().flat_map(|&v| [v, v, v]).collect() - } else { - img.to_vec() + type Refined = Option<(Roi, Option)>; + static LAST: crate::memo::Memo = crate::memo::Memo::new(); + let key = crate::memo::key(img, &[h, w, cn]); + let refined = || { + let det = detection_plane(img, h, w, cn, key)?; + refine_roi(img, &det, h, w, cn, roi) }; - let mut lum = vec![0f32; h * w]; - luminance(&color, &mut lum); - let mut det = vec![0f32; h * w]; - detection_luma(&lum, &mut det)?; + LAST.get_or( + crate::memo::extend(key, &[roi.0, roi.1, roi.2, roi.3]), + refined, + ) +} + +fn refine_roi( + img: &[f32], + det: &[f32], + h: usize, + w: usize, + cn: usize, + roi: Roi, +) -> Option<(Roi, Option)> { let clamp = |v: i64| v.max(0) as usize; let uroi = (clamp(roi.0), clamp(roi.1), clamp(roi.2), clamp(roi.3)); - if let Some(r) = refine_film_roi_by_tiers(&det, h, w, uroi) { + if let Some(r) = refine_film_roi_by_tiers(det, h, w, uroi) { return Some((r.roi, Some(r))); } - let walked = trim_film_edges(&det, h, w, roi); + let walked = trim_film_edges(det, h, w, roi); if walked != roi { return Some((walked, None)); } - if let Some(m) = roi_from_measured_border(&det, h, w, roi) { + if let Some(m) = roi_from_measured_border(det, h, w, roi) { return Some((m, None)); } - if find_rebate_level(&det, h, w, uroi, None).is_none() { + if find_rebate_level(det, h, w, uroi, None).is_none() { return Some((roi, None)); } let (y1, y2, x1, x2) = (uroi.0.min(h), uroi.1.min(h), uroi.2.min(w), uroi.3.min(w)); let crop_w = x2.saturating_sub(x1); + let color = rgb(img, cn); let crop: Vec = (y1..y2) .flat_map(|y| color[(y * w + x1) * 3..(y * w + x2) * 3].iter().copied()) .collect(); diff --git a/negpy_core/src/capture.rs b/negpy_core/src/capture.rs index f071bc2df..389a70c6a 100644 --- a/negpy_core/src/capture.rs +++ b/negpy_core/src/capture.rs @@ -120,15 +120,10 @@ pub fn meter_picture( sat: f32, blown_share: f64, ) -> (f64, bool, [f32; 3]) { - let picture: Vec<[f32; 3]> = (y1..y2) - .flat_map(|y| { - img[(y * w + x1) * 3..(y * w + x2) * 3] - .as_chunks::<3>() - .0 - .iter() - .copied() - }) - .collect(); + let mut picture: Vec<[f32; 3]> = Vec::with_capacity((y2 - y1) * (x2 - x1)); + for y in y1..y2 { + picture.extend_from_slice(img[(y * w + x1) * 3..(y * w + x2) * 3].as_chunks::<3>().0); + } let saturated: Vec = picture .iter() .map(|p| p[0].max(p[1]).max(p[2]) >= sat) @@ -252,9 +247,10 @@ pub fn ring_inner_medians(arr: &[f32], h: usize, w: usize, m: usize) -> (f32, f3 ring.extend_from_slice(&at(y)[..m]); ring.extend_from_slice(&at(y)[w - m..]); } - let mut inner: Vec = (h / 4..3 * h / 4) - .flat_map(|y| at(y)[w / 4..3 * w / 4].iter().copied()) - .collect(); + let mut inner = Vec::with_capacity((3 * h / 4 - h / 4) * (3 * w / 4 - w / 4)); + for y in h / 4..3 * h / 4 { + inner.extend_from_slice(&at(y)[w / 4..3 * w / 4]); + } (np_median(&mut ring), np_median(&mut inner)) } diff --git a/negpy_core/src/detect.rs b/negpy_core/src/detect.rs index 8b0a88896..f1ecf58d1 100644 --- a/negpy_core/src/detect.rs +++ b/negpy_core/src/detect.rs @@ -3,7 +3,7 @@ use rayon::prelude::*; -use crate::analysis::{Snap, detection_luma, luminance, snap_edge_to_gradient}; +use crate::analysis::{Snap, crop, detection_plane, rgb, snap_edge_to_gradient}; use crate::contours::{contour_area, find_external_contours}; use crate::filter::Border; use crate::gray; @@ -123,12 +123,6 @@ fn outer_ring_values(lum: &[f32], h: usize, w: usize) -> Vec { out } -fn crop(img: &[f32], w: usize, (y1, y2, x1, x2): (usize, usize, usize, usize)) -> Vec { - (y1..y2) - .flat_map(|y| img[y * w + x1..y * w + x2].iter().copied()) - .collect() -} - /// `_threshold_polarities`: which polarities the trials sample. fn threshold_polarities( lum: &[f32], @@ -737,6 +731,7 @@ fn pairwise_sum_f64(a: &[f64]) -> f64 { pairwise_sum_f64(&a[..n2]) + pairwise_sum_f64(&a[n2..]) } +#[derive(Clone)] pub struct Consensus { pub roi: Roi, pub correction_angle: f64, @@ -910,11 +905,12 @@ pub fn film_surround_is_plausible(lum: &[f32], h: usize, w: usize, roi: &Roi) -> let clampx = |v: i64| v.clamp(0, w as i64) as usize; let (y1, y2, x1, x2) = (clampy(roi.0), clampy(roi.1), clampx(roi.2), clampx(roi.3)); let mut out = Vec::with_capacity(h * w); - for y in 0..h { - for x in 0..w { - if !(y >= y1 && y < y2 && x >= x1 && x < x2) { - out.push(lum[y * w + x]); - } + for (y, row) in lum.chunks_exact(w).enumerate() { + if y >= y1 && y < y2 && x2 > x1 { + out.extend_from_slice(&row[..x1]); + out.extend_from_slice(&row[x2..]); + } else { + out.extend_from_slice(row); } } if (out.len() as f64) < 0.005 * lum.len() as f64 { @@ -973,6 +969,25 @@ fn snap_film_bounds(roi: Roi, lum: &[f32], h: usize, w: usize) -> Roi { ) } +/// The structural masks of an HxW or HxWx3 image, in trial order. +fn structural_masks(img: &[f32], h: usize, w: usize, cn: usize) -> Vec<(&'static str, Vec)> { + let mut preview = vec![0u8; h * w * 3]; + crate::analysis::normalize_to_uint8(&rgb(img, cn), &mut preview); + let mut gray_img = vec![0u8; h * w]; + gray::bgr_to_gray(&preview, &mut gray_img); + ["inverse-threshold", "blackhat", "edges"] + .par_iter() + .map(|&source| { + let mask = match source { + "inverse-threshold" => mask_from_inverse_threshold(&gray_img, h, w), + "blackhat" => mask_from_blackhat(&gray_img, h, w), + _ => mask_from_edges(&gray_img, h, w), + }; + (source, mask) + }) + .collect() +} + /// One adaptive threshold trial. struct Trial { source: &'static str, @@ -981,6 +996,7 @@ struct Trial { threshold: f64, } +#[derive(Clone)] pub struct Detection { pub consensus: Option, pub profile: Vec, @@ -989,6 +1005,12 @@ pub struct Detection { /// `detect_film_bounds_with_confidence(img)` for an HxW or HxWx3 float32 image. pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detection { + static LAST: crate::memo::Memo = crate::memo::Memo::new(); + let key = crate::memo::key(img, &[h, w, cn]); + LAST.get_or(key, || detect(img, h, w, cn, key)) +} + +fn detect(img: &[f32], h: usize, w: usize, cn: usize, key: u128) -> Detection { let empty = |profile: Vec, contrast: f64| Detection { consensus: None, profile, @@ -997,37 +1019,40 @@ pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detecti if h < 2 || w < 2 { return empty(Vec::new(), 0.0); } - let color: Vec = if cn == 1 { - img.iter().flat_map(|&v| [v, v, v]).collect() - } else { - img.to_vec() - }; - let mut raw = vec![0f32; h * w]; - luminance(&color, &mut raw); - let mut lum = vec![0f32; h * w]; - if detection_luma(&raw, &mut lum).is_none() { + let Some(lum) = detection_plane(img, h, w, cn, key) else { return empty(Vec::new(), 0.0); - } - let (profile, contrast) = vertical_edge_profile(&lum, h, w); - let lohi = np_percentiles_f64(&lum, &[2.0, 98.0]); - let span = lohi[1] - lohi[0]; - if span < 0.04 || span.is_nan() { - return empty(profile, contrast); - } - let ring_median = np_median(&mut outer_ring_values(&lum, h, w)) as f64; - let polarities = threshold_polarities(&lum, h, w, span, ring_median); - let blur = odd_kernel_size(0.004 * h.min(w) as f64, 3, 9) as usize; - let mut blurred = vec![0f32; h * w]; - let _ = crate::filter::gaussian_blur( - &lum, - &mut blurred, - (h, w, 1), - (blur, blur), - (0.0, 0.0), - Border::Reflect101, + }; + let lum = lum.as_slice(); + let threshold_inputs = || { + let lohi = np_percentiles_f64(lum, &[2.0, 98.0]); + let span = lohi[1] - lohi[0]; + if span < 0.04 || span.is_nan() { + return None; + } + let ring_median = np_median(&mut outer_ring_values(lum, h, w)) as f64; + let polarities = threshold_polarities(lum, h, w, span, ring_median); + let blur = odd_kernel_size(0.004 * h.min(w) as f64, 3, 9) as usize; + let mut blurred = vec![0f32; h * w]; + let _ = crate::filter::gaussian_blur( + lum, + &mut blurred, + (h, w, 1), + (blur, blur), + (0.0, 0.0), + Border::Reflect101, + ); + Some((lohi, span, ring_median, polarities, blurred)) + }; + // The structural masks need none of the threshold inputs, so they build alongside them. + let (((profile, contrast), inputs), masks) = rayon::join( + || rayon::join(|| vertical_edge_profile(lum, h, w), threshold_inputs), + || structural_masks(img, h, w, cn), ); + let Some((lohi, span, ring_median, polarities, blurred)) = inputs else { + return empty(profile, contrast); + }; let scene = Scene { - lum: &lum, + lum, h, w, ring_median, @@ -1062,32 +1087,24 @@ pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detecti }); } } - let structural = ["inverse-threshold", "blackhat", "edges"]; - let gray_img = { - let mut preview = vec![0u8; h * w * 3]; - crate::analysis::normalize_to_uint8(&color, &mut preview); - let mut g = vec![0u8; h * w]; - gray::bgr_to_gray(&preview, &mut g); - g - }; - let mut results: Vec> = trials - .par_iter() - .map(|t| { - let mask = adaptive_threshold_mask(&blurred, h, w, t.threshold, t.polarity == "dark"); - candidates_from_mask(&mask, &scene, t.source, Some(t.index), Some(t.polarity)) - }) - .collect(); - let structural_results: Vec> = structural - .par_iter() - .map(|&source| { - let mask = match source { - "inverse-threshold" => mask_from_inverse_threshold(&gray_img, h, w), - "blackhat" => mask_from_blackhat(&gray_img, h, w), - _ => mask_from_edges(&gray_img, h, w), - }; - candidates_from_mask(&mask, &scene, source, None, None) - }) - .collect(); + let (mut results, structural_results): (Vec>, Vec>) = rayon::join( + || { + trials + .par_iter() + .map(|t| { + let mask = + adaptive_threshold_mask(&blurred, h, w, t.threshold, t.polarity == "dark"); + candidates_from_mask(&mask, &scene, t.source, Some(t.index), Some(t.polarity)) + }) + .collect() + }, + || { + masks + .par_iter() + .map(|(source, mask)| candidates_from_mask(mask, &scene, source, None, None)) + .collect() + }, + ); results.extend(structural_results); let candidates: Vec = results.into_iter().flatten().collect(); @@ -1095,11 +1112,11 @@ pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detecti return empty(profile, contrast); }; if !film_box_covers_enough(h, w, &consensus.roi) - || !film_surround_is_plausible(&lum, h, w, &consensus.roi) + || !film_surround_is_plausible(lum, h, w, &consensus.roi) { return empty(profile, contrast); } - let roi = snap_film_bounds(consensus.roi, &lum, h, w); + let roi = snap_film_bounds(consensus.roi, lum, h, w); Detection { consensus: Some(Consensus { roi, ..consensus }), profile, diff --git a/negpy_core/src/distance.rs b/negpy_core/src/distance.rs index bac74435e..e44bcee32 100644 --- a/negpy_core/src/distance.rs +++ b/negpy_core/src/distance.rs @@ -200,12 +200,60 @@ pub fn distance_transform_labels(src: &[u8], h: usize, w: usize) -> (Vec, V /// `_mask_to_score(mask, pad_px)` of retouch: the floor on the defect (nonzero), rising to 1 over /// `pad` pixels of 3x3 chamfer distance along a smoothstep, in float32. pub fn mask_to_score(mask: &[u8], h: usize, w: usize, pad: f64, floor: f64) -> Vec { - let clean: Vec = mask.iter().map(|&m| (m == 0) as u8).collect(); - let mut d = distance_transform(&clean, h, w, Metric::L2Mask3); + use rayon::prelude::*; + let clean: Vec = mask.par_iter().map(|&m| (m == 0) as u8).collect(); let (pad, floor, rise) = (pad.max(1e-3) as f32, floor as f32, (1.0 - floor) as f32); - for v in d.iter_mut() { - let t = (*v / pad).clamp(0.0, 1.0); - *v = floor + rise * (t * t * (3.0 - 2.0 * t)); + // The score is flat past `pad`, and a chamfer path no longer than `pad` moves at most + // pad / 0.955 rows, so bands with that much margin give every unclamped distance exactly. + let halo = (pad / steps(Metric::L2Mask3)[0]).ceil() as usize + 2; + let band = 64.max(h.div_ceil(4 * rayon::current_num_threads())); + let mut out = vec![0f32; h * w]; + out.par_chunks_mut(band * w.max(1)) + .enumerate() + .for_each(|(i, rows)| { + let (y0, y1) = (i * band, i * band + rows.len() / w.max(1)); + let (lo, hi) = (y0.saturating_sub(halo), (y1 + halo).min(h)); + let d = distance_transform(&clean[lo * w..hi * w], hi - lo, w, Metric::L2Mask3); + for (o, &v) in rows.iter_mut().zip(&d[(y0 - lo) * w..]) { + let t = (v / pad).clamp(0.0, 1.0); + *o = floor + rise * (t * t * (3.0 - 2.0 * t)); + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn banded_score_matches_one_pass() { + let (h, w) = (700, 97); + let mut seed = 7u64; + let mut mask = vec![0u8; h * w]; + for m in mask.iter_mut() { + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + *m = (seed >> 33).is_multiple_of(400) as u8; + } + for pad in [0.5, 2.5, 9.0, 40.0] { + let clean: Vec = mask.iter().map(|&m| (m == 0) as u8).collect(); + let (p, floor) = (pad as f32, 0.02f32); + let want: Vec = distance_transform(&clean, h, w, Metric::L2Mask3) + .iter() + .map(|&v| { + let t = (v / p).clamp(0.0, 1.0); + floor + (1.0 - floor) * (t * t * (3.0 - 2.0 * t)) + }) + .collect(); + let got = mask_to_score(&mask, h, w, pad, floor as f64); + assert!( + want.iter() + .zip(&got) + .all(|(a, b)| a.to_bits() == b.to_bits()), + "pad {pad}" + ); + } } - d } diff --git a/negpy_core/src/flatfield.rs b/negpy_core/src/flatfield.rs index 451b5b8a8..66b97aac1 100644 --- a/negpy_core/src/flatfield.rs +++ b/negpy_core/src/flatfield.rs @@ -206,8 +206,9 @@ pub fn compute_gain( .0 .iter() .zip(&lit) - .flat_map(|(p, &l)| [p[0] * l, p[1] * l, p[2] * l]) - .collect(); + .map(|(p, &l)| [p[0] * l, p[1] * l, p[2] * l]) + .collect::>() + .into_flattened(); let (mut num, mut den) = (vec![0f32; 3 * n], vec![0f32; n]); let s = (c.sigma, c.sigma); gaussian_blur( diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 372fb3510..2b1257fbb 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -23,6 +23,7 @@ pub mod loaders; pub mod lsd; pub mod mathf; pub mod median; +pub mod memo; pub mod meter; pub mod morph; pub mod phase; diff --git a/negpy_core/src/lsd.rs b/negpy_core/src/lsd.rs index 687f1e838..9b0360664 100644 --- a/negpy_core/src/lsd.rs +++ b/negpy_core/src/lsd.rs @@ -160,6 +160,9 @@ struct Detector { h: usize, angles: Vec, modgrad: Vec, + /// `(cosf, sinf)` of each defined angle, as the region sums take them. + unit: Vec<(f32, f32)>, + /// Taken by a region, or with no defined angle: no region can take it. used: Vec, } @@ -235,12 +238,27 @@ impl Detector { order[*s] = i as u32; *s += 1; } + let mut unit = vec![(0f32, 0f32); h * w]; + let mut used = vec![false; h * w]; + unit.par_chunks_mut(w) + .zip(used.par_chunks_mut(w)) + .zip(angles.par_chunks(w)) + .for_each(|((u, d), a)| { + for ((u, d), &a) in u.iter_mut().zip(d).zip(a) { + if a == NOTDEF { + *d = true; + } else { + *u = (libm::cosf(a as f32), libm::sinf(a as f32)); + } + } + }); let det = Detector { w, h, angles, modgrad, - used: vec![false; h * w], + unit, + used, }; (det, order) } @@ -286,8 +304,9 @@ impl Detector { angle, modgrad: self.modgrad[j], }); - sumdx += libm::cosf(angle as f32); - sumdy += libm::sinf(angle as f32); + let (c, s) = self.unit[j]; + sumdx += c; + sumdy += s; reg_angle = fast_atan2(sumdy, sumdx) as f64 * DEG_TO_RADS; } } @@ -444,7 +463,7 @@ pub fn line_segments(img: &[u8], (h, w): (usize, usize)) -> Vec<[f32; 4]> { let mut lines = Vec::new(); for &i in &order { let i = i as usize; - if det.used[i] || det.angles[i] == NOTDEF { + if det.used[i] { continue; } let (x, y) = (i % sw, i / sw); diff --git a/negpy_core/src/memo.rs b/negpy_core/src/memo.rs new file mode 100644 index 000000000..65d99df40 --- /dev/null +++ b/negpy_core/src/memo.rs @@ -0,0 +1,118 @@ +//! Last results of detection work that several callers repeat on one frame: the base-log +//! meter, auto crop and the skew fit each find the film box on the same buffer. + +use rayon::prelude::*; +use std::sync::Mutex; + +const SEEDS: [u64; 2] = [0x9e37_79b9_7f4a_7c15, 0xd6e8_feb8_6659_fd93]; + +fn fold(mut h: [u64; 2], v: u64) -> [u64; 2] { + const P: u64 = 0xa076_1d64_78bd_642f; + for (s, seed) in h.iter_mut().zip(SEEDS) { + let r = ((*s ^ v) as u128) * ((P ^ seed) as u128); + *s = (r as u64) ^ ((r >> 64) as u64); + } + h +} + +fn join(h: [u64; 2]) -> u128 { + (h[0] as u128) << 64 | h[1] as u128 +} + +/// A 128-bit key of a float32 buffer's bits and shape: two seeded multiply-mix chains per block, +/// blocks in parallel, folded in order. Not for adversarial input. +pub fn key(data: &[f32], shape: &[usize]) -> u128 { + let blocks: Vec<[u64; 2]> = data + .par_chunks(1 << 16) + .map(|block| { + let (pairs, rest) = block.as_chunks::<2>(); + let h = pairs.iter().fold(SEEDS, |h, p| { + fold(h, (p[0].to_bits() as u64) << 32 | p[1].to_bits() as u64) + }); + rest.iter().fold(h, |h, v| fold(h, v.to_bits() as u64)) + }) + .collect(); + let h = shape + .iter() + .map(|&d| d as u64) + .chain([data.len() as u64]) + .fold(SEEDS, fold); + join(blocks.into_iter().flatten().fold(h, fold)) +} + +/// `key` extended by more values, such as the box a call refines. +pub fn extend(key: u128, extra: &[i64]) -> u128 { + let h = [(key >> 64) as u64, key as u64]; + join(extra.iter().fold(h, |h, &v| fold(h, v as u64))) +} + +/// The `N` most recently used values by key. +pub struct Memo { + slots: Mutex>, +} + +impl Default for Memo { + fn default() -> Self { + Self::new() + } +} + +impl Memo { + pub const fn new() -> Self { + Memo { + slots: Mutex::new(Vec::new()), + } + } + + /// The value under `key`, or `f()` stored under it. `f` runs unlocked, so two callers may + /// both compute a missing value; both get the same result. + pub fn get_or(&self, key: u128, f: impl FnOnce() -> V) -> V { + let lock = || self.slots.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut slots = lock(); + if let Some(i) = slots.iter().position(|(k, _)| *k == key) { + let hit = slots.remove(i); + slots.insert(0, hit); + return slots[0].1.clone(); + } + } + let v = f(); + let mut slots = lock(); + slots.retain(|(k, _)| *k != key); + slots.insert(0, (key, v.clone())); + slots.truncate(N); + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_sees_every_bit_and_the_shape() { + let a: Vec = (0..200_000).map(|i| i as f32 * 0.25).collect(); + let k = key(&a, &[400, 500]); + assert_eq!(k, key(&a.clone(), &[400, 500])); + assert_ne!(k, key(&a, &[500, 400])); + let mut b = a.clone(); + b[123_457] = f32::from_bits(b[123_457].to_bits() ^ 1); + assert_ne!(k, key(&b, &[400, 500])); + let mut c = a.clone(); + c[0] = -0.0; + assert_ne!(key(&c, &[400, 500]), { + c[0] = 0.0; + key(&c, &[400, 500]) + }); + } + + #[test] + fn memo_keeps_the_last_n() { + let m: Memo = Memo::new(); + assert_eq!(m.get_or(1, || 10), 10); + assert_eq!(m.get_or(2, || 20), 20); + assert_eq!(m.get_or(1, || 99), 10); + assert_eq!(m.get_or(3, || 30), 30); + assert_eq!(m.get_or(2, || 21), 21); + } +} diff --git a/negpy_core/src/meter.rs b/negpy_core/src/meter.rs index 47c7d5661..04a2da2c0 100644 --- a/negpy_core/src/meter.rs +++ b/negpy_core/src/meter.rs @@ -2,6 +2,7 @@ //! range, shadow reach and highlight hold, read off the block-median grid. use crate::stats::{np_median, np_percentiles, np_percentiles_f64, np_quantile, pairwise_sum_f32}; +use rayon::prelude::*; const LUMA_R: f32 = 0.2126; const LUMA_G: f32 = 0.7152; @@ -220,17 +221,19 @@ pub fn neutral_axis( crate::analysis::normalize_log_image(grid, floors32, ceils32, &mut norm_all); let (norm_all, log_all) = (norm_all.as_chunks::<3>().0, grid.as_chunks::<3>().0); let keep: Vec = (0..norm_all.len()) + .into_par_iter() .filter(|&i| !exclude_subjects || !is_subject(norm_all[i], &c.subjects)) .collect(); - let norm: Vec<[f32; 3]> = keep.iter().map(|&i| norm_all[i]).collect(); - let log: Vec<[f32; 3]> = keep.iter().map(|&i| log_all[i]).collect(); + let norm: Vec<[f32; 3]> = keep.par_iter().map(|&i| norm_all[i]).collect(); + let log: Vec<[f32; 3]> = keep.par_iter().map(|&i| log_all[i]).collect(); let lum: Vec = norm - .iter() + .par_iter() .map(|n| LUMA_R * n[0] + LUMA_G * n[1] + LUMA_B * n[2]) .collect(); let band_refs = |(lo, hi): (f32, f32), chroma: &[f32], cap: f64| -> Option { let band: Vec = (0..lum.len()) + .into_par_iter() .filter(|&i| lum[i] >= lo && lum[i] <= hi) .collect(); if band.len() < c.min_pixels { @@ -284,7 +287,7 @@ pub fn neutral_axis( }) }; - let chroma1: Vec = norm.iter().map(|&n| rms_chroma(n)).collect(); + let chroma1: Vec = norm.par_iter().map(|&n| rms_chroma(n)).collect(); let mid1 = band_refs(c.mid, &chroma1, c.first_pass_cap)?; let sh1 = band_refs(c.shadow, &chroma1, c.first_pass_cap)?; let (nm, ns) = (norm_ref(mid1.refs), norm_ref(sh1.refs)); @@ -300,7 +303,7 @@ pub fn neutral_axis( gain[ch] = (a as f32, b as f32); } let chroma2: Vec = norm - .iter() + .par_iter() .map(|n| { rms_chroma([ gain[0].0 * n[0] + gain[0].1, diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs index d51d6d73e..b55365faf 100644 --- a/negpy_core/src/retouch.rs +++ b/negpy_core/src/retouch.rs @@ -1358,8 +1358,9 @@ fn local_mean( .0 .iter() .zip(weight) - .flat_map(|(p, &k)| [p[0] * k, p[1] * k, p[2] * k]) - .collect(); + .map(|(p, &k)| [p[0] * k, p[1] * k, p[2] * k]) + .collect::>() + .into_flattened(); let (mut num, mut den) = (vec![0f32; 3 * h * w], vec![0f32; h * w]); gaussian_blur( &weighted, @@ -1495,7 +1496,8 @@ pub fn release_score(score: &mut [f32], cover: &[u8], (h, w): (usize, usize), ri let (x0, x1) = (cols[0].saturating_sub(1), (cols[cols.len() - 1] + 2).min(w)); let (ch, cw) = (y1 - y0, x1 - x0); let crop: Vec = (y0..y1) - .flat_map(|y| cover[y * w + x0..y * w + x1].iter().copied()) + .flat_map(|y| &cover[y * w + x0..y * w + x1]) + .copied() .collect(); let d = crate::distance::distance_transform(&crop, ch, cw, crate::distance::Metric::L2Mask3); diff --git a/negpy_core/src/skew.rs b/negpy_core/src/skew.rs index b6c4e24cd..c694bddc8 100644 --- a/negpy_core/src/skew.rs +++ b/negpy_core/src/skew.rs @@ -537,6 +537,22 @@ pub fn coarse_angles( min_segment: f64, max_skew: f64, angle_bin: f64, +) -> Vec { + // Squaring the frame and the crop's own edge fit both measure the same plane. + static LAST: crate::memo::Memo, 2> = crate::memo::Memo::new(); + let params = [min_segment, max_skew, angle_bin].map(|v| v.to_bits() as i64); + let key = crate::memo::extend(crate::memo::key(lum, &[h, w]), ¶ms); + LAST.get_or(key, || { + segment_angles(lum, (h, w), min_segment, max_skew, angle_bin) + }) +} + +fn segment_angles( + lum: &[f32], + (h, w): (usize, usize), + min_segment: f64, + max_skew: f64, + angle_bin: f64, ) -> Vec { let q = crate::stats::np_percentiles_f64(lum, &[1.0, 99.0]); let (lo, span) = (q[0], (q[1] - q[0]).max(1e-6)); diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs index 20d91e890..e4ec4dd37 100644 --- a/negpy_core/src/stats.rs +++ b/negpy_core/src/stats.rs @@ -204,12 +204,15 @@ pub fn np_percentiles_f64(values: &[f32], qs: &[f64]) -> Vec { } /// `np.median` of float32 values: the middle one, or the float32 mean of the two middle ones; -/// NaN when any value is NaN or there are none. Reorders `values`. +/// NaN when any value is NaN or there are none. May reorder `values`. pub fn np_median(values: &mut [f32]) -> f32 { + let n = values.len(); + if n > DIRECT_MAX { + return median(values); + } if values.is_empty() || values.iter().any(|v| v.is_nan()) { return f32::NAN; } - let n = values.len(); let (_, &mut hi, _) = values.select_nth_unstable_by(n / 2, f32::total_cmp); if n % 2 == 1 { return hi; diff --git a/negpy_core/src/zones.rs b/negpy_core/src/zones.rs index bca4f9d2c..6b2799f8c 100644 --- a/negpy_core/src/zones.rs +++ b/negpy_core/src/zones.rs @@ -241,8 +241,9 @@ pub fn loupe_acutance(rgb: &[f32], (h, w, cn): (usize, usize, usize)) -> f64 { } else { let three: Vec = rgb .chunks_exact(cn) - .flat_map(|p| [p[0], p[1], p[2]]) - .collect(); + .map(|p| [p[0], p[1], p[2]]) + .collect::>() + .into_flattened(); crate::analysis::luminance_chunk(&three, &mut lum); } // Reflect-101 padding, one pixel wide, so every tap reads inside the buffer. From 98f3db66ecdbae5f68cef8042738e107ca2802cb Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 24 Oct 2026 08:42:42 +0200 Subject: [PATCH 127/133] perf: parallel thumbnail refresh and export encode negpy_core gets a background pool with a quarter of the cores. A thread marked by set_background_thread(True) sends its large work there, so the live preview keeps the other cores. Results do not change. Thumbnail refresh renders two frames at once on that pool, each with its own CPU ImageProcessor. A second frame starts only when free RAM holds its preview decode above MIN_RAM_RESERVE_BYTES; composites and unknown RAM stay at one frame. Signals still leave the worker in frame order. Export encodes up to three files at once. encode_slots() sets the queue depth from free RAM before each submit, so memory pressure drops it to one. A lock keeps two encodes from taking the same output name. The detection memos get more slots, so frames in flight do not evict each other. Headless check on 4 sample RAWs: thumbnails 3.03 s serial, 1.98 s with two frames; outputs bit-equal except DSCF0966.RAF, whose LibRaw decode already differs run to run. --- CLAUDE.md | 2 +- negpy/desktop/controller.py | 17 ++- negpy/desktop/workers/export.py | 44 ++++-- negpy/desktop/workers/render.py | 157 ++++++++++++++------- negpy_core/src/analysis.rs | 4 +- negpy_core/src/detect.rs | 2 +- negpy_core/src/lib.rs | 3 +- negpy_core/src/py/mod.rs | 27 +++- negpy_core/src/py/pool.rs | 18 +++ negpy_core/src/skew.rs | 2 +- tests/test_core_background_pool.py | 43 ++++++ tests/test_export_pipelining.py | 57 ++++++++ tests/test_thumbnail_refresh_controller.py | 8 +- tests/test_thumbnail_refresh_worker.py | 105 ++++++++++++++ 14 files changed, 410 insertions(+), 79 deletions(-) create mode 100644 negpy_core/src/py/pool.rs create mode 100644 tests/test_core_background_pool.py diff --git a/CLAUDE.md b/CLAUDE.md index 0bcda4d16..a4184ea3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. -- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is only a dependency of the plustek extra and group, for pyopticfilm's pass alignment; the tests use it as their oracle through the plustek group. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). The app has no single-core mode. +- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is only a dependency of the plustek extra and group, for pyopticfilm's pass alignment; the tests use it as their oracle through the plustek group. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). A thread marked by `negpy_core.set_background_thread(True)` (the thumbnail refresh) enters a quarter-core pool instead, so the live preview keeps the rest. The app has no single-core mode. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index bc66fea81..951373963 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -43,6 +43,7 @@ resolve_output_dir, ) from negpy.desktop.workers.render import ( + _THUMBNAIL_WORKERS, AssetDiscoveryTask, AssetDiscoveryWorker, AutoDetectAllSplitsTask, @@ -453,13 +454,15 @@ def thumbnail_refresh_progress_text( mean_render_s: float, *, in_flight: bool = False, + frames_at_once: int = 1, ) -> str: - """With ``in_flight`` the ``index``-th frame is still decoding; otherwise ``index`` frames are done.""" + """With ``in_flight`` the ``index``-th frame is still decoding; otherwise ``index`` frames are done. + The time left divides by ``frames_at_once``, the frames the worker renders side by side.""" text = tr("Thumbnails {index}/{total}", index=index, total=total) samples = index - 1 if in_flight else index left = total - samples if in_flight else total - index if samples >= 2 and left > 0: - seconds = (mean_decode_s + mean_render_s) * left + seconds = (mean_decode_s + mean_render_s) * left / frames_at_once left_text = ( tr("~{value} min left", value=round(seconds / 60)) if seconds >= 60 else tr("~{value} s left", value=max(1, round(seconds))) ) @@ -4762,7 +4765,11 @@ def _thumbnail_render_means(self) -> tuple[float, float]: def _on_thumbnail_render_frame_started(self, index: int, total: int, name: str) -> None: if not self._thumbnail_render_running: return - self.thumbnail_refresh_progress.emit(thumbnail_refresh_progress_text(index, total, *self._thumbnail_render_means(), in_flight=True)) + self.thumbnail_refresh_progress.emit( + thumbnail_refresh_progress_text( + index, total, *self._thumbnail_render_means(), in_flight=True, frames_at_once=_THUMBNAIL_WORKERS + ) + ) def _on_thumbnail_render_progress(self, current: int, total: int, name: str, decode_s: float, render_s: float) -> None: if not self._thumbnail_render_running: @@ -4772,7 +4779,9 @@ def _on_thumbnail_render_progress(self, current: int, total: int, name: str, dec timing[1] += render_s timing[2] += 1 self.status_progress_requested.emit(current, total) - self.thumbnail_refresh_progress.emit(thumbnail_refresh_progress_text(current, total, *self._thumbnail_render_means())) + self.thumbnail_refresh_progress.emit( + thumbnail_refresh_progress_text(current, total, *self._thumbnail_render_means(), frames_at_once=_THUMBNAIL_WORKERS) + ) def _on_thumbnail_rendered(self, frame: ThumbnailRenderInput, buffer: np.ndarray) -> None: if not self._thumbnail_render_running: diff --git a/negpy/desktop/workers/export.py b/negpy/desktop/workers/export.py index 067ce2e07..2cbfc88d6 100644 --- a/negpy/desktop/workers/export.py +++ b/negpy/desktop/workers/export.py @@ -1,6 +1,7 @@ from negpy.kernel.system.i18n import tr from negpy.kernel.system import timing from negpy.kernel.system.logging import get_logger +from collections import deque from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass, replace from typing import List, Optional, Any, Union @@ -39,6 +40,8 @@ from negpy.services.export.contact_sheet_roll import SheetFrame, SheetLook, tile_params, turns_for from negpy.services.export.encoders import encode_jpeg from negpy.kernel.system.paths import apply_umask_mode +from negpy.kernel.system.memory import available_system_memory_bytes +from negpy.services.rendering.prefetch_policy import MIN_RAM_RESERVE_BYTES logger = get_logger(__name__) @@ -272,6 +275,18 @@ def find_export_conflicts(tasks: List[ExportTask]) -> List[str]: return [path for task in tasks if os.path.exists(path := resolve_export_target_path(task))] +# Each encode runs on one core; more than a few at once only multiplies the held renders. +_ENCODE_WORKERS = max(1, min(3, (os.cpu_count() or 1) // 4)) + + +def encode_slots(nbytes: int, available: Optional[int]) -> int: + """Renders that may wait for or run an encode at once: each holds its buffer and about as much + again in encoded bytes, above the RAM reserve. Unknown RAM gives one.""" + if available is None: + return 1 + return max(1, min(_ENCODE_WORKERS, (available - MIN_RAM_RESERVE_BYTES) // max(1, 2 * nbytes))) + + class ExportWorker(QObject): """ Background batch export orchestrator. @@ -291,6 +306,7 @@ def __init__(self) -> None: self._processor = ImageProcessor() self._cancel = threading.Event() self._written: set[str] = set() # paths this batch wrote + self._names_lock = threading.Lock() # finishers pick output names one at a time @pyqtSlot() def cancel(self) -> None: @@ -304,14 +320,14 @@ def arm(self) -> None: @pyqtSlot(list) def run_batch(self, tasks: List[ExportTask]) -> None: - """Export the tasks pipelined: the next source prepares and the previous render encodes - while the current one renders, so at most two full-res buffers are held. A failed file + """Export the tasks pipelined: the next source prepares and earlier renders encode while + the current one renders. `encode_slots` bounds the renders held for encoding. A failed file never stops the batch, and every exit emits `finished` or `cancelled` to free the lane.""" self._written = set() total = len(tasks) - finisher = ThreadPoolExecutor(max_workers=1) + finisher = ThreadPoolExecutor(max_workers=_ENCODE_WORKERS) prefetcher = ThreadPoolExecutor(max_workers=1) - pending: Optional[Future] = None + pending: deque[Future] = deque() def _drain(fut: Future) -> None: try: @@ -388,15 +404,14 @@ def _drain(fut: Future) -> None: self.error.emit(status) continue - if pending is not None: - _drain(pending) - pending = finisher.submit(self._finish_task, task, buffer, status, embed_plan) + while pending and len(pending) >= encode_slots(buffer.nbytes, available_system_memory_bytes()): + _drain(pending.popleft()) + pending.append(finisher.submit(self._finish_task, task, buffer, status, embed_plan)) except Exception as e: self.error.emit(tr("Export failed for {full_name}: {e}", full_name=full_name, e=e)) - if pending is not None: - _drain(pending) - pending = None + while pending: + _drain(pending.popleft()) if border_crushed: self.warning.emit( f"{border_crushed} of {len(tasks)} exports rendered almost black with no crop set: " @@ -468,10 +483,11 @@ def _encode_and_write(self, task: ExportTask, buffer: np.ndarray, color_space: s # Overwrite replaces files from before the batch, never another frame of it. counter = 2 - while path in self._written or (not task.export_settings.overwrite and os.path.exists(path)): - path = os.path.join(out_dir, f"{filename}_{counter}.{ext}") - counter += 1 - self._written.add(path) + with self._names_lock: + while path in self._written or (not task.export_settings.overwrite and os.path.exists(path)): + path = os.path.join(out_dir, f"{filename}_{counter}.{ext}") + counter += 1 + self._written.add(path) if _export_target_is_a_source(path, task): return f"Export skipped for {task.file_info['name']}: it would overwrite the source file. Change the filename pattern or destination." diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index 378bd5cd9..bdac321a5 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -1,10 +1,13 @@ +import queue import threading import time -from concurrent.futures import ThreadPoolExecutor, as_completed +from collections import deque +from concurrent.futures import Future, ThreadPoolExecutor, as_completed from contextlib import contextmanager from dataclasses import dataclass, replace from typing import Any, Callable, Iterator, Optional +import negpy_core import numpy as np from PyQt6.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot @@ -30,6 +33,7 @@ from negpy.features.process.sensor import apply_sensor_correction, effective_sensor_matrix from negpy.features.process.logic import effective_highlight_reconstruction, effective_linear_raw, highlight_reconstruction_bakes_wb from negpy.features.process.models import DemosaicMode +from negpy.infrastructure.loaders.factory import loader_factory from negpy.infrastructure.loaders.helpers import unsupported_raw_reason from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet from negpy.features.stitch.models import StitchConfig, stitch_active @@ -40,8 +44,10 @@ from negpy.infrastructure.gpu.resources import GPUTexture from negpy.kernel.system.config import APP_CONFIG from negpy.kernel.system.logging import get_logger +from negpy.kernel.system.memory import available_system_memory_bytes from negpy.kernel.system import timing from negpy.services.rendering.image_processor import ImageProcessor, preview_takes_unmix, read_source_base +from negpy.services.rendering.prefetch_policy import MIN_RAM_RESERVE_BYTES logger = get_logger(__name__) @@ -1841,6 +1847,22 @@ def _name(index: int) -> str: self.error.emit(str(exc)) +_THUMBNAIL_WORKERS = 2 + + +def _room_for_another_frame(frame: ThumbnailRenderInput) -> bool: + """True when RAM holds this frame's preview decode beside the one already in flight. A composite + reads several files, and an unknown RAM size gives no margin, so both wait their turn.""" + config = frame.config + if stitch_active(config.stitch) or hdr_active(config.hdr) or is_rgb_triplet(config.rgbscan): + return False + available = available_system_memory_bytes() + if available is None: + return False + estimate = loader_factory.estimate_linear_preview_prefetch_memory(frame.file_info["path"], APP_CONFIG.preview_render_size) + return available >= MIN_RAM_RESERVE_BYTES + estimate.temporary_bytes + estimate.cached_bytes + + class ThumbnailRenderWorker(QObject): """Re-render frames a bulk settings write touched but did not open, off the live render path. Its own CPU-only ImageProcessor never contends with the live render's GPU texture @@ -1859,7 +1881,11 @@ def __init__(self, preview_service, live_preview_service=None) -> None: super().__init__() self._preview_service = preview_service self._live_preview_service = live_preview_service - self._processor = ImageProcessor(use_gpu=False) + # One processor per pool thread: its caches hold one frame each. + self._all_processors = [ImageProcessor(use_gpu=False) for _ in range(_THUMBNAIL_WORKERS)] + self._processors: queue.SimpleQueue[ImageProcessor] = queue.SimpleQueue() + for processor in self._all_processors: + self._processors.put(processor) self._cancel_lock = threading.RLock() self._cancelled_generations: set[int] = set() self._active_generation: int | None = None @@ -1939,8 +1965,8 @@ def _emit_finished_unless_cancelled(self, generation: int, rendered_count: int) @pyqtSlot(ThumbnailRenderTask) def process(self, task: ThumbnailRenderTask) -> None: - """Render each frame sequentially: the CPU engine is already multi-core, - and one frame at a time keeps the live UI responsive while the user keeps editing.""" + """Render up to `_THUMBNAIL_WORKERS` frames at once on negpy_core's background pool, so the + live preview keeps the other cores. Signals leave this thread in frame order.""" generation = int(task.generation) with self._cancel_lock: self._active_generation = generation @@ -1948,56 +1974,37 @@ def process(self, task: ThumbnailRenderTask) -> None: return total = len(task.frames) rendered_count = 0 + pending: deque[tuple[int, str, ThumbnailRenderInput, Future]] = deque() try: - for done, frame in enumerate(task.frames, 1): - if self._cancel_requested(generation): - break - name = str(frame.file_info.get("name") or frame.file_info.get("path") or done) - self.frame_started.emit(done, total, name) - decode_s = render_s = 0.0 - decoding = True - started = time.perf_counter() - try: - buffer, meta = self._decode(frame, task.workspace_color_space) - decode_s = time.perf_counter() - started - decoding = False - cam_xyz = meta.get("cam_xyz") - if frame.icc_input_active: - cam_xyz = wb_only_cam_xyz(cam_xyz) - if not self._cancel_requested(generation): - started = time.perf_counter() - result, _metrics = self._processor.run_pipeline( - buffer, - frame.config, - frame.file_info["hash"], - render_size_ref=float(APP_CONFIG.preview_render_size), - prefer_gpu=False, - readback_metrics=False, - wants_uv_grid=False, - cache_stages=False, - ir_buffer=meta.get("ir_preview"), - detect_buffer=meta.get("detect_preview"), - cam_xyz=cam_xyz, - camera_wb=meta.get("camera_wb"), - gain_slices=gain_slices_for_asset(frame.file_info), - source_orientation=meta.get("orientation", 1), - ) - render_s = time.perf_counter() - started - if isinstance(result, np.ndarray) and not self._cancel_requested(generation): - self.rendered.emit(frame, np.ascontiguousarray(result[:, :, :3])) - rendered_count += 1 - except Exception: - if self._cancel_requested(generation): + with ThreadPoolExecutor(_THUMBNAIL_WORKERS, initializer=negpy_core.set_background_thread, initargs=(True,)) as pool: + frames = iter(enumerate(task.frames, 1)) + nxt = next(frames, None) + while nxt is not None or pending: + if ( + nxt is not None + and not self._cancel_requested(generation) + and (not pending or (len(pending) < _THUMBNAIL_WORKERS and _room_for_another_frame(nxt[1]))) + ): + done, frame = nxt + name = str(frame.file_info.get("name") or frame.file_info.get("path") or done) + self.frame_started.emit(done, total, name) + future = pool.submit(self._render_one, frame, task.workspace_color_space, generation) + pending.append((done, name, frame, future)) + nxt = next(frames, None) + continue + if not pending: break - # A failed read costs time too; the time-left estimate counts it. - if decoding: - decode_s = time.perf_counter() - started - else: - render_s = time.perf_counter() - started - logger.exception("Background thumbnail refresh skipped failed frame %s", name) - self.progress.emit(done, total, name, decode_s, render_s) - - self._processor.cleanup(release_source_cache=True, collect=False) + done, name, frame, future = pending.popleft() + result, decode_s, render_s = future.result() + if self._cancel_requested(generation): + continue + if result is not None: + self.rendered.emit(frame, result) + rendered_count += 1 + self.progress.emit(done, total, name, decode_s, render_s) + + for processor in self._all_processors: + processor.cleanup(release_source_cache=True, collect=False) self._emit_finished_unless_cancelled(generation, rendered_count) except Exception as exc: if self._emit_cancelled_if_requested(generation): @@ -2008,6 +2015,52 @@ def process(self, task: ThumbnailRenderTask) -> None: logger.exception("Background thumbnail refresh worker failure") self.error.emit(str(exc)) + def _render_one(self, frame: ThumbnailRenderInput, color_space: str, generation: int) -> tuple[Optional[np.ndarray], float, float]: + """Decode and render one frame on a pool thread: the RGB result (None when skipped or + failed), decode seconds and render seconds. A failed frame counts its time too.""" + decode_s = render_s = 0.0 + decoding = True + started = time.perf_counter() + processor = self._processors.get() + try: + buffer, meta = self._decode(frame, color_space) + decode_s = time.perf_counter() - started + decoding = False + cam_xyz = meta.get("cam_xyz") + if frame.icc_input_active: + cam_xyz = wb_only_cam_xyz(cam_xyz) + if self._cancel_requested(generation): + return None, decode_s, render_s + started = time.perf_counter() + result, _metrics = processor.run_pipeline( + buffer, + frame.config, + frame.file_info["hash"], + render_size_ref=float(APP_CONFIG.preview_render_size), + prefer_gpu=False, + readback_metrics=False, + wants_uv_grid=False, + cache_stages=False, + ir_buffer=meta.get("ir_preview"), + detect_buffer=meta.get("detect_preview"), + cam_xyz=cam_xyz, + camera_wb=meta.get("camera_wb"), + gain_slices=gain_slices_for_asset(frame.file_info), + source_orientation=meta.get("orientation", 1), + ) + render_s = time.perf_counter() - started + return (np.ascontiguousarray(result[:, :, :3]) if isinstance(result, np.ndarray) else None), decode_s, render_s + except Exception: + if decoding: + decode_s = time.perf_counter() - started + else: + render_s = time.perf_counter() - started + if not self._cancel_requested(generation): + logger.exception("Background thumbnail refresh skipped failed frame %s", frame.file_info.get("name")) + return None, decode_s, render_s + finally: + self._processors.put(processor) + class NormalizationWorker(QObject): """ diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 5c01120ef..638a52cab 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -49,7 +49,7 @@ pub fn detection_plane( cn: usize, key: u128, ) -> Option>> { - static LAST: crate::memo::Memo>>, 2> = crate::memo::Memo::new(); + static LAST: crate::memo::Memo>>, 4> = crate::memo::Memo::new(); LAST.get_or(key, || { let mut lum = vec![0f32; h * w]; luminance(&rgb(img, cn), &mut lum); @@ -1608,7 +1608,7 @@ pub fn refine_roi_to_image( roi: Roi, ) -> Option<(Roi, Option)> { type Refined = Option<(Roi, Option)>; - static LAST: crate::memo::Memo = crate::memo::Memo::new(); + static LAST: crate::memo::Memo = crate::memo::Memo::new(); let key = crate::memo::key(img, &[h, w, cn]); let refined = || { let det = detection_plane(img, h, w, cn, key)?; diff --git a/negpy_core/src/detect.rs b/negpy_core/src/detect.rs index f1ecf58d1..cc88c0366 100644 --- a/negpy_core/src/detect.rs +++ b/negpy_core/src/detect.rs @@ -1005,7 +1005,7 @@ pub struct Detection { /// `detect_film_bounds_with_confidence(img)` for an HxW or HxWx3 float32 image. pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detection { - static LAST: crate::memo::Memo = crate::memo::Memo::new(); + static LAST: crate::memo::Memo = crate::memo::Memo::new(); let key = crate::memo::key(img, &[h, w, cn]); LAST.get_or(key, || detect(img, h, w, cn, key)) } diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index 2b1257fbb..cc3c3af5d 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -95,5 +95,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::skew::register(m)?; py::register::register(m)?; py::autocrop::register(m)?; - py::zones::register(m) + py::zones::register(m)?; + py::pool::register(m) } diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index cf8e4b7f2..59b247d33 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -20,6 +20,7 @@ pub mod loaders; pub mod meter; pub mod morph; pub mod phase; +pub mod pool; pub mod print_curve; pub mod process; pub mod register; @@ -48,6 +49,8 @@ thread_local! { .use_current_thread() .build() .ok(); + /// Set on a thread whose large work runs in `background()`, such as a thumbnail worker. + static BACKGROUND: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Every core. A thread that joined `LOCAL` stays in it, so large work enters this pool @@ -61,6 +64,28 @@ fn all_cores() -> &'static rayon::ThreadPool { }) } +/// A quarter of the cores, so background work leaves the rest to the live preview. +fn background() -> &'static rayon::ThreadPool { + static POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); + rayon::ThreadPoolBuilder::new() + .num_threads((cores / 4).max(1)) + .thread_name(|i| format!("negpy-bg-{i}")) + .build() + .expect("cannot start the negpy_core background pool") + }) +} + +/// The pool that large work from the calling thread enters. +fn large_pool() -> &'static rayon::ThreadPool { + if BACKGROUND.get() { + background() + } else { + all_cores() + } +} + /// `py.detach(f)` for work of `n` elements: the GIL released, and every binding's work run /// through here so that each call enters a pool once. pub fn detach( @@ -71,7 +96,7 @@ pub fn detach( py.detach(|| { LOCAL.with(|local| match local { Some(p) if n < SMALL => p.install(f), - _ => all_cores().install(f), + _ => large_pool().install(f), }) }) } diff --git a/negpy_core/src/py/pool.rs b/negpy_core/src/py/pool.rs new file mode 100644 index 000000000..ad1dfd6b5 --- /dev/null +++ b/negpy_core/src/py/pool.rs @@ -0,0 +1,18 @@ +use pyo3::prelude::*; + +/// Sends the calling thread's large work to the background pool, or back to every core. +#[pyfunction] +fn set_background_thread(on: bool) { + super::BACKGROUND.set(on); +} + +/// The thread count of the pool that large work from the calling thread enters. +#[pyfunction] +fn pool_threads() -> usize { + super::large_pool().current_num_threads() +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(set_background_thread, m)?)?; + m.add_function(wrap_pyfunction!(pool_threads, m)?) +} diff --git a/negpy_core/src/skew.rs b/negpy_core/src/skew.rs index c694bddc8..04fac7797 100644 --- a/negpy_core/src/skew.rs +++ b/negpy_core/src/skew.rs @@ -539,7 +539,7 @@ pub fn coarse_angles( angle_bin: f64, ) -> Vec { // Squaring the frame and the crop's own edge fit both measure the same plane. - static LAST: crate::memo::Memo, 2> = crate::memo::Memo::new(); + static LAST: crate::memo::Memo, 4> = crate::memo::Memo::new(); let params = [min_segment, max_skew, angle_bin].map(|v| v.to_bits() as i64); let key = crate::memo::extend(crate::memo::key(lum, &[h, w]), ¶ms); LAST.get_or(key, || { diff --git a/tests/test_core_background_pool.py b/tests/test_core_background_pool.py new file mode 100644 index 000000000..1f52b4e45 --- /dev/null +++ b/tests/test_core_background_pool.py @@ -0,0 +1,43 @@ +"""A background thread's large work enters the small pool, with the same bits.""" + +import threading + +import numpy as np + +import negpy_core + + +def _on_thread(fn): + out = [] + t = threading.Thread(target=lambda: out.append(fn())) + t.start() + t.join() + return out[0] + + +def _background(fn): + def run(): + negpy_core.set_background_thread(True) + return fn() + + return _on_thread(run) + + +def test_a_background_thread_uses_a_quarter_of_the_cores(): + cores = negpy_core.pool_threads() + assert _background(negpy_core.pool_threads) == max(1, cores // 4) + assert _on_thread(negpy_core.pool_threads) == cores + + +def test_the_flag_stays_on_its_thread(): + def mark_then_read(): + negpy_core.set_background_thread(True) + return _on_thread(negpy_core.pool_threads) + + assert _on_thread(mark_then_read) == negpy_core.pool_threads() + + +def test_the_background_pool_gives_the_same_bits(): + values = np.random.default_rng(0).random(1 << 20, dtype=np.float32) + qs = [0.5, 1.0, 50.0, 99.0, 99.5] + assert _background(lambda: negpy_core.percentiles(values, qs)) == negpy_core.percentiles(values, qs) diff --git a/tests/test_export_pipelining.py b/tests/test_export_pipelining.py index 9f8a70c36..3a0b97ed4 100644 --- a/tests/test_export_pipelining.py +++ b/tests/test_export_pipelining.py @@ -470,3 +470,60 @@ def test_overwrite_never_replaces_another_frame_of_the_same_batch(tmp_path) -> N worker.run_batch([_task(src, "DSC_0001.NEF", preset), _task(src, "DSC_0001.JPG", preset)]) assert sorted(p.read_bytes() for p in tmp_path.glob("*.jpg")) == [b"FIRST", b"SECOND"] + + +def test_encode_slots_follow_known_ram_above_the_reserve() -> None: + from negpy.desktop.workers import export as export_worker + + gib = 1 << 30 + reserve = export_worker.MIN_RAM_RESERVE_BYTES + assert export_worker.encode_slots(gib, None) == 1 + assert export_worker.encode_slots(gib, reserve) == 1 + assert export_worker.encode_slots(gib, reserve + 4 * gib) == min(2, export_worker._ENCODE_WORKERS) + assert export_worker.encode_slots(gib, 1 << 50) == export_worker._ENCODE_WORKERS + + +def _two_encoders(monkeypatch) -> None: + from negpy.desktop.workers import export as export_worker + + monkeypatch.setattr(export_worker, "_ENCODE_WORKERS", 2) + monkeypatch.setattr(export_worker, "available_system_memory_bytes", lambda: 1 << 50) + + +def test_two_encodes_at_once_never_share_an_output_name(tmp_path, monkeypatch) -> None: + _two_encoders(monkeypatch) + worker, proc = _worker() + both = threading.Barrier(2, timeout=10) + proc.encode_export.side_effect = lambda buffer, *_a, **_k: (both.wait(), (b"X", "jpg"))[1] + preset = _preset(tmp_path, overwrite=True) + src = tmp_path / "src" + src.mkdir() + + worker.run_batch([_task(src, "DSC_0001.NEF", preset), _task(src, "DSC_0001.JPG", preset)]) + + assert len(list(tmp_path.glob("*.jpg"))) == 2 + + +def test_errors_of_parallel_encodes_keep_the_task_order(tmp_path, monkeypatch) -> None: + _two_encoders(monkeypatch) + worker, proc = _worker() + renders = iter(np.full((4, 4, 3), i, np.float32) for i in range(2)) + proc.render_export.side_effect = lambda *_a, **_k: (next(renders), "sRGB") + second_done = threading.Event() + + def _encode(buffer, *_a, **_k): + index = int(buffer[0, 0, 0]) + if index == 0: + second_done.wait(timeout=10) + else: + second_done.set() + return None, f"boom {index}" + + proc.encode_export.side_effect = _encode + errors: list[str] = [] + worker.error.connect(errors.append) + + worker.run_batch([_task(tmp_path, "a.cr2"), _task(tmp_path, "b.cr2")]) + + assert second_done.is_set() + assert errors == ["boom 0", "boom 1"] diff --git a/tests/test_thumbnail_refresh_controller.py b/tests/test_thumbnail_refresh_controller.py index 3e6115810..bedba46be 100644 --- a/tests/test_thumbnail_refresh_controller.py +++ b/tests/test_thumbnail_refresh_controller.py @@ -532,8 +532,8 @@ def test_frame_started_counts_the_frame_in_flight_as_left(self) -> None: self.controller.refresh_thumbnails_for(["other", "third"]) self.controller._thumbnail_render_timing = [4.0, 6.0, 2] self.controller._on_thumbnail_render_frame_started(3, 5, "c.dng") - # Frames 3, 4 and 5 are left at 5 s each. - assert lines[-1] == "Thumbnails 3/5 · ~15 s left" + # Frames 3, 4 and 5 are left at 5 s each, two at a time. + assert lines[-1] == "Thumbnails 3/5 · ~8 s left" self.controller._on_thumbnail_render_cancelled() def test_a_cancelled_refresh_clears_its_progress(self) -> None: @@ -1025,6 +1025,10 @@ def test_progress_text_formats_seconds_and_minutes() -> None: assert thumbnail_refresh_progress_text(2, 49, 1.0, 1.0) == "Thumbnails 2/49 · ~2 min left" +def test_progress_text_divides_the_time_left_by_the_frames_at_once() -> None: + assert thumbnail_refresh_progress_text(2, 5, 1.0, 1.0, frames_at_once=2) == "Thumbnails 2/5 · ~3 s left" + + def test_progress_text_has_no_time_left_on_the_last_frame() -> None: assert thumbnail_refresh_progress_text(5, 5, 1.0, 1.0) == "Thumbnails 5/5" diff --git a/tests/test_thumbnail_refresh_worker.py b/tests/test_thumbnail_refresh_worker.py index 877471c16..e3fd99a5c 100644 --- a/tests/test_thumbnail_refresh_worker.py +++ b/tests/test_thumbnail_refresh_worker.py @@ -1,6 +1,10 @@ +import threading from dataclasses import replace import numpy as np +import pytest + +import negpy_core import negpy.desktop.workers.render as render_workers from negpy.desktop.workers.render import ( @@ -14,6 +18,17 @@ from negpy.features.stitch.models import StitchConfig +@pytest.fixture(autouse=True) +def _one_worker(monkeypatch): + """Frame order and timing ticks are exact with one worker; the parallel tests set two.""" + monkeypatch.setattr(render_workers, "_THUMBNAIL_WORKERS", 1) + + +def _two_workers(monkeypatch, room: bool = True) -> None: + monkeypatch.setattr(render_workers, "_THUMBNAIL_WORKERS", 2) + monkeypatch.setattr(render_workers, "_room_for_another_frame", lambda _frame: room) + + def _meta(tag: str) -> dict: return { "cam_xyz": [[tag, 0, 0], [0, 1, 0], [0, 0, 1]], @@ -420,3 +435,93 @@ def test_thumbnail_render_skips_the_live_peek_for_a_stitch(qapp, monkeypatch) -> assert live.peek_calls == [] assert len(private.stitch_calls) == 1 + + +def _counting_decode(monkeypatch, wait: threading.Barrier | None = None) -> dict: + """Wraps the decode to record the most decodes at once and the pool size each one sees.""" + seen = {"now": 0, "most": 0, "pool": set()} + lock = threading.Lock() + real_decode = render_workers._decode_asset_preview_with_meta + + def _decode(*args): + with lock: + seen["now"] += 1 + seen["most"] = max(seen["most"], seen["now"]) + seen["pool"].add(negpy_core.pool_threads()) + try: + if wait is not None: + wait.wait() + return real_decode(*args) + finally: + with lock: + seen["now"] -= 1 + + monkeypatch.setattr(render_workers, "_decode_asset_preview_with_meta", _decode) + return seen + + +def test_thumbnail_render_runs_two_frames_at_once_on_the_background_pool(qapp, monkeypatch) -> None: + _two_workers(monkeypatch) + worker, _processor = _worker(monkeypatch) + seen = _counting_decode(monkeypatch, threading.Barrier(2, timeout=10)) + rendered: list[str] = [] + progress: list[int] = [] + finished: list[int] = [] + worker.rendered.connect(lambda frame, _buf: rendered.append(frame.thumbnail_key)) + worker.progress.connect(lambda done, *_rest: progress.append(done)) + worker.finished.connect(finished.append) + base = WorkspaceConfig() + + worker.process(_task(_input("a", base), _input("b", base))) + + assert seen["most"] == 2 + assert seen["pool"] == {max(1, negpy_core.pool_threads() // 4)} + assert rendered == ["key-a", "key-b"] + assert progress == [1, 2] + assert finished == [2] + + +def test_thumbnail_render_without_room_keeps_one_frame_in_flight(qapp, monkeypatch) -> None: + _two_workers(monkeypatch, room=False) + worker, _processor = _worker(monkeypatch) + seen = _counting_decode(monkeypatch) + finished: list[int] = [] + worker.finished.connect(finished.append) + base = WorkspaceConfig() + + worker.process(_task(_input("a", base), _input("b", base), _input("c", base))) + + assert seen["most"] == 1 + assert finished == [3] + + +def test_thumbnail_render_cancel_stops_frames_not_yet_started(qapp, monkeypatch) -> None: + _two_workers(monkeypatch) + worker, processor = _worker(monkeypatch) + rendered: list[str] = [] + cancelled: list[bool] = [] + worker.rendered.connect(lambda frame, _buf: (rendered.append(frame.thumbnail_key), worker.cancel())) + worker.cancelled.connect(lambda: cancelled.append(True)) + base = WorkspaceConfig() + + worker.process(_task(*(_input(name, base) for name in "abcd"))) + + assert rendered == ["key-a"] + assert len(processor.run_calls) <= 2 + assert cancelled == [True] + + +def test_room_for_another_frame_needs_known_ram_above_the_reserve(monkeypatch) -> None: + estimate = render_workers.loader_factory.estimate_preview_memory("/missing.dng", 100) + need = render_workers.MIN_RAM_RESERVE_BYTES + estimate.temporary_bytes + estimate.cached_bytes + monkeypatch.setattr(render_workers.loader_factory, "estimate_linear_preview_prefetch_memory", lambda *_args: estimate) + frame = _input("a", WorkspaceConfig()) + + monkeypatch.setattr(render_workers, "available_system_memory_bytes", lambda: None) + assert not render_workers._room_for_another_frame(frame) + monkeypatch.setattr(render_workers, "available_system_memory_bytes", lambda: need - 1) + assert not render_workers._room_for_another_frame(frame) + monkeypatch.setattr(render_workers, "available_system_memory_bytes", lambda: need) + assert render_workers._room_for_another_frame(frame) + stitch = replace(WorkspaceConfig(), stitch=StitchConfig(stitch_enabled=True, stitch_paths=("/a.dng", "/b.dng"))) + assert not render_workers._room_for_another_frame(_input("pano", stitch)) From 8f7e4276cb7f9b473b2c82bbdec64a897f25f755 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 24 Oct 2026 08:52:40 +0200 Subject: [PATCH 128/133] perf(core): background pool keeps at least two threads On a 4-core machine a quarter of the cores is one thread, which left the thumbnail refresh slower than the all-core pool it replaced. --- CLAUDE.md | 2 +- negpy_core/src/py/mod.rs | 5 +++-- tests/test_core_background_pool.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4184ea3a..939d501ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored - Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. - A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). - **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. -- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is only a dependency of the plustek extra and group, for pyopticfilm's pass alignment; the tests use it as their oracle through the plustek group. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). A thread marked by `negpy_core.set_background_thread(True)` (the thumbnail refresh) enters a quarter-core pool instead, so the live preview keeps the rest. The app has no single-core mode. +- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is only a dependency of the plustek extra and group, for pyopticfilm's pass alignment; the tests use it as their oracle through the plustek group. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). A thread marked by `negpy_core.set_background_thread(True)` (the thumbnail refresh) enters a pool of a quarter of the cores (at least two) instead, so the live preview keeps the rest. The app has no single-core mode. - `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. - **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. - Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index 59b247d33..dddb56d05 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -64,13 +64,14 @@ fn all_cores() -> &'static rayon::ThreadPool { }) } -/// A quarter of the cores, so background work leaves the rest to the live preview. +/// A quarter of the cores, so background work leaves the rest to the live preview. At least +/// two: on a small machine one thread would leave thumbnails slower than the all-core pool. fn background() -> &'static rayon::ThreadPool { static POOL: std::sync::OnceLock = std::sync::OnceLock::new(); POOL.get_or_init(|| { let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); rayon::ThreadPoolBuilder::new() - .num_threads((cores / 4).max(1)) + .num_threads((cores / 4).max(2).min(cores)) .thread_name(|i| format!("negpy-bg-{i}")) .build() .expect("cannot start the negpy_core background pool") diff --git a/tests/test_core_background_pool.py b/tests/test_core_background_pool.py index 1f52b4e45..34ccefe46 100644 --- a/tests/test_core_background_pool.py +++ b/tests/test_core_background_pool.py @@ -23,9 +23,9 @@ def run(): return _on_thread(run) -def test_a_background_thread_uses_a_quarter_of_the_cores(): +def test_a_background_thread_uses_a_quarter_of_the_cores_and_at_least_two(): cores = negpy_core.pool_threads() - assert _background(negpy_core.pool_threads) == max(1, cores // 4) + assert _background(negpy_core.pool_threads) == min(cores, max(2, cores // 4)) assert _on_thread(negpy_core.pool_threads) == cores From 1c0794017a49d9d365eb80ff741eeb60c593eb15 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 24 Oct 2026 09:24:45 +0200 Subject: [PATCH 129/133] perf(core): parallel baseline JPEG encode in Rust Export spent about 420 ms per 23.5 MP frame in PIL's single-threaded JPEG encode, and a module-wide lock around PIL's MAXBLOCK kept the three export encode slots from running JPEGs side by side. negpy_core.encode_jpeg ports libjpeg-turbo's path as Pillow drives it (quality-scaled Annex K tables, fixed-point YCbCr, islow DCT, rounded quantization, optimal Huffman tables) and splits the scan with restart markers, so strips count and encode in parallel. Without restarts the bytes equal Pillow's; with them every decoded pixel does. Size grows by 0.01-0.03 %. Progressive files stay on PIL. 23.5 MP frame: PIL 325 ms, negpy_core 45 ms on all cores. CPU export of the same frame: 1.81 s before this branch's speed work, 1.28 s now. --- negpy/services/export/encoders.py | 11 +- negpy_core/LEDGER.md | 1 + negpy_core/manifest.toml | 7 + negpy_core/src/jpeg.rs | 572 ++++++++++++++++++++++++++++++ negpy_core/src/lib.rs | 4 +- negpy_core/src/py/jpeg.rs | 46 +++ negpy_core/src/py/mod.rs | 1 + tests/test_core_jpeg.py | 83 +++++ 8 files changed, 722 insertions(+), 3 deletions(-) create mode 100644 negpy_core/src/jpeg.rs create mode 100644 negpy_core/src/py/jpeg.rs create mode 100644 tests/test_core_jpeg.py diff --git a/negpy/services/export/encoders.py b/negpy/services/export/encoders.py index ab88dfbd2..73a28197e 100644 --- a/negpy/services/export/encoders.py +++ b/negpy/services/export/encoders.py @@ -12,6 +12,7 @@ from typing import Any, Dict, Optional import imagecodecs +import negpy_core import numpy as np import tifffile from PIL import Image, ImageFile, PngImagePlugin @@ -22,7 +23,7 @@ _PNG_METRE = 1 # pHYs unit specifier _INCH_PER_METRE = 39.3701 -# optimize and progressive make PIL encode the whole frame into one buffer sized +# PIL's progressive encode holds the whole frame in one buffer sized # `w*h + ImageFile.MAXBLOCK`, which a large noisy scan overflows: the save then fails # with "broken data stream when writing image file". MAXBLOCK is module-global, so the # raise is serialised and restored around the encode. @@ -98,7 +99,13 @@ def encode_jpeg( quality: int = 90, progressive: bool = False, ) -> bytes: - """Encode uint8 pixels as JPEG. Chroma subsampling stays 4:4:4 (#224).""" + """Encode uint8 pixels as JPEG. Chroma subsampling stays 4:4:4 (#224). A baseline file comes + from negpy_core, which encodes strips in parallel: its pixels are PIL's and only the restart + markers differ.""" + if not progressive and arr.dtype == np.uint8 and (arr.ndim == 2 or (arr.ndim == 3 and arr.shape[2] == 3)): + # PIL writes a density only when both rounded values are positive. + dpi = None if resolution is None else (round(resolution.x_dpi), round(resolution.y_dpi)) + return negpy_core.encode_jpeg(np.ascontiguousarray(arr), quality, icc or None, dpi if dpi and min(dpi) > 0 else None) buf = io.BytesIO() kwargs: Dict[str, Any] = { "format": "JPEG", diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md index 277ba0f41..41eaa3a7d 100644 --- a/negpy_core/LEDGER.md +++ b/negpy_core/LEDGER.md @@ -44,3 +44,4 @@ tolerance listed for each entry. | `desktop/workers/chart_fit.py::ChartFitWorker.__init__` | cv2 took the area scale from `fx` while it rounded the output size; negpy_core takes the scale from the rounded size, so the cells tile the frame exactly. | none (no recordings) | | `infrastructure/simulated/gphoto.py::preview_jpeg` | The simulated camera's preview JPEG is Pillow's encoder at quality 95, not libjpeg through cv2. | none | | `kernel/image/logic.py::srgb_to_linear` (JPEG and 8-bit TIFF loads, placeholder thumbnails) | The 2.4 power is `mathf::pow_f64` rounded to float32, within an ULP of the correctly rounded value; NumPy's float32 power here runs SVML, up to 4 ULP off on most inputs. The placeholder thumbnail's log stretch (`thumbnails.preview_positive`) also takes `mathf::log10f`, so a few values land one uint8 level apart. | ulp=4; uint8 levels within 1 | +| `services/export/encoders.py::encode_jpeg` (baseline files) | Restart markers split the scan, so strips encode in parallel. Each interval starts its DC prediction at zero, which changes the Huffman statistics, so the tables and bytes differ from PIL's. Without restart markers the bytes are PIL's (`tests/test_core_jpeg.py`). Progressive files stay on PIL. | decoded pixels exact | diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml index eb7dfe587..c15ae3e5e 100644 --- a/negpy_core/manifest.toml +++ b/negpy_core/manifest.toml @@ -2692,6 +2692,13 @@ calls = ["negpy_core.resize_u8"] depends = [] note = "negpy_core.resize_u8" +["services/export/encoders.py::encode_jpeg"] +phase = 7 +status = "ported" +compare = "quality" +calls = ["negpy_core.encode_jpeg"] +depends = [] + ["services/export/linear_output.py::_apply_ice"] phase = 7 status = "ported" diff --git a/negpy_core/src/jpeg.rs b/negpy_core/src/jpeg.rs new file mode 100644 index 000000000..697d7d8a0 --- /dev/null +++ b/negpy_core/src/jpeg.rs @@ -0,0 +1,572 @@ +//! Baseline 4:4:4 JPEG of uint8 RGB or gray, as Pillow writes it through libjpeg-turbo with +//! `subsampling=0, optimize=True`: the quality-scaled Annex K tables, fixed-point YCbCr, the +//! islow DCT and optimal Huffman tables. Restart markers split the scan into strips that encode +//! in parallel; they change the bytes but not one decoded pixel. Without them the bytes are +//! Pillow's. + +// The tables index several arrays by symbol or by code length in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +const NATURAL: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, + 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, + 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +const STD_LUMA: [u32; 64] = [ + 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56, + 14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68, 109, 103, 77, 24, 35, 55, 64, 81, 104, 113, + 92, 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99, +]; + +const STD_CHROMA: [u32; 64] = [ + 17, 18, 24, 47, 99, 99, 99, 99, 18, 21, 26, 66, 99, 99, 99, 99, 24, 26, 56, 99, 99, 99, 99, 99, + 47, 66, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, +]; + +pub struct Options<'a> { + pub quality: i32, + pub icc: Option<&'a [u8]>, + pub dpi: Option<(u16, u16)>, + /// MCU rows per restart interval; 0 for one interval, None to pick one for the cores. + pub restart_rows: Option, +} + +/// `jpeg_set_quality(q, force_baseline=TRUE)`'s table, in natural order. +fn quant_table(basic: &[u32; 64], quality: i32) -> [u32; 64] { + let q = quality.clamp(1, 100); + let scale = if q < 50 { 5000 / q } else { 200 - 2 * q } as i64; + basic.map(|b| ((b as i64 * scale + 50) / 100).clamp(1, 255) as u32) +} + +/// jccolor.c's fixed-point YCbCr weights. Its tables are these products, so a row computes the +/// same sums in vector lanes. +const fn fix(x: f64) -> i32 { + (x * 65536.0 + 0.5) as i32 +} +const HALF: i32 = 1 << 15; +const CBCR: i32 = (128 << 16) + HALF - 1; +const Y_W: [i32; 3] = [fix(0.29900), fix(0.58700), fix(0.11400)]; +const CB_W: [i32; 3] = [-fix(0.16874), -fix(0.33126), fix(0.5)]; +const CR_W: [i32; 3] = [fix(0.5), -fix(0.41869), -fix(0.08131)]; + +/// One RGB row to Y, Cb and Cr rows of the padded width, the last pixel repeated into the pad. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn ycc_row(src: &[u8], [y, cb, cr]: [&mut [u8]; 3]) { + let w = src.len() / 3; + let px = src.as_chunks::<3>().0; + let sum = |wt: [i32; 3], p: &[u8; 3], off: i32| { + ((wt[0] * p[0] as i32 + wt[1] * p[1] as i32 + wt[2] * p[2] as i32 + off) >> 16) as u8 + }; + for (((y, cb), cr), p) in y.iter_mut().zip(cb.iter_mut()).zip(cr.iter_mut()).zip(px) { + *y = sum(Y_W, p, HALF); + *cb = sum(CB_W, p, CBCR); + *cr = sum(CR_W, p, CBCR); + } + for plane in [y, cb, cr] { + let last = plane[w - 1]; + plane[w..].fill(last); + } +} + +/// jfdctint.c, `jpeg_fdct_islow`, on a block given transposed (`t[col][row]`), so each pass +/// runs the eight rows or columns side by side; the result is in natural order, scaled by 8. +#[inline(always)] +fn fdct_islow(t: &mut [[i32; 8]; 8]) { + for j in 0..8 { + fdct_pass::(t, j); + } + let mut u = [[0i32; 8]; 8]; + for (r, row) in u.iter_mut().enumerate() { + for (c, v) in row.iter_mut().enumerate() { + *v = t[c][r]; + } + } + for j in 0..8 { + fdct_pass::(&mut u, j); + } + *t = u; +} + +/// One lane of the islow DCT: the row pass (`FIRST`) or the column pass. +#[inline(always)] +fn fdct_pass(d: &mut [[i32; 8]; 8], j: usize) { + const CB: i32 = 13; + const P1: i32 = 2; + let descale = |x: i32, n: i32| (x + (1 << (n - 1))) >> n; + let odd = if FIRST { CB - P1 } else { CB + P1 }; + let (tmp0, tmp7) = (d[0][j] + d[7][j], d[0][j] - d[7][j]); + let (tmp1, tmp6) = (d[1][j] + d[6][j], d[1][j] - d[6][j]); + let (tmp2, tmp5) = (d[2][j] + d[5][j], d[2][j] - d[5][j]); + let (tmp3, tmp4) = (d[3][j] + d[4][j], d[3][j] - d[4][j]); + let (tmp10, tmp13) = (tmp0 + tmp3, tmp0 - tmp3); + let (tmp11, tmp12) = (tmp1 + tmp2, tmp1 - tmp2); + if FIRST { + d[0][j] = (tmp10 + tmp11) << P1; + d[4][j] = (tmp10 - tmp11) << P1; + } else { + d[0][j] = descale(tmp10 + tmp11, P1); + d[4][j] = descale(tmp10 - tmp11, P1); + } + let z1 = (tmp12 + tmp13) * 4433; + d[2][j] = descale(z1 + tmp13 * 6270, odd); + d[6][j] = descale(z1 + tmp12 * -15137, odd); + let (z1, z2, z3, z4) = (tmp4 + tmp7, tmp5 + tmp6, tmp4 + tmp6, tmp5 + tmp7); + let z5 = (z3 + z4) * 9633; + let (tmp4, tmp5, tmp6, tmp7) = (tmp4 * 2446, tmp5 * 16819, tmp6 * 25172, tmp7 * 12299); + let (z1, z2) = (z1 * -7373, z2 * -20995); + let (z3, z4) = (z3 * -16069 + z5, z4 * -3196 + z5); + d[7][j] = descale(tmp4 + z1 + z3, odd); + d[5][j] = descale(tmp5 + z2 + z4, odd); + d[3][j] = descale(tmp6 + z2 + z3, odd); + d[1][j] = descale(tmp7 + z1 + z4, odd); +} + +/// libjpeg's rounded quantization, `(|v| + d / 2) / d` with the sign put back, for the islow +/// divisor `d = 8 q`. The division is a multiply by ceil(2^32 / d): exact while |v| + d / 2 +/// stays under 2^16 and d under 2^12, which every 8-bit DCT output and baseline table keeps. +struct Divisors { + half: [u32; 64], + recip: [u64; 64], +} + +impl Divisors { + fn new(q: &[u32; 64]) -> Self { + Divisors { + half: q.map(|q| (q << 3) >> 1), + recip: q.map(|q| (1u64 << 32).div_ceil((q << 3) as u64)), + } + } + + #[inline(always)] + fn quantize(&self, block: &mut [[i32; 8]; 8]) { + for ((v, &h), &r) in block + .as_flattened_mut() + .iter_mut() + .zip(&self.half) + .zip(&self.recip) + { + let m = (((v.unsigned_abs() + h) as u64 * r) >> 32) as i32; + *v = if *v < 0 { -m } else { m }; + } + } +} + +/// The quantized blocks of one MCU row, in scan order (block column, then component), from +/// the component planes of its eight padded rows. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn dct_row(planes: &[Vec], pw: usize, divisors: &[Divisors; 2], out: &mut [[[i32; 8]; 8]]) { + let cn = planes.len(); + for (i, block) in out.iter_mut().enumerate() { + let (bx, ci) = (i / cn, i % cn); + let plane = &planes[ci]; + for (r, row) in plane.chunks_exact(pw).enumerate() { + for (c, &v) in row[bx * 8..bx * 8 + 8].iter().enumerate() { + block[c][r] = v as i32 - 128; + } + } + fdct_islow(block); + divisors[ci.min(1)].quantize(block); + } +} + +/// The quantized coefficients of every block of MCU rows `rows`, handed to `f` in scan order +/// (component index, block in natural order). Rows and columns past the image repeat its edge. +fn for_each_block( + px: &[u8], + (h, w, cn): (usize, usize, usize), + rows: std::ops::Range, + divisors: &[Divisors; 2], + mut f: impl FnMut(usize, &[i32]), +) { + let pw = w.div_ceil(8) * 8; + let mut planes = vec![vec![0u8; 8 * pw]; cn]; + let mut blocks = vec![[[0i32; 8]; 8]; pw / 8 * cn]; + for mcu_row in rows { + for r in 0..8 { + let y = (mcu_row * 8 + r).min(h - 1); + let src = &px[y * w * cn..(y + 1) * w * cn]; + if cn == 3 { + let [a, b, c] = &mut planes[..] else { + unreachable!() + }; + let span = r * pw..(r + 1) * pw; + ycc_row( + src, + [&mut a[span.clone()], &mut b[span.clone()], &mut c[span]], + ); + } else { + let dst = &mut planes[0][r * pw..(r + 1) * pw]; + dst[..w].copy_from_slice(src); + let last = src[w - 1]; + dst[w..].fill(last); + } + } + dct_row(&planes, pw, divisors, &mut blocks); + for (i, b) in blocks.iter().enumerate() { + f(i % cn, b.as_flattened()); + } + } +} + +fn nbits(v: i32) -> u32 { + 32 - v.unsigned_abs().leading_zeros() +} + +/// Symbol counts per table: [dc0, ac0, dc1, ac1]. +type Counts = [[i64; 257]; 4]; + +fn count_block(counts: &mut Counts, t: usize, block: &[i32], last_dc: &mut i32) { + counts[2 * t][nbits(block[0] - *last_dc) as usize] += 1; + *last_dc = block[0]; + let mut run = 0; + for &n in &NATURAL[1..] { + let v = block[n]; + if v == 0 { + run += 1; + continue; + } + while run > 15 { + counts[2 * t + 1][0xF0] += 1; + run -= 16; + } + counts[2 * t + 1][(run << 4) + nbits(v) as usize] += 1; + run = 0; + } + if run > 0 { + counts[2 * t + 1][0] += 1; + } +} + +/// jchuff.c's `jpeg_gen_optimal_table`: (bits[1..=16], huffval). +fn optimal_table(freq: &[i64; 257]) -> ([u8; 17], Vec) { + const MAX_CLEN: usize = 32; + let mut freq = *freq; + let mut bits = [0u8; MAX_CLEN + 1]; + let mut codesize = [0usize; 257]; + let mut others = [-1i64; 257]; + freq[256] = 1; + loop { + let mut c1 = -1i64; + let mut v = 1_000_000_000i64; + for (i, &f) in freq.iter().enumerate() { + if f != 0 && f <= v { + v = f; + c1 = i as i64; + } + } + let mut c2 = -1i64; + v = 1_000_000_000; + for (i, &f) in freq.iter().enumerate() { + if f != 0 && f <= v && i as i64 != c1 { + v = f; + c2 = i as i64; + } + } + if c2 < 0 { + break; + } + let (mut a, mut b) = (c1 as usize, c2 as usize); + freq[a] += freq[b]; + freq[b] = 0; + codesize[a] += 1; + while others[a] >= 0 { + a = others[a] as usize; + codesize[a] += 1; + } + others[a] = b as i64; + codesize[b] += 1; + while others[b] >= 0 { + b = others[b] as usize; + codesize[b] += 1; + } + } + for &s in &codesize { + if s != 0 { + bits[s] += 1; + } + } + let mut i = MAX_CLEN; + while i > 16 { + while bits[i] > 0 { + let mut j = i - 2; + while bits[j] == 0 { + j -= 1; + } + bits[i] -= 2; + bits[i - 1] += 1; + bits[j + 1] += 2; + bits[j] -= 1; + } + i -= 1; + } + while bits[i] == 0 { + i -= 1; + } + bits[i] -= 1; + let mut huffval = Vec::new(); + for len in 1..=MAX_CLEN { + for (sym, &s) in codesize.iter().enumerate().take(256) { + if s == len { + huffval.push(sym as u8); + } + } + } + let mut out = [0u8; 17]; + out.copy_from_slice(&bits[..17]); + (out, huffval) +} + +/// Canonical codes (code, length) by symbol, as `jpeg_make_c_derived_tbl` assigns them. +fn derived(bits: &[u8; 17], huffval: &[u8]) -> [(u32, u32); 256] { + let mut table = [(0u32, 0u32); 256]; + let (mut code, mut k) = (0u32, 0usize); + for len in 1..=16 { + for _ in 0..bits[len] { + table[huffval[k] as usize] = (code, len as u32); + code += 1; + k += 1; + } + code <<= 1; + } + table +} + +struct BitWriter { + out: Vec, + acc: u64, + n: u32, +} + +impl BitWriter { + fn put(&mut self, code: u32, size: u32) { + self.acc = (self.acc << size) | code as u64; + self.n += size; + while self.n >= 8 { + self.n -= 8; + let b = (self.acc >> self.n) as u8; + self.out.push(b); + if b == 0xFF { + self.out.push(0); + } + } + self.acc &= (1u64 << self.n) - 1; + } + + /// Pads the last byte with ones, as libjpeg's `flush_bits` does. + fn flush(&mut self) { + if self.n > 0 { + self.put((1 << (8 - self.n)) - 1, 8 - self.n); + } + } +} + +type Codes = [[(u32, u32); 256]; 4]; + +fn encode_block(bw: &mut BitWriter, codes: &Codes, t: usize, block: &[i32], last_dc: &mut i32) { + let (dc, ac) = (&codes[2 * t], &codes[2 * t + 1]); + let emit = |bw: &mut BitWriter, v: i32, n: u32| { + if n > 0 { + let bits = if v < 0 { v - 1 } else { v }; + bw.put(bits as u32 & ((1 << n) - 1), n); + } + }; + let diff = block[0] - *last_dc; + *last_dc = block[0]; + let n = nbits(diff); + bw.put(dc[n as usize].0, dc[n as usize].1); + emit(bw, diff, n); + let mut run = 0; + for &k in &NATURAL[1..] { + let v = block[k]; + if v == 0 { + run += 1; + continue; + } + while run > 15 { + bw.put(ac[0xF0].0, ac[0xF0].1); + run -= 16; + } + let n = nbits(v); + let s = (run << 4) + n as usize; + bw.put(ac[s].0, ac[s].1); + emit(bw, v, n); + run = 0; + } + if run > 0 { + bw.put(ac[0].0, ac[0].1); + } +} + +fn marker(out: &mut Vec, m: u8, body: &[u8]) { + out.extend_from_slice(&[0xFF, m]); + out.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(body); +} + +/// The JPEG of an HxW (`cn` 1) or HxWx3 uint8 image. None for an empty image or one too wide +/// for a restart interval. +pub fn encode(px: &[u8], (h, w, cn): (usize, usize, usize), o: &Options) -> Option> { + if h == 0 || w == 0 || h > 65535 || w > 65535 || !(cn == 1 || cn == 3) || px.len() != h * w * cn + { + return None; + } + let quant = [ + quant_table(&STD_LUMA, o.quality), + quant_table(&STD_CHROMA, o.quality), + ]; + let divisors = [Divisors::new(&quant[0]), Divisors::new(&quant[1])]; + let (mcu_rows, mcu_cols) = (h.div_ceil(8), w.div_ceil(8)); + let rows = match o.restart_rows { + Some(0) => mcu_rows, + Some(r) => r.max(1), + None => mcu_rows.div_ceil(4 * rayon::current_num_threads()).max(1), + }; + // The restart interval counts MCUs in 16 bits. + let rows = if rows < mcu_rows { + rows.min(65535 / mcu_cols).max(1) + } else { + rows + }; + let restart = rows < mcu_rows; + let strips: Vec> = (0..mcu_rows) + .step_by(rows) + .map(|s| s..(s + rows).min(mcu_rows)) + .collect(); + let table = |ci: usize| ci.min(1); + + let counts = strips + .par_iter() + .map(|r| { + let mut counts: Counts = [[0; 257]; 4]; + let mut last = [0i32; 3]; + for_each_block(px, (h, w, cn), r.clone(), &divisors, |ci, b| { + count_block(&mut counts, table(ci), b, &mut last[ci]); + }); + counts + }) + .reduce( + || [[0; 257]; 4], + |mut a, b| { + for (x, y) in a.iter_mut().zip(&b) { + x.iter_mut().zip(y).for_each(|(p, q)| *p += q); + } + a + }, + ); + let tables: Vec<([u8; 17], Vec)> = (0..if cn == 3 { 4 } else { 2 }) + .map(|t| optimal_table(&counts[t])) + .collect(); + let mut codes: Codes = [[(0, 0); 256]; 4]; + for (c, (bits, vals)) in codes.iter_mut().zip(&tables) { + *c = derived(bits, vals); + } + + let scans: Vec> = strips + .par_iter() + .map(|r| { + let mut bw = BitWriter { + out: Vec::with_capacity(r.len() * mcu_cols * cn * 32), + acc: 0, + n: 0, + }; + let mut last = [0i32; 3]; + for_each_block(px, (h, w, cn), r.clone(), &divisors, |ci, b| { + encode_block(&mut bw, &codes, table(ci), b, &mut last[ci]); + }); + bw.flush(); + bw.out + }) + .collect(); + + let mut out = vec![0xFF, 0xD8]; + let (unit, dx, dy) = o.dpi.map_or((0u8, 1u16, 1u16), |(x, y)| (1, x, y)); + let mut jfif = b"JFIF\0\x01\x01".to_vec(); + jfif.push(unit); + jfif.extend_from_slice(&dx.to_be_bytes()); + jfif.extend_from_slice(&dy.to_be_bytes()); + jfif.extend_from_slice(&[0, 0]); + marker(&mut out, 0xE0, &jfif); + if let Some(icc) = o.icc.filter(|i| !i.is_empty()) { + let chunks: Vec<&[u8]> = icc.chunks(65533 - 14).collect(); + for (i, chunk) in chunks.iter().enumerate() { + let mut body = b"ICC_PROFILE\0".to_vec(); + body.extend_from_slice(&[i as u8 + 1, chunks.len() as u8]); + body.extend_from_slice(chunk); + marker(&mut out, 0xE2, &body); + } + } + for (t, q) in quant.iter().enumerate().take(if cn == 3 { 2 } else { 1 }) { + let mut body = vec![t as u8]; + body.extend(NATURAL.iter().map(|&n| q[n] as u8)); + marker(&mut out, 0xDB, &body); + } + let mut sof = vec![8]; + sof.extend_from_slice(&(h as u16).to_be_bytes()); + sof.extend_from_slice(&(w as u16).to_be_bytes()); + sof.push(cn as u8); + for ci in 0..cn { + sof.extend_from_slice(&[ci as u8 + 1, 0x11, table(ci) as u8]); + } + marker(&mut out, 0xC0, &sof); + for (t, (bits, vals)) in tables.iter().enumerate() { + let mut body = vec![(((t % 2) << 4) | (t / 2)) as u8]; + body.extend_from_slice(&bits[1..]); + body.extend_from_slice(vals); + marker(&mut out, 0xC4, &body); + } + if restart { + marker(&mut out, 0xDD, &((rows * mcu_cols) as u16).to_be_bytes()); + } + let mut sos = vec![cn as u8]; + for ci in 0..cn { + let t = table(ci) as u8; + sos.extend_from_slice(&[ci as u8 + 1, t << 4 | t]); + } + sos.extend_from_slice(&[0, 63, 0]); + marker(&mut out, 0xDA, &sos); + let total: usize = scans.iter().map(Vec::len).sum(); + out.reserve(total + 2 * scans.len() + 2); + for (i, s) in scans.iter().enumerate() { + if i > 0 { + out.extend_from_slice(&[0xFF, 0xD0 + ((i - 1) % 8) as u8]); + } + out.extend_from_slice(s); + } + out.extend_from_slice(&[0xFF, 0xD9]); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quality_90_tables_match_libjpeg() { + let q = quant_table(&STD_LUMA, 90); + assert_eq!(&q[..8], &[3, 2, 2, 3, 5, 8, 10, 12]); + assert_eq!(quant_table(&STD_CHROMA, 90)[0], 3); + assert_eq!(quant_table(&STD_LUMA, 1)[0], 255); + } + + #[test] + fn a_flat_block_has_only_dc() { + let mut b = [[10i32; 8]; 8]; + fdct_islow(&mut b); + let b = b.as_flattened(); + assert_eq!(b[0], 10 * 64); + assert!(b[1..].iter().all(|&v| v == 0)); + } +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs index cc3c3af5d..05508b3da 100644 --- a/negpy_core/src/lib.rs +++ b/negpy_core/src/lib.rs @@ -17,6 +17,7 @@ pub mod gray; pub mod hdr; pub mod histogram; pub mod inpaint; +pub mod jpeg; pub mod lab; pub mod lens; pub mod loaders; @@ -96,5 +97,6 @@ fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> py::register::register(m)?; py::autocrop::register(m)?; py::zones::register(m)?; - py::pool::register(m) + py::pool::register(m)?; + py::jpeg::register(m) } diff --git a/negpy_core/src/py/jpeg.rs b/negpy_core/src/py/jpeg.rs new file mode 100644 index 000000000..2d4a0beee --- /dev/null +++ b/negpy_core/src/py/jpeg.rs @@ -0,0 +1,46 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +/// Pillow's `save(format="JPEG", quality=..., subsampling=0, optimize=True, icc_profile=..., +/// dpi=...)` of an HxW or HxWx3 uint8 array, with restart markers every `restart_rows` MCU rows +/// (0 for none, None to split the scan across the cores). +#[pyfunction] +#[pyo3(signature = (img, quality=90, icc=None, dpi=None, restart_rows=None))] +fn encode_jpeg<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, + quality: i32, + icc: Option<&[u8]>, + dpi: Option<(u16, u16)>, + restart_rows: Option, +) -> PyResult> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "encode_jpeg needs an HxW or HxWx3 array", + )); + } + }; + let data = img + .as_slice() + .map_err(|_| PyValueError::new_err("encode_jpeg needs a contiguous array"))?; + let options = crate::jpeg::Options { + quality, + icc, + dpi, + restart_rows, + }; + let out = super::detach(py, data.len(), || { + crate::jpeg::encode(data, (h, w, cn), &options) + }) + .ok_or_else(|| PyValueError::new_err("encode_jpeg: image empty or over 65535 px"))?; + Ok(PyBytes::new(py, &out)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(encode_jpeg, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs index dddb56d05..e4caf415d 100644 --- a/negpy_core/src/py/mod.rs +++ b/negpy_core/src/py/mod.rs @@ -14,6 +14,7 @@ pub mod flatfield; pub mod gray; pub mod hdr; pub mod histogram; +pub mod jpeg; pub mod lab; pub mod lens; pub mod loaders; diff --git a/tests/test_core_jpeg.py b/tests/test_core_jpeg.py new file mode 100644 index 000000000..3056b7e32 --- /dev/null +++ b/tests/test_core_jpeg.py @@ -0,0 +1,83 @@ +"""negpy_core's baseline JPEG against PIL's: the same bytes without restart markers, the same +decoded pixels with them, and the same pixels through the export encoder.""" + +import io + +import numpy as np +import pytest +from PIL import Image, ImageFile + +import negpy_core +from negpy.features.metadata.resolution import Resolution +from negpy.services.export.encoders import encode_jpeg + + +def _pil(arr, quality=90, icc=None, dpi=None): + kwargs = {"format": "JPEG", "quality": quality, "subsampling": 0, "optimize": True} + if icc: + kwargs["icc_profile"] = icc + if dpi: + kwargs["dpi"] = dpi + buf = io.BytesIO() + prev = ImageFile.MAXBLOCK + ImageFile.MAXBLOCK = max(prev, 3 * arr.shape[0] * arr.shape[1]) + try: + Image.fromarray(arr).save(buf, **kwargs) + finally: + ImageFile.MAXBLOCK = prev + return buf.getvalue() + + +def _pixels(data): + return np.asarray(Image.open(io.BytesIO(data))) + + +def _photo(h, w): + """Smooth gradients with edges and grain, so every coefficient band carries signal.""" + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + rng = np.random.default_rng(3) + base = np.stack([128 + 90 * np.sin(x / 23 + c) * np.cos(y / 17 - c) for c in range(3)], axis=-1) + base += (x[..., None] > w / 2) * 40 + rng.normal(0, 6, (h, w, 3)) + return np.clip(base, 0, 255).astype(np.uint8) + + +CASES = { + "photo": _photo(203, 317), + "noise": (np.random.default_rng(0).random((37, 53, 3)) * 255).astype(np.uint8), + "gray": _photo(121, 89)[..., 1].copy(), + "flat": np.full((16, 24, 3), 77, np.uint8), +} + + +@pytest.mark.parametrize("name", CASES) +@pytest.mark.parametrize("quality", [10, 75, 90, 100]) +def test_without_restarts_the_bytes_are_pils(name, quality): + arr = CASES[name] + assert negpy_core.encode_jpeg(arr, quality, None, None, 0) == _pil(arr, quality) + + +def test_icc_chunks_and_density_are_written_as_pil_writes_them(): + arr = CASES["photo"] + icc = bytes(range(256)) * 300 + assert negpy_core.encode_jpeg(arr, 90, icc, (300, 240), 0) == _pil(arr, icc=icc, dpi=(300, 240)) + + +@pytest.mark.parametrize("name", CASES) +@pytest.mark.parametrize("rows", [1, 3, None]) +def test_restart_markers_keep_every_pixel(name, rows): + arr = CASES[name] + np.testing.assert_array_equal(_pixels(negpy_core.encode_jpeg(arr, 90, None, None, rows)), _pixels(_pil(arr))) + + +def test_the_export_encoder_keeps_pils_pixels_and_density(): + arr = CASES["photo"] + data = encode_jpeg(arr, icc=b"\x00" * 128, resolution=Resolution.from_dpi(300), quality=88) + np.testing.assert_array_equal(_pixels(data), _pixels(_pil(arr, 88))) + img = Image.open(io.BytesIO(data)) + assert img.info["dpi"] == (300, 300) + assert img.info["icc_profile"] == b"\x00" * 128 + + +def test_a_progressive_export_stays_on_pil(): + arr = CASES["photo"] + assert Image.open(io.BytesIO(encode_jpeg(arr, progressive=True))).info.get("progressive") From 20c42082637c3cded18a80b8ff7818b0d346529c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 24 Oct 2026 09:32:20 +0200 Subject: [PATCH 130/133] ci: drop the LLVM setup for llvmlite Numba left with the Rust port, so no dependency builds llvmlite from source on Intel macOS any more. --- .github/workflows/ci.yml | 11 ----------- .github/workflows/release.yml | 11 +---------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60a62051a..1198d290d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,17 +124,6 @@ jobs: sudo apt-get update sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 - - name: Configure LLVM for llvmlite (macOS x86_64) - # llvmlite has no Intel macOS wheel, so it builds from source against LLVM 20. - if: matrix.os == 'macos-15-intel' - shell: bash - run: | - brew install llvm@20 - LLVM20="$(brew --prefix llvm@20)" - echo "PATH=/usr/local/bin:${LLVM20}/bin:${PATH}" >> "$GITHUB_ENV" - echo "LLVM_CONFIG=${LLVM20}/bin/llvm-config" >> "$GITHUB_ENV" - echo "CMAKE_ARGS=-DLLVM_DIR=${LLVM20}/lib/cmake/llvm -DCMAKE_PREFIX_PATH=${LLVM20}" >> "$GITHUB_ENV" - - name: Install Dependencies # The plustek group brings opencv, the oracle the ported-function tests compare against. run: uv sync --group dev --group plustek diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e1b7565e..815d19a3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,7 +80,7 @@ jobs: - name: Install System Dependencies (macOS) if: startsWith(matrix.os, 'macos-') - run: brew install sane-backends llvm@20 + run: brew install sane-backends - name: Install Packaging Tools (Windows) if: matrix.os == 'windows-latest' @@ -97,15 +97,6 @@ jobs: if: matrix.os == 'ubuntu-latest' run: uv sync --all-groups - - name: Configure LLVM for llvmlite (macOS x86_64) - if: matrix.arch == 'x86_64' - shell: bash - run: | - LLVM20="$(brew --prefix llvm@20)" - echo "PATH=/usr/local/bin:${LLVM20}/bin:${PATH}" >> "$GITHUB_ENV" - echo "LLVM_CONFIG=${LLVM20}/bin/llvm-config" >> "$GITHUB_ENV" - echo "CMAKE_ARGS=-DLLVM_DIR=${LLVM20}/lib/cmake/llvm -DCMAKE_PREFIX_PATH=${LLVM20}" >> "$GITHUB_ENV" - - name: Install Dependencies (macOS) if: startsWith(matrix.os, 'macos-') run: CFLAGS="-I$(brew --prefix)/include" LDFLAGS="-L$(brew --prefix)/lib" uv sync --all-groups From d5c1c1c55472835b95c663dde0e5a784b842b236 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 24 Oct 2026 09:34:56 +0200 Subject: [PATCH 131/133] ci: move actions to their Node 24 releases; pin the release's Linux runner checkout v7, cache v6, upload-artifact v7, download-artifact v8, github-script v9, setup-uv v10.3.2 (it no longer tags majors) and action-gh-release v3 run on Node 24; GitHub deprecates Node 20. The inputs the workflows pass exist in each new version. The release builds Linux on ubuntu-24.04: ubuntu-latest moves to Ubuntu 26 from October 19, and a newer glibc would stop the AppImage from starting on older distributions. --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/metrics.yml | 10 +++++----- .github/workflows/release.yml | 20 +++++++++++--------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1198d290d..1c2384496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true cache-dependency-glob: "uv.lock" @@ -78,7 +78,7 @@ jobs: run: uv run pytest tests/ --cov=negpy --cov-report=term-missing --cov-report=xml - name: Upload Coverage Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage path: coverage.xml @@ -95,10 +95,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index a88b666ee..75cc3455d 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -48,10 +48,10 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true @@ -65,7 +65,7 @@ jobs: - name: Restore perf fixture cache id: cache-fixture - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/negpy-metrics key: negpy-perf-raw-rawsamples-v1 @@ -105,7 +105,7 @@ jobs: test -f "$NEGPY_METRICS_OUT" && cat "$NEGPY_METRICS_OUT" || true - name: Upload metrics JSON - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: preview-metrics path: metrics-artifacts/preview_metrics.json @@ -114,7 +114,7 @@ jobs: - name: Post PR comment if: github.event_name == 'pull_request' continue-on-error: true - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 815d19a3b..6b1c0f573 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,14 +29,16 @@ jobs: outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: + # Linux builds on a fixed Ubuntu: the AppImage needs the oldest glibc it can get, and + # ubuntu-latest moves to a newer release on its own. - id: set-matrix run: | if [ "${{ github.event.inputs.platform }}" == "all" ]; then - echo 'matrix=[{"os":"ubuntu-latest"},{"os":"windows-latest"},{"os":"macos-14","arch":"arm64"},{"os":"macos-15-intel","arch":"x86_64"}]' >> $GITHUB_OUTPUT + echo 'matrix=[{"os":"ubuntu-24.04"},{"os":"windows-latest"},{"os":"macos-14","arch":"arm64"},{"os":"macos-15-intel","arch":"x86_64"}]' >> $GITHUB_OUTPUT elif [ "${{ github.event.inputs.platform }}" == "windows" ]; then echo 'matrix=[{"os":"windows-latest"}]' >> $GITHUB_OUTPUT elif [ "${{ github.event.inputs.platform }}" == "linux" ]; then - echo 'matrix=[{"os":"ubuntu-latest"}]' >> $GITHUB_OUTPUT + echo 'matrix=[{"os":"ubuntu-24.04"}]' >> $GITHUB_OUTPUT elif [ "${{ github.event.inputs.platform }}" == "macos" ]; then echo 'matrix=[{"os":"macos-14","arch":"arm64"},{"os":"macos-15-intel","arch":"x86_64"}]' >> $GITHUB_OUTPUT fi @@ -50,10 +52,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true cache-dependency-glob: "uv.lock" @@ -71,7 +73,7 @@ jobs: workspaces: negpy_core - name: Install System Dependencies (Linux) - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-24.04' run: | sudo apt-get update sudo apt-get install -y libfuse2 libxcb-cursor0 libxkbcommon-x11-0 libxcb-xinerama0 patchelf libsane-dev @@ -94,7 +96,7 @@ jobs: } - name: Install Dependencies (Linux) - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-24.04' run: uv sync --all-groups - name: Install Dependencies (macOS) @@ -126,7 +128,7 @@ jobs: run: ls -R dist/ - name: Upload Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: negpy-${{ matrix.os }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }}-${{ github.event.inputs.version }} if-no-files-found: error @@ -144,14 +146,14 @@ jobs: contents: write steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: all-binaries pattern: negpy-* merge-multiple: true - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.event.inputs.version }} name: NegPy ${{ github.event.inputs.version }} From afa017f88ea6a23de7ba7bf279de9c641ff5ab11 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 24 Oct 2026 09:41:26 +0200 Subject: [PATCH 132/133] test: background pool floor and the export name lock in two worker tests The thumbnail test still expected a quarter of the cores (1 on a 4-core runner), and the umask test builds an ExportWorker without __init__, so it lacked the lock that guards output names. --- tests/test_file_modes.py | 2 ++ tests/test_thumbnail_refresh_worker.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_file_modes.py b/tests/test_file_modes.py index 8c117ce3b..3c31b5d82 100644 --- a/tests/test_file_modes.py +++ b/tests/test_file_modes.py @@ -3,6 +3,7 @@ import os import pathlib import stat +import threading from types import SimpleNamespace import numpy as np @@ -35,6 +36,7 @@ def test_an_export_takes_the_umask_mode(tmp_path, monkeypatch): worker = export.ExportWorker.__new__(export.ExportWorker) worker._processor = SimpleNamespace(encode_export=lambda *a, **k: (b"jpeg bytes", "")) worker._written = set() + worker._names_lock = threading.Lock() monkeypatch.setattr(export, "_export_resolution", lambda task: None) monkeypatch.setattr(export, "resolve_export_naming", lambda task: (str(tmp_path), "frame", "jpg")) monkeypatch.setattr(export, "_export_target_is_a_source", lambda path, task: False) diff --git a/tests/test_thumbnail_refresh_worker.py b/tests/test_thumbnail_refresh_worker.py index e3fd99a5c..622b90f39 100644 --- a/tests/test_thumbnail_refresh_worker.py +++ b/tests/test_thumbnail_refresh_worker.py @@ -475,7 +475,8 @@ def test_thumbnail_render_runs_two_frames_at_once_on_the_background_pool(qapp, m worker.process(_task(_input("a", base), _input("b", base))) assert seen["most"] == 2 - assert seen["pool"] == {max(1, negpy_core.pool_threads() // 4)} + cores = negpy_core.pool_threads() + assert seen["pool"] == {min(cores, max(2, cores // 4))} assert rendered == ["key-a", "key-b"] assert progress == [1, 2] assert finished == [2] From 4904fc9e27559cc7d2120a22d980061e9dcc86a1 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 24 Oct 2026 10:10:00 +0200 Subject: [PATCH 133/133] docs: README names the Rust core and recommends running from source Python runs the interface; wgpu renders on the GPU and negpy_core does every CPU step. Running from source comes first under Getting started, with the release builds after it. --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7e382232b..081372951 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,14 @@ [![Downloads](https://img.shields.io/github/downloads/marcinz606/NegPy/total)](https://github.com/marcinz606/NegPy/releases) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.13%2B-blue)](pyproject.toml) + [![Rust](https://img.shields.io/badge/rust-negpy__core-orange?logo=rust)](negpy_core) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](#getting-started) [![Contributors](https://img.shields.io/github/contributors/marcinz606/NegPy)](https://github.com/marcinz606/NegPy/graphs/contributors) [![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/JySNzUWgwy) -NegPy turns scans of film negatives and slides into finished pictures. I wrote it because I wanted a tool made for film scans that does more than invert them. It models film and photographic paper, and adds the conveniences of a lab scanner. It is written in Python and runs on Linux, macOS and Windows. +NegPy turns scans of film negatives and slides into finished pictures. I wrote it because I wanted a tool made for film scans that does more than invert them. It models film and photographic paper, and adds the conveniences of a lab scanner. It runs on Linux, macOS and Windows. Python runs the interface and organizes the work, and Rust does the image processing. The GPU renders through wgpu, a Rust library. Every CPU step runs in `negpy_core` on all cores, with or without the GPU: analysis, detection, dust removal, export encoding and the CPU render path. ![NegPy main window](docs/media/0650.webp) @@ -53,7 +54,7 @@ The desktop interface and panel guides support English, Simplified Chinese (简 * Darkroom tools: test strips, ring-around, step wedge, zone overlay, spot densitometer and zone placement. * Contrast mask, tilt and swing, edge burn, filed carrier and print mats. * Lith, cyanotype and toners for B&W. -* GPU rendering (Vulkan, Metal or DX12) with a CPU fallback that gives the same result. +* GPU rendering through wgpu (Vulkan, Metal or DX12), and a multi-core CPU path in Rust that gives the same result. ### Output @@ -71,11 +72,9 @@ The desktop interface and panel guides support English, Simplified Chinese (简 ## Getting started -Download the build for your OS from the [Releases page](https://github.com/marcinz606/NegPy/releases). When a new version is out, a dot shows on the ⋯ menu, and **Update to vX.Y.Z…** in its Help menu installs it. +The recommended way is to run NegPy from source: you get each fix as soon as it is on `main`, and your system shows no warning for an unsigned app. [CONTRIBUTING.md](CONTRIBUTING.md#development-setup) has the steps: install `uv` and `rustup`, then run `make install` and `make run`. -To build from source, see [CONTRIBUTING.md](CONTRIBUTING.md). - -The builds are not signed, so expect a warning on first run. +You can also download the build for your OS from the [Releases page](https://github.com/marcinz606/NegPy/releases). When a new version is out, a dot shows on the ⋯ menu, and **Update to vX.Y.Z…** in its Help menu installs it. The builds are not signed, so expect a warning on first run. ### Linux