xisf_rs/
image.rs

1//! Anything related to [`Image`]
2
3use std::{
4    any::TypeId,
5    io::Read,
6    fmt,
7    str::FromStr, ops::Deref, collections::HashMap,
8};
9
10use byteorder::{ReadBytesExt, LE, BE};
11use error_stack::{Report, report, Result, ResultExt};
12use libxml::{readonly::RoNode, xpath::Context as XpathContext};
13use ndarray::{ArrayD, IxDyn};
14use num_complex::Complex;
15use ordered_multimap::ListOrderedMultimap;
16use parse_int::parse as parse_auto_radix;
17use strum::{Display, EnumString, EnumVariantNames, VariantNames};
18use uuid::Uuid;
19use crate::{
20    data_block::{ByteOrder, Context, DataBlock},
21    error::{ReadDataBlockError, ParseValueError, ReadFitsKeyError, ParseNodeErrorKind::*, ReadPropertyError},
22    is_valid_id,
23    MaybeReference,
24    ParseNodeError,
25    property::{Property, PropertyContent, FromProperty},
26    ReadOptions,
27};
28
29mod image_data;
30pub use image_data::*;
31
32mod fits_keyword;
33pub use fits_keyword::*;
34
35mod icc_profile;
36pub use icc_profile::*;
37
38mod rgb_working_space;
39pub use rgb_working_space::*;
40
41mod display_function;
42pub use display_function::*;
43
44mod color_filter_array;
45pub use color_filter_array::*;
46
47mod resolution;
48pub use resolution::*;
49
50mod thumbnail;
51pub use thumbnail::*;
52
53/// Shared components of [`Image`] and [`Thumbnail`] elements
54///
55/// Any public field or function of [`ImageBase`] is transparently accessible from
56/// [`Image`]s or [`Thumbnail`]s through the use of [`Deref`] coercion
57#[derive(Clone, Debug)]
58pub struct ImageBase {
59    data_block: DataBlock,
60    geometry: Vec<usize>,
61    sample_format: SampleFormat,
62
63    image_type: Option<ImageType>,
64    pixel_storage: PixelStorage,
65    color_space: ColorSpace,
66    offset: f64,
67    orientation: Option<Orientation>,
68    id: Option<String>,
69    uuid: Option<Uuid>,
70
71    properties: HashMap<String, PropertyContent>,
72    fits_header: ListOrderedMultimap<String, FitsKeyContent>,
73    icc_profile: Option<ICCProfile>,
74    rgb_working_space: Option<RGBWorkingSpace>,
75    display_function: Option<DisplayFunction>,
76    resolution: Option<Resolution>,
77}
78impl ImageBase {
79    /// The number of dimensions in this image
80    ///
81    /// The channel axis is not considered a dimension
82    #[inline]
83    pub fn num_dimensions(&self) -> usize {
84        self.geometry.len() - 1
85    }
86
87    /// A slice of dimension sizes, in row-major order
88    ///
89    /// For a 2D image, this means height is before width.
90    /// The channel axis is not considered a dimension.
91    #[inline]
92    pub fn dimensions(&self) -> &[usize] {
93        &self.geometry[1..]
94    }
95
96    /// The total number of channels in this image, both color and alpha
97    #[inline]
98    pub fn num_channels(&self) -> usize {
99        self.geometry[0]
100    }
101    /// Called nominal channels in the spec, this is the number of channels required to represent
102    /// all components of the image's color space (1 for grayscale, 3 for RGB or L\*a\*b\*)
103    #[inline]
104    pub fn num_color_channels(&self) -> usize {
105        self.color_space.num_channels()
106    }
107    /// Any channel after what's needed for the image's color space (1 for grayscale, 3 for RGB or L\*a\*b\*) is considered an alpha channel
108    #[inline]
109    pub fn num_alpha_channels(&self) -> usize {
110        self.num_channels() - self.color_space.num_channels()
111    }
112
113    /// Returns the sample format
114    #[inline]
115    pub fn sample_format(&self) -> SampleFormat {
116        self.sample_format
117    }
118
119    /// Returns the image type (bias, dark, flat, light, etc)
120    #[inline]
121    pub fn image_type(&self) -> Option<ImageType> {
122        self.image_type
123    }
124
125    /// Returns the pixel sample's layout in memory
126    #[inline]
127    pub fn pixel_layout(&self) -> PixelStorage {
128        self.pixel_storage
129    }
130
131    /// Returns the color space
132    #[inline]
133    pub fn color_space(&self) -> ColorSpace {
134        self.color_space
135    }
136
137    /// Returns the offset (AKA pedestal)
138    ///
139    /// An offset is a value added to all pixel samples, sometimes necessary to
140    /// ensure positive data from the sensor in the presence of noise
141    #[inline]
142    pub fn offset(&self) -> f64 {
143        self.offset
144    }
145
146    /// Returns a simple transformation (90-degree rotations and an a reflection) which should be applied before the image is displayed
147    #[inline]
148    pub fn orientation(&self) -> Option<Orientation> {
149        self.orientation
150    }
151
152    /// Returns the ID, if one exists
153    ///
154    /// An ID is a sequence of ASCII characters that may be used to identify the image to the end user,
155    /// and could be thought of as a name tag. Must satisfy the regex `[_a-zA-Z][_a-zA-Z0-9]*`.
156    // TODO: is ID validated while parsing?
157    #[inline]
158    pub fn id(&self) -> Option<&str> {
159        self.id.as_deref()
160    }
161
162    /// Returns the UUID, if one exists
163    #[inline]
164    pub fn uuid(&self) -> Option<Uuid> {
165        self.uuid
166    }
167
168    /// Reads data from the image's [data block](DataBlock) and packs it into an image type
169    // TODO: better handling for CIE L*a*b* images
170    pub fn read_data(&self, ctx: &Context) -> Result<DynImageData, ReadDataBlockError> {
171        self.data_block.verify_checksum(ctx)?;
172        let reader = &mut *self.data_block.decompressed_bytes(ctx)?;
173
174        macro_rules! read_real {
175            ($func:ident) => {
176                self.read_data_impl(reader,
177                    ReadBytesExt::$func::<LE>,
178                    ReadBytesExt::$func::<BE>
179                ).map(|buf| buf.into_dyn_img(self.pixel_storage))
180            }
181        }
182        macro_rules! read_complex {
183            ($func:ident, $t:ty) => {
184                {
185                    let mut buf;
186                    match self.pixel_storage {
187                        PixelStorage::Planar => buf = ArrayD::<Complex<$t>>::zeros(IxDyn(&self.geometry[..])),
188                        PixelStorage::Normal => {
189                            let mut geometry = self.geometry.clone();
190                            geometry.rotate_left(1);
191                            buf = ArrayD::<Complex<$t>>::zeros(IxDyn(&geometry[..]));
192                        },
193                    }
194                    // this unwrap is safe because:
195                    // 1. all owned arrays are contiguous, and
196                    // 2. it's in standard order since we just allocated it
197                    let buf_slice = buf.as_slice_mut().unwrap();
198                    let bytemuck_slice: &mut [$t] = bytemuck::cast_slice_mut(buf_slice);
199
200                    match self.data_block.byte_order {
201                        ByteOrder::Big => reader.$func::<BE>(bytemuck_slice),
202                        ByteOrder::Little => reader.$func::<LE>(bytemuck_slice),
203                    }.change_context(ReadDataBlockError::IoError)?;
204                    Ok(buf.into_dyn_img(self.pixel_storage))
205                }
206            }
207        }
208
209        match self.sample_format {
210            SampleFormat::UInt8 => self.read_data_impl(reader,
211                Read::read_exact,
212                Read::read_exact
213            ).map(|buf| buf.into_dyn_img(self.pixel_storage)),
214            SampleFormat::UInt16 => read_real!(read_u16_into),
215            SampleFormat::UInt32 => read_real!(read_u32_into),
216            SampleFormat::UInt64 => read_real!(read_u64_into),
217            SampleFormat::Float32 => read_real!(read_f32_into),
218            SampleFormat::Float64 => read_real!(read_f64_into),
219            SampleFormat::Complex32 => read_complex!(read_f32_into, f32),
220            SampleFormat::Complex64 => read_complex!(read_f64_into, f64),
221        }
222    }
223
224    // TODO: handle out of memory errors gracefully instead of panicking, which I assume is the default behavior
225    // F1 and F2 have identical signatures, but they need to be separate
226    // because two functions with the same signature are not technically the same type according to rust
227    fn read_data_impl<'a, T, F1, F2>(&self, reader: &'a mut dyn Read, read_le: F1, read_be: F2) -> Result<ArrayD<T>, ReadDataBlockError>
228        where F1: Fn(&'a mut dyn Read, &mut [T]) -> std::io::Result<()>,
229        F2: Fn(&'a mut dyn Read, &mut [T]) -> std::io::Result<()>,
230        T: Clone + num_traits::Zero {
231        let mut buf;
232        match self.pixel_storage {
233            PixelStorage::Planar => buf = ArrayD::<T>::zeros(IxDyn(&self.geometry[..])),
234            PixelStorage::Normal => {
235                let mut geometry = self.geometry.clone();
236                geometry.rotate_left(1);
237                buf = ArrayD::<T>::zeros(IxDyn(&geometry[..]));
238            },
239        }
240        // this unwrap is safe because:
241        // 1. all owned arrays are contiguous, and
242        // 2. it's in standard order since we just allocated it
243        let buf_slice = buf.as_slice_mut().unwrap();
244        match self.data_block.byte_order {
245            ByteOrder::Big => read_be(reader, buf_slice),
246            ByteOrder::Little => read_le(reader, buf_slice),
247        }.change_context(ReadDataBlockError::IoError)?;
248        Ok(buf)
249    }
250
251    /// Returns true iff an XISF property is present with the given ID
252    pub fn has_property(&self, id: impl AsRef<str>) -> bool {
253        self.properties.contains_key(id.as_ref())
254    }
255
256    /// Attempts to parse an XISF property with the given ID as type T
257    ///
258    /// To read a value and comment pair, use the pattern `let (value, comment) = properties.parse_property("ID", &xisf)?;`
259    pub fn parse_property<T: FromProperty>(&self, id: impl AsRef<str>, ctx: &Context) -> Result<T, ReadPropertyError> {
260        let content = self.properties.get(id.as_ref())
261            .ok_or(report!(ReadPropertyError::NotFound))?;
262        T::from_property(&content, ctx)
263            .change_context(ReadPropertyError::InvalidFormat)
264    }
265    /// Returns the raw content of the XISF property matching the given ID`
266    pub fn raw_property(&self, id: impl AsRef<str>) -> Option<&PropertyContent> {
267        self.properties.get(id.as_ref())
268    }
269    /// Iterates through all XISF properties as (id, type+value+comment) tuples,
270    /// in the order they appear in file, returned as raw unparsed strings/data blocks.
271    pub fn all_raw_properties(&self) -> impl Iterator<Item = (&String, &PropertyContent)> {
272        self.properties.iter()
273    }
274
275    /// Returns true iff the given FITS key is present in the header
276    pub fn has_fits_key(&self, name: impl AsRef<str>) -> bool {
277        self.fits_header.get(name.as_ref()).is_some()
278    }
279    /// Attempts to parse a FITS key with the given name as type `T`, following the syntax laid out in
280    /// [section 4.1](https://fits.gsfc.nasa.gov/standard40/fits_standard40aa-le.pdf#subsection.4.1) of the FITS specification.
281    /// If there is more than one key present with the given name, only the first one is returned.
282    pub fn parse_fits_key<T: FromFitsKey>(&self, name: impl AsRef<str>) -> Result<T, ReadFitsKeyError> {
283        let content = self.fits_header.get(name.as_ref())
284            .ok_or(report!(ReadFitsKeyError::NotFound))?;
285        T::from_fits_key(content)
286            .change_context(ReadFitsKeyError::InvalidFormat)
287    }
288    /// Returns an iterator over all values in the FITS header matching the given name.
289    /// Each key is attempted to be parsed as type `T`, following the syntax laid out in
290    /// [section 4.1](https://fits.gsfc.nasa.gov/standard40/fits_standard40aa-le.pdf#subsection.4.1) of the FITS specification.
291    pub fn parse_fits_keys<T: FromFitsKey>(&self, name: impl AsRef<str>) -> impl Iterator<Item = Result<T, ParseValueError>> + '_ {
292        self.fits_header.get_all(name.as_ref())
293            .map(|content| T::from_fits_key(content))
294    }
295    /// Returns the raw string (both value and comment) of the FITS key matching the given name
296    /// As of the time of writing, this is the only way to get comments from the FITS header
297    pub fn raw_fits_key(&self, name: impl AsRef<str>) -> Option<&FitsKeyContent> {
298        self.fits_header.get(name.as_ref())
299    }
300    /// Returns an iterator over all values (and comments) of keys in the FITS header matching the given name.
301    /// Although most keys are only allowed to appear once in a header, this is especially useful for the HISTORY keyword,
302    /// which is typically appended each time the image is processed in some way
303    pub fn raw_fits_keys(&self, name: impl AsRef<str>) -> impl Iterator<Item = &FitsKeyContent> {
304        self.fits_header.get_all(name.as_ref())
305    }
306    /// Iterates through all FITS keys as (key, value+comment) tuples,
307    /// in the order they appear in file, returned as raw unparsed strings.
308    pub fn all_raw_fits_keys(&self) -> impl Iterator<Item = (&String, &FitsKeyContent)> {
309        self.fits_header.iter()
310    }
311
312    /// Returns a reference to the embedded ICC profile, if one exists.
313    /// If the returned value is `Some`, obtain the profile data by calling `read_data()` on the contained value.
314    /// Note: `read_data()` just returns a `Vec<u8>`; consider the `lcms2` crate if you need to actually decode it.
315    pub fn icc_profile(&self) -> Option<&ICCProfile> {
316        self.icc_profile.as_ref()
317    }
318
319    /// Returns a reference to the RGB working space, if one is specified.
320    /// If none is specified, the default is the sRGB color space, relative to the D50 standard illuminant.
321    /// Consider using [`Option::unwrap_or_default()`] on the result.
322    pub fn rgb_working_space(&self) -> Option<&RGBWorkingSpace> {
323        self.rgb_working_space.as_ref()
324    }
325
326    /// Returns a reference to the display function, if one is specified.
327    /// If none is specified, the default is the identity function.
328    /// Although the identity display function is the [`Default`], using [`Option::unwrap_or_default()`] on the result
329    /// would likely be unwise for most cases, as applying the identity function would result in unnecessary computation.
330    pub fn display_function(&self) -> Option<&DisplayFunction> {
331        self.display_function.as_ref()
332    }
333
334    /// Returns the pixel density of this image, in pixels-per-inch or pixels-per-centimeter
335    /// If none is specified, the default is 72 PPI.
336    /// Consider using [`Option::unwrap_or_default()`] on the result
337    pub fn pixel_density(&self) -> Option<&Resolution> {
338        self.resolution.as_ref()
339    }
340}
341
342///
343///
344/// XISF images may have an arbitrary number of dimensions and
345#[derive(Clone, Debug)]
346pub struct Image {
347    base: ImageBase,
348    bounds: Option<SampleBounds>,
349    color_filter_array: Option<CFA>,
350    thumbnail: Option<Thumbnail>,
351}
352
353
354fn parse_image<T: ParseImage + 'static>(node: RoNode, xpath: &XpathContext, opts: &ReadOptions) -> Result<Image, ParseNodeError> {
355    let is_thumbnail = TypeId::of::<T>() == TypeId::of::<Thumbnail>();
356
357    let context = |kind| -> ParseNodeError {
358        ParseNodeError::new(T::TAG_NAME, kind)
359    };
360    let report = |kind| -> Report<ParseNodeError> {
361        report!(ParseNodeError::new(T::TAG_NAME, kind))
362    };
363
364    // * this is mutable because we use .remove() instead of .get()
365    // that way, after we've extracted everything we recognize,
366    // we can just iterate through what remains and emit warnings
367    // saying we don't know what so-and-so attribute means
368    let mut attrs = node.get_attributes();
369
370    let data_block = DataBlock::parse_node(node, T::TAG_NAME, &mut attrs)?
371        .ok_or(context(MissingAttr))
372        .attach_printable("Missing location attribute: Image elements must have a data block")?;
373
374    let mut geometry: Vec<usize> = vec![];
375    if let Some(dims) = attrs.remove("geometry") {
376        for i in dims.split(":") {
377            let dim = parse_auto_radix::<usize>(i.trim())
378                .change_context(context(InvalidAttr))
379                .attach_printable("Invalid geometry attribute: failed to parse dimension/channel count")
380                .attach_printable_lazy(|| format!("Expected pattern \"{{dim_1}}:...:{{dim_N}}:{{channel_count}}\" (for N>=1 and all values > 0), found \"{i}\""))?;
381            if dim > 0 {
382                geometry.push(dim);
383            } else {
384                return Err(report(InvalidAttr))
385                    .attach_printable("Invalid geometry attribute: dimensions and channel count all must be nonzero")
386            }
387        }
388        if geometry.len() < 2 {
389            return Err(report(InvalidAttr))
390                .attach_printable("Invalid geometry attribute: must have at least one dimension and one channel")
391        } else {
392            // convert to row-major order
393            geometry = geometry.into_iter().rev().collect();
394        }
395    } else {
396        return Err(report(MissingAttr)).attach_printable("Missing geometry attribute")
397    }
398
399    let sample_format = attrs.remove("sampleFormat")
400        .ok_or(report(MissingAttr))
401        .attach_printable("Missing sampleFormat attribute")
402        .and_then(|val| {
403            val.parse::<SampleFormat>()
404                .change_context(context(InvalidAttr))
405                .attach_printable_lazy(||
406                    format!("Invalid sampleFormat attribute: expected one of {:?}, found {val}", SampleFormat::VARIANTS))
407        })?;
408
409    let bounds = if let Some(val) = attrs.remove("bounds") {
410        let (low, high) = val.split_once(":")
411            .ok_or(report(InvalidAttr))
412            .attach_printable_lazy(|| "Invalid bounds attribute: expected pattern \"low:high\", found \"{val}\"")?;
413
414        Some(SampleBounds {
415            low: low.trim().parse::<f64>()
416                .change_context(context(InvalidAttr))
417                .attach_printable("Invalid bounds attribute: failed to parse lower bound")?,
418            high: high.trim().parse::<f64>()
419                .change_context(context(InvalidAttr))
420                .attach_printable("Invalid bounds attribute: failed to parse upper bound")?
421        })
422    } else if sample_format.requires_bounds() {
423        return Err(report(MissingAttr))
424            .attach_printable(format!("Missing bounds attribute: required when using using {sample_format} sample format"));
425    } else {
426        None
427    };
428
429    let image_type = if let Some(val) = attrs.remove("imageType") {
430        Some(val.parse::<ImageType>()
431            .change_context(context(InvalidAttr))
432            .attach_printable_lazy(||
433                format!("Invalid imageType attribute: expected one of {:?}, found {val}", ImageType::VARIANTS))?)
434    } else {
435        None
436    };
437
438    let pixel_storage = if let Some(val) = attrs.remove("pixelStorage") {
439        val.parse::<PixelStorage>()
440            .change_context(context(InvalidAttr))
441            .attach_printable_lazy(||
442                format!("Invalid pixelStorage attribute: expected one of {:?}, found {val}", PixelStorage::VARIANTS)
443            )?
444    } else {
445        Default::default()
446    };
447
448    let color_space = if let Some(val) = attrs.remove("colorSpace") {
449        val.parse::<ColorSpace>()
450            .change_context(context(InvalidAttr))
451            .attach_printable_lazy(||
452                format!("Invalid colorSpace attribute: expected one of {:?}, found {val}", ColorSpace::VARIANTS)
453            )?
454    } else {
455        Default::default()
456    };
457
458    let offset = if let Some(val) = attrs.remove("offset") {
459        let maybe_negative = val.parse::<f64>()
460            .change_context(context(InvalidAttr))
461            .attach_printable("Invalid offset attribute")?;
462        if maybe_negative < 0.0 {
463            return Err(report!(context(InvalidAttr))).attach_printable("Invalid offset attribute: must be zero or greater")
464        } else {
465            maybe_negative
466        }
467    } else {
468        0.0
469    };
470
471    let orientation = if let Some(val) = attrs.remove("orientation") {
472        Some(val.parse::<Orientation>()
473            .change_context(context(InvalidAttr))
474            .attach_printable("Invalid orientation attribute")?)
475    } else {
476        None
477    };
478
479    let id = attrs.remove("id");
480    if let Some(id) = &id {
481        if !is_valid_id(id) {
482            return Err(report(InvalidAttr)).attach_printable(
483                format!("Invalid id attribute: must match regex [_a-zA-Z][_a-zA-Z0-9]*, found \"{id}\"")
484            )
485        }
486    }
487
488    let uuid = if let Some(val) = attrs.remove("uuid") {
489        Some(val.parse::<Uuid>()
490            .change_context(context(InvalidAttr))
491            .attach_printable("Invalid uuid attribute")?)
492    } else {
493        None
494    };
495
496    for remaining in attrs.into_iter() {
497        tracing::warn!("Ignoring unrecognized attribute {}=\"{}\"", remaining.0, remaining.1);
498    }
499
500    let mut properties = HashMap::new();
501    let mut fits_header = ListOrderedMultimap::new();
502    let mut icc_profile = None;
503    let mut rgb_working_space = None;
504    let mut display_function = None;
505    let mut color_filter_array = None;
506    let mut resolution = None;
507    let mut thumbnail = None;
508
509    // TODO: ignore text/<Data> children of nodes with inline or embedded blocks, respectively
510    for mut child in node.get_child_nodes() {
511        child = child.follow_reference(xpath).change_context(context(InvalidReference))?;
512
513        macro_rules! parse_optional {
514            ($t:ty, $opt_out:ident) => {
515                {
516                    let parsed = <$t>::parse_node(child)?;
517                    if $opt_out.replace(parsed).is_some() {
518                        tracing::warn!(concat!("Duplicate ", stringify!($t), " element found -- discarding the previous one"));
519                    }
520                }
521            };
522            ($t:ty, $opt_out:ident, full) => {
523                {
524                    let parsed = <$t>::parse_node(child, xpath, opts)?;
525                    if $opt_out.replace(parsed).is_some() {
526                        tracing::warn!(concat!("Duplicate ", stringify!($t), " element found -- discarding the previous one"));
527                    }
528                }
529            };
530        }
531
532        match child.get_name().as_str() {
533            "Property" => {
534                let prop = Property::parse_node(child)?;
535                if properties.insert(prop.id.clone(), prop.content).is_some() {
536                    tracing::warn!("Duplicate property found with id {} -- discarding the previous one", prop.id);
537                }
538            }
539            "FITSKeyword" if opts.import_fits_keywords => {
540                let key = FitsKeyword::parse_node(child)?;
541                fits_header.append(key.name, key.content);
542                // TODO: respect fits_keywords_as_properties option
543            },
544            "ICCProfile" => parse_optional!(ICCProfile, icc_profile),
545            "RGBWorkingSpace" => parse_optional!(RGBWorkingSpace, rgb_working_space),
546            "DisplayFunction" => parse_optional!(DisplayFunction, display_function),
547            "ColorFilterArray" if !is_thumbnail => parse_optional!(CFA, color_filter_array),
548            "Resolution" => parse_optional!(Resolution, resolution),
549            "Thumbnail" if !is_thumbnail => parse_optional!(Thumbnail, thumbnail, full),
550            bad => tracing::warn!("Ignoring unrecognized child node <{}>", bad),
551        }
552    }
553
554    //===============//
555    // SANITY CHECKS //
556    //===============//
557
558    if geometry[0] < color_space.num_channels() {
559        return Err(report(InvalidAttr))
560            .attach_printable(format!(
561                "Insufficient color channels: {color_space} color space requires {}; only found {}",
562                color_space.num_channels(),
563                geometry[0]
564            ));
565    }
566
567    if color_filter_array.is_some() && geometry.len() - 1 != 2 {
568        tracing::warn!("ColorFilterArray element only has a defined meaning for 2D images; found one on a {}D image", geometry.len() - 1);
569    }
570
571    Ok(Image {
572        base: ImageBase {
573            data_block,
574            geometry,
575            sample_format,
576
577            image_type,
578            pixel_storage,
579            color_space,
580            offset,
581            orientation,
582            id,
583            uuid,
584
585            properties,
586            fits_header,
587            icc_profile,
588            rgb_working_space,
589            display_function,
590            resolution,
591        },
592        bounds,
593        color_filter_array,
594        thumbnail,
595    })
596}
597
598pub(crate) trait ParseImage: Sized {
599    const TAG_NAME: &'static str;
600}
601
602impl ParseImage for Image {
603    const TAG_NAME: &'static str = "Image";
604}
605
606impl Deref for Image {
607    type Target = ImageBase;
608
609    fn deref(&self) -> &Self::Target {
610        &self.base
611    }
612}
613
614impl Image {
615    pub(crate) fn parse_node(node: RoNode, xpath: &XpathContext, opts: &ReadOptions) -> Result<Self, ParseNodeError> {
616        parse_image::<Self>(node, xpath, opts)
617    }
618
619    /// The minimum and maximum value for a pixel sample in this image.
620    /// For images in a non-RGB [color space](ColorSpace), these bounds apply to pixel sample values once converted to RGB, not in its native color space.
621    /// For integer [sample format](SampleFormat)s,
622    pub fn bounds(&self) -> Option<SampleBounds> {
623        self.bounds
624    }
625
626    /// Returns the color filter array, if one exists
627    pub fn cfa(&self) -> Option<&CFA> {
628        self.color_filter_array.as_ref()
629    }
630
631    /// Returns the thumbnail, if one exists
632    pub fn thumbnail(&self) -> Option<&Thumbnail> {
633        self.thumbnail.as_ref()
634    }
635}
636
637/// Describes what kind of data makes up a given image
638///
639/// See also [`DynImageData`]
640#[derive(Clone, Copy, Debug, Display, EnumString, EnumVariantNames, PartialEq)]
641pub enum SampleFormat {
642    /// Pixel samples are scalar `u8`s
643    UInt8,
644    /// Pixel samples are scalar `u16`s
645    UInt16,
646    /// Pixel samples are scalar `u32`s
647    UInt32,
648    /// Pixel samples are scalar `u64`s
649    UInt64,
650    /// Pixel samples are scalar `f32`s
651    Float32,
652    /// Pixel samples are scalar `f64`s
653    Float64,
654    /// Pixel samples are complex with `f32` parts
655    Complex32,
656    /// Pixel samples are complex with `f64` parts
657    Complex64,
658}
659impl SampleFormat {
660    pub(crate) fn requires_bounds(&self) -> bool {
661        match self {
662            Self::Float32 | Self::Float64 => true,
663            _ => false,
664        }
665    }
666}
667
668/// Sets the minimum and maximum value of a pixel sample
669// TODO: I think this is just used for display purposes and doesn't actually clamp input? should add that to the doc if so
670#[derive(Clone, Copy, Debug, PartialEq)]
671pub struct SampleBounds {
672    /// The lower bound
673    pub low: f64,
674    /// The upper bound
675    pub high: f64,
676}
677
678/// Describes whether this is a light frame, dark frame, flat frame, bias frame, etc
679#[derive(Clone, Copy, Debug, Display, EnumString, EnumVariantNames, PartialEq)]
680pub enum ImageType {
681    /// A bias frame is an extremely short exposure taken with the sensor covered,
682    /// taken to counteract the sensor's readout noise.
683    Bias,
684    /// A dark frame is an exposure taken with the sensor covered, with the same length and at the same temperature as the light frame it's calibrating.
685    /// Dark frames are taken to counteract the sensor's thermal noise and hot/cold pixels.
686    Dark,
687    /// A flat frame is an exposure taken with the main optical element completely illuminated by an even light source,
688    /// taken to counteract the optical system's dust and vignetting.
689    Flat,
690    /// A light frame is an exposure of the actual target
691    Light,
692    /// An integration (AKA stack) of two or more [`Bias`](Self::Bias) frames
693    MasterBias,
694    /// An integration (AKA stack) of two or more [`Dark`](Self::Dark) frames
695    MasterDark,
696    /// An integration (AKA stack) of two or more [`Flat`](Self::Flat) frames
697    MasterFlat,
698    /// An integration (AKA stack) of two or more [`Light`](Self::Light) frames
699    MasterLight,
700    /// An integer or floating point real image where nonzero pixel sample values represent invalid or defective pixels.
701    /// Defective pixels are typically ignored or replaced with plausible statistical estimates, such as robust averages of neighbor pixels.
702    DefectMap,
703    /// For a process that takes multiple images as an input and produces one output while rejecting outliers, such as integration.
704    /// For any given pixel on the rejection map, the minimum value\* indicates that none of the corresponding pixels from the input images
705    /// were rejected for being **high outliers**, and following a linear scale between, the maximum value\* indicates that all of the
706    /// corresponding pixels from the input images were rejected. May be an integer or floating point real image.
707    ///
708    /// \* The minimum and maximum values of an [`Image`] are specified in the [`"bounds"`](SampleBounds) attribute of an [`Image`].
709    /// Optional for integer images, where the default is the lower and upper bounds of the integer type.
710    RejectionMapHigh,
711    /// For a process that takes multiple images as an input and produces one output while rejecting outliers, such as integration.
712    /// For any given pixel on the rejection map, the minimum value\* indicates that none of the corresponding pixels from the input images
713    /// were rejected for being **low outliers**, and following a linear scale between, the maximum value\* indicates that all of the
714    /// corresponding pixels from the input images were rejected. May be an integer or floating point real image.
715    ///
716    /// \* The minimum and maximum values of an [`Image`] are specified in the [`"bounds"`](SampleBounds) attribute of an [`Image`].
717    /// Optional for integer images, where the default is the lower and upper bounds of the integer type.
718    RejectionMapLow,
719    /// For a process that takes a single image as an input and produces one output while rejecting outliers, such as cosmetic correction.
720    /// For any given pixel on the rejection map, any nonzero value indicates that the corresponding input pixel was rejected for being a **high outlier**,
721    /// and a value of 0 indicates that the pixel was not rejected. Must be an 8-bit unsigned integer image.
722    BinaryRejectionMapHigh,
723    /// For a process that takes a single image as an input and produces one output while rejecting outliers, such as cosmetic correction.
724    /// For any given pixel on the rejection map, any nonzero value indicates that the corresponding input pixel was rejected for being a **low outlier**,
725    /// and a value of 0 indicates that the pixel was not rejected. Must be an 8-bit unsigned integer image.
726    BinaryRejectionMapLow,
727    /// Integer or floating point real images where each pixel sample value is proportional to the slope of a straight line
728    /// fitted to a set of integrated pixels at the corresponding pixel coordinates.
729    /// A slope map value equal to the lower bound of the representable range corresponds to a
730    /// horizontal line (or a slope of zero degrees), while the upper bound represents a vertical line (infinite slope).
731    SlopeMap,
732    /// Integer or floating point real images where each pixel sample value is proportional to a statistical weight assigned
733    /// at the corresponding pixel coordinates. Statistical weights represented by weight maps are typically generated by image calibration,
734    /// registration and integration processes, but can be produced by any task performing per-pixel evaluations or comparisons.
735    WeightMap,
736}
737
738/// Describes the memory layout of the image's pixel samples
739///
740/// No matter which pixel storage layout is used, the pixel samples are stored in row-major order.
741/// See [the specification](https://pixinsight.com/doc/docs/XISF-1.0-spec/XISF-1.0-spec.html#pixel_storage_models) for more details and a visual representation of each layout.
742#[derive(Clone, Copy, Debug, Display, Default, EnumString, EnumVariantNames, PartialEq)]
743pub enum PixelStorage {
744    /// The image is stored as all of the first channel, then all of the second channel, and so on.
745    /// That is, for a W\*H 2D image with 3 channels and `u8` samples, pixel *p<sub>x,y,c</sub>* is stored at byte offset *WHc + Wy + x*.
746    /// Default when none is specified. See also [`memory_layout::Planar`].
747    #[default]
748    Planar,
749    /// The image is stored as the first pixel (its first channel, second channel, and so on),
750    /// the second pixel (its first channel, second channel, and so on), and so on.
751    /// That is, for a W\*H 2D image with 3 channels and `u8` samples, pixel *p<sub>x,y,c</sub>* is stored at byte offset *3Wy + 3x + c*.
752    /// See also [`memory_layout::Normal`].
753    Normal,
754}
755
756/// An image's [color space](https://en.wikipedia.org/wiki/Color_space)
757#[derive(Clone, Copy, Debug, Display, Default, EnumString, EnumVariantNames, PartialEq)]
758pub enum ColorSpace {
759    /// A one-channel grayscale image. Default when none is specified.
760    #[default]
761    Gray,
762    /// A three-channel RGB image. Arguably only a color *model* and not a color *space*;
763    /// requires context from an [`RGBWorkingSpace`] or [`ICCProfile`] element to become the latter.
764    /// Should be considered coordinates in the sRGB color space (relative to the D50 standard illuminant) if neither supporting element is present.
765    RGB,
766    /// A three-channel CIE L\*a\*b\* image
767    CIELab,
768}
769impl ColorSpace {
770    /// Returns the number of channels that it takes to store a complete pixel in this color space
771    pub fn num_channels(&self) -> usize {
772        match self {
773            Self::Gray => 1,
774            Self::RGB | Self::CIELab => 3,
775        }
776    }
777}
778
779/// A transformation to be applied before *visual presentation* of the image
780#[derive(Clone, Copy, Debug, Default, PartialEq)]
781pub struct Orientation {
782    /// What kind of rotation should be applied, if any
783    ///
784    /// Should be applied *before* the horizontal flip, if a flip is present
785    pub rotation: Rotation,
786    /// True iff a horizontal reflection should be applied
787    ///
788    /// Should be applied *after* the rotation, if a rotation is present.
789    /// *Reminder: a horizontal reflection of a 2D image flips pixels across the y axis,
790    /// leaving pixels on the far-right of the image on the far-left and vice-versa*
791    pub hflip: bool,
792}
793impl fmt::Display for Orientation {
794    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
795        // if there is a horizontal flip but no rotation, it's serialized just as "flip", not "0;flip"
796        if self.hflip && self.rotation == Rotation::None {
797            f.write_str("flip")
798        }
799        else {
800            f.write_fmt(format_args!("{}{}",
801                self.rotation,
802                if self.hflip { ";flip" } else { "" }
803            ))
804        }
805    }
806}
807impl FromStr for Orientation {
808    type Err = Report<ParseValueError>;
809    fn from_str(s: &str) -> Result<Self, ParseValueError> {
810        match s {
811            "0" => Ok(Self { rotation: Rotation::None, hflip: false }),
812            "flip" => Ok(Self { rotation: Rotation::None, hflip: true }),
813            "90" => Ok(Self { rotation: Rotation::Ccw90, hflip: false }),
814            "90;flip" => Ok(Self { rotation: Rotation::Ccw90, hflip: true }),
815            "-90" => Ok(Self { rotation: Rotation::Cw90, hflip: false }),
816            "-90;flip" => Ok(Self { rotation: Rotation::Cw90, hflip: true }),
817            "180" => Ok(Self { rotation: Rotation::_180, hflip: false }),
818            "180;flip" => Ok(Self { rotation: Rotation::_180, hflip: true }),
819            bad => Err(report!(ParseValueError("Orientation")))
820                .attach_printable(format!("Expected one of [0, flip, 90, 90;flip, -90, -90;flip, 180, 180;flip], found {bad}",))
821        }
822    }
823}
824
825/// A rotation (in multiples of 90 degrees) to be applied before visual presentation of the image
826#[derive(Clone, Copy, Debug, Default, PartialEq)]
827pub enum Rotation {
828    /// No rotation
829    #[default]
830    None,
831    /// A 90 degree clockwise rotation
832    Cw90,
833    /// A 90 degree counterclockwise rotation
834    Ccw90,
835    /// A 180 degree rotation
836    _180,
837}
838impl fmt::Display for Rotation {
839    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
840        f.write_str(match self {
841            Rotation::None => "0",
842            Rotation::Cw90 => "-90",
843            Rotation::Ccw90 => "90",
844            Rotation::_180 => "180",
845        })
846    }
847}