Refactor and test improvements

Traded the `int_reshape` function for a `round_shape` method on
the Window class with better docs and clearer semantics.
This commit is contained in:
Sean Gillies 2017-06-16 17:20:15 +02:00
parent 56a6eba992
commit 361112e825
8 changed files with 189 additions and 121 deletions

View File

@ -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:

View File

@ -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."""

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -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)))

View File

@ -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

View File

@ -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