# Copyright 2021 United States Government as represented by the Administrator of the National Aeronautics and Space
# Administration. No copyright is claimed in the United States under Title 17, U.S. Code. All Other Rights Reserved.
"""
This module defines the Camera object for GIANT, which collects information about a camera and images captured by that
camera in a single place and provides some methods for filtering, sorting, and handling the images.
A camera object is used to collect various pieces of data about a camera in a single place, such as a
:class:`.CameraModel`, a list of :class:`.OpNavImage` objects captured by the camera, a function representing the point
spread function of the camera, and some other pieces of information about the camera and images that are used throughout
the other GIANT routines. By collecting all this data into a single object, the interface for individual components of
GIANT is unified.
In addition to collecting much of the information GIANT requires in a single location, camera objects provide some
capabilities to make managing image sets easier. These capabilities include things like the ability to turn an image
(or a set of images) off, so it is no longer considered in the GIANT estimation and measurement routines (and also the
ability to turn images back on), the ability to quickly add new images to be considered by only supplying the path to
the files, the ability to override some of the metadata in the images based off of an external file (like the
attitude of the camera), and also the ability to apply a preprocessor to all images (where you can reorient the image,
remove bad pixels, subtract a flat field, etc..).
"""
import warnings
from datetime import timedelta, datetime
from enum import Enum
from typing import Union, Sequence, Iterable, Callable, Optional, Tuple, List
from pathlib import Path
import numpy as np
from giant.image import OpNavImage, ExposureType
from giant.camera_models import CameraModel
from giant.rotations import slerp, Rotation
from giant._typing import ARRAY_LIKE_2D, PATH
from giant.point_spread_functions import PointSpreadFunction
[docs]class AttitudeUpdateMethods(Enum):
"""
This enumeration provides options for performing quaternion updates on short exposure images using long exposure
images.
See :meth:`.update_short_attitude` for more details.
"""
REPLACE = "replace"
"""
The replace method where the closest long exposure attitude is used to overwrite the short exposure attitude.
"""
PROPAGATE = "propagate"
"""
The delta quaternion method where the closest long exposure attitude is propagated using the attitude function
to overwrite the short exposure attitude.
"""
INTERPOLATE = "interpolate"
"""
The interpolate method where the 2 closest long exposure attitudes are interpolated using spherical linear
interpolation to overwrite the short exposure attitude.
"""
[docs]class Camera:
"""
This class collects images, the :class:`.CameraModel`, and some relevant metadata about the camera into a single
object for passing to the GIANT estimators and measurements.
The :class:`Camera` class is primarily a container for collecting and manipulating images and metadata for a
single physical camera. This container is passed to the various measurement and estimation routines
throughout GIANT to provide them with the requisite images and data needed to complete their tasks. The
:class:`Camera` object is also an iterator over the images that are turned on. This means you could do
something like:
>>> from giant.camera import Camera
>>> import numpy
>>> # generate the image data
>>> image_list = [numpy.random.randn(100, 100) for _ in range(10)] # type: List[np.ndarray]
>>> # create an instance with the images included
>>> cam = Camera(images=image_list, parse_data=False)
>>> # turn off a few of the images
>>> cam.image_mask[1], cam.image_mask[5], cam.image_mask[8] = False, False, False
>>> # iterate over the images that are turned on
>>> for ind, image in cam:
>>> print(ind, numpy.array_equal(image_list[ind], image))
0 True
2 True
3 True
4 True
6 True
7 True
9 True
The implementation of the Camera object here is fully functional, however, you may want to subclass this
object for customization purposes. For instance you may want to implement the :meth:`preprocessor` method to
apply corrections to images immediately after loading. You may also want to update the :meth:`image_check`
method to use a custom :class:`.OpNavImage` subclass instead of the default (alternatively you could override
the ``default_image_class`` argument to ``__init__``), or provide more custom functionality.
"""
def __init__(self, images: Union[Iterable[Union[PATH, ARRAY_LIKE_2D]], PATH, ARRAY_LIKE_2D, None] = None,
model: Optional[CameraModel] = None, name: Optional[str] = None, spacecraft_name: Optional[str] = None,
frame: Optional[str] = None, parse_data: bool = True, psf: Optional[PointSpreadFunction] = None,
attitude_function: Optional[Callable] = None, start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None, metadata_only: bool = False,
default_image_class: type = OpNavImage):
"""
:param images: A single image, or a list of images to store in the camera object. The image data can either be
a string (in which case it is represents the path to the image file), an array of image data
(this is generally not recommended), an :class:`.OpNavImage` object already initialized, or a
list containing any of these three options.
:param model: A camera model that represents how 3D points project onto the 2D imaging plane
:param name: The name of the camera that is represented by this object. Not Required
:param spacecraft_name: The name of the spacecraft that hosts the camera. Not Required
:param frame: The name of the frame for this camera. Not Required
:param parse_data: A flag specifying whether to parse the metadata for each image when it is being loaded.
:param psf: A callable object that applies a PSF to a 2D image, and provides a
:meth`~.PointSpreadFunction.apply_1d` method to apply the PSF to 1D scan lines. Typically this is a
:class:`.PointSpreadFunction` subclass.
:param attitude_function: A function that returns the attitude of the camera frame with respect to the inertial
frame for an input datetime object. This is generally a call to a spice routine
generated by the :mod:`.spice_interface` helper functions.
(see the :func:`.create_callable_orientation` and
:func:`.et_callable_to_datetime_callable` functions in particular)
:param start_date: The time at which images should start being processed
:param end_date: The time at which images should start being processed
:param metadata_only: Only load image metadata to an empty OpNavImage instead of loading the full image data.
:param default_image_class: The class that the images stored in this instance should be an instance of.
"""
# store the camera model object
self._model = None
self.model = model
# store the image class we want to make sure our images are instances of
self._default_image_class = default_image_class
# add the images and create the image mask
self._image_mask = []
self._images = []
if images is not None:
self.add_images(images, parse_data=parse_data, metadata_only=metadata_only)
# add the start and end dates:
self.start_date = start_date
"""
The initial time to start processing images.
Any images with an :attr:`~.OpNavImage.observation_date` before this epoch are ignored. This typically should
be set to None (no filtering) or a python datetime object. See the :meth:`apply_date_range` method for more
details.
"""
self.end_date = end_date
"""
The final time to stop processing images.
Any images with an :attr:`~.OpNavImage.observation_date` after this epoch are ignored. This typically should
be set to None (no filtering) or a python datetime object. See the :meth:`apply_date_range` method for more
details.
"""
self.apply_date_range()
# store the metadata
self.name = name
"""
The name of the camera.
This attribute is provided for documentation and convenience, but isn't used directly by core GIANT
functions. For an example of how one might use this attribute, see the :ref:`getting started <getting-started>`
page for more details.
"""
self.frame = frame
"""
The name of the camera frame corresponding to this camera (typically a spice id).
This attribute is provided for documentation and convenience, but isn't used directly by core GIANT
functions. For an example of how one might use this attribute, see the :ref:`getting started <getting-started>`
page for more details.
"""
self.spacecraft_name = spacecraft_name
"""
The name of the spacecraft hosting this camera (typically set to a spice id).
This attribute is provided for documentation and convenience, but isn't used directly by core GIANT
functions. For an example of how one might use this attribute, see the :ref:`getting started <getting-started>`
page for more details.
"""
# store the psf
self._psf = None
self.psf = psf
# store the attitude function
self._attitude_function = None
self.attitude_function = attitude_function
def __iter__(self) -> Iterable[Tuple[int, OpNavImage]]:
"""
Loop through the images and their indices that are stored in the :attr:`.images` attribute
and that are turned on according to the :attr:`.image_mask` attribute.
:returns: A tuple of index, OpNavImage
"""
for ind, image in enumerate(self._images):
if self._image_mask[ind]:
yield ind, image
def __repr__(self) -> str:
odict = {}
for key, value in self.__dict__.items():
if not key.startswith("_"):
odict[key] = value
return (self.__module__ + "." + self.__class__.__name__ + "(" +
', '.join(['{}={!r}'.format(k, v) for k, v in odict.items()]) + ")")
def __str__(self) -> str:
odict = {}
for key, value in self.__dict__.items():
if isinstance(value, Callable):
value = value.__module__ + "." + value.__name__
if not key.startswith("_"):
odict[key] = value
return (self.__module__ + "." + self.__class__.__name__ + "(" +
', '.join(['{}={!s}'.format(k, v) for k, v in odict.items()]) + ")")
@property
def images(self) -> List[OpNavImage]:
"""
The list of :class:`.OpNavImages` contained in this camera object that should be considered by the GIANT
routines.
Note that this attribute is read only. To add or remove images from the list, use the :meth:`add_images` method
or :meth:`remove_images` method respectively as these will ensure that the :attr:`.images` and
:attr:`.image_mask` lists stay in sync.
"""
return self._images
@images.setter
def images(self, data):
raise AttributeError("The images cannot be set directly.\n"
"Use the add_images or remove_images methods instead")
@property
def image_mask(self) -> list:
"""
This property contains a mask for turning images on or off in the GIANT estimation and measurement routines.
The mask is a list of boolean values, where a ``True`` indicates that the image **should** be considered
in the GIANT routines and returned when iterating over the images using this class and a ``False`` indicates
that the image **should not** be considered in the GIANT routines or returned when iterating over the images
using this class.
In general, you should not write to, or update this mask directly, and you should never add or remove elements
from the list directly. Instead you should use the :meth:`add_images` or :meth:`remove_images` methods to
add/remove images entirely and use the `*_on` or `*_off` methods to adjust the boolean values within the
array.
.. note::
While it is strongly recommended to not directly modify this attribute, it is not write protected. Instead
there is a setter method which ensures that anything set to this attribute is coerced to be the right length
and type that is expected.
"""
return self._image_mask
@image_mask.setter
def image_mask(self, val):
if isinstance(val, Sequence):
if len(val) == len(self._images):
self._image_mask = list(val)
elif len(val) == 1:
self._image_mask = list(val) * len(self._images)
else:
raise ValueError("The list you provided us is not the right size")
elif isinstance(val, bool):
self._image_mask = [val] * len(self._images)
elif val is None:
self._image_mask = [True] * len(self._images)
else:
raise ValueError("The image mask must be set as a Sequence or a bool")
@property
def psf(self) -> Optional[PointSpreadFunction]:
"""
An object that applies a point spread function to 1D scan lines and 2D images.
This object should provide a ``__call__`` method which applies the PSF to a 2D image, and an ``apply_1d`` method
which applies the PSF to 1D scan lines (as the rows of a 2D array). Typically this is a subclass of
:class:`.PointSpreadFunction`.
The PSF object is used when generating templates in GIANT for use with the surface feature and cross
correlation techniques.
.. code::
new_template = camera.psf(template)
"""
return self._psf
@psf.setter
def psf(self, val: Optional[PointSpreadFunction]):
if isinstance(val, Callable):
self._psf = val
elif val is None:
self._psf = None
else:
raise ValueError('The psf object must be callable')
@property
def attitude_function(self) -> Callable:
"""
A function that returns the orientation of the camera frame with respect to the inertial frame as an
:class:`.Rotation` object for a specific time input as a python :class:`datetime` object.
This function is used in two different ways. First, it can be used to replace the attitude metadata
in all of the images that are turned on directly using the :meth:`update_attitude_from_function` method.
This is useful when you are parsing the image metadata from an outdated source (such as the header information
in a fits file) and want to update the attitude to reflect the most recent attitude knowledge. Second, this
function can be used to compute delta quaternions in order to propagate a solved-for attitude from one image to
another image using the :meth:`update_short_attitude` method. This is useful when you are taking long-short
exposure sequences and need to update the attitude of the short images based off of the solved-for attitude
from the long images.
In both cases the call to this function passes a datetime object representing the UTC
time we need the attitude for as the only argument and the function will return the attitude
as an :class:`.Rotation` object.
In general, this function is a wrapper around spice calls to retrieve the attitude from a ck file. GIANT
provides some helper routines in the :mod:`.spice_interface` module to make it easy to generate this function.
For instance, we can create a valid function for this attribute using the following:
>>> from giant.utilities.spice_interface import et_callable_to_datetime_callable
>>> from giant.utilities.spice_interface import create_callable_orientation
>>> attitude_function = et_callable_to_datetime_callable(create_callable_orientation('J2000', 'MyNavCam'))
and then we simply need to ensure we furnish a metakernel that provides enough information to compute the
transformation from the J2000 inertial frame to the `'MyNavCam'` frame. See the :mod:`.spice_interface`
documentation for more information about how this works.
"""
return self._attitude_function
@attitude_function.setter
def attitude_function(self, val: Optional[Callable]):
if isinstance(val, Callable):
self._attitude_function = val
elif val is None:
self._attitude_function = None
else:
raise ValueError('The attitude_function object must be callable')
@property
def model(self) -> CameraModel:
"""
The camera model which describes how 3D points in the camera frame are transformed into 2D points in the image.
The object set to this class will be used by all GIANT routines that make use of a camera model to relate
3D and 2D points (which is nearly all of them).
For more information about camera models and their theories, refer to the :mod:`.camera_models` module
documentation.
Although not required, it is strongly recommended that the object assigned to this property be a
subclass of :class:`.CameraModel`.
"""
return self._model
@model.setter
def model(self, val: Optional[CameraModel]):
if isinstance(val, CameraModel):
self._model = val
else:
self._model = val
warnings.warn("The camera model you specified is not a subclass of CameraModel.\n"
"We'll assume you know what you're doing and have setup the proper methods.\n"
"In the future you should subclass CameraModel to avoid this warning.")
[docs] def short_on(self) -> None:
"""
This method updates the :attr:`.image_mask` attribute so that any image whose :attr:`~.OpNavImage.exposure_type`
is set to ``SHORT`` or ``DUAL`` is turned on (processed).
This method checks the :attr:`.exposure_type` attribute of each :class:`.OpNavImage` contained in the
:attr:`.images` list, and if it is set to ``SHORT`` or ``DUAL``, then the corresponding index of the
:attr:`.image_mask` list is set to ``True`` so that the image is considered in GIANT measurement and estimation
routines.
.. note::
This method does not turn off images whose :attr:`.exposure_type` attribute is not set to ``SHORT``. See
the :meth:`only_short_on` method if you want this functionality.
.. note::
The :attr:`.exposure_type` attribute must be set correctly on each image for this method to work correctly.
The :attr:`.exposure_type` is generally set automatically when an :class:`.OpNavImage` is created according
to some threshold set by the user.
"""
for ind, image in enumerate(self._images):
if image.exposure_type in [ExposureType.SHORT, ExposureType.DUAL]:
self._image_mask[ind] = True
[docs] def short_off(self) -> None:
"""
This method updates the :attr:`.image_mask` attribute so that any image whose :attr:`~.OpNavImage.exposure_type`
is set to ``SHORT`` is turned off (not processed).
This method checks the :attr:`.exposure_type` attribute of each :class:`.OpNavImage` contained in the
:attr:`.images` list, and if it is set to ``SHORT`` then the corresponding index of the :attr:`.image_mask` list
is set to ``False`` so that the image is not considered in GIANT measurement and estimation routines.
.. note::
This method does not turn on images whose :attr:`.exposure_type` attribute is not set to ``SHORT``. See
the :meth:`only_long_on` method if you want this functionality.
.. note::
The :attr:`.exposure_type` attribute must be set correctly on each image for this method to work correctly.
The :attr:`.exposure_type` is generally set automatically when an :class:`.OpNavImage` is created according
to some threshold set by the user.
"""
for ind, image in enumerate(self._images):
if image.exposure_type == ExposureType.SHORT:
self._image_mask[ind] = False
[docs] def long_on(self) -> None:
"""
This method updates the :attr:`.image_mask` attribute so that any image whose :attr:`~.OpNavImage.exposure_type`
is set to ``LONG`` or ``DUAL`` is turned on (processed).
This method checks the :attr:`.exposure_type` attribute of each :class:`.OpNavImage` contained in the
:attr:`.images` list, and if it is set to ``LONG`` or ``DUAL``, then the corresponding index of the
:attr:`.image_mask` list is set to ``True`` so that the image is considered in GIANT measurement and estimation
routines.
.. note::
This method does not turn off images whose :attr:`.exposure_type` attribute is not set to ``LONG``. See
the :meth:`only_long_on` method if you want this functionality.
.. note::
The :attr:`.exposure_type` attribute must be set correctly on each image for this method to work correctly.
The :attr:`.exposure_type` is generally set automatically when an :class:`.OpNavImage` is created according
to some threshold set by the user.
"""
for ind, image in enumerate(self._images):
if image.exposure_type in [ExposureType.LONG, ExposureType.DUAL]:
self._image_mask[ind] = True
[docs] def long_off(self) -> None:
"""
This method updates the :attr:`.image_mask` attribute so that any image whose :attr:`~.OpNavImage.exposure_type`
is set to ``LONG`` is turned off (not processed).
This method checks the :attr:`.exposure_type` attribute of each :class:`.OpNavImage` contained in the
:attr:`.images` list, and if it is set to ``LONG``, then the corresponding index of the :attr:`.image_mask` list
is set to ``False`` so that the image is not considered in GIANT measurement and estimation routines.
.. note::
This method does not turn on images whose :attr:`.exposure_type` attribute is not set to ``LONG``. See
the :meth:`only_short_on` method if you want this functionality.
.. note::
The :attr:`.exposure_type` attribute must be set correctly on each image for this method to work correctly.
The :attr:`.exposure_type` is generally set automatically when an :class:`.OpNavImage` is created according
to some threshold set by the user.
"""
for ind, image in enumerate(self._images):
if image.exposure_type == ExposureType.LONG:
self._image_mask[ind] = False
[docs] def all_on(self) -> None:
"""
This method sets every element of the :attr:`.image_mask` list to ``True`` so that all images are considered in
the GIANT routines and returned when this class is iterated on.
"""
self._image_mask[:] = [True] * len(self._images)
[docs] def all_off(self) -> None:
"""
This method sets every element of the :attr:`.image_mask` list to ``False`` so that no images are considered in
the GIANT routines or returned when this class is iterated on.
"""
self._image_mask[:] = [False] * len(self._images)
[docs] def only_short_on(self) -> None:
"""
This method updated the :attr:`.image_mask` list so that any image whose :attr:`~.OpNavImage.exposure_type`
attribute is set to ``SHORT`` or ``DUAL`` is turned on (processed) and any image whose
:attr:`~.OpNavImage.exposure_type` is set to ``LONG`` is turned off (not processed).
This method checks the :attr:`.exposure_type` attribute of each :class:`.OpNavImage` contained in the
:attr:`.images` list, and if it is set to ``SHORT`` or ``DUAL``, then the corresponding index of the
:attr:`.image_mask` list is set to ``True`` so that the image is considered in GIANT measurement and estimation
routines. If the :attr:`.exposure_type` attribute is set to ``LONG``, then the corresponding index of the
:attr:`.image_mask` list is set to ``False`` so that the image is not considered in GIANT measurement and
estimation routines.
.. note::
This method turns off images whose :attr:`.exposure_type` attribute is not set to ``SHORT`` or ``DUAL``.
See the :meth:`short_on` method if you do not want to change the other :attr:`.image_mask` elements.
.. note::
The :attr:`.exposure_type` attribute must be set correctly on each image for this method to work correctly.
The :attr:`.exposure_type` is generally set automatically when an :class:`.OpNavImage` is created according
to some threshold set by the user.
"""
for ind, image in enumerate(self._images):
self._image_mask[ind] = image.exposure_type in [ExposureType.SHORT, ExposureType.DUAL]
[docs] def only_long_on(self):
"""
This method updated the :attr:`.image_mask` list so that any image whose :attr:`~.OpNavImage.exposure_type`
attribute is set to ``LONG`` or ``DUAL`` is turned on (processed) and any image whose
:attr:`~.OpNavImage.exposure_type` is set to ``SHORT`` is turned off (not processed).
This method checks the :attr:`.exposure_type` attribute of each :class:`.OpNavImage` contained in the
:attr:`.images` list, and if it is set to ``LONG`` or ``DUAL``, then the corresponding index of the
:attr:`.image_mask` list is set to ``True`` so that the image is considered in GIANT measurement and estimation
routines. If the :attr:`.exposure_type` attribute is set to ``SHORT``, then the corresponding index of the
:attr:`.image_mask` list is set to ``False`` so that the image is not considered in GIANT measurement and
estimation routines.
.. note::
This method turns off images whose :attr:`.exposure_type` attribute is not set to ``LONG`` or ``DUAL``.
See the :meth:`long_on` method if you do not want to change the other :attr:`.image_mask` elements.
.. note::
The :attr:`.exposure_type` attribute must be set correctly on each image for this method to work correctly.
The :attr:`.exposure_type` is generally set automatically when an :class:`.OpNavImage` is created according
to some threshold set by the user.
"""
for ind, image in enumerate(self._images):
self._image_mask[ind] = image.exposure_type in [ExposureType.LONG, ExposureType.DUAL]
[docs] def apply_date_range(self):
"""
This method filters images by date, setting any whose :attr:`~.OpNavImage.observation_date` is not
between :attr:`start_date` and :attr:`end_date` to False.
This method uses the :attr:`.start_date` and :attr:`.end_date` attributes to create a date range.
It then checks the :attr:`~.OpNavImage.observation_date` attribute of each :class:`.OpNavImage` contained in the
:attr:`images` to identify which images are in the specified date range. If an image was not taken
during the specific date range, then the corresponding index of the :attr:`image_mask` list is set
to ``False``.
.. note::
This method must be called after all other filter methods, such as :meth:`only_long_on`.
.. note::
If either the :attr:`.start_date` or :attr:`.end_date` attributes are type None, they will not be considered
.. note::
This method does not turn on any images that are turned off, even if they fall within the date
range.
"""
if (self.start_date is not None) and (self.end_date is not None):
for ind, image in self:
if not (self.start_date <= image.observation_date <= self.end_date):
self._image_mask[ind] = False
elif (self.start_date is None) and (self.end_date is None):
pass
elif self.end_date is None:
for ind, image in self:
if not (self.start_date <= image.observation_date):
self._image_mask[ind] = False
elif self.start_date is None:
for ind, image in self:
if not (image.observation_date <= self.end_date):
self._image_mask[ind] = False
[docs] def sort_by_date(self):
"""
This method is used to sort the images currently loaded to the :attr:`images` attribute by date.
It also ensures that the :attr:`image_mask` list remains in sync with the images. The images are sorted
by the :attr:`~.OpNavImage.observation_date` attribute for each image.
.. note::
To ensure that the images are truly sorted by date throughout all image processing steps, this
method should be called after any :meth:`add_images`, or :meth:`remove_images` method calls needed.
"""
dates = []
for image in self._images:
dates.append(image.observation_date)
sorted_date_inds = np.argsort(dates)
sorted_images = []
sorted_image_mask = []
for ind in sorted_date_inds:
sorted_images.append(self._images[ind])
sorted_image_mask.append(self._image_mask[ind])
self._images = sorted_images
self._image_mask = sorted_image_mask
[docs] def add_images(self, data: Union[Iterable[Union[PATH, ARRAY_LIKE_2D]], PATH, ARRAY_LIKE_2D],
parse_data: bool = True, preprocessor: bool = True, metadata_only: bool = False):
"""
This method is used to add images to the :attr:`.images` while also ensuring that the :attr:`.image_mask` list
remains the same size as the :attr:`.images` list.
This method is the only way that a user should add images to a :class:`Camera` object after the object has been
initialized. It ensures that the :attr:`.images` and :attr:`.image_mask` lists do not get out of sync, ensures
that the new images are turned on (their corresponding :attr:`.image_mask` values are set to ``True``) and also
interprets the input in order to create an :class:`.OpNavImage` for each instance.
There are a few different ways you can specify the images to be added to the camera model. The first, and most
effective, is to specify a list of strings representing the paths to the files that contain the image
information. Similarly, you can specify a single string representing the path to a single image if you only
want to add one image. Inputting the image data in this method allows the :class:`.OpNavImage` class to
retrieve the required metadata for each image, assuming the user has successfully subclassed the
:class:`.OpNavImage` class and set up the :meth:`.parse_data` method.
The next most useful way to enter the image data is by entering either a single, or a list of
:class:`.OpNavImage` objects. When using this method, the user should be sure that the appropriate metadata
has been set for each image.
The least useful way to enter the image data is by entering the raw image data either as a numpy array, a list
of numpy arrays, or a list of lists of lists. In each of these cases, the data contained in the arrays/inner
lists of lists is interpreted directly as the imaging data and no metadata is attached to the created
:class:`.OpNavImage` object. The user must the be sure to go an enter the correct metadata for each image
to ensure functionality is not broken for other GIANT routines.
Regardless of how the image data is entered, this method expands the :attr:`.image_mask` list by the number of
images that are being added and turns each of the new images on. In addition, if the ``preprocessor`` argument
is set to ``True`` the :meth:`preprocessor` method is called on each image before it is stored in the
:attr:`.images` list.
If you are entering the image data as a string or a list of strings then you can optionally turn off the
`parse_data` functionality by setting the ``parse_data`` keyword argument to ``False``. This is not recommended
however.
:param data: The image data to be stored in the :attr:`.images` list
:param parse_data: A flag to specify whether to attempt to parse the metadata automatically for the images
:param preprocessor: A flag to specify whether to run the preprocessor after loading an image.
:param metadata_only: A flag to specify to only load the metadata for an image, not the image data itself.
"""
if isinstance(data, (list, tuple)):
for datum in data:
image = self.image_check(datum, parse_data=parse_data, metadata_only=metadata_only)
if preprocessor:
self._images.append(self.preprocessor(image))
else:
self._images.append(image)
if getattr(self.model, 'estimate_multiple_misalignments', False):
if hasattr(self.model, 'misalignment'):
if isinstance(self.model.misalignment, list):
self.model.misalignment.append(np.zeros(3))
else:
self.model.misalignment = [self.model.misalignment]
self.model.misalignment.append(np.zeros(3))
try:
self._image_mask.append(True)
except AttributeError:
pass
else:
image = self.image_check(data, parse_data=parse_data, metadata_only=metadata_only)
self._images.append(self.preprocessor(image))
if getattr(self.model, 'estimate_multiple_misalignments', False):
if hasattr(self.model, 'misalignment'):
if isinstance(self.model.misalignment, list):
self.model.misalignment.append(np.zeros(3))
try:
self._image_mask.append(True)
except AttributeError:
pass
[docs] def remove_images(self, images: Union[int, slice, Iterable[Union[int, slice]]]):
"""
This method is used to remove images from the :attr:`.images` list while also ensuring that the
:attr:`.image_mask` list remains the same size as the :attr:`.images` list.
This method is the only way that a user should remove images from a :class:`Camera` object after the object has
been initialized. It ensures that the :attr:`.images` and :attr:`.image_mask` lists do not get out of sync.
Images to be removed are specified by index, list of indices, or slice. The images are removed by using::
del self.images[ind]
del self.image_mask[ind]
If *images* is an iterable, it should be sorted in decreasing order to make sure the proper images
are removed.
:param images: The images to be removed from the camera, as either an index, a slice,
or a list of indices and slices
"""
if isinstance(images, Iterable):
for image in images:
del self._images[image]
del self._image_mask[image]
if getattr(self.model, 'estimate_multiple_misalignments', False):
if hasattr(self.model, 'misalignment'):
del self.model.misalignment[image]
else:
del self._images[images]
del self._image_mask[images]
if getattr(self.model, 'estimate_multiple_misalignments', False):
if hasattr(self.model, 'misalignment'):
del self.model.misalignment[images]
[docs] def image_check(self, data: Union[PATH, ARRAY_LIKE_2D],
parse_data: bool = True, metadata_only: bool = False) -> OpNavImage:
"""
This method is used to interpret the image data that is supplied by the user (either during initialization or
through the :meth:`add_images` method) and ensure that it is a subclass of :class:`.OpNavImage`
The input to this method should be a single representation of image data (either an :class:`OpNavImage`,
Sequence of Sequences, numpy array, or the path to the image file) and the output will be that representation
converted to an :class:`OpNavImage`. If you are entering the path to the image file then you can specify
the optional ``parse_data`` flag which is passed to the OpNavImage initialization function.
In general it may be desirable to override this method when the :class:`Camera` class is subclassed to customize
the functionality. If you simply want to use a subclass of :class:`.OpNavImage` with the same functionality
then you can simply override the ``default_image_class`` keyword argument to the constructor of this class.
:param data: The data to be converted into an :class:`.OpNavImage`
:param parse_data: A flag specifying whether to attempt to automatically parse the metadata for the image
:param metadata_only: A flag to specify to only load the metadata for an image, not the image data itself.
:return: The image data converted into an :class:`.OpNavImage` or one of its subclasses
"""
if isinstance(data, self._default_image_class):
image = data
elif isinstance(data, np.ndarray) or isinstance(data, list):
image = self._default_image_class(data, parse_data=False)
warnings.warn("The data you gave us is not the appropriate type.\n"
"We created one for you but please add in the a priori knowledge to the image class.\n"
"See the {} documentation for details.".format(self._default_image_class.__name__))
elif isinstance(data, (str, Path)):
if metadata_only:
image = self._default_image_class([], file=data, parse_data=parse_data)
else:
image = self._default_image_class(data, parse_data=parse_data)
else:
image = self._default_image_class(data)
warnings.warn("We're not sure what {0} type is.\n"
"We'll assume it's array like for now and hope for the best.\n"
"Please be sure that it is array like and \n"
"to specify the a priori knowledge to the image class.\n"
"See the OpNavImage documentation for details.".format(self._default_image_class.__name__))
return image
[docs] def preprocessor(self, image: OpNavImage) -> OpNavImage:
"""
This method is used to globally apply corrections to all images contained in the :class:`Camera` instance.
The corrections applied by this method generally include things like image flips/transposes to put the image
into the proper orientation that GIANT expects, dark frame removal to flatten the responsivity of the images
and other basic image processing steps that apply the same to all images. The only input to this method is
the image itself as an :class:`.OpNavImage` subclass and the method should return the corrected
:class:`OpNavImage` subclass (preserving the metadata).
This method is applied once, immediately after loading the image.
:param image: The image to apply the preprocessor corrections to
:return: The corrected image
"""
return image
def _determine_closest_image(self, ind: int, image: OpNavImage) -> OpNavImage:
"""
This private method determines the closest (in time) long exposure image to a given short exposure image.
Note that this does not ensure that the returned image is long exposure so you should check yourself.
:param ind: The index into the :attr:`.images` list of the short exposure image being updated
:param image: The actual short exposure image object being updated.
:return: The closest image
"""
# if we are at the beginning or end of the images list then we only have one option to check
if ind == 0:
next_ind = 1
elif ind == (len(self._images) - 1):
next_ind = ind - 1
else:
# if the previous image is short then only check the following image
if self.images[ind - 1].exposure_type == ExposureType.SHORT:
next_ind = ind + 1
# if the following image is short then only check the previous image
elif self.images[ind + 1].exposure_type == ExposureType.SHORT:
next_ind = ind - 1
# otherwise both are long. Choose the one with the smallest time difference
else:
# check if they both have estimated attitude
if self.images[ind - 1].pointing_post_fit and (not self.images[ind + 1].pointing_post_fit):
next_ind = ind-1
elif self.images[ind + 1].pointing_post_fit and (not self.images[ind - 1].pointing_post_fit):
next_ind = ind + 1
else:
# 2*np.argmin - 1 will either give -1 (previous image) or 1 (next image)
delta = 2 * np.argmin([abs(self.images[ind-1].observation_date - image.observation_date),
abs(self.images[ind+1].observation_date - image.observation_date)]) - 1
next_ind = ind + delta
# return the image we are considering
return self.images[next_ind]
def _replace(self, ind: int, image: OpNavImage, max_delta: timedelta):
"""
This private method applies the replace method to update short exposure attitude information from surrounding
long exposure images.
This method works on a single image to determine which of the 2 (or 1) surrounding long exposure images
are closest in time to the supplied short exposure image (within a maximum time difference of *timedelta*.
It then simply copies the long exposure attitude to the short exposure image.
If we are successful at updating a short exposure image using this method, then the
:attr:`.OpNavImage.pointing_post_fit` flag is updated to be ``True``. Otherwise it is set to ``False``.
:param ind: The index into the :attr:`.images` list of the short exposure image being updated
:param image: The actual short exposure image object being updated.
:param max_delta: The maximum time difference allowed between the short exposure and long exposure images for
an update to be made
"""
next_image = self._determine_closest_image(ind, image)
# if this image is a short exposure (only happens if both surrounding images are short exposure)
# throw a warning and do nothing
if next_image.exposure_type == ExposureType.SHORT:
warnings.warn("A short image cannot be preceded or followed by another short image to use "
"replace quaternion")
image.pointing_post_fit = False
return
if not next_image.pointing_post_fit:
warnings.warn("The attitude of the next image has not been estimated. Unable to replace quaternion.")
image.pointing_post_fit = False
return
# if the difference between the short and long exposure image is too long
# throw a warning and do nothing
diff = abs(next_image.observation_date - image.observation_date)
if diff > max_delta:
warnings.warn("Two images are separated by too large of a time difference to use replace."
"Diff {} between {} and {}".format(diff, image.observation_date, next_image.observation_date))
image.pointing_post_fit = False
return
# copy the long exposure attitude to the short exposure attitude
image.rotation_inertial_to_camera = next_image.rotation_inertial_to_camera.copy()
image.pointing_post_fit = True
# noinspection PyTypeChecker
def _propagate_attitude(self, ind, image, max_delta):
"""
This private method applies the delta quaternion method to update short exposure attitude information from
surrounding long exposure images.
This method works on a single image to determine which of the 2 (or 1) surrounding long exposure images
are closest in time to the supplied short exposure image (within a maximum time difference of *timedelta*.
It then queries the :attr:`.attitude_function` to get the change in the pointing between the two images and
applies this delta to the long exposure attitude to update the short exposure attitude.
If we are successful at updating a short exposure image using this method, then the
:attr:`.OpNavImage.pointing_post_fit` flag is updated to be ``True``. Otherwise it is set to ``False``.
:param ind: The index into the :attr:`.images` list of the short exposure image being updated
:param image: The actual short exposure image object being updated.
:param max_delta: The maximum time difference allowed between the short exposure and long exposure images for
an update to be made
"""
next_image = self._determine_closest_image(ind, image)
# if this image is a short exposure (only happens if both surrounding images are short exposure)
# throw a warning and do nothing
if next_image.exposure_type == ExposureType.SHORT:
warnings.warn("A short image cannot be both preceded and followed by another short image to use "
"delta quaternion")
image.pointing_post_fit = False
return
if not next_image.pointing_post_fit:
warnings.warn("The attitude of the next image has not been estimated. Unable to use delta quaternion.")
image.pointing_post_fit = False
return
# if the difference between the short and long exposure image is too long
# throw a warning and do nothing
diff = abs(next_image.observation_date - image.observation_date)
if diff > max_delta:
warnings.warn("Two images are separated by too large of a time difference to use delta quaternion."
"Diff {} between {} and {}".format(diff, image.observation_date, next_image.observation_date))
image.pointing_post_fit = False
return
att_prev = self.attitude_function(next_image.observation_date) # type: Rotation
att_curr = self.attitude_function(image.observation_date) # type: Rotation
# compute the delta quaternion between the long exposure and short exposure image.
delta_q = att_curr * att_prev.inv()
# apply the updated delta quaternion
image.rotation_inertial_to_camera = delta_q * next_image.rotation_inertial_to_camera
image.pointing_post_fit = True
def _interp(self, ind, image, max_delta):
"""
This private method applies the interpolate quaternion method to a given short exposure image.
If a short exposure image is not surrounded by long exposure images then the quaternion interpolation will not
work and we fall back to replace. We also fall back to replace if one of the 2 surrounding images is too far
away (time) from the short exposure image.
If all conditions are met then the interpolation method performs spherical linear interpolation between the two
long exposure images to get the updated attitude for the short exposure image (see :func:`.slerp`).
If we are successful at updating a short exposure image using this method, then the
:attr:`.OpNavImage.pointing_post_fit` flag is updated to be ``True``. Otherwise it is set to ``False``.
:param ind: The index into the :attr:`.images` list of the short exposure image being updated
:param image: The actual short exposure image object being updated.
:param max_delta: The maximum time difference allowed between the short exposure and long exposure images for
an update to be made
"""
if image.exposure_type == ExposureType.SHORT:
if ind == 0:
warnings.warn('A short image is first in the image list, falling back to replace method')
self._replace(ind, image, max_delta)
elif ind == (len(self.images) - 1):
warnings.warn('A short image is last in the image list, falling back to replace method')
self._replace(ind, image, max_delta)
else:
image_prev = self.images[ind - 1]
image_next = self.images[ind + 1]
if image_prev.exposure_type == ExposureType.SHORT:
warnings.warn("A short image precedes a short image, falling back to replace method")
self._replace(ind, image, max_delta)
return
if not image_prev.pointing_post_fit:
warnings.warn(
"The attitude of the preceding image has not been estimated. Falling back to replace method.")
self._replace(ind, image, max_delta)
return
if image_next.exposure_type == ExposureType.SHORT:
warnings.warn("A short image follows a short image, falling back to replace method")
self._replace(ind, image, max_delta)
return
if not image_next.pointing_post_fit:
warnings.warn(
"The attitude of the next image has not been estimated. Falling back to replace method.")
self._replace(ind, image, max_delta)
return
if abs(image.observation_date - image_prev.observation_date) > max_delta:
warnings.warn("the time delta between two images is larger than the maximum time delta."
"Falling back to replace method.")
self._replace(ind, image, max_delta)
elif abs(image_next.observation_date - image.observation_date) > max_delta:
warnings.warn("the time delta between two images is larger than the maximum time delta."
"Falling back to replace method.")
self._replace(ind, image, max_delta)
else:
image.rotation_inertial_to_camera = Rotation(slerp(image_prev.rotation_inertial_to_camera,
image_next.rotation_inertial_to_camera,
image.observation_date,
image_prev.observation_date,
image_next.observation_date))
image.pointing_post_fit = True
[docs] def update_short_attitude(self,
method: Union[str, AttitudeUpdateMethods] = AttitudeUpdateMethods.INTERPOLATE,
max_delta: timedelta = timedelta(minutes=5)):
r"""
This method updates the attitude metadata for short exposure images based off of the solved for attitudes in
the long-exposure images.
There are three different techniques that you can use to update the short exposure attitudes which are selected
using the `method` key word argument. The first technique, ``'propagate'``, "propagates" the attitude
from a long exposure image to the short exposure image using a delta quaternion. The delta quaternion is
calculated using the :attr:`.attitude_function` and is computed using
.. math::
\delta\mathbf{q}=\mathbf{q}_{sf}\otimes\mathbf{q}_{lf}^{-1}
where :math:`\delta\mathbf{q}` is the delta quaternion, :math:`\mathbf{q}_{sf}` is the attitude quaternion
at the short exposure image time according to the :attr:`.attitude_function`, :math:`\mathbf{q}_{lf}^{-1}` is
the inverse of the attitude quaternion for the long exposure image closest (in time) to the short exposure
image according to the :attr:`.attitude_function`, and :math:`\otimes` is quaternion multiplication. The delta
quaternion is applied according to
.. math::
\mathbf{q}_{ss}=\delta\mathbf{q}\otimes\mathbf{q}_{ls}
where :math:`\mathbf{q}_{ss}` is the solved for attitude for the short exposure image and
:math:`\mathbf{q}_{ls}` is the solved for attitude for the long exposure image closest (in time) to the short
exposure image. This means that to use this method short exposure images must be either preceded or followed by
a long exposure image in the :attr:`.images` list.
The next potential method is ``'interpolate'``. In interpolate, the attitude of a short exposure image that is
sandwiched between 2 long exposure images is updated by using the SLERP quaternion interpolation method. The
SLERP quaternion interpolation method is described in :func:`.slerp` function documentation. In order to use
the ``'interpolate'`` method all turned on short exposure images must be immediately preceded and followed by
long exposure images.
The final potential method is ``'replace'``. In the ``'replace'`` method, the attitude for short exposure
images are replaced with the attitude from the closest (in time) long exposure image to them from the
:attr:`.images` list. In order to use the `'replace`' method every turned on short exposure image must be
preceded or followed by a long exposure image.
If we are successful at updating a short exposure image using this method, then the
:attr:`.OpNavImage.pointing_post_fit` flag is updated to be ``True`` for the corresponding image.
Otherwise it is set to ``False``.
.. note::
The attitude is only updated for "short" exposure images that are turned on (it does not matter if the long
exposure images are turned on or off).
:param method: The method to use to update the attitude for the turned on short exposure images
:param max_delta: The maximum time difference allowed between 2 images for them to be paired as a timedelta
object
"""
if isinstance(method, str):
method = AttitudeUpdateMethods(method.lower())
if method == AttitudeUpdateMethods.PROPAGATE:
if callable(self.attitude_function):
func = self._propagate_attitude
else:
raise ValueError("attitude_function must be callable to use propagate")
elif method == AttitudeUpdateMethods.INTERPOLATE:
func = self._interp
elif method == AttitudeUpdateMethods.REPLACE:
func = self._replace
else:
raise ValueError("Couldn't understand method of {}".format(method))
for ind, image in self:
if image.exposure_type == ExposureType.SHORT:
func(ind, image, max_delta)
[docs] def update_attitude_from_function(self):
"""
This method is used ot overwrite the attitude information stored in all images that are turned on with
information from the :attr:`.attitude_function`.
For each turned on image, the attitude function is queried with the :attr:`~.OpNavImage.observation_date`
attribute of the image and the resulting Rotation object is set as the new :attr:`.rotation_inertial_to_camera`
for that image. The image attitude are updated regardless of their exposure type as long as they are turned on.
When we update the attitude for an image using this method we set the :attr:`.OpNavImage.pointing_post_fit`
flag to ``False`` for the corresponding image.
:raises: ValueError if the :attr:`.attitude_function` is not callable.
"""
if not callable(self.attitude_function):
raise ValueError("attitude_function must be callable to use update_attitude_from_file")
for _, image in self:
image.rotation_inertial_to_camera = self.attitude_function(image.observation_date)
image.pointing_post_fit = False