Skip to main content

fdars_core/alignment/
multires.rs

1//! Multi-resolution elastic alignment: coarse DP + fine gradient refinement.
2//!
3//! Standard DP alignment has O(m²) complexity. Multi-resolution alignment
4//! runs DP on a coarsened grid first, then refines the warp using gradient
5//! descent on the original resolution, giving faster alignment for long curves.
6
7use super::pairwise::elastic_align_pair;
8use super::srsf::{reparameterize_curve, srsf_single};
9use super::{dp_alignment_core, AlignmentResult};
10use crate::error::FdarError;
11use crate::helpers::{l2_distance, linear_interp, simpsons_weights};
12use crate::warping::normalize_warp;
13
14// ─── Types ──────────────────────────────────────────────────────────────────
15
16/// Configuration for multi-resolution alignment.
17///
18/// Construct via `MultiresConfig::default()`, then assign the fields you need (e.g. `let mut c = MultiresConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
19#[non_exhaustive]
20#[derive(Debug, Clone, PartialEq)]
21pub struct MultiresConfig {
22    /// Coarsening factor: the coarse grid has `m / coarsen_factor` points.
23    /// Must be >= 2. Default 4.
24    pub coarsen_factor: usize,
25    /// Number of gradient refinement steps on the fine grid.
26    /// Default 10.
27    pub n_refine_steps: usize,
28    /// Gradient descent step size for refinement.
29    /// Default 0.01.
30    pub step_size: f64,
31    /// Roughness penalty for elastic alignment (0.0 = no penalty).
32    pub lambda: f64,
33}
34
35impl Default for MultiresConfig {
36    fn default() -> Self {
37        Self {
38            coarsen_factor: 4,
39            n_refine_steps: 10,
40            step_size: 0.01,
41            lambda: 0.0,
42        }
43    }
44}
45
46// ─── Public API ─────────────────────────────────────────────────────────────
47
48/// Align curve `f2` to `f1` using multi-resolution elastic alignment.
49///
50/// 1. **Coarse stage**: Subsample both SRSFs to a coarser grid, run DP,
51///    interpolate the resulting warp back to full resolution.
52/// 2. **Fine stage**: Starting from the coarse warp, run gradient descent
53///    steps to locally refine the warp on the full-resolution grid.
54///
55/// For short curves (m < 2 * coarsen_factor), falls back to standard DP.
56///
57/// # Arguments
58/// * `f1` — Target curve (length m)
59/// * `f2` — Curve to align (length m)
60/// * `argvals` — Evaluation points (length m)
61/// * `config` — Multi-resolution configuration
62///
63/// # Errors
64/// Returns [`FdarError::InvalidDimension`] if lengths do not match or m < 2.
65/// Returns [`FdarError::InvalidParameter`] if `coarsen_factor < 2`.
66#[must_use = "expensive computation whose result should not be discarded"]
67pub fn elastic_align_pair_multires(
68    f1: &[f64],
69    f2: &[f64],
70    argvals: &[f64],
71    config: &MultiresConfig,
72) -> Result<AlignmentResult, FdarError> {
73    let m = f1.len();
74
75    if m != f2.len() || m != argvals.len() {
76        return Err(FdarError::InvalidDimension {
77            parameter: "f1/f2/argvals",
78            expected: format!("equal lengths, f1 has {m}"),
79            actual: format!("f2 has {}, argvals has {}", f2.len(), argvals.len()),
80        });
81    }
82    if m < 2 {
83        return Err(FdarError::InvalidDimension {
84            parameter: "f1",
85            expected: "length >= 2".to_string(),
86            actual: format!("length {m}"),
87        });
88    }
89    if config.coarsen_factor < 2 {
90        return Err(FdarError::InvalidParameter {
91            parameter: "coarsen_factor",
92            message: format!("must be >= 2, got {}", config.coarsen_factor),
93        });
94    }
95
96    // For short curves, fall back to standard alignment
97    if m < 2 * config.coarsen_factor {
98        let result = elastic_align_pair(f1, f2, argvals, config.lambda);
99        return Ok(result);
100    }
101
102    let q1 = srsf_single(f1, argvals);
103    let q2 = srsf_single(f2, argvals);
104
105    // ── Stage 1: Coarse DP ──
106    let m_coarse = (m / config.coarsen_factor).max(4);
107    let coarse_argvals = subsample_grid(argvals, m_coarse);
108    let coarse_q1 = subsample_values(&q1, argvals, &coarse_argvals);
109    let coarse_q2 = subsample_values(&q2, argvals, &coarse_argvals);
110
111    let coarse_gamma = dp_alignment_core(&coarse_q1, &coarse_q2, &coarse_argvals, config.lambda);
112
113    // Interpolate coarse warp to fine grid
114    let mut gamma: Vec<f64> = argvals
115        .iter()
116        .map(|&t| linear_interp(&coarse_argvals, &coarse_gamma, t))
117        .collect();
118    normalize_warp(&mut gamma, argvals);
119
120    // ── Stage 2: Gradient refinement ──
121    for _ in 0..config.n_refine_steps {
122        // Compute current cost and gradient
123        let f2_warped = reparameterize_curve(f2, argvals, &gamma);
124        let q2_warped = srsf_single(&f2_warped, argvals);
125
126        // Approximate gradient: dJ/dγ_j ≈ -2(q1_j - q2_warped_j) * dq2/dγ_j
127        // We use a finite-difference approximation for simplicity
128        let h = 1.0 / (m as f64 * 10.0);
129        let weights = simpsons_weights(argvals);
130        let _current_dist = l2_distance(&q1, &q2_warped, &weights);
131
132        let mut improved = false;
133        for j in 1..m - 1 {
134            // Perturb gamma[j] and measure cost change
135            let orig = gamma[j];
136
137            gamma[j] = orig + h;
138            // Ensure monotonicity
139            if gamma[j] <= gamma[j - 1] || gamma[j] >= gamma[j + 1] {
140                gamma[j] = orig;
141                continue;
142            }
143
144            let f2_pert = reparameterize_curve(f2, argvals, &gamma);
145            let q2_pert = srsf_single(&f2_pert, argvals);
146            let dist_plus = l2_distance(&q1, &q2_pert, &weights);
147
148            gamma[j] = orig - h;
149            if gamma[j] <= gamma[j - 1] || gamma[j] >= gamma[j + 1] {
150                gamma[j] = orig;
151                continue;
152            }
153
154            let f2_pert2 = reparameterize_curve(f2, argvals, &gamma);
155            let q2_pert2 = srsf_single(&f2_pert2, argvals);
156            let dist_minus = l2_distance(&q1, &q2_pert2, &weights);
157
158            // Central difference gradient
159            let grad = (dist_plus - dist_minus) / (2.0 * h);
160
161            // Gradient step
162            let new_val = orig - config.step_size * grad;
163            // Clamp to maintain monotonicity
164            let lo = gamma[j - 1] + 1e-12;
165            let hi = gamma[j + 1] - 1e-12;
166            gamma[j] = new_val.clamp(lo, hi);
167
168            if (gamma[j] - orig).abs() > 1e-15 {
169                improved = true;
170            }
171        }
172
173        if !improved {
174            break;
175        }
176
177        normalize_warp(&mut gamma, argvals);
178    }
179
180    // ── Final alignment ──
181    let f_aligned = reparameterize_curve(f2, argvals, &gamma);
182    let q_aligned = srsf_single(&f_aligned, argvals);
183    let weights = simpsons_weights(argvals);
184    let distance = l2_distance(&q1, &q_aligned, &weights);
185
186    Ok(AlignmentResult {
187        gamma,
188        f_aligned,
189        distance,
190    })
191}
192
193// ─── Helpers ────────────────────────────────────────────────────────────────
194
195/// Create a uniform subsample of a grid.
196fn subsample_grid(argvals: &[f64], m_coarse: usize) -> Vec<f64> {
197    let m = argvals.len();
198    if m_coarse >= m {
199        return argvals.to_vec();
200    }
201    (0..m_coarse)
202        .map(|i| {
203            let idx_f = i as f64 * (m - 1) as f64 / (m_coarse - 1) as f64;
204            let lo = idx_f.floor() as usize;
205            let hi = idx_f.ceil().min((m - 1) as f64) as usize;
206            let frac = idx_f - lo as f64;
207            argvals[lo] * (1.0 - frac) + argvals[hi] * frac
208        })
209        .collect()
210}
211
212/// Interpolate values from the fine grid to a coarser grid.
213fn subsample_values(values: &[f64], fine_grid: &[f64], coarse_grid: &[f64]) -> Vec<f64> {
214    coarse_grid
215        .iter()
216        .map(|&t| linear_interp(fine_grid, values, t))
217        .collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::test_helpers::uniform_grid;
224
225    #[test]
226    fn multires_identity() {
227        let m = 50;
228        let t = uniform_grid(m);
229        let f: Vec<f64> = t.iter().map(|&x| (x * 6.0).sin()).collect();
230
231        let config = MultiresConfig::default();
232        let result = elastic_align_pair_multires(&f, &f, &t, &config).unwrap();
233
234        assert!(
235            result.distance < 0.5,
236            "identical curves should have near-zero distance, got {}",
237            result.distance
238        );
239    }
240
241    #[test]
242    fn multires_phase_shifted() {
243        let m = 60;
244        let t = uniform_grid(m);
245        let f1: Vec<f64> = t.iter().map(|&x| (x * 6.0).sin()).collect();
246        let f2: Vec<f64> = t.iter().map(|&x| ((x + 0.1) * 6.0).sin()).collect();
247
248        let config = MultiresConfig::default();
249        let result = elastic_align_pair_multires(&f1, &f2, &t, &config).unwrap();
250
251        // Should produce a reasonable alignment
252        let standard = elastic_align_pair(&f1, &f2, &t, 0.0);
253        // Multi-res may be slightly worse but should not be dramatically worse
254        assert!(
255            result.distance < standard.distance * 2.0 + 0.5,
256            "multi-res distance ({}) should be comparable to standard ({})",
257            result.distance,
258            standard.distance,
259        );
260    }
261
262    #[test]
263    fn multires_falls_back_short_curves() {
264        let m = 6;
265        let t = uniform_grid(m);
266        let f1: Vec<f64> = t.iter().map(|&x| x * x).collect();
267        let f2: Vec<f64> = t.iter().map(|&x| x * x + 0.1).collect();
268
269        let config = MultiresConfig {
270            coarsen_factor: 4,
271            ..Default::default()
272        };
273        let result = elastic_align_pair_multires(&f1, &f2, &t, &config).unwrap();
274        assert_eq!(result.gamma.len(), m);
275        assert_eq!(result.f_aligned.len(), m);
276    }
277
278    #[test]
279    fn multires_rejects_bad_coarsen_factor() {
280        let t = uniform_grid(20);
281        let f: Vec<f64> = t.to_vec();
282        let config = MultiresConfig {
283            coarsen_factor: 1,
284            ..Default::default()
285        };
286        assert!(elastic_align_pair_multires(&f, &f, &t, &config).is_err());
287    }
288
289    #[test]
290    fn multires_config_default() {
291        let config = MultiresConfig::default();
292        assert_eq!(config.coarsen_factor, 4);
293        assert_eq!(config.n_refine_steps, 10);
294        assert!((config.step_size - 0.01).abs() < f64::EPSILON);
295        assert!((config.lambda - 0.0).abs() < f64::EPSILON);
296    }
297}