diff --git a/large_image/tilesource/resample.py b/large_image/tilesource/resample.py new file mode 100644 index 000000000..1d374f272 --- /dev/null +++ b/large_image/tilesource/resample.py @@ -0,0 +1,129 @@ +from enum import Enum +from typing import Dict + +import numpy as np +from PIL import Image + + +class ResampleMethod(Enum): + PIL_NEAREST = Image.Resampling.NEAREST # 0 + PIL_LANCZOS = Image.Resampling.LANCZOS # 1 + PIL_BILINEAR = Image.Resampling.BILINEAR # 2 + PIL_BICUBIC = Image.Resampling.BICUBIC # 3 + PIL_BOX = Image.Resampling.BOX # 4 + PIL_HAMMING = Image.Resampling.HAMMING # 5 + PIL_MAX_ENUM = 5 + NP_MEAN = 6 + NP_MEDIAN = 7 + NP_MODE = 8 + NP_MAX = 9 + NP_MIN = 10 + NP_NEAREST = 11 + NP_MAX_COLOR = 12 + NP_MIN_COLOR = 13 + + +def pilResize( + tile: np.ndarray, + new_shape: Dict, + resample_method: ResampleMethod, +) -> np.ndarray: + # Only NEAREST works for 16 bit images + img = Image.fromarray(tile) + resized_img = img.resize( + (new_shape['width'], new_shape['height']), + resample=resample_method.value, + ) + result = np.array(resized_img).astype(tile.dtype) + return result + + +def numpyResize( + tile: np.ndarray, + new_shape: Dict, + resample_method: ResampleMethod, +) -> np.ndarray: + if resample_method == ResampleMethod.NP_NEAREST: + return tile[::2, ::2] + else: + if tile.shape[0] % 2 != 0: + tile = np.append(tile, np.expand_dims(tile[-1], axis=0), axis=0) + if tile.shape[1] % 2 != 0: + tile = np.append(tile, np.expand_dims(tile[:, -1], axis=1), axis=1) + + pixel_selection = None + subarrays = np.asarray( + [ + tile[0::2, 0::2], + tile[1::2, 0::2], + tile[0::2, 1::2], + tile[1::2, 1::2], + ], + ) + + if resample_method == ResampleMethod.NP_MEAN: + return np.mean(subarrays, axis=0).astype(tile.dtype) + elif resample_method == ResampleMethod.NP_MEDIAN: + return np.median(subarrays, axis=0).astype(tile.dtype) + elif resample_method == ResampleMethod.NP_MAX: + return np.max(subarrays, axis=0).astype(tile.dtype) + elif resample_method == ResampleMethod.NP_MIN: + return np.min(subarrays, axis=0).astype(tile.dtype) + elif resample_method == ResampleMethod.NP_MAX_COLOR: + summed = np.sum(subarrays, axis=3) + pixel_selection = np.argmax(summed, axis=0) + elif resample_method == ResampleMethod.NP_MIN_COLOR: + summed = np.sum(subarrays, axis=3) + pixel_selection = np.argmin(summed, axis=0) + elif resample_method == ResampleMethod.NP_MODE: + # if a pixel occurs twice in a set of four, it is a mode + # if no mode, default to pixel 0. check for minimal matches 1=2, 1=3, 2=3 + pixel_selection = np.where( + ( + (subarrays[1] == subarrays[2]).all(axis=2) | + (subarrays[1] == subarrays[3]).all(axis=2) + ), + 1, np.where( + (subarrays[2] == subarrays[3]).all(axis=2), + 2, 0, + ), + ) + + if pixel_selection is not None: + if len(tile.shape) > 2: + pixel_selection = np.expand_dims(pixel_selection, axis=2) + pixel_selection = np.repeat(pixel_selection, tile.shape[2], axis=2) + return np.choose(pixel_selection, subarrays).astype(tile.dtype) + else: + msg = f'Unknown resample method {resample_method}.' + raise ValueError(msg) + + +def downsampleTileHalfRes( + tile: np.ndarray, + resample_method: ResampleMethod, +) -> np.ndarray: + new_shape = { + 'height': (tile.shape[0] + 1) // 2, + 'width': (tile.shape[1] + 1) // 2, + 'bands': 1, + } + if len(tile.shape) > 2: + new_shape['bands'] = tile.shape[-1] + if resample_method.value <= ResampleMethod.PIL_MAX_ENUM.value: + if new_shape['bands'] > 4: + result = np.empty( + (new_shape['height'], new_shape['width'], new_shape['bands']), + dtype=tile.dtype, + ) + for band_index in range(new_shape['bands']): + result[(..., band_index)] = pilResize( + tile[(..., band_index)], + new_shape, + resample_method, + ) + return result + else: + return pilResize(tile, new_shape, resample_method) + else: + return numpyResize(tile, new_shape, resample_method) diff --git a/sources/zarr/large_image_source_zarr/__init__.py b/sources/zarr/large_image_source_zarr/__init__.py index 576b42117..4432acb4e 100644 --- a/sources/zarr/large_image_source_zarr/__init__.py +++ b/sources/zarr/large_image_source_zarr/__init__.py @@ -17,6 +17,7 @@ from large_image.constants import NEW_IMAGE_PATH_FLAG, TILE_FORMAT_NUMPY, SourcePriority from large_image.exceptions import TileSourceError, TileSourceFileNotFoundError from large_image.tilesource import FileTileSource +from large_image.tilesource.resample import ResampleMethod, downsampleTileHalfRes from large_image.tilesource.utilities import _imageToNumpy, nearPowerOfTwo try: @@ -516,6 +517,31 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): return self._outputTile(tile, TILE_FORMAT_NUMPY, x, y, z, pilImageAllowed, numpyAllowed, **kwargs) + def _validateNewTile(self, tile, mask, placement, axes): + if not isinstance(tile, np.ndarray) or axes is None: + axes = 'yxs' + tile, mode = _imageToNumpy(tile) + elif not isinstance(axes, str) and not isinstance(axes, list): + err = 'Invalid type for axes. Must be str or list[str].' + raise ValueError(err) + axes = [x.lower() for x in axes] + if axes[-1] != 's': + axes.append('s') + if mask is not None and len(axes) - 1 == len(mask.shape): + mask = mask[:, :, np.newaxis] + if 'x' not in axes or 'y' not in axes: + err = 'Invalid value for axes. Must contain "y" and "x".' + raise ValueError(err) + for k in placement: + if k not in axes: + axes[0:0] = [k] + while len(tile.shape) < len(axes): + tile = np.expand_dims(tile, axis=0) + while mask is not None and len(mask.shape) < len(axes): + mask = np.expand_dims(mask, axis=0) + + return tile, mask, placement, axes + def addTile(self, tile, x=0, y=0, mask=None, axes=None, **kwargs): """ Add a numpy or image tile to the image, expanding the image as needed @@ -537,60 +563,47 @@ def addTile(self, tile, x=0, y=0, mask=None, axes=None, **kwargs): # TODO: improve band bookkeeping self._checkEditable() + store_path = str(kwargs.pop('level', 0)) placement = { 'x': x, 'y': y, **kwargs, } - if not isinstance(tile, np.ndarray) or axes is None: - axes = 'yxs' - tile, mode = _imageToNumpy(tile) - elif not isinstance(axes, str) and not isinstance(axes, list): - err = 'Invalid type for axes. Must be str or list[str].' - raise ValueError(err) - axes = [x.lower() for x in axes] - if axes[-1] != 's': - axes.append('s') - if mask is not None and len(axes) - 1 == len(mask.shape): - mask = mask[:, :, np.newaxis] - if 'x' not in axes or 'y' not in axes: - err = 'Invalid value for axes. Must contain "y" and "x".' - raise ValueError(err) - for k in placement: - if k not in axes: - axes[0:0] = [k] + tile, mask, placement, axes = self._validateNewTile(tile, mask, placement, axes) + with self._addLock: self._axes = {k: i for i, k in enumerate(axes)} - while len(tile.shape) < len(axes): - tile = np.expand_dims(tile, axis=0) - while mask is not None and len(mask.shape) < len(axes): - mask = np.expand_dims(mask, axis=0) - new_dims = { a: max( - self._dims.get(a, 0), + self._dims.get(store_path, {}).get(a, 0), placement.get(a, 0) + tile.shape[i], ) for a, i in self._axes.items() } + self._dims[store_path] = new_dims placement_slices = tuple([ slice(placement.get(a, 0), placement.get(a, 0) + tile.shape[i], 1) for i, a in enumerate(axes) ]) current_arrays = dict(self._zarr.arrays()) + if store_path == '0': + # if writing to base data, invalidate generated levels + for path in current_arrays: + if path != store_path: + self._zarr_store.rmdir(path) chunking = None - if 'root' not in current_arrays: - root = np.empty(tuple(new_dims.values()), dtype=tile.dtype) + if store_path not in current_arrays: + arr = np.empty(tuple(new_dims.values()), dtype=tile.dtype) chunking = tuple([ self._tileSize if a in ['x', 'y'] else new_dims.get('s') if a == 's' else 1 for a in axes ]) else: - root = current_arrays['root'] - root.resize(*tuple(new_dims.values())) - if root.chunks[-1] != new_dims.get('s'): + arr = current_arrays[store_path] + arr.resize(*tuple(new_dims.values())) + if arr.chunks[-1] != new_dims.get('s'): # rechunk if length of samples axis changes chunking = tuple([ self._tileSize if a in ['x', 'y'] else @@ -599,40 +612,45 @@ def addTile(self, tile, x=0, y=0, mask=None, axes=None, **kwargs): ]) if mask is not None: - root[placement_slices] = np.where(mask, tile, root[placement_slices]) + arr[placement_slices] = np.where(mask, tile, arr[placement_slices]) else: - root[placement_slices] = tile + arr[placement_slices] = tile if chunking: - self._zarr.create_dataset('root', data=root[:], chunks=chunking, overwrite=True) - - # Edit OME metadata - self._zarr.attrs.update({ - 'multiscales': [{ - 'version': '0.5-dev', - 'axes': [{ - 'name': a, - 'type': 'space' if a in ['x', 'y'] else 'other', - } for a in axes], - 'datasets': [{'path': 0}], - }], - 'omero': {'version': '0.5-dev'}, - }) - - # Edit large_image attributes - self._dims = new_dims - self._dtype = tile.dtype - self._bandCount = new_dims.get(axes[-1]) # last axis is assumed to be bands - self.sizeX = new_dims.get('x') - self.sizeY = new_dims.get('y') - self._framecount = np.prod([ - length - for axis, length in new_dims.items() - if axis in axes[:-3] - ]) - self._cacheValue = str(uuid.uuid4()) - self._levels = None - self.levels = int(max(1, math.ceil(math.log(max( - self.sizeX / self.tileWidth, self.sizeY / self.tileHeight)) / math.log(2)) + 1)) + zarr.array( + arr, + chunks=chunking, + overwrite=True, + store=self._zarr_store, + path=store_path, + ) + + # If base data changed, update large_image attributes and OME metadata + if store_path == '0': + self._zarr.attrs.update({ + 'multiscales': [{ + 'version': '0.5-dev', + 'axes': [{ + 'name': a, + 'type': 'space' if a in ['x', 'y'] else 'other', + } for a in axes], + 'datasets': [{'path': 0}], + }], + 'omero': {'version': '0.5-dev'}, + }) + + self._dtype = tile.dtype + self._bandCount = new_dims.get(axes[-1]) # last axis is assumed to be bands + self.sizeX = new_dims.get('x') + self.sizeY = new_dims.get('y') + self._framecount = np.prod([ + length + for axis, length in new_dims.items() + if axis in axes[:-3] + ]) + self._cacheValue = str(uuid.uuid4()) + self._levels = None + self.levels = int(max(1, math.ceil(math.log(max( + self.sizeX / self.tileWidth, self.sizeY / self.tileHeight)) / math.log(2)) + 1)) @property def crop(self): @@ -659,12 +677,75 @@ def crop(self, value): raise TileSourceError(msg) self._crop = (x, y, w, h) + def _generateDownsampledLevels(self, resample_method): + self._checkEditable() + current_arrays = dict(self._zarr.arrays()) + if '0' not in current_arrays: + msg = 'No root data found, cannot generate lower resolution levels.' + raise TileSourceError(msg) + if 'x' not in self._axes or 'y' not in self._axes: + msg = 'Data must have an X axis and Y axis to generate lower resolution levels.' + raise TileSourceError(msg) + + metadata = self.getMetadata() + + if ( + resample_method.value < ResampleMethod.PIL_MAX_ENUM.value and + resample_method != ResampleMethod.PIL_NEAREST + ): + tile_overlap = dict(x=4, y=4, edges=True) + else: + tile_overlap = dict(x=0, y=0) + tile_size = dict( + width=4096 + tile_overlap['x'], + height=4096 + tile_overlap['y'], + ) + for level in range(1, self.levels): + scale_factor = 2 ** level + iterator_output = dict( + maxWidth=self.sizeX // scale_factor, + maxHeight=self.sizeY // scale_factor, + ) + for frame in metadata.get('frames', [{'Index': 0}]): + frame_position = { + k.replace('Index', '').lower(): v + for k, v in frame.items() + if k.replace('Index', '').lower() in self._axes + } + for tile in self.tileIterator( + tile_size=tile_size, + tile_overlap=tile_overlap, + frame=frame['Index'], + output=iterator_output, + resample=False, # TODO: incorporate resampling in core + ): + new_tile = downsampleTileHalfRes(tile['tile'], resample_method) + overlap = {k: int(v / 2) for k, v in tile['tile_overlap'].items()} + new_tile = new_tile[ + slice(overlap['top'], new_tile.shape[0] - overlap['bottom']), + slice(overlap['left'], new_tile.shape[1] - overlap['right']), + ] + + x = int(tile['x'] / 2 + overlap['left']) + y = int(tile['y'] / 2 + overlap['top']) + + self.addTile( + new_tile, + x=x, + y=y, + **frame_position, + axes=list(self._axes.keys()), + level=level, + ) + self._validateZarr() # refresh self._levels before continuing + def write( self, path, lossy=True, alpha=True, overwriteAllowed=True, + resample=None, ): """ Output the current image to a file. @@ -684,53 +765,58 @@ def write( else: raise TileSourceError('Output path exists (%s).' % str(path)) - # TODO: compute half, quarter, etc. resolutions - self._validateZarr() suffix = Path(path).suffix - data_dir = self._tempdir - data_store = self._zarr_store + source = self if self.crop: - x, y, w, h = self.crop - current_arrays = dict(self._zarr.arrays()) - # create new temp storage for cropped data - data_dir = tempfile.TemporaryDirectory() - data_store = zarr.DirectoryStore(data_dir.name) - cropped_zarr = zarr.open(data_store, mode='w') - for arr_name in current_arrays: - arr = np.array(current_arrays[arr_name]) - cropped_arr = arr.take( - indices=range(x, x + w), - axis=self._axes.get('x'), - ).take( - indices=range(y, y + h), - axis=self._axes.get('y'), + top, left, height, width = self.crop + source = new() + source._zarr.attrs.update(self._zarr.attrs) + for frame in self.getMetadata().get('frames', [{'Index': 0}]): + frame_position = { + k.replace('Index', '').lower(): v + for k, v in frame.items() + if k.replace('Index', '').lower() in self._axes + } + for tile in self.tileIterator( + frame=frame['Index'], + region=dict(top=top, left=left, width=width, height=height), + resample=False, + ): + source.addTile( + tile['tile'], + x=tile['x'] - left, + y=tile['y'] - top, + axes=list(self._axes.keys()), + **frame_position, + ) + + if suffix in ['.zarr', '.db', '.sqlite', '.zip']: + if resample is None: + resample = ( + ResampleMethod.PIL_LANCZOS + if lossy and source.dtype == np.uint8 + else ResampleMethod.NP_NEAREST ) - cropped_zarr.create_dataset(arr_name, data=cropped_arr, overwrite=True) - cropped_zarr.attrs.update(self._zarr.attrs) - - if suffix == '.zarr': - shutil.copytree(data_dir.name, path) - - elif suffix in ['.db', '.sqlite']: - sqlite_store = zarr.SQLiteStore(path) - zarr.copy_store(data_store, sqlite_store, if_exists='replace') - sqlite_store.close() - - elif suffix == '.zip': - zip_store = zarr.ZipStore(path) - zarr.copy_store(data_store, zip_store, if_exists='replace') - zip_store.close() + source._generateDownsampledLevels(resample) + + if suffix == '.zarr': + shutil.copytree(source._tempdir.name, path) + elif suffix in ['.db', '.sqlite']: + sqlite_store = zarr.SQLiteStore(path) + zarr.copy_store(source._zarr_store, sqlite_store, if_exists='replace') + sqlite_store.close() + elif suffix == '.zip': + zip_store = zarr.ZipStore(path) + zarr.copy_store(source._zarr_store, zip_store, if_exists='replace') + zip_store.close() else: from large_image_converter import convert - attrs_path = Path(data_dir.name) / '.zattrs' + attrs_path = Path(source._tempdir.name) / '.zattrs' convert(str(attrs_path), path, overwrite=overwriteAllowed) - if self.crop: - shutil.rmtree(data_dir.name) - def open(*args, **kwargs): """ diff --git a/test/test_sink.py b/test/test_sink.py index e1c7ff587..6d31d9687 100644 --- a/test/test_sink.py +++ b/test/test_sink.py @@ -4,6 +4,7 @@ import pytest import large_image +from large_image.tilesource.resample import ResampleMethod TMP_DIR = 'tmp/zarr_sink' FILE_TYPES = [ @@ -12,9 +13,6 @@ 'db', 'zip', 'zarr', - # "dz", - # 'svi', - # 'svs', ] @@ -64,6 +62,31 @@ def testAddTileWithMask(): assert not (tile1 == cur[:, :, 0]).all() +def testAddTileWithLevel(): + sink = large_image_source_zarr.new() + tile0 = np.random.random((100, 100)) + sink.addTile(tile0, 0, 0) + arrays = dict(sink._zarr.arrays()) + assert arrays.get('0') is not None + assert arrays.get('0').shape == (100, 100, 1) + + tile1 = np.random.random((10, 10)) + sink.addTile(tile1, 0, 0, level=1) + arrays = dict(sink._zarr.arrays()) + assert arrays.get('0') is not None + assert arrays.get('0').shape == (100, 100, 1) + assert arrays.get('1') is not None + assert arrays.get('1').shape == (10, 10, 1) + + tile1 = np.random.random((100, 100)) + sink.addTile(tile1, 0, 100) + arrays = dict(sink._zarr.arrays()) + assert arrays.get('0') is not None + assert arrays.get('0').shape == (200, 100, 1) + # previously written levels should be cleared after changing level 0 data + assert arrays.get('1') is None + + def testExtraAxis(): sink = large_image_source_zarr.new() sink.addTile(np.random.random((256, 256)), 0, 0, z=1) @@ -100,7 +123,7 @@ def testCrop(file_type, tmp_path): @pytest.mark.parametrize('file_type', FILE_TYPES) -def testImageCopySmall(file_type, tmp_path): +def testImageCopySmallFileTypes(file_type, tmp_path): output_file = tmp_path / f'test.{file_type}' sink = large_image_source_zarr.new() source = large_image_source_test.TestTileSource( @@ -121,13 +144,12 @@ def testImageCopySmall(file_type, tmp_path): assert metadata.get('bandCount') == 3 assert len(metadata.get('frames')) == 6 - # TODO: fix these failures; unexpected metadata when reading it back sink.write(output_file) if file_type == 'zarr': output_file /= '.zattrs' written = large_image.open(output_file) - new_metadata = written.metadata + new_metadata = written.getMetadata() assert new_metadata.get('sizeX') == 512 assert new_metadata.get('sizeY') == 1024 assert new_metadata.get('dtype') == 'uint8' @@ -136,13 +158,16 @@ def testImageCopySmall(file_type, tmp_path): assert len(new_metadata.get('frames')) == 6 -@pytest.mark.parametrize('file_type', FILE_TYPES) -def testImageCopySmallMultiband(file_type, tmp_path): - output_file = tmp_path / f'test.{file_type}' +@pytest.mark.parametrize('resample_method', [ + ResampleMethod.PIL_LANCZOS, + ResampleMethod.NP_NEAREST, +]) +def testImageCopySmallMultiband(resample_method, tmp_path): + output_file = tmp_path / f'test_{resample_method}.db' sink = large_image_source_zarr.new() bands = ( - 'red=400-12000,green=0-65535,blue=800-4000,' - 'ir1=200-24000,ir2=200-22000,gray=100-10000,other=0-65535' + 'red=0-255,green=0-255,blue=0-255,' + 'ir1=0-255,ir2=0-255,gray=0-255,other=0-255' ) source = large_image_source_test.TestTileSource( fractal=True, @@ -158,21 +183,128 @@ def testImageCopySmallMultiband(file_type, tmp_path): metadata = sink.getMetadata() assert metadata.get('sizeX') == 512 assert metadata.get('sizeY') == 1024 - assert metadata.get('dtype') == 'uint16' + assert metadata.get('dtype') == 'uint8' assert metadata.get('levels') == 2 assert metadata.get('bandCount') == 7 assert len(metadata.get('frames')) == 6 - # TODO: fix these failures; unexpected metadata when reading it back - sink.write(output_file) - if file_type == 'zarr': - output_file /= '.zattrs' + sink.write(output_file, resample=resample_method) written = large_image.open(output_file) new_metadata = written.getMetadata() assert new_metadata.get('sizeX') == 512 assert new_metadata.get('sizeY') == 1024 - assert new_metadata.get('dtype') == 'uint16' + assert new_metadata.get('dtype') == 'uint8' assert new_metadata.get('levels') == 2 or new_metadata.get('levels') == 3 assert new_metadata.get('bandCount') == 7 assert len(new_metadata.get('frames')) == 6 + + written_arrays = dict(written._zarr.arrays()) + assert len(written_arrays) == written.levels + assert written_arrays.get('0') is not None + assert written_arrays.get('0').shape == (2, 3, 1024, 512, 7) + assert written_arrays.get('1') is not None + assert written_arrays.get('1').shape == (2, 3, 512, 256, 7) + + +@pytest.mark.parametrize('resample_method', list(ResampleMethod)) +def testImageCopySmallDownsampling(resample_method, tmp_path): + output_file = tmp_path / f'test_{resample_method}.db' + sink = large_image_source_zarr.new() + source = large_image_source_test.TestTileSource( + fractal=True, + tileWidth=128, + tileHeight=128, + sizeX=511, + sizeY=1023, + frames='c=2,z=3', + ) + copyFromSource(source, sink) + + sink.write(output_file, resample=resample_method) + written = large_image.open(output_file) + + written_arrays = dict(written._zarr.arrays()) + assert len(written_arrays) == written.levels + assert written_arrays.get('0') is not None + assert written_arrays.get('0').shape == (2, 3, 1023, 511, 3) + assert written_arrays.get('1') is not None + assert written_arrays.get('1').shape == (2, 3, 512, 256, 3) + + sample_region, _format = written.getRegion( + region=dict(top=252, bottom=260, left=0, right=4), + output=dict(maxWidth=2, maxHeight=4), + format='numpy', + ) + assert sample_region.shape == (4, 2, 3) + white_mask = (sample_region[..., 0] == 255).flatten().tolist() + + expected_masks = { + ResampleMethod.PIL_NEAREST: [ + # expect any of the four variations, this will depend on version + [True, False, True, False, True, True, True, False], # upper left + [True, False, True, True, True, False, True, False], # lower left + [True, False, False, True, True, True, False, False], # upper right + [False, False, True, True, False, True, True, False], # lower right + ], + ResampleMethod.PIL_LANCZOS: + [[False, False, False, False, False, False, False, False]], + ResampleMethod.PIL_BILINEAR: + [[False, False, False, False, False, False, False, False]], + ResampleMethod.PIL_BICUBIC: + [[False, False, False, False, False, False, False, False]], + ResampleMethod.PIL_BOX: + [[False, False, False, False, False, False, False, False]], + ResampleMethod.PIL_HAMMING: + [[False, False, False, False, False, False, False, False]], + ResampleMethod.NP_MEAN: + [[False, False, False, False, False, False, False, False]], + ResampleMethod.NP_MEDIAN: + [[True, False, True, True, True, True, True, False]], + ResampleMethod.NP_MODE: + [[True, False, True, True, True, True, True, False]], + ResampleMethod.NP_MAX: + [[True, False, True, True, True, True, True, False]], + ResampleMethod.NP_MIN: + [[False, False, False, False, False, False, False, False]], + ResampleMethod.NP_NEAREST: + [[True, False, True, False, True, True, True, False]], + ResampleMethod.NP_MAX_COLOR: + [[True, False, True, True, True, True, True, False]], + ResampleMethod.NP_MIN_COLOR: + [[False, False, False, False, False, False, False, False]], + } + + assert white_mask in expected_masks[resample_method] + + +def testCropAndDownsample(tmp_path): + output_file = tmp_path / 'cropped.db' + sink = large_image_source_zarr.new() + + # add tiles with some overlap to multiple frames + num_frames = 4 + num_bands = 5 + for z in range(num_frames): + sink.addTile(np.random.random((1000, 1000, num_bands)), 0, 0, z=z) + sink.addTile(np.random.random((1000, 1000, num_bands)), 950, 0, z=z) + sink.addTile(np.random.random((1000, 1000, num_bands)), 0, 900, z=z) + sink.addTile(np.random.random((1000, 1000, num_bands)), 950, 900, z=z) + + current_arrays = dict(sink._zarr.arrays()) + assert len(current_arrays) == 1 + assert current_arrays.get('0') is not None + assert current_arrays.get('0').shape == (num_frames, 1900, 1950, num_bands) + + sink.crop = (100, 50, 1800, 1825) + sink.write(output_file) + written = large_image_source_zarr.open(output_file) + written_arrays = dict(written._zarr.arrays()) + + assert len(written_arrays) == written.levels + assert written_arrays.get('0') is not None + assert written_arrays.get('0').shape == (num_frames, 1800, 1825, num_bands) + assert written_arrays.get('1') is not None + assert written_arrays.get('1').shape == (num_frames, 900, 913, num_bands) + assert written_arrays.get('2') is not None + assert written_arrays.get('2').shape == (num_frames, 450, 456, num_bands)