diff --git a/README.md b/README.md index 1584370..514121a 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ and [PyPI](https://pypi.org/project/toasty/#history). [toasty] is a Python package so, yes, Python is required. -- [astropy] +- [astropy] if using FITS files or WCS coordinates - [cython] +- [filelock] - [healpy] if using [HEALPix] maps - [numpy] - [pillow] @@ -80,6 +81,7 @@ and [PyPI](https://pypi.org/project/toasty/#history). [astropy]: https://www.astropy.org/ [cython]: https://cython.org/ +[filelock]: https://github.com/benediktschmitt/py-filelock [healpy]: https://healpy.readthedocs.io/ [HEALPix]: https://healpix.jpl.nasa.gov/ [numpy]: https://numpy.org/ diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md deleted file mode 100644 index 308a5b0..0000000 --- a/RELEASE_PROCESS.md +++ /dev/null @@ -1,30 +0,0 @@ -# The toasty release process - -These are notes for the `toasty` developers about how to create a new release. - -1. Create a branch intended to become the next release. -2. Finish features, test functionality, etc. -3. `python setup.py sdist` and verify contents. -4. Make sure that `CHANGES.md` is up-to-date. -5. For the final commit, update the version number in `setup.py` and - `docs/conf.py`, and add a proper version and date to `CHANGES.md`. Commit - with message `Release version ${version}`. -6. Push to GitHub and create a pull request for the new release called - "Release PR for version $version". -7. Get it so that it passes CI, creating fixup commits as necessary. -8. When it's really really ready, `git clean -fxd && python setup.py sdist && - twine upload dist/*.tar.gz`. If `twine` finds problems, make any final - changes and retry. -9. If needed, do a `git rebase -i` to make the version-bump commit the last - one again. -10. `git tag v${version}` -11. Update the version number to `${cur_major}.${next_minor}.0.dev0` and add a - new separator in `CHANGES.md` along the lines of `${version} (unreleased)`. - Commit with a message of `Back to development.` -12. `git push` (with `-f` if history was rewritten) to the PR branch. This had - *really* better still pass CI. -13. Merge into `master`. -14. Pull the merged `master` locally. -15. `git push --tags` -16. Create a new release on GitHub and copy the latest contents of - `CHANGES.md` into the description. diff --git a/ci/azure-build-and-test.yml b/ci/azure-build-and-test.yml index 8a7862c..f5afca5 100644 --- a/ci/azure-build-and-test.yml +++ b/ci/azure-build-and-test.yml @@ -84,7 +84,7 @@ jobs: set -euo pipefail source activate-conda.sh set -x - \conda create -y -n build setuptools pip + \conda create -y -n build setuptools pip python=3.8 conda activate build pip install $BASH_WORKSPACE/sdist/*.tar.gz displayName: Install from sdist @@ -96,6 +96,7 @@ jobs: set -x \conda install -y \ cython \ + filelock \ healpy \ numpy \ openexr-python \ @@ -123,7 +124,7 @@ jobs: set -euo pipefail source activate-conda.sh set -x - \conda create -y -n build setuptools pip + \conda create -y -n build setuptools pip python=3.8 conda activate build pip install $BASH_WORKSPACE/sdist/*.tar.gz displayName: Install from sdist diff --git a/ci/azure-sdist.yml b/ci/azure-sdist.yml index 624cca7..0c020d5 100644 --- a/ci/azure-sdist.yml +++ b/ci/azure-sdist.yml @@ -53,6 +53,7 @@ jobs: conda config --add channels conda-forge conda install -y \ cython \ + filelock \ numpy \ pillow \ pip \ diff --git a/docs/api/toasty.image.Image.rst b/docs/api/toasty.image.Image.rst index efa33db..34fe9a9 100644 --- a/docs/api/toasty.image.Image.rst +++ b/docs/api/toasty.image.Image.rst @@ -28,6 +28,7 @@ Image ~Image.from_pil ~Image.make_thumbnail_bitmap ~Image.save_default + ~Image.update_into_maskable_buffer .. rubric:: Attributes Documentation @@ -47,3 +48,4 @@ Image .. automethod:: from_pil .. automethod:: make_thumbnail_bitmap .. automethod:: save_default + .. automethod:: update_into_maskable_buffer diff --git a/docs/api/toasty.pyramid.PyramidIO.rst b/docs/api/toasty.pyramid.PyramidIO.rst index b28e150..60cb065 100644 --- a/docs/api/toasty.pyramid.PyramidIO.rst +++ b/docs/api/toasty.pyramid.PyramidIO.rst @@ -13,15 +13,17 @@ PyramidIO ~PyramidIO.get_path_scheme ~PyramidIO.open_metadata_for_read ~PyramidIO.open_metadata_for_write - ~PyramidIO.read_toasty_image + ~PyramidIO.read_image ~PyramidIO.tile_path - ~PyramidIO.write_toasty_image + ~PyramidIO.update_image + ~PyramidIO.write_image .. rubric:: Methods Documentation .. automethod:: get_path_scheme .. automethod:: open_metadata_for_read .. automethod:: open_metadata_for_write - .. automethod:: read_toasty_image + .. automethod:: read_image .. automethod:: tile_path - .. automethod:: write_toasty_image + .. automethod:: update_image + .. automethod:: write_image diff --git a/docs/api/toasty.study.StudyTiling.rst b/docs/api/toasty.study.StudyTiling.rst index 9e9bc71..6cedf78 100644 --- a/docs/api/toasty.study.StudyTiling.rst +++ b/docs/api/toasty.study.StudyTiling.rst @@ -11,6 +11,7 @@ StudyTiling .. autosummary:: ~StudyTiling.apply_to_imageset + ~StudyTiling.compute_for_subimage ~StudyTiling.count_populated_positions ~StudyTiling.generate_populated_positions ~StudyTiling.image_to_tile @@ -20,6 +21,7 @@ StudyTiling .. rubric:: Methods Documentation .. automethod:: apply_to_imageset + .. automethod:: compute_for_subimage .. automethod:: count_populated_positions .. automethod:: generate_populated_positions .. automethod:: image_to_tile diff --git a/setup.py b/setup.py index c3ad451..74fb72f 100644 --- a/setup.py +++ b/setup.py @@ -72,6 +72,7 @@ def get_long_desc(): }, install_requires = [ + 'filelock>=3', 'numpy>=1.7', 'pillow>=7.0', 'PyYAML>=5.0', diff --git a/toasty/image.py b/toasty/image.py index 07d8ed3..08a7153 100644 --- a/toasty/image.py +++ b/toasty/image.py @@ -547,6 +547,48 @@ def fill_into_maskable_buffer(self, buffer, iy_idx, ix_idx, by_idx, bx_idx): else: raise Exception('unhandled mode in fill_into_maskable_buffer') + def update_into_maskable_buffer(self, buffer, iy_idx, ix_idx, by_idx, bx_idx): + """ + Update a maskable buffer with data from this image. + + Parameters + ---------- + buffer : :class:`Image` + The destination buffer image, created with :meth:`ImageMode.make_maskable_buffer`. + iy_idx : slice or other indexer + The indexer into the Y axis of the source image (self). + ix_idx : slice or other indexer + The indexer into the X axis of the source image (self). + by_idx : slice or other indexer + The indexer into the Y axis of the destination *buffer*. + bx_idx : slice or other indexer + The indexer into the X axis of the destination *buffer*. + + Notes + ----- + Unlike :meth:`fill_into_maskable_buffer`, this function does not clear + the entire buffer. It only overwrites the portion of the buffer covered + by non-NaN-like values of the input image. + + """ + i = self.asarray() + b = buffer.asarray() + + sub_b = b[by_idx,bx_idx] + sub_i = i[iy_idx,ix_idx] + + if self.mode == ImageMode.RGB: + sub_b[...,:3] = sub_i + sub_b[...,3] = 255 + elif self.mode == ImageMode.RGBA: + valid = (sub_i[...,3] != 0) + np.putmask(sub_b, valid, sub_i) + elif self.mode == ImageMode.F32: + valid = ~np.isnan(sub_i) + np.putmask(sub_b, valid, sub_i) + else: + raise Exception('unhandled mode in update_into_maskable_buffer') + def save_default(self, path_or_stream): """ Save this image to a filesystem path or stream diff --git a/toasty/merge.py b/toasty/merge.py index d0037df..32bfdf0 100644 --- a/toasty/merge.py +++ b/toasty/merge.py @@ -31,6 +31,7 @@ import os import sys from tqdm import tqdm +import warnings from . import pyramid from .image import Image @@ -50,7 +51,12 @@ def averaging_merger(data): """ s = (data.shape[0] // 2, 2, data.shape[1] // 2, 2) + data.shape[2:] - return np.nanmean(data.reshape(s), axis=(1, 3)).astype(data.dtype) + + # nanmean will raise a RuntimeWarning if there are all-NaN quartets. This + # gets annoying, so we silence them. + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + return np.nanmean(data.reshape(s), axis=(1, 3)).astype(data.dtype) def cascade_images(pio, mode, start, merger, parallel=None, cli_progress=False): @@ -108,10 +114,10 @@ def _cascade_images_serial(pio, mode, start, merger, cli_progress): # processed. children = pyramid.pos_children(pos) - img0 = pio.read_toasty_image(children[0], mode, default='none') - img1 = pio.read_toasty_image(children[1], mode, default='none') - img2 = pio.read_toasty_image(children[2], mode, default='none') - img3 = pio.read_toasty_image(children[3], mode, default='none') + img0 = pio.read_image(children[0], mode, default='none') + img1 = pio.read_image(children[1], mode, default='none') + img2 = pio.read_image(children[2], mode, default='none') + img3 = pio.read_image(children[3], mode, default='none') if img0 is None and img1 is None and img2 is None and img3 is None: progress.update(1) @@ -128,7 +134,7 @@ def _cascade_images_serial(pio, mode, start, merger, cli_progress): buf.asarray()[slidx] = subimg.asarray() merged = Image.from_array(mode, merger(buf.asarray())) - pio.write_toasty_image(pos, merged) + pio.write_image(pos, merged) progress.update(1) if cli_progress: @@ -153,10 +159,9 @@ def _cascade_images_parallel(pio, mode, start, merger, cli_progress, parallel): first_level_to_do = start - 1 n_todo = pyramid.depth2tiles(first_level_to_do) - ready_queue = mp.Queue(maxsize = 2 * parallel) + ready_queue = mp.Queue() done_queue = mp.Queue(maxsize = 2 * parallel) - dispatcher = mp.Process( target=_mp_cascade_dispatcher, args=(done_queue, ready_queue, n_todo, cli_progress) @@ -267,10 +272,10 @@ def _mp_cascade_worker(done_queue, ready_queue, pio, merger, mode): # processed. children = pyramid.pos_children(pos) - img0 = pio.read_toasty_image(children[0], mode, default='none') - img1 = pio.read_toasty_image(children[1], mode, default='none') - img2 = pio.read_toasty_image(children[2], mode, default='none') - img3 = pio.read_toasty_image(children[3], mode, default='none') + img0 = pio.read_image(children[0], mode, default='none') + img1 = pio.read_image(children[1], mode, default='none') + img2 = pio.read_image(children[2], mode, default='none') + img3 = pio.read_image(children[3], mode, default='none') if img0 is None and img1 is None and img2 is None and img3 is None: pass # No data here; ignore @@ -286,6 +291,6 @@ def _mp_cascade_worker(done_queue, ready_queue, pio, merger, mode): buf.asarray()[slidx] = subimg.asarray() merged = Image.from_array(mode, merger(buf.asarray())) - pio.write_toasty_image(pos, merged) + pio.write_image(pos, merged) done_queue.put(pos) diff --git a/toasty/multi_wcs.py b/toasty/multi_wcs.py new file mode 100644 index 0000000..050b0ba --- /dev/null +++ b/toasty/multi_wcs.py @@ -0,0 +1,303 @@ +# -*- mode: python; coding: utf-8 -*- +# Copyright 2020 the AAS WorldWide Telescope project +# Licensed under the MIT License. + +""" +Generate tiles from a collection of images with associated WCS coordinate +systems. + +This module has the following Python package dependencies: + +- astropy +- ccdproc (to trim FITS CCD datasets) +- reproject +- shapely (to optimize the projection in reproject) + +""" + +__all__ = ''' +make_lsst_directory_loader_generator +MultiWcsProcessor +'''.split() + +import numpy as np +from tqdm import tqdm +import warnings + +from .image import Image, ImageMode +from .study import StudyTiling + + +def make_lsst_directory_loader_generator(dirname, unit=None): + from astropy.io import fits + from astropy.nddata import ccddata + import ccdproc + from glob import glob + from os.path import join + + def loader_generator(actually_load_data): + # Ideally we would just return a shape instead of an empty data array in + # the case where actually_load_data is false, but we can't use + # `ccdproc.trim_image()` without having a CCDData in hand, so to get the + # right shape we're going to need to create a full CCDData anyway. + + for fits_path in glob(join(dirname, '*.fits')): + # `astropy.nddata.ccddata.fits_ccddata_reader` only opens FITS from + # filenames, not from an open HDUList, which means that creating + # multiple CCDDatas from the same FITS file rapidly becomes + # inefficient. So, we emulate its logic. + + with fits.open(fits_path) as hdu_list: + for idx, hdu in enumerate(hdu_list): + if idx == 0: + header0 = hdu.header + else: + hdr = hdu.header + hdr.extend(header0, unique=True) + + # This ccddata function often generates annoying warnings + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + hdr, wcs = ccddata._generate_wcs_and_update_header(hdr) + + # Note: we skip all the unit-handling logic here since the LSST + # sim data I'm using don't have anything useful. + + if actually_load_data: + data = hdu.data + else: + data = np.empty(hdu.shape, dtype=np.void) + + ccd = ccddata.CCDData(data, meta=hdr, unit=unit, wcs=wcs) + + ccd = ccdproc.trim_image(ccd, fits_section=ccd.header['DATASEC']) + yield (f'{fits_path}:{idx}', ccd) + + return loader_generator + + +class MultiWcsDescriptor(object): + ident = None + in_shape = None + in_wcs = None + + imin = None + imax = None + jmin = None + jmax = None + + sub_tiling = None + + +class MultiWcsProcessor(object): + def __init__(self, loader_generator): + self._loader_generator = loader_generator + + + def compute_global_pixelization(self): + from reproject.mosaicking.wcs_helpers import find_optimal_celestial_wcs + + # Load up current WCS information for all of the inputs + + def create_descriptor(loader_data): + desc = MultiWcsDescriptor() + desc.ident = loader_data[0] + desc.in_shape = loader_data[1].shape + desc.in_wcs = loader_data[1].wcs + return desc + + self._descs = [create_descriptor(tup) for tup in self._loader_generator(False)] + + # Compute the optimal tangential tiling that fits all of them. + + self._combined_wcs, self._combined_shape = find_optimal_celestial_wcs( + ((desc.in_shape, desc.in_wcs) for desc in self._descs), + auto_rotate = True, + projection = 'TAN', + ) + + self._tiling = StudyTiling(self._combined_shape[1], self._combined_shape[0]) + + # While we're here, figure out how each input will map onto the global + # tiling. This makes sure that nothing funky happened during the + # computation and allows us to know how many tiles we'll have to visit. + + self._n_todo = 0 + + for desc in self._descs: + # XXX: this functionality is largely copied from + # `reproject.mosaicking.coadd.reproject_and_coadd`, and redundant + # with it, but it's sufficiently different that I think the best + # approach is to essentially fork the implementation. + + # Figure out where this array lands in the mosaic. + + ny, nx = desc.in_shape + xc = np.array([-0.5, nx - 0.5, nx - 0.5, -0.5]) + yc = np.array([-0.5, -0.5, ny - 0.5, ny - 0.5]) + xc_out, yc_out = self._combined_wcs.world_to_pixel(desc.in_wcs.pixel_to_world(xc, yc)) + + if np.any(np.isnan(xc_out)) or np.any(np.isnan(yc_out)): + raise Exception(f'segment {desc.ident} does not fit within the global mosaic') + + desc.imin = max(0, int(np.floor(xc_out.min() + 0.5))) + desc.imax = min(self._combined_shape[1], int(np.ceil(xc_out.max() + 0.5))) + desc.jmin = max(0, int(np.floor(yc_out.min() + 0.5))) + desc.jmax = min(self._combined_shape[0], int(np.ceil(yc_out.max() + 0.5))) + + # Compute the sub-tiling now so that we can count how many total + # tiles we'll need to process. Note that the combined WCS coordinate + # system has y=0 on the bottom, whereas the tiling coordinate system + # has y=0 at the top. So we need to invert the coordinates + # vertically when determining the sub-tiling. + + if desc.imax < desc.imin or desc.jmax < desc.jmin: + raise Exception(f'segment {desc.ident} maps to zero size in the global mosaic') + + desc.sub_tiling = self._tiling.compute_for_subimage( + desc.imin, + self._combined_shape[0] - desc.jmax, + desc.imax - desc.imin, + desc.jmax - desc.jmin, + ) + + self._n_todo += desc.sub_tiling.count_populated_positions() + + return self # chaining convenience + + + def tile(self, pio, reproject_function, parallel=None, cli_progress=False, **kwargs): + """ + Tile!!!! + + Parameters + ---------- + pio : :class:`toasty.pyramid.PyramidIO` + A :class:`~toasty.pyramid.PyramidIO` instance to manage the I/O with + the tiles in the tile pyramid. + reproject_function : TKTK + TKTK + parallel : integer or None (the default) + The level of parallelization to use. If unspecified, defaults to using + all CPUs. If the OS does not support fork-based multiprocessing, + parallel processing is not possible and serial processing will be + forced. Pass ``1`` to force serial processing. + cli_progress : optional boolean, defaults False + If true, a progress bar will be printed to the terminal using tqdm. + + """ + from .par_util import resolve_parallelism + parallel = resolve_parallelism(parallel) + + if parallel > 1: + self._tile_parallel(pio, reproject_function, cli_progress, parallel, **kwargs) + else: + self._tile_serial(pio, reproject_function, cli_progress, **kwargs) + + + def _tile_serial(self, pio, reproject_function, cli_progress, **kwargs): + with tqdm(total=self._n_todo, disable=not cli_progress) as progress: + for (ident, ccd), desc in zip(self._loader_generator(True), self._descs): + # XXX: more copying from + # `reproject.mosaicking.coadd.reproject_and_coadd`. + + wcs_out_indiv = self._combined_wcs[desc.jmin:desc.jmax, desc.imin:desc.imax] + shape_out_indiv = (desc.jmax - desc.jmin, desc.imax - desc.imin) + + array = reproject_function( + (ccd.data, ccd.wcs), + output_projection=wcs_out_indiv, + shape_out=shape_out_indiv, + return_footprint=False, + **kwargs + ) + + # Once again, FITS coordinates have y=0 at the bottom and our + # coordinates have y=0 at the top, so we need a vertical flip. + image = Image.from_array(ImageMode.F32, array.astype(np.float32)[::-1]) + + for pos, width, height, image_x, image_y, tile_x, tile_y in desc.sub_tiling.generate_populated_positions(): + 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) + + with pio.update_image(pos, image.mode, default='masked') as basis: + image.update_into_maskable_buffer(basis, iy_idx, ix_idx, by_idx, bx_idx) + + progress.update(1) + + if cli_progress: + print() + + + def _tile_parallel(self, pio, reproject_function, cli_progress, parallel, **kwargs): + import multiprocessing as mp + + # Start up the workers + + queue = mp.Queue(maxsize = 2 * parallel) + workers = [] + + for _ in range(parallel): + w = mp.Process(target=_mp_tile_worker, args=(queue, pio, reproject_function, kwargs)) + w.daemon = True + w.start() + workers.append(w) + + # Send out them segments + + with tqdm(total=len(self._descs), disable=not cli_progress) as progress: + for (ident, ccd), desc in zip(self._loader_generator(True), self._descs): + wcs_out_indiv = self._combined_wcs[desc.jmin:desc.jmax, desc.imin:desc.imax] + queue.put((ident, ccd, desc, wcs_out_indiv)) + progress.update(1) + + queue.close() + + for w in workers: + w.join() + + if cli_progress: + print() + + +def _mp_tile_worker(queue, pio, reproject_function, kwargs): + """ + Generate and enqueue the tiles that need to be processed. + """ + from queue import Empty + + while True: + try: + # un-pickling WCS objects always triggers warnings right now + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + ident, ccd, desc, wcs_out_indiv = queue.get(True, timeout=1) + except (OSError, ValueError, Empty): + # OSError or ValueError => queue closed. This signal seems not to + # cross multiprocess lines, though. + break + + shape_out_indiv = (desc.jmax - desc.jmin, desc.imax - desc.imin) + + array = reproject_function( + (ccd.data, ccd.wcs), + output_projection=wcs_out_indiv, + shape_out=shape_out_indiv, + return_footprint=False, + **kwargs + ) + + # Once again, FITS coordinates have y=0 at the bottom and our + # coordinates have y=0 at the top, so we need a vertical flip. + image = Image.from_array(ImageMode.F32, array.astype(np.float32)[::-1]) + + for pos, width, height, image_x, image_y, tile_x, tile_y in desc.sub_tiling.generate_populated_positions(): + 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) + + with pio.update_image(pos, image.mode, default='masked') as basis: + image.update_into_maskable_buffer(basis, iy_idx, ix_idx, by_idx, bx_idx) diff --git a/toasty/pyramid.py b/toasty/pyramid.py index 0db2b7f..33c8047 100644 --- a/toasty/pyramid.py +++ b/toasty/pyramid.py @@ -26,6 +26,7 @@ '''.split() from collections import namedtuple +from contextlib import contextmanager import numpy as np import os.path @@ -255,9 +256,9 @@ def get_path_scheme(self): """ return self._scheme - def read_toasty_image(self, pos, mode, default='none'): + def read_image(self, pos, mode, default='none'): """ - Read a toasty Image for the specified tile position. + Read an Image for the specified tile position. Parameters ---------- @@ -288,15 +289,17 @@ def read_toasty_image(self, pos, mode, default='none'): if default == 'none': return None elif default == 'masked': - return mode.make_maskable_buffer(256, 256) + buf = mode.make_maskable_buffer(256, 256) + buf.clear() + return buf else: raise ValueError('unexpected value for "default": {!r}'.format(default)) assert img.mode == mode return img - def write_toasty_image(self, pos, image): - """Write a toasty Image for the specified tile position. + def write_image(self, pos, image): + """Write an Image for the specified tile position. Parameters ---------- @@ -309,6 +312,17 @@ def write_toasty_image(self, pos, image): p = self.tile_path(pos, image.mode.get_default_save_extension()) image.save_default(p) + @contextmanager + def update_image(self, pos, mode, default='none'): + from filelock import FileLock + + p = self.tile_path(pos, mode.get_default_save_extension()) + + with FileLock(p + '.lock'): + img = self.read_image(pos, mode, default=default) + yield img + self.write_image(pos, img) + def open_metadata_for_read(self, basename): """ Open a metadata file in read mode. diff --git a/toasty/study.py b/toasty/study.py index 5f2dc12..e4b7924 100644 --- a/toasty/study.py +++ b/toasty/study.py @@ -87,6 +87,49 @@ def __init__(self, width, height): self._img_gy0 = (self._p2n - self._height) // 2 + def compute_for_subimage(self, subim_ix, subim_iy, subim_width, subim_height): + """ + Create a new compatible tiling whose underlying image is a subset of this one. + + Parameters + ---------- + subim_ix : integer + The 0-based horizontal pixel position of the left edge of the sub-image, + relative to this tiling's image. + subim_iy : integer + The 0-based vertical pixel position of the top edge of the sub-image, + relative to this tiling's image. + subim_width : nonnegative integer + The width of the sub-image, in pixels. + subim_height : nonnegative integer + The height of the sub-image, in pixels. + + Returns + ------- + A new :class:`~StudyTiling` with the same number of tile levels as this one. + However, the internal information about where the available data land within + that tiling will be appropriate for the specified sub-image. Methods like + :meth:`count_populated_positions`, :meth:`generate_populated_positions`, and + :meth:`tile_image` will behave differently. + + """ + if subim_width < 0 or subim_width > self._width: + raise ValueError('bad subimage width value {!r}'.format(subim_width)) + if subim_height < 0 or subim_height > self._height: + raise ValueError('bad subimage height value {!r}'.format(subim_height)) + if subim_ix < 0 or subim_ix + subim_width > self._width: + raise ValueError('bad subimage ix value {!r}'.format(subim_ix)) + if subim_iy < 0 or subim_iy + subim_height > self._height: + raise ValueError('bad subimage iy value {!r}'.format(subim_iy)) + + sub_tiling = StudyTiling(self._width, self._height) + sub_tiling._width = subim_width + sub_tiling._height = subim_height + sub_tiling._img_gx0 += subim_ix + sub_tiling._img_gy0 += subim_iy + return sub_tiling + + def n_deepest_layer_tiles(self): """Return the number of tiles in the highest-resolution layer.""" return 4**self._tile_levels @@ -157,7 +200,7 @@ def image_to_tile(self, im_ix, im_iy): def count_populated_positions(self): """ - Count how man tiles contain image data. + Count how many tiles contain image data. This is used for progress reporting. @@ -286,7 +329,7 @@ def tile_image(self, image, pio, cli_progress=False): 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_toasty_image(pos, buffer) + pio.write_image(pos, buffer) progress.update(1) if cli_progress: diff --git a/toasty/tests/test_toast.py b/toasty/tests/test_toast.py index c589ee8..3fba2b5 100644 --- a/toasty/tests/test_toast.py +++ b/toasty/tests/test_toast.py @@ -164,7 +164,7 @@ def verify_level1(self, mode): expected = expected.mean(axis=2) pos = Pos(n=n, x=x, y=y) - observed = self.pio.read_toasty_image(pos, mode).asarray() + observed = self.pio.read_image(pos, mode).asarray() image_test(expected, observed, 'Failed for %s' % ref_path) diff --git a/toasty/toast.py b/toasty/toast.py index ac8b9d7..130c730 100644 --- a/toasty/toast.py +++ b/toasty/toast.py @@ -459,7 +459,7 @@ def _sample_layer_serial(pio, mode, sampler, depth, cli_progress): tile.increasing, ) sampled_data = sampler(lon, lat) - pio.write_toasty_image(tile.pos, Image.from_array(mode, sampled_data)) + pio.write_image(tile.pos, Image.from_array(mode, sampled_data)) progress.update(1) if cli_progress: @@ -525,4 +525,4 @@ def _mp_sample_worker(queue, pio, sampler, mode): tile.increasing, ) sampled_data = sampler(lon, lat) - pio.write_toasty_image(tile.pos, Image.from_array(mode, sampled_data)) + pio.write_image(tile.pos, Image.from_array(mode, sampled_data))