Skip to main content

fpm_rs/algorithms/
admm.rs

1use num_complex::Complex64;
2
3use crate::{
4    Result,
5    algorithms::objective::{LossType, point_loss},
6    backend::FftDirection,
7    diagnostics::StepDiagnostics,
8    error::Error,
9    measurements::MeasurementRead,
10    model::{FourierOffset, ImagePlaneModel, fftshift_copy, ifftshift_copy},
11    reconstruction::{
12        AdmmAuxiliaryState, AlgorithmAuxiliaryState, Batch, ReconstructionProblem,
13        ReconstructionState,
14    },
15};
16
17use super::ReconstructionAlgorithm;
18
19/// Linearized ADMM reconstruction for Fourier ptychographic microscopy.
20///
21/// # Method
22///
23/// The algorithm splits each predicted detector field from the shared object
24/// by introducing an auxiliary field and a scaled dual variable. Each step
25/// alternates among a measurement-amplitude proximal update of the auxiliary
26/// fields, a pupil-preconditioned linearized update of the common object
27/// spectrum, and a scaled-dual update that drives the auxiliary and predicted
28/// fields toward consensus. This separates the nonlinear measurement
29/// constraint from the overlapping Fourier-patch consistency constraint.
30///
31/// For incoherently multiplexed data, the amplitude proximal is joint across
32/// all source modes in a frame. Auxiliary and scaled-dual fields are stored per
33/// frame-source mode in [`ReconstructionState`] for exact checkpoint resumption.
34/// The fixed-pupil linearization and multiplexed proximal used here are crate
35/// adaptations of the reference ADMM-FPM formulation.
36///
37/// Each iteration reports detector-field RMS residuals. The primal residual is
38/// `r_k = A x_k - z_k`, evaluated after the linearized object update, and the
39/// dual residual is `s_k = rho (z_k - z_{k-1})`. Here `A x` denotes the
40/// concatenated per-mode detector fields, including each mode of a multiplexed
41/// frame. Masked pixels and zero-weight frames are excluded. These residuals
42/// diagnose consensus and auxiliary-field motion; the solver does not use them
43/// as stopping criteria.
44///
45/// # Reference
46///
47/// A. Wang, Z. Zhang, S. Wang, A. Pan, C. Ma, and B. Yao, “Fourier
48/// Ptychographic Microscopy via Alternating Direction Method of Multipliers,”
49/// *Cells* **11**(9), 1512 (2022),
50/// [doi:10.3390/cells11091512](https://doi.org/10.3390/cells11091512).
51#[derive(Clone, Debug)]
52pub struct Admm {
53    /// Number of complete passes through the acquisition schedule.
54    pub iterations: usize,
55    /// Step size of the linearized, pupil-preconditioned object update.
56    pub object_step: f64,
57    /// Positive augmented-Lagrangian penalty tying auxiliary fields to the
58    /// fields predicted by the shared object.
59    pub penalty: f64,
60    /// Scaled-dual update relaxation in the inclusive range `0..=2`.
61    pub dual_relaxation: f64,
62    /// Number of measured frames supplied to each reconstruction step; the
63    /// default processes every frame together.
64    pub batch_size: usize,
65    /// Positive numerical floor used in normalizations and dark-field handling.
66    pub epsilon: f64,
67}
68
69impl Default for Admm {
70    fn default() -> Self {
71        Self {
72            iterations: 100,
73            object_step: 0.8,
74            penalty: 1.0,
75            dual_relaxation: 1.0,
76            batch_size: usize::MAX,
77            epsilon: 1e-10,
78        }
79    }
80}
81
82impl Admm {
83    pub fn iterations(mut self, iterations: usize) -> Self {
84        self.iterations = iterations;
85        self
86    }
87
88    pub fn object_step(mut self, step: f64) -> Self {
89        self.object_step = step;
90        self
91    }
92
93    pub fn penalty(mut self, penalty: f64) -> Self {
94        self.penalty = penalty;
95        self
96    }
97
98    pub fn dual_relaxation(mut self, relaxation: f64) -> Self {
99        self.dual_relaxation = relaxation;
100        self
101    }
102
103    pub fn batch_size(mut self, batch_size: usize) -> Self {
104        self.batch_size = batch_size;
105        self
106    }
107}
108
109impl ReconstructionAlgorithm for Admm {
110    fn validate(&self) -> Result<()> {
111        if !self.object_step.is_finite() || self.object_step <= 0.0 {
112            return Err(Error::InvalidParameter {
113                name: "object_step",
114                reason: "must be finite and positive".into(),
115            });
116        }
117        if !self.penalty.is_finite() || self.penalty <= 0.0 {
118            return Err(Error::InvalidParameter {
119                name: "penalty",
120                reason: "must be finite and positive".into(),
121            });
122        }
123        if !self.dual_relaxation.is_finite() || !(0.0..=2.0).contains(&self.dual_relaxation) {
124            return Err(Error::InvalidParameter {
125                name: "dual_relaxation",
126                reason: "must be finite and between zero and two".into(),
127            });
128        }
129        if self.batch_size == 0 || !self.epsilon.is_finite() || self.epsilon <= 0.0 {
130            return Err(Error::InvalidParameter {
131                name: "batch_size/epsilon",
132                reason: "batch size must be non-zero and epsilon finite and positive".into(),
133            });
134        }
135        Ok(())
136    }
137
138    fn step<M: MeasurementRead>(
139        &mut self,
140        problem: &ReconstructionProblem<M>,
141        state: &mut ReconstructionState,
142        batch: &Batch,
143        _iteration: usize,
144    ) -> Result<StepDiagnostics> {
145        let expected = admm_auxiliary_len(&problem.model)?;
146        let mut auxiliary = match state.algorithm_auxiliary.take() {
147            None => AdmmAuxiliaryState {
148                auxiliary_fields: vec![Complex64::default(); expected],
149                dual_fields: vec![Complex64::default(); expected],
150            },
151            Some(AlgorithmAuxiliaryState::Admm(auxiliary)) => auxiliary,
152        };
153        let result = self.step_with_auxiliary(problem, state, batch, &mut auxiliary);
154        state.algorithm_auxiliary = Some(AlgorithmAuxiliaryState::Admm(auxiliary));
155        result
156    }
157
158    fn iterations(&self) -> usize {
159        self.iterations
160    }
161
162    fn batch_size(&self) -> usize {
163        self.batch_size
164    }
165}
166
167impl Admm {
168    fn step_with_auxiliary<M: MeasurementRead>(
169        &self,
170        problem: &ReconstructionProblem<M>,
171        state: &mut ReconstructionState,
172        batch: &Batch,
173        auxiliary: &mut AdmmAuxiliaryState,
174    ) -> Result<StepDiagnostics> {
175        let model = &problem.model;
176        let shape = model.image_shape;
177        let image_len = shape.0 * shape.1;
178        let expected = admm_auxiliary_len(model)?;
179        if auxiliary.auxiliary_fields.len() != expected || auxiliary.dual_fields.len() != expected {
180            return Err(Error::InvalidModel(
181                "ADMM auxiliary state does not match the problem modes".into(),
182            ));
183        }
184        state
185            .scratch
186            .object_gradient
187            .resize(state.object_spectrum.len(), Complex64::default());
188        state.scratch.object_gradient.fill(Complex64::default());
189        let maximum_pupil_power = state
190            .pupil
191            .values
192            .as_slice()
193            .iter()
194            .map(|value| value.norm_sqr())
195            .fold(0.0, f64::max)
196            .max(self.epsilon);
197        let mut diagnostics = StepDiagnostics::default();
198        let mut active_frames = 0;
199
200        for &frame in &batch.indices {
201            let frame_weight = problem.measurements.frame_weight(frame)?;
202            if frame_weight == 0.0 {
203                diagnostics.push_frame(frame, 0.0, 0.0);
204                continue;
205            }
206            active_frames += 1;
207            let single_source = [(frame, 1.0)];
208            let sources = frame_sources(model, frame, &single_source);
209            let source_weight_sum: f64 = sources.iter().map(|&(_, weight)| weight).sum();
210            let mode_start = frame_mode_start(model, frame);
211            let multiplex_len = image_len * sources.len();
212            state
213                .scratch
214                .multiplex_fields
215                .resize(multiplex_len, Complex64::default());
216            state.scratch.multiplex_offsets.clear();
217            state.scratch.projected_field.fill(Complex64::default());
218
219            // The real component accumulates the physical prediction; the
220            // imaginary component accumulates the dual-shifted proximal norm.
221            for (local_mode, &(source, source_weight)) in sources.iter().enumerate() {
222                let offset = state.effective_source_offset(model, source)?;
223                state.scratch.multiplex_offsets.push(offset);
224                compute_source_field(problem, state, source, offset)?;
225                let local_start = local_mode * image_len;
226                state.scratch.multiplex_fields[local_start..local_start + image_len]
227                    .copy_from_slice(&state.scratch.field);
228                let auxiliary_start = (mode_start + local_mode) * image_len;
229                for pixel in 0..image_len {
230                    let field = state.scratch.field[pixel];
231                    let consensus = field + auxiliary.dual_fields[auxiliary_start + pixel];
232                    state.scratch.projected_field[pixel].re += source_weight * field.norm_sqr();
233                    state.scratch.projected_field[pixel].im += source_weight * consensus.norm_sqr();
234                }
235            }
236
237            let measured = problem.measurements.frame(frame)?;
238            let mask = problem.measurements.frame_mask(frame)?;
239            let gain = state
240                .frame_gains
241                .as_ref()
242                .map_or(1.0, |values| values[frame]);
243            if !gain.is_finite() || gain <= 0.0 {
244                return Err(Error::InvalidModel(format!(
245                    "state frame {frame} has invalid gain {gain}"
246                )));
247            }
248            let mut frame_loss = 0.0;
249            let mut valid_pixels = 0;
250            for pixel in 0..image_len {
251                if mask.is_some_and(|values| values[pixel] == 0) {
252                    continue;
253                }
254                valid_pixels += 1;
255                let background = background_value(state, frame, pixel, image_len);
256                let predicted = gain * state.scratch.projected_field[pixel].re + background;
257                frame_loss += point_loss(predicted, measured[pixel], LossType::AmplitudeMse);
258            }
259            if valid_pixels == 0 {
260                return Err(Error::InvalidMeasurements(format!(
261                    "frame {frame} has no unmasked pixels"
262                )));
263            }
264            diagnostics.push_frame(frame, frame_loss / valid_pixels as f64, frame_weight);
265
266            for (local_mode, &(source, source_weight)) in sources.iter().enumerate() {
267                let local_start = local_mode * image_len;
268                state.scratch.field.copy_from_slice(
269                    &state.scratch.multiplex_fields[local_start..local_start + image_len],
270                );
271                let auxiliary_start = (mode_start + local_mode) * image_len;
272                for pixel in 0..image_len {
273                    let index = auxiliary_start + pixel;
274                    if mask.is_some_and(|values| values[pixel] == 0) {
275                        auxiliary.auxiliary_fields[index] = state.scratch.field[pixel];
276                        auxiliary.dual_fields[index] = Complex64::default();
277                        state.scratch.difference[pixel] = Complex64::default();
278                        continue;
279                    }
280                    let background = background_value(state, frame, pixel, image_len);
281                    let target = ((measured[pixel] - background) / gain).max(0.0).sqrt();
282                    let consensus = state.scratch.field[pixel] + auxiliary.dual_fields[index];
283                    let consensus_norm = state.scratch.projected_field[pixel].im;
284                    let projected = if consensus_norm > self.epsilon {
285                        consensus * (target / consensus_norm.sqrt())
286                    } else {
287                        Complex64::new(target / source_weight_sum.sqrt(), 0.0)
288                    };
289                    let auxiliary_value = (self.penalty * consensus + frame_weight * projected)
290                        / (self.penalty + frame_weight);
291                    diagnostics.push_admm_dual_change(
292                        auxiliary_value - auxiliary.auxiliary_fields[index],
293                        self.penalty,
294                    );
295                    auxiliary.auxiliary_fields[index] = auxiliary_value;
296                    state.scratch.difference[pixel] =
297                        auxiliary_value - auxiliary.dual_fields[index] - state.scratch.field[pixel];
298                }
299                state.backend.fft2(
300                    &mut state.scratch.difference,
301                    shape,
302                    FftDirection::Forward,
303                    &mut state.scratch.column,
304                )?;
305                fftshift_copy(
306                    &state.scratch.difference,
307                    &mut state.scratch.projected_spectrum,
308                    shape,
309                );
310                for pixel in 0..image_len {
311                    state.scratch.difference[pixel] = state.pupil.values.as_slice()[pixel].conj()
312                        * state.scratch.projected_spectrum[pixel]
313                        / (maximum_pupil_power + self.epsilon);
314                }
315                model.insert_patch_adjoint_slice_at_offset(
316                    &mut state.scratch.object_gradient,
317                    source,
318                    &state.scratch.difference,
319                    source_weight / source_weight_sum,
320                    state.scratch.multiplex_offsets[local_mode],
321                )?;
322            }
323        }
324
325        if active_frames > 0 {
326            let step = self.object_step / active_frames as f64;
327            for (object, &update) in state
328                .object_spectrum
329                .as_mut_slice()
330                .iter_mut()
331                .zip(&state.scratch.object_gradient)
332            {
333                *object += step * update;
334            }
335        }
336
337        for &frame in &batch.indices {
338            if problem.measurements.frame_weight(frame)? == 0.0 {
339                continue;
340            }
341            let single_source = [(frame, 1.0)];
342            let sources = frame_sources(model, frame, &single_source);
343            let mode_start = frame_mode_start(model, frame);
344            let mask = problem.measurements.frame_mask(frame)?;
345            for (local_mode, &(source, _)) in sources.iter().enumerate() {
346                let offset = state.effective_source_offset(model, source)?;
347                compute_source_field(problem, state, source, offset)?;
348                let auxiliary_start = (mode_start + local_mode) * image_len;
349                for pixel in 0..image_len {
350                    let index = auxiliary_start + pixel;
351                    if mask.is_some_and(|values| values[pixel] == 0) {
352                        auxiliary.auxiliary_fields[index] = state.scratch.field[pixel];
353                        auxiliary.dual_fields[index] = Complex64::default();
354                    } else {
355                        let primal_residual =
356                            state.scratch.field[pixel] - auxiliary.auxiliary_fields[index];
357                        diagnostics.push_admm_primal_residual(primal_residual);
358                        auxiliary.dual_fields[index] += self.dual_relaxation * primal_residual;
359                    }
360                }
361            }
362        }
363        Ok(diagnostics)
364    }
365}
366
367pub(crate) fn admm_auxiliary_len(model: &ImagePlaneModel) -> Result<usize> {
368    let mode_count = model.multiplexing_matrix.as_ref().map_or_else(
369        || Ok(model.frame_count()),
370        |matrix| {
371            matrix.iter().try_fold(0_usize, |count, row| {
372                count
373                    .checked_add(row.len())
374                    .ok_or_else(|| Error::InvalidShape("ADMM source mode count overflows".into()))
375            })
376        },
377    )?;
378    (model.image_shape.0 * model.image_shape.1)
379        .checked_mul(mode_count)
380        .ok_or_else(|| Error::InvalidShape("ADMM auxiliary length overflows".into()))
381}
382
383fn frame_mode_start(model: &ImagePlaneModel, frame: usize) -> usize {
384    model
385        .multiplexing_matrix
386        .as_ref()
387        .map_or(frame, |matrix| matrix[..frame].iter().map(Vec::len).sum())
388}
389
390fn frame_sources<'a>(
391    model: &'a ImagePlaneModel,
392    frame: usize,
393    single_source: &'a [(usize, f64); 1],
394) -> &'a [(usize, f64)] {
395    model
396        .multiplexing_matrix
397        .as_ref()
398        .map_or(single_source, |matrix| matrix[frame].as_slice())
399}
400
401fn compute_source_field<M: MeasurementRead>(
402    problem: &ReconstructionProblem<M>,
403    state: &mut ReconstructionState,
404    source: usize,
405    offset: FourierOffset,
406) -> Result<()> {
407    let model = &problem.model;
408    let shape = model.image_shape;
409    model.extract_patch_at_offset(
410        &state.object_spectrum,
411        source,
412        offset,
413        &mut state.scratch.patch,
414    )?;
415    for pixel in 0..state.scratch.patch.len() {
416        state.scratch.exit_spectrum[pixel] =
417            state.scratch.patch[pixel] * state.pupil.values.as_slice()[pixel];
418    }
419    ifftshift_copy(
420        &state.scratch.exit_spectrum,
421        &mut state.scratch.field,
422        shape,
423    );
424    state.backend.fft2(
425        &mut state.scratch.field,
426        shape,
427        FftDirection::Inverse,
428        &mut state.scratch.column,
429    )
430}
431
432fn background_value(
433    state: &ReconstructionState,
434    frame: usize,
435    pixel: usize,
436    image_len: usize,
437) -> f64 {
438    state.background.as_ref().map_or(0.0, |values| {
439        values[if values.len() == image_len {
440            pixel
441        } else {
442            frame * image_len + pixel
443        }]
444    })
445}