Module pyaurorax.tools.mosaic
Prepare data and create mosaics.
Expand source code
# Copyright 2024 University of Calgary
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Prepare data and create mosaics.
"""
from ._prep_skymaps import prep_skymaps
from ._prep_images import prep_images
from ._create import create
__all__ = [
"prep_skymaps",
"prep_images",
"create",
]
Functions
def create(prepped_data: Union[MosaicData, List[MosaicData]], prepped_skymap: Union[MosaicSkymap, List[MosaicSkymap]], timestamp: datetime.datetime, cartopy_projection: cartopy.crs.Projection, min_elevation: Union[int, List[int]] = 5, colormap: Union[str, List[str], ForwardRef(None)] = None, image_intensity_scales: Union[List, Dict, ForwardRef(None)] = None) ‑> Mosaic
-
Create a mosaic object.
Args
prepped_data
:MosaicData
- The prepared mosaic data. Generated from a prior
prep_images()
function call. prepped_skymap
:MosaicSkymap
- The prepared skymap data. Generated from a prior
prep_skymaps()
function call. timestamp
:datetime.datetime
- The timestamp to generate a mosaic for. Must be within the range of timestamps for which image data was prepped and provided.
cartopy_projection
:cartopy.crs.Projection
- The cartopy projection to use when creating the mosaic.
min_elevation
:int
- The minimum elevation cutoff when projecting images on the map, in degrees. Default is
5
. cbar_colorcmap
:str
-
The matplotlib colormap to use for the rendered image data. Default is
gray
.Commonly used colormaps are:
- REGO:
gist_heat
- THEMIS ASI:
gray
- TREx Blue:
Blues_r
- TREx NIR:
gray
- TREx RGB:
None
A list of all available colormaps can be found on the matplotlib documentation.
- REGO:
image_intensity_scaled
:List
orDict
-
Ranges for scaling images. Either a a list with 2 elements which will scale all sites with the same range, or as a dictionary which can be used for scaling each site differently.
Example of uniform scaling across all sites:
image_intensity_scales = [2000, 8000]
Example of scaling each site differently:
image_intensity_scales = {"fsmi": [1000, 10000], "gill": [2000, 8000]}
Returns
The generated
Mosaic
object.Raises
ValueError
- issues with supplied parameters.
Expand source code
def create(prepped_data: Union[MosaicData, List[MosaicData]], prepped_skymap: Union[MosaicSkymap, List[MosaicSkymap]], timestamp: datetime.datetime, cartopy_projection: cartopy.crs.Projection, min_elevation: Union[int, List[int]] = 5, colormap: Optional[Union[str, List[str]]] = None, image_intensity_scales: Optional[Union[List, Dict]] = None) -> Mosaic: """ Create a mosaic object. Args: prepped_data (pyaurorax.tools.MosaicData): The prepared mosaic data. Generated from a prior `prep_images()` function call. prepped_skymap (pyaurorax.tools.MosaicSkymap): The prepared skymap data. Generated from a prior `prep_skymaps()` function call. timestamp (datetime.datetime): The timestamp to generate a mosaic for. Must be within the range of timestamps for which image data was prepped and provided. cartopy_projection (cartopy.crs.Projection): The cartopy projection to use when creating the mosaic. min_elevation (int): The minimum elevation cutoff when projecting images on the map, in degrees. Default is `5`. cbar_colorcmap (str): The matplotlib colormap to use for the rendered image data. Default is `gray`. Commonly used colormaps are: - REGO: `gist_heat` - THEMIS ASI: `gray` - TREx Blue: `Blues_r` - TREx NIR: `gray` - TREx RGB: `None` A list of all available colormaps can be found on the [matplotlib documentation](https://matplotlib.org/stable/gallery/color/colormap_reference.html). image_intensity_scaled (List or Dict): Ranges for scaling images. Either a a list with 2 elements which will scale all sites with the same range, or as a dictionary which can be used for scaling each site differently. Example of uniform scaling across all sites: `image_intensity_scales = [2000, 8000]` Example of scaling each site differently: `image_intensity_scales = {"fsmi": [1000, 10000], "gill": [2000, 8000]}` Returns: The generated `pyaurorax.tools.Mosaic` object. Raises: ValueError: issues with supplied parameters. """ # init coordinates transformer # # To convert from geodetic coordinates onto the map projection, we use pyproj instead # of cartopy's native transformations. This is an optimization. pyproj_src_proj = pyproj.CRS.from_user_input(cartopy.crs.Geodetic()) pyproj_des_proj = pyproj.CRS.from_user_input(cartopy_projection) transformer = pyproj.Transformer.from_crs(pyproj_src_proj, pyproj_des_proj, always_xy=True) # Convert data, skymaps, colormap indicators to lists for iteration purposed if not isinstance(prepped_data, list): prepped_data = [prepped_data] if not isinstance(prepped_skymap, list): prepped_skymap = [prepped_skymap] if not isinstance(colormap, list): if colormap is None: colormap = [] for _ in range(len(prepped_data)): colormap.append('gray') else: colormap = [colormap] if not isinstance(min_elevation, list): tmp = [] for _ in range(len(prepped_data)): tmp.append(min_elevation) min_elevation = tmp # Make sure all lists are same length if (len(prepped_data) != len(prepped_skymap)): raise ValueError("When passing lists of prepped data and prepped skymap, they must be of the same length.") if (len(prepped_data) != len(colormap)) or (len(prepped_skymap) != len(colormap)): raise ValueError("List of colormaps must have same length as lists of prepped data and prepped skymaps.") # Itarate through each set of prepped data, prepped skymap img_poly_list = [] for mosaic_data_idx in range(len(prepped_data)): data = prepped_data[mosaic_data_idx] skymap = prepped_skymap[mosaic_data_idx] iter_cmap = colormap[mosaic_data_idx] min_el = min_elevation[mosaic_data_idx] # get sites site_list = data.site_uid_list # set image intensity scales if (image_intensity_scales is None): # defaults to scaling all sites between 0-20000 image_intensity_scales = {} for site_uid in skymap.site_uid_list: image_intensity_scales[site_uid] = [__DEFAULT_SCALE_MIN, __DEFAULT_SCALE_MAX] elif (isinstance(image_intensity_scales, list) is True): image_intensity_scales_dict = {} for site_uid in site_list: image_intensity_scales_dict[site_uid] = image_intensity_scales image_intensity_scales = image_intensity_scales_dict elif (isinstance(image_intensity_scales, dict) is True): # no action needed pass else: raise ValueError("Invalid image_intensity_scales format. Please refer to the documentation for this function.") # We need a numpy array of the sites requested, that will be used to make sure any sites # that don't have data for the requested frame are not plotted. Also empty dict for images.. site_list_arr = np.array(site_list) # all_images = np.zeros([len(site_list), width * height, __N_CHANNELS], dtype=np.int32) all_images = {} # Grab the elevation, and filling lats/lons elev = skymap.elevation polyfill_lon = skymap.polyfill_lon polyfill_lat = skymap.polyfill_lat # Now we begin to fill in the above arrays, one site at a time. Before doing so # we need lists to keep track of which sites actually have data for this frame. sites_with_data = [] sites_with_data_idx = [] # Determine the frame index of data the corresponds to the requested timestamp minimum_timestamp = (np.array(data.timestamps))[np.argmin(np.array(data.timestamps))] maximum_timestamp = (np.array(data.timestamps))[np.argmax(np.array(data.timestamps))] if timestamp < minimum_timestamp or timestamp > maximum_timestamp: raise ValueError("Could not create mosaic for timestamp" + timestamp.strftime("%Y/%m/%d %H:%M:%S") + " as image data was only supplied for the timestamp range: " + minimum_timestamp.strftime("%Y/%m/%d %H:%M:%S") + " to " + maximum_timestamp.strftime("%Y/%m/%d %H:%M:%S")) # Get the frame index of the timestamp closest to desired mosaic frame frame_idx = np.argmin(np.abs(np.array(data.timestamps) - timestamp)) # We also define a list that will hold all unique timestamps pulled from each # frame's metadata. This should be of length 1, and we can check that to make # sure all images being plotted correspond to the same time. unique_timestamps = [] n_channels_dict = {} for site in site_list: # set image dimensions height = data.images_dimensions[site][0] width = data.images_dimensions[site][1] # Grab the timestamp for this frame/site meta_timestamp = data.timestamps[frame_idx] # Determine whether current image is single or multi-channel, and add to dictionary for reference if len(data.images[site].shape) == 4: n_channels = data.images[site].shape[2] else: n_channels = 1 n_channels_dict[site] = n_channels # Now, obtain the frame of interest, for this site, from the image data and flatten it if n_channels == 1: img = data.images[site][:, :, frame_idx] flattened_img = np.reshape(img, (width * height)) else: img = data.images[site][:, :, :, frame_idx] flattened_img = np.reshape(img, (width * height, n_channels)) tmp = flattened_img if (np.sum(tmp) == 0.0): # If it's sum is zero, we know there is no data so we can simply continue. continue # Scale this site's data based on previously defined scaling bounds tmp = scale_intensity( tmp, min=image_intensity_scales[site][0], # type: ignore max=image_intensity_scales[site][1], # type: ignore top=255, memory_saver=False, ) # Add the timestamp to tracking list if it's unique if meta_timestamp not in unique_timestamps: unique_timestamps.append(meta_timestamp) # Append sites to respective lists, and add image data to master list sites_with_data.append(site) sites_with_data_idx.append(np.where(site_list_arr == site)[0][0]) all_images[site] = tmp.astype(np.int32) # This checks to make sure all images have the same timestamps if len(unique_timestamps) != 1: raise Exception("Error: Images have different timestamps.") # Create empty lists for tracking the pixel polygons and their values lon_list = [] lat_list = [] cmap_vals = [] # Set up elevation increment for plotting. We start at the min elevation # and plot groups of elevations until reaching 90 deg. elev_delta = 0.1 el = min_el # Iterate through all elevation ranges while el < 90: # Only iterate through the sites that actually have data for site_id, site_idx in zip(sites_with_data, sites_with_data_idx): # Get this sites number of channels n_channels = n_channels_dict[site_id] # Get all pixels within current elevation threshold el_idx = np.nonzero(np.logical_and(elev[site_idx] > el, elev[site_idx] <= el + elev_delta))[0] if len(el_idx) == 0: continue # Grab this level's filling lat/lons el_lvl_fill_lats = polyfill_lat[site_idx][:, el_idx] el_lvl_fill_lons = polyfill_lon[site_idx][:, el_idx] # Grab this level's data values if n_channels == 1: el_lvl_cmap_vals = all_images[site_id][el_idx] else: el_lvl_cmap_vals = all_images[site_id][el_idx, :] # # Mask any nans that may have slipped through - done as a precaution nan_mask = ~np.isnan(el_lvl_fill_lats).any(axis=0) & ~np.isnan(el_lvl_fill_lons).any(axis=0) el_lvl_fill_lats = el_lvl_fill_lats[:, nan_mask] el_lvl_fill_lons = el_lvl_fill_lons[:, nan_mask] if n_channels == 1: el_lvl_cmap_vals = el_lvl_cmap_vals[nan_mask] else: el_lvl_cmap_vals = el_lvl_cmap_vals[nan_mask, :] # Convert pixel values to a normalized float el_lvl_colors = el_lvl_cmap_vals.astype(np.float32) / 255.0 # Append polygon lat/lons and values to master lists if n_channels == 1: # print(1, len(el_lvl_fill_lats), len(el_lvl_colors)) cmap = plt.get_cmap(iter_cmap) for k in range(len(el_lvl_fill_lats[0, :])): lon_list.append(el_lvl_fill_lons[:, k]) lat_list.append(el_lvl_fill_lats[:, k]) cmap_vals.append(cmap(el_lvl_colors[k])) else: for k in range(len(el_lvl_fill_lats[0, :])): lon_list.append(el_lvl_fill_lons[:, k]) lat_list.append(el_lvl_fill_lats[:, k]) cmap_vals.append(el_lvl_colors[k, :]) el += elev_delta # Use our transformer object to convert the lat/lon polygons into projection coordinates. lons, lats = transformer.transform(np.array(lon_list), np.array(lat_list)) # Format polygons for creation of PolyCollection object lonlat_polygons = np.empty((lons.shape[0], 5, 2)) lonlat_polygons[:, :, 0] = lons lonlat_polygons[:, :, 1] = lats # generate a PolyCollection object, containing all of the Polygons shaded with # their corresponding RGB value img_data_poly = matplotlib.collections.PolyCollection( lonlat_polygons, # type: ignore facecolors=cmap_vals, array=None, clim=[0.0, 1.0], edgecolors="face", ) img_poly_list.append(img_data_poly) # cast into mosaic object if len(img_poly_list) == 1: mosaic = Mosaic(polygon_data=img_poly_list[0], cartopy_projection=cartopy_projection) else: mosaic = Mosaic(polygon_data=img_poly_list, cartopy_projection=cartopy_projection) # return return mosaic
def prep_images(image_list: List[pyucalgarysrs.data.classes.Data], data_attribute: Literal['data', 'calibrated_data'] = 'data') ‑> MosaicData
-
Prepare the image data for use in a mosaic.
Args
image_list
:List[Data]
- List of image data. Each element of the list is the data for each site.
data_attribute
:str
- The data attribute to use when prepping the images. Either
data
orcalibrated_data
. Default isdata
.
Returns
The prepared data, as a
MosaicData
object.Raises
ValueError
- issues encountered with supplied paramters.
Expand source code
def prep_images(image_list: List[Data], data_attribute: Literal["data", "calibrated_data"] = "data") -> MosaicData: """ Prepare the image data for use in a mosaic. Args: image_list (List[pyaurorax.data.ucalgary.Data]): List of image data. Each element of the list is the data for each site. data_attribute (str): The data attribute to use when prepping the images. Either `data` or `calibrated_data`. Default is `data`. Returns: The prepared data, as a `pyaurorax.tools.MosaicData` object. Raises: ValueError: issues encountered with supplied paramters. """ # set image dimensions and number of sites if (data_attribute == "data"): # check that the timestamp and calibrated data match in size for i in range(0, len(image_list)): if (image_list[i].data.shape[-1] != len(image_list[i].timestamp)): raise ValueError(("Number of frames does not match number of timestamp records. There are %d timestamp " + "records, and %d images for list index %d") % ( len(image_list[i].timestamp), image_list[i].data.shape[-1], i, )) # set height and width height = image_list[0].data.shape[0] width = image_list[0].data.shape[1] elif (data_attribute == "calibrated_data"): # check that the timestamp and calibrated data match in size for i in range(0, len(image_list)): if (image_list[i].calibrated_data.shape[-1] != len(image_list[i].timestamp)): raise ValueError(("Number of frames does not match number of timestamp records. There are %d timestamp " + "records, and %d images for list index %d") % ( len(image_list[i].timestamp), image_list[i].calibrated_data.shape[-1], i, )) else: raise ValueError("Invalid 'data_attribute' parameter. Must be either 'data' or 'calibrated_data'.") # determine the number of expected frames # # NOTE: this is done to ensure that the eventual image arrays are all the # same size, and we adequately account for dropped frames. # # Steps: # 1) finding the over-arching start and end times of data across all sites # 2) determine the cadence using the timestamps # 3) determine the number of expected frames using the cadence, start and end # start_dt = image_list[0].timestamp[0] end_dt = image_list[0].timestamp[-1] for site_data in image_list: this_start_dt = site_data.timestamp[0] this_end_dt = site_data.timestamp[-1] if (this_start_dt < start_dt): start_dt = this_start_dt if (this_end_dt > end_dt): end_dt = this_end_dt cadence = __determine_cadence(image_list[0].timestamp) curr_dt = start_dt.replace(microsecond=0) expected_num_frames = 0 expected_timestamps = [] while (curr_dt <= end_dt): expected_timestamps.append(curr_dt) expected_num_frames += 1 curr_dt += datetime.timedelta(seconds=cadence) # for each site site_uid_list = [] images_dict = {} for site_image_data in image_list: if (data_attribute == "data"): site_data = site_image_data.data elif (data_attribute == "calibrated_data"): site_data = site_image_data.calibrated_data # set image dimensions height = site_data.shape[0] width = site_data.shape[1] # Determine number of channels of image data if len(site_data.shape) == 4: n_channels = site_data.shape[2] else: n_channels = 1 # add to site uid list - must use try as metadata differs between networks try: site_uid = site_image_data.metadata[0]["site_unique_id"] except KeyError: try: site_uid = site_image_data.metadata[0]["Site unique ID"] except KeyError as e: raise KeyError("Unable to find site UID in Metadata") from e # We don't attempt to handle the same site being passed in for multiple networks if site_uid in images_dict.keys(): warnings.warn( "Same site between differing networks detected. Omitting additional '%s' data" % (site_uid), stacklevel=1, ) continue site_uid_list.append(site_uid) # initialize this site's data destination variables images_dict[site_uid] = np.squeeze(np.full((height, width, n_channels, expected_num_frames), np.nan)) # use binary search to find the index in the data corresponding to each # expected timestamp (we assume it is already sorted) for i in range(0, len(expected_timestamps)): searching_dt = expected_timestamps[i] found_idx = None low = 0 high = len(site_image_data.timestamp) - 1 while (low <= high): mid = low + (high - low) // 2 this_ts = site_image_data.timestamp[mid].replace(microsecond=0) if (this_ts == searching_dt): found_idx = mid break elif (this_ts > searching_dt): high = mid - 1 else: low = mid + 1 if (found_idx is None): # didn't find the timestamp, just move on because there will be no data # for this timestamp continue else: # found data for this timestamp if n_channels != 1: images_dict[site_uid][:, :, :, i] = site_data[:, :, :, found_idx] else: images_dict[site_uid][:, :, i] = site_data[:, :, found_idx] dimensions_dict = {} for site_uid, image in images_dict.items(): dimensions_dict[site_uid] = (image.shape[0], image.shape[1]) # cast into object prepped_data = MosaicData( site_uid_list=site_uid_list, timestamps=expected_timestamps, images=images_dict, images_dimensions=dimensions_dict, ) # return return prepped_data
def prep_skymaps(skymaps: List[pyucalgarysrs.data.classes.Skymap], height_km: int, site_uid_order: Optional[List[str]] = None, progress_bar_disable: bool = False, n_parallel: int = 1) ‑> MosaicSkymap
-
Prepare skymap data for use by the mosaic routine. This is not time-dependent, so it would only need to be done once.
Allows for plotting multiple images on a map, masking the boundaries between images by elevation angle.
Args
skymaps
:List[Skymap]
- The skymaps to prep.
height_km
:int
- The altitude to utilize, in kilometers.
site_uid_order
:List[str]
- The site list order. The order of this list is not important for plotting, but must be
consistent with the order of the
skymaps
parameter. progress_bar_disable
:bool
- Disable the progress bar. Defaults to
False
. n_parallel
:int
- Number of skymaps to prepare in parallel using multiprocessing. Default is
1
.
Returns
The prepared skymap data as a
MosaicSkymap
object.Raises
ValueError
- issues encountered with supplied parameters.
Expand source code
def prep_skymaps(skymaps: List[Skymap], height_km: int, site_uid_order: Optional[List[str]] = None, progress_bar_disable: bool = False, n_parallel: int = 1) -> MosaicSkymap: """ Prepare skymap data for use by the mosaic routine. This is not time-dependent, so it would only need to be done once. Allows for plotting multiple images on a map, masking the boundaries between images by elevation angle. Args: skymaps (List[pyaurorax.data.ucalgary.Skymap]): The skymaps to prep. height_km (int): The altitude to utilize, in kilometers. site_uid_order (List[str]): The site list order. The order of this list is not important for plotting, but must be consistent with the order of the `skymaps` parameter. progress_bar_disable (bool): Disable the progress bar. Defaults to `False`. n_parallel (int): Number of skymaps to prepare in parallel using multiprocessing. Default is `1`. Returns: The prepared skymap data as a `pyaurorax.tools.MosaicSkymap` object. Raises: ValueError: issues encountered with supplied parameters. """ # reorder the skymap list based on the site_uid_list supplied skymaps_sorted = [] site_uid_list = [] if (site_uid_order is not None): # need to specifically order the skymaps # # NOTE: can do an optimization here, but with only a few items # ever in this list, it doesn't matter. A O(N)^2 routine is # good enough. for site_uid in site_uid_order: for skymap in skymaps: if (site_uid == skymap.site_uid): skymaps_sorted.append(skymap) site_uid_list.append(site_uid) if (len(skymaps_sorted) != len(skymaps)): raise ValueError("Number of items in supplied skymaps and site_uid_order lists do not match, or " + "some site_uids specified in the order were not found. Unable to flatten skymaps due to this mismatch.") else: site_uid_list = [x.site_uid for x in skymaps] skymaps_sorted = skymaps # define empty numpy arrays for the lats, lons, and elevation angles of all of # the sites. Also define numpy arrays for 'filling' the pixel coordinates, which # will contain polygons vertices in lat/lon. elevation = [np.zeros((skymap.full_azimuth.shape[0] * skymap.full_azimuth.shape[1])) for skymap in skymaps_sorted] polyfill_lat = [np.zeros((5, skymap.full_azimuth.shape[0] * skymap.full_azimuth.shape[1])) for skymap in skymaps_sorted] polyfill_lon = [np.zeros((5, skymap.full_azimuth.shape[0] * skymap.full_azimuth.shape[1])) for skymap in skymaps_sorted] # set up processing objects processing_dicts = [] for i in range(0, len(skymaps_sorted)): processing_dicts.append({ "skymap": skymaps_sorted[i], "height_km": height_km, "i": i, }) if (n_parallel == 1): # don't do anything special, just a basic loop if (progress_bar_disable is True): # no progress bar for processing_dict in processing_dicts: results_dict = __flatten_skymap(processing_dict) elevation[results_dict["i"]] = results_dict["elevation"] polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"] polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"] else: # with progress bar for processing_dict in tqdm(processing_dicts, desc="Preparing skymaps: ", unit="skymap"): results_dict = __flatten_skymap(processing_dict) elevation[results_dict["i"]] = results_dict["elevation"] polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"] polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"] else: # multiple workers, do it in a multiprocessing loop if (progress_bar_disable is True): with ProcessPoolExecutor(max_workers=n_parallel) as executor: for results_dict in executor.map(__flatten_skymap, processing_dicts): elevation[results_dict["i"]] = results_dict["elevation"] polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"] polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"] else: results_dicts = tqdm_process_map( __flatten_skymap, processing_dicts, max_workers=n_parallel, chunksize=1, desc="Preparing skymaps: ", unit="skymap", tqdm_class=tqdm, ) for results_dict in results_dicts: elevation[results_dict["i"]] = results_dict["elevation"] polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"] polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"] # cast data into object flattened_skymap = MosaicSkymap( elevation=elevation, polyfill_lat=polyfill_lat, polyfill_lon=polyfill_lon, site_uid_list=site_uid_list, ) # return return flattened_skymap