Skip to main content

spatialrust_vision/
dense_odometry.rs

1//! Explicit two-frame CPU RGB-D projective odometry.
2
3use spatialrust_image::ImageView;
4use spatialrust_math::{solve_linear_system, LeastSquaresResult, Mat3, Vec3};
5
6use crate::{AbsolutePose, CameraMatrix3, VisionError, VisionResult};
7
8const TILE_ROWS: usize = 16;
9
10mod huber;
11mod reference_precision;
12mod rounded_sobel;
13
14/// Explicit complete fitting model; final admission always uses original pixels.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum DenseRgbdFittingModel {
18    /// Existing bilinear model, with the separately selected preprocessing policy.
19    #[default]
20    Bilinear,
21    /// Joint Gaussian3/pair means, floor pyramids, rounded pixels and Sobel gradients.
22    /// Requires the legacy preprocessing selector Raw; executes every planned update.
23    RoundedSobel,
24    /// Float32 intermediate filters/XYZ, f64 checked solve and proper left Euler update.
25    /// Requires Raw and retains original raw final admission and every scheduled update.
26    ReferencePrecision,
27    /// ReferencePrecision with scalar Huber IRLS: normalized gray delta 0.02,
28    /// metric depth delta max_depth_difference/3. Raw final gates stay unweighted.
29    /// Requires Raw and a finite strictly positive derived depth delta.
30    ReferencePrecisionHuber,
31}
32
33impl DenseRgbdFittingModel {
34    /// Stable complete-model identifier for execution receipts.
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::Bilinear => "bilinear",
38            Self::RoundedSobel => "rounded-sobel",
39            Self::ReferencePrecision => "reference-precision",
40            Self::ReferencePrecisionHuber => "reference-precision-huber",
41        }
42    }
43}
44
45/// Explicit fitting input policy; final admission always uses original pixels.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum DenseRgbdPreprocessing {
49    /// Preserve the original full-resolution fitting inputs and buffer bound.
50    #[default]
51    Raw,
52    /// Replicated-edge Gaussian3 gray/depth; any missing depth tap stays missing.
53    Gaussian3,
54    /// The same Gaussian3 gray inputs, with original full-resolution fitting depth.
55    Gaussian3GrayOnly,
56    /// Original fitting depth and Gaussian3 gray scaled once by initial pair means.
57    Gaussian3GrayOnlyPairMean,
58}
59
60impl DenseRgbdPreprocessing {
61    /// Stable policy identifier for execution receipts.
62    pub fn as_str(self) -> &'static str {
63        match self {
64            Self::Raw => "raw",
65            Self::Gaussian3 => "gaussian3",
66            Self::Gaussian3GrayOnly => "gaussian3-gray-only",
67            Self::Gaussian3GrayOnlyPairMean => "gaussian3-gray-only-pair-mean",
68        }
69    }
70}
71
72/// Borrowed grayscale and metric-depth views for one registered RGB-D frame.
73///
74/// Depth is already in metres. Nonfinite, nonpositive and out-of-range samples
75/// are missing data. Estimation builds owned CPU pyramids; no device copy occurs.
76#[derive(Clone, Copy, Debug)]
77pub struct DenseRgbdFrame<'a> {
78    gray: ImageView<'a, u8, 1>,
79    depth: ImageView<'a, f32, 1>,
80}
81
82impl<'a> DenseRgbdFrame<'a> {
83    /// Checks that both nonempty images have the same geometry.
84    pub fn try_new(gray: ImageView<'a, u8, 1>, depth: ImageView<'a, f32, 1>) -> VisionResult<Self> {
85        if gray.width() < 3 || gray.height() < 3 {
86            return Err(VisionError::InvalidDimensions("dense RGB-D requires at least 3x3".into()));
87        }
88        if gray.width() != depth.width() || gray.height() != depth.height() {
89            return Err(VisionError::ShapeMismatch("dense RGB-D intensity/depth geometry".into()));
90        }
91        Ok(Self { gray, depth })
92    }
93}
94
95/// Bounded dense photometric/projective-depth least-squares controls.
96#[derive(Clone, Debug, PartialEq)]
97pub struct DenseRgbdOdometryOptions {
98    /// Iterations from coarsest to finest; one to six levels, 1..=100 each.
99    pub iterations: Vec<usize>,
100    /// Inclusive minimum valid metric depth.
101    pub min_depth: f64,
102    /// Inclusive maximum valid metric depth.
103    pub max_depth: f64,
104    /// Maximum absolute target-depth / warped-source-depth difference, metres.
105    pub max_depth_difference: f64,
106    /// Relative squared depth weight; intensity weight is one minus this value.
107    pub depth_weight: f64,
108    /// Minimum projective correspondence count at every level and final pose.
109    pub min_correspondences: usize,
110    /// Minimum matched fraction of all valid source pixels at the returned pose.
111    /// Initial/intermediate hypotheses need the minimum count and a solvable
112    /// normal system; they are not yet accepted motion estimates.
113    pub min_correspondence_fraction: f64,
114    /// Stop a level when rotation-vector norm plus translation norm is smaller.
115    pub epsilon: f64,
116    /// Upper bound on logical owned pyramid/reduction buffer payloads.
117    pub max_working_bytes: usize,
118}
119
120impl Default for DenseRgbdOdometryOptions {
121    fn default() -> Self {
122        Self {
123            iterations: vec![20, 10, 5],
124            min_depth: 0.1,
125            max_depth: 5.0,
126            max_depth_difference: 0.03,
127            depth_weight: 0.968,
128            min_correspondences: 30,
129            min_correspondence_fraction: 0.5,
130            epsilon: 1e-7,
131            max_working_bytes: 128 * 1024 * 1024,
132        }
133    }
134}
135
136impl DenseRgbdOdometryOptions {
137    /// Preflights dimensions, controls and logical buffers before allocation.
138    ///
139    /// Excludes input views/copies, allocator headers and worker/thread stacks.
140    pub fn required_working_bytes(&self, width: usize, height: usize) -> VisionResult<usize> {
141        let invalid =
142            || VisionError::InvalidParameter("invalid or over-budget dense RGB-D controls".into());
143        if self.iterations.is_empty()
144            || self.iterations.len() > 6
145            || self.iterations.iter().any(|&n| !(1..=100).contains(&n))
146            || !self.min_depth.is_finite()
147            || self.min_depth <= 0.0
148            || !self.max_depth.is_finite()
149            || self.max_depth < self.min_depth
150            || !self.max_depth_difference.is_finite()
151            || self.max_depth_difference <= 0.0
152            || !self.depth_weight.is_finite()
153            || !(0.0..=1.0).contains(&self.depth_weight)
154            || self.min_correspondences < 6
155            || !self.min_correspondence_fraction.is_finite()
156            || self.min_correspondence_fraction <= 0.0
157            || self.min_correspondence_fraction > 1.0
158            || !self.epsilon.is_finite()
159            || self.epsilon <= 0.0
160        {
161            return Err(invalid());
162        }
163        let mut pixels = 0usize;
164        let (mut w, mut h) = (width, height);
165        for _ in &self.iterations {
166            if w < 3 || h < 3 {
167                return Err(invalid());
168            }
169            pixels =
170                pixels.checked_add(w.checked_mul(h).ok_or_else(invalid)?).ok_or_else(invalid)?;
171            w = w / 2 + w % 2;
172            h = h / 2 + h % 2;
173        }
174        let pyramid = pixels.checked_mul(4 * std::mem::size_of::<f64>()).ok_or_else(invalid)?;
175        let tiles = (height / TILE_ROWS + usize::from(height % TILE_ROWS != 0))
176            .checked_mul(std::mem::size_of::<Normal>())
177            .ok_or_else(invalid)?;
178        let bytes = pyramid.checked_add(tiles).ok_or_else(invalid)?;
179        if bytes > self.max_working_bytes {
180            return Err(invalid());
181        }
182        Ok(bytes)
183    }
184
185    /// Preflights a complete fitting model before allocating any logical buffers.
186    /// RoundedSobel owns raw levels, floor pyramids and one level's Sobel gradients.
187    pub fn required_working_bytes_with_model(
188        &self,
189        width: usize,
190        height: usize,
191        preprocessing: DenseRgbdPreprocessing,
192        model: DenseRgbdFittingModel,
193    ) -> VisionResult<usize> {
194        match model {
195            DenseRgbdFittingModel::Bilinear => {
196                self.required_working_bytes_with_preprocessing(width, height, preprocessing)
197            }
198            DenseRgbdFittingModel::RoundedSobel => {
199                if preprocessing != DenseRgbdPreprocessing::Raw {
200                    return Err(VisionError::InvalidParameter(
201                        "rounded-sobel defines its complete fitting policy; preprocessing must be raw".into(),
202                    ));
203                }
204                rounded_sobel::required_bytes(self, width, height)
205            }
206            DenseRgbdFittingModel::ReferencePrecision => {
207                if preprocessing != DenseRgbdPreprocessing::Raw {
208                    return Err(VisionError::InvalidParameter(
209                        "reference-precision defines its complete fitting policy; preprocessing must be raw".into(),
210                    ));
211                }
212                reference_precision::required_bytes(self, width, height)
213            }
214            DenseRgbdFittingModel::ReferencePrecisionHuber => {
215                if preprocessing != DenseRgbdPreprocessing::Raw {
216                    return Err(VisionError::InvalidParameter(
217                        "reference-precision-huber defines its complete fitting policy; preprocessing must be raw".into(),
218                    ));
219                }
220                huber::required_bytes(self, width, height)
221            }
222        }
223    }
224
225    /// Includes both retained raw levels when filtered fitting is selected.
226    pub fn required_working_bytes_with_preprocessing(
227        &self,
228        width: usize,
229        height: usize,
230        preprocessing: DenseRgbdPreprocessing,
231    ) -> VisionResult<usize> {
232        let original = self.required_working_bytes(width, height)?;
233        if preprocessing == DenseRgbdPreprocessing::Raw {
234            return Ok(original);
235        }
236        width
237            .checked_mul(height)
238            .and_then(|pixels| pixels.checked_mul(4 * std::mem::size_of::<f64>()))
239            .and_then(|extra| original.checked_add(extra))
240            .filter(|bytes| *bytes <= self.max_working_bytes)
241            .ok_or_else(|| {
242                VisionError::InvalidParameter("over-budget dense preprocessing buffers".into())
243            })
244    }
245}
246
247/// Owned pose and final full-resolution projective diagnostics.
248#[derive(Clone, Debug, PartialEq)]
249pub struct DenseRgbdOdometryEstimate {
250    /// Source-camera to target-camera metric pose.
251    pub pose: AbsolutePose,
252    /// Number of solved pose updates, bounded by the iteration schedule.
253    pub iterations: usize,
254    /// All valid full-resolution source depth samples, including unmatched rows.
255    pub valid_source_pixels: usize,
256    /// Accepted full-resolution projective correspondences.
257    pub correspondences: usize,
258    /// Correspondences divided by all valid source pixels.
259    pub correspondence_fraction: f64,
260    /// Unweighted normalized [0,1] intensity RMSE over final correspondences.
261    pub photometric_rmse: f64,
262    /// Unweighted depth RMSE in metres over final correspondences.
263    pub depth_rmse_m: f64,
264    /// Preflighted logical pyramid/reduction-buffer payload bound.
265    pub working_bytes: usize,
266    /// Maximum private CPU workers available to this call (one on WASM).
267    pub workers: usize,
268}
269
270/// Estimates dense motion from both registered frames and an explicit initial pose.
271///
272/// Uses a centered Gaussian pyramid, bilinear target warps and analytic
273/// photometric/depth derivatives with left rigid updates. Original inputs are
274/// borrowed; owned CPU pyramids and fixed ordered row-tile reductions are bounded.
275/// Singular systems, insufficient overlap and invalid updates return errors.
276pub fn estimate_dense_rgbd_odometry(
277    source: DenseRgbdFrame<'_>,
278    target: DenseRgbdFrame<'_>,
279    camera: CameraMatrix3,
280    initial: AbsolutePose,
281    options: &DenseRgbdOdometryOptions,
282) -> VisionResult<DenseRgbdOdometryEstimate> {
283    estimate_dense_rgbd_odometry_with_preprocessing(
284        source,
285        target,
286        camera,
287        initial,
288        options,
289        DenseRgbdPreprocessing::Raw,
290    )
291}
292
293/// Fits with an explicit policy, retaining original raw full-resolution admission.
294///
295/// All additional logical planes are checked before allocation. The Gaussian3
296/// policy uses fixed ordered row tiles and at most four private CPU workers;
297/// WASM is serial. No input mutation or device copy occurs.
298pub fn estimate_dense_rgbd_odometry_with_preprocessing(
299    source: DenseRgbdFrame<'_>,
300    target: DenseRgbdFrame<'_>,
301    camera: CameraMatrix3,
302    initial: AbsolutePose,
303    options: &DenseRgbdOdometryOptions,
304    preprocessing: DenseRgbdPreprocessing,
305) -> VisionResult<DenseRgbdOdometryEstimate> {
306    if source.gray.width() != target.gray.width() || source.gray.height() != target.gray.height() {
307        return Err(VisionError::ShapeMismatch("dense RGB-D source/target geometry".into()));
308    }
309    let working_bytes = options.required_working_bytes_with_preprocessing(
310        source.gray.width(),
311        source.gray.height(),
312        preprocessing,
313    )?;
314    let (source, target, raw) = match preprocessing {
315        DenseRgbdPreprocessing::Raw => (pyramid(source, options), pyramid(target, options), None),
316        DenseRgbdPreprocessing::Gaussian3
317        | DenseRgbdPreprocessing::Gaussian3GrayOnly
318        | DenseRgbdPreprocessing::Gaussian3GrayOnlyPairMean => {
319            let filter_depth = preprocessing == DenseRgbdPreprocessing::Gaussian3;
320            let a = base_level(source, options);
321            let b = base_level(target, options);
322            let mut source = gaussian3(&a, filter_depth);
323            let mut target = gaussian3(&b, filter_depth);
324            if preprocessing == DenseRgbdPreprocessing::Gaussian3GrayOnlyPairMean {
325                normalize_pair(
326                    &mut source,
327                    &mut target,
328                    scaled_camera(camera, 0),
329                    initial,
330                    options,
331                )?;
332            }
333            let source = pyramid_from_level(source, options);
334            let target = pyramid_from_level(target, options);
335            (source, target, Some((a, b)))
336        }
337    };
338    let mut pose = initial;
339    let mut iterations = 0;
340    for (schedule, level) in (0..source.len()).rev().enumerate() {
341        let intrinsics = scaled_camera(camera, level);
342        for iteration in 0..options.iterations[schedule] {
343            let normal = linearize(&source[level], &target[level], intrinsics, pose, options);
344            check_support(&normal, options, SupportStage::Linearization { level, iteration })?;
345            let mut matrix = normal.hessian;
346            for (row, values) in matrix.iter_mut().enumerate() {
347                for (col, value) in values.iter_mut().take(row).enumerate() {
348                    *value = normal.hessian[col][row];
349                }
350            }
351            if matrix.iter().flatten().chain(normal.rhs.iter()).any(|x| !x.is_finite()) {
352                return Err(VisionError::InvalidParameter(
353                    "nonfinite dense RGB-D normal system".into(),
354                ));
355            }
356            let LeastSquaresResult::Solved(delta) = solve_linear_system(
357                matrix.iter().map(|row| row.to_vec()).collect(),
358                normal.rhs.to_vec(),
359            ) else {
360                return Err(VisionError::InvalidParameter(
361                    "singular dense RGB-D normal system".into(),
362                ));
363            };
364            if delta.iter().any(|x| !x.is_finite()) {
365                return Err(VisionError::InvalidParameter("nonfinite dense RGB-D update".into()));
366            }
367            pose = update_pose(pose, &delta)?;
368            iterations += 1;
369            if Vec3::new(delta[0], delta[1], delta[2]).length()
370                + Vec3::new(delta[3], delta[4], delta[5]).length()
371                < options.epsilon
372            {
373                break;
374            }
375        }
376    }
377    let (final_source, final_target) =
378        raw.as_ref().map_or((&source[0], &target[0]), |(a, b)| (a, b));
379    let final_rows = linearize(final_source, final_target, scaled_camera(camera, 0), pose, options);
380    check_support(&final_rows, options, SupportStage::Final)?;
381    Ok(DenseRgbdOdometryEstimate {
382        pose,
383        iterations,
384        valid_source_pixels: final_rows.valid,
385        correspondences: final_rows.count,
386        correspondence_fraction: final_rows.count as f64 / final_rows.valid as f64,
387        photometric_rmse: (final_rows.photo_sse / final_rows.count as f64).sqrt(),
388        depth_rmse_m: (final_rows.depth_sse / final_rows.count as f64).sqrt(),
389        working_bytes,
390        workers: worker_count(source[0].width * source[0].height),
391    })
392}
393
394/// Estimates motion with an explicit complete model and legacy preprocessing.
395///
396/// Bilinear delegates to the existing estimator. RoundedSobel requires Raw,
397/// retains original raw final admission, preflights every logical buffer and
398/// executes the complete schedule. Sobel Jacobians are local surrogates for the
399/// discontinuous rounded objective. Inputs are borrowed; no device copy occurs.
400pub fn estimate_dense_rgbd_odometry_with_model(
401    source: DenseRgbdFrame<'_>,
402    target: DenseRgbdFrame<'_>,
403    camera: CameraMatrix3,
404    initial: AbsolutePose,
405    options: &DenseRgbdOdometryOptions,
406    preprocessing: DenseRgbdPreprocessing,
407    model: DenseRgbdFittingModel,
408) -> VisionResult<DenseRgbdOdometryEstimate> {
409    match model {
410        DenseRgbdFittingModel::Bilinear => estimate_dense_rgbd_odometry_with_preprocessing(
411            source,
412            target,
413            camera,
414            initial,
415            options,
416            preprocessing,
417        ),
418        DenseRgbdFittingModel::RoundedSobel
419        | DenseRgbdFittingModel::ReferencePrecision
420        | DenseRgbdFittingModel::ReferencePrecisionHuber => {
421            if source.gray.width() != target.gray.width()
422                || source.gray.height() != target.gray.height()
423            {
424                return Err(VisionError::ShapeMismatch(
425                    "dense RGB-D source/target geometry".into(),
426                ));
427            }
428            let working_bytes = options.required_working_bytes_with_model(
429                source.gray.width(),
430                source.gray.height(),
431                preprocessing,
432                model,
433            )?;
434            if model == DenseRgbdFittingModel::ReferencePrecisionHuber {
435                huber::estimate(source, target, camera, initial, options, working_bytes)
436            } else if model == DenseRgbdFittingModel::ReferencePrecision {
437                reference_precision::estimate(
438                    source,
439                    target,
440                    camera,
441                    initial,
442                    options,
443                    working_bytes,
444                )
445            } else {
446                rounded_sobel::estimate(source, target, camera, initial, options, working_bytes)
447            }
448        }
449    }
450}
451
452struct Level {
453    width: usize,
454    height: usize,
455    color: Vec<f64>,
456    depth: Vec<f64>,
457}
458
459fn base_level(frame: DenseRgbdFrame<'_>, options: &DenseRgbdOdometryOptions) -> Level {
460    let (width, height) = (frame.gray.width(), frame.gray.height());
461    let mut color = Vec::with_capacity(width * height);
462    let mut depth = Vec::with_capacity(width * height);
463    for y in 0..height {
464        for x in 0..width {
465            color
466                .push(f64::from(frame.gray.get(x, y).expect("checked image rectangle")[0]) / 255.0);
467            let z = f64::from(frame.depth.get(x, y).expect("checked image rectangle")[0]);
468            depth.push(if z.is_finite() && z >= options.min_depth && z <= options.max_depth {
469                z
470            } else {
471                f64::NAN
472            });
473        }
474    }
475    Level { width, height, color, depth }
476}
477
478fn gaussian3_tile(
479    raw: &Level,
480    color: &mut [f64],
481    depth: &mut [f64],
482    start_row: usize,
483    filter_depth: bool,
484) {
485    for (i, (intensity, z)) in color.iter_mut().zip(depth.iter_mut()).enumerate() {
486        let (x, y) = (i % raw.width, start_row + i / raw.width);
487        let (mut isum, mut zsum, mut finite) = (0.0, 0.0, true);
488        for (dy, wy) in [1.0, 2.0, 1.0].into_iter().enumerate() {
489            for (dx, wx) in [1.0, 2.0, 1.0].into_iter().enumerate() {
490                let sx = (x + dx).saturating_sub(1).min(raw.width - 1);
491                let sy = (y + dy).saturating_sub(1).min(raw.height - 1);
492                let index = sy * raw.width + sx;
493                isum += wx * wy * raw.color[index];
494                if filter_depth {
495                    zsum += wx * wy * raw.depth[index];
496                    finite &= raw.depth[index].is_finite();
497                }
498            }
499        }
500        *intensity = isum / 16.0;
501        *z = if !filter_depth {
502            raw.depth[y * raw.width + x]
503        } else if finite {
504            zsum / 16.0
505        } else {
506            f64::NAN
507        };
508    }
509}
510
511fn gaussian3(raw: &Level, filter_depth: bool) -> Level {
512    let mut output = Level {
513        width: raw.width,
514        height: raw.height,
515        color: vec![0.0; raw.color.len()],
516        depth: vec![0.0; raw.depth.len()],
517    };
518    let tile_pixels = raw.width * TILE_ROWS;
519    #[cfg(not(target_arch = "wasm32"))]
520    if worker_count(raw.width * raw.height) > 1 {
521        use rayon::prelude::*;
522        pool().expect("worker count requires a pool").install(|| {
523            output
524                .color
525                .par_chunks_mut(tile_pixels)
526                .zip(output.depth.par_chunks_mut(tile_pixels))
527                .enumerate()
528                .for_each(|(block, (color, depth))| {
529                    gaussian3_tile(raw, color, depth, block * TILE_ROWS, filter_depth)
530                });
531        });
532        return output;
533    }
534    for (block, (color, depth)) in
535        output.color.chunks_mut(tile_pixels).zip(output.depth.chunks_mut(tile_pixels)).enumerate()
536    {
537        gaussian3_tile(raw, color, depth, block * TILE_ROWS, filter_depth);
538    }
539    output
540}
541
542fn pyramid(frame: DenseRgbdFrame<'_>, options: &DenseRgbdOdometryOptions) -> Vec<Level> {
543    pyramid_from_level(base_level(frame, options), options)
544}
545
546fn pyramid_from_level(base: Level, options: &DenseRgbdOdometryOptions) -> Vec<Level> {
547    let mut levels = vec![base];
548    while levels.len() < options.iterations.len() {
549        let previous = levels.last().expect("base level exists");
550        let (width, height) =
551            (previous.width / 2 + previous.width % 2, previous.height / 2 + previous.height % 2);
552        let mut color = Vec::with_capacity(width * height);
553        let mut depth = Vec::with_capacity(width * height);
554        for y in 0..height {
555            for x in 0..width {
556                let (mut intensity, mut zsum, mut weight) = (0.0, 0.0, 0.0);
557                for (dy, wy) in [1.0, 2.0, 1.0].into_iter().enumerate() {
558                    for (dx, wx) in [1.0, 2.0, 1.0].into_iter().enumerate() {
559                        let sx = (2 * x + dx).saturating_sub(1).min(previous.width - 1);
560                        let sy = (2 * y + dy).saturating_sub(1).min(previous.height - 1);
561                        let index = sy * previous.width + sx;
562                        let w = wy * wx;
563                        intensity += w * previous.color[index];
564                        if previous.depth[index].is_finite() {
565                            zsum += w * previous.depth[index];
566                            weight += w;
567                        }
568                    }
569                }
570                color.push(intensity / 16.0);
571                depth.push(if weight >= 8.0 { zsum / weight } else { f64::NAN });
572            }
573        }
574        levels.push(Level { width, height, color, depth });
575    }
576    levels
577}
578
579fn scaled_camera(camera: CameraMatrix3, level: usize) -> [f64; 4] {
580    let k = camera.matrix().m;
581    let scale = (1usize << level) as f64;
582    [k[0][0] / scale, k[1][1] / scale, k[0][2] / scale, k[1][2] / scale]
583}
584
585// Value and exact within-cell bilinear derivatives. Missing corners reject a row.
586fn sample(data: &[f64], width: usize, height: usize, x: f64, y: f64) -> Option<[f64; 3]> {
587    if !x.is_finite()
588        || !y.is_finite()
589        || x < 0.0
590        || y < 0.0
591        || x >= (width - 1) as f64
592        || y >= (height - 1) as f64
593    {
594        return None;
595    }
596    let (ix, iy) = (x as usize, y as usize);
597    let (u, v) = (x - ix as f64, y - iy as f64);
598    let [a, b, c, d] = [
599        data[iy * width + ix],
600        data[iy * width + ix + 1],
601        data[(iy + 1) * width + ix],
602        data[(iy + 1) * width + ix + 1],
603    ];
604    if [a, b, c, d].iter().any(|z| !z.is_finite()) {
605        return None;
606    }
607    Some([
608        (a * (1.0 - u) + b * u) * (1.0 - v) + (c * (1.0 - u) + d * u) * v,
609        (b - a) * (1.0 - v) + (d - c) * v,
610        (c - a) * (1.0 - u) + (d - b) * u,
611    ])
612}
613
614// Streaming row-major means use the same depth/projection admission as tile().
615// Keep them separate from the original normal reduction and its arithmetic.
616fn normalize_pair(
617    source: &mut Level,
618    target: &mut Level,
619    k: [f64; 4],
620    initial: AbsolutePose,
621    options: &DenseRgbdOdometryOptions,
622) -> VisionResult<[f64; 2]> {
623    let [fx, fy, cx, cy] = k;
624    let (mut source_sum, mut target_sum, mut count) = (0.0, 0.0, 0usize);
625    for y in 0..source.height {
626        for x in 0..source.width {
627            let i = y * source.width + x;
628            let z = source.depth[i];
629            if !z.is_finite() {
630                continue;
631            }
632            let point = initial.transform_point(Vec3::new(
633                (x as f64 - cx) * z / fx,
634                (y as f64 - cy) * z / fy,
635                z,
636            ));
637            if !point.z.is_finite() || point.z <= 0.0 {
638                continue;
639            }
640            let (u, v) = (fx * point.x / point.z + cx, fy * point.y / point.z + cy);
641            let Some(depth) = sample(&target.depth, target.width, target.height, u, v) else {
642                continue;
643            };
644            if (depth[0] - point.z).abs() > options.max_depth_difference {
645                continue;
646            }
647            let Some(color) = sample(&target.color, target.width, target.height, u, v) else {
648                continue;
649            };
650            source_sum += source.color[i];
651            target_sum += color[0];
652            count += 1;
653        }
654    }
655    let means = [source_sum / count as f64, target_sum / count as f64];
656    let gains = means.map(|mean| 0.5 / mean);
657    if count < options.min_correspondences
658        || means.iter().chain(gains.iter()).any(|value| !value.is_finite() || *value <= 0.0)
659    {
660        return Err(VisionError::InvalidParameter(format!(
661            "dense pair-mean normalization requires minimum count and finite positive means/gains: {count} correspondences"
662        )));
663    }
664    #[cfg(not(target_arch = "wasm32"))]
665    if worker_count(source.width * source.height) > 1 {
666        use rayon::prelude::*;
667        pool().expect("worker count requires a pool").install(|| {
668            source.color.par_iter_mut().zip(target.color.par_iter_mut()).for_each(|(a, b)| {
669                *a *= gains[0];
670                *b *= gains[1];
671            });
672        });
673        return Ok(gains);
674    }
675    for (a, b) in source.color.iter_mut().zip(target.color.iter_mut()) {
676        *a *= gains[0];
677        *b *= gains[1];
678    }
679    Ok(gains)
680}
681
682#[derive(Clone, Default, Debug, PartialEq)]
683struct Normal {
684    hessian: [[f64; 6]; 6],
685    rhs: [f64; 6],
686    photo_sse: f64,
687    depth_sse: f64,
688    valid: usize,
689    count: usize,
690}
691
692impl Normal {
693    fn add(&mut self, jacobian: [f64; 6], residual: f64, weight: f64) {
694        for a in 0..6 {
695            self.rhs[a] -= weight * jacobian[a] * residual;
696            for b in a..6 {
697                self.hessian[a][b] += weight * jacobian[a] * jacobian[b];
698            }
699        }
700    }
701    fn merge(&mut self, other: Self) {
702        for a in 0..6 {
703            self.rhs[a] += other.rhs[a];
704            for b in a..6 {
705                self.hessian[a][b] += other.hessian[a][b];
706            }
707        }
708        self.photo_sse += other.photo_sse;
709        self.depth_sse += other.depth_sse;
710        self.valid += other.valid;
711        self.count += other.count;
712    }
713}
714
715// Scalar residual gradient wrt transformed point, followed by left rigid action.
716fn twist_gradient(point: Vec3<f64>, gradient: [f64; 3]) -> [f64; 6] {
717    let [x, y, z] = gradient;
718    [point.y * z - point.z * y, point.z * x - point.x * z, point.x * y - point.y * x, x, y, z]
719}
720
721fn residual_jacobians(
722    point: Vec3<f64>,
723    k: [f64; 4],
724    color: [f64; 3],
725    depth: [f64; 3],
726) -> [[f64; 6]; 2] {
727    let [fx, fy, _, _] = k;
728    let image_gradient = |du: f64, dv: f64| {
729        let a = du * fx / point.z;
730        let b = dv * fy / point.z;
731        [a, b, -(a * point.x + b * point.y) / point.z]
732    };
733    let photo = image_gradient(color[1], color[2]);
734    let mut geometric = image_gradient(depth[1], depth[2]);
735    geometric[2] -= 1.0;
736    [twist_gradient(point, photo), twist_gradient(point, geometric)]
737}
738
739fn tile(
740    source: &Level,
741    target: &Level,
742    k: [f64; 4],
743    pose: AbsolutePose,
744    options: &DenseRgbdOdometryOptions,
745    block: usize,
746) -> Normal {
747    let mut out = Normal::default();
748    let [fx, fy, cx, cy] = k;
749    for y in block * TILE_ROWS..((block + 1) * TILE_ROWS).min(source.height) {
750        for x in 0..source.width {
751            let i = y * source.width + x;
752            let z = source.depth[i];
753            if !z.is_finite() {
754                continue;
755            }
756            out.valid += 1;
757            let point = pose.transform_point(Vec3::new(
758                (x as f64 - cx) * z / fx,
759                (y as f64 - cy) * z / fy,
760                z,
761            ));
762            if !point.z.is_finite() || point.z <= 0.0 {
763                continue;
764            }
765            let (u, v) = (fx * point.x / point.z + cx, fy * point.y / point.z + cy);
766            let Some(depth) = sample(&target.depth, target.width, target.height, u, v) else {
767                continue;
768            };
769            let depth_error = depth[0] - point.z;
770            if depth_error.abs() > options.max_depth_difference {
771                continue;
772            }
773            let Some(color) = sample(&target.color, target.width, target.height, u, v) else {
774                continue;
775            };
776            let photo_error = color[0] - source.color[i];
777            let jacobian = residual_jacobians(point, k, color, depth);
778            out.add(jacobian[0], photo_error, 1.0 - options.depth_weight);
779            out.add(jacobian[1], depth_error, options.depth_weight);
780            out.photo_sse += photo_error * photo_error;
781            out.depth_sse += depth_error * depth_error;
782            out.count += 1;
783        }
784    }
785    out
786}
787
788#[cfg(not(target_arch = "wasm32"))]
789fn pool() -> Option<&'static rayon::ThreadPool> {
790    use std::sync::OnceLock;
791    static POOL: OnceLock<Option<rayon::ThreadPool>> = OnceLock::new();
792    POOL.get_or_init(|| {
793        let workers = std::thread::available_parallelism().map_or(1, |n| n.get().min(4));
794        rayon::ThreadPoolBuilder::new()
795            .num_threads(workers)
796            .thread_name(|i| format!("dense-rgbd-{i}"))
797            .build()
798            .ok()
799    })
800    .as_ref()
801}
802
803fn worker_count(pixels: usize) -> usize {
804    #[cfg(not(target_arch = "wasm32"))]
805    if pixels >= 32768 {
806        return pool().map_or(1, rayon::ThreadPool::current_num_threads);
807    }
808    let _ = pixels;
809    1
810}
811
812fn linearize(
813    source: &Level,
814    target: &Level,
815    k: [f64; 4],
816    pose: AbsolutePose,
817    options: &DenseRgbdOdometryOptions,
818) -> Normal {
819    let blocks = source.height / TILE_ROWS + usize::from(source.height % TILE_ROWS != 0);
820    let evaluate = |b| tile(source, target, k, pose, options, b);
821    let partials: Vec<Normal> = {
822        #[cfg(not(target_arch = "wasm32"))]
823        {
824            if worker_count(source.width * source.height) > 1 {
825                use rayon::prelude::*;
826                pool()
827                    .expect("worker count requires a pool")
828                    .install(|| (0..blocks).into_par_iter().map(evaluate).collect())
829            } else {
830                (0..blocks).map(evaluate).collect()
831            }
832        }
833        #[cfg(target_arch = "wasm32")]
834        {
835            (0..blocks).map(evaluate).collect()
836        }
837    };
838    let mut total = Normal::default();
839    for part in partials {
840        total.merge(part);
841    }
842    total
843}
844
845#[derive(Clone, Copy)]
846enum SupportStage {
847    Linearization { level: usize, iteration: usize },
848    Final,
849}
850
851fn check_support(
852    rows: &Normal,
853    options: &DenseRgbdOdometryOptions,
854    stage: SupportStage,
855) -> VisionResult<()> {
856    let fraction = if rows.valid == 0 { 0.0 } else { rows.count as f64 / rows.valid as f64 };
857    if rows.valid == 0
858        || rows.count < options.min_correspondences
859        || (matches!(stage, SupportStage::Final) && fraction < options.min_correspondence_fraction)
860        || !rows.photo_sse.is_finite()
861        || !rows.depth_sse.is_finite()
862    {
863        let context = match stage {
864            SupportStage::Linearization { level, iteration } => {
865                format!("level {level}, iteration {iteration}")
866            }
867            SupportStage::Final => "returned full-resolution pose".to_string(),
868        };
869        return Err(VisionError::InvalidParameter(format!(
870            "insufficient or nonfinite dense RGB-D support at {context}: {}/{} correspondences ({fraction}), minimum count {}, final fraction {}",
871            rows.count,rows.valid,options.min_correspondences,options.min_correspondence_fraction)));
872    }
873    Ok(())
874}
875
876fn update_pose(pose: AbsolutePose, delta: &[f64]) -> VisionResult<AbsolutePose> {
877    let omega = Vec3::new(delta[0], delta[1], delta[2]);
878    let angle = omega.length();
879    let a2 = angle * angle;
880    let (s, c) = if angle < 1e-8 {
881        (1.0 - a2 / 6.0, 0.5 - a2 / 24.0)
882    } else {
883        (angle.sin() / angle, (1.0 - angle.cos()) / a2)
884    };
885    let [x, y, z] = [omega.x, omega.y, omega.z];
886    let rotation = Mat3::from_rows(
887        [1.0 - c * (y * y + z * z), c * x * y - s * z, c * x * z + s * y],
888        [c * x * y + s * z, 1.0 - c * (x * x + z * z), c * y * z - s * x],
889        [c * x * z - s * y, c * y * z + s * x, 1.0 - c * (x * x + y * y)],
890    );
891    AbsolutePose::try_new(
892        rotation.mul_mat3(pose.rotation()),
893        rotation.mul_vec3(pose.translation()) + Vec3::new(delta[3], delta[4], delta[5]),
894    )
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use spatialrust_image::Image;
901
902    #[test]
903    fn source_bound_sampling_audit() {
904        let affine = || Level {
905            width: 8,
906            height: 8,
907            color: (0..64).map(|i| (3 * (i % 8) + 5 * (i / 8) + 7) as f64 / 255.0).collect(),
908            depth: vec![2.0; 64],
909        };
910        let options = DenseRgbdOdometryOptions { iterations: vec![1, 1], ..Default::default() };
911        let even = pyramid_from_level(affine(), &options);
912        assert_eq!((even[1].width, even[1].height), (4, 4));
913        let coarse_gray = even[1].color[5];
914        assert!((coarse_gray - 23.0 / 255.0).abs() < 1e-14);
915        let odd = pyramid_from_level(
916            Level { width: 7, height: 5, color: vec![0.0; 35], depth: vec![2.0; 35] },
917            &options,
918        );
919        assert_eq!((odd[1].width, odd[1].height), (4, 3));
920        let mut hole = affine();
921        hole.depth[2 * 8 + 2] = f64::NAN;
922        let hole = pyramid_from_level(hole, &options);
923        let coarse_depth = hole[1].depth[5];
924        assert_eq!(coarse_depth, 2.0);
925        let field = affine();
926        let subpixel = sample(&field.color, 8, 8, 2.25, 2.5).unwrap();
927        for (observed, expected) in subpixel.iter().zip([26.25 / 255.0, 3.0 / 255.0, 5.0 / 255.0]) {
928            assert!((*observed - expected).abs() < 1e-14);
929        }
930        let mut neighbor = affine();
931        neighbor.depth[2 * 8 + 3] = f64::NAN;
932        let hole_admitted = sample(&neighbor.depth, 8, 8, 2.0, 2.0).is_some();
933        let border_admitted = sample(&field.depth, 8, 8, 7.0, 2.0).is_some();
934        assert!(!hole_admitted && !border_admitted);
935        let point = Vec3::new(f64::from(0.0625_f32), f64::from(0.05_f32), 2.0);
936        let gray_gradient = [f64::from(0.002_f32), f64::from(-0.001_f32)];
937        let depth_gradient = [f64::from(0.003_f32), f64::from(0.004_f32)];
938        let jacobian = residual_jacobians(
939            point,
940            [64.0, 80.0, 0.0, 0.0],
941            [0.0, gray_gradient[0], gray_gradient[1]],
942            [2.0, depth_gradient[0], depth_gradient[1]],
943        );
944        assert!(jacobian.iter().flatten().all(|value| value.is_finite()));
945        println!(
946            "DENSE_SAMPLING_AUDIT_JSON:{{\"schema\":\"spatialrust.native-sampling-probe.v1\",\"even_dimensions\":[{},{}],\"coarse_gray\":{coarse_gray},\"odd_dimensions\":[{},{}],\"hole_coarse_depth\":{coarse_depth},\"hole_admitted\":{hole_admitted},\"border_admitted\":{border_admitted},\"subpixel\":{subpixel:?},\"row_point\":{:?},\"row_gray_gradient\":{gray_gradient:?},\"row_depth_gradient\":{depth_gradient:?},\"row_jacobian\":{jacobian:?}}}",
947            even[1].width, even[1].height, odd[1].width, odd[1].height, [point.x, point.y, point.z],
948        );
949    }
950
951    #[test]
952    fn pair_means_use_only_initial_depth_correspondences_and_do_not_clip() {
953        let make = |gain| Level {
954            width: 5,
955            height: 5,
956            color: (0..25)
957                .map(|i| {
958                    gain * if i % 5 == 4 || i / 5 == 4 { 100.0 } else { (i + 1) as f64 / 10.0 }
959                })
960                .collect(),
961            depth: vec![1.0; 25],
962        };
963        let (mut source, mut target) = (make(1.0), make(2.0));
964        source.depth[12] = f64::NAN;
965        target.depth[12] = f64::NAN;
966        let options = DenseRgbdOdometryOptions { min_correspondences: 6, ..Default::default() };
967        let gains =
968            normalize_pair(&mut source, &mut target, [1.0, 1.0, 0.0, 0.0], identity(), &options)
969                .unwrap();
970        assert!((gains[0] - 0.5).abs() < 1e-14 && (gains[1] - 0.25).abs() < 1e-14);
971        assert!(source.color[24] > 49.0 && target.color[24] > 49.0);
972        assert!(source.depth[12].is_nan() && target.depth[12].is_nan());
973        assert_eq!(
974            DenseRgbdPreprocessing::Gaussian3GrayOnlyPairMean.as_str(),
975            "gaussian3-gray-only-pair-mean"
976        );
977        assert_eq!(
978            options
979                .required_working_bytes_with_preprocessing(
980                    96,
981                    72,
982                    DenseRgbdPreprocessing::Gaussian3GrayOnlyPairMean
983                )
984                .unwrap(),
985            options
986                .required_working_bytes_with_preprocessing(
987                    96,
988                    72,
989                    DenseRgbdPreprocessing::Gaussian3GrayOnly
990                )
991                .unwrap()
992        );
993    }
994
995    #[test]
996    fn pair_mean_uses_supplied_initial_pose_and_original_depth_difference_gate() {
997        let make = |gain| Level {
998            width: 5,
999            height: 5,
1000            color: (0..25).map(|i| gain * (i + 1) as f64 / 10.0).collect(),
1001            depth: vec![1.0; 25],
1002        };
1003        let (mut source, mut target) = (make(1.0), make(2.0));
1004        target.depth[12] = 1.5;
1005        let initial =
1006            AbsolutePose::try_new(Mat3::<f64>::identity(), Vec3::new(1.0, 0.0, 0.0)).unwrap();
1007        let options = DenseRgbdOdometryOptions { min_correspondences: 6, ..Default::default() };
1008        let gains =
1009            normalize_pair(&mut source, &mut target, [1.0, 1.0, 0.0, 0.0], initial, &options)
1010                .unwrap();
1011        // A one-pixel shift leaves 12 rows; depth rejects only source (1,2).
1012        assert!((gains[0] - 0.5 / (10.2 / 11.0)).abs() < 1e-14);
1013        assert!((gains[1] - 0.5 / (22.6 / 11.0)).abs() < 1e-14);
1014        assert_eq!(target.depth[12], 1.5);
1015    }
1016
1017    #[test]
1018    fn invalid_pair_means_reject_before_mutating_fitting_inputs() {
1019        let make =
1020            |value| Level { width: 5, height: 5, color: vec![value; 25], depth: vec![1.0; 25] };
1021        for (a, b, missing) in
1022            [(0.0, 1.0, false), (1.0, 0.0, false), (f64::INFINITY, 1.0, false), (1.0, 1.0, true)]
1023        {
1024            let (mut source, mut target) = (make(a), make(b));
1025            if missing {
1026                source.depth.fill(f64::NAN);
1027            }
1028            let before: Vec<_> =
1029                source.color.iter().chain(target.color.iter()).map(|x| x.to_bits()).collect();
1030            let options = DenseRgbdOdometryOptions { min_correspondences: 6, ..Default::default() };
1031            assert!(normalize_pair(
1032                &mut source,
1033                &mut target,
1034                [1.0, 1.0, 0.0, 0.0],
1035                identity(),
1036                &options
1037            )
1038            .is_err());
1039            assert_eq!(
1040                source
1041                    .color
1042                    .iter()
1043                    .chain(target.color.iter())
1044                    .map(|x| x.to_bits())
1045                    .collect::<Vec<_>>(),
1046                before
1047            );
1048        }
1049    }
1050
1051    #[test]
1052    fn pair_mean_corrects_gain_for_fitting_and_retains_original_final_statistics() {
1053        let values: Vec<u8> = (0..96 * 72)
1054            .map(|i| ((i % 96 * 3 + i / 96 * 5 + (i % 96) * (i / 96) % 17) % 90 + 20) as u8)
1055            .collect();
1056        let a = Image::<u8, 1>::try_new(96, 72, values.clone()).unwrap();
1057        let b =
1058            Image::<u8, 1>::try_new(96, 72, values.into_iter().map(|x| x * 2).collect()).unwrap();
1059        let depth = Image::<f32, 1>::try_new(96, 72, vec![2.0; 96 * 72]).unwrap();
1060        let source = DenseRgbdFrame::try_new(a.view(), depth.view()).unwrap();
1061        let target = DenseRgbdFrame::try_new(b.view(), depth.view()).unwrap();
1062        let options = DenseRgbdOdometryOptions::default();
1063        let result = estimate_dense_rgbd_odometry_with_preprocessing(
1064            source,
1065            target,
1066            camera(),
1067            identity(),
1068            &options,
1069            DenseRgbdPreprocessing::Gaussian3GrayOnlyPairMean,
1070        )
1071        .unwrap();
1072        assert!(result.pose.translation().length() < 1e-10);
1073        assert!(result.photometric_rmse > 0.1 && result.depth_rmse_m < 1e-12);
1074        let original = linearize(
1075            &base_level(source, &options),
1076            &base_level(target, &options),
1077            scaled_camera(camera(), 0),
1078            result.pose,
1079            &options,
1080        );
1081        assert_eq!(result.correspondences, original.count);
1082        assert_eq!(result.photometric_rmse, (original.photo_sse / original.count as f64).sqrt());
1083    }
1084
1085    #[test]
1086    fn pair_mean_parallel_scaling_matches_independent_serial_multiplication() {
1087        let (width, height) = (193, 173);
1088        let make = |gain| Level {
1089            width,
1090            height,
1091            color: (0..width * height)
1092                .map(|i| gain * (1 + (i * 17) % 251) as f64 / 255.0)
1093                .collect(),
1094            depth: vec![2.0; width * height],
1095        };
1096        let (mut source, mut target) = (make(1.0), make(2.0));
1097        let (mut a, mut b) = (source.color.clone(), target.color.clone());
1098        let gains = normalize_pair(
1099            &mut source,
1100            &mut target,
1101            [64.0, 64.0, 48.0, 36.0],
1102            identity(),
1103            &Default::default(),
1104        )
1105        .unwrap();
1106        for x in &mut a {
1107            *x *= gains[0];
1108        }
1109        for x in &mut b {
1110            *x *= gains[1];
1111        }
1112        assert_eq!(source.color, a);
1113        assert_eq!(target.color, b);
1114        assert!(source.depth.iter().chain(target.depth.iter()).all(|&z| z == 2.0));
1115    }
1116
1117    #[test]
1118    fn gray_only_preserves_original_depth_bits_and_joint_gray_arithmetic() {
1119        let mut raw = Level {
1120            width: 5,
1121            height: 5,
1122            color: (0..25).map(|i| (i * 17 % 251) as f64 / 255.0).collect(),
1123            depth: (0..25).map(|i| 1.0 + (i % 3) as f64 / 10.0).collect(),
1124        };
1125        raw.depth[12] = f64::NAN;
1126        let joint = gaussian3(&raw, true);
1127        let gray = gaussian3(&raw, false);
1128        assert_eq!(gray.color, joint.color);
1129        assert!(gray.depth.iter().zip(&raw.depth).all(|(a, b)| a.to_bits() == b.to_bits()));
1130        assert_eq!(gray.depth.iter().filter(|z| z.is_nan()).count(), 1);
1131        assert_eq!(joint.depth.iter().filter(|z| z.is_nan()).count(), 9);
1132        let options = DenseRgbdOdometryOptions::default();
1133        assert_eq!(
1134            options
1135                .required_working_bytes_with_preprocessing(
1136                    96,
1137                    72,
1138                    DenseRgbdPreprocessing::Gaussian3GrayOnly
1139                )
1140                .unwrap(),
1141            options
1142                .required_working_bytes_with_preprocessing(
1143                    96,
1144                    72,
1145                    DenseRgbdPreprocessing::Gaussian3
1146                )
1147                .unwrap()
1148        );
1149        assert_eq!(DenseRgbdPreprocessing::Gaussian3GrayOnly.as_str(), "gaussian3-gray-only");
1150    }
1151
1152    #[test]
1153    fn gray_only_identity_keeps_raw_statistics_with_missing_original_depth() {
1154        let (gray, _) = images(0.0, 2.0);
1155        let mut values = vec![2.0; 96 * 72];
1156        values[36 * 96 + 48] = f32::NAN;
1157        let depth = Image::<f32, 1>::try_new(96, 72, values).unwrap();
1158        let frame = DenseRgbdFrame::try_new(gray.view(), depth.view()).unwrap();
1159        let result = estimate_dense_rgbd_odometry_with_preprocessing(
1160            frame,
1161            frame,
1162            camera(),
1163            identity(),
1164            &Default::default(),
1165            DenseRgbdPreprocessing::Gaussian3GrayOnly,
1166        )
1167        .unwrap();
1168        assert_eq!(result.valid_source_pixels, 96 * 72 - 1);
1169        assert!(result.pose.translation().length() < 1e-10);
1170        assert!(result.photometric_rmse < 1e-12 && result.depth_rmse_m < 1e-12);
1171    }
1172
1173    #[test]
1174    fn gaussian3_impulses_borders_and_missing_depth_are_explicit() {
1175        let mut raw = Level { width: 5, height: 5, color: vec![0.0; 25], depth: vec![2.0; 25] };
1176        raw.color[12] = 1.0;
1177        let filtered = gaussian3(&raw, true);
1178        assert_eq!(filtered.color[12], 4.0 / 16.0);
1179        assert_eq!(filtered.color[11], 2.0 / 16.0);
1180        assert_eq!(filtered.color[6], 1.0 / 16.0);
1181        assert!(filtered.depth.iter().all(|z| *z == 2.0));
1182        raw.color.fill(0.0);
1183        raw.color[0] = 1.0;
1184        raw.depth[12] = f64::NAN;
1185        let filtered = gaussian3(&raw, true);
1186        assert_eq!(filtered.color[0], 9.0 / 16.0);
1187        assert_eq!(filtered.color[1], 3.0 / 16.0);
1188        assert_eq!(filtered.depth.iter().filter(|z| z.is_nan()).count(), 9);
1189        assert_eq!(filtered.depth[0], 2.0);
1190        assert!(raw.depth[12].is_nan() && raw.color[0] == 1.0);
1191    }
1192
1193    #[test]
1194    fn filtered_fitting_keeps_the_original_raw_valid_pixel_denominator() {
1195        let (gray, _) = images(0.0, 2.0);
1196        let mut depths = vec![2.0; 96 * 72];
1197        depths[36 * 96 + 48] = f32::NAN;
1198        let depth = Image::<f32, 1>::try_new(96, 72, depths).unwrap();
1199        let frame = DenseRgbdFrame::try_new(gray.view(), depth.view()).unwrap();
1200        let result = estimate_dense_rgbd_odometry_with_preprocessing(
1201            frame,
1202            frame,
1203            camera(),
1204            identity(),
1205            &Default::default(),
1206            DenseRgbdPreprocessing::Gaussian3,
1207        )
1208        .unwrap();
1209        assert_eq!(result.valid_source_pixels, 96 * 72 - 1);
1210        assert!(result.pose.translation().length() < 1e-10);
1211        assert!(result.photometric_rmse < 1e-12 && result.depth_rmse_m < 1e-12);
1212        let filtered = gaussian3(&base_level(frame, &Default::default()), true);
1213        assert_eq!(filtered.depth.iter().filter(|z| z.is_finite()).count(), 96 * 72 - 9);
1214    }
1215
1216    #[test]
1217    fn filtered_preflight_charges_all_retained_raw_planes_before_allocation() {
1218        let options = DenseRgbdOdometryOptions::default();
1219        let raw = options.required_working_bytes(96, 72).unwrap();
1220        assert_eq!(
1221            raw,
1222            options
1223                .required_working_bytes_with_preprocessing(96, 72, DenseRgbdPreprocessing::Raw)
1224                .unwrap()
1225        );
1226        let bytes = options
1227            .required_working_bytes_with_preprocessing(96, 72, DenseRgbdPreprocessing::Gaussian3)
1228            .unwrap();
1229        assert_eq!(bytes - raw, 4 * 96 * 72 * std::mem::size_of::<f64>());
1230        let exact = DenseRgbdOdometryOptions { max_working_bytes: bytes, ..options.clone() };
1231        assert_eq!(
1232            exact
1233                .required_working_bytes_with_preprocessing(
1234                    96,
1235                    72,
1236                    DenseRgbdPreprocessing::Gaussian3
1237                )
1238                .unwrap(),
1239            bytes
1240        );
1241        let short = DenseRgbdOdometryOptions { max_working_bytes: bytes - 1, ..options };
1242        assert!(short.required_working_bytes(96, 72).is_ok());
1243        assert!(short
1244            .required_working_bytes_with_preprocessing(96, 72, DenseRgbdPreprocessing::Gaussian3)
1245            .is_err());
1246        assert!(short
1247            .required_working_bytes_with_preprocessing(
1248                usize::MAX,
1249                72,
1250                DenseRgbdPreprocessing::Gaussian3
1251            )
1252            .is_err());
1253        assert_eq!(DenseRgbdPreprocessing::default(), DenseRgbdPreprocessing::Raw);
1254    }
1255
1256    #[test]
1257    fn gaussian3_serial_parallel_and_partial_row_tiles_are_bit_identical() {
1258        let (width, height) = (256, 163);
1259        let mut raw = Level {
1260            width,
1261            height,
1262            color: (0..width * height).map(|i| (i % 251) as f64 / 255.0).collect(),
1263            depth: (0..width * height).map(|i| 1.0 + (i % 17) as f64 / 100.0).collect(),
1264        };
1265        raw.depth[80 * width + 50] = f64::NAN;
1266        for filter_depth in [true, false] {
1267            let parallel = gaussian3(&raw, filter_depth);
1268            let mut color = vec![0.0; width * height];
1269            let mut depth = vec![0.0; width * height];
1270            for (block, (a, b)) in color
1271                .chunks_mut(width * TILE_ROWS)
1272                .zip(depth.chunks_mut(width * TILE_ROWS))
1273                .enumerate()
1274            {
1275                gaussian3_tile(&raw, a, b, block * TILE_ROWS, filter_depth);
1276            }
1277            assert_eq!(parallel.color, color);
1278            assert!(parallel.depth.iter().zip(depth).all(|(a, b)| a.to_bits() == b.to_bits()));
1279        }
1280    }
1281
1282    fn identity() -> AbsolutePose {
1283        AbsolutePose::try_new(Mat3::<f64>::identity(), Vec3::new(0.0, 0.0, 0.0)).unwrap()
1284    }
1285    fn camera() -> CameraMatrix3 {
1286        CameraMatrix3::try_from_pinhole(64.0, 64.0, 48.0, 36.0).unwrap()
1287    }
1288    fn texture(x: f64, y: f64) -> u8 {
1289        (128.0 + 45.0 * (0.26 * x).sin() + 40.0 * (0.19 * y).cos() + 25.0 * (0.13 * (x + y)).sin())
1290            .round() as u8
1291    }
1292    fn images(shift: f64, z: f32) -> (Image<u8, 1>, Image<f32, 1>) {
1293        let gray = (0..72)
1294            .flat_map(|y| (0..96).map(move |x| texture(x as f64 - shift, y as f64)))
1295            .collect();
1296        (Image::try_new(96, 72, gray).unwrap(), Image::try_new(96, 72, vec![z; 96 * 72]).unwrap())
1297    }
1298
1299    #[test]
1300    fn identity_and_metric_horizontal_motion_on_a_textured_plane() {
1301        let (a, depth) = images(0.0, 2.0);
1302        let (b, other) = images(1.0, 2.0);
1303        let source = DenseRgbdFrame::try_new(a.view(), depth.view()).unwrap();
1304        let target = DenseRgbdFrame::try_new(b.view(), other.view()).unwrap();
1305        let options = DenseRgbdOdometryOptions::default();
1306        let still =
1307            estimate_dense_rgbd_odometry(source, source, camera(), identity(), &options).unwrap();
1308        assert!(still.pose.translation().length() < 1e-10);
1309        assert!(still.photometric_rmse < 1e-12 && still.depth_rmse_m < 1e-12);
1310        let moved =
1311            estimate_dense_rgbd_odometry(source, target, camera(), identity(), &options).unwrap();
1312        // One pixel is exactly z/fx = 1/32 metre, in source-to-target direction.
1313        assert!(
1314            (moved.pose.translation() - Vec3::new(0.03125, 0.0, 0.0)).length() < 1e-4,
1315            "{:?}",
1316            moved.pose.translation()
1317        );
1318        assert!(moved.depth_rmse_m < 1e-5 && moved.photometric_rmse < 1e-4);
1319        assert_eq!(moved.valid_source_pixels, 96 * 72);
1320        assert!(moved.correspondence_fraction > 0.9);
1321        assert!(moved.iterations <= 35 && moved.working_bytes <= options.max_working_bytes);
1322    }
1323
1324    #[test]
1325    fn left_rotation_also_rotates_existing_translation() {
1326        let initial =
1327            AbsolutePose::try_new(Mat3::<f64>::identity(), Vec3::new(1.0, 0.0, 0.0)).unwrap();
1328        let pose =
1329            update_pose(initial, &[0.0, 0.0, std::f64::consts::FRAC_PI_2, 0.0, 0.0, 0.0]).unwrap();
1330        assert!((pose.translation() - Vec3::new(0.0, 1.0, 0.0)).length() < 1e-12);
1331        assert!(
1332            (pose.transform_point(Vec3::new(1.0, 0.0, 0.0)) - Vec3::new(0.0, 2.0, 0.0)).length()
1333                < 1e-12
1334        );
1335    }
1336
1337    #[test]
1338    fn low_initial_overlap_can_recover_final_support() {
1339        let (source_gray, source_depth) = images(0.0, 2.0);
1340        let angle = 0.05_f64;
1341        let (s, c) = angle.sin_cos();
1342        let mut gray = Vec::new();
1343        let mut depth = Vec::new();
1344        for y in 0..72 {
1345            for x in 0..96 {
1346                let ray_x = (x as f64 - 48.0) / 64.0;
1347                let ray_y = (y as f64 - 36.0) / 64.0;
1348                let z = 2.0 / (c + s * ray_x);
1349                let source_x = 64.0 * z * (c * ray_x - s) / 2.0 + 48.0;
1350                let source_y = 64.0 * z * ray_y / 2.0 + 36.0;
1351                gray.push(texture(source_x, source_y));
1352                depth.push(z as f32);
1353            }
1354        }
1355        let target_gray = Image::<u8, 1>::try_new(96, 72, gray).unwrap();
1356        let target_depth = Image::<f32, 1>::try_new(96, 72, depth).unwrap();
1357        let source = DenseRgbdFrame::try_new(source_gray.view(), source_depth.view()).unwrap();
1358        let target = DenseRgbdFrame::try_new(target_gray.view(), target_depth.view()).unwrap();
1359        let options = DenseRgbdOdometryOptions::default();
1360        let a = pyramid(source, &options);
1361        let b = pyramid(target, &options);
1362        let initial = linearize(&a[0], &b[0], scaled_camera(camera(), 0), identity(), &options);
1363        assert!((initial.count as f64 / initial.valid as f64) < 0.5);
1364        let result =
1365            estimate_dense_rgbd_odometry(source, target, camera(), identity(), &options).unwrap();
1366        let expected = Mat3::from_rows([c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]);
1367        for (actual, truth) in
1368            result.pose.rotation().m.iter().flatten().zip(expected.m.iter().flatten())
1369        {
1370            assert!((actual - truth).abs() < 1e-3);
1371        }
1372        assert!(result.pose.translation().length() < 0.003);
1373        assert!(result.correspondence_fraction > 0.8 && result.photometric_rmse < 1.0 / 255.0);
1374    }
1375
1376    #[test]
1377    fn an_exact_but_small_overlap_is_still_rejected_at_return() {
1378        let (gray, source_depth) = images(0.0, 2.0);
1379        let mut values = vec![0.0; 96 * 72];
1380        for y in 0..72 {
1381            for x in 30..55 {
1382                values[y * 96 + x] = 2.0;
1383            }
1384        }
1385        let target_depth = Image::<f32, 1>::try_new(96, 72, values).unwrap();
1386        let source = DenseRgbdFrame::try_new(gray.view(), source_depth.view()).unwrap();
1387        let target = DenseRgbdFrame::try_new(gray.view(), target_depth.view()).unwrap();
1388        let options = DenseRgbdOdometryOptions { iterations: vec![1], ..Default::default() };
1389        let error = estimate_dense_rgbd_odometry(source, target, camera(), identity(), &options)
1390            .unwrap_err();
1391        assert!(error.to_string().contains("returned full-resolution pose"));
1392    }
1393
1394    #[test]
1395    fn both_residual_jacobians_match_six_finite_pose_differences() {
1396        let (width, height) = (96, 72);
1397        let k = [64.0, 80.0, 30.0, 24.0];
1398        let field = |a: f64, b: f64, c: f64| {
1399            (0..height)
1400                .flat_map(move |y| (0..width).map(move |x| a * x as f64 + b * y as f64 + c))
1401                .collect::<Vec<_>>()
1402        };
1403        let color = field(0.002, -0.001, 0.1);
1404        let depth = field(0.003, 0.004, 2.0);
1405        let object = Vec3::new(0.17, -0.22, 1.8);
1406        let pose =
1407            AbsolutePose::try_new(Mat3::<f64>::identity(), Vec3::new(0.03, -0.04, 0.2)).unwrap();
1408        let values = |pose: AbsolutePose| {
1409            let p = pose.transform_point(object);
1410            let u = k[0] * p.x / p.z + k[2];
1411            let v = k[1] * p.y / p.z + k[3];
1412            let i = sample(&color, width, height, u, v).unwrap();
1413            let d = sample(&depth, width, height, u, v).unwrap();
1414            ([i[0] - 0.3, d[0] - p.z], residual_jacobians(p, k, i, d))
1415        };
1416        let (_, jacobian) = values(pose);
1417        let epsilon = 1e-6;
1418        for axis in 0..6 {
1419            let mut delta = [0.0; 6];
1420            delta[axis] = epsilon;
1421            let plus = values(update_pose(pose, &delta).unwrap()).0;
1422            delta[axis] = -epsilon;
1423            let minus = values(update_pose(pose, &delta).unwrap()).0;
1424            for row in 0..2 {
1425                assert!(
1426                    ((plus[row] - minus[row]) / (2.0 * epsilon) - jacobian[row][axis]).abs() < 1e-8,
1427                    "row {row}, axis {axis}"
1428                );
1429            }
1430        }
1431    }
1432
1433    #[test]
1434    fn missing_corners_and_unobservable_or_missing_scenes_fail() {
1435        let mut values = vec![2.0; 16];
1436        values[5] = f64::NAN;
1437        assert!(sample(&values, 4, 4, 0.0, 0.0).is_none());
1438        assert!(sample(&values, 4, 4, f64::NAN, 0.0).is_none());
1439        let gray = Image::<u8, 1>::try_new(96, 72, vec![100; 96 * 72]).unwrap();
1440        for z in [2.0, f32::NAN, 0.0, -1.0, f32::INFINITY] {
1441            let depth = Image::<f32, 1>::try_new(96, 72, vec![z; 96 * 72]).unwrap();
1442            let frame = DenseRgbdFrame::try_new(gray.view(), depth.view()).unwrap();
1443            assert!(estimate_dense_rgbd_odometry(
1444                frame,
1445                frame,
1446                camera(),
1447                identity(),
1448                &Default::default()
1449            )
1450            .is_err());
1451        }
1452    }
1453
1454    #[test]
1455    fn preflight_rejects_invalid_controls_overflow_and_small_budget() {
1456        let options = DenseRgbdOdometryOptions::default();
1457        let bytes = options.required_working_bytes(96, 72).unwrap();
1458        assert!(options.required_working_bytes(usize::MAX, usize::MAX).is_err());
1459        assert!(options.required_working_bytes(3, 3).is_err());
1460        let bad = vec![
1461            DenseRgbdOdometryOptions { iterations: vec![], ..options.clone() },
1462            DenseRgbdOdometryOptions { iterations: vec![0], ..options.clone() },
1463            DenseRgbdOdometryOptions { iterations: vec![1; 7], ..options.clone() },
1464            DenseRgbdOdometryOptions { epsilon: f64::NAN, ..options.clone() },
1465            DenseRgbdOdometryOptions { depth_weight: 1.1, ..options.clone() },
1466            DenseRgbdOdometryOptions { min_correspondence_fraction: 0.0, ..options.clone() },
1467            DenseRgbdOdometryOptions { max_depth_difference: 0.0, ..options.clone() },
1468            DenseRgbdOdometryOptions { max_working_bytes: bytes - 1, ..options.clone() },
1469        ];
1470        for option in bad {
1471            assert!(option.required_working_bytes(96, 72).is_err());
1472        }
1473        let (gray, depth) = images(0.0, 2.0);
1474        let tiny = Image::<f32, 1>::try_new(3, 3, vec![2.0; 9]).unwrap();
1475        assert!(DenseRgbdFrame::try_new(gray.view(), tiny.view()).is_err());
1476        let tiny_gray = Image::<u8, 1>::try_new(3, 3, vec![0; 9]).unwrap();
1477        let a = DenseRgbdFrame::try_new(gray.view(), depth.view()).unwrap();
1478        let b = DenseRgbdFrame::try_new(tiny_gray.view(), tiny.view()).unwrap();
1479        assert!(estimate_dense_rgbd_odometry(a, b, camera(), identity(), &options).is_err());
1480    }
1481
1482    #[test]
1483    fn centered_pyramid_camera_and_depth_missingness_are_explicit() {
1484        let gray = Image::<u8, 1>::try_new(5, 5, vec![0; 25]).unwrap();
1485        let mut z = vec![f32::NAN; 25];
1486        z[12] = 2.0;
1487        let depth = Image::<f32, 1>::try_new(5, 5, z).unwrap();
1488        let options = DenseRgbdOdometryOptions { iterations: vec![1, 1], ..Default::default() };
1489        let p = pyramid(DenseRgbdFrame::try_new(gray.view(), depth.view()).unwrap(), &options);
1490        assert_eq!((p[1].width, p[1].height), (3, 3));
1491        assert!(p[1].depth[4].is_nan());
1492        let k = scaled_camera(camera(), 1);
1493        assert_eq!(k, [32.0, 32.0, 24.0, 18.0]);
1494        // Source pixel 2x and level-one x describe exactly the same optical ray.
1495        assert_eq!((10.0 - 48.0) / 64.0, (5.0 - k[2]) / k[0]);
1496    }
1497
1498    #[test]
1499    fn parallel_and_serial_row_tiles_reduce_bit_identically() {
1500        let (width, height) = (256, 160);
1501        let color = (0..height)
1502            .flat_map(|y| (0..width).map(move |x| f64::from(texture(x as f64, y as f64)) / 255.0))
1503            .collect();
1504        let level = Level { width, height, color, depth: vec![2.0; width * height] };
1505        let options = DenseRgbdOdometryOptions::default();
1506        let mut serial = Normal::default();
1507        for block in 0..height / TILE_ROWS {
1508            serial.merge(tile(
1509                &level,
1510                &level,
1511                [128.0, 128.0, 128.0, 80.0],
1512                identity(),
1513                &options,
1514                block,
1515            ));
1516        }
1517        let parallel = linearize(&level, &level, [128.0, 128.0, 128.0, 80.0], identity(), &options);
1518        assert_eq!(serial, parallel);
1519        assert!((1..=4).contains(&worker_count(width * height)));
1520    }
1521}