Skip to main content

scirs2_vision/
depth_estimation.rs

1//! Monocular depth estimation features
2//!
3//! Provides evaluation metrics for depth estimation networks, structure-from-motion
4//! triangulation, and sparse-to-dense depth propagation.
5
6use crate::error::{Result, VisionError};
7use scirs2_core::ndarray::Array2;
8use std::collections::VecDeque;
9
10// ─────────────────────────────────────────────────────────────────────────────
11// DepthMap
12// ─────────────────────────────────────────────────────────────────────────────
13
14/// A depth map with associated metadata.
15///
16/// Stores per-pixel depth values together with calibration metadata that allows
17/// converting raw network outputs (which are typically scale-ambiguous) into
18/// metric depths.
19#[derive(Debug, Clone)]
20pub struct DepthMap {
21    /// Per-pixel depth values.  Shape is `[H, W]`.
22    pub data: Array2<f64>,
23    /// Multiplicative scale factor that maps `data` values to metric depth
24    /// (metres).  For ground-truth or metric depths set to `1.0`.
25    pub scale: f64,
26    /// Smallest valid (positive) depth in the map.
27    pub min_depth: f64,
28    /// Largest valid depth in the map.
29    pub max_depth: f64,
30}
31
32impl DepthMap {
33    /// Create a new `DepthMap` with explicit metadata.
34    pub fn new(data: Array2<f64>, scale: f64, min_depth: f64, max_depth: f64) -> Self {
35        Self {
36            data,
37            scale,
38            min_depth,
39            max_depth,
40        }
41    }
42
43    /// Create a `DepthMap` by computing metadata automatically from the data.
44    ///
45    /// All positive finite values are considered valid depth samples.
46    /// If no valid values exist, `min_depth` and `max_depth` are set to `0.0`.
47    ///
48    /// # Example
49    ///
50    /// ```
51    /// use scirs2_vision::depth_estimation::DepthMap;
52    /// use scirs2_core::ndarray::Array2;
53    ///
54    /// let data = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
55    /// let dm = DepthMap::from_data(data, 1.0);
56    /// assert!((dm.min_depth - 1.0).abs() < 1e-9);
57    /// assert!((dm.max_depth - 4.0).abs() < 1e-9);
58    /// ```
59    pub fn from_data(data: Array2<f64>, scale: f64) -> Self {
60        let mut min_depth = f64::INFINITY;
61        let mut max_depth = f64::NEG_INFINITY;
62        for &v in data.iter() {
63            if v > 0.0 && v.is_finite() {
64                if v < min_depth {
65                    min_depth = v;
66                }
67                if v > max_depth {
68                    max_depth = v;
69                }
70            }
71        }
72        if !min_depth.is_finite() {
73            min_depth = 0.0;
74            max_depth = 0.0;
75        }
76        Self {
77            data,
78            scale,
79            min_depth,
80            max_depth,
81        }
82    }
83
84    /// Apply the scale factor: returns a new `DepthMap` where all values are in
85    /// metric units.
86    pub fn to_metric(&self) -> DepthMap {
87        DepthMap {
88            data: self.data.mapv(|v| v * self.scale),
89            scale: 1.0,
90            min_depth: self.min_depth * self.scale,
91            max_depth: self.max_depth * self.scale,
92        }
93    }
94
95    /// Dimensions `(height, width)` of the depth map.
96    pub fn dim(&self) -> (usize, usize) {
97        self.data.dim()
98    }
99}
100
101// ─────────────────────────────────────────────────────────────────────────────
102// Evaluation metrics
103// ─────────────────────────────────────────────────────────────────────────────
104
105/// Scale-invariant logarithmic depth loss (Eigen et al., 2014).
106///
107/// Aligns the predicted and ground-truth depth maps by the optimal scale factor
108/// (minimising log RMSE), making the metric invariant to global scale ambiguity.
109///
110/// Only pixels where both `pred > 0` and `gt > 0` are included.
111///
112/// Formula:
113///
114/// ```text
115/// d_i = log(pred_i) − log(gt_i)
116/// SILoss = (1/n) Σ d_i² − (1/n²)(Σ d_i)²
117/// ```
118///
119/// # Errors
120///
121/// Returns [`VisionError::DimensionMismatch`] when `pred` and `gt` have
122/// different shapes, or [`VisionError::InvalidParameter`] when there are no
123/// valid pixels.
124///
125/// # Example
126///
127/// ```
128/// use scirs2_vision::depth_estimation::scale_invariant_loss;
129/// use scirs2_core::ndarray::Array2;
130///
131/// let pred = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
132/// let gt   = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
133/// let loss = scale_invariant_loss(&pred, &gt).unwrap();
134/// assert!(loss.abs() < 1e-10);   // perfect prediction ⇒ zero loss
135/// ```
136pub fn scale_invariant_loss(pred: &Array2<f64>, gt: &Array2<f64>) -> Result<f64> {
137    if pred.dim() != gt.dim() {
138        return Err(VisionError::DimensionMismatch(
139            "pred and gt must have the same shape".to_string(),
140        ));
141    }
142
143    let mut sum_d = 0.0_f64;
144    let mut sum_d2 = 0.0_f64;
145    let mut n = 0usize;
146
147    for (&p, &g) in pred.iter().zip(gt.iter()) {
148        if p > 0.0 && g > 0.0 && p.is_finite() && g.is_finite() {
149            let d = p.ln() - g.ln();
150            sum_d += d;
151            sum_d2 += d * d;
152            n += 1;
153        }
154    }
155
156    if n == 0 {
157        return Err(VisionError::InvalidParameter(
158            "No valid pixels (pred > 0 and gt > 0) found".to_string(),
159        ));
160    }
161
162    let n_f = n as f64;
163    Ok(sum_d2 / n_f - (sum_d * sum_d) / (n_f * n_f))
164}
165
166/// Absolute Relative Error (AbsRel): `mean(|pred − gt| / gt)`.
167///
168/// Only pixels where `gt > 0` are included.
169///
170/// # Errors
171///
172/// Returns [`VisionError::DimensionMismatch`] when shapes differ, or
173/// [`VisionError::InvalidParameter`] when there are no valid pixels.
174///
175/// # Example
176///
177/// ```
178/// use scirs2_vision::depth_estimation::absolute_relative_error;
179/// use scirs2_core::ndarray::Array2;
180///
181/// let pred = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
182/// let gt   = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
183/// let err = absolute_relative_error(&pred, &gt).unwrap();
184/// assert!(err.abs() < 1e-10);
185/// ```
186pub fn absolute_relative_error(pred: &Array2<f64>, gt: &Array2<f64>) -> Result<f64> {
187    if pred.dim() != gt.dim() {
188        return Err(VisionError::DimensionMismatch(
189            "pred and gt must have the same shape".to_string(),
190        ));
191    }
192
193    let mut sum = 0.0_f64;
194    let mut n = 0usize;
195
196    for (&p, &g) in pred.iter().zip(gt.iter()) {
197        if g > 0.0 && g.is_finite() {
198            sum += (p - g).abs() / g;
199            n += 1;
200        }
201    }
202
203    if n == 0 {
204        return Err(VisionError::InvalidParameter(
205            "No valid pixels (gt > 0) found".to_string(),
206        ));
207    }
208
209    Ok(sum / n as f64)
210}
211
212/// Threshold accuracy δ < `threshold` (e.g. δ₁: threshold = 1.25).
213///
214/// Fraction of pixels where `max(pred/gt, gt/pred) < threshold`.
215///
216/// Only pixels where both `pred > 0` and `gt > 0` are included.
217///
218/// # Arguments
219///
220/// * `pred`      – Predicted depth map.
221/// * `gt`        – Ground-truth depth map.
222/// * `threshold` – The δ threshold.  Common values: 1.25, 1.25², 1.25³.
223///
224/// # Errors
225///
226/// Returns [`VisionError::DimensionMismatch`] when shapes differ, or
227/// [`VisionError::InvalidParameter`] when there are no valid pixels or
228/// `threshold ≤ 1.0`.
229///
230/// # Example
231///
232/// ```
233/// use scirs2_vision::depth_estimation::threshold_accuracy;
234/// use scirs2_core::ndarray::Array2;
235///
236/// let pred = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
237/// let gt   = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
238/// let acc = threshold_accuracy(&pred, &gt, 1.25).unwrap();
239/// assert!((acc - 1.0).abs() < 1e-10);  // perfect prediction ⇒ 100 %
240/// ```
241pub fn threshold_accuracy(pred: &Array2<f64>, gt: &Array2<f64>, threshold: f64) -> Result<f64> {
242    if pred.dim() != gt.dim() {
243        return Err(VisionError::DimensionMismatch(
244            "pred and gt must have the same shape".to_string(),
245        ));
246    }
247    if threshold <= 1.0 {
248        return Err(VisionError::InvalidParameter(
249            "threshold must be greater than 1.0".to_string(),
250        ));
251    }
252
253    let mut correct = 0usize;
254    let mut n = 0usize;
255
256    for (&p, &g) in pred.iter().zip(gt.iter()) {
257        if p > 0.0 && g > 0.0 && p.is_finite() && g.is_finite() {
258            let ratio = (p / g).max(g / p);
259            if ratio < threshold {
260                correct += 1;
261            }
262            n += 1;
263        }
264    }
265
266    if n == 0 {
267        return Err(VisionError::InvalidParameter(
268            "No valid pixels (pred > 0 and gt > 0) found".to_string(),
269        ));
270    }
271
272    Ok(correct as f64 / n as f64)
273}
274
275// ─────────────────────────────────────────────────────────────────────────────
276// Structure-from-Motion: depth_from_sfm
277// ─────────────────────────────────────────────────────────────────────────────
278
279/// A camera pose represented by a 3×3 rotation matrix `R` and a translation
280/// vector `t` (3-element array).  The camera-to-world transform is:
281/// `P_world = R P_cam + t`.
282#[derive(Debug, Clone)]
283pub struct CameraPose {
284    /// 3×3 rotation matrix (row-major: `R[i]` is row *i*).
285    pub r: [[f64; 3]; 3],
286    /// Translation vector `[tx, ty, tz]` (camera centre in world coordinates).
287    pub t: [f64; 3],
288}
289
290impl CameraPose {
291    /// Create a new camera pose.
292    pub fn new(r: [[f64; 3]; 3], t: [f64; 3]) -> Self {
293        Self { r, t }
294    }
295
296    /// Identity pose (camera at world origin, no rotation).
297    pub fn identity() -> Self {
298        Self {
299            r: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
300            t: [0.0, 0.0, 0.0],
301        }
302    }
303}
304
305/// A 2-D image keypoint with floating-point sub-pixel coordinates.
306#[derive(Debug, Clone, Copy)]
307pub struct KeyPoint2D {
308    /// Horizontal pixel coordinate (column).
309    pub x: f64,
310    /// Vertical pixel coordinate (row).
311    pub y: f64,
312}
313
314impl KeyPoint2D {
315    /// Create a new keypoint.
316    pub fn new(x: f64, y: f64) -> Self {
317        Self { x, y }
318    }
319}
320
321/// A match between a keypoint in image *i* and a keypoint in image *j*.
322#[derive(Debug, Clone, Copy)]
323pub struct KeyPointMatch {
324    /// Index into the keypoints array for image *i*.
325    pub idx_i: usize,
326    /// Index into the keypoints array for image *j*.
327    pub idx_j: usize,
328    /// Index of image *i* in the pose array.
329    pub cam_i: usize,
330    /// Index of image *j* in the pose array.
331    pub cam_j: usize,
332}
333
334impl KeyPointMatch {
335    /// Create a new keypoint match.
336    pub fn new(idx_i: usize, idx_j: usize, cam_i: usize, cam_j: usize) -> Self {
337        Self {
338            idx_i,
339            idx_j,
340            cam_i,
341            cam_j,
342        }
343    }
344}
345
346/// Recover 3-D structure from known camera poses and keypoint correspondences
347/// using linear triangulation (DLT).
348///
349/// For each match the function triangulates the two rays (one from each camera)
350/// and returns the world-space 3-D point.
351///
352/// # Arguments
353///
354/// * `camera_poses` – Per-image camera pose (rotation + translation).
355/// * `keypoints`    – Per-image list of 2-D keypoints.
356/// * `matches`      – List of cross-image keypoint matches.
357///
358/// # Returns
359///
360/// A vector of `(X, Y, Z)` world-space points, one per valid match.
361/// Points that triangulate behind both cameras are omitted.
362///
363/// # Errors
364///
365/// Returns [`VisionError::InvalidParameter`] when the pose or keypoint
366/// arrays are empty.
367///
368/// # Example
369///
370/// ```
371/// use scirs2_vision::depth_estimation::{
372///     CameraPose, KeyPoint2D, KeyPointMatch, depth_from_sfm,
373/// };
374///
375/// // Two cameras side by side (baseline 1 unit along X).
376/// let poses = vec![
377///     CameraPose::identity(),
378///     CameraPose::new(
379///         [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
380///         [1.0, 0.0, 0.0],
381///     ),
382/// ];
383/// // A point at (0, 0, 5) projects to (0, 0) in both cameras when focal=1,
384/// // cx=cy=0, and the point is along the optical axis.
385/// let kps = vec![
386///     vec![KeyPoint2D::new(0.0, 0.0)],  // cam 0
387///     vec![KeyPoint2D::new(-0.2, 0.0)], // cam 1 (point appears displaced)
388/// ];
389/// let matches = vec![KeyPointMatch::new(0, 0, 0, 1)];
390/// let pts = depth_from_sfm(&poses, &kps, &matches).unwrap();
391/// assert!(!pts.is_empty());
392/// ```
393pub fn depth_from_sfm(
394    camera_poses: &[CameraPose],
395    keypoints: &[Vec<KeyPoint2D>],
396    matches: &[KeyPointMatch],
397) -> Result<Vec<(f64, f64, f64)>> {
398    if camera_poses.is_empty() {
399        return Err(VisionError::InvalidParameter(
400            "camera_poses must not be empty".to_string(),
401        ));
402    }
403    if keypoints.is_empty() {
404        return Err(VisionError::InvalidParameter(
405            "keypoints must not be empty".to_string(),
406        ));
407    }
408
409    let mut points = Vec::with_capacity(matches.len());
410
411    for m in matches {
412        if m.cam_i >= camera_poses.len() || m.cam_j >= camera_poses.len() {
413            continue;
414        }
415        if m.cam_i >= keypoints.len() || m.cam_j >= keypoints.len() {
416            continue;
417        }
418        let kps_i = &keypoints[m.cam_i];
419        let kps_j = &keypoints[m.cam_j];
420        if m.idx_i >= kps_i.len() || m.idx_j >= kps_j.len() {
421            continue;
422        }
423
424        let pose_i = &camera_poses[m.cam_i];
425        let pose_j = &camera_poses[m.cam_j];
426        let kp_i = kps_i[m.idx_i];
427        let kp_j = kps_j[m.idx_j];
428
429        // Build 3×4 projection matrices P = [R | -R t] for each camera.
430        // (Assumes identity intrinsics K = I for simplicity; the caller should
431        //  pass normalised coordinates if using real calibrated cameras.)
432        let pi = pose_to_projection(pose_i);
433        let pj = pose_to_projection(pose_j);
434
435        if let Some(pt) = triangulate_dlt(&pi, &pj, (kp_i.x, kp_i.y), (kp_j.x, kp_j.y)) {
436            points.push(pt);
437        }
438    }
439
440    Ok(points)
441}
442
443/// Build the 3×4 projection matrix `[R | t_cam]` where `t_cam = -R^T t_world`.
444///
445/// The convention used here is the standard computer-vision one:
446/// `x_cam = R (X_world - t) = R X_world - R t`.
447fn pose_to_projection(pose: &CameraPose) -> [[f64; 4]; 3] {
448    // t_cam = -R t  (translation in camera frame)
449    let r = pose.r;
450    let t_world = pose.t;
451    let tc = [
452        -(r[0][0] * t_world[0] + r[0][1] * t_world[1] + r[0][2] * t_world[2]),
453        -(r[1][0] * t_world[0] + r[1][1] * t_world[1] + r[1][2] * t_world[2]),
454        -(r[2][0] * t_world[0] + r[2][1] * t_world[1] + r[2][2] * t_world[2]),
455    ];
456    [
457        [r[0][0], r[0][1], r[0][2], tc[0]],
458        [r[1][0], r[1][1], r[1][2], tc[1]],
459        [r[2][0], r[2][1], r[2][2], tc[2]],
460    ]
461}
462
463/// Linear triangulation (DLT) from two projection matrices and corresponding
464/// image points.
465///
466/// Returns the 3-D world point in homogeneous / Euclidean coordinates.
467/// Returns `None` when the system is degenerate.
468fn triangulate_dlt(
469    p1: &[[f64; 4]; 3],
470    p2: &[[f64; 4]; 3],
471    (u1, v1): (f64, f64),
472    (u2, v2): (f64, f64),
473) -> Option<(f64, f64, f64)> {
474    // Build 4×4 system A X = 0:
475    // row 0: u1 * P1[2] - P1[0]
476    // row 1: v1 * P1[2] - P1[1]
477    // row 2: u2 * P2[2] - P2[0]
478    // row 3: v2 * P2[2] - P2[1]
479    let a: [[f64; 4]; 4] = [
480        [
481            u1 * p1[2][0] - p1[0][0],
482            u1 * p1[2][1] - p1[0][1],
483            u1 * p1[2][2] - p1[0][2],
484            u1 * p1[2][3] - p1[0][3],
485        ],
486        [
487            v1 * p1[2][0] - p1[1][0],
488            v1 * p1[2][1] - p1[1][1],
489            v1 * p1[2][2] - p1[1][2],
490            v1 * p1[2][3] - p1[1][3],
491        ],
492        [
493            u2 * p2[2][0] - p2[0][0],
494            u2 * p2[2][1] - p2[0][1],
495            u2 * p2[2][2] - p2[0][2],
496            u2 * p2[2][3] - p2[0][3],
497        ],
498        [
499            v2 * p2[2][0] - p2[1][0],
500            v2 * p2[2][1] - p2[1][1],
501            v2 * p2[2][2] - p2[1][2],
502            v2 * p2[2][3] - p2[1][3],
503        ],
504    ];
505
506    // Solve via least-squares SVD: find the null space of A (last right
507    // singular vector).  For a 4×4 we use Gaussian elimination to find the
508    // solution X that minimises ||AX||² with ||X|| = 1.
509    let x = solve_4x4_nullspace(&a)?;
510
511    // Normalise homogeneous coordinates.
512    if x[3].abs() < 1e-10 {
513        return None;
514    }
515    let w = x[3];
516    Some((x[0] / w, x[1] / w, x[2] / w))
517}
518
519/// Find the null-space vector of a 4×4 matrix via Gaussian elimination.
520/// Returns the last column of V in the SVD (approximated using the smallest
521/// diagonal after elimination).
522fn solve_4x4_nullspace(a: &[[f64; 4]; 4]) -> Option<[f64; 4]> {
523    // Use 4×4 SVD via power iteration on AᵀA.
524    let mut ata = [[0.0_f64; 4]; 4];
525    #[allow(clippy::needless_range_loop)]
526    for i in 0..4 {
527        for j in 0..4 {
528            for k in 0..4 {
529                ata[i][j] += a[k][i] * a[k][j];
530            }
531        }
532    }
533
534    // Jacobi eigendecomposition for the 4×4 symmetric matrix.
535    let mut v = [[0.0_f64; 4]; 4];
536    #[allow(clippy::needless_range_loop)]
537    for i in 0..4 {
538        v[i][i] = 1.0;
539    }
540    let mut b = ata;
541
542    for _ in 0..200 {
543        let mut max_val = 0.0_f64;
544        let mut p = 0;
545        let mut q = 1;
546        #[allow(clippy::needless_range_loop)]
547        for i in 0..4 {
548            for j in (i + 1)..4 {
549                if b[i][j].abs() > max_val {
550                    max_val = b[i][j].abs();
551                    p = i;
552                    q = j;
553                }
554            }
555        }
556        if max_val < 1e-14 {
557            break;
558        }
559
560        let mpq = b[p][q];
561        if mpq.abs() < 1e-30 {
562            continue;
563        }
564        let theta = (b[q][q] - b[p][p]) / (2.0 * mpq);
565        let t = if theta >= 0.0 {
566            1.0 / (theta + (1.0 + theta * theta).sqrt())
567        } else {
568            1.0 / (theta - (1.0 + theta * theta).sqrt())
569        };
570        let cos = 1.0 / (1.0 + t * t).sqrt();
571        let sin = t * cos;
572        let tau = sin / (1.0 + cos);
573
574        b[p][p] -= t * mpq;
575        b[q][q] += t * mpq;
576        b[p][q] = 0.0;
577        b[q][p] = 0.0;
578
579        #[allow(clippy::needless_range_loop)]
580        for r in 0..4 {
581            if r != p && r != q {
582                let brp = b[r][p];
583                let brq = b[r][q];
584                b[r][p] = brp - sin * (brq + tau * brp);
585                b[p][r] = b[r][p];
586                b[r][q] = brq + sin * (brp - tau * brq);
587                b[q][r] = b[r][q];
588            }
589        }
590
591        #[allow(clippy::needless_range_loop)]
592        for r in 0..4 {
593            let vrp = v[r][p];
594            let vrq = v[r][q];
595            v[r][p] = vrp - sin * (vrq + tau * vrp);
596            v[r][q] = vrq + sin * (vrp - tau * vrq);
597        }
598    }
599
600    // Find the column of V with the smallest eigenvalue.
601    let eigenvalues = [b[0][0], b[1][1], b[2][2], b[3][3]];
602    let min_idx = eigenvalues
603        .iter()
604        .enumerate()
605        .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
606        .map(|(i, _)| i)?;
607
608    Some([v[0][min_idx], v[1][min_idx], v[2][min_idx], v[3][min_idx]])
609}
610
611// ─────────────────────────────────────────────────────────────────────────────
612// dense_depth_from_sparse
613// ─────────────────────────────────────────────────────────────────────────────
614
615/// Densify a sparse depth map using an image-guided fast-marching propagation.
616///
617/// Valid pixels (depth > 0) are used as seeds.  Their values propagate outward
618/// to neighbouring zero-depth pixels in a BFS fashion, weighted by the inverse
619/// of the image intensity gradient (to encourage propagation along homogeneous
620/// regions).
621///
622/// # Arguments
623///
624/// * `sparse_depth` – Sparse depth values, shape `[H, W]`.
625///   Zero (or negative) values are treated as missing.
626/// * `image`        – Guidance image as a grayscale array, shape `[H, W]`.
627///   Used to weight propagation; need not be normalised.
628///
629/// # Returns
630///
631/// Dense depth map of the same shape.  Any pixels that are not reachable from
632/// a valid seed pixel remain 0.
633///
634/// # Errors
635///
636/// Returns [`VisionError::DimensionMismatch`] when `sparse_depth` and `image`
637/// have different shapes.
638///
639/// # Example
640///
641/// ```
642/// use scirs2_vision::depth_estimation::dense_depth_from_sparse;
643/// use scirs2_core::ndarray::Array2;
644///
645/// let mut sparse = Array2::zeros((4, 4));
646/// sparse[[1, 1]] = 5.0;
647/// let image = Array2::from_elem((4, 4), 128.0_f64);
648/// let dense = dense_depth_from_sparse(&sparse, &image).unwrap();
649/// assert_eq!(dense.dim(), (4, 4));
650/// // The seed pixel is preserved.
651/// assert!((dense[[1, 1]] - 5.0).abs() < 1e-9);
652/// ```
653pub fn dense_depth_from_sparse(
654    sparse_depth: &Array2<f64>,
655    image: &Array2<f64>,
656) -> Result<Array2<f64>> {
657    let (h, w) = sparse_depth.dim();
658    if image.dim() != (h, w) {
659        return Err(VisionError::DimensionMismatch(
660            "sparse_depth and image must have the same shape".to_string(),
661        ));
662    }
663
664    let mut output = sparse_depth.clone();
665    // Confidence weights for the blending (we track total weight per pixel).
666    let mut weight = Array2::zeros((h, w));
667
668    // Mark valid seeds with weight 1.
669    for y in 0..h {
670        for x in 0..w {
671            if sparse_depth[[y, x]] > 0.0 {
672                weight[[y, x]] = 1.0;
673            }
674        }
675    }
676
677    // Compute an edge-strength map based on local gradient.
678    // Pixels with high gradient get higher resistance to propagation.
679    let mut edge = Array2::zeros((h, w));
680    for y in 1..h - 1 {
681        for x in 1..w - 1 {
682            let dx = (image[[y, x + 1]] - image[[y, x - 1]]) * 0.5;
683            let dy = (image[[y + 1, x]] - image[[y - 1, x]]) * 0.5;
684            edge[[y, x]] = (dx * dx + dy * dy).sqrt();
685        }
686    }
687    let max_edge = edge.iter().cloned().fold(0.0_f64, f64::max).max(1.0);
688
689    // BFS from all valid seeds.
690    let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
691    for y in 0..h {
692        for x in 0..w {
693            if sparse_depth[[y, x]] > 0.0 {
694                queue.push_back((y, x));
695            }
696        }
697    }
698
699    let neighbours: [(i64, i64); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
700
701    while let Some((cy, cx)) = queue.pop_front() {
702        let cur_depth = output[[cy, cx]];
703        let cur_w = weight[[cy, cx]];
704
705        for &(dy, dx) in &neighbours {
706            let ny = cy as i64 + dy;
707            let nx = cx as i64 + dx;
708            if ny < 0 || ny >= h as i64 || nx < 0 || nx >= w as i64 {
709                continue;
710            }
711            let ny = ny as usize;
712            let nx = nx as usize;
713
714            // Propagation weight decreases across edges.
715            let edge_penalty = 1.0 + edge[[ny, nx]] / max_edge;
716            let prop_w = cur_w / edge_penalty;
717
718            if prop_w > weight[[ny, nx]] {
719                // Weighted accumulation: the neighbour adopts the depth of the
720                // strongest propagating path.
721                output[[ny, nx]] = cur_depth;
722                weight[[ny, nx]] = prop_w;
723                queue.push_back((ny, nx));
724            }
725        }
726    }
727
728    Ok(output)
729}
730
731// ─────────────────────────────────────────────────────────────────────────────
732// Tests
733// ─────────────────────────────────────────────────────────────────────────────
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use scirs2_core::ndarray::Array2;
739
740    #[test]
741    fn test_depth_map_from_data() {
742        let data = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
743            .expect("from_shape_vec should succeed with correct element count");
744        let dm = DepthMap::from_data(data, 1.0);
745        assert!((dm.min_depth - 1.0).abs() < 1e-9);
746        assert!((dm.max_depth - 4.0).abs() < 1e-9);
747    }
748
749    #[test]
750    fn test_depth_map_to_metric() {
751        let data = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
752            .expect("from_shape_vec should succeed with correct element count");
753        let dm = DepthMap::new(data, 0.5, 0.5, 2.0);
754        let metric = dm.to_metric();
755        assert!((metric.data[[0, 0]] - 0.5).abs() < 1e-9);
756        assert!((metric.data[[1, 1]] - 2.0).abs() < 1e-9);
757        assert!((metric.scale - 1.0).abs() < 1e-9);
758    }
759
760    #[test]
761    fn test_scale_invariant_loss_perfect() {
762        let pred = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
763            .expect("from_shape_vec should succeed with correct element count");
764        let gt = pred.clone();
765        let loss = scale_invariant_loss(&pred, &gt)
766            .expect("scale_invariant_loss should succeed on identical arrays");
767        assert!(loss.abs() < 1e-10);
768    }
769
770    #[test]
771    fn test_scale_invariant_loss_scaled() {
772        // If pred = k * gt, the scale-invariant loss should still be zero.
773        let gt = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
774            .expect("from_shape_vec should succeed with correct element count");
775        let pred = gt.mapv(|v| v * 2.0);
776        let loss = scale_invariant_loss(&pred, &gt)
777            .expect("scale_invariant_loss should succeed with valid scaled inputs");
778        assert!(loss.abs() < 1e-10, "loss = {loss}");
779    }
780
781    #[test]
782    fn test_scale_invariant_loss_no_valid_pixels() {
783        let zeros = Array2::zeros((2, 2));
784        assert!(scale_invariant_loss(&zeros, &zeros).is_err());
785    }
786
787    #[test]
788    fn test_scale_invariant_loss_shape_mismatch() {
789        let a = Array2::from_elem((2, 2), 1.0_f64);
790        let b = Array2::from_elem((2, 3), 1.0_f64);
791        assert!(scale_invariant_loss(&a, &b).is_err());
792    }
793
794    #[test]
795    fn test_absolute_relative_error_perfect() {
796        let pred = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
797            .expect("from_shape_vec should succeed with correct element count");
798        let err = absolute_relative_error(&pred, &pred)
799            .expect("absolute_relative_error should succeed on identical arrays");
800        assert!(err.abs() < 1e-10);
801    }
802
803    #[test]
804    fn test_absolute_relative_error_value() {
805        let gt = Array2::from_shape_vec((1, 1), vec![4.0])
806            .expect("from_shape_vec should succeed with correct element count");
807        let pred = Array2::from_shape_vec((1, 1), vec![5.0])
808            .expect("from_shape_vec should succeed with correct element count");
809        let err = absolute_relative_error(&pred, &gt)
810            .expect("absolute_relative_error should succeed with valid inputs");
811        // |5 - 4| / 4 = 0.25
812        assert!((err - 0.25).abs() < 1e-10, "err={err}");
813    }
814
815    #[test]
816    fn test_threshold_accuracy_perfect() {
817        let pred = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
818            .expect("from_shape_vec should succeed with correct element count");
819        let acc = threshold_accuracy(&pred, &pred, 1.25)
820            .expect("threshold_accuracy should succeed on identical arrays");
821        assert!((acc - 1.0).abs() < 1e-10);
822    }
823
824    #[test]
825    fn test_threshold_accuracy_none_pass() {
826        let gt = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
827            .expect("from_shape_vec should succeed with correct element count");
828        let pred = gt.mapv(|v| v * 10.0); // ratio = 10 > 1.25
829        let acc = threshold_accuracy(&pred, &gt, 1.25)
830            .expect("threshold_accuracy should succeed with valid inputs");
831        assert!((acc).abs() < 1e-10, "acc={acc}");
832    }
833
834    #[test]
835    fn test_threshold_accuracy_bad_threshold() {
836        let img = Array2::from_elem((2, 2), 1.0_f64);
837        assert!(threshold_accuracy(&img, &img, 1.0).is_err());
838        assert!(threshold_accuracy(&img, &img, 0.5).is_err());
839    }
840
841    #[test]
842    fn test_depth_from_sfm_basic() {
843        // Two cameras: cam0 at origin, cam1 offset by 1 unit along X.
844        let poses = vec![
845            CameraPose::identity(),
846            CameraPose::new(
847                [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
848                [1.0, 0.0, 0.0],
849            ),
850        ];
851        // Keypoints: the point is at world (0, 0, 5).
852        // With K = I and normalised coords:
853        //   cam0: projects to (0/5, 0/5) = (0, 0)
854        //   cam1: projects to ((0-1)/5, 0/5) = (-0.2, 0)
855        let kps = vec![
856            vec![KeyPoint2D::new(0.0, 0.0)],
857            vec![KeyPoint2D::new(-0.2, 0.0)],
858        ];
859        let matches = vec![KeyPointMatch::new(0, 0, 0, 1)];
860        let pts = depth_from_sfm(&poses, &kps, &matches)
861            .expect("depth_from_sfm should succeed with valid inputs");
862        assert_eq!(pts.len(), 1);
863        let (x, y, z) = pts[0];
864        // Expect approximately (0, 0, 5).
865        assert!(x.abs() < 0.5, "x={x}");
866        assert!(y.abs() < 0.5, "y={y}");
867        assert!((z - 5.0).abs() < 1.0, "z={z}");
868    }
869
870    #[test]
871    fn test_depth_from_sfm_empty_poses() {
872        let kps: Vec<Vec<KeyPoint2D>> = vec![vec![]];
873        let matches: Vec<KeyPointMatch> = vec![];
874        assert!(depth_from_sfm(&[], &kps, &matches).is_err());
875    }
876
877    #[test]
878    fn test_dense_depth_from_sparse() {
879        let mut sparse = Array2::zeros((6, 6));
880        sparse[[2, 2]] = 5.0;
881        let image = Array2::from_elem((6, 6), 128.0_f64);
882        let dense = dense_depth_from_sparse(&sparse, &image)
883            .expect("dense_depth_from_sparse should succeed with valid inputs");
884        assert_eq!(dense.dim(), (6, 6));
885        assert!((dense[[2, 2]] - 5.0).abs() < 1e-9);
886        // Neighbours should also have been filled.
887        assert!(dense[[2, 3]] > 0.0);
888        assert!(dense[[3, 2]] > 0.0);
889    }
890
891    #[test]
892    fn test_dense_depth_from_sparse_shape_mismatch() {
893        let sparse = Array2::zeros((4, 4));
894        let image = Array2::zeros((4, 5));
895        assert!(dense_depth_from_sparse(&sparse, &image).is_err());
896    }
897
898    #[test]
899    fn test_dense_depth_no_seeds() {
900        let sparse = Array2::zeros((4, 4));
901        let image = Array2::from_elem((4, 4), 100.0_f64);
902        let dense = dense_depth_from_sparse(&sparse, &image)
903            .expect("dense_depth_from_sparse should succeed with all-zero sparse depth");
904        // No seeds → all zeros.
905        assert!(dense.iter().all(|&v| v == 0.0));
906    }
907}