Skip to main content

fpm_rs/algorithms/
gradient_descent.rs

1use num_complex::Complex64;
2use std::thread;
3
4use crate::{
5    Result,
6    algorithms::objective::{LossType, point_loss},
7    backend::FftDirection,
8    diagnostics::StepDiagnostics,
9    error::Error,
10    measurements::MeasurementRead,
11    model::{FourierOffset, fftshift_copy, ifftshift_copy},
12    reconstruction::{Batch, ReconstructionProblem, ReconstructionState},
13};
14
15use super::{
16    ReconstructionAlgorithm,
17    regularization::{apply_complex_tv_step, apply_quadratic_smoothing_step},
18};
19
20/// Wirtinger-style loss-gradient reconstruction for Fourier ptychography.
21///
22/// # Method
23///
24/// The solver differentiates a selected real-valued data loss through the
25/// complex FPM forward model, accumulates gradients from a mini-batch, and
26/// applies a pupil-power-preconditioned update to the shared object spectrum.
27/// This is the Fourier-ptychographic Wirtinger-flow viewpoint: phase retrieval
28/// is treated as direct optimization rather than alternating hard projections.
29/// Losses are evaluated after accounting for known linear gain and background,
30/// so detector count scaling does not change the intrinsic update scale.
31///
32/// Optional extensions recover the pupil with an analogous normalized
33/// gradient, estimate illumination offsets with finite-difference derivatives
34/// and diagonal Gauss–Newton scaling, and regularize the complex object or
35/// pupil. Incoherent multiplexing, these calibration updates, and the selectable
36/// losses extend the reference formulation.
37///
38/// # Reference
39///
40/// L. Bian, J. Suo, G. Zheng, K. Guo, F. Chen, and Q. Dai, “Fourier
41/// ptychographic reconstruction using Wirtinger flow optimization,” *Optics
42/// Express* **23**(4), 4856–4866 (2015),
43/// [doi:10.1364/OE.23.004856](https://doi.org/10.1364/OE.23.004856).
44#[derive(Clone, Debug)]
45pub struct GradientDescent {
46    /// Number of complete passes through the acquisition schedule.
47    pub iterations: usize,
48    /// Step size of the pupil-power-preconditioned object update.
49    pub object_step: f64,
50    /// Number of frame gradients averaged into one update.
51    pub batch_size: usize,
52    /// Positive numerical floor used by losses and preconditioners.
53    pub epsilon: f64,
54    /// Data-fidelity objective to differentiate and report.
55    pub loss_type: LossType,
56    /// Whether to estimate a Fourier-grid offset for every illumination source.
57    pub recover_illumination: bool,
58    /// Step size of the diagonally scaled illumination-offset update.
59    pub illumination_step: f64,
60    /// Central finite-difference spacing in Fourier-grid pixels.
61    pub illumination_finite_difference: f64,
62    /// Maximum absolute row or column correction, in Fourier-grid pixels.
63    pub maximum_illumination_correction: f64,
64    /// Whether to update the complex pupil alongside the object.
65    pub recover_pupil: bool,
66    /// Step size of the normalized pupil update.
67    pub pupil_step: f64,
68    /// Whether to zero recovered pupil values outside the compiled aperture.
69    pub constrain_pupil_support: bool,
70    /// Weight of the isotropic total-variation step on the complex object;
71    /// `0` disables it.
72    pub object_tv_weight: f64,
73    /// Positive smoothing constant in the differentiable TV norm.
74    pub object_tv_epsilon: f64,
75    /// Weight of quadratic nearest-neighbor pupil smoothing; `0` disables it
76    /// and positive values require pupil recovery.
77    pub pupil_smoothing_weight: f64,
78    /// Maximum number of frame-gradient worker threads.
79    pub parallel_workers: usize,
80}
81
82impl Default for GradientDescent {
83    fn default() -> Self {
84        Self {
85            iterations: 100,
86            object_step: 0.5,
87            batch_size: 1,
88            epsilon: 1e-10,
89            loss_type: LossType::AmplitudeMse,
90            recover_illumination: false,
91            illumination_step: 0.1,
92            illumination_finite_difference: 0.05,
93            maximum_illumination_correction: 1.0,
94            recover_pupil: false,
95            pupil_step: 0.05,
96            constrain_pupil_support: true,
97            object_tv_weight: 0.0,
98            object_tv_epsilon: 1e-6,
99            pupil_smoothing_weight: 0.0,
100            parallel_workers: std::thread::available_parallelism().map_or(1, |count| count.get()),
101        }
102    }
103}
104
105impl GradientDescent {
106    pub fn iterations(mut self, iterations: usize) -> Self {
107        self.iterations = iterations;
108        self
109    }
110
111    pub fn object_step(mut self, step: f64) -> Self {
112        self.object_step = step;
113        self
114    }
115
116    pub fn batch_size(mut self, batch_size: usize) -> Self {
117        self.batch_size = batch_size;
118        self
119    }
120
121    pub fn loss_type(mut self, loss_type: LossType) -> Self {
122        self.loss_type = loss_type;
123        self
124    }
125
126    pub fn recover_illumination(mut self, recover: bool) -> Self {
127        self.recover_illumination = recover;
128        self
129    }
130
131    pub fn illumination_step(mut self, step: f64) -> Self {
132        self.illumination_step = step;
133        self
134    }
135
136    pub fn illumination_finite_difference(mut self, distance: f64) -> Self {
137        self.illumination_finite_difference = distance;
138        self
139    }
140
141    pub fn illumination_bounds(mut self, maximum_absolute_correction: f64) -> Self {
142        self.maximum_illumination_correction = maximum_absolute_correction;
143        self
144    }
145
146    pub fn recover_pupil(mut self, recover: bool) -> Self {
147        self.recover_pupil = recover;
148        self
149    }
150
151    pub fn pupil_step(mut self, step: f64) -> Self {
152        self.pupil_step = step;
153        self
154    }
155
156    pub fn constrain_pupil_support(mut self, constrain: bool) -> Self {
157        self.constrain_pupil_support = constrain;
158        self
159    }
160
161    pub fn object_tv(mut self, weight: f64) -> Self {
162        self.object_tv_weight = weight;
163        self
164    }
165
166    pub fn object_tv_epsilon(mut self, epsilon: f64) -> Self {
167        self.object_tv_epsilon = epsilon;
168        self
169    }
170
171    pub fn pupil_smoothing(mut self, weight: f64) -> Self {
172        self.pupil_smoothing_weight = weight;
173        self
174    }
175
176    /// Sets the maximum number of frame-gradient workers. Object, pupil, and
177    /// illumination-calibration contributions are reduced deterministically.
178    pub fn parallel_workers(mut self, workers: usize) -> Self {
179        self.parallel_workers = workers;
180        self
181    }
182}
183
184impl ReconstructionAlgorithm for GradientDescent {
185    fn validate(&self) -> Result<()> {
186        if !self.object_step.is_finite() || self.object_step <= 0.0 {
187            return Err(Error::InvalidParameter {
188                name: "object_step",
189                reason: "must be finite and positive".into(),
190            });
191        }
192        if !self.epsilon.is_finite() || self.epsilon <= 0.0 {
193            return Err(Error::InvalidParameter {
194                name: "epsilon",
195                reason: "must be finite and positive".into(),
196            });
197        }
198        if self.batch_size == 0 {
199            return Err(Error::InvalidParameter {
200                name: "batch_size",
201                reason: "must be greater than zero".into(),
202            });
203        }
204        if !self.illumination_step.is_finite() || self.illumination_step <= 0.0 {
205            return Err(Error::InvalidParameter {
206                name: "illumination_step",
207                reason: "must be finite and positive".into(),
208            });
209        }
210        if !self.illumination_finite_difference.is_finite()
211            || self.illumination_finite_difference <= 0.0
212        {
213            return Err(Error::InvalidParameter {
214                name: "illumination_finite_difference",
215                reason: "must be finite and positive".into(),
216            });
217        }
218        if !self.maximum_illumination_correction.is_finite()
219            || self.maximum_illumination_correction <= 0.0
220        {
221            return Err(Error::InvalidParameter {
222                name: "maximum_illumination_correction",
223                reason: "must be finite and positive".into(),
224            });
225        }
226        if !self.pupil_step.is_finite() || self.pupil_step < 0.0 {
227            return Err(Error::InvalidParameter {
228                name: "pupil_step",
229                reason: "must be finite and non-negative".into(),
230            });
231        }
232        if !self.object_tv_weight.is_finite() || self.object_tv_weight < 0.0 {
233            return Err(Error::InvalidParameter {
234                name: "object_tv_weight",
235                reason: "must be finite and non-negative".into(),
236            });
237        }
238        if !self.object_tv_epsilon.is_finite() || self.object_tv_epsilon <= 0.0 {
239            return Err(Error::InvalidParameter {
240                name: "object_tv_epsilon",
241                reason: "must be finite and positive".into(),
242            });
243        }
244        if !self.pupil_smoothing_weight.is_finite() || self.pupil_smoothing_weight < 0.0 {
245            return Err(Error::InvalidParameter {
246                name: "pupil_smoothing_weight",
247                reason: "must be finite and non-negative".into(),
248            });
249        }
250        if self.pupil_smoothing_weight > 0.0 && !self.recover_pupil {
251            return Err(Error::InvalidParameter {
252                name: "pupil_smoothing_weight",
253                reason: "requires pupil recovery to be enabled".into(),
254            });
255        }
256        if self.parallel_workers == 0 {
257            return Err(Error::InvalidParameter {
258                name: "parallel_workers",
259                reason: "must be greater than zero".into(),
260            });
261        }
262        Ok(())
263    }
264
265    fn step<M: MeasurementRead>(
266        &mut self,
267        problem: &ReconstructionProblem<M>,
268        state: &mut ReconstructionState,
269        batch: &Batch,
270        iteration: usize,
271    ) -> Result<StepDiagnostics> {
272        if self.parallel_workers > 1 && batch.indices.len() > 1 {
273            return self.parallel_step(problem, state, batch, iteration);
274        }
275        let model = &problem.model;
276        let shape = model.image_shape;
277        let image_len = shape.0 * shape.1;
278        let maximum_pupil_power = state
279            .pupil
280            .values
281            .as_slice()
282            .iter()
283            .map(|value| value.norm_sqr())
284            .fold(0.0, f64::max)
285            .max(self.epsilon);
286        let mut diagnostics = StepDiagnostics::default();
287        let mut active_frames = 0;
288        if self.recover_illumination {
289            prepare_illumination_accumulators(model, state)?;
290        }
291        state
292            .scratch
293            .object_gradient
294            .resize(state.object_spectrum.len(), Complex64::default());
295        state.scratch.object_gradient.fill(Complex64::default());
296        if self.recover_pupil {
297            state.scratch.pupil_gradient.fill(Complex64::default());
298        }
299
300        for &frame in &batch.indices {
301            let frame_weight = problem.measurements.frame_weight(frame)?;
302            if frame_weight == 0.0 {
303                diagnostics.push_frame(frame, 0.0, 0.0);
304                continue;
305            }
306            active_frames += 1;
307            let single_source = [(frame, 1.0)];
308            let sources = model
309                .multiplexing_matrix
310                .as_ref()
311                .map_or(single_source.as_slice(), |matrix| matrix[frame].as_slice());
312
313            state.scratch.projected_field.fill(Complex64::default());
314            for &(source, source_weight) in sources {
315                let offset = state.effective_source_offset(model, source)?;
316                compute_source_field(problem, state, source, offset)?;
317                for (predicted, field) in state
318                    .scratch
319                    .projected_field
320                    .iter_mut()
321                    .zip(&state.scratch.field)
322                {
323                    predicted.re += source_weight * field.norm_sqr();
324                }
325            }
326
327            let measured = problem.measurements.frame(frame)?;
328            let mask = problem.measurements.frame_mask(frame)?;
329            let gain = state
330                .frame_gains
331                .as_ref()
332                .map_or(1.0, |values| values[frame]);
333            if !gain.is_finite() || gain <= 0.0 {
334                return Err(Error::InvalidModel(format!(
335                    "state frame {frame} has invalid gain {gain}"
336                )));
337            }
338            let mut frame_loss = 0.0;
339            let mut valid_pixels = 0;
340            for pixel in 0..image_len {
341                if mask.is_some_and(|values| values[pixel] == 0) {
342                    state.scratch.projected_field[pixel] = Complex64::default();
343                    continue;
344                }
345                valid_pixels += 1;
346                let background = background_value(state, frame, pixel, image_len);
347                let intrinsic_prediction = state.scratch.projected_field[pixel].re.max(0.0);
348                let target_intensity = ((measured[pixel] - background) / gain).max(0.0);
349                frame_loss += point_loss(intrinsic_prediction, target_intensity, self.loss_type);
350                state.scratch.projected_field[pixel] = Complex64::new(
351                    descent_factor(
352                        intrinsic_prediction,
353                        target_intensity,
354                        self.loss_type,
355                        self.epsilon,
356                    ),
357                    intrinsic_prediction,
358                );
359            }
360            if valid_pixels == 0 {
361                return Err(Error::InvalidMeasurements(format!(
362                    "frame {frame} has no unmasked pixels"
363                )));
364            }
365            // The FFT adjoint contributes a 1/image_len normalization. Match
366            // the reported per-frame mean when masked pixels reduce its divisor.
367            let valid_pixel_scale = image_len as f64 / valid_pixels as f64;
368            diagnostics.push_frame(frame, frame_loss / valid_pixels as f64, frame_weight);
369
370            for &(source, source_weight) in sources {
371                let offset = state.effective_source_offset(model, source)?;
372                compute_source_field(problem, state, source, offset)?;
373                if self.recover_illumination {
374                    for (reference, field) in state
375                        .scratch
376                        .calibration_reference
377                        .iter_mut()
378                        .zip(&state.scratch.field)
379                    {
380                        *reference = field.norm_sqr();
381                    }
382                }
383                for pixel in 0..image_len {
384                    state.scratch.field[pixel] *=
385                        source_weight * state.scratch.projected_field[pixel].re;
386                }
387                state.backend.fft2(
388                    &mut state.scratch.field,
389                    shape,
390                    FftDirection::Forward,
391                    &mut state.scratch.column,
392                )?;
393                fftshift_copy(
394                    &state.scratch.field,
395                    &mut state.scratch.projected_spectrum,
396                    shape,
397                );
398                if self.recover_pupil {
399                    let maximum_object_power = state
400                        .scratch
401                        .patch
402                        .iter()
403                        .map(|value| value.norm_sqr())
404                        .fold(0.0, f64::max)
405                        .max(self.epsilon);
406                    for pixel in 0..image_len {
407                        state.scratch.pupil_gradient[pixel] += frame_weight
408                            * valid_pixel_scale
409                            * state.scratch.patch[pixel].conj()
410                            * state.scratch.projected_spectrum[pixel]
411                            / (maximum_object_power + self.epsilon);
412                    }
413                }
414                for pixel in 0..image_len {
415                    state.scratch.difference[pixel] = state.pupil.values.as_slice()[pixel].conj()
416                        * state.scratch.projected_spectrum[pixel]
417                        / (maximum_pupil_power + self.epsilon);
418                }
419                model.insert_patch_adjoint_slice_at_offset(
420                    &mut state.scratch.object_gradient,
421                    source,
422                    &state.scratch.difference,
423                    frame_weight * valid_pixel_scale,
424                    offset,
425                )?;
426                if self.recover_illumination {
427                    let gradient = illumination_gradient(
428                        problem,
429                        state,
430                        source,
431                        offset,
432                        IlluminationGradientConfiguration {
433                            source_weight,
434                            valid_pixels,
435                            distance: self.illumination_finite_difference,
436                            loss_type: self.loss_type,
437                            epsilon: self.epsilon,
438                        },
439                    )?;
440                    state.scratch.illumination_gradient[source].0 +=
441                        frame_weight * gradient.row.gradient;
442                    state.scratch.illumination_gradient[source].1 +=
443                        frame_weight * gradient.column.gradient;
444                    state.scratch.illumination_curvature[source].0 +=
445                        frame_weight * gradient.row.curvature;
446                    state.scratch.illumination_curvature[source].1 +=
447                        frame_weight * gradient.column.curvature;
448                    state.scratch.illumination_weight[source] += frame_weight;
449                }
450            }
451        }
452        if active_frames > 0 {
453            let step = self.object_step / active_frames as f64;
454            for (object, &gradient) in state
455                .object_spectrum
456                .as_mut_slice()
457                .iter_mut()
458                .zip(&state.scratch.object_gradient)
459            {
460                *object -= step * gradient;
461            }
462            if self.recover_pupil {
463                let pupil_step = self.pupil_step / active_frames as f64;
464                for (pupil, &gradient) in state
465                    .pupil
466                    .values
467                    .as_mut_slice()
468                    .iter_mut()
469                    .zip(&state.scratch.pupil_gradient)
470                {
471                    *pupil -= pupil_step * gradient;
472                    if !pupil.re.is_finite() || !pupil.im.is_finite() {
473                        return Err(Error::Numerical(
474                            "pupil update produced a non-finite value".into(),
475                        ));
476                    }
477                }
478            }
479        }
480        let batch_fraction = batch.indices.len() as f64 / model.frame_count() as f64;
481        if self.object_tv_weight > 0.0 {
482            apply_object_tv(
483                state,
484                model.reconstruction_shape,
485                batch_fraction * self.object_tv_weight,
486                self.object_tv_epsilon,
487            )?;
488        }
489        if self.recover_pupil && self.pupil_smoothing_weight > 0.0 {
490            apply_quadratic_smoothing_step(
491                state.pupil.values.as_mut_slice(),
492                model.image_shape,
493                batch_fraction * self.pupil_smoothing_weight,
494                &mut state.scratch.pupil_gradient,
495            )?;
496        }
497        if self.recover_pupil && self.constrain_pupil_support {
498            state.pupil.apply_support();
499        }
500        if self.recover_illumination {
501            self.apply_illumination_update(model, state)?;
502        }
503        Ok(diagnostics)
504    }
505
506    fn iterations(&self) -> usize {
507        self.iterations
508    }
509
510    fn batch_size(&self) -> usize {
511        self.batch_size
512    }
513}
514
515struct ParallelWorkerResult {
516    position: usize,
517    object_delta: Vec<Complex64>,
518    pupil_delta: Vec<Complex64>,
519    illumination_gradient: Vec<(f64, f64)>,
520    illumination_curvature: Vec<(f64, f64)>,
521    illumination_weight: Vec<f64>,
522    diagnostics: StepDiagnostics,
523    active_frames: usize,
524}
525
526impl GradientDescent {
527    fn parallel_step<M: MeasurementRead>(
528        &self,
529        problem: &ReconstructionProblem<M>,
530        state: &mut ReconstructionState,
531        batch: &Batch,
532        iteration: usize,
533    ) -> Result<StepDiagnostics> {
534        let worker_count = self.parallel_workers.min(batch.indices.len());
535        if self.recover_illumination {
536            prepare_illumination_accumulators(&problem.model, state)?;
537        }
538        let base_state = state.clone();
539        let mut results = thread::scope(|scope| -> Result<Vec<ParallelWorkerResult>> {
540            let base_chunk_len = batch.indices.len() / worker_count;
541            let remainder = batch.indices.len() % worker_count;
542            let handles: Vec<_> = (0..worker_count)
543                .map(|worker| {
544                    let start = worker * base_chunk_len + worker.min(remainder);
545                    let length = base_chunk_len + usize::from(worker < remainder);
546                    let frames = &batch.indices[start..start + length];
547                    let base_state = &base_state;
548                    scope.spawn(move || -> Result<ParallelWorkerResult> {
549                        let mut local_state = base_state.clone();
550                        let mut local_algorithm = self.clone();
551                        local_algorithm.parallel_workers = 1;
552                        local_algorithm.object_tv_weight = 0.0;
553                        local_algorithm.pupil_smoothing_weight = 0.0;
554                        local_algorithm.constrain_pupil_support = false;
555                        let mut output = ParallelWorkerResult {
556                            position: worker,
557                            object_delta: vec![
558                                Complex64::default();
559                                base_state.object_spectrum.len()
560                            ],
561                            pupil_delta: if self.recover_pupil {
562                                vec![Complex64::default(); base_state.pupil.values.len()]
563                            } else {
564                                Vec::new()
565                            },
566                            illumination_gradient: if self.recover_illumination {
567                                vec![(0.0, 0.0); problem.model.source_count()]
568                            } else {
569                                Vec::new()
570                            },
571                            illumination_curvature: if self.recover_illumination {
572                                vec![(0.0, 0.0); problem.model.source_count()]
573                            } else {
574                                Vec::new()
575                            },
576                            illumination_weight: if self.recover_illumination {
577                                vec![0.0; problem.model.source_count()]
578                            } else {
579                                Vec::new()
580                            },
581                            diagnostics: StepDiagnostics::default(),
582                            active_frames: 0,
583                        };
584                        for &frame in frames {
585                            local_state
586                                .object_spectrum
587                                .as_mut_slice()
588                                .copy_from_slice(base_state.object_spectrum.as_slice());
589                            if self.recover_pupil {
590                                local_state
591                                    .pupil
592                                    .values
593                                    .as_mut_slice()
594                                    .copy_from_slice(base_state.pupil.values.as_slice());
595                            }
596                            if self.recover_illumination {
597                                local_state
598                                    .illumination_corrections
599                                    .clone_from(&base_state.illumination_corrections);
600                            }
601                            let diagnostics = local_algorithm.step(
602                                problem,
603                                &mut local_state,
604                                &Batch::single(frame),
605                                iteration,
606                            )?;
607                            if diagnostics.weight_sum > 0.0 {
608                                output.active_frames += 1;
609                                for ((sum, &value), &initial) in output
610                                    .object_delta
611                                    .iter_mut()
612                                    .zip(local_state.object_spectrum.as_slice())
613                                    .zip(base_state.object_spectrum.as_slice())
614                                {
615                                    *sum += value - initial;
616                                }
617                                if self.recover_pupil {
618                                    for ((sum, &value), &initial) in output
619                                        .pupil_delta
620                                        .iter_mut()
621                                        .zip(local_state.pupil.values.as_slice())
622                                        .zip(base_state.pupil.values.as_slice())
623                                    {
624                                        *sum += value - initial;
625                                    }
626                                }
627                                if self.recover_illumination {
628                                    for source in 0..problem.model.source_count() {
629                                        output.illumination_gradient[source].0 +=
630                                            local_state.scratch.illumination_gradient[source].0;
631                                        output.illumination_gradient[source].1 +=
632                                            local_state.scratch.illumination_gradient[source].1;
633                                        output.illumination_curvature[source].0 +=
634                                            local_state.scratch.illumination_curvature[source].0;
635                                        output.illumination_curvature[source].1 +=
636                                            local_state.scratch.illumination_curvature[source].1;
637                                        output.illumination_weight[source] +=
638                                            local_state.scratch.illumination_weight[source];
639                                    }
640                                }
641                            }
642                            output.diagnostics.merge(diagnostics);
643                        }
644                        Ok(output)
645                    })
646                })
647                .collect();
648            let mut output = Vec::with_capacity(handles.len());
649            for handle in handles {
650                output.push(
651                    handle.join().map_err(|_| {
652                        Error::Numerical("parallel gradient worker panicked".into())
653                    })??,
654                );
655            }
656            Ok(output)
657        })?;
658        results.sort_by_key(|result| result.position);
659
660        state
661            .scratch
662            .object_gradient
663            .resize(state.object_spectrum.len(), Complex64::default());
664        state.scratch.object_gradient.fill(Complex64::default());
665        if self.recover_pupil {
666            state.scratch.pupil_gradient.fill(Complex64::default());
667        }
668        if self.recover_illumination {
669            state.scratch.illumination_gradient.fill((0.0, 0.0));
670            state.scratch.illumination_curvature.fill((0.0, 0.0));
671            state.scratch.illumination_weight.fill(0.0);
672        }
673        let mut diagnostics = StepDiagnostics::default();
674        let mut active_frames = 0;
675        for result in results {
676            active_frames += result.active_frames;
677            for (sum, &value) in state
678                .scratch
679                .object_gradient
680                .iter_mut()
681                .zip(&result.object_delta)
682            {
683                *sum += value;
684            }
685            if self.recover_pupil {
686                for (sum, &value) in state
687                    .scratch
688                    .pupil_gradient
689                    .iter_mut()
690                    .zip(&result.pupil_delta)
691                {
692                    *sum += value;
693                }
694            }
695            if self.recover_illumination {
696                for source in 0..problem.model.source_count() {
697                    state.scratch.illumination_gradient[source].0 +=
698                        result.illumination_gradient[source].0;
699                    state.scratch.illumination_gradient[source].1 +=
700                        result.illumination_gradient[source].1;
701                    state.scratch.illumination_curvature[source].0 +=
702                        result.illumination_curvature[source].0;
703                    state.scratch.illumination_curvature[source].1 +=
704                        result.illumination_curvature[source].1;
705                    state.scratch.illumination_weight[source] += result.illumination_weight[source];
706                }
707            }
708            diagnostics.merge(result.diagnostics);
709        }
710        if active_frames > 0 {
711            let normalization = active_frames as f64;
712            for (object, &sum) in state
713                .object_spectrum
714                .as_mut_slice()
715                .iter_mut()
716                .zip(&state.scratch.object_gradient)
717            {
718                *object += sum / normalization;
719            }
720            if self.recover_pupil {
721                for (pupil, &sum) in state
722                    .pupil
723                    .values
724                    .as_mut_slice()
725                    .iter_mut()
726                    .zip(&state.scratch.pupil_gradient)
727                {
728                    *pupil += sum / normalization;
729                    if !pupil.re.is_finite() || !pupil.im.is_finite() {
730                        return Err(Error::Numerical(
731                            "parallel pupil update produced a non-finite value".into(),
732                        ));
733                    }
734                }
735            }
736        }
737
738        let model = &problem.model;
739        let batch_fraction = batch.indices.len() as f64 / model.frame_count() as f64;
740        if self.object_tv_weight > 0.0 {
741            apply_object_tv(
742                state,
743                model.reconstruction_shape,
744                batch_fraction * self.object_tv_weight,
745                self.object_tv_epsilon,
746            )?;
747        }
748        if self.recover_pupil && self.pupil_smoothing_weight > 0.0 {
749            apply_quadratic_smoothing_step(
750                state.pupil.values.as_mut_slice(),
751                model.image_shape,
752                batch_fraction * self.pupil_smoothing_weight,
753                &mut state.scratch.pupil_gradient,
754            )?;
755        }
756        if self.recover_pupil && self.constrain_pupil_support {
757            state.pupil.apply_support();
758        }
759        if self.recover_illumination {
760            self.apply_illumination_update(model, state)?;
761        }
762        Ok(diagnostics)
763    }
764
765    fn apply_illumination_update(
766        &self,
767        model: &crate::model::ImagePlaneModel,
768        state: &mut ReconstructionState,
769    ) -> Result<()> {
770        let corrections = state.illumination_corrections.as_mut().ok_or_else(|| {
771            Error::InvalidModel("illumination corrections were not initialized".into())
772        })?;
773        for (source, correction) in corrections.iter_mut().enumerate() {
774            let weight = state.scratch.illumination_weight[source];
775            if weight == 0.0 {
776                continue;
777            }
778            let gradient = state.scratch.illumination_gradient[source];
779            let curvature = state.scratch.illumination_curvature[source];
780            if !gradient.0.is_finite()
781                || !gradient.1.is_finite()
782                || !curvature.0.is_finite()
783                || !curvature.1.is_finite()
784                || curvature.0 < 0.0
785                || curvature.1 < 0.0
786            {
787                return Err(Error::Numerical(format!(
788                    "illumination gradient or curvature for source {source} is invalid"
789                )));
790            }
791            let candidate = (
792                (correction.0 - self.illumination_step * gradient.0 / (curvature.0 + self.epsilon))
793                    .clamp(
794                        -self.maximum_illumination_correction,
795                        self.maximum_illumination_correction,
796                    ),
797                (correction.1 - self.illumination_step * gradient.1 / (curvature.1 + self.epsilon))
798                    .clamp(
799                        -self.maximum_illumination_correction,
800                        self.maximum_illumination_correction,
801                    ),
802            );
803            let base = model.source_offset(source)?;
804            let effective = FourierOffset::new(base.row + candidate.0, base.column + candidate.1);
805            if model.validate_source_offset(source, effective).is_ok() {
806                *correction = candidate;
807            }
808        }
809        Ok(())
810    }
811}
812
813fn prepare_illumination_accumulators(
814    model: &crate::model::ImagePlaneModel,
815    state: &mut ReconstructionState,
816) -> Result<()> {
817    match &state.illumination_corrections {
818        None => {
819            state.illumination_corrections = Some(vec![(0.0, 0.0); model.source_count()]);
820        }
821        Some(corrections) if corrections.len() != model.source_count() => {
822            return Err(Error::InvalidModel(
823                "illumination correction count does not match source count".into(),
824            ));
825        }
826        Some(_) => {}
827    }
828    state
829        .scratch
830        .illumination_gradient
831        .resize(model.source_count(), (0.0, 0.0));
832    state.scratch.illumination_gradient.fill((0.0, 0.0));
833    state
834        .scratch
835        .illumination_curvature
836        .resize(model.source_count(), (0.0, 0.0));
837    state.scratch.illumination_curvature.fill((0.0, 0.0));
838    state
839        .scratch
840        .illumination_weight
841        .resize(model.source_count(), 0.0);
842    state.scratch.illumination_weight.fill(0.0);
843    Ok(())
844}
845
846fn apply_object_tv(
847    state: &mut ReconstructionState,
848    shape: (usize, usize),
849    weight: f64,
850    epsilon: f64,
851) -> Result<()> {
852    state
853        .scratch
854        .regularization_field
855        .resize(state.object_spectrum.len(), Complex64::default());
856    ifftshift_copy(
857        state.object_spectrum.as_slice(),
858        &mut state.scratch.regularization_field,
859        shape,
860    );
861    state.backend.fft2(
862        &mut state.scratch.regularization_field,
863        shape,
864        FftDirection::Inverse,
865        &mut state.scratch.column,
866    )?;
867    apply_complex_tv_step(
868        &mut state.scratch.regularization_field,
869        shape,
870        weight,
871        epsilon,
872        &mut state.scratch.object_gradient,
873    )?;
874    state.backend.fft2(
875        &mut state.scratch.regularization_field,
876        shape,
877        FftDirection::Forward,
878        &mut state.scratch.column,
879    )?;
880    fftshift_copy(
881        &state.scratch.regularization_field,
882        state.object_spectrum.as_mut_slice(),
883        shape,
884    );
885    Ok(())
886}
887
888fn descent_factor(predicted: f64, measured: f64, loss_type: LossType, epsilon: f64) -> f64 {
889    match loss_type {
890        LossType::AmplitudeMse => 1.0 - measured.max(0.0).sqrt() / predicted.max(epsilon).sqrt(),
891        LossType::IntensityMse => 2.0 * (predicted - measured),
892        LossType::PoissonNegativeLogLikelihood => 1.0 - measured.max(0.0) / predicted.max(epsilon),
893        LossType::HuberAmplitude => {
894            let predicted_amplitude = predicted.max(epsilon).sqrt();
895            let residual = predicted_amplitude - measured.max(0.0).sqrt();
896            residual.clamp(-1.0, 1.0) / (2.0 * predicted_amplitude)
897        }
898    }
899}
900
901fn compute_source_field<M: MeasurementRead>(
902    problem: &ReconstructionProblem<M>,
903    state: &mut ReconstructionState,
904    source: usize,
905    offset: FourierOffset,
906) -> Result<()> {
907    let model = &problem.model;
908    let shape = model.image_shape;
909    model.extract_patch_at_offset(
910        &state.object_spectrum,
911        source,
912        offset,
913        &mut state.scratch.patch,
914    )?;
915    for pixel in 0..state.scratch.patch.len() {
916        state.scratch.exit_spectrum[pixel] =
917            state.scratch.patch[pixel] * state.pupil.values.as_slice()[pixel];
918    }
919    ifftshift_copy(
920        &state.scratch.exit_spectrum,
921        &mut state.scratch.field,
922        shape,
923    );
924    state.backend.fft2(
925        &mut state.scratch.field,
926        shape,
927        FftDirection::Inverse,
928        &mut state.scratch.column,
929    )
930}
931
932#[derive(Clone, Copy)]
933struct IlluminationGradientConfiguration {
934    source_weight: f64,
935    valid_pixels: usize,
936    distance: f64,
937    loss_type: LossType,
938    epsilon: f64,
939}
940
941#[derive(Clone, Copy, Default)]
942struct AxisGradient {
943    gradient: f64,
944    curvature: f64,
945}
946
947#[derive(Clone, Copy, Default)]
948struct IlluminationGradient {
949    row: AxisGradient,
950    column: AxisGradient,
951}
952
953fn illumination_gradient<M: MeasurementRead>(
954    problem: &ReconstructionProblem<M>,
955    state: &mut ReconstructionState,
956    source: usize,
957    offset: FourierOffset,
958    configuration: IlluminationGradientConfiguration,
959) -> Result<IlluminationGradient> {
960    let row = illumination_axis_gradient(
961        problem,
962        state,
963        source,
964        offset,
965        FourierOffset::new(configuration.distance, 0.0),
966        configuration,
967    )?;
968    let column = illumination_axis_gradient(
969        problem,
970        state,
971        source,
972        offset,
973        FourierOffset::new(0.0, configuration.distance),
974        configuration,
975    )?;
976    let pixels = configuration.valid_pixels as f64;
977    Ok(IlluminationGradient {
978        row: AxisGradient {
979            gradient: row.gradient / pixels,
980            curvature: row.curvature / pixels,
981        },
982        column: AxisGradient {
983            gradient: column.gradient / pixels,
984            curvature: column.curvature / pixels,
985        },
986    })
987}
988
989fn illumination_axis_gradient<M: MeasurementRead>(
990    problem: &ReconstructionProblem<M>,
991    state: &mut ReconstructionState,
992    source: usize,
993    offset: FourierOffset,
994    displacement: FourierOffset,
995    configuration: IlluminationGradientConfiguration,
996) -> Result<AxisGradient> {
997    let model = &problem.model;
998    let distance = displacement.row.abs() + displacement.column.abs();
999    let plus = FourierOffset::new(
1000        offset.row + displacement.row,
1001        offset.column + displacement.column,
1002    );
1003    let minus = FourierOffset::new(
1004        offset.row - displacement.row,
1005        offset.column - displacement.column,
1006    );
1007    let plus_valid = model.validate_source_offset(source, plus).is_ok();
1008    let minus_valid = model.validate_source_offset(source, minus).is_ok();
1009    if !plus_valid && !minus_valid {
1010        return Ok(AxisGradient::default());
1011    }
1012    if plus_valid {
1013        compute_source_field(problem, state, source, plus)?;
1014        for (candidate, field) in state
1015            .scratch
1016            .difference
1017            .iter_mut()
1018            .zip(&state.scratch.field)
1019        {
1020            candidate.re = field.norm_sqr();
1021        }
1022    }
1023    if minus_valid {
1024        compute_source_field(problem, state, source, minus)?;
1025    }
1026    let mut gradient = 0.0;
1027    let mut curvature = 0.0;
1028    for pixel in 0..state.scratch.field.len() {
1029        let derivative = match (plus_valid, minus_valid) {
1030            (true, true) => {
1031                (state.scratch.difference[pixel].re - state.scratch.field[pixel].norm_sqr())
1032                    / (2.0 * distance)
1033            }
1034            (true, false) => {
1035                (state.scratch.difference[pixel].re - state.scratch.calibration_reference[pixel])
1036                    / distance
1037            }
1038            (false, true) => {
1039                (state.scratch.calibration_reference[pixel] - state.scratch.field[pixel].norm_sqr())
1040                    / distance
1041            }
1042            (false, false) => 0.0,
1043        };
1044        let intensity_derivative = configuration.source_weight * derivative;
1045        let loss_derivative = state.scratch.projected_field[pixel].re;
1046        let predicted = state.scratch.projected_field[pixel].im;
1047        gradient += loss_derivative * intensity_derivative;
1048        curvature += descent_curvature(
1049            predicted,
1050            loss_derivative,
1051            configuration.loss_type,
1052            configuration.epsilon,
1053        ) * intensity_derivative
1054            * intensity_derivative;
1055    }
1056    Ok(AxisGradient {
1057        gradient,
1058        curvature,
1059    })
1060}
1061
1062fn descent_curvature(
1063    predicted: f64,
1064    descent_factor: f64,
1065    loss_type: LossType,
1066    epsilon: f64,
1067) -> f64 {
1068    let mean = predicted.max(epsilon);
1069    match loss_type {
1070        LossType::AmplitudeMse => 0.5 / mean,
1071        LossType::IntensityMse => 2.0,
1072        LossType::PoissonNegativeLogLikelihood => 1.0 / mean,
1073        LossType::HuberAmplitude => {
1074            let clipped_residual = descent_factor * 2.0 * mean.sqrt();
1075            if clipped_residual.abs() < 1.0 {
1076                0.25 / mean
1077            } else {
1078                epsilon
1079            }
1080        }
1081    }
1082}
1083
1084fn background_value(
1085    state: &ReconstructionState,
1086    frame: usize,
1087    pixel: usize,
1088    image_len: usize,
1089) -> f64 {
1090    state.background.as_ref().map_or(0.0, |values| {
1091        values[if values.len() == image_len {
1092            pixel
1093        } else {
1094            frame * image_len + pixel
1095        }]
1096    })
1097}