Skip to main content

fpm_rs/model/
image_plane_fpm.rs

1use num_complex::Complex64;
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    Array2, Result,
6    error::Error,
7    experiment::{IlluminationSource, KVector, MultiplexingMatrix, Optics},
8};
9
10use super::{CropIndices, FourierCrop, FourierOffset, Pupil, Sampling};
11
12/// Selects an explicit reconstruction grid or an automatic sizing strategy.
13///
14/// Automatic shapes preserve the low-resolution aspect ratio, so the recovered
15/// object has the same pixel size in both axes. [`Self::Smooth`] and
16/// [`Self::PowerOfTwo`] round the shared reduced-aspect-ratio multiplier rather
17/// than each dimension independently.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum ReconstructionShape {
20    /// Use the supplied concrete `(height, width)` after validating it.
21    Exact((usize, usize)),
22    /// Use the smallest grid containing every Fourier crop and interpolation
23    /// stencil.
24    Minimum,
25    /// Round the minimum shared multiplier up to a value whose prime factors
26    /// are limited to 2, 3, 5, and 7.
27    Smooth,
28    /// Round the minimum shared multiplier up to a power of two.
29    PowerOfTwo,
30}
31
32#[derive(Clone, Copy, Debug)]
33struct GridShift {
34    integer: isize,
35    offset: f64,
36    lower: isize,
37    upper: isize,
38}
39
40#[derive(Clone, Copy, Debug)]
41pub(crate) struct CropDisplacementBounds {
42    minimum_row: isize,
43    maximum_row: isize,
44    minimum_column: isize,
45    maximum_column: isize,
46}
47
48/// Algorithm-facing image-plane FPM model. It contains no LED or camera geometry.
49#[derive(Clone, Debug, Serialize, Deserialize)]
50pub struct ImagePlaneModel {
51    /// One transverse wave vector per illumination source.
52    pub k_vectors: Vec<KVector>,
53    pub pupil: Pupil,
54    pub crop_indices: CropIndices,
55    /// Fractional `(row, column)` Fourier-grid offsets relative to each crop.
56    #[serde(default)]
57    pub subpixel_offsets: Option<Vec<FourierOffset>>,
58    pub sampling: Sampling,
59    pub image_shape: (usize, usize),
60    pub reconstruction_shape: (usize, usize),
61    pub frame_gains: Option<Vec<f64>>,
62    pub background: Option<Vec<f64>>,
63    /// Optional measured-frame rows of `(source_index, incoherent_weight)`.
64    pub multiplexing_matrix: Option<MultiplexingMatrix>,
65}
66
67impl ImagePlaneModel {
68    pub fn new(
69        k_vectors: Vec<KVector>,
70        pupil: Pupil,
71        crop_indices: CropIndices,
72        sampling: Sampling,
73        image_shape: (usize, usize),
74        reconstruction_shape: (usize, usize),
75    ) -> Result<Self> {
76        let model = Self {
77            k_vectors,
78            pupil,
79            crop_indices,
80            subpixel_offsets: None,
81            sampling,
82            image_shape,
83            reconstruction_shape,
84            frame_gains: None,
85            background: None,
86            multiplexing_matrix: None,
87        };
88        model.validate()?;
89        Ok(model)
90    }
91
92    /// Compiles an experiment using an explicit or automatically selected
93    /// reconstruction shape.
94    pub fn from_experiment<I: IlluminationSource>(
95        optics: &Optics,
96        illumination: &I,
97        image_shape: (usize, usize),
98        reconstruction_shape: ReconstructionShape,
99    ) -> Result<Self> {
100        optics.validate()?;
101        let k_vectors = illumination.k_vectors(optics)?;
102        Self::from_experiment_with_k_vectors(
103            optics,
104            illumination,
105            k_vectors,
106            image_shape,
107            reconstruction_shape,
108        )
109    }
110
111    /// Resolves an explicit shape or suggests an automatic reconstruction grid
112    /// from the actual illumination wave vectors.
113    pub fn suggest_reconstruction_shape<I: IlluminationSource>(
114        optics: &Optics,
115        illumination: &I,
116        image_shape: (usize, usize),
117        reconstruction_shape: ReconstructionShape,
118    ) -> Result<(usize, usize)> {
119        optics.validate()?;
120        let k_vectors = illumination.k_vectors(optics)?;
121        let bounds = Self::crop_displacement_bounds(optics, image_shape, &k_vectors)?;
122        Self::resolve_reconstruction_shape(image_shape, reconstruction_shape, &[bounds])
123    }
124
125    pub(crate) fn from_experiment_with_k_vectors<I: IlluminationSource>(
126        optics: &Optics,
127        illumination: &I,
128        k_vectors: Vec<KVector>,
129        image_shape: (usize, usize),
130        reconstruction_shape: ReconstructionShape,
131    ) -> Result<Self> {
132        optics.validate()?;
133        let bounds = Self::crop_displacement_bounds(optics, image_shape, &k_vectors)?;
134        let reconstruction_shape =
135            Self::resolve_reconstruction_shape(image_shape, reconstruction_shape, &[bounds])?;
136        let scale = reconstruction_shape.1 as f64 / image_shape.1 as f64;
137        let low_res_pixel_size = optics.object_pixel_size();
138        let mut sampling = Sampling::new(
139            low_res_pixel_size,
140            low_res_pixel_size / scale,
141            std::f64::consts::TAU / (image_shape.1 as f64 * low_res_pixel_size),
142            std::f64::consts::TAU / (image_shape.0 as f64 * low_res_pixel_size),
143        )?;
144        sampling.wavelength = Some(optics.wavelength);
145        let maximum_illumination_na = k_vectors
146            .iter()
147            .map(|vector| vector.kx.hypot(vector.ky) * optics.wavelength / std::f64::consts::TAU)
148            .fold(0.0, f64::max);
149        sampling.synthetic_na = Some(optics.objective_na + maximum_illumination_na);
150        let pupil = Pupil::circular(image_shape, &sampling, optics)?;
151        let mut offsets = Vec::with_capacity(k_vectors.len());
152        let crops = k_vectors
153            .iter()
154            .map(|vector| {
155                let (crop, offset) =
156                    Self::crop_for_k_vector(vector, &sampling, image_shape, reconstruction_shape)?;
157                offsets.push(offset);
158                Ok(crop)
159            })
160            .collect::<Result<Vec<_>>>()?;
161        let mut model = Self::new(
162            k_vectors,
163            pupil,
164            CropIndices::new(crops),
165            sampling,
166            image_shape,
167            reconstruction_shape,
168        )?;
169        model.frame_gains = illumination.frame_gains()?;
170        model.multiplexing_matrix = illumination.multiplexing_matrix()?;
171        model.subpixel_offsets = Some(offsets);
172        model.validate()?;
173        Ok(model)
174    }
175
176    pub(crate) fn crop_displacement_bounds(
177        optics: &Optics,
178        image_shape: (usize, usize),
179        k_vectors: &[KVector],
180    ) -> Result<CropDisplacementBounds> {
181        optics.validate()?;
182        validate_image_shape(image_shape)?;
183        if k_vectors.is_empty() {
184            return Err(Error::InvalidModel(
185                "illumination must contain at least one frame".into(),
186            ));
187        }
188        let low_res_pixel_size = optics.object_pixel_size();
189        let dkx = std::f64::consts::TAU / (image_shape.1 as f64 * low_res_pixel_size);
190        let dky = std::f64::consts::TAU / (image_shape.0 as f64 * low_res_pixel_size);
191        let mut bounds = CropDisplacementBounds {
192            minimum_row: isize::MAX,
193            maximum_row: isize::MIN,
194            minimum_column: isize::MAX,
195            maximum_column: isize::MIN,
196        };
197        for vector in k_vectors {
198            let row = checked_grid_shift(vector.ky / dky)?;
199            let column = checked_grid_shift(vector.kx / dkx)?;
200            bounds.minimum_row = bounds.minimum_row.min(row.lower);
201            bounds.maximum_row = bounds.maximum_row.max(row.upper);
202            bounds.minimum_column = bounds.minimum_column.min(column.lower);
203            bounds.maximum_column = bounds.maximum_column.max(column.upper);
204        }
205        Ok(bounds)
206    }
207
208    pub(crate) fn resolve_reconstruction_shape(
209        image_shape: (usize, usize),
210        reconstruction_shape: ReconstructionShape,
211        bounds: &[CropDisplacementBounds],
212    ) -> Result<(usize, usize)> {
213        validate_image_shape(image_shape)?;
214        if bounds.is_empty() {
215            return Err(Error::InvalidModel(
216                "at least one set of illumination crop bounds is required".into(),
217            ));
218        }
219        match reconstruction_shape {
220            ReconstructionShape::Exact(shape) => {
221                validate_reconstruction_aspect(image_shape, shape)?;
222                if !shape_contains_bounds(image_shape, shape, bounds)? {
223                    return Err(Error::InvalidShape(format!(
224                        "reconstruction shape {shape:?} does not contain every illumination crop and subpixel interpolation stencil"
225                    )));
226                }
227                Ok(shape)
228            }
229            policy => {
230                let divisor = greatest_common_divisor(image_shape.0, image_shape.1);
231                let aspect_height = image_shape.0 / divisor;
232                let aspect_width = image_shape.1 / divisor;
233                let maximum_multiplier =
234                    (isize::MAX as usize / aspect_height).min(isize::MAX as usize / aspect_width);
235                let minimum_multiplier = minimum_fitting_multiplier(
236                    image_shape,
237                    (aspect_height, aspect_width),
238                    divisor,
239                    maximum_multiplier,
240                    bounds,
241                )?;
242                let multiplier = match policy {
243                    ReconstructionShape::Minimum => minimum_multiplier,
244                    ReconstructionShape::Smooth => next_smooth_multiplier(minimum_multiplier)
245                        .filter(|&value| value <= maximum_multiplier)
246                        .ok_or_else(|| {
247                            Error::InvalidShape(
248                                "no supported 2/3/5/7-smooth reconstruction multiplier exists"
249                                    .into(),
250                            )
251                        })?,
252                    ReconstructionShape::PowerOfTwo => minimum_multiplier
253                        .checked_next_power_of_two()
254                        .filter(|&value| value <= maximum_multiplier)
255                        .ok_or_else(|| {
256                            Error::InvalidShape(
257                                "no supported power-of-two reconstruction multiplier exists".into(),
258                            )
259                        })?,
260                    ReconstructionShape::Exact(_) => unreachable!(),
261                };
262                candidate_shape((aspect_height, aspect_width), multiplier)
263            }
264        }
265    }
266
267    pub fn source_count(&self) -> usize {
268        self.k_vectors.len()
269    }
270
271    pub fn frame_count(&self) -> usize {
272        self.multiplexing_matrix
273            .as_ref()
274            .map_or_else(|| self.source_count(), Vec::len)
275    }
276
277    pub fn is_multiplexed(&self) -> bool {
278        self.multiplexing_matrix.is_some()
279    }
280
281    pub fn with_multiplexing(mut self, matrix: MultiplexingMatrix) -> Result<Self> {
282        self.multiplexing_matrix = Some(matrix);
283        self.validate()?;
284        Ok(self)
285    }
286
287    pub fn with_subpixel_offsets(mut self, offsets: Vec<FourierOffset>) -> Result<Self> {
288        self.subpixel_offsets = Some(offsets);
289        self.validate()?;
290        Ok(self)
291    }
292
293    pub fn source_offset(&self, source: usize) -> Result<FourierOffset> {
294        self.crop_indices.get(source)?;
295        match &self.subpixel_offsets {
296            None => Ok(FourierOffset::default()),
297            Some(offsets) => offsets.get(source).copied().ok_or_else(|| {
298                Error::InvalidModel("subpixel offset count does not match source count".into())
299            }),
300        }
301    }
302
303    pub fn extract_patch(
304        &self,
305        object_spectrum: &Array2<Complex64>,
306        source: usize,
307        destination: &mut [Complex64],
308    ) -> Result<()> {
309        self.extract_patch_at_offset(
310            object_spectrum,
311            source,
312            self.source_offset(source)?,
313            destination,
314        )
315    }
316
317    pub fn extract_patch_at_offset(
318        &self,
319        object_spectrum: &Array2<Complex64>,
320        source: usize,
321        offset: FourierOffset,
322        destination: &mut [Complex64],
323    ) -> Result<()> {
324        if object_spectrum.shape() != self.reconstruction_shape {
325            return Err(Error::InvalidShape(format!(
326                "object spectrum shape {:?} does not match {:?}",
327                object_spectrum.shape(),
328                self.reconstruction_shape
329            )));
330        }
331        let crop = self.crop_indices.get(source)?;
332        crop.extract_subpixel(object_spectrum, destination, offset)
333    }
334
335    pub fn insert_patch_adjoint(
336        &self,
337        destination: &mut Array2<Complex64>,
338        source: usize,
339        update: &[Complex64],
340        scale: f64,
341    ) -> Result<()> {
342        self.insert_patch_adjoint_at_offset(
343            destination,
344            source,
345            update,
346            scale,
347            self.source_offset(source)?,
348        )
349    }
350
351    pub fn insert_patch_adjoint_at_offset(
352        &self,
353        destination: &mut Array2<Complex64>,
354        source: usize,
355        update: &[Complex64],
356        scale: f64,
357        offset: FourierOffset,
358    ) -> Result<()> {
359        if destination.shape() != self.reconstruction_shape {
360            return Err(Error::InvalidShape(format!(
361                "object spectrum shape {:?} does not match {:?}",
362                destination.shape(),
363                self.reconstruction_shape
364            )));
365        }
366        let crop = self.crop_indices.get(source)?;
367        crop.insert_subpixel_adjoint(destination, update, scale, offset)
368    }
369
370    pub(crate) fn insert_patch_adjoint_slice_at_offset(
371        &self,
372        destination: &mut [Complex64],
373        source: usize,
374        update: &[Complex64],
375        scale: f64,
376        offset: FourierOffset,
377    ) -> Result<()> {
378        let crop = self.crop_indices.get(source)?;
379        crop.insert_subpixel_adjoint_slice(
380            destination,
381            self.reconstruction_shape,
382            update,
383            scale,
384            offset,
385        )
386    }
387
388    pub fn validate_source_offset(&self, source: usize, offset: FourierOffset) -> Result<()> {
389        self.crop_indices
390            .get(source)?
391            .validate_subpixel_inside(self.reconstruction_shape, offset)
392    }
393
394    pub(crate) fn crop_for_k_vector(
395        vector: &KVector,
396        sampling: &Sampling,
397        image_shape: (usize, usize),
398        reconstruction_shape: (usize, usize),
399    ) -> Result<(FourierCrop, FourierOffset)> {
400        let continuous_row = vector.ky / sampling.dky;
401        let continuous_column = vector.kx / sampling.dkx;
402        let row_shift = checked_grid_shift(continuous_row)?;
403        let column_shift = checked_grid_shift(continuous_column)?;
404        let reconstruction_center_row = isize::try_from(reconstruction_shape.0 / 2)
405            .map_err(|_| Error::InvalidShape("reconstruction height is too large".into()))?;
406        let reconstruction_center_column = isize::try_from(reconstruction_shape.1 / 2)
407            .map_err(|_| Error::InvalidShape("reconstruction width is too large".into()))?;
408        let image_half_height = isize::try_from(image_shape.0 / 2)
409            .map_err(|_| Error::InvalidShape("image height is too large".into()))?;
410        let image_half_width = isize::try_from(image_shape.1 / 2)
411            .map_err(|_| Error::InvalidShape("image width is too large".into()))?;
412        let start_row = reconstruction_center_row
413            .checked_sub(image_half_height)
414            .and_then(|value| value.checked_add(row_shift.integer));
415        let start_column = reconstruction_center_column
416            .checked_sub(image_half_width)
417            .and_then(|value| value.checked_add(column_shift.integer));
418        let (Some(start_row), Some(start_column)) = (start_row, start_column) else {
419            return Err(Error::InvalidModel(format!(
420                "illumination vector {vector:?} overflows the reconstruction grid"
421            )));
422        };
423        if start_row < 0 || start_column < 0 {
424            return Err(Error::InvalidModel(format!(
425                "illumination vector {vector:?} produces a crop outside the reconstruction grid"
426            )));
427        }
428        let crop = FourierCrop::new(
429            usize::try_from(start_row).map_err(|_| {
430                Error::InvalidModel("crop row cannot be represented as an index".into())
431            })?,
432            usize::try_from(start_column).map_err(|_| {
433                Error::InvalidModel("crop column cannot be represented as an index".into())
434            })?,
435            image_shape.0,
436            image_shape.1,
437        );
438        let offset = FourierOffset::new(row_shift.offset, column_shift.offset);
439        crop.validate_subpixel_inside(reconstruction_shape, offset)?;
440        Ok((crop, offset))
441    }
442
443    pub fn frame_gain(&self, frame: usize) -> Result<f64> {
444        if frame >= self.frame_count() {
445            return Err(Error::FrameOutOfRange {
446                index: frame,
447                frames: self.frame_count(),
448            });
449        }
450        Ok(self
451            .frame_gains
452            .as_ref()
453            .map_or(1.0, |values| values[frame]))
454    }
455
456    pub fn background_value(&self, frame: usize, pixel: usize) -> Result<f64> {
457        if frame >= self.frame_count() {
458            return Err(Error::FrameOutOfRange {
459                index: frame,
460                frames: self.frame_count(),
461            });
462        }
463        let image_len = self.image_shape.0 * self.image_shape.1;
464        if pixel >= image_len {
465            return Err(Error::InvalidParameter {
466                name: "pixel",
467                reason: format!("index {pixel} is outside an image with {image_len} pixels"),
468            });
469        }
470        Ok(self.background.as_ref().map_or(0.0, |values| {
471            values[if values.len() == image_len {
472                pixel
473            } else {
474                frame * image_len + pixel
475            }]
476        }))
477    }
478
479    pub fn validate(&self) -> Result<()> {
480        self.sampling.validate()?;
481        if self.k_vectors.is_empty() || self.source_count() != self.crop_indices.len() {
482            return Err(Error::InvalidModel(format!(
483                "{} source k-vectors and {} crops; counts must be equal and non-zero",
484                self.source_count(),
485                self.crop_indices.len()
486            )));
487        }
488        if self
489            .k_vectors
490            .iter()
491            .any(|vector| !vector.kx.is_finite() || !vector.ky.is_finite())
492        {
493            return Err(Error::InvalidModel(
494                "k-vectors must contain finite values".into(),
495            ));
496        }
497        if self.pupil.shape() != self.image_shape {
498            return Err(Error::InvalidModel(format!(
499                "pupil shape {:?} does not match image shape {:?}",
500                self.pupil.shape(),
501                self.image_shape
502            )));
503        }
504        if self.pupil.support.len() != self.pupil.values.len()
505            || self
506                .pupil
507                .values
508                .as_slice()
509                .iter()
510                .any(|value| !value.re.is_finite() || !value.im.is_finite())
511        {
512            return Err(Error::InvalidModel(
513                "pupil support or numeric values are invalid".into(),
514            ));
515        }
516        if self.reconstruction_shape.0 < self.image_shape.0
517            || self.reconstruction_shape.1 < self.image_shape.1
518        {
519            return Err(Error::InvalidModel(
520                "reconstruction shape must contain a low-resolution crop".into(),
521            ));
522        }
523        for crop in &self.crop_indices.crops {
524            if (crop.height, crop.width) != self.image_shape {
525                return Err(Error::InvalidModel(format!(
526                    "crop shape {:?} does not match image shape {:?}",
527                    (crop.height, crop.width),
528                    self.image_shape
529                )));
530            }
531            crop.validate_inside(self.reconstruction_shape)?;
532        }
533        if let Some(offsets) = &self.subpixel_offsets {
534            if offsets.len() != self.source_count() {
535                return Err(Error::InvalidModel(
536                    "subpixel offset count does not match source count".into(),
537                ));
538            }
539            for (crop, &offset) in self.crop_indices.crops.iter().zip(offsets) {
540                crop.validate_subpixel_inside(self.reconstruction_shape, offset)?;
541            }
542        }
543        if let Some(values) = &self.frame_gains {
544            if values.len() != self.frame_count() {
545                return Err(Error::InvalidModel(
546                    "frame gain count does not match frame count".into(),
547                ));
548            }
549            if values
550                .iter()
551                .any(|value| !value.is_finite() || *value <= 0.0)
552            {
553                return Err(Error::InvalidModel(
554                    "frame gains must be finite and positive".into(),
555                ));
556            }
557        }
558        let image_len = self.image_shape.0 * self.image_shape.1;
559        if self.background.as_ref().is_some_and(|values| {
560            values.len() != image_len && values.len() != image_len * self.frame_count()
561        }) {
562            return Err(Error::InvalidModel(
563                "background must be one image or one image per frame".into(),
564            ));
565        }
566        if self
567            .background
568            .as_ref()
569            .is_some_and(|values| values.iter().any(|value| !value.is_finite()))
570        {
571            return Err(Error::InvalidModel(
572                "background values must be finite".into(),
573            ));
574        }
575        if let Some(matrix) = &self.multiplexing_matrix {
576            if matrix.is_empty() {
577                return Err(Error::InvalidModel(
578                    "multiplexing matrix must contain at least one measured frame".into(),
579                ));
580            }
581            for (row_index, row) in matrix.iter().enumerate() {
582                let mut seen = vec![false; self.source_count()];
583                let invalid = row.is_empty()
584                    || row.iter().any(|&(source, weight)| {
585                        let duplicate = source < self.source_count() && seen[source];
586                        if source < self.source_count() {
587                            seen[source] = true;
588                        }
589                        source >= self.source_count()
590                            || !weight.is_finite()
591                            || weight <= 0.0
592                            || duplicate
593                    });
594                if invalid {
595                    return Err(Error::InvalidModel(format!(
596                        "multiplexing row {row_index} is empty or contains a duplicate/invalid source or weight"
597                    )));
598                }
599            }
600        }
601        Ok(())
602    }
603}
604
605fn checked_grid_shift(value: f64) -> Result<GridShift> {
606    if !value.is_finite() {
607        return Err(Error::InvalidModel(
608            "illumination shift must be finite".into(),
609        ));
610    }
611    let rounded = value.round();
612    if rounded < isize::MIN as f64 || rounded > isize::MAX as f64 {
613        return Err(Error::InvalidModel(
614            "illumination shift is outside the supported index range".into(),
615        ));
616    }
617    let integer = rounded as isize;
618    let offset = value - rounded;
619    let nearest_offset = offset.round();
620    let (lower, upper) = if (offset - nearest_offset).abs() <= 1e-12 {
621        let displacement = integer
622            .checked_add(nearest_offset as isize)
623            .ok_or_else(|| {
624                Error::InvalidModel(
625                    "illumination shift is outside the supported index range".into(),
626                )
627            })?;
628        (displacement, displacement)
629    } else if offset > 0.0 {
630        (
631            integer,
632            integer.checked_add(1).ok_or_else(|| {
633                Error::InvalidModel(
634                    "illumination shift is outside the supported index range".into(),
635                )
636            })?,
637        )
638    } else {
639        (
640            integer.checked_sub(1).ok_or_else(|| {
641                Error::InvalidModel(
642                    "illumination shift is outside the supported index range".into(),
643                )
644            })?,
645            integer,
646        )
647    };
648    Ok(GridShift {
649        integer,
650        offset,
651        lower,
652        upper,
653    })
654}
655
656fn validate_image_shape(image_shape: (usize, usize)) -> Result<()> {
657    if image_shape.0 == 0 || image_shape.1 == 0 {
658        return Err(Error::InvalidShape(format!(
659            "image shape {image_shape:?} must be non-zero"
660        )));
661    }
662    if image_shape.0 > isize::MAX as usize || image_shape.1 > isize::MAX as usize {
663        return Err(Error::InvalidShape(
664            "image shape is outside the supported index range".into(),
665        ));
666    }
667    Ok(())
668}
669
670fn validate_reconstruction_aspect(
671    image_shape: (usize, usize),
672    reconstruction_shape: (usize, usize),
673) -> Result<()> {
674    if reconstruction_shape.0 < image_shape.0 || reconstruction_shape.1 < image_shape.1 {
675        return Err(Error::InvalidShape(format!(
676            "image shape {image_shape:?} must fit reconstruction shape {reconstruction_shape:?}"
677        )));
678    }
679    if reconstruction_shape.0 > isize::MAX as usize || reconstruction_shape.1 > isize::MAX as usize
680    {
681        return Err(Error::InvalidShape(
682            "reconstruction shape is outside the supported index range".into(),
683        ));
684    }
685    let left = reconstruction_shape.0 as u128 * image_shape.1 as u128;
686    let right = reconstruction_shape.1 as u128 * image_shape.0 as u128;
687    if left != right {
688        return Err(Error::InvalidShape(
689            "reconstruction must use the same scale factor in both dimensions".into(),
690        ));
691    }
692    Ok(())
693}
694
695fn shape_contains_bounds(
696    image_shape: (usize, usize),
697    reconstruction_shape: (usize, usize),
698    bounds: &[CropDisplacementBounds],
699) -> Result<bool> {
700    validate_reconstruction_aspect(image_shape, reconstruction_shape)?;
701    for bound in bounds {
702        if !axis_contains_bounds(
703            image_shape.0,
704            reconstruction_shape.0,
705            bound.minimum_row,
706            bound.maximum_row,
707        )? || !axis_contains_bounds(
708            image_shape.1,
709            reconstruction_shape.1,
710            bound.minimum_column,
711            bound.maximum_column,
712        )? {
713            return Ok(false);
714        }
715    }
716    Ok(true)
717}
718
719fn axis_contains_bounds(
720    image_length: usize,
721    reconstruction_length: usize,
722    minimum_displacement: isize,
723    maximum_displacement: isize,
724) -> Result<bool> {
725    let centre = i128::try_from(reconstruction_length / 2)
726        .map_err(|_| Error::InvalidShape("reconstruction dimension is too large".into()))?;
727    let image_half = i128::try_from(image_length / 2)
728        .map_err(|_| Error::InvalidShape("image dimension is too large".into()))?;
729    let base = centre.checked_sub(image_half).ok_or_else(|| {
730        Error::InvalidShape("reconstruction crop origin overflows the index range".into())
731    })?;
732    let lower = base
733        .checked_add(minimum_displacement as i128)
734        .ok_or_else(|| {
735            Error::InvalidShape("reconstruction crop origin overflows the index range".into())
736        })?;
737    let upper = base
738        .checked_add((image_length - 1) as i128)
739        .and_then(|value| value.checked_add(maximum_displacement as i128))
740        .ok_or_else(|| {
741            Error::InvalidShape("reconstruction crop extent overflows the index range".into())
742        })?;
743    Ok(lower >= 0 && upper < reconstruction_length as i128)
744}
745
746fn minimum_fitting_multiplier(
747    image_shape: (usize, usize),
748    aspect: (usize, usize),
749    initial_multiplier: usize,
750    maximum_multiplier: usize,
751    bounds: &[CropDisplacementBounds],
752) -> Result<usize> {
753    let fits = |multiplier| -> Result<bool> {
754        let shape = candidate_shape(aspect, multiplier)?;
755        shape_contains_bounds(image_shape, shape, bounds)
756    };
757    if fits(initial_multiplier)? {
758        return Ok(initial_multiplier);
759    }
760    let mut lower = initial_multiplier;
761    let mut upper = initial_multiplier;
762    loop {
763        let doubled = upper.checked_mul(2).unwrap_or(maximum_multiplier);
764        upper = doubled.min(maximum_multiplier);
765        if upper == lower {
766            return Err(Error::InvalidShape(
767                "illumination crops require a reconstruction shape outside the supported index range"
768                    .into(),
769            ));
770        }
771        if fits(upper)? {
772            break;
773        }
774        lower = upper;
775    }
776    while lower + 1 < upper {
777        let middle = lower + (upper - lower) / 2;
778        if fits(middle)? {
779            upper = middle;
780        } else {
781            lower = middle;
782        }
783    }
784    Ok(upper)
785}
786
787fn candidate_shape(aspect: (usize, usize), multiplier: usize) -> Result<(usize, usize)> {
788    let height = aspect.0.checked_mul(multiplier).ok_or_else(|| {
789        Error::InvalidShape("reconstruction height overflows the supported range".into())
790    })?;
791    let width = aspect.1.checked_mul(multiplier).ok_or_else(|| {
792        Error::InvalidShape("reconstruction width overflows the supported range".into())
793    })?;
794    Ok((height, width))
795}
796
797fn greatest_common_divisor(mut left: usize, mut right: usize) -> usize {
798    while right != 0 {
799        let remainder = left % right;
800        left = right;
801        right = remainder;
802    }
803    left
804}
805
806fn next_smooth_multiplier(minimum: usize) -> Option<usize> {
807    fn visit(value: usize, prime_index: usize, minimum: usize, best: &mut Option<usize>) {
808        if value >= minimum {
809            if best.is_none_or(|current| value < current) {
810                *best = Some(value);
811            }
812            return;
813        }
814        const PRIMES: [usize; 4] = [2, 3, 5, 7];
815        for (index, &prime) in PRIMES.iter().enumerate().skip(prime_index) {
816            let Some(next) = value.checked_mul(prime) else {
817                continue;
818            };
819            if best.is_none_or(|current| next < current) {
820                visit(next, index, minimum, best);
821            }
822        }
823    }
824
825    let mut best = None;
826    visit(1, 0, minimum, &mut best);
827    best
828}