1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum DenseRgbdFittingModel {
18 #[default]
20 Bilinear,
21 RoundedSobel,
24 ReferencePrecision,
27 ReferencePrecisionHuber,
31}
32
33impl DenseRgbdFittingModel {
34 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum DenseRgbdPreprocessing {
49 #[default]
51 Raw,
52 Gaussian3,
54 Gaussian3GrayOnly,
56 Gaussian3GrayOnlyPairMean,
58}
59
60impl DenseRgbdPreprocessing {
61 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#[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 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#[derive(Clone, Debug, PartialEq)]
97pub struct DenseRgbdOdometryOptions {
98 pub iterations: Vec<usize>,
100 pub min_depth: f64,
102 pub max_depth: f64,
104 pub max_depth_difference: f64,
106 pub depth_weight: f64,
108 pub min_correspondences: usize,
110 pub min_correspondence_fraction: f64,
114 pub epsilon: f64,
116 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 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 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 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#[derive(Clone, Debug, PartialEq)]
249pub struct DenseRgbdOdometryEstimate {
250 pub pose: AbsolutePose,
252 pub iterations: usize,
254 pub valid_source_pixels: usize,
256 pub correspondences: usize,
258 pub correspondence_fraction: f64,
260 pub photometric_rmse: f64,
262 pub depth_rmse_m: f64,
264 pub working_bytes: usize,
266 pub workers: usize,
268}
269
270pub 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
293pub 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
394pub 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
585fn 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
614fn 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
715fn 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 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 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 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}