Skip to main content

fpm_rs/simulation/
synthetic_object.rs

1use ndarray::{Array2, ArrayView2, ArrayViewMut2};
2use num_complex::Complex64;
3use rand::{Rng, SeedableRng, rngs::StdRng};
4use rand_distr::{Distribution, Normal};
5use std::path::Path;
6
7use crate::{
8    Result,
9    array_layout::{StandardArray2, checked_len_2d},
10    complex,
11    error::Error,
12    image_io::{GrayscaleScaling, load_grayscale},
13};
14
15/// Owned high-resolution complex sample field used as simulation ground truth.
16///
17/// The array is shaped `(height, width)`, indexed `(row, column)`, and stored in
18/// standard row-major order. Magnitude is amplitude transmission and argument is phase
19/// delay in radians.
20///
21/// # Example
22///
23/// ```
24/// use fpm_rs::simulation::SyntheticObject;
25///
26/// # fn main() -> fpm_rs::Result<()> {
27/// let object = SyntheticObject::phase_disk((32, 48), 6.0, 0.4)?
28///     .with_label("phase target");
29/// assert_eq!(object.shape(), (32, 48));
30/// assert_eq!(object.label(), Some("phase target"));
31/// assert_eq!(object.field().dim(), object.shape());
32/// # Ok(())
33/// # }
34/// ```
35#[derive(Clone, Debug)]
36pub struct SyntheticObject {
37    pub(crate) field: StandardArray2<Complex64>,
38    label: Option<String>,
39}
40
41impl SyntheticObject {
42    /// Stores a finite complex field without copying its elements.
43    ///
44    /// The field must be non-empty and C-contiguous standard row-major layout.
45    pub fn new(field: Array2<Complex64>) -> Result<Self> {
46        let field = StandardArray2::try_from(field)?;
47        validate_shape(field.dim())?;
48        if field
49            .as_slice()
50            .iter()
51            .any(|value| !value.re.is_finite() || !value.im.is_finite())
52        {
53            return Err(Error::InvalidParameter {
54                name: "field",
55                reason: "values must have finite real and imaginary components".into(),
56            });
57        }
58        Ok(Self { field, label: None })
59    }
60
61    /// Allocates a complex field from matching borrowed amplitude and phase arrays.
62    ///
63    /// Amplitude must be finite and non-negative; phase is in radians and must be finite.
64    pub fn from_amplitude_phase(
65        amplitude: ArrayView2<'_, f64>,
66        phase: ArrayView2<'_, f64>,
67    ) -> Result<Self> {
68        Self::new(complex::from_amplitude_phase(amplitude, phase)?)
69    }
70
71    /// Creates a zero-phase object from owned finite non-negative amplitude transmission.
72    pub fn amplitude_only(amplitude: Array2<f64>) -> Result<Self> {
73        let amplitude = StandardArray2::try_from(amplitude)?;
74        if amplitude
75            .as_slice()
76            .iter()
77            .any(|value| !value.is_finite() || *value < 0.0)
78        {
79            return Err(Error::InvalidParameter {
80                name: "amplitude",
81                reason: "values must be finite and non-negative".into(),
82            });
83        }
84        Self::from_values(
85            amplitude.dim(),
86            amplitude
87                .as_slice()
88                .iter()
89                .map(|&value| Complex64::new(value, 0.0))
90                .collect(),
91        )
92    }
93
94    /// Creates a unit-amplitude object from owned finite phase values in radians.
95    pub fn phase_only(phase: Array2<f64>) -> Result<Self> {
96        let phase = StandardArray2::try_from(phase)?;
97        if phase.as_slice().iter().any(|value| !value.is_finite()) {
98            return Err(Error::InvalidParameter {
99                name: "phase",
100                reason: "values must be finite".into(),
101            });
102        }
103        Self::from_values(
104            phase.dim(),
105            phase
106                .as_slice()
107                .iter()
108                .map(|&value| Complex64::from_polar(1.0, value))
109                .collect(),
110        )
111    }
112
113    /// Loads a grayscale image normalized to `[0, 1]` as amplitude transmission.
114    pub fn from_amplitude_image(path: impl AsRef<Path>) -> Result<Self> {
115        let amplitude = load_grayscale(path, GrayscaleScaling::Unit)?;
116        Self::amplitude_only(amplitude)
117    }
118
119    /// Loads normalized grayscale amplitude and phase images. Phase values map
120    /// linearly from black/white to `-phase_extent`/`+phase_extent` radians.
121    pub fn from_amplitude_phase_images(
122        amplitude_path: impl AsRef<Path>,
123        phase_path: impl AsRef<Path>,
124        phase_extent: f64,
125    ) -> Result<Self> {
126        if !phase_extent.is_finite() || phase_extent <= 0.0 {
127            return Err(Error::InvalidParameter {
128                name: "phase_extent",
129                reason: "must be finite and positive".into(),
130            });
131        }
132        let amplitude = load_grayscale(amplitude_path, GrayscaleScaling::Unit)?;
133        let normalized_phase = load_grayscale(phase_path, GrayscaleScaling::Unit)?;
134        if amplitude.dim() != normalized_phase.dim() {
135            return Err(Error::InvalidShape(format!(
136                "amplitude image shape {:?} differs from phase image shape {:?}",
137                amplitude.dim(),
138                normalized_phase.dim()
139            )));
140        }
141        let phase = normalized_phase.mapv(|value| (2.0 * value - 1.0) * phase_extent);
142        Self::from_amplitude_phase(amplitude.view(), phase.view())
143    }
144
145    /// Creates a constant `(height, width)` field from non-negative amplitude and radian phase.
146    pub fn constant(shape: (usize, usize), amplitude: f64, phase: f64) -> Result<Self> {
147        validate_shape(shape)?;
148        validate_amplitude(amplitude)?;
149        let length = checked_len_2d(shape)?;
150        Self::from_values(shape, vec![Complex64::from_polar(amplitude, phase); length])
151    }
152
153    /// Creates a unit-amplitude centered disk with phase `phase_shift` radians.
154    ///
155    /// Radius is measured from pixel centers in pixels; pixels outside the disk have zero phase.
156    pub fn phase_disk(shape: (usize, usize), radius_pixels: f64, phase_shift: f64) -> Result<Self> {
157        validate_shape(shape)?;
158        if !radius_pixels.is_finite() || radius_pixels <= 0.0 || !phase_shift.is_finite() {
159            return Err(Error::InvalidParameter {
160                name: "phase disk",
161                reason: "radius must be positive and phase must be finite".into(),
162            });
163        }
164        let center = ((shape.0 - 1) as f64 / 2.0, (shape.1 - 1) as f64 / 2.0);
165        let mut values = Vec::with_capacity(checked_len_2d(shape)?);
166        for row in 0..shape.0 {
167            for column in 0..shape.1 {
168                let radius = (row as f64 - center.0).hypot(column as f64 - center.1);
169                values.push(Complex64::from_polar(
170                    1.0,
171                    if radius <= radius_pixels {
172                        phase_shift
173                    } else {
174                        0.0
175                    },
176                ));
177            }
178        }
179        Self::from_values(shape, values)
180    }
181
182    /// Creates a centered binary-amplitude Siemens-star test target with at least two spokes.
183    pub fn siemens_star(shape: (usize, usize), spokes: usize) -> Result<Self> {
184        validate_shape(shape)?;
185        if spokes < 2 {
186            return Err(Error::InvalidParameter {
187                name: "spokes",
188                reason: "must be at least 2".into(),
189            });
190        }
191        let center = ((shape.0 - 1) as f64 / 2.0, (shape.1 - 1) as f64 / 2.0);
192        let maximum_radius = shape.0.min(shape.1) as f64 * 0.46;
193        let mut values = Vec::with_capacity(checked_len_2d(shape)?);
194        for row in 0..shape.0 {
195            for column in 0..shape.1 {
196                let y = row as f64 - center.0;
197                let x = column as f64 - center.1;
198                let radius = x.hypot(y);
199                let amplitude =
200                    if radius <= maximum_radius && ((x.atan2(y) * spokes as f64).sin() >= 0.0) {
201                        0.25
202                    } else {
203                        1.0
204                    };
205                values.push(Complex64::new(amplitude, 0.0));
206            }
207        }
208        Self::from_values(shape, values)
209    }
210
211    /// Creates deterministic horizontal and vertical binary-amplitude bar groups.
212    pub fn resolution_target(shape: (usize, usize)) -> Result<Self> {
213        validate_shape(shape)?;
214        let mut values = vec![Complex64::new(1.0, 0.0); checked_len_2d(shape)?];
215        let groups = [2_usize, 3, 4, 6, 8];
216        for (group, &period) in groups.iter().enumerate() {
217            let top = group * shape.0 / groups.len();
218            let bottom = (group + 1) * shape.0 / groups.len();
219            for row in top..bottom {
220                for column in 0..shape.1 {
221                    let dark = if group.is_multiple_of(2) {
222                        (column / period).is_multiple_of(2)
223                    } else {
224                        ((row - top) / period).is_multiple_of(2)
225                    };
226                    if dark {
227                        values[row * shape.1 + column] = Complex64::new(0.2, 0.0);
228                    }
229                }
230            }
231        }
232        Self::from_values(shape, values)
233    }
234
235    /// Creates unit amplitude with independent zero-mean Gaussian phase in radians.
236    pub fn random_phase(shape: (usize, usize), standard_deviation: f64, seed: u64) -> Result<Self> {
237        validate_shape(shape)?;
238        if !standard_deviation.is_finite() || standard_deviation < 0.0 {
239            return Err(Error::InvalidParameter {
240                name: "standard_deviation",
241                reason: "must be finite and non-negative".into(),
242            });
243        }
244        let mut rng = StdRng::seed_from_u64(seed);
245        let length = checked_len_2d(shape)?;
246        let values = if standard_deviation == 0.0 {
247            vec![Complex64::new(1.0, 0.0); length]
248        } else {
249            let distribution =
250                Normal::new(0.0, standard_deviation).map_err(|error| Error::InvalidParameter {
251                    name: "standard_deviation",
252                    reason: error.to_string(),
253                })?;
254            (0..length)
255                .map(|_| Complex64::from_polar(1.0, distribution.sample(&mut rng)))
256                .collect()
257        };
258        Self::from_values(shape, values)
259    }
260
261    /// Creates a unit-amplitude field with seeded dark single-pixel particles.
262    pub fn particle_field(shape: (usize, usize), particles: usize, seed: u64) -> Result<Self> {
263        validate_shape(shape)?;
264        let mut values = vec![Complex64::new(1.0, 0.0); checked_len_2d(shape)?];
265        let mut rng = StdRng::seed_from_u64(seed);
266        for _ in 0..particles {
267            let row = rng.random_range(0..shape.0);
268            let column = rng.random_range(0..shape.1);
269            values[row * shape.1 + column] = Complex64::new(0.1, 0.0);
270        }
271        Self::from_values(shape, values)
272    }
273
274    /// A deterministic mixed amplitude/phase target useful for smoke tests.
275    pub fn mixed_test_pattern(shape: (usize, usize)) -> Result<Self> {
276        validate_shape(shape)?;
277        let center = ((shape.0 - 1) as f64 / 2.0, (shape.1 - 1) as f64 / 2.0);
278        let scale = shape.0.min(shape.1) as f64;
279        let mut values = Vec::with_capacity(checked_len_2d(shape)?);
280        for row in 0..shape.0 {
281            for column in 0..shape.1 {
282                let y = row as f64 - center.0;
283                let x = column as f64 - center.1;
284                let radius = x.hypot(y);
285                let amplitude = if (x.abs() < 0.12 * scale && y.abs() < 0.35 * scale)
286                    || radius < 0.12 * scale
287                {
288                    0.45
289                } else {
290                    1.0
291                };
292                let phase = 0.8 * (-radius * radius / (0.08 * scale * scale)).exp()
293                    + 0.25 * (std::f64::consts::TAU * x / scale).sin();
294                values.push(Complex64::from_polar(amplitude, phase));
295            }
296        }
297        Self::from_values(shape, values)
298    }
299
300    /// Smooth random phase and absorption blobs approximating a weak biological
301    /// specimen. This is a test object, not a tissue-specific physical model.
302    pub fn biological_like(shape: (usize, usize), features: usize, seed: u64) -> Result<Self> {
303        validate_shape(shape)?;
304        if features == 0 {
305            return Err(Error::InvalidParameter {
306                name: "features",
307                reason: "must be greater than zero".into(),
308            });
309        }
310        let mut rng = StdRng::seed_from_u64(seed);
311        let minimum_size = shape.0.min(shape.1) as f64;
312        let lower_sigma = (0.02 * minimum_size).max(1.0);
313        let upper_sigma = (0.12 * minimum_size).max(lower_sigma + 0.1);
314        let blobs: Vec<_> = (0..features)
315            .map(|_| {
316                (
317                    rng.random_range(0.0..shape.0 as f64),
318                    rng.random_range(0.0..shape.1 as f64),
319                    rng.random_range(lower_sigma..upper_sigma),
320                    rng.random_range(-0.4..1.0),
321                    rng.random_range(0.0..0.25),
322                )
323            })
324            .collect();
325        let mut values = Vec::with_capacity(checked_len_2d(shape)?);
326        for row in 0..shape.0 {
327            for column in 0..shape.1 {
328                let mut phase = 0.0;
329                let mut absorption = 0.0;
330                for &(center_row, center_column, sigma, phase_strength, absorption_strength) in
331                    &blobs
332                {
333                    let squared_radius =
334                        (row as f64 - center_row).powi(2) + (column as f64 - center_column).powi(2);
335                    let profile = (-squared_radius / (2.0 * sigma * sigma)).exp();
336                    phase += phase_strength * profile;
337                    absorption += absorption_strength * profile;
338                }
339                values.push(Complex64::from_polar(
340                    (1.0_f64 - absorption).max(0.1),
341                    phase,
342                ));
343            }
344        }
345        Self::from_values(shape, values)
346    }
347
348    /// Returns the high-resolution object shape as `(height, width)`.
349    pub fn shape(&self) -> (usize, usize) {
350        self.field.dim()
351    }
352
353    /// Borrows the `(row, column)` complex field without copying.
354    pub fn field(&self) -> ArrayView2<'_, Complex64> {
355        self.field.ndarray_view()
356    }
357
358    /// Mutably borrows field elements without permitting shape changes.
359    pub fn field_mut(&mut self) -> ArrayViewMut2<'_, Complex64> {
360        self.field.ndarray_view_mut()
361    }
362
363    /// Borrows the optional human-readable object label.
364    pub fn label(&self) -> Option<&str> {
365        self.label.as_deref()
366    }
367
368    /// Assigns a human-readable label without changing field values.
369    pub fn with_label(mut self, label: impl Into<String>) -> Self {
370        self.label = Some(label.into());
371        self
372    }
373
374    fn from_values(shape: (usize, usize), values: Vec<Complex64>) -> Result<Self> {
375        Ok(Self {
376            field: StandardArray2::from_shape_vec(shape, values)?,
377            label: None,
378        })
379    }
380}
381
382impl TryFrom<Array2<Complex64>> for SyntheticObject {
383    type Error = Error;
384
385    fn try_from(field: Array2<Complex64>) -> Result<Self> {
386        Self::new(field)
387    }
388}
389
390fn validate_amplitude(amplitude: f64) -> Result<()> {
391    if !amplitude.is_finite() || amplitude < 0.0 {
392        Err(Error::InvalidParameter {
393            name: "amplitude",
394            reason: "must be finite and non-negative".into(),
395        })
396    } else {
397        Ok(())
398    }
399}
400
401fn validate_shape(shape: (usize, usize)) -> Result<()> {
402    if shape.0 == 0 || shape.1 == 0 {
403        Err(Error::InvalidShape(format!(
404            "synthetic object dimensions must be non-zero, got {shape:?}"
405        )))
406    } else {
407        Ok(())
408    }
409}