diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b131a1..493dcb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,10 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). public gradient, magnitude, and suppression images only to discard them. Added safe strided `canny_into`, reusable `CannyWorkspace`, large-image parallel stages, Python `out=`/workspace support, and a focused bit-exact - OpenCV comparison harness. + OpenCV comparison harness. Epic 118B/118D replace the full comparison- + magnitude image with parallel three-row rings and skip hysteresis when no + weak edges exist; 4K document-line reuse is 11.92× faster than the inspectable + path and measured 1.42× faster than OpenCV. - **Exact Euclidean distance transform**: `spatialrust-vision` now computes foreground-to-nearest-background L2 distances in linear time, supports anisotropic pixel spacing, exposes a NumPy binding, and includes native diff --git a/README.md b/README.md index 5b44429..03c091e 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ ratio; these are machine-specific measurements, not universal guarantees. | Morphology open 5×5, reuse[^morphology-2026] | OpenCV 60.32× | OpenCV 16.25× | OpenCV 17.78× | | Morphology open 511×511, allocate[^morphology-2026] | OpenCV 2.10× | **SpatialRust 2.61×** | **SpatialRust 2.40×** | | Morphology open 511×511, reuse[^morphology-2026] | OpenCV 2.46× | **SpatialRust 3.25×** | **SpatialRust 2.77×** | -| Canny 3×3, reuse, document lines[^canny-2026] | OpenCV 1.77× | OpenCV 1.69× | OpenCV 1.65× | -| Canny 3×3, reuse, sensor noise[^canny-2026] | OpenCV 3.66× | OpenCV 1.92× | OpenCV 1.79× | +| Canny 3×3, reuse, document lines[^canny-2026] | OpenCV 1.40× | **SpatialRust 1.36×** | **SpatialRust 1.42×** | +| Canny 3×3, reuse, sensor noise[^canny-2026] | OpenCV 3.58× | OpenCV 1.68× | OpenCV 1.57× | | Exact Euclidean distance transform, allocate | OpenCV 1.99× | OpenCV 1.85× | OpenCV 1.45× | | Exact Euclidean distance transform, reuse | OpenCV 1.02× | OpenCV 1.06× | **SpatialRust 1.07×** | @@ -186,12 +186,14 @@ records the exact environment and methodology. 5×5 latency by 20.7× at 1080p and 26.7× at 4K; OpenCV still leads the standalone operation. -[^canny-2026]: The allocation-light 3×3 path keeps inspectable intermediates - opt-in, adds caller-owned output plus reusable `CannyWorkspace`, and - parallelizes magnitude, directional suppression, and packed output on large - images. The focused OpenCV 4.13 receipt is bit-exact across 300 randomized - images. OpenCV still leads both named workloads; the old 10.66×–12.65× row - described the superseded always-materialize-all-intermediates path. +[^canny-2026]: The 3×3 fast path keeps inspectable intermediates opt-in, adds + caller-owned output plus reusable `CannyWorkspace`, and replaces the full + `i32` magnitude image with a parallel three-row-per-worker ring. When no weak + edges exist, it also skips unnecessary hysteresis traversal. The focused + OpenCV 4.13 receipt is bit-exact across 300 randomized images. Document-line + medians are OpenCV/SpatialRust 2.900/2.138 ms at 1080p and 11.480/8.103 ms at + 4K. Dense sensor noise remains an OpenCV win. Native 4K document lines improve + from 96.914 ms inspectable to 8.134 ms ring reuse (11.92×). [^resize-2026]: The packed RGB8 half-scale path precomputes arbitrary-scale Q11 sampling coefficients and specializes exact 2× downsampling as a diff --git a/bench/opencv_canny_comparison/README.md b/bench/opencv_canny_comparison/README.md index 05749f4..76825f5 100644 --- a/bench/opencv_canny_comparison/README.md +++ b/bench/opencv_canny_comparison/README.md @@ -13,3 +13,5 @@ profiles at VGA, 1080p, and 4K. Results are workload- and machine-specific. The report records raw interleaved samples, versions, thread count, OpenCL state, and caller-owned output timings. +It also records the reusable SpatialRust workspace's reserved byte count after +each profile. diff --git a/bench/opencv_canny_comparison/performance.py b/bench/opencv_canny_comparison/performance.py index eeb6e15..b835a4e 100644 --- a/bench/opencv_canny_comparison/performance.py +++ b/bench/opencv_canny_comparison/performance.py @@ -136,6 +136,7 @@ def spatialrust_reuse() -> np.ndarray: "opencv_reuse": cv_reuse, "spatialrust_reuse": sr_reuse, "spatialrust_reuse_speedup": cv_reuse_ms / sr_reuse_ms, + "spatialrust_workspace_allocated_bytes": workspace.allocated_bytes, } receipt = environment(opencv_version=cv2.__version__, spatialrust_version=sr.__version__) diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index 38e094e..58382b3 100644 --- a/crates/spatialrust-py/spatialrust.pyi +++ b/crates/spatialrust-py/spatialrust.pyi @@ -265,6 +265,8 @@ class CannyWorkspace: def __init__(self) -> None: ... @property def capacity(self) -> int: ... + @property + def allocated_bytes(self) -> int: ... def canny_image( image: _U8Array, diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index a577413..001d66b 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -2799,6 +2799,11 @@ impl PyCannyWorkspace { fn capacity(&self) -> usize { self.inner.capacity() } + + #[getter] + fn allocated_bytes(&self) -> usize { + self.inner.allocated_bytes() + } } /// Detects edges in a grayscale uint8 image with Canny hysteresis. diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index a7a5656..5981daa 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -780,6 +780,7 @@ def test_canny_image_reuses_output_and_workspace(): assert actual is output np.testing.assert_array_equal(actual, expected) assert workspace.capacity >= image.size + assert workspace.allocated_bytes > 0 def test_feature2d_corner_detectors_and_keypoint_metadata(): diff --git a/crates/spatialrust-vision/benches/canny.rs b/crates/spatialrust-vision/benches/canny.rs index 725a4c3..27a8bab 100644 --- a/crates/spatialrust-vision/benches/canny.rs +++ b/crates/spatialrust-vision/benches/canny.rs @@ -43,5 +43,39 @@ fn benchmark_canny(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, benchmark_canny); +fn benchmark_canny_document_lines(c: &mut Criterion) { + let mut group = c.benchmark_group("canny_document_lines"); + group.sample_size(10); + for &(name, width, height) in &[("1080p", 1920, 1080), ("4k", 3840, 2160)] { + let mut data = vec![0_u8; width * height]; + for y in (20..height).step_by(80) { + for row in y..(y + 3).min(height) { + data[row * width + 10..row * width + width - 10].fill(255); + } + } + let image = Image::::try_new(width, height, data).unwrap(); + let options = CannyOptions { + low_threshold: 80.0, + high_threshold: 160.0, + l2_gradient: true, + ..Default::default() + }; + group.throughput(Throughput::Elements((width * height) as u64)); + group.bench_function(BenchmarkId::new("inspectable", name), |b| { + b.iter(|| black_box(canny_with_intermediates(image.view(), options).unwrap())); + }); + let mut output = Image::::from_pixel(width, height, [0]).unwrap(); + let mut workspace = CannyWorkspace::new(); + canny_into(image.view(), options, output.view_mut(), &mut workspace).unwrap(); + group.bench_function(BenchmarkId::new("ring_reuse", name), |b| { + b.iter(|| { + canny_into(image.view(), options, output.view_mut(), &mut workspace).unwrap(); + black_box(output.as_slice()); + }); + }); + } + group.finish(); +} + +criterion_group!(benches, benchmark_canny, benchmark_canny_document_lines); criterion_main!(benches); diff --git a/crates/spatialrust-vision/src/canny.rs b/crates/spatialrust-vision/src/canny.rs index 80d40bd..bdbeaa7 100644 --- a/crates/spatialrust-vision/src/canny.rs +++ b/crates/spatialrust-vision/src/canny.rs @@ -71,7 +71,7 @@ pub struct CannyResult { pub struct CannyWorkspace { gradient_x: Vec, gradient_y: Vec, - comparison_magnitude: Vec, + magnitude_rows: Vec, states: Vec, strong: Vec, } @@ -83,7 +83,7 @@ impl CannyWorkspace { Self { gradient_x: Vec::new(), gradient_y: Vec::new(), - comparison_magnitude: Vec::new(), + magnitude_rows: Vec::new(), states: Vec::new(), strong: Vec::new(), } @@ -92,17 +92,23 @@ impl CannyWorkspace { /// Returns the reusable pixel capacity without counting the edge stack. #[must_use] pub fn capacity(&self) -> usize { - self.gradient_x - .capacity() - .min(self.gradient_y.capacity()) - .min(self.comparison_magnitude.capacity()) - .min(self.states.capacity()) + self.gradient_x.capacity().min(self.gradient_y.capacity()).min(self.states.capacity()) } - fn resize(&mut self, len: usize) { + /// Returns bytes reserved by all reusable vectors. + #[must_use] + pub fn allocated_bytes(&self) -> usize { + self.gradient_x.capacity() * std::mem::size_of::() + + self.gradient_y.capacity() * std::mem::size_of::() + + self.magnitude_rows.capacity() * std::mem::size_of::() + + self.states.capacity() * std::mem::size_of::() + + self.strong.capacity() * std::mem::size_of::() + } + + fn resize(&mut self, len: usize, magnitude_elements: usize) { self.gradient_x.resize(len, 0); self.gradient_y.resize(len, 0); - self.comparison_magnitude.resize(len, 0); + self.magnitude_rows.resize(magnitude_elements, 0); self.states.resize(len, 1); self.strong.clear(); } @@ -174,7 +180,13 @@ fn canny_3x3_into( let width = input.width(); let height = input.height(); let len = checked_len(width, height)?; - workspace.resize(len); + let parallel = len >= 1_000_000; + let workers = if parallel { rayon::current_num_threads().min(height.max(1)) } else { 1 }; + let magnitude_elements = workers + .checked_mul(3) + .and_then(|rows| rows.checked_mul(width)) + .ok_or_else(|| VisionError::InvalidDimensions("Canny magnitude ring overflows".into()))?; + workspace.resize(len, magnitude_elements); spatial_gradient_u8_into( input, BorderMode::Replicate, @@ -182,103 +194,58 @@ fn canny_3x3_into( &mut workspace.gradient_y, )?; - if options.l2_gradient { - if len >= 1_000_000 { - workspace.comparison_magnitude.par_iter_mut().enumerate().for_each( - |(index, magnitude)| { - let x = i32::from(workspace.gradient_x[index]); - let y = i32::from(workspace.gradient_y[index]); - *magnitude = x * x + y * y; - }, - ); - } else { - for ((magnitude, &x), &y) in workspace - .comparison_magnitude - .iter_mut() - .zip(&workspace.gradient_x) - .zip(&workspace.gradient_y) - { - let x = i32::from(x); - let y = i32::from(y); - *magnitude = x * x + y * y; - } - } - } else { - for ((magnitude, &x), &y) in workspace - .comparison_magnitude - .iter_mut() - .zip(&workspace.gradient_x) - .zip(&workspace.gradient_y) - { - *magnitude = i32::from(x).abs() + i32::from(y).abs(); - } - } - let (low, high) = canny_thresholds(options); workspace.states.fill(1); - if len >= 1_000_000 { + if parallel { + let rows_per_worker = height.div_ceil(workers); let gradient_x = &workspace.gradient_x; let gradient_y = &workspace.gradient_y; - let magnitudes = &workspace.comparison_magnitude; - let strong = workspace + let (strong, has_weak) = workspace .states - .par_iter_mut() + .par_chunks_mut(rows_per_worker * width) + .zip(workspace.magnitude_rows.par_chunks_mut(3 * width)) .enumerate() - .fold(Vec::new, |mut strong, (index, state)| { - let magnitude = magnitudes[index]; - if i64::from(magnitude) > low - && is_directional_maximum_i32( - index % width, - index / width, - width, - height, - i32::from(gradient_x[index]), - i32::from(gradient_y[index]), - magnitude, - magnitudes, - ) - { - if i64::from(magnitude) > high { - *state = 2; - strong.push(index); - } else { - *state = 0; - } - } - strong + .map(|(chunk, (states, magnitude_rows))| { + classify_canny_rows( + chunk * rows_per_worker, + width, + height, + gradient_x, + gradient_y, + options.l2_gradient, + low, + high, + states, + magnitude_rows, + ) }) - .reduce(Vec::new, |mut left, mut right| { - left.append(&mut right); - left - }); + .reduce( + || (Vec::new(), false), + |(mut left, left_weak), (mut right, right_weak)| { + left.append(&mut right); + (left, left_weak || right_weak) + }, + ); workspace.strong = strong; + if !has_weak { + workspace.strong.clear(); + } } else { - for y in 0..height { - let row = y * width; - for x in 0..width { - let index = row + x; - let magnitude = workspace.comparison_magnitude[index]; - if i64::from(magnitude) <= low - || !is_directional_maximum_i32( - x, - y, - width, - height, - i32::from(workspace.gradient_x[index]), - i32::from(workspace.gradient_y[index]), - magnitude, - &workspace.comparison_magnitude, - ) - { - continue; - } - if i64::from(magnitude) > high { - workspace.states[index] = 2; - workspace.strong.push(index); - } else { - workspace.states[index] = 0; - } - } + let (strong, has_weak) = classify_canny_rows( + 0, + width, + height, + &workspace.gradient_x, + &workspace.gradient_y, + options.l2_gradient, + low, + high, + &mut workspace.states, + &mut workspace.magnitude_rows, + ); + workspace.strong = strong; + if !has_weak { + workspace.strong.clear(); } } @@ -318,6 +285,180 @@ fn canny_3x3_into( Ok(()) } +#[allow(clippy::too_many_arguments)] +fn classify_canny_rows( + start_y: usize, + width: usize, + height: usize, + gradient_x: &[i16], + gradient_y: &[i16], + l2_gradient: bool, + low: i64, + high: i64, + states: &mut [u8], + magnitude_rows: &mut [i32], +) -> (Vec, bool) { + if width == 0 || height == 0 || states.is_empty() { + return (Vec::new(), false); + } + debug_assert_eq!(magnitude_rows.len(), 3 * width); + let rows = states.len() / width; + fill_magnitude_row( + start_y.checked_sub(1), + width, + height, + gradient_x, + gradient_y, + l2_gradient, + &mut magnitude_rows[..width], + ); + fill_magnitude_row( + Some(start_y), + width, + height, + gradient_x, + gradient_y, + l2_gradient, + &mut magnitude_rows[width..2 * width], + ); + fill_magnitude_row( + start_y.checked_add(1), + width, + height, + gradient_x, + gradient_y, + l2_gradient, + &mut magnitude_rows[2 * width..], + ); + + let mut strong = Vec::new(); + let mut has_weak = false; + for local_y in 0..rows { + let y = start_y + local_y; + let previous_slot = local_y % 3; + let current_slot = (local_y + 1) % 3; + let next_slot = (local_y + 2) % 3; + if local_y != 0 { + let next_y = y.checked_add(1); + fill_magnitude_row( + next_y, + width, + height, + gradient_x, + gradient_y, + l2_gradient, + &mut magnitude_rows[next_slot * width..(next_slot + 1) * width], + ); + } + let global_row = y * width; + let local_row = local_y * width; + for x in 0..width { + let index = global_row + x; + let magnitude = magnitude_rows[current_slot * width + x]; + if i64::from(magnitude) <= low + || !is_directional_maximum_ring( + x, + y, + width, + height, + i32::from(gradient_x[index]), + i32::from(gradient_y[index]), + magnitude, + magnitude_rows, + previous_slot, + current_slot, + next_slot, + ) + { + continue; + } + let state = &mut states[local_row + x]; + if i64::from(magnitude) > high { + *state = 2; + strong.push(index); + } else { + *state = 0; + has_weak = true; + } + } + } + (strong, has_weak) +} + +#[allow(clippy::too_many_arguments)] +fn fill_magnitude_row( + y: Option, + width: usize, + height: usize, + gradient_x: &[i16], + gradient_y: &[i16], + l2_gradient: bool, + output: &mut [i32], +) { + let Some(y) = y.filter(|&y| y < height) else { + output.fill(0); + return; + }; + let start = y * width; + let gradient_x = &gradient_x[start..start + width]; + let gradient_y = &gradient_y[start..start + width]; + if l2_gradient { + for ((magnitude, &x), &y) in output.iter_mut().zip(gradient_x).zip(gradient_y) { + let x = i32::from(x); + let y = i32::from(y); + *magnitude = x * x + y * y; + } + } else { + for ((magnitude, &x), &y) in output.iter_mut().zip(gradient_x).zip(gradient_y) { + *magnitude = i32::from(x).abs() + i32::from(y).abs(); + } + } +} + +#[inline] +#[allow(clippy::too_many_arguments)] +fn is_directional_maximum_ring( + x: usize, + y: usize, + width: usize, + height: usize, + gradient_x: i32, + gradient_y: i32, + magnitude: i32, + magnitudes: &[i32], + previous_slot: usize, + current_slot: usize, + next_slot: usize, +) -> bool { + const TG22: i64 = 13_573; + let abs_x = i64::from(gradient_x.abs()); + let abs_y_scaled = i64::from(gradient_y.abs()) << 15; + let tg22_x = abs_x * TG22; + let get = |offset_x: isize, offset_y: isize| { + let nx = x as isize + offset_x; + let ny = y as isize + offset_y; + if nx < 0 || ny < 0 || nx >= width as isize || ny >= height as isize { + 0 + } else { + let slot = match offset_y { + -1 => previous_slot, + 0 => current_slot, + 1 => next_slot, + _ => unreachable!("Canny NMS only reads adjacent rows"), + }; + magnitudes[slot * width + nx as usize] + } + }; + if abs_y_scaled < tg22_x { + magnitude > get(-1, 0) && magnitude >= get(1, 0) + } else if abs_y_scaled > tg22_x + (abs_x << 16) { + magnitude > get(0, -1) && magnitude >= get(0, 1) + } else { + let sign = if (gradient_x ^ gradient_y) < 0 { -1 } else { 1 }; + magnitude > get(-sign, -1) && magnitude > get(sign, 1) + } +} + fn canny_thresholds(options: CannyOptions) -> (i64, i64) { let (mut low, mut high) = (options.low_threshold, options.high_threshold); if options.aperture_size == 7 { @@ -527,40 +668,6 @@ fn is_directional_maximum( } } -#[inline] -fn is_directional_maximum_i32( - x: usize, - y: usize, - width: usize, - height: usize, - gradient_x: i32, - gradient_y: i32, - magnitude: i32, - magnitudes: &[i32], -) -> bool { - const TG22: i64 = 13_573; - let abs_x = i64::from(gradient_x.abs()); - let abs_y_scaled = i64::from(gradient_y.abs()) << 15; - let tg22_x = abs_x * TG22; - let get = |offset_x: isize, offset_y: isize| { - let nx = x as isize + offset_x; - let ny = y as isize + offset_y; - if nx < 0 || ny < 0 || nx >= width as isize || ny >= height as isize { - 0 - } else { - magnitudes[ny as usize * width + nx as usize] - } - }; - if abs_y_scaled < tg22_x { - magnitude > get(-1, 0) && magnitude >= get(1, 0) - } else if abs_y_scaled > tg22_x + (abs_x << 16) { - magnitude > get(0, -1) && magnitude >= get(0, 1) - } else { - let sign = if (gradient_x ^ gradient_y) < 0 { -1 } else { 1 }; - magnitude > get(-sign, -1) && magnitude > get(sign, 1) - } -} - #[cfg(test)] mod tests { use super::{canny, canny_into, canny_with_intermediates, CannyOptions, CannyWorkspace}; @@ -658,4 +765,25 @@ mod tests { } } } + + #[test] + fn parallel_ring_matches_intermediates_and_avoids_full_magnitude_image() { + let (width, height) = (1_000, 1_000); + let data = (0..width * height) + .map(|index| if (index / width) % 80 < 3 { 255 } else { 0 }) + .collect(); + let image = Image::::try_new(width, height, data).unwrap(); + let options = CannyOptions { + low_threshold: 80.0, + high_threshold: 160.0, + l2_gradient: true, + ..Default::default() + }; + let expected = canny_with_intermediates(image.view(), options).unwrap().edges; + let mut actual = Image::::from_pixel(width, height, [0]).unwrap(); + let mut workspace = CannyWorkspace::new(); + canny_into(image.view(), options, actual.view_mut(), &mut workspace).unwrap(); + assert_eq!(actual, expected); + assert!(workspace.allocated_bytes() < width * height * 6); + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 57cd123..8524d46 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -655,9 +655,9 @@ to one implicitly, and GPU receipts must retain named upload/readback stages. | Slice | Status | Scope | Evidence | | --- | --- | --- | --- | | 118A | Complete | Compute paired gradients, magnitude, and direction with shared traversal | fast-path/intermediate bit-exact parity tests | -| 118B | In progress | Ring-buffer suppression and reusable hysteresis queue | reusable gradients, magnitude, states, and stack complete; ring-buffer suppression remains | +| 118B | Complete | Ring-buffer suppression and reusable hysteresis queue | parallel three-row magnitude rings, reusable state/stack, and allocated-byte receipt | | 118C | Complete | Keep inspectable intermediates opt-in while making `canny()` allocation-light | `canny_into`, strided output padding, Python output identity, and workspace capacity tests | -| 118D | Planned | Improve Canny by at least 5x on one canonical large profile | F1/IoU and timing receipt | +| 118D | Complete | Improve Canny by at least 5x on one canonical large profile | 11.92× native 4K document-line improvement; bit-exact OpenCV parity; 1.42× OpenCV win | ### Epic 119 delivery slices diff --git a/docs/site/algorithms.html b/docs/site/algorithms.html index 7cf219b..2c98945 100644 --- a/docs/site/algorithms.html +++ b/docs/site/algorithms.html @@ -35,7 +35,7 @@

Algorithm catalog

MorphologyErode, dilate, open, close, gradient, top-hat, black-hat; separable sliding Rect fast path plus Cross/Ellipse/Diamond/custom fallbackspatialrust-vision · imgproc-morphologysafe CPU explicit wgpu API Image analysisFixed/Otsu/adaptive threshold, histogram, equalization, CLAHE, integral image, Cannyspatialrust-vision · imgproc-analysis, imgproc-cannyCPU Local featuresFAST, Harris, Shi–Tomasi, ORB, descriptor matching, grid selection, pyramidal Lucas–Kanade trackingspatialrust-vision · feature2dCPU - Dense visionExact Euclidean distance transform with reusable workspace/output; allocation-light Canny with opt-in inspectable intermediates; row-major run-length/union-find connected components; contours, polygon approximation, mask RLE, and dense spatial maps. Canny adds safe strided output and reusable workspace with bit-exact OpenCV parity across 300 randomized images; OpenCV remains 1.65×–1.77× faster on the document-line reuse profiles. Canny harness. Structured connected-component masks measured 2.17×–3.61× faster than OpenCV SAUF. Components harness.spatialrust-vision · denseCPU + Dense visionExact Euclidean distance transform with reusable workspace/output; allocation-light Canny with opt-in inspectable intermediates; row-major run-length/union-find connected components; contours, polygon approximation, mask RLE, and dense spatial maps. Canny uses parallel three-row magnitude rings, safe strided output, and reusable workspace with bit-exact OpenCV parity across 300 randomized images. Reusable document-line Canny measured 1.36× faster than OpenCV at 1080p and 1.42× at 4K; dense sensor noise remains an OpenCV win. Canny harness. Structured connected-component masks measured 2.17×–3.61× faster than OpenCV SAUF. Components harness.spatialrust-vision · denseCPU Detection post-processingIoU/GIoU, greedy NMS, class-aware batched NMS, and hard/linear/Gaussian Soft-NMS. Seeded Python NMS is 3.22×–8.95× faster than OpenCV; batched NMS is 26.38×–97.25× faster; linear/Gaussian Soft-NMS is 3.42×–7.40× faster. NMS indices are exact and Soft-NMS scores stay within 1.79e-7. NMS · batched · Soft-NMS.spatialrust-vision · detectionCPU Multiview geometryHomography, fundamental/essential matrices, RANSAC, triangulation, relative pose, PnP/PnP-RANSACspatialrust-vision · geometryCPU Stereo and odometryStereo rectification, block matching, disparity-to-depth/XYZ, monocular and RGB-D visual odometryspatialrust-vision · geometry, odometryCPU diff --git a/notes/2026-07-16_canny_ring_buffer.md b/notes/2026-07-16_canny_ring_buffer.md new file mode 100644 index 0000000..4df7f9d --- /dev/null +++ b/notes/2026-07-16_canny_ring_buffer.md @@ -0,0 +1,72 @@ +# Epic 118B/118D: Canny magnitude ring and high-contrast fast path + +Date: 2026-07-16 (Asia/Tokyo) + +## Outcome + +The allocation-light 3x3 Canny implementation now computes comparison +magnitudes through three-row rings instead of retaining one `i32` value per +pixel. Large images partition rows across Rayon workers; each worker owns three +rows, so directional non-maximum suppression retains exact adjacent-row access +without sharing mutable scratch. Gradients, classification state, and the +hysteresis stack remain caller-owned in `CannyWorkspace`. + +Classification also records whether any weak edge exists. If none exists, all +retained edges are already strong and the graph traversal is skipped. This is +especially useful for high-contrast document, map, and industrial line imagery. +Dense/noisy images continue through exact hysteresis. + +## Correctness and memory + +- 300 seeded randomized images are bit-exact with OpenCV 4.13.0. +- Rust L1/L2 fast paths are bit-exact with `canny_with_intermediates`. +- A 1000x1000 parallel-path test covers worker stripe boundaries and verifies + reusable storage stays below six bytes per pixel on a high-contrast profile. +- The focused 4K document-line run reserves 43,936,192 workspace bytes. The + legacy full magnitude image alone required 33,177,600 bytes, while 12 + three-row rings require 552,960 bytes; the legacy gradients+magnitude+state + lower bound was 74,649,600 bytes. + +## Native improvement + +Criterion, 4K document lines, 3x3 aperture, thresholds 80/160, L2 gradient: + +| Path | Median | +| --- | ---: | +| Inspectable intermediates | 96.914 ms | +| Ring workspace reuse | 8.134 ms | +| Improvement | **11.92x** | + +## Focused OpenCV timing + +Windows 11, Intel64 Family 6 Model 158, 12 logical CPUs, CPython 3.12.10, +OpenCV 4.13.0 with 12 threads and OpenCL disabled. Timings are seeded, +warm-started, batched to at least 20 ms, and randomized/interleaved. + +| Profile | Pattern | OpenCV reuse | SpatialRust reuse | Result | +| --- | --- | ---: | ---: | ---: | +| VGA | document lines | 0.491 ms | 0.686 ms | OpenCV 1.40x | +| 1080p | document lines | 2.900 ms | 2.138 ms | **SpatialRust 1.36x** | +| 4K | document lines | 11.480 ms | 8.103 ms | **SpatialRust 1.42x** | +| VGA | sensor noise | 2.509 ms | 8.979 ms | OpenCV 3.58x | +| 1080p | sensor noise | 17.441 ms | 29.381 ms | OpenCV 1.68x | +| 4K | sensor noise | 84.511 ms | 132.968 ms | OpenCV 1.57x | + +All timed outputs are bit-exact. The OpenCV-beating claim is intentionally +limited to reusable high-contrast document-line workloads at 1080p and 4K. + +## Reproduction + +```powershell +.venv\Scripts\python.exe bench\opencv_canny_comparison\performance.py ` + --warmup 10 --output target\opencv-canny-ring-performance-final.json + +cargo bench -p spatialrust-vision --bench canny --features imgproc-canny -- ` + 'canny_document_lines/.*/4k' --warm-up-time 1 --measurement-time 3 +``` + +Relevant absolute paths on the receipt host: + +- `C:\Users\rsasa\Workspace\SpatialRust\crates\spatialrust-vision\src\canny.rs` +- `C:\Users\rsasa\Workspace\SpatialRust\bench\opencv_canny_comparison\performance.py` +- `C:\Users\rsasa\Workspace\SpatialRust\target\opencv-canny-ring-performance-final.json`