diff --git a/rasterio/_io.pyx b/rasterio/_io.pyx index 3170095b..6fd4e63b 100644 --- a/rasterio/_io.pyx +++ b/rasterio/_io.pyx @@ -225,14 +225,14 @@ cdef class DatasetReaderBase(DatasetBase): if window: if isinstance(window, tuple): - windows.warn_window_deprecation() + window = windows.coerce_and_warn(window) if not boundless: window = windows.crop( windows.evaluate(window, self.height, self.width), self.height, self.width) - int_window = windows.int_reshape(window) + int_window = window.round_shape() win_shape += (int(int_window.num_rows), int(int_window.num_cols)) else: @@ -463,14 +463,14 @@ cdef class DatasetReaderBase(DatasetBase): if window: if isinstance(window, tuple): - windows.warn_window_deprecation() + window = windows.coerce_and_warn(window) if not boundless: window = windows.crop( windows.evaluate(window, self.height, self.width), self.height, self.width) - int_window = windows.int_reshape(window) + int_window = window.round_shape() win_shape += (int(int_window.num_rows), int(int_window.num_cols)) else: diff --git a/rasterio/errors.py b/rasterio/errors.py index 41dde28c..73bbcf5c 100644 --- a/rasterio/errors.py +++ b/rasterio/errors.py @@ -30,7 +30,7 @@ class RasterioIOError(IOError): registered format drivers.""" -class NodataShadowWarning(Warning): +class NodataShadowWarning(UserWarning): """Warn that a dataset's nodata attribute is shadowing its alpha band.""" def __str__(self): @@ -39,7 +39,7 @@ class NodataShadowWarning(Warning): "by the nodata attribute") -class NotGeoreferencedWarning(Warning): +class NotGeoreferencedWarning(UserWarning): """Warn that a dataset isn't georeferenced.""" diff --git a/rasterio/mask.py b/rasterio/mask.py index 1a88ec14..25daf295 100644 --- a/rasterio/mask.py +++ b/rasterio/mask.py @@ -6,7 +6,6 @@ import warnings import rasterio from rasterio.features import geometry_mask -from rasterio.windows import int_reshape logger = logging.getLogger(__name__) @@ -103,9 +102,9 @@ def mask(raster, shapes, nodata=None, crop=False, all_touched=False, if crop: bounds_window = raster.window(*mask_bounds) - # Call int_reshape to get the window with integer height + # Get the window with integer height # and width that contains the bounds window. - out_window = int_reshape(bounds_window) + out_window = bounds_window.round_shape() height = int(out_window.num_rows) width = int(out_window.num_cols) diff --git a/rasterio/merge.py b/rasterio/merge.py index 49d6a9b7..1b2bc779 100644 --- a/rasterio/merge.py +++ b/rasterio/merge.py @@ -145,7 +145,7 @@ def merge(sources, bounds=None, res=None, nodata=None, precision=7): boundless=True) logger.debug("Src %s window: %r", src.name, src_window) - src_window = windows.int_reshape(src_window) + src_window = src_window.round_shape() # 3. Compute the destination window. dst_window = windows.from_bounds( diff --git a/rasterio/rio/clip.py b/rasterio/rio/clip.py index 1034ee9a..7b3d6f29 100644 --- a/rasterio/rio/clip.py +++ b/rasterio/rio/clip.py @@ -7,7 +7,6 @@ from .helpers import resolve_inout from . import options import rasterio from rasterio.coords import disjoint_bounds -from rasterio.windows import int_reshape # Geographic (default), projected, or Mercator switch. @@ -102,9 +101,9 @@ def clip(ctx, files, output, bounds, like, driver, projection, bounds_window = src.window(*bounds) - # Call int_reshape to get the window with integer height + # Get the window with integer height # and width that contains the bounds window. - out_window = int_reshape(bounds_window) + out_window = bounds_window.round_shape() height = int(out_window.num_rows) width = int(out_window.num_cols) diff --git a/rasterio/windows.py b/rasterio/windows.py index d8f62921..faf384ef 100644 --- a/rasterio/windows.py +++ b/rasterio/windows.py @@ -29,6 +29,15 @@ def warn_window_deprecation(): DeprecationWarning) +def coerce_and_warn(ranges): + """Coerce ranges to Window and warn""" + warn_window_deprecation() + if not (len(ranges) == 2 and len(ranges[0]) == 2 and len(ranges[1]) == 2): + raise ValueError("Not a valid pair of ranges") + + return Window.from_ranges(*ranges) + + def iter_args(function): """Decorator to allow function to take either *args or a single iterable which gets expanded to *args. @@ -172,7 +181,7 @@ def intersect(*windows): return True -def from_bounds(left, bottom, right, top, transform, +def from_bounds(left, bottom, right, top, transform=None, height=None, width=None, boundless=False, precision=6): """Get the window corresponding to the bounding coordinates. @@ -213,33 +222,6 @@ def from_bounds(left, bottom, right, top, transform, return crop(window, height, width) -def int_reshape(window, pixel_precision=3): - """Converts floating point value Windows to integer value Windows. - - Parameters - ---------- - window : Window - Input window with floating point values. - pixel_precision : int - Rounding precision in decimal places. - Returns - ------- - Window - A new Window - """ - if isinstance(window, tuple): - warn_window_deprecation() - return Window.from_offlen( - window[1][0], window[0][0], - math.ceil(round(window[1][1] - window[1][0], pixel_precision)), - math.ceil(round(window[0][1] - window[0][0], pixel_precision))) - else: - return Window.from_offlen( - window.col_off, window.row_off, - math.ceil(round(window.num_cols, pixel_precision)), - math.ceil(round(window.num_rows, pixel_precision))) - - def transform(window, transform): """Construct an affine transform matrix relative to a window. @@ -255,13 +237,12 @@ def transform(window, transform): Affine The affine transform matrix for the given window """ - if isinstance(window, tuple): - warn_window_deprecation() - r, c = window[0][0], window[1][0] - else: - r, c = window.row_off, window.col_off + window = (coerce_and_warn(window) if isinstance(window, tuple) else + window) - return transform * Affine.translation(c or 0, r or 0) + x, y = transform * (window.col_off or 0.0, window.row_off or 0.0) + return Affine.translation( + x - transform.c, y - transform.f) * transform def bounds(window, transform): @@ -269,28 +250,27 @@ def bounds(window, transform): Parameters ---------- - window : a Window or window tuple + window: a Window or window tuple The input window. transform: Affine an affine transform matrix. Returns ------- - x_min, y_min, x_max, y_max : float + left, bottom, right, top: float A tuple of spatial coordinate bounding values. """ - if isinstance(window, tuple): - warn_window_deprecation() - (row_min, row_max), (col_min, col_max) = window - else: - row_min = window.row_off - row_max = row_min + window.num_rows - col_min = window.col_off - col_max = col_min + window.num_cols + window = (coerce_and_warn(window) if isinstance(window, tuple) else + window) + + row_min = window.row_off + row_max = row_min + window.num_rows + col_min = window.col_off + col_max = col_min + window.num_cols - x_min, y_min = transform * (col_min, row_max) - x_max, y_max = transform * (col_max, row_min) - return x_min, y_min, x_max, y_max + left, bottom = transform * (col_min, row_max) + right, top = transform * (col_max, row_min) + return left, bottom, right, top def crop(window, height, width): @@ -309,18 +289,15 @@ def crop(window, height, width): A new Window object. """ if isinstance(window, tuple): - warn_window_deprecation() - (row_min, row_max), (col_min, col_max) = window - else: - row_min = window.row_off - row_max = row_min + window.num_rows - col_min = window.col_off - col_max = col_min + window.num_cols + window = coerce_and_warn(window) - return Window.from_ranges( - (min(max(row_min, 0), height), max(0, min(row_max, height))), - (min(max(col_min, 0), width), max(0, min(col_max, width)))) + row_start = min(max(window.row_off, 0), height) + col_start = min(max(window.col_off, 0), width) + row_stop = max(0, min(window.row_off + window.num_rows, height)) + col_stop = max(0, min(window.col_off + window.num_cols, width)) + return Window.from_ranges((row_start, row_stop), (col_start, col_stop)) + def evaluate(window, height, width): """Evaluates a window tuple that may contain relative index values. @@ -342,16 +319,9 @@ def evaluate(window, height, width): A new Window object with absolute index values. """ if isinstance(window, tuple): - warn_window_deprecation() - try: - r, c = window - assert len(r) == 2 - assert len(c) == 2 - except (ValueError, TypeError, AssertionError): - raise ValueError("invalid window structure; expecting ints" - "((row_start, row_stop), (col_start, col_stop))") - else: - r, c = window.toranges() + window = coerce_and_warn(window) + + r, c = window.toranges() r_start = r[0] or 0 if r_start < 0: @@ -408,6 +378,8 @@ def shape(window, height=-1, width=-1): def window_index(window): """Construct a pair of slice objects for ndarray indexing + Starting indexes are rounded down, Stopping indexes are rounded up. + Parameters ---------- window : a Window or window tuple @@ -418,11 +390,13 @@ def window_index(window): row_slice, col_slice: slice A pair of slices in row, column order """ - if isinstance(window, tuple): - warn_window_deprecation() - return tuple(slice(*w) for w in window) - else: - return tuple(slice(*w) for w in window.toranges()) + window = (coerce_and_warn(window) if isinstance(window, tuple) else + window) + + r, c = window.toranges() + return ( + slice(int(math.floor(r[0])), int(math.ceil(r[1]))), + slice(int(math.floor(c[0])), int(math.ceil(c[1])))) def round_window_to_full_blocks(window, block_shapes): @@ -441,17 +415,17 @@ def round_window_to_full_blocks(window, block_shapes): ------- Window """ - if len(set(block_shapes)) != 1: - raise ValueError('All bands must have the same block/stripe structure') + if len(set(block_shapes)) != 1: # pragma: no cover + raise ValueError( + "All bands must have the same block/stripe structure") + window = (coerce_and_warn(window) if isinstance(window, tuple) else + window) + height_shape = block_shapes[0][0] width_shape = block_shapes[0][1] - if isinstance(window, tuple): - warn_window_deprecation() - row_range, col_range = window - else: - row_range, col_range = window.toranges() + row_range, col_range = window.toranges() row_min = int(row_range[0] // height_shape) * height_shape row_max = int(row_range[1] // height_shape) * height_shape + \ @@ -516,10 +490,11 @@ class Window(object): num_rows=self.num_rows) def toranges(self): - """A pair of range tuples""" + """Makes an equivalent pair of range tuples""" + row_stop = None if self.num_rows is None else self.row_off + self.num_rows + col_stop = None if self.num_cols is None else self.col_off + self.num_cols return ( - (self.row_off, self.row_off + self.num_rows), - (self.col_off, self.col_off + self.num_cols)) + (self.row_off, row_stop), (self.col_off, col_stop)) def toslices(self): """Slice objects for use as an ndarray indexer. @@ -553,9 +528,12 @@ class Window(object): ------- Window """ - return cls(col_range[0], row_range[0], - col_range[1] - col_range[0], - row_range[1] - row_range[0]) + col_off = col_range[0] or 0.0 + row_off = row_range[0] or 0.0 + num_cols = None if col_range[1] is None else col_range[1] - col_off + num_rows = None if row_range[1] is None else row_range[1] - row_off + return cls(col_off, row_off, num_cols, num_rows) + @classmethod def from_offlen(cls, col_off, row_off, num_cols, num_rows): @@ -573,3 +551,28 @@ class Window(object): Window """ return cls(col_off, row_off, num_cols, num_rows) + + def round_shape(self, op='ceil', pixel_precision=3): + """Return a copy with column and row numbers rounded + + Numbers are rounded to the nearest whole number. The offsets + are not changed. + + Parameters + ---------- + op: str + 'ceil' or 'floor' + pixel_precision: int + Number of places of rounding precision. + + Returns + ------- + Window + """ + operator = getattr(math, op, None) + if not operator: + raise ValueError("operator must be 'ceil' or 'floor'") + + return Window(self.col_off, self.row_off, + operator(round(self.num_cols, pixel_precision)), + operator(round(self.num_rows, pixel_precision))) diff --git a/tests/test_transform.py b/tests/test_transform.py index 3846e073..3550fc13 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -3,6 +3,7 @@ import pytest import rasterio from rasterio import transform from rasterio.transform import xy, rowcol +from rasterio.windows import Window def test_window_transform(): @@ -59,7 +60,7 @@ def test_window_bounds(): # Test a small window in each corner, both in and slightly out of bounds p = 10 - for window in ( + for ranges in ( # In bounds (UL, UR, LL, LR) ((0, p), (0, p)), ((0, p), (cols - p, p)), @@ -74,7 +75,8 @@ def test_window_bounds(): # Alternate formula - ((row_min, row_max), (col_min, col_max)) = window + window = Window.from_ranges(*ranges) + (row_min, row_max), (col_min, col_max) = ranges win_aff = src.window_transform(window) x_min, y_max = win_aff.c, win_aff.f diff --git a/tests/test_windows.py b/tests/test_windows.py index 3a4622c1..2e6cf1db 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -1,25 +1,26 @@ from copy import copy import logging +import math import sys from affine import Affine from hypothesis import given -from hypothesis.strategies import floats +from hypothesis.strategies import floats, integers import numpy as np import pytest import rasterio from rasterio.windows import ( - from_bounds, bounds, transform, evaluate, window_index, shape, Window, - intersect, intersection, get_data_window, union, round_window_to_full_blocks) - + crop, from_bounds, bounds, transform, evaluate, window_index, shape, Window, + intersect, intersection, get_data_window, union, round_window_to_full_blocks, + toranges) EPS = 1.0e-8 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) -def assert_window_almost_equals(a, b, precision=6): +def assert_window_almost_equals(a, b, precision=3): for pair_outer in zip(a, b): for x, y in zip(*pair_outer): assert round(x, precision) == round(y, precision) @@ -37,6 +38,53 @@ def test_window_ctor(col_off, row_off, num_cols, num_rows): assert window.num_rows == num_rows + +@given(col_off=floats(min_value=-1.0e+7, max_value=1.0e+7), + row_off=floats(min_value=-1.0e+7, max_value=1.0e+7), + num_cols=floats(min_value=0.0, max_value=1.0e+7), + num_rows=floats(min_value=0.0, max_value=1.0e+7)) +def test_window_round_up(col_off, row_off, num_cols, num_rows): + window = Window(col_off, row_off, num_cols, num_rows).round_shape() + assert window.col_off == col_off + assert window.row_off == row_off + assert window.num_cols == math.ceil(round(num_cols, 3)) + assert window.num_rows == math.ceil(round(num_rows, 3)) + + +def test_round_shape_invalid_op(): + with pytest.raises(ValueError): + Window(0, 0, 1, 1).round_shape(op='bogus') + + +@given(col_off=floats(min_value=-1.0e+7, max_value=1.0e+7), + row_off=floats(min_value=-1.0e+7, max_value=1.0e+7), + num_cols=floats(min_value=0.0, max_value=1.0e+7), + num_rows=floats(min_value=0.0, max_value=1.0e+7), + height=integers(min_value=0, max_value=10000000), + width=integers(min_value=0, max_value=10000000)) +def test_crop(col_off, row_off, num_cols, num_rows, height, width): + window = crop(Window(col_off, row_off, num_cols, num_rows), height, width) + assert 0.0 <= round(window.col_off, 3) <= width + assert 0.0 <= round(window.row_off, 3) <= height + assert round(window.num_cols, 3) <= round(width - window.col_off, 3) + assert round(window.num_rows, 3) <= round(height - window.row_off, 3) + + +def test_crop_coerce(): + with pytest.warns(DeprecationWarning): + assert crop(((-10, 10), (-10, 10)), 5, 5).num_cols == 5 + + +def test_transform_coerce(): + with pytest.warns(DeprecationWarning): + assert transform(((-10, 10), (-10, 10)), Affine.identity()).a == 1.0 + + +def test_window_index_coerce(): + with pytest.warns(DeprecationWarning): + assert window_index(((-10, 10), (-10, 10)))[0].start == -10 + + def test_window_function(): # TODO: break this test up. with rasterio.open('tests/data/RGB.byte.tif') as src: @@ -53,11 +101,6 @@ def test_window_function(): left, top - 2 * dy - EPS, left + 2 * dx - EPS, top, src.transform, height, width), ((0, 2), (0, 2))) - # bounds cropped - assert_window_almost_equals(from_bounds( - left - 2 * dx, top - 2 * dy, left + 2 * dx, top + 2 * dy, - src.transform, height, width), ((0, 2), (0, 2))) - # boundless assert_window_almost_equals(from_bounds( left - 2 * dx, top - 2 * dy, left + 2 * dx, top + 2 * dy, @@ -84,10 +127,16 @@ def test_window_bounds_south_up(): Window(0, 0, 10, 10), precision=5) + def test_toranges(): assert Window(0, 0, 1, 1).toranges() == ((0, 1), (0, 1)) +def test_toranges_warn(): + with pytest.warns(DeprecationWarning): + toranges(((0, 1), (0, 1))) + + def test_window_function(): # TODO: break this test up. with rasterio.open('tests/data/RGB.byte.tif') as src: @@ -104,11 +153,6 @@ def test_window_function(): left, top - 2 * dy - EPS, left + 2 * dx - EPS, top, src.transform, height, width), ((0, 2), (0, 2))) - # bounds cropped - assert_window_almost_equals(from_bounds( - left - 2 * dx, top - 2 * dy, left + 2 * dx, top + 2 * dy, - src.transform, height, width), ((0, 2), (0, 2))) - # boundless assert_window_almost_equals(from_bounds( left - 2 * dx, top - 2 * dy, left + 2 * dx, top + 2 * dy, @@ -155,6 +199,7 @@ def test_window_bounds_north_up(): precision=5) +@pytest.mark.xfail(reason="feature eliminated") def test_window_function_valuerror(): with rasterio.open('tests/data/RGB.byte.tif') as src: left, bottom, right, top = src.bounds @@ -186,17 +231,27 @@ def test_window_bounds_function(): assert bounds(((0, rows), (0, cols)), src.transform) == src.bounds -bad_windows = ( +bad_value_windows = [ (1, 2, 3), - (1, 2), - ((1, 0), 2)) + ((1, 0), (2,))] -@pytest.mark.parametrize("window", bad_windows) -def test_eval_window_bad_structure(window): +bad_type_windows = [ + (1, 2), + ((1, 0), 2)] + + +@pytest.mark.parametrize("window", bad_value_windows) +def test_eval_window_bad_value(window): with pytest.raises(ValueError): evaluate(window, 10, 10) +@pytest.mark.parametrize("window", bad_type_windows) +def test_eval_window_bad_type(window): + with pytest.raises(TypeError): + evaluate(window, 10, 10) + + bad_params = ( (((-1, 10), (0, 10)), -1, 10), (((1, -1), (0, 10)), -1, 10), @@ -257,6 +312,9 @@ def test_shape_positive(): def test_shape_negative(): assert shape(((-10, None), (-10, None)), 100, 90) == (10, 10) assert shape(((~0, None), (~0, None)), 100, 90) == (1, 1) + + +def test_shape_negative_start(): assert shape(((None, ~0), (None, ~0)), 100, 90) == (99, 89) @@ -409,7 +467,7 @@ def test_intersection(): def test_round_window_to_full_blocks(): - with rasterio.open('tests/data/alpha.tif') as src: + with rasterio.open('tests/data/alpha.tif') as src, pytest.warns(DeprecationWarning): block_shapes = src.block_shapes test_window = ((321, 548), (432, 765)) rounded_window = round_window_to_full_blocks(test_window, block_shapes) @@ -421,6 +479,13 @@ def test_round_window_to_full_blocks(): assert rounded_window[1][0] % width_shape == 0 assert rounded_window[1][1] % width_shape == 0 + +def test_round_window_to_full_blocks_error(): + with pytest.raises(ValueError): + round_window_to_full_blocks( + Window(0, 0, 10, 10), block_shapes=[(1, 1), (2, 2)]) + + def test_round_window_already_at_edge(): with rasterio.open('tests/data/alpha.tif') as src: block_shapes = src.block_shapes