# -*- mode: python; coding: utf-8 -*-
# Copyright 2019-2022 the .NET Foundation
# Licensed under the MIT License.
"""
An image, possibly tiled, for display in WWT.
"""
from __future__ import absolute_import, division, print_function
__all__ = """
ImageSet
""".split()
from argparse import Namespace
import math
from traitlets import Bool, Float, Instance, Int, Unicode, UseEnum
from . import LockedXmlTraits, XmlSer
from .abcs import UrlContainer
from .enums import Bandpass, DataSetType, ProjectionType
[docs]
class ImageSet(LockedXmlTraits, UrlContainer):
"""
A WWT imagery dataset.
Instances of this class express WWT imagery datasets and their spatial
positioning. Imagesets are exposed to the WWT engine through their XML
serialization in a WTML :class:`~wwt_data_formats.folder.Folder`. The engine
can be instructed to load such a folder, making its imagesets available for
rendering.
"""
data_set_type = UseEnum(DataSetType, default_value=DataSetType.SKY).tag(
xml=XmlSer.attr("DataSetType")
)
"""The renderer mode to which these data apply.
Possible values are ``"Earth"``, ``"Planet"``, ``"Sky"``, ``"Panorama"``,
``"SolarSystem"``, and ``"Sandbox"``.
"""
reference_frame = Unicode("").tag(xml=XmlSer.attr("ReferenceFrame"))
"""TBD."""
name = Unicode("").tag(xml=XmlSer.attr("Name"), xml_even_if_empty=True)
"""A name used to refer to this imageset.
Various parts of the WWT internals reference imagesets by this name, so
it should be distinctive.
"""
url = Unicode("").tag(xml=XmlSer.attr("Url"), xml_even_if_empty=True)
"""The URL of the image data.
Either a URL or a URL template. URLs that are exposed to the engine should
be absolute and use the ``http://`` protocol (the web engine will rewrite
them to HTTPS if needed). The ``wwtdatatool`` program that comes with this
package provides some helpful utilities to allow data-processing to use relative URLs. TODO: more
details.
"""
alt_url = Unicode("").tag(xml=XmlSer.attr("AltUrl"))
"""An alternative URL that provided the data.
If provided and the Windows client attempts to load an imageset using this
alternative URL, that imageset will be replaced by this one. This provides a
mechanism for superseding old imagesets with improved versions.
"""
dem_url = Unicode("").tag(xml=XmlSer.attr("DemUrl"))
"""The URL of the DEM data.
Either a URL or a URL template. TODO: details
"""
width_factor = Int(2).tag(xml=XmlSer.attr("WidthFactor"))
"""This is a legacy parameter. Leave it at 2."""
base_tile_level = Int(0).tag(xml=XmlSer.attr("BaseTileLevel"))
"""The level of the highest (coarsest-resolution) tiling available.
This should be zero except for special circumstances.
"""
quad_tree_map = Unicode("").tag(
xml=XmlSer.attr("QuadTreeMap"), xml_even_if_empty=True
)
"""TBD."""
tile_levels = Int(0).tag(xml=XmlSer.attr("TileLevels"))
"""The number of levels of tiling.
Should be zero for untiled images (``projection =
ProjectionType.SkyImage``).
For tiled images (``projection = ProjectionType.Tan``), an image with
``tile_levels = 1`` has been broken into four tiles, each 256x256 pixels.
For ``tile_levels = 2``, there are sixteen tiles, and the padded height of
the tiled area is ``256 * 2**2 = 1024`` pixels. Image with dimensions of
2048 pixels or smaller do not need to be tiled, so if this parameter is
nonzero it will usually be 4 or larger.
"""
base_degrees_per_tile = Float(0.0).tag(xml=XmlSer.attr("BaseDegreesPerTile"))
"""The angular scale of the image.
For untiled images, this is the pixel scale: the number of degrees per pixel
in the vertical direction. Non-square pixels are not supported.
For tiled images, this is the angular height of the image, in degrees, after
its dimensions have been padded out to the next largest power of 2 for
tiling purposes. If a square image is 1200 pixels tall and has a height of
0.016 deg, the padded height would be 2048 pixels and this parameter should
be set to 0.016 * 2048 / 1200 = 0.0273.
"""
file_type = Unicode(".png").tag(xml=XmlSer.attr("FileType"), xml_even_if_empty=True)
"""
The extension(s) of the image file(s) in this set.
In the simplest case, this field will contain an image filetype extension
including the leading period, such as ``.jpeg`` or ``.png``. Some datasets
in the wild lack the leading period: they have just ``png`` or something
similar. The value ``.auto`` is also used in some cases, which can be OK
because often WWT doesn't actually use this field for any particular
purpose.
Some datasets, like HiPS imagery, provide multiple filetypes simultaneously.
These can be expressed by including several filename extensions separated by
spaces. For instance, ``png jpeg fits``. The existing WTML records that
support multiple filetypes do not include any leading periods, but clients
should be prepared for them to be present.
Imagesets to be rendered as FITS data *must* have the exact value ``.fits``
for this field. If multiple filetypes are specified, the special
FITS-rendering machinery will not be invoked. This is true for both single
FITS files and tiled FITS imagesets, including HiPS FITS datasets.
A supported filetype extension of ``tsv`` (or ``.tsv``) means that this
"imageset" actually contains a HiPS progressive catalog, not bitmap imagery.
Imageset records should not intermix image-type and catalog-type filetypes.
(We don't know if there are any examples in the wild of HiPS datasets that
claim to contain both kinds of data.)
"""
bottoms_up = Bool(False).tag(xml=XmlSer.attr("BottomsUp"))
"""
The parity of the image's projection on the sky.
For untiled (``projection = SkyImage``) images, this flag defines the
image's parity, which basically sets whether the image needs to be flipped
during rendering. This field should be False for typical RGB color images
that map onto the sky as if you had taken them with a digital camera. For
these images, the first row of image data is at the top of the image at zero
rotation. For typical FITS files, on the other hand, the first row of image
data is at the bottom of the image, which results in a parity inversion. In
these cases, the ``bottoms_up`` flag should be True (hence its name). In the
terminology of `Astrometry.Net
<https://astroquery.readthedocs.io/en/latest/astrometry_net/astrometry_net.html#parity>`_,
``bottoms_up = False`` corresponds to negative parity, and ``bottoms_up =
True`` corresponds to positive parity.
The effect of setting this flag to True is to effectively flip the image and
its coordinate system left-to-right. For a ``bottoms_up = False`` image with
:attr:`offset_x`, :attr:`offset_y`, and :attr:`rotation_deg` all zero, the
lower-left corner of the image lands at the :attr:`center_x` and
:attr:`center_y`, and positive rotations rotate the image counter-clockwise
around that origin. If you take the same image and make ``bottoms_up =
True``, the image will appear to have been flipped left-to-right, the
lower-*right* corner of the image will land at the coordinate center, and
positive rotations will rotate it *clockwise* around that origin. In both
cases, positive values of :attr:`offset_x` and :attr:`offset_y` move the
center of the image closer to the coordinate center, but when ``bottoms_up =
False``, this means that the image is moving down and left, and when
``bottoms_up = True`` this means that the image is moving down and right.
For tiled images (``projection = Tan``), this field must be false. If it is
true, the imageset won't render.
"""
projection = UseEnum(ProjectionType, default_value=ProjectionType.SKY_IMAGE).tag(
xml=XmlSer.attr("Projection")
)
"""The type of projection used to place this image on the sky.
For untiled images, this should be "SkyImage". For tiled images, it should
be "Tan". The :meth:`set_position_from_wcs` method will set this value
appropriately based on :attr:`tile_levels`.
"""
center_x = Float(0.0).tag(xml=XmlSer.attr("CenterX"))
"""The horizontal location of the center of the image’s projection
coordinate system.
For sky images, this is a right ascension in degrees. Note that this
parameter just helps to define a coordinate system; it does not control how
the actual image data are placed onto that coordinate system. The
:attr:`offset_x` and :attr:`offset_y` parameters do that.
"""
center_y = Float(0.0).tag(xml=XmlSer.attr("CenterY"))
"""The vertical location of the center of the image’s projection coordinate
system.
For sky images, this is a declination in degrees. Note that this parameter
just helps to define a coordinate system; it does not control how the actual
image data are placed onto that coordinate system. The :attr:`offset_x` and
:attr:`offset_y` parameters do that.
"""
offset_x = Float(0.0).tag(xml=XmlSer.attr("OffsetX"), xml_omit_zero=True)
"""
The horizontal positioning of the image relative to its projection
coordinate system.
For untiled sky images with :attr:`bottoms_up` false, the image is by
default positioned such that its lower left corner lands at the center of
the projection coordinate system (namely, :attr:`center_x` and
:attr:`center_y`). The offset is measured in pixels and moves the image
leftwards. Therefore, ``offset_x = image_width / 2`` places the horizontal
center of the image at ``center_x``. This parameter is therefore analogous
to the WCS keyword ``CRVAL1``.
For untiled sky images where :attr:`bottoms_up` is true, the X coordinate
system has been mirrored. Therefore when this field is zero, the lower
*right* corner of the image will land at the center of the projection
coordinate system, and positive values will move the image to the right.
For tiled sky images, the offset is measured in *degrees*, and a value of
zero means that the *center* of the image lands at the center of the
projection coordinate system. Increasingly positive values move the image to
the right.
As per the usual practice, offsets are always along the horizontal axis of
the image in question, regardless of its :attr:`rotation <rotation_deg>` on
the sky.
"""
offset_y = Float(0.0).tag(xml=XmlSer.attr("OffsetY"), xml_omit_zero=True)
"""The vertical positioning of the image relative to its projection
coordinate system.
For untiled sky images with :attr:`bottoms_up` false, the image is by
default positioned such that its lower left corner lands at the center of
the projection coordinate system (namely, :attr:`center_x` and
:attr:`center_y`). The offset is measured in pixels and moves the image
downwards. Therefore, ``offset_y = image_height / 2`` places the vertical
center of the image at ``center_y``. This parameter is therefore analogous
to the WCS keyword ``CRVAL2``.
For untiled sky images where :attr:`bottoms_up` is true, the X coordinate
system has been mirrored but the Y coordinate system is the same. Therefore
when this field is zero, the lower *right* corner of the image will land at
the center of the projection coordinate system, but positive values will
still move the image downwards.
For tiled sky images, the offset is measured in *degrees*, and a value of
zero means that the *center* of the image lands at the center of the
projection coordinate system. Increasingly positive values move the image
upwards.
As per the usual practice, offsets are always along the vertical axis of the
image in question, regardless of its :attr:`rotation <rotation_deg>` on the
sky.
"""
rotation_deg = Float(0.0).tag(xml=XmlSer.attr("Rotation"))
"""
The rotation of image’s projection coordinate system, in degrees.
For sky images with :attr:`bottoms_up` false, this is East from North, i.e.
counterclockwise. If :attr:`bottoms_up` is true (only allowed for untiled
images), the image coordinate system is mirrored, and positive rotations
rotate the image *clockwise* relative to the sky.
"""
band_pass = UseEnum(Bandpass, default_value=Bandpass.VISIBLE).tag(
xml=XmlSer.attr("BandPass")
)
"""The bandpass of the image data."""
sparse = Bool(True).tag(xml=XmlSer.attr("Sparse"))
"""TBD."""
elevation_model = Bool(False).tag(xml=XmlSer.attr("ElevationModel"))
"""TBD."""
stock_set = Bool(False).tag(xml=XmlSer.attr("StockSet"))
"""TBD."""
generic = Bool(False).tag(xml=XmlSer.attr("Generic"))
"""TBD."""
mean_radius = Float(0.0).tag(xml=XmlSer.attr("MeanRadius"), xml_omit_zero=True)
"""TBD."""
credits = Unicode("").tag(xml=XmlSer.text_elem("Credits"))
"""Textual credits for the image originator."""
credits_url = Unicode("").tag(xml=XmlSer.text_elem("CreditsUrl"))
"""A URL giving the source of the image or more information about its creation."""
thumbnail_url = Unicode("").tag(
xml=XmlSer.text_elem("ThumbnailUrl"), xml_even_if_empty=True
)
"""A URL to a standard WWT thumbnail representation of this imageset."""
description = Unicode("").tag(xml=XmlSer.text_elem("Description"))
"""
A textual description of the imagery.
This field is referenced a few times in the original WWT documentation, but
is not actually implemented. The ``Place.description`` field is at least
loaded from the XML.
"""
msr_community_id = Int(0).tag(xml=XmlSer.attr("MSRCommunityId"), xml_omit_zero=True)
"""The ID number of the WWT Community that this content came from."""
msr_component_id = Int(0).tag(xml=XmlSer.attr("MSRComponentId"), xml_omit_zero=True)
"""The ID number of this content item on the WWT Communities system."""
permission = Int(0).tag(xml=XmlSer.attr("Permission"), xml_omit_zero=True)
"TBD."
pixel_cut_low = Float(0.0).tag(xml=XmlSer.attr("PixelCutLow"), xml_omit_zero=True)
"""Suggested default low cutoff value when displaying FITS."""
pixel_cut_high = Float(0.0).tag(xml=XmlSer.attr("PixelCutHigh"), xml_omit_zero=True)
"""Suggested default high cutoff value when displaying FITS."""
data_min = Float(0.0).tag(xml=XmlSer.attr("DataMin"), xml_omit_zero=True)
"""Lowest data value of a FITS file."""
data_max = Float(0.0).tag(xml=XmlSer.attr("DataMax"), xml_omit_zero=True)
"""Highest data value of a FITS file."""
xmeta = Instance(
Namespace,
args=(),
help="XML metadata - a namespace object for attaching arbitrary text to serialize",
).tag(xml=XmlSer.ns_to_attr("X"))
def _tag_name(self):
return "ImageSet"
[docs]
def mutate_urls(self, mutator):
if self.url:
self.url = mutator(self.url)
if self.dem_url:
self.dem_url = mutator(self.dem_url)
if self.credits_url:
self.credits_url = mutator(self.credits_url)
if self.thumbnail_url:
self.thumbnail_url = mutator(self.thumbnail_url)
[docs]
def set_position_from_wcs(self, headers, width, height, place=None, fov_factor=1.7):
"""
Set the positional information associated with this imageset to match a
set of WCS headers.
Parameters
----------
headers : :class:`~astropy.io.fits.Header` or string-keyed dict-like
A set of FITS-like headers including WCS keywords such as
``CRVAL1``.
width : positive integer
The width of the image associated with the WCS, in pixels.
height : positive integer
The height of the image associated with the WCS, in pixels.
place : optional :class:`~wwt_data_formats.place.Place`
If specified, the centering and zoom level of the
:class:`~wwt_data_formats.place.Place` object will be set to match
the center and size of this image.
fov_factor : optional
If *place* is provided, its zoom level will be set so that the
angular height of the client viewport is this factor times the
angular height of the image. The default is 1.7.
Returns
-------
**self**
For convenience in chaining function calls.
Notes
-----
Certain of the ImageSet parameters take on different meanings depending
on whether the image in question is a tiled "study" or not. This method
will alter its behavior depending on whether the :attr:`tile_levels`
attribute is greater than zero. **Make sure that this parameter has
acquired its final value before calling this function.**
For the time being, the WCS must be equatorial using the gnomonic
(``TAN``) projection.
This function may be used when the imageset :attr:`projection` is TOAST.
However, in this case most of the WCS parameters are irrelevant. The WCS
information is only used to apply basic centering and sizing settings.
Required keywords in *headers* are:
- ``CTYPE1`` and ``CTYPE2``
- ``CRVAL1`` and ``CRVAL2``
- ``CRPIX1`` and ``CRPIX2``
- Either:
- ``CD1_1``, ``CD2_2`` (preferred) or
- ``CDELT1``, ``CDELT2``, ``PC1_1``, and ``PC1_2``;
If present ``PC1_2``, ``PC2_1``, ``CD1_2``, and/or ``CD2_1`` are used.
If absent, they are assumed to be zero.
This routine assumes that the WCS coordinates have the correct parity
for the data that they describe. If these WCS coordinates describe a
JPEG-like image, the parity of the coordinates should be negative
("top-down"), which means that the determinant of the CD matrix should
have a *positive* sign. If these coordinates describe a FITS-like image,
the parity should be positive or "bottoms-up", with a negative
determinant of the CD matrix. If the image in question is tiled, the
parity must be top-down, in the sense the bottoms-up tiled imagery just
won't render in the engine. There are some CD matrices that can't be
expressed in WWT's formalism (rotation, scale, parity) and this method
will do its best to detect and reject them.
"""
if self.projection != ProjectionType.TOAST:
if headers["CTYPE1"] not in ("RA---TAN", "RA---TPV") or headers[
"CTYPE2"
] not in ("DEC--TAN", "DEC--TPV"):
raise ValueError(
"WCS coordinates must be in an equatorial/TAN projection"
)
# Figure out the stuff we need from the headers.
ra_deg = headers["CRVAL1"]
dec_deg = headers["CRVAL2"]
# In FITS/WCS, pixel coordinates are 1-based and integer pixel
# coordinates land on pixel centers. Therefore in standard FITS
# orientation, where the "first" pixel is at the lower-left, the
# lower-left corner of the image has pixel coordinate (0.5, 0.5). For
# the WWT offset parameters, the lower-left corner of the image has
# coordinate (0, 0).
refpix_x = headers["CRPIX1"] - 0.5
refpix_y = headers["CRPIX2"] - 0.5
if "CD1_1" in headers:
cd1_1 = headers["CD1_1"]
cd2_2 = headers["CD2_2"]
cd1_2 = headers.get("CD1_2", 0.0)
cd2_1 = headers.get("CD2_1", 0.0)
else:
# older PC/CDELT form -- note that we're using two additional
# numbers to express the same information.
d1 = headers["CDELT1"]
d2 = headers["CDELT2"]
cd1_1 = d1 * headers.get("PC1_1", 1.0)
cd2_2 = d2 * headers.get("PC2_2", 1.0)
cd1_2 = d1 * headers.get("PC1_2", 0.0)
cd2_1 = d2 * headers.get("PC2_1", 0.0)
cd_det = cd1_1 * cd2_2 - cd1_2 * cd2_1
if cd_det < 0:
cd_sign = -1
if self.tile_levels > 0 and self.projection != ProjectionType.TOAST:
raise Exception(
"WCS for tiled imagery must have top-down/negative/JPEG_like parity"
)
else:
cd_sign = 1
# Given how WWT implements its rotation coordinates, this expression
# turns out to give us the correct value for different rotations and
# parities:
rot_rad = math.atan2(-cd_sign * cd1_2, -cd2_2)
# If we're in TOAST projection, we're only using the WCS to assign basic
# centering and sizing attributes, and the details of the projection are
# unimportant.
#
# In other cases, they do matter. We can only express square pixels with
# a rotation and a potential parity flip. Do some cross-checks to ensure
# that the input matrix can be well-approximated this way. There's
# probably a smarter linear-algebra way to do this.
TOL = 0.05
scale_x = math.sqrt(cd1_1**2 + cd1_2**2)
scale_y = math.sqrt(cd2_1**2 + cd2_2**2)
if self.projection != ProjectionType.TOAST:
if not abs(cd_det) > 0:
raise ValueError("determinant of the CD matrix zero or ill-defined")
if abs(scale_x - scale_y) / (scale_x + scale_y) > TOL:
raise ValueError(
"WWT cannot express non-square pixels, which this WCS has"
)
det_scale = math.sqrt(abs(cd_det))
if abs((cd1_1 - cd_sign * cd2_2) / det_scale) > TOL:
raise ValueError(
f"WWT cannot express this CD matrix (1; {cd1_1} {cd_sign} {cd2_2} {det_scale})"
)
if abs((cd2_1 + cd_sign * cd1_2) / det_scale) > TOL:
raise ValueError(
f"WWT cannot express this CD matrix (2; {cd2_1} {cd_sign} {cd1_2} {det_scale})"
)
# This is our best effort to make sure that the view centers on the
# center of the image.
try:
from astropy.wcs import WCS
except:
center_ra_deg = ra_deg
center_dec_deg = dec_deg
else:
wcs = WCS(headers)
# The WCS object uses 0-based indices where the integer coordinates
# land on pixel centers. Therefore the dead center of the image has
# pixel coordinates as below. For instance, in an image 2 pixels
# wide, the horizontal center is where the two pixels touch, which
# has an X coordinate of 0.5.
center = wcs.pixel_to_world((width - 1) / 2, (height - 1) / 2)
center_ra_deg = center.ra.deg
center_dec_deg = center.dec.deg
# Now, assign the fields
self.data_set_type = DataSetType.SKY
self.width_factor = 2
self.center_x = ra_deg
self.center_y = dec_deg
self.rotation_deg = rot_rad * 180 / math.pi
if self.projection != ProjectionType.TOAST:
if self.tile_levels > 0: # are we tiled?
self.projection = ProjectionType.TAN
self.bottoms_up = False
self.base_degrees_per_tile = scale_y * 256 * 2**self.tile_levels
# In the tiled case, the proper setting of `offset_[xy]` depends
# on how exactly the source image will be placed onto the tile
# coordinate system. For instance, if the source image were only
# 10x10 pixels, you could drop it just about anywhere within a
# 256x256 tile, and you'd have to adjust these settings to
# match. There's one obvious preferred choice: place the image
# right at the center of the tiling pixelization, which makes
# the calculation easy. One subtlety, though, is what to do when
# the image size is odd, in which case it has to be shifted a
# half-pixel over from "optimal" centering. The expressions here
# give correct placement in conjunction with the "study" code in
# Toasty.
self.offset_x = ((width + 1) // 2 - refpix_x) * scale_x
self.offset_y = (refpix_y - (height + 1) // 2) * scale_y
else:
self.projection = ProjectionType.SKY_IMAGE
self.bottoms_up = cd_sign == -1
self.offset_x = refpix_x
self.offset_y = height - refpix_y
self.base_degrees_per_tile = scale_y
if self.bottoms_up:
self.rotation_deg = -self.rotation_deg
if place is not None:
place.set_ra_dec(center_ra_deg / 15.0, center_dec_deg)
place.rotation_deg = (
0.0 # I think this is better than propagating the image rotation?
)
# It is hardcoded that in sky mode, zoom_level = height of client FOV * 6.
place.zoom_level = height * scale_y * fov_factor * 6
return self