From b5b5d3798166f9c91934ea58871cc4fd9c1f7298 Mon Sep 17 00:00:00 2001 From: rsasaki0109 Date: Mon, 12 Oct 2026 07:19:21 +0900 Subject: [PATCH] Parallelize bounded CPU FPFH descriptor phases --- CHANGELOG.md | 4 + crates/spatialrust-registration/src/fpfh.rs | 61 ++- .../src/fpfh_parallel_benchmark.rs | 44 ++ .../src/fpfh_parallel_tests.rs | 238 +++++++++ .../tests/fixtures/fpfh_serial.rs | 451 ++++++++++++++++++ .../spatialrust/tests/fpfh_parallel_smoke.rs | 33 ++ docs/ARCHITECTURE.md | 9 + docs/FEATURE_MATRIX.md | 5 + docs/ROADMAP.md | 1 + notes/2026-10-12_parallel_fpfh_descriptors.md | 162 +++++++ 10 files changed, 1000 insertions(+), 8 deletions(-) create mode 100644 crates/spatialrust-registration/src/fpfh_parallel_benchmark.rs create mode 100644 crates/spatialrust-registration/src/fpfh_parallel_tests.rs create mode 100644 crates/spatialrust-registration/tests/fixtures/fpfh_serial.rs create mode 100644 crates/spatialrust/tests/fpfh_parallel_smoke.rs create mode 100644 notes/2026-10-12_parallel_fpfh_descriptors.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3111aa..331f27f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ### Changed +- FPFH point-cloud descriptors share one KD-tree across bounded scoped CPU + workers for both SPFH construction and neighbor weighting. Small/one-worker + clouds stay serial; output order and exact descriptor words are preserved + under `register-fpfh`, without a new dependency or public signature. - GPU nearest resize and Sobel reject foreign runtimes, unsupported dimensions, device limits and insufficient profiler capacity before work allocation or pipeline initialization. Resize coordinate products are checked for u32 diff --git a/crates/spatialrust-registration/src/fpfh.rs b/crates/spatialrust-registration/src/fpfh.rs index 12883ebd..4ec446d6 100644 --- a/crates/spatialrust-registration/src/fpfh.rs +++ b/crates/spatialrust-registration/src/fpfh.rs @@ -8,7 +8,7 @@ use spatialrust_core::{HasNormals3, HasPositions3, PointCloud, SpatialError, SpatialResult}; use spatialrust_math::{Isometry3, TransformPoint, Vec3}; -use spatialrust_search::{KdTree, RadiusSearchIndex}; +use spatialrust_search::{parallel_worker_count, KdTree, RadiusSearchIndex}; use crate::kabsch::estimate_rigid_transform; use crate::registration::{PointCloudRegistration, RegistrationResult}; @@ -43,13 +43,24 @@ type Descriptor = FpfhDescriptor; /// used to build each descriptor (≈5× the point spacing is a good start). This /// is the reusable building block behind [`FpfhRansacRegistration`]; compute it /// once on a keypoint cloud to drive descriptor-based matching cheaply. +/// Independent point descriptors use bounded scoped CPU workers sharing one +/// KD-tree. Small clouds and one-worker staging remain serial; neighbor order, +/// floating-point operation order and output point order are preserved. pub fn fpfh_descriptors( cloud: &PointCloud, feature_radius: f32, +) -> SpatialResult> { + fpfh_descriptors_with_workers(cloud, feature_radius, parallel_worker_count(cloud.len())) +} + +fn fpfh_descriptors_with_workers( + cloud: &PointCloud, + feature_radius: f32, + workers: usize, ) -> SpatialResult> { validate_distance(feature_radius, "feature_radius")?; let set = PointSet::from_cloud(cloud)?; - Ok(compute_fpfh(&set, feature_radius)) + Ok(compute_fpfh_with_workers(&set, feature_radius, workers)) } /// Configuration for [`FpfhRansacRegistration`]. @@ -320,9 +331,9 @@ fn bin_index(value: f32, lo: f32, hi: f32) -> usize { } /// Builds the per-point Simplified Point Feature Histograms (SPFH). -fn compute_spfh(set: &PointSet, radius: f32) -> Vec { +fn compute_spfh(set: &PointSet, radius: f32, workers: usize) -> Vec { let mut spfh = vec![[0.0_f32; FPFH_DIM]; set.points.len()]; - for (i, spfh_i) in spfh.iter_mut().enumerate() { + fill_descriptor_ranges(&mut spfh, workers, |i, spfh_i| { let p = set.points[i]; let np = set.normals[i]; let neighbors = set.tree.radius_search(p.x, p.y, p.z, radius); @@ -352,15 +363,20 @@ fn compute_spfh(set: &PointSet, radius: f32) -> Vec { } } } - } + }); spfh } /// Builds FPFH descriptors by distance-weighting each point's neighbors' SPFH. fn compute_fpfh(set: &PointSet, radius: f32) -> Vec { - let spfh = compute_spfh(set, radius); + compute_fpfh_with_workers(set, radius, parallel_worker_count(set.points.len())) +} + +fn compute_fpfh_with_workers(set: &PointSet, radius: f32, workers: usize) -> Vec { + // The first scope joins before the second phase can read any SPFH row. + let spfh = compute_spfh(set, radius, workers); let mut fpfh = vec![[0.0_f32; FPFH_DIM]; set.points.len()]; - for (i, fpfh_i) in fpfh.iter_mut().enumerate() { + fill_descriptor_ranges(&mut fpfh, workers, |i, fpfh_i| { let p = set.points[i]; let neighbors = set.tree.radius_search(p.x, p.y, p.z, radius); let mut sum_weight = 0.0_f32; @@ -383,10 +399,35 @@ fn compute_fpfh(set: &PointSet, radius: f32) -> Vec { for bin in 0..FPFH_DIM { fpfh_i[bin] = spfh[i][bin] + if sum_weight > 0.0 { acc[bin] / sum_weight } else { 0.0 }; } - } + }); fpfh } +fn fill_descriptor_ranges(output: &mut [Descriptor], workers: usize, fill: F) +where + F: Fn(usize, &mut Descriptor) + Sync, +{ + let workers = workers.max(1).min(output.len().max(1)); + if workers == 1 { + for (i, descriptor) in output.iter_mut().enumerate() { + fill(i, descriptor); + } + return; + } + let chunk_size = output.len().div_ceil(workers); + std::thread::scope(|scope| { + for (band, chunk) in output.chunks_mut(chunk_size).enumerate() { + let start = band * chunk_size; + let fill = &fill; + scope.spawn(move || { + for (offset, descriptor) in chunk.iter_mut().enumerate() { + fill(start + offset, descriptor); + } + }); + } + }); +} + /// Brute-force nearest descriptor (squared L2) in feature space. fn nearest_feature(feature: &Descriptor, candidates: &[Descriptor]) -> usize { let mut best = 0_usize; @@ -450,6 +491,10 @@ impl Lcg { } } +#[cfg(all(test, not(target_arch = "wasm32")))] +#[path = "fpfh_parallel_tests.rs"] +mod parallel_tests; + #[cfg(test)] mod tests { use super::{FpfhRansacConfig, FpfhRansacRegistration}; diff --git a/crates/spatialrust-registration/src/fpfh_parallel_benchmark.rs b/crates/spatialrust-registration/src/fpfh_parallel_benchmark.rs new file mode 100644 index 00000000..ccd5aeda --- /dev/null +++ b/crates/spatialrust-registration/src/fpfh_parallel_benchmark.rs @@ -0,0 +1,44 @@ +use super::{assert_words, fixture, previous_descriptors, *}; +use std::{hint::black_box, time::Instant}; + +#[test] +#[ignore = "completed FPFH API comparison; explicit native invocation only"] +fn paired_previous_serial_parallel_fpfh() { + let available = std::thread::available_parallelism().map_or(1, |n| n.get()); + eprintln!("available_parallelism={available}; 2 warmups + 20 rotating rounds; default shared staging policy"); + eprintln!("Timers include validation, point/normal lifting, KD-tree build, both descriptor allocations and phases, worker spawn/join, intermediate/tree destruction; exclude fixture creation, exact-word checks and result drop."); + eprintln!("points,kind,radius,workers,round,previous_ms,serial_ms,parallel_ms"); + for points in [512usize, 16384, 32768, 65536] { + for kind in 0..3 { + let radius = if kind == 0 { 0.11 } else { 0.16 }; + let cloud = fixture(points, kind); + let expected = previous_descriptors(&cloud, radius); + let workers = parallel_worker_count(points); + let run = |mode| match mode { + 0 => previous_descriptors(&cloud, radius), + 1 => fpfh_descriptors_with_workers(&cloud, radius, 1).unwrap(), + _ => fpfh_descriptors(&cloud, radius).unwrap(), + }; + for _ in 0..2 { + for mode in 0..3 { + assert_words(&run(mode), &expected); + } + } + for round in 0..20 { + let mut ms = [0.0; 3]; + for offset in 0..3 { + let mode = (round + offset) % 3; + let start = Instant::now(); + let actual = run(mode); + ms[mode] = start.elapsed().as_secs_f64() * 1000.0; + assert_words(&actual, &expected); + black_box(&actual); + } + eprintln!( + "{points},{kind},{radius},{workers},{round},{:.6},{:.6},{:.6}", + ms[0], ms[1], ms[2] + ); + } + } + } +} diff --git a/crates/spatialrust-registration/src/fpfh_parallel_tests.rs b/crates/spatialrust-registration/src/fpfh_parallel_tests.rs new file mode 100644 index 00000000..2c386ce9 --- /dev/null +++ b/crates/spatialrust-registration/src/fpfh_parallel_tests.rs @@ -0,0 +1,238 @@ +use super::*; +use spatialrust_core::{ + DType, FieldSemantic, PointBuffer, PointBufferSet, PointField, PointSchema, SpatialMetadata, + Timestamp, +}; + +#[allow(dead_code)] +#[path = "../tests/fixtures/fpfh_serial.rs"] +mod previous; + +pub(super) fn fixture(len: usize, kind: usize) -> PointCloud { + let mut values: [Vec; 6] = std::array::from_fn(|_| Vec::with_capacity(len)); + for i in 0..len { + let j = if kind == 2 { i / 2 } else { i }; + let x = (j % 128) as f32 * 0.05 - 3.2; + let y = (j / 128) as f32 * 0.05 - 1.6; + let z = if kind == 0 { 0.0 } else { 0.02 * (x * x + y * y) }; + let normal = if kind == 0 { + Vec3::new(0.0, 0.0, 1.0) + } else { + Vec3::new(-0.04 * x, -0.04 * y, 1.0).normalize() + }; + for (axis, value) in values.iter_mut().zip([x, y, z, normal.x, normal.y, normal.z]) { + axis.push(value); + } + } + let mut schema = PointSchema::new(); + let mut buffers = PointBufferSet::new(); + for ((name, semantic), values) in [ + ("px", FieldSemantic::PositionX), + ("py", FieldSemantic::PositionY), + ("pz", FieldSemantic::PositionZ), + ("nx", FieldSemantic::NormalX), + ("ny", FieldSemantic::NormalY), + ("nz", FieldSemantic::NormalZ), + ] + .into_iter() + .zip(values) + { + schema = schema.with_field(PointField::scalar(name, semantic, DType::F32)); + buffers.insert(name, PointBuffer::F32(values)); + } + schema = schema.with_field(PointField::scalar("id", FieldSemantic::Unknown, DType::U32)); + buffers.insert("id", PointBuffer::U32((0..len).map(|i| i as u32).collect())); + PointCloud::try_from_parts( + schema, + buffers, + SpatialMetadata::new("sensor", Timestamp::from_nanos(37)), + ) + .unwrap() +} + +pub(super) fn previous_descriptors(cloud: &PointCloud, radius: f32) -> Vec { + previous::fpfh_descriptors(cloud, radius).unwrap() +} + +pub(super) fn assert_words(actual: &[FpfhDescriptor], expected: &[FpfhDescriptor]) { + assert_eq!(actual.len(), expected.len()); + for (i, (a, b)) in actual.iter().zip(expected).enumerate() { + assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "descriptor {i}"); + } +} + +#[test] +fn frozen_descriptor_words_match_for_radii_duplicates_and_worker_bands() { + let mut words = 0; + for len in [0usize, 1, 17, 4095, 4096, 4103] { + for kind in 0..3 { + let cloud = fixture(len, kind); + for radius in [0.075, 0.16] { + let expected = previous_descriptors(&cloud, radius); + for workers in [1, 2, 6] { + assert_words( + &fpfh_descriptors_with_workers(&cloud, radius, workers).unwrap(), + &expected, + ); + words += len * FPFH_DIM; + } + assert_words(&fpfh_descriptors(&cloud, radius).unwrap(), &expected); + words += len * FPFH_DIM; + } + } + } + // Cross the real two-worker staging boundary with an uneven final band. + for kind in [1, 2] { + let cloud = fixture(32771, kind); + let expected = previous_descriptors(&cloud, 0.11); + for workers in [1, 2, 6] { + assert_words(&fpfh_descriptors_with_workers(&cloud, 0.11, workers).unwrap(), &expected); + words += cloud.len() * FPFH_DIM; + } + assert_words(&fpfh_descriptors(&cloud, 0.11).unwrap(), &expected); + words += cloud.len() * FPFH_DIM; + } + eprintln!("Verified {words} exact FPFH descriptor words against the frozen serial API."); +} + +#[test] +fn independent_planar_bins_and_degenerate_neighborhoods_are_exact() { + // One pair: Darboux alpha/phi/theta are zero. Each own and neighboring + // SPFH contributes exactly 100 to the central bin of each sub-histogram. + let cloud = fixture(2, 0); + for workers in [0, 1, 2, 6, usize::MAX] { + let actual = fpfh_descriptors_with_workers(&cloud, 0.075, workers).unwrap(); + for descriptor in actual { + for (bin, value) in descriptor.into_iter().enumerate() { + assert_eq!(value.to_bits(), if bin % BINS == 5 { 200.0_f32.to_bits() } else { 0 }); + } + } + } + // Radius excludes the other point; coincident duplicates and collinear + // displacement parallel to the normal all have no valid Darboux frame. + for cloud in [fixture(1, 0), fixture(2, 0), fixture(2, 2)] { + for workers in [1, 2, 6] { + let actual = fpfh_descriptors_with_workers(&cloud, 0.001, workers).unwrap(); + assert!(actual.iter().flatten().all(|v| v.to_bits() == 0)); + } + } + let mut cloud = fixture(2, 0); + let (schema, mut buffers, metadata) = cloud.clone().into_parts(); + buffers.insert("px", PointBuffer::F32(vec![0.0, 0.0])); + buffers.insert("py", PointBuffer::F32(vec![0.0, 0.0])); + buffers.insert("pz", PointBuffer::F32(vec![0.0, 0.05])); + cloud = PointCloud::try_from_parts(schema, buffers, metadata).unwrap(); + for workers in [1, 2, 6] { + assert!(fpfh_descriptors_with_workers(&cloud, 0.075, workers) + .unwrap() + .iter() + .flatten() + .all(|v| v.to_bits() == 0)); + } +} + +#[test] +fn validation_preserves_the_serial_error_contract() { + let cloud = fixture(17, 1); + for radius in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::MAX, f32::MIN_POSITIVE] { + let expected = previous::fpfh_descriptors(&cloud, radius).unwrap_err().to_string(); + for workers in [1, 2, 6] { + assert_eq!( + fpfh_descriptors_with_workers(&cloud, radius, workers).unwrap_err().to_string(), + expected + ); + } + } + for (field, value) in [("px", f32::NAN), ("ny", f32::INFINITY), ("nz", 2.0)] { + let (schema, mut buffers, metadata) = cloud.clone().into_parts(); + let mut values = vec![0.0; cloud.len()]; + values[0] = value; + buffers.insert(field, PointBuffer::F32(values)); + let invalid = PointCloud::try_from_parts(schema, buffers, metadata).unwrap(); + let expected = previous::fpfh_descriptors(&invalid, 0.11).unwrap_err().to_string(); + for workers in [1, 2, 6] { + assert_eq!( + fpfh_descriptors_with_workers(&invalid, 0.11, workers).unwrap_err().to_string(), + expected + ); + } + } +} + +#[test] +fn concurrent_calls_preserve_inputs_and_descriptor_order() { + let cloud = fixture(4103, 2); + let before = cloud.clone(); + let expected = previous_descriptors(&cloud, 0.11); + std::thread::scope(|scope| { + let jobs: Vec<_> = [2, 6] + .into_iter() + .map(|workers| { + let cloud = &cloud; + scope.spawn(move || fpfh_descriptors_with_workers(cloud, 0.11, workers).unwrap()) + }) + .collect(); + for job in jobs { + assert_words(&job.join().unwrap(), &expected); + } + }); + assert_eq!(cloud, before); +} + +#[test] +fn seeded_registration_matches_frozen_transform_and_diagnostics() { + let (schema, mut buffers, metadata) = fixture(193, 1).into_parts(); + for (name, multiplier, modulus) in [("px", 31, 257), ("py", 97, 263), ("pz", 101, 269)] { + buffers.insert( + name, + PointBuffer::F32( + (0..193) + .map(|i| (i * multiplier % modulus) as f32 / modulus as f32 - 0.5) + .collect(), + ), + ); + } + let normals: Vec<_> = (0..193) + .map(|i| { + Vec3::new((i * 37 % 41) as f32 / 20.0 - 1.0, (i * 23 % 43) as f32 / 21.0 - 1.0, 1.0) + .normalize() + }) + .collect(); + buffers.insert("nx", PointBuffer::F32(normals.iter().map(|n| n.x).collect())); + buffers.insert("ny", PointBuffer::F32(normals.iter().map(|n| n.y).collect())); + buffers.insert("nz", PointBuffer::F32(normals.iter().map(|n| n.z).collect())); + let target = PointCloud::try_from_parts(schema, buffers, metadata).unwrap(); + let source = crate::transform::transform_point_cloud( + &target, + Isometry3::new( + spatialrust_math::Quat::from_axis_angle(Vec3::new(0.0, 0.0, 1.0), 0.2), + Vec3::new(0.1, -0.05, 0.025), + ), + ) + .unwrap(); + let config = FpfhRansacConfig { + feature_radius: 0.5, + max_correspondence_distance: 0.075, + ransac_iterations: 97, + seed: 7, + ..Default::default() + }; + let current = FpfhRansacRegistration::new(config).align(&source, &target).unwrap(); + let previous = previous::FpfhRansacRegistration::new(previous::FpfhRansacConfig { + feature_radius: config.feature_radius, + max_correspondence_distance: config.max_correspondence_distance, + ransac_iterations: config.ransac_iterations, + sample_size: config.sample_size, + edge_length_tolerance: config.edge_length_tolerance, + seed: config.seed, + }) + .align(&source, &target) + .unwrap(); + assert_eq!(current, previous); + assert!(current.converged); + assert!(current.fitness.is_finite()); + assert_eq!(current.fitness.to_bits(), previous.fitness.to_bits()); +} + +#[path = "fpfh_parallel_benchmark.rs"] +mod benchmark; diff --git a/crates/spatialrust-registration/tests/fixtures/fpfh_serial.rs b/crates/spatialrust-registration/tests/fixtures/fpfh_serial.rs new file mode 100644 index 00000000..601b04b4 --- /dev/null +++ b/crates/spatialrust-registration/tests/fixtures/fpfh_serial.rs @@ -0,0 +1,451 @@ +//! Fast Point Feature Histograms (FPFH) and FPFH-based global registration. +//! +//! Global registration recovers a coarse alignment *without* an initial guess, +//! which the local refiners (ICP/GICP/NDT) need. It describes each point by an +//! FPFH descriptor, matches descriptors between the clouds, and runs a RANSAC +//! loop that samples correspondences, estimates a rigid transform, and keeps the +//! pose with the most inliers. + +use spatialrust_core::{HasNormals3, HasPositions3, PointCloud, SpatialError, SpatialResult}; +use spatialrust_math::{Isometry3, TransformPoint, Vec3}; +use spatialrust_search::{KdTree, RadiusSearchIndex}; + +use crate::kabsch::estimate_rigid_transform; +use crate::registration::{PointCloudRegistration, RegistrationResult}; + +/// Number of bins per angular feature; the full descriptor is `3 * BINS`. +const BINS: usize = 11; +/// FPFH descriptor dimensionality (`alpha`, `phi`, `theta` histograms). +const FPFH_DIM: usize = 3 * BINS; + +/// Length of an FPFH descriptor (33: three 11-bin angular histograms). +pub const FPFH_DESCRIPTOR_LEN: usize = FPFH_DIM; + +/// A single FPFH descriptor. +pub type FpfhDescriptor = [f32; FPFH_DIM]; + +fn validate_distance(distance: f32, name: &str) -> SpatialResult<()> { + let squared = distance * distance; + if !distance.is_finite() || distance <= 0.0 || !squared.is_finite() || squared == 0.0 { + return Err(SpatialError::InvalidArgument(format!( + "{name} must be positive with finite nonzero f32 square" + ))); + } + Ok(()) +} + +/// Internal shorthand kept for the existing call sites. +type Descriptor = FpfhDescriptor; + +/// Computes an FPFH descriptor for every point in `cloud`. +/// +/// The cloud must carry finite unit normals and finite positions. `feature_radius` is the neighborhood radius +/// used to build each descriptor (≈5× the point spacing is a good start). This +/// is the reusable building block behind [`FpfhRansacRegistration`]; compute it +/// once on a keypoint cloud to drive descriptor-based matching cheaply. +pub fn fpfh_descriptors( + cloud: &PointCloud, + feature_radius: f32, +) -> SpatialResult> { + validate_distance(feature_radius, "feature_radius")?; + let set = PointSet::from_cloud(cloud)?; + Ok(compute_fpfh(&set, feature_radius)) +} + +/// Configuration for [`FpfhRansacRegistration`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FpfhRansacConfig { + /// Radius used to gather neighbors when building FPFH descriptors. Should be + /// noticeably larger than the normal-estimation radius (≈5× point spacing). + pub feature_radius: f32, + /// Distance below which a transformed source point counts as an inlier. + pub max_correspondence_distance: f32, + /// Number of RANSAC sampling iterations. + pub ransac_iterations: usize, + /// Correspondences per RANSAC sample (minimum 3 for a rigid transform). + pub sample_size: usize, + /// Pairwise edge lengths in a sample must agree within this relative + /// tolerance, which cheaply rejects geometrically inconsistent samples. + pub edge_length_tolerance: f32, + /// Seed for the deterministic RNG, so runs are reproducible. + pub seed: u64, +} + +impl Default for FpfhRansacConfig { + fn default() -> Self { + Self { + feature_radius: 0.25, + max_correspondence_distance: 0.075, + ransac_iterations: 4000, + sample_size: 3, + edge_length_tolerance: 0.9, + seed: 0x5eed, + } + } +} + +impl FpfhRansacConfig { + /// Validates numerical and sampling settings before computing descriptors. + pub fn validate(&self) -> SpatialResult<()> { + validate_distance(self.feature_radius, "feature_radius")?; + validate_distance(self.max_correspondence_distance, "max_correspondence_distance")?; + if self.ransac_iterations == 0 { + return Err(SpatialError::InvalidArgument( + "ransac_iterations must be positive".to_owned(), + )); + } + if self.sample_size < 3 || self.sample_size.checked_mul(8).is_none() { + return Err(SpatialError::InvalidArgument( + "sample_size must be at least 3 without overflow".to_owned(), + )); + } + if !self.edge_length_tolerance.is_finite() + || self.edge_length_tolerance <= 0.0 + || self.edge_length_tolerance > 1.0 + { + return Err(SpatialError::InvalidArgument( + "edge_length_tolerance must be finite in (0, 1]".to_owned(), + )); + } + Ok(()) + } + + /// Creates a config from the feature radius and inlier distance. + #[must_use] + pub fn with_radius(feature_radius: f32, max_correspondence_distance: f32) -> Self { + Self { feature_radius, max_correspondence_distance, ..Self::default() } + } +} + +/// FPFH + RANSAC global registration. +/// +/// Both `source` and `target` must carry normals (e.g. from normal estimation); +/// FPFH is built from the angular relationships between a point's normal and its +/// neighbors' normals, so finite unit normals are mandatory. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FpfhRansacRegistration { + config: FpfhRansacConfig, +} + +impl FpfhRansacRegistration { + /// Creates a registration from config. + #[must_use] + pub const fn new(config: FpfhRansacConfig) -> Self { + Self { config } + } + + /// Returns the registration config. + #[must_use] + pub const fn config(&self) -> FpfhRansacConfig { + self.config + } +} + +impl PointCloudRegistration for FpfhRansacRegistration { + fn name(&self) -> &'static str { + "FpfhRansacRegistration" + } + + fn align(&self, source: &PointCloud, target: &PointCloud) -> SpatialResult { + self.config.validate()?; + if source.len() < self.config.sample_size || target.len() < self.config.sample_size { + return Err(SpatialError::InvalidArgument( + "source and target must contain at least sample_size points".to_owned(), + )); + } + + let src = PointSet::from_cloud(source)?; + let tgt = PointSet::from_cloud(target)?; + + let src_features = compute_fpfh(&src, self.config.feature_radius); + let tgt_features = compute_fpfh(&tgt, self.config.feature_radius); + + // For each source point, its best feature-space match in the target. + let matches: Vec = + src_features.iter().map(|feature| nearest_feature(feature, &tgt_features)).collect(); + + self.ransac(&src, &tgt, &matches) + } +} + +impl FpfhRansacRegistration { + fn ransac( + &self, + src: &PointSet, + tgt: &PointSet, + matches: &[usize], + ) -> SpatialResult { + let n = src.points.len(); + let max_sq = self.config.max_correspondence_distance.powi(2); + let mut rng = Lcg::new(self.config.seed); + + let mut best_inliers = 0_usize; + let mut best_error = f64::INFINITY; + let mut best_transform = Isometry3::::identity(); + + for _ in 0..self.config.ransac_iterations { + // Draw `sample_size` distinct source indices. + let mut sample = Vec::with_capacity(self.config.sample_size); + let mut attempts = 0; + while sample.len() < self.config.sample_size && attempts < self.config.sample_size * 8 { + let idx = (rng.next_u32() as usize) % n; + if !sample.contains(&idx) { + sample.push(idx); + } + attempts += 1; + } + if sample.len() < self.config.sample_size { + continue; + } + + let sample_src: Vec> = sample.iter().map(|&i| src.points[i]).collect(); + let sample_tgt: Vec> = + sample.iter().map(|&i| tgt.points[matches[i]]).collect(); + + if !edge_lengths_consistent(&sample_src, &sample_tgt, self.config.edge_length_tolerance) + { + continue; + } + + let Some(transform) = estimate_rigid_transform(&sample_src, &sample_tgt) else { + continue; + }; + + // Score the candidate over all feature matches. + let mut inliers = 0_usize; + let mut error = 0.0_f64; + for (i, &match_idx) in matches.iter().enumerate() { + let moved = transform.transform_point(src.points[i]); + let dist_sq = (moved - tgt.points[match_idx]).length_squared(); + if dist_sq <= max_sq { + inliers += 1; + error += f64::from(dist_sq); + } + } + + let mean_error = if inliers > 0 { error / inliers as f64 } else { f64::INFINITY }; + // Prefer more inliers; break ties by lower mean inlier error. + if inliers > best_inliers || (inliers == best_inliers && mean_error < best_error) { + best_inliers = inliers; + best_error = mean_error; + best_transform = transform; + } + } + + Ok(RegistrationResult { + transform: best_transform, + fitness: if best_inliers > 0 { best_error } else { f64::INFINITY }, + iterations: self.config.ransac_iterations, + converged: best_inliers >= self.config.sample_size, + }) + } +} + +/// Positions + normals lifted out of a cloud into contiguous `Vec3` storage. +struct PointSet { + points: Vec>, + normals: Vec>, + tree: KdTree, +} + +impl PointSet { + fn from_cloud(cloud: &PointCloud) -> SpatialResult { + let (x, y, z) = cloud.positions3()?; + let (nx, ny, nz) = cloud.normals3()?; + let points: Vec> = (0..x.len()).map(|i| Vec3::new(x[i], y[i], z[i])).collect(); + let normals: Vec> = + (0..nx.len()).map(|i| Vec3::new(nx[i], ny[i], nz[i])).collect(); + if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite()) { + return Err(SpatialError::InvalidArgument("FPFH positions must be finite".to_owned())); + } + if !points.is_empty() { + let extent_squared: f64 = [x, y, z] + .iter() + .map(|axis| { + let lo = axis.iter().copied().fold(f32::INFINITY, f32::min); + let hi = axis.iter().copied().fold(f32::NEG_INFINITY, f32::max); + (f64::from(hi) - f64::from(lo)).powi(2) + }) + .sum(); + if !(extent_squared as f32).is_finite() { + return Err(SpatialError::InvalidArgument( + "FPFH coordinate extent overflows f32 squared distances".to_owned(), + )); + } + } + if normals.iter().any(|n| { + !n.x.is_finite() + || !n.y.is_finite() + || !n.z.is_finite() + || !n.length_squared().is_finite() + || (n.length_squared() - 1.0).abs() > 1e-3 + }) { + return Err(SpatialError::InvalidArgument( + "FPFH normals must be finite unit vectors".to_owned(), + )); + } + let tree = KdTree::from_slices(x, y, z); + Ok(Self { points, normals, tree }) + } +} + +/// Computes the three Darboux-frame angular features between an anchor point +/// `(p, n_p)` and a neighbor `(q, n_q)`: returns `(alpha, phi, theta)`. +fn darboux(p: Vec3, np: Vec3, q: Vec3, nq: Vec3) -> Option<(f32, f32, f32)> { + let diff = q - p; + let dist = diff.length(); + if dist < 1e-9 { + return None; + } + let u = np; + let pq = diff.normalize(); + let v = pq.cross(u); + let v_len = v.length(); + if v_len < 1e-9 { + return None; + } + let v = v.normalize(); + let w = u.cross(v); + + let alpha = v.dot(nq); // [-1, 1] + let phi = u.dot(pq); // [-1, 1] + let theta = w.dot(nq).atan2(u.dot(nq)); // [-pi, pi] + Some((alpha, phi, theta)) +} + +/// Bins a value from `[lo, hi]` into `BINS` buckets. +fn bin_index(value: f32, lo: f32, hi: f32) -> usize { + let t = ((value - lo) / (hi - lo)).clamp(0.0, 0.999_999); + (t * BINS as f32) as usize +} + +/// Builds the per-point Simplified Point Feature Histograms (SPFH). +fn compute_spfh(set: &PointSet, radius: f32) -> Vec { + let mut spfh = vec![[0.0_f32; FPFH_DIM]; set.points.len()]; + for (i, spfh_i) in spfh.iter_mut().enumerate() { + let p = set.points[i]; + let np = set.normals[i]; + let neighbors = set.tree.radius_search(p.x, p.y, p.z, radius); + let mut count = 0_u32; + for neighbor in neighbors { + let j = neighbor.index; + if j == i { + continue; + } + let Some((alpha, phi, theta)) = darboux(p, np, set.points[j], set.normals[j]) else { + continue; + }; + spfh_i[bin_index(alpha, -1.0, 1.0)] += 1.0; + spfh_i[BINS + bin_index(phi, -1.0, 1.0)] += 1.0; + spfh_i[2 * BINS + bin_index(theta, -std::f32::consts::PI, std::f32::consts::PI)] += 1.0; + count += 1; + } + if count > 0 { + // Normalize each sub-histogram to percentages. + for sub in 0..3 { + let slice = &mut spfh_i[sub * BINS..(sub + 1) * BINS]; + let sum: f32 = slice.iter().sum(); + if sum > 0.0 { + for bin in slice { + *bin = *bin / sum * 100.0; + } + } + } + } + } + spfh +} + +/// Builds FPFH descriptors by distance-weighting each point's neighbors' SPFH. +fn compute_fpfh(set: &PointSet, radius: f32) -> Vec { + let spfh = compute_spfh(set, radius); + let mut fpfh = vec![[0.0_f32; FPFH_DIM]; set.points.len()]; + for (i, fpfh_i) in fpfh.iter_mut().enumerate() { + let p = set.points[i]; + let neighbors = set.tree.radius_search(p.x, p.y, p.z, radius); + let mut sum_weight = 0.0_f32; + let mut acc = [0.0_f32; FPFH_DIM]; + for neighbor in neighbors { + let j = neighbor.index; + if j == i { + continue; + } + let dist = neighbor.distance_squared.sqrt(); + if dist < 1e-9 { + continue; + } + let weight = 1.0 / dist; + sum_weight += weight; + for bin in 0..FPFH_DIM { + acc[bin] += weight * spfh[j][bin]; + } + } + for bin in 0..FPFH_DIM { + fpfh_i[bin] = spfh[i][bin] + if sum_weight > 0.0 { acc[bin] / sum_weight } else { 0.0 }; + } + } + fpfh +} + +/// Brute-force nearest descriptor (squared L2) in feature space. +fn nearest_feature(feature: &Descriptor, candidates: &[Descriptor]) -> usize { + let mut best = 0_usize; + let mut best_dist = f32::INFINITY; + for (idx, candidate) in candidates.iter().enumerate() { + let mut dist = 0.0_f32; + for bin in 0..FPFH_DIM { + let d = feature[bin] - candidate[bin]; + dist += d * d; + if dist >= best_dist { + break; + } + } + if dist < best_dist { + best_dist = dist; + best = idx; + } + } + best +} + +/// Checks that pairwise edge lengths in the source sample match the target +/// sample within `tolerance` (a relative ratio), rejecting distorted samples. +fn edge_lengths_consistent(src: &[Vec3], tgt: &[Vec3], tolerance: f32) -> bool { + for a in 0..src.len() { + for b in (a + 1)..src.len() { + let ls = (src[a] - src[b]).length(); + let lt = (tgt[a] - tgt[b]).length(); + let lo = ls.min(lt); + let hi = ls.max(lt); + if hi < 1e-9 { + continue; + } + if lo / hi < tolerance { + return false; + } + } + } + true +} + +/// Small deterministic xorshift-style PRNG (avoids a `rand` dependency). +struct Lcg { + state: u64, +} + +impl Lcg { + fn new(seed: u64) -> Self { + // Avoid a zero state, which xorshift cannot escape. + let state = seed ^ 0x9e37_79b9_7f4a_7c15; + Self { state: if state == 0 { 1 } else { state } } + } + + fn next_u32(&mut self) -> u32 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + (x >> 32) as u32 + } +} diff --git a/crates/spatialrust/tests/fpfh_parallel_smoke.rs b/crates/spatialrust/tests/fpfh_parallel_smoke.rs new file mode 100644 index 00000000..74f316c6 --- /dev/null +++ b/crates/spatialrust/tests/fpfh_parallel_smoke.rs @@ -0,0 +1,33 @@ +#![cfg(feature = "register-fpfh")] + +use spatialrust::{ + fpfh_descriptors, DType, FieldSemantic, PointCloudBuilder, PointField, PointSchema, + FPFH_DESCRIPTOR_LEN, +}; + +#[test] +fn facade_fpfh_feature_keeps_order_and_exact_planar_bins() { + let mut schema = PointSchema::new(); + for (name, semantic) in [ + ("px", FieldSemantic::PositionX), + ("py", FieldSemantic::PositionY), + ("pz", FieldSemantic::PositionZ), + ("nx", FieldSemantic::NormalX), + ("ny", FieldSemantic::NormalY), + ("nz", FieldSemantic::NormalZ), + ] { + schema = schema.with_field(PointField::scalar(name, semantic, DType::F32)); + } + let mut builder = PointCloudBuilder::new(schema); + builder.push_point([0.0, 0.0, 0.0, 0.0, 0.0, 1.0]).unwrap(); + builder.push_point([0.05, 0.0, 0.0, 0.0, 0.0, 1.0]).unwrap(); + let cloud = builder.build().unwrap(); + let descriptors = fpfh_descriptors(&cloud, 0.075).unwrap(); + assert_eq!(FPFH_DESCRIPTOR_LEN, 33); + assert_eq!(descriptors.len(), cloud.len()); + for descriptor in descriptors { + for (bin, value) in descriptor.into_iter().enumerate() { + assert_eq!(value.to_bits(), if bin % 11 == 5 { 200.0_f32.to_bits() } else { 0 }); + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab4479fb..c45371d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -277,6 +277,15 @@ vector is allocated once; fitting scratch remains local to each active point. No all-query neighborhood table, new dependency or implicit device transfer is introduced. Filter reconstruction preserves field names, schema, all non-position buffers and metadata. Concurrent calls have independent per-call worker budgets. +FPFH descriptors under `spatialrust-registration/register-fpfh` reuse the same +point-staging policy for independent SPFH rows and weighted FPFH rows. Each +phase fills disjoint slices directly in its final descriptor array, sharing one +immutable KD-tree. The SPFH scope joins completely before weighting reads its +rows. Per-point neighbor order, Darboux/bin/normalization arithmetic and output +order are preserved; descriptor matching and seeded RANSAC retain their existing +execution. Two 132-byte-per-point descriptor arrays are retained during weighting, +with one radius-neighbor vector per active worker and no all-query neighborhood +table. Worker caps are per call, not a process-wide CPU or scratch-byte budget. `spatialrust-image-io` depends on storage, never the reverse; standard codecs are additive, while TIFF and OpenEXR remain independently gated. Calibration datasets, robust solver controls, and residual receipts live in diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index d59ecebb..35491089 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -191,6 +191,11 @@ workspace because its build requires a Python toolchain. Existing small/one-worker point staging remains serial; each concurrent call has its own worker budget. No GPU transfer or extra runtime dependency is added. See [MLS execution measurements](../notes/2026-10-12_parallel_mls_smoothing.md). +- `register-fpfh` computes SPFH and weighted FPFH rows with bounded scoped CPU + workers sharing one KD-tree. The first phase joins before weighting begins; + neighbor order, descriptor words and seeded registration results are preserved. + Small/one-worker clouds retain serial staging, with no additional dependency. + See [FPFH execution measurements](../notes/2026-10-12_parallel_fpfh_descriptors.md). - `GpuStageProfiler` (under `gpu-wgpu`) records named pass timestamps through `run_gpu_vision_chain_profiled` (`gpu-image`) and `run_aoso_voxel_normal_frame_profiled` (`gpu-aoso-staging`). Create an optional diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b3c04240..b595e2b5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1094,6 +1094,7 @@ v1.3 admission gates. | Bounded CPU neighborhood graphs | Complete | Explicit parallel k-NN/radius graph APIs with one shared tree, ordered edge joins and existing bounded chunk dispatch | Frozen edge-order parity across duplicate/tie and small/threshold/partial cases with 1/2/6 workers, independent membership/oversized k/index tests, feature/facade/WASM checks and 24 completed-API comparisons; `notes/2026-10-12_parallel_neighbor_graphs.md` | | GPU exact geometry ratios | Complete | Exact exponent scaling for power-of-two tile sizes, preserving all general ratio/interpolation math | 332,917 ratio/axis cases against independent CPU and frozen GPU words, 137 image/point regressions, feature/WASM checks and 24 paired completed resident conditions; `notes/2026-10-12_exact_gpu_clahe_geometry.md` | | Bounded CPU MLS smoothing | Complete | Independent point fits on shared KD-tree with existing bounded staging and disjoint ordered output bands | Frozen exact-coordinate parity with 1/2/6 workers, curved/duplicate/degenerate/support cases, all attribute types and metadata, feature/facade/WASM checks and twelve completed-position-API comparisons; `notes/2026-10-12_parallel_mls_smoothing.md` | +| Bounded CPU FPFH descriptors | Complete | Shared KD-tree, scoped SPFH/weighting phases and direct disjoint descriptor writes using existing point staging | 18,402,648 frozen exact descriptor words with 1/2/6 workers and public staging, independent planar/degenerate cases, validation/concurrency/seeded alignment, feature/facade/WASM checks and twelve completed-descriptor-API comparisons; `notes/2026-10-12_parallel_fpfh_descriptors.md` | | GPU spatial execution guards | Complete | Pre-allocation runtime/dimension/device/profiler checks for nearest resize and Sobel, including checked resize coordinate products | CPU/frozen full-RGBA parity, invalid-limit/capacity and ROI/concurrent/live-output/pool checks, image/point regressions and feature/WASM builds; `notes/2026-10-12_gpu_spatial_dispatch_validation.md` | | GPU shared Sobel tiles | Deferred after repeat measurement | Preserve the direct production shader while retaining an exact shared-halo prototype in native tests | 54 CPU/frozen/direct/experimental fixtures; initial gains did not survive repeat measurement across 36 conditions, with adverse wide/odd strips; `notes/2026-10-12_gpu_spatial_dispatch_validation.md` | | GPU contrast geometry reuse | Deferred after measurement | Keep per-call device geometry and existing bounded scratch while shortening exact ratios | A 24-condition survey found geometry below 1% of GPU pass time for 3840x2160 / 8x8 mixed and hot fixtures; revisit retention if repeated geometry becomes a material completed-call cost; `notes/2026-10-12_exact_gpu_clahe_geometry.md` | diff --git a/notes/2026-10-12_parallel_fpfh_descriptors.md b/notes/2026-10-12_parallel_fpfh_descriptors.md new file mode 100644 index 00000000..e05d107f --- /dev/null +++ b/notes/2026-10-12_parallel_fpfh_descriptors.md @@ -0,0 +1,162 @@ +# Bounded CPU FPFH descriptors + +Date: 2026-10-12. This named slice follows merged +[PR #144](https://github.com/rsasaki0109/SpatialRust/pull/144), main +`f83ed256fdcce6b6927374eadf71195d96d6eb63`. + +## Result and execution boundary + +FPFH previously computed every SPFH row and weighted descriptor on the caller, +although rows within each phase depend only on immutable input. Under +`register-fpfh`, the public `fpfh_descriptors` API and descriptor construction +inside `FpfhRansacRegistration` now share one caller-built KD-tree across bounded +scoped CPU workers. Workers fill disjoint contiguous slices directly in each +phase's final array. The SPFH scope joins completely before the weighting phase +can read any SPFH row. There is no concurrent read of partially written rows. + +The implementation is +`C:/Users/rsasa/Workspace/SpatialRust/crates/spatialrust-registration/src/fpfh.rs`. +It reuses `spatialrust-search::parallel_worker_count`: one worker below 4,096 +points; otherwise at most the available CPU parallelism and +`max(1, floor(points / 16,384))` useful chunks. With default chunking, fewer than +32,768 points retain serial execution. A one-worker phase performs its original +indexed loop without a thread. Parallel phases allocate no per-worker result +vector or result-merge buffer. Worker injection is private and used by tests; +there is no new public worker configuration, dependency or feature flag. + +Point order and unsorted KD-tree radius-neighbor order are preserved. Each +point retains the original Darboux-frame math, self/degenerate-neighbor +exclusions, angular bins, per-sub-histogram normalization, f32 distance weights +and the exact accumulation order of all 33 bins. No floating reduction spans +workers. Descriptor matching, tie handling, seeded RNG, RANSAC sampling/scoring +and pose estimation retain their existing execution and arithmetic. Existing +radius, finite-position/unit-normal and coordinate-extent validation is unchanged. + +SPFH and FPFH arrays each contain 132 payload bytes per point, with both present +during weighting as before. Lifted positions/normals and the KD-tree also scale +with input count. Each active worker holds one query's radius-neighbor vector; +an unbounded radius may include the entire cloud. The worker cap is not a byte +limit on active scratch. Concurrent callers each have their own worker budget, +not a process-wide CPU limit. There is no all-query neighbor table, distance +matrix, GPU copy or global mutable state. + +## Correctness and feature validation + +The frozen parent module is +`C:/Users/rsasa/Workspace/SpatialRust/crates/spatialrust-registration/tests/fixtures/fpfh_serial.rs`. +It matches parent main f83ed25 after line-ending/trailing-newline normalization, +with only the original test module omitted. It is compiled solely in native +test builds and retains the complete independent serial public API and seeded +registration implementation. + +Five new tests in +`C:/Users/rsasa/Workspace/SpatialRust/crates/spatialrust-registration/src/fpfh_parallel_tests.rs` +cover: + +- **18,402,648 exact f32 descriptor words** against the frozen public API. + Inputs have 0/1/17/4,095/4,096/4,103 points, planar/curved/duplicate curved + geometry and radii 0.075/0.16. Additional 32,771-point curved and duplicate + cases cross the real multi-worker staging boundary with uneven final bands. + Forced 1/2/6 workers and public staging retain identical bin words and row order. +- An independent two-point planar expectation: only the center of each + 11-bin angular histogram has value 200. Coincident points, excluded-radius + neighbors, singleton support and displacement parallel to the normal retain + zero descriptors. Empty/zero/oversized private worker requests remain safe. +- Invalid/underflowing/overflowing radii, nonfinite positions and nonunit or + nonfinite normals retain the frozen error strings. Existing extreme-extent + and configuration rejection tests continue to pass. +- Concurrent forced-worker calls preserve independent results and the borrowed + input, including renamed XYZ/normal fields, metadata and an integer ID column. +- Seeded registration on a transformed, spatially varied 193-point cloud gives + the same transform, fitness bits, iteration count and convergence diagnostics + as frozen registration; convergence and finite fitness are required. + +Minimal `register-fpfh` tests pass: 19 native tests and one ignored benchmark. +All-feature registration tests pass: 35 native tests and one ignored benchmark, +including ICP, point-to-plane, GICP, NDT, rigid transforms and FPFH. +The minimal facade API smoke passes. Minimal/all-feature all-target strict +Clippy and facade smoke Clippy pass. Crate and facade WASM compilation passes; +this does not establish browser execution or threading performance. Formatting +and diff checks pass. No workflow or job names changed; all 114 named CI checks +gate merge. + +Checks used: + +```text +cargo test -p spatialrust-registration --no-default-features --features register-fpfh --lib -- --test-threads=1 +cargo test -p spatialrust-registration --all-features --tests -- --test-threads=1 --nocapture +cargo test -p spatialrust --no-default-features --features register-fpfh --test fpfh_parallel_smoke +cargo clippy -p spatialrust-registration --no-default-features --features register-fpfh --all-targets -- -D warnings +cargo clippy -p spatialrust-registration --all-features --all-targets -- -D warnings +cargo clippy -p spatialrust --no-default-features --features register-fpfh --test fpfh_parallel_smoke -- -D warnings +cargo check -p spatialrust-registration --target wasm32-unknown-unknown --no-default-features --features register-fpfh +cargo check -p spatialrust --target wasm32-unknown-unknown --no-default-features --features register-fpfh +cargo fmt --all --check +git diff --check +``` + +## Completed descriptor-API measurement + +Release native Windows, Rust/Cargo 1.98.1. Twelve conditions combine +512/16,384/32,768/65,536 points with planar, curved and duplicate curved inputs. +Radius is 0.11 for the plane and 0.16 for the other two fixtures. This machine +reports 12 available CPU workers; default policy uses 1/1/2/4 workers respectively. +No other tests or builds launched by this task overlap the performance run. +Desktop activity and other callers' CPU usage are not controlled. + +The ignored diagnostic is +`C:/Users/rsasa/Workspace/SpatialRust/crates/spatialrust-registration/src/fpfh_parallel_benchmark.rs`: + +```text +cargo test -p spatialrust-registration --no-default-features --features register-fpfh --release --lib paired_previous_serial_parallel_fpfh -- --ignored --nocapture --test-threads=1 +``` + +Every condition runs two warmups and twenty rotating rounds of the frozen +serial public API, the new forced-one-worker path and the current public API. +Every warmup and measured output is checked against frozen descriptor bits. +Timers include input validation, point/normal lifting, KD-tree construction, +both descriptor allocations and phases, worker spawn/join and destruction of +the SPFH/tree/input staging. Fixture construction, output verification and +destruction of the returned descriptor vector are outside the timer. These are +complete host-returning API measurements, not isolated query timings. + +Each table time is a per-variant median. Ratios are medians of twenty paired +old/variant times, with 10,000 paired median bootstrap resamples (seed 20261012, +sorted indices 249 and 9749). Above one favors the new path. The intervals +describe this run's samples and are not independent-run confidence or a +portable/ideal scaling guarantee. All conditions and serial controls are shown. + +At 32,768 points/two workers, paired old/public median ratios are +1.421–1.458. At 65,536 points/four workers, they are 2.375–2.595. All six +multi-worker intervals are above one. The six one-worker public controls range +0.984–1.000: some show small regressions, including the 16,384-point plane +(0.984 [0.976, 0.993]) and curved input (0.987 [0.979, 0.997]). Forced-serial +controls also show small regressions in several conditions; every control and +sample range is retained. These observed gains apply to this fixture/host/run, +and do not establish ideal scaling or behavior under concurrent CPU load. + +| Points | Surface / radius | Workers | Previous / serial / public ms | Previous/public ratio [95% CI] | Previous/serial ratio [95% CI] | +| ---: | --- | ---: | --- | --- | --- | +| 512 | plane / 0.11 | 1 | 0.791 / 0.791 / 0.790 | 0.996 [0.980, 1.009] | 1.000 [0.986, 1.034] | +| 512 | curved / 0.16 | 1 | 1.221 / 1.232 / 1.229 | 0.993 [0.988, 1.000] | 0.992 [0.989, 0.996] | +| 512 | duplicate curved / 0.16 | 1 | 1.193 / 1.198 / 1.196 | 0.996 [0.991, 1.001] | 0.995 [0.991, 1.000] | +| 16,384 | plane / 0.11 | 1 | 34.960 / 35.795 / 35.450 | 0.984 [0.976, 0.993] | 0.980 [0.961, 0.992] | +| 16,384 | curved / 0.16 | 1 | 67.874 / 68.044 / 68.492 | 0.987 [0.979, 0.997] | 0.988 [0.975, 1.004] | +| 16,384 | duplicate curved / 0.16 | 1 | 106.467 / 107.221 / 107.744 | 1.000 [0.979, 1.010] | 0.996 [0.983, 0.999] | +| 32,768 | plane / 0.11 | 2 | 70.709 / 71.854 / 50.449 | 1.421 [1.240, 1.616] | 0.990 [0.980, 1.008] | +| 32,768 | curved / 0.16 | 2 | 130.051 / 134.410 / 91.899 | 1.444 [1.400, 1.542] | 0.977 [0.939, 0.991] | +| 32,768 | duplicate curved / 0.16 | 2 | 213.817 / 216.531 / 149.335 | 1.458 [1.371, 1.570] | 0.989 [0.971, 1.001] | +| 65,536 | plane / 0.11 | 4 | 158.563 / 156.152 / 67.186 | 2.377 [2.229, 2.534] | 1.022 [0.957, 1.059] | +| 65,536 | curved / 0.16 | 4 | 248.476 / 253.536 / 107.257 | 2.375 [2.272, 2.448] | 0.981 [0.950, 1.003] | +| 65,536 | duplicate curved / 0.16 | 4 | 457.102 / 458.787 / 172.411 | 2.595 [2.387, 2.811] | 0.983 [0.950, 1.030] | + +Private reproduction logs are under +`C:/Users/rsasa/Workspace/SpatialRust/target/automation/`: +`parallel-fpfh-tests.log`, `parallel-fpfh-all-tests.log`, +`parallel-fpfh-facade.log`, `parallel-fpfh-seeded.log`, +`parallel-fpfh-clippy.log`, `parallel-fpfh-all-clippy.log`, +`parallel-fpfh-facade-clippy.log`, `parallel-fpfh-wasm.log`, +`parallel-fpfh-facade-wasm.log`, `parallel-fpfh-release-build.log`, +`parallel-fpfh-paired.log`, `parallel-fpfh-estimates.json`, +`parallel-fpfh-table.md` and `summarize-parallel-fpfh.py`. +The estimates retain all samples, minimum/maximum times and paired intervals.