Merge pull request #61 from pkgw/fits-study

Fix up tile-study with FITS files
This commit is contained in:
Peter Williams 2021-09-17 19:34:46 +00:00 коммит произвёл GitHub
Родитель bf509529fe 2a339443b8
Коммит 3d23dd7ec7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 266 добавлений и 39 удалений

Просмотреть файл

@ -104,6 +104,8 @@ jobs:
pyavm \
pytest-cov \
pyyaml \
reproject \
shapely \
tqdm
pip install wwt_data_formats
pip install -e .

Просмотреть файл

@ -23,9 +23,11 @@ Builder
~Builder.cascade
~Builder.create_wtml_folder
~Builder.default_tiled_study_astrometry
~Builder.execute_study_tiling
~Builder.load_from_wwtl
~Builder.make_placeholder_thumbnail
~Builder.make_thumbnail_from_other
~Builder.prepare_study_tiling
~Builder.set_name
~Builder.tile_base_as_study
~Builder.toast_base
@ -44,9 +46,11 @@ Builder
.. automethod:: cascade
.. automethod:: create_wtml_folder
.. automethod:: default_tiled_study_astrometry
.. automethod:: execute_study_tiling
.. automethod:: load_from_wwtl
.. automethod:: make_placeholder_thumbnail
.. automethod:: make_thumbnail_from_other
.. automethod:: prepare_study_tiling
.. automethod:: set_name
.. automethod:: tile_base_as_study
.. automethod:: toast_base

Просмотреть файл

@ -71,7 +71,74 @@ class Builder(object):
raise Exception('order-of-operations error: you must apply WCS after applying tiling settings')
def prepare_study_tiling(self, image):
"""
Set up to tile the specified image as a WWT "study".
Parameters
----------
image : `toasty.image.Image`
The image that will be tiled
Returns
-------
tiling : `toasty.study.StudyTiling`
The prepared tiling information
Remarks
-------
After calling this method, you should set up the WCS for the tiled
imagery, using :meth:`default_tiled_study_astrometry` as a backstop if
no real information is available. Then use :meth:`execute_study_tiling`
to actually perform the tiling process.
"""
from .study import StudyTiling
tiling = StudyTiling(image.width, image.height)
tiling.apply_to_imageset(self.imgset)
return tiling
def execute_study_tiling(self, image, tiling, **kwargs):
"""
Tile the specified image as a WWT "study".
Parameters
----------
image : `toasty.image.Image`
The image that will be tiled
tiling : `toasty.study.StudyTiling`
The prepared tiling information
**kwargs
Arguments relayed to :meth:`toasty.study.StudyTiling.tile_image`,
such as ``cli_progress``.
Returns
-------
*self*
"""
tiling.tile_image(image, self.pio, **kwargs)
return self
def tile_base_as_study(self, image, **kwargs):
"""
Tile an image assuming that it is in the appropriate format for WWT's
"study" framework, namely that it uses a tangential (gnomonic)
projection on the sky.
Use of this method is somewhat discouraged since it both analyzes and
performs the tiling all at once, which means that you can only correctly
set (and validate) the WCS information *after* doing all the work of
tiling. (Which in turn is because the proper way to apply WCS
information to an imageset depends on the tiling parameters.) It is
generally better to use :meth:`prepare_study_tiling` and
:meth:`execute_study_tiling`, applying the WCS metadata in between, so
that WCS errors can be caught and reported before doing the I/O.
"""
from .study import tile_study_image
self._check_no_wcs_yet()

Просмотреть файл

@ -418,6 +418,13 @@ def tile_study_impl(settings):
pio = PyramidIO(settings.outdir, default_format=img.default_format)
builder = Builder(pio)
# First, prepare the tiling, because we can't correctly apply WCS until we
# know the details of the tiling parameters
tiling = builder.prepare_study_tiling(img)
# Now deal with the WCS
if settings.avm:
# We don't *always* check for AVM because pyavm prints out a lot of junk
# and may not be installed.
@ -433,22 +440,26 @@ def tile_study_impl(settings):
except Exception:
print(f'error: failed to read AVM tags of input `{settings.imgpath}`', file=sys.stderr)
raise
builder.apply_avm_info(avm, img.width, img.height)
elif img.wcs is not None:
# Study images must have negative parity.
img.ensure_negative_parity()
builder.apply_wcs_info(img.wcs, img.width, img.height)
else:
builder.default_tiled_study_astrometry()
# Do the thumbnail first since for large inputs it can be the memory high-water mark!
# Do the thumbnail before the main image since for large inputs it can be
# the memory high-water mark!
if settings.placeholder_thumbnail:
builder.make_placeholder_thumbnail()
else:
builder.make_thumbnail_from_other(img)
builder.tile_base_as_study(img, cli_progress=True)
if settings.avm:
builder.apply_avm_info(avm, img.width, img.height)
elif img.wcs is not None:
builder.apply_wcs_info(img.wcs, img.width, img.height)
# Finally, actually tile the image
builder.execute_study_tiling(img, tiling, cli_progress=True)
# Final metadata
builder.set_name(settings.name)
builder.write_index_rel_wtml()

Просмотреть файл

@ -168,8 +168,8 @@ class MultiTanProcessor(object):
desc.sub_tiling = self._tiling.compute_for_subimage(
desc.imin,
desc.jmin,
desc.imax - desc.imin,
desc.jmax - desc.jmin,
desc.imax + 1 - desc.imin,
desc.jmax + 1 - desc.jmin,
)
self._n_todo += desc.sub_tiling.count_populated_positions()

Просмотреть файл

@ -320,6 +320,12 @@ class StudyTiling(object):
if image.width != self._width:
raise ValueError('width of image to be sampled does not match tiling')
# For tiled FITS, the overall input image and tiling coordinate system
# need to have negative (JPEG-like) parity, but the individal tiles need
# to be saved with positive (FITS-like) parity. The upshot is that we
# have to vertically flip the data layout in our tile buffers.
invert_into_tiles = pio.get_default_vertical_parity_sign() == 1
# TODO: ideally make_maskable_buffer should be a method
# on the Image class which could then avoid having to
# manually transfer _format.
@ -328,10 +334,21 @@ class StudyTiling(object):
with tqdm(total=self.count_populated_positions(), disable=not cli_progress) as progress:
for pos, width, height, image_x, image_y, tile_x, tile_y in self.generate_populated_positions():
if invert_into_tiles:
flip_tile_y1 = 255 - tile_y
flip_tile_y0 = flip_tile_y1 - height
if flip_tile_y0 == -1:
flip_tile_y0 = None # with a slice, -1 does the wrong thing
by_idx = slice(flip_tile_y1, flip_tile_y0, -1)
else:
by_idx = slice(tile_y, tile_y + height)
iy_idx = slice(image_y, image_y + height)
ix_idx = slice(image_x, image_x + width)
by_idx = slice(tile_y, tile_y + height)
bx_idx = slice(tile_x, tile_x + width)
image.fill_into_maskable_buffer(buffer, iy_idx, ix_idx, by_idx, bx_idx)
pio.write_image(pos, buffer)
progress.update(1)

Просмотреть файл

@ -1,14 +1,15 @@
# -*- mode: python; coding: utf-8 -*-
# Copyright 2019-2020 the AAS WorldWide Telescope project
# Copyright 2019-2021 the AAS WorldWide Telescope project
# Licensed under the MIT License.
from __future__ import absolute_import, division, print_function
import numpy as np
from numpy import testing as nt
import numpy.testing as nt
import os.path
import pytest
import sys
from xml.etree import ElementTree as etree
from . import assert_xml_elements_equal, test_path
from ..builder import Builder
@ -17,42 +18,48 @@ from .. import collection
from .. import multi_tan
try:
from astropy.io import fits
HAS_ASTRO = True
except ImportError:
HAS_ASTRO = False
try:
import reproject
HAS_REPROJECT = True
except ImportError:
HAS_REPROJECT = False
class TestMultiTan(object):
WTML = """
<Folder Group="Explorer" Name="TestName">
<Place Angle="0" DataSetType="Sky" Dec="0.74380165289257" Name="TestName"
Opacity="100" RA="14.419753086419734" Rotation="0.0"
ZoomLevel="0.1433599999999144">
<Folder Browseable="True" Group="Explorer" Name="Toasty" MSRCommunityId="0"
MSRComponentId="0" Permission="0" Searchable="True" Type="Sky">
<Place Angle="0.0" AngularSize="0.0" DataSetType="Sky" Dec="0.7438249862258411"
Distance="0.0" DomeAlt="0.0" DomeAz="0.0" Lat="0.0" Lng="0.0"
Magnitude="0.0" MSRCommunityId="0" MSRComponentId="0" Name="Toasty"
Opacity="100.0" Permission="0" RA="14.41975153073335" Rotation="0.0"
Thumbnail="thumb.jpg" ZoomLevel="0.2437119999998555" >
<ForegroundImageSet>
<ImageSet BandPass="Gamma" BaseDegreesPerTile="0.023893333333319066"
BaseTileLevel="0" BottomsUp="False" CenterX="216.296296296296"
CenterY="0.74380165289257" DataSetType="Sky" FileType=".png"
Name="TestName" OffsetX="4.66666666666388e-05"
OffsetY="4.66666666666388e-05" Projection="Tan" Rotation="0.0"
Sparse="True" TileLevels="1" Url="UP{1}/{3}/{3}_{2}.png"
WidthFactor="2">
<Credits>CT</Credits>
<CreditsUrl>CU</CreditsUrl>
<ThumbnailUrl>TU</ThumbnailUrl>
<Description>DT</Description>
<ImageSet BandPass="Visible" BaseDegreesPerTile="0.023893333333319167"
BaseTileLevel="0" BottomsUp="False" CenterX="216.2962962963"
CenterY="0.74380165289257" DataSetType="Sky" ElevationModel="False"
FileType=".fits" Generic="False" MeanRadius="0.0" MSRCommunityId="0"
MSRComponentId="0" Name="Toasty" OffsetX="2.33333333333195e-05"
OffsetY="2.33333333333195e-05" Permission="0" Projection="Tan"
Rotation="-0.0" Sparse="True" StockSet="False" TileLevels="1"
Url="{1}/{3}/{3}_{2}.fits" WidthFactor="2">
<ThumbnailUrl>thumb.jpg</ThumbnailUrl>
</ImageSet>
</ForegroundImageSet>
</Place>
</Folder>"""
# Gross workaround for Python 2.7, where the XML serialization is
# apparently slightly different from Python >=3 (for Numpy types only, I
# think?). Fun times. We could avoid this by comparing floats as floats,
# not text, but then we basically have to learn how to deserialize WTML
# with the full semantics of the format.
# Gross workaround for platform differences in the XML output.
if sys.version_info.major == 2:
WTML = (WTML
.replace('Dec="0.74380165289257"', 'Dec="0.743801652893"')
.replace('RA="14.419753086419734"', 'RA="14.4197530864"')
.replace('CenterX="216.296296296296"', 'CenterX="216.296296296"')
.replace('CenterY="0.74380165289257"', 'CenterY="0.743801652893"')
)
if sys.platform == 'darwin':
WTML = WTML.replace('Dec="0.7438249862258411"', 'Dec="0.743824986225841"')
# Back to the non-gross stuff.
@ -67,6 +74,7 @@ class TestMultiTan(object):
def work_path(self, *pieces):
return os.path.join(self.work_dir, *pieces)
def test_basic(self):
coll = collection.SimpleFitsCollection([test_path('wcs512.fits.gz')])
@ -80,12 +88,56 @@ class TestMultiTan(object):
proc.compute_global_pixelization(builder)
proc.tile(pio)
BARY_SLICES = [
(slice(0, 128), slice(0, 128)),
(slice(0, 128), slice(128, None)),
(slice(128, None), slice(0, 128)),
(slice(128, None), slice(128, None)),
]
def maybe_test_barycenter(self, path, bary_expected):
"""
Check the barycenters of four 128x128 quadrants of a tile file. The idea
here is that if we introduce a problem with vertical flips in tiled FITS
processing, we'll detect it here.
"""
if not HAS_ASTRO:
return
with fits.open(path) as hdul:
data = hdul[0].data
data[~np.isfinite(data)] = 0.0
bary_observed = []
for islice in self.BARY_SLICES:
idata = data[islice]
yidx, xidx = np.indices((128, 128))
xbary = (idata * xidx).sum() / idata.sum()
ybary = (idata * yidx).sum() / idata.sum()
bary_observed.append((xbary, ybary))
nt.assert_array_almost_equal(bary_observed, bary_expected, decimal=5)
WCS512_BARYDATA = [
(63.44949378800272, 64.40535387506924),
(63.24744175084746, 63.67473452789256),
(65.22950207855361, 63.35629429568745),
(62.027396724898814, 62.815937534782144)
]
def test_basic_cli(self):
"""
Test the CLI interface. We don't go out of our way to validate the
computations in detail -- that's for the unit tests that probe the
module directly.
"""
expected = etree.fromstring(
self.WTML
.replace('Thumbnail="thumb.jpg"', '')
.replace('<ThumbnailUrl>thumb.jpg</ThumbnailUrl>', '')
)
args = [
'tile-multi-tan',
@ -94,3 +146,77 @@ class TestMultiTan(object):
test_path('wcs512.fits.gz')
]
cli.entrypoint(args)
with open(self.work_path('basic_cli', 'index_rel.wtml'), 'rt', encoding='utf8') as f:
observed = etree.fromstring(f.read())
assert_xml_elements_equal(observed, expected)
args = [
'cascade',
'--start', '1',
self.work_path('basic_cli'),
]
cli.entrypoint(args)
self.maybe_test_barycenter(self.work_path('basic_cli', '0', '0', '0_0.fits'), self.WCS512_BARYDATA)
def test_study_cli(self):
"""
Test tile-study on FITS. This should properly go in test_study.py, but
this file is the one that has the reference WTML information.
"""
expected = etree.fromstring(self.WTML)
args = [
'tile-study',
'--placeholder-thumbnail',
'--outdir', self.work_path('study_cli'),
test_path('wcs512.fits.gz')
]
cli.entrypoint(args)
with open(self.work_path('study_cli', 'index_rel.wtml'), 'rt', encoding='utf8') as f:
observed = etree.fromstring(f.read())
assert_xml_elements_equal(observed, expected)
args = [
'cascade',
'--start', '1',
self.work_path('study_cli'),
]
cli.entrypoint(args)
self.maybe_test_barycenter(self.work_path('study_cli', '0', '0', '0_0.fits'), self.WCS512_BARYDATA)
@pytest.mark.skipif('not HAS_REPROJECT')
def test_as_multi_wcs(self):
"""
Once again, this doesn't super belong here, but this is where we have
the reference data. We don't compare the WTML contents here since the
reprojection isn't going to preserve the WCS in detail.
"""
from .. import builder, collection, multi_wcs, pyramid
reproject_function = reproject.reproject_interp
outdir = self.work_path('as_multi_wcs')
pio = pyramid.PyramidIO(outdir, default_format='fits')
bld = builder.Builder(pio)
coll = collection.SimpleFitsCollection([test_path('wcs512.fits.gz')], hdu_index=0)
proc = multi_wcs.MultiWcsProcessor(coll)
proc.compute_global_pixelization(bld)
proc.tile(pio, reproject_function, cli_progress=False, parallel=1)
bld.write_index_rel_wtml()
args = [
'cascade',
'--start', '1',
self.work_path('as_multi_wcs'),
]
cli.entrypoint(args)
self.maybe_test_barycenter(self.work_path('as_multi_wcs', '0', '0', '0_0.fits'), self.WCS512_BARYDATA)