Skip to main content

fpm_rs/reconstruction/
result.rs

1use std::{collections::BTreeMap, fs::File, io::BufWriter, path::Path};
2
3use image::{GrayImage, Luma};
4use ndarray::{Array2, ArrayView2};
5use num_complex::Complex64;
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    Result,
10    array_layout::StandardArray2,
11    array_serde::Array2Data,
12    backend::FftDirection,
13    complex,
14    error::Error,
15    illumination_calibration::IlluminationCalibrationState,
16    model::{ImagePlaneModel, Pupil, ifftshift_copy},
17};
18
19use super::{ReconstructionState, ReconstructionTrace};
20
21/// Execution summary attached to a completed reconstruction.
22#[derive(Clone, Debug, Default, Serialize, Deserialize)]
23pub struct RuntimeInfo {
24    /// Wall-clock seconds spent in this run, including elapsed time restored from a checkpoint.
25    pub elapsed_seconds: f64,
26    /// Number of complete reconstruction iterations represented by the result.
27    pub completed_iterations: usize,
28    /// Whether a callback requested termination before the configured iteration limit.
29    pub stopped_early: bool,
30    /// Stable algorithm type name used for the run.
31    pub algorithm: String,
32}
33
34/// Owned reconstructed fields, calibration values, trace, and runtime metadata.
35///
36/// Object-domain arrays and the centered object spectrum all have high-resolution
37/// `(height, width)` shape and standard row-major storage. `object` is complex field,
38/// `amplitude` is its magnitude, and `phase` is wrapped in radians in `[-π, π]`.
39#[derive(Clone, Debug)]
40pub struct ReconstructionResult {
41    /// Reconstructed high-resolution complex sample transmission field.
42    pub object: Array2<Complex64>,
43    /// Non-negative magnitude of [`Self::object`].
44    pub amplitude: Array2<f64>,
45    /// Wrapped argument of [`Self::object`], in radians in `[-π, π]`.
46    pub phase: Array2<f64>,
47    /// Centered Fourier spectrum corresponding to [`Self::object`].
48    pub object_spectrum: Array2<Complex64>,
49    /// Recovered low-resolution complex pupil and binary aperture support.
50    ///
51    /// Built-in blind-pupil algorithms report this field in the compiled
52    /// pupil's canonical scale and phase gauge. The corresponding object fields
53    /// carry the reciprocal correction.
54    pub recovered_pupil: Pupil,
55    /// Per-source `(row, column)` corrections in Fourier-grid pixels.
56    pub calibrated_illumination: Option<Vec<(f64, f64)>>,
57    /// Optional positive multiplicative gains in acquisition-frame order.
58    pub recovered_frame_gains: Option<Vec<f64>>,
59    /// Optional additive intensity background in acquisition-frame order.
60    pub recovered_background: Option<Vec<f64>>,
61    /// Complete physical planar-array calibration state for joint runs.
62    pub physical_illumination_calibration: Option<IlluminationCalibrationState>,
63    /// Reusable model compiled from the final physical illumination.
64    pub calibrated_model: Option<ImagePlaneModel>,
65    /// Universal and algorithm-specific iteration history.
66    pub trace: ReconstructionTrace,
67    /// Final named scalar diagnostics not represented by the trace.
68    pub scalar_diagnostics: BTreeMap<String, f64>,
69    /// Timing, iteration count, early-stop status, and algorithm name.
70    pub runtime: RuntimeInfo,
71    /// User- and runner-supplied string metadata.
72    pub metadata: BTreeMap<String, String>,
73}
74
75impl ReconstructionResult {
76    pub(crate) fn from_state(
77        state: &mut ReconstructionState,
78        trace: ReconstructionTrace,
79        runtime: RuntimeInfo,
80    ) -> Result<Self> {
81        let object = state_object(state)?;
82        let amplitude = complex::amplitude(object.view());
83        let phase = complex::phase(object.view());
84        let mut scalar_diagnostics = BTreeMap::new();
85        if let Some(objective) = trace.final_objective() {
86            scalar_diagnostics.insert("final_objective".into(), objective);
87        }
88        Ok(Self {
89            object,
90            amplitude,
91            phase,
92            object_spectrum: state.object_spectrum.clone().into_inner(),
93            recovered_pupil: state.pupil.clone(),
94            calibrated_illumination: state.illumination_corrections.clone(),
95            recovered_frame_gains: state.frame_gains.clone(),
96            recovered_background: state.background.clone(),
97            physical_illumination_calibration: state.physical_illumination_calibration.clone(),
98            calibrated_model: state.calibrated_model.clone(),
99            trace,
100            scalar_diagnostics,
101            runtime,
102            metadata: BTreeMap::new(),
103        })
104    }
105
106    /// Writes object amplitude as a linearly normalized 8-bit grayscale image.
107    pub fn save_amplitude(&self, path: impl AsRef<Path>) -> Result<()> {
108        save_grayscale(self.amplitude.view(), path, false)
109    }
110
111    /// Writes wrapped object phase as an 8-bit grayscale image mapping `[-π, π]` to `[0, 255]`.
112    pub fn save_phase(&self, path: impl AsRef<Path>) -> Result<()> {
113        save_grayscale(self.phase.view(), path, true)
114    }
115
116    /// Serializes the complex object and its `(height, width)` shape as JSON.
117    pub fn save_complex_object(&self, path: impl AsRef<Path>) -> Result<()> {
118        let writer = BufWriter::new(File::create(path)?);
119        serde_json::to_writer(writer, &Array2Data::from_view(self.object.view()))?;
120        Ok(())
121    }
122
123    /// Serializes recovered complex pupil values and binary support as JSON.
124    pub fn save_pupil(&self, path: impl AsRef<Path>) -> Result<()> {
125        let writer = BufWriter::new(File::create(path)?);
126        serde_json::to_writer(writer, &self.recovered_pupil)?;
127        Ok(())
128    }
129
130    /// Writes one CSV row per iteration with objective and elapsed seconds.
131    pub fn save_trace_csv(&self, path: impl AsRef<Path>) -> Result<()> {
132        let mut writer = csv::Writer::from_path(path)?;
133        writer.write_record(["iteration", "objective", "elapsed_seconds"])?;
134        for record in &self.trace.iterations {
135            writer.serialize((record.iteration, record.objective, record.elapsed_seconds))?;
136        }
137        writer.flush()?;
138        Ok(())
139    }
140
141    #[cfg(feature = "parquet")]
142    /// Writes a self-describing Parquet/NPY result bundle and reopens it lazily.
143    ///
144    /// The returned [`crate::reconstruction::ResultBundle`] reads its manifest
145    /// immediately but does not load scientific arrays until an accessor is called.
146    /// Repeated access returns the same cached [`std::sync::Arc`].
147    ///
148    /// # Example
149    ///
150    /// ```no_run
151    /// use fpm_rs::{
152    ///     reconstruction::{BundleExportOptions, ReconstructionResult, read_bundle},
153    ///     Result,
154    /// };
155    /// use std::sync::Arc;
156    ///
157    /// # fn completed_reconstruction() -> Result<ReconstructionResult> { unimplemented!() }
158    /// # fn main() -> Result<()> {
159    /// let result = completed_reconstruction()?;
160    /// result.write_bundle("result-bundle", BundleExportOptions::default())?;
161    ///
162    /// let bundle = read_bundle("result-bundle")?;
163    /// let first = bundle.object()?;
164    /// let second = bundle.object()?;
165    /// assert!(Arc::ptr_eq(&first, &second));
166    /// # Ok(())
167    /// # }
168    /// ```
169    pub fn write_bundle(
170        &self,
171        path: impl AsRef<Path>,
172        options: crate::reconstruction::BundleExportOptions,
173    ) -> Result<crate::reconstruction::ResultBundle> {
174        crate::tabular::parquet::write_result_bundle(self, path.as_ref(), options, None, None)
175    }
176
177    /// Writes a bundle including optional callback diagnostics and reference
178    /// evaluation records.
179    #[cfg(feature = "parquet")]
180    pub fn write_bundle_with_context(
181        &self,
182        path: impl AsRef<Path>,
183        options: crate::reconstruction::BundleExportOptions,
184        diagnostics: Option<&crate::diagnostics::ReconstructionDiagnostics>,
185        evaluation: Option<&crate::evaluation::ReconstructionEvaluation>,
186    ) -> Result<crate::reconstruction::ResultBundle> {
187        crate::tabular::parquet::write_result_bundle(
188            self,
189            path.as_ref(),
190            options,
191            diagnostics,
192            evaluation,
193        )
194    }
195
196    /// Checks matching non-empty standard-layout arrays, pupil shape, finite values,
197    /// calibration lengths and ranges, and consistency with optional frame-count metadata.
198    pub fn validate(&self) -> Result<()> {
199        let shape = self.object.dim();
200        if shape.0 == 0 || shape.1 == 0 {
201            return Err(Error::InvalidShape(
202                "result reconstruction arrays must be non-empty".into(),
203            ));
204        }
205        for (context, array_shape, strides, is_standard) in [
206            (
207                "reconstruction result object",
208                self.object.shape(),
209                self.object.strides(),
210                self.object.is_standard_layout(),
211            ),
212            (
213                "reconstruction result amplitude",
214                self.amplitude.shape(),
215                self.amplitude.strides(),
216                self.amplitude.is_standard_layout(),
217            ),
218            (
219                "reconstruction result phase",
220                self.phase.shape(),
221                self.phase.strides(),
222                self.phase.is_standard_layout(),
223            ),
224            (
225                "reconstruction result object spectrum",
226                self.object_spectrum.shape(),
227                self.object_spectrum.strides(),
228                self.object_spectrum.is_standard_layout(),
229            ),
230        ] {
231            if !is_standard {
232                return Err(Error::NonStandardLayout {
233                    context,
234                    shape: array_shape.to_vec(),
235                    strides: strides.to_vec(),
236                });
237            }
238        }
239        if self.amplitude.dim() != shape
240            || self.phase.dim() != shape
241            || self.object_spectrum.dim() != shape
242        {
243            return Err(Error::InvalidShape(
244                "result object, amplitude, phase, and spectrum shapes must match".into(),
245            ));
246        }
247        if self.recovered_pupil.support.len() != self.recovered_pupil.values.len() {
248            return Err(Error::InvalidShape(
249                "result pupil support and values have different lengths".into(),
250            ));
251        }
252        if self
253            .calibrated_illumination
254            .as_ref()
255            .is_some_and(Vec::is_empty)
256            || self
257                .recovered_frame_gains
258                .as_ref()
259                .is_some_and(Vec::is_empty)
260            || self
261                .recovered_background
262                .as_ref()
263                .is_some_and(Vec::is_empty)
264        {
265            return Err(Error::InvalidShape(
266                "present result calibration arrays must be non-empty".into(),
267            ));
268        }
269        if let (Some(gains), Some(background)) =
270            (&self.recovered_frame_gains, &self.recovered_background)
271            && gains.len() != background.len()
272        {
273            return Err(Error::InvalidShape(
274                "result frame gains and background lengths must match".into(),
275            ));
276        }
277        if let Some(frame_count) = self
278            .metadata
279            .get("frame_count")
280            .and_then(|value| value.parse::<usize>().ok())
281            && self
282                .recovered_frame_gains
283                .as_ref()
284                .into_iter()
285                .chain(self.recovered_background.as_ref())
286                .any(|values| values.len() != frame_count)
287        {
288            return Err(Error::InvalidShape(
289                "result frame calibration length must match metadata frame_count".into(),
290            ));
291        }
292        if self
293            .object
294            .iter()
295            .chain(self.object_spectrum.iter())
296            .chain(self.recovered_pupil.values.as_slice())
297            .any(|value| !value.re.is_finite() || !value.im.is_finite())
298            || self
299                .amplitude
300                .iter()
301                .chain(self.phase.iter())
302                .any(|value| !value.is_finite())
303        {
304            return Err(Error::InvalidModel(
305                "result arrays contain non-finite values".into(),
306            ));
307        }
308        if self.calibrated_illumination.as_ref().is_some_and(|values| {
309            values
310                .iter()
311                .any(|&(row, column)| !row.is_finite() || !column.is_finite())
312        }) || self.recovered_frame_gains.as_ref().is_some_and(|values| {
313            values
314                .iter()
315                .any(|value| !value.is_finite() || *value <= 0.0)
316        }) || self
317            .recovered_background
318            .as_ref()
319            .is_some_and(|values| values.iter().any(|value| !value.is_finite()))
320        {
321            return Err(Error::InvalidModel(
322                "result calibration values are invalid".into(),
323            ));
324        }
325        if self.physical_illumination_calibration.is_some() != self.calibrated_model.is_some() {
326            return Err(Error::InvalidModel(
327                "result physical calibration and calibrated model must be present together".into(),
328            ));
329        }
330        if let Some(model) = &self.calibrated_model {
331            model.validate()?;
332            if model.reconstruction_shape() != shape
333                || model.pupil().shape() != self.recovered_pupil.shape()
334            {
335                return Err(Error::InvalidModel(
336                    "result calibrated model shapes do not match reconstructed fields".into(),
337                ));
338            }
339        }
340        if let Some(calibration) = &self.physical_illumination_calibration {
341            calibration.validate()?;
342        }
343        if self
344            .scalar_diagnostics
345            .values()
346            .any(|value| !value.is_finite())
347        {
348            return Err(Error::InvalidModel(
349                "result diagnostics contain non-finite values".into(),
350            ));
351        }
352        if !self.runtime.elapsed_seconds.is_finite()
353            || self.runtime.elapsed_seconds < 0.0
354            || self.runtime.algorithm.is_empty()
355            || self.runtime.completed_iterations != self.trace.iterations.len()
356            || self
357                .trace
358                .iterations
359                .iter()
360                .enumerate()
361                .any(|(index, record)| {
362                    record.iteration != index + 1
363                        || !record.objective.is_finite()
364                        || !record.elapsed_seconds.is_finite()
365                        || record.elapsed_seconds < 0.0
366                })
367            || self
368                .trace
369                .iterations
370                .windows(2)
371                .any(|pair| pair[1].elapsed_seconds < pair[0].elapsed_seconds)
372            || self
373                .trace
374                .iterations
375                .last()
376                .is_some_and(|record| record.elapsed_seconds > self.runtime.elapsed_seconds)
377        {
378            return Err(Error::InvalidModel(
379                "result runtime and trace are inconsistent".into(),
380            ));
381        }
382        if self.trace.algorithm_metrics.iter().any(|record| {
383            record.iteration == 0
384                || record.iteration > self.runtime.completed_iterations
385                || record.namespace.is_empty()
386                || record.metric.is_empty()
387                || !record.value.is_finite()
388        }) {
389            return Err(Error::InvalidModel(
390                "result algorithm metrics are invalid".into(),
391            ));
392        }
393        Ok(())
394    }
395}
396
397pub(crate) fn state_object(state: &mut ReconstructionState) -> Result<Array2<Complex64>> {
398    if let Some(cached) = &state.object_real_space_cache {
399        return Ok(cached.clone().into_inner());
400    }
401    let shape = state.object_spectrum.dim();
402    let mut unshifted = vec![Complex64::default(); state.object_spectrum.len()];
403    ifftshift_copy(state.object_spectrum.as_slice(), &mut unshifted, shape);
404    state.backend.fft2(
405        &mut unshifted,
406        shape,
407        FftDirection::Inverse,
408        &mut state.scratch.column,
409    )?;
410    let object = StandardArray2::from_shape_vec(shape, unshifted)?;
411    state.object_real_space_cache = Some(object.clone());
412    Ok(object.into_inner())
413}
414
415pub(crate) fn save_grayscale(
416    values: ArrayView2<'_, f64>,
417    path: impl AsRef<Path>,
418    phase: bool,
419) -> Result<()> {
420    let range = if phase {
421        (-std::f64::consts::PI, std::f64::consts::PI)
422    } else {
423        let minimum = values.iter().copied().fold(f64::INFINITY, f64::min);
424        let maximum = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
425        (minimum, maximum)
426    };
427    save_grayscale_with_range(values, path, range)
428}
429
430pub(crate) fn save_signed_grayscale(
431    values: ArrayView2<'_, f64>,
432    path: impl AsRef<Path>,
433) -> Result<()> {
434    let maximum_absolute = values
435        .iter()
436        .map(|value| value.abs())
437        .fold(0.0, f64::max)
438        .max(f64::EPSILON);
439    save_grayscale_with_range(values, path, (-maximum_absolute, maximum_absolute))
440}
441
442fn save_grayscale_with_range(
443    values: ArrayView2<'_, f64>,
444    path: impl AsRef<Path>,
445    (minimum, maximum): (f64, f64),
446) -> Result<()> {
447    let width = u32::try_from(values.ncols()).map_err(|_| {
448        Error::InvalidShape("image width does not fit the PNG dimension type".into())
449    })?;
450    let height = u32::try_from(values.nrows()).map_err(|_| {
451        Error::InvalidShape("image height does not fit the PNG dimension type".into())
452    })?;
453    let range = (maximum - minimum).max(f64::EPSILON);
454    let mut image = GrayImage::new(width, height);
455    for row in 0..values.nrows() {
456        for column in 0..values.ncols() {
457            let normalized = ((values[(row, column)] - minimum) / range).clamp(0.0, 1.0);
458            image.put_pixel(
459                column as u32,
460                row as u32,
461                Luma([(normalized * 255.0).round() as u8]),
462            );
463        }
464    }
465    image.save(path)?;
466    Ok(())
467}
468
469impl Serialize for ReconstructionResult {
470    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
471    where
472        S: serde::Serializer,
473    {
474        #[derive(Serialize)]
475        struct Representation<'a> {
476            object: Array2Data<Complex64>,
477            amplitude: Array2Data<f64>,
478            phase: Array2Data<f64>,
479            object_spectrum: Array2Data<Complex64>,
480            recovered_pupil: &'a Pupil,
481            calibrated_illumination: &'a Option<Vec<(f64, f64)>>,
482            recovered_frame_gains: &'a Option<Vec<f64>>,
483            recovered_background: &'a Option<Vec<f64>>,
484            physical_illumination_calibration: &'a Option<IlluminationCalibrationState>,
485            calibrated_model: &'a Option<ImagePlaneModel>,
486            trace: &'a ReconstructionTrace,
487            scalar_diagnostics: &'a BTreeMap<String, f64>,
488            runtime: &'a RuntimeInfo,
489            metadata: &'a BTreeMap<String, String>,
490        }
491
492        Representation {
493            object: Array2Data::from_view(self.object.view()),
494            amplitude: Array2Data::from_view(self.amplitude.view()),
495            phase: Array2Data::from_view(self.phase.view()),
496            object_spectrum: Array2Data::from_view(self.object_spectrum.view()),
497            recovered_pupil: &self.recovered_pupil,
498            calibrated_illumination: &self.calibrated_illumination,
499            recovered_frame_gains: &self.recovered_frame_gains,
500            recovered_background: &self.recovered_background,
501            physical_illumination_calibration: &self.physical_illumination_calibration,
502            calibrated_model: &self.calibrated_model,
503            trace: &self.trace,
504            scalar_diagnostics: &self.scalar_diagnostics,
505            runtime: &self.runtime,
506            metadata: &self.metadata,
507        }
508        .serialize(serializer)
509    }
510}
511
512impl<'de> Deserialize<'de> for ReconstructionResult {
513    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
514    where
515        D: serde::Deserializer<'de>,
516    {
517        use serde::de::Error as _;
518
519        #[derive(Deserialize)]
520        #[serde(deny_unknown_fields)]
521        struct Representation {
522            object: Array2Data<Complex64>,
523            amplitude: Array2Data<f64>,
524            phase: Array2Data<f64>,
525            object_spectrum: Array2Data<Complex64>,
526            recovered_pupil: Pupil,
527            calibrated_illumination: Option<Vec<(f64, f64)>>,
528            recovered_frame_gains: Option<Vec<f64>>,
529            recovered_background: Option<Vec<f64>>,
530            physical_illumination_calibration: Option<IlluminationCalibrationState>,
531            calibrated_model: Option<ImagePlaneModel>,
532            trace: ReconstructionTrace,
533            scalar_diagnostics: BTreeMap<String, f64>,
534            runtime: RuntimeInfo,
535            metadata: BTreeMap<String, String>,
536        }
537
538        let representation = Representation::deserialize(deserializer)?;
539        let result = Self {
540            object: representation
541                .object
542                .into_array()
543                .map_err(D::Error::custom)?,
544            amplitude: representation
545                .amplitude
546                .into_array()
547                .map_err(D::Error::custom)?,
548            phase: representation
549                .phase
550                .into_array()
551                .map_err(D::Error::custom)?,
552            object_spectrum: representation
553                .object_spectrum
554                .into_array()
555                .map_err(D::Error::custom)?,
556            recovered_pupil: representation.recovered_pupil,
557            calibrated_illumination: representation.calibrated_illumination,
558            recovered_frame_gains: representation.recovered_frame_gains,
559            recovered_background: representation.recovered_background,
560            physical_illumination_calibration: representation.physical_illumination_calibration,
561            calibrated_model: representation.calibrated_model,
562            trace: representation.trace,
563            scalar_diagnostics: representation.scalar_diagnostics,
564            runtime: representation.runtime,
565            metadata: representation.metadata,
566        };
567        result.validate().map_err(D::Error::custom)?;
568        Ok(result)
569    }
570}