Repository: rasterio/rasterio

Issue:
Epsilon used in transform.py::rowcol() appears to be introducing errors
I could be misinterpreting the intent of `epsilon` as used in this function, but combined with passing in `op=float` as used by `windows.py::from_bounds()` to get a window within a raster, it appears to be adding undesirable effects.

For example, take a 10x10 raster with `Affine.identity()` transform:
`rowcol(src.transform, 0, 0, op=float)` => `(-8e-07, 8e-07)`

but it seems like it should return `(0, 0)` instead.  

It seems like we would only want to apply the epsilon in particular cases.  The docstring doesn't quite match the implementation, so I'm not quite sure what to go by here (`floor`, `ceil`, `round` are specified as options for `op` but not `float`).

I can check in a failing test if that would help.  Our tests of `rowcol` do not cover this case.

This was first detected in #1136 and is confounding some of the refactoring there.

Internal rounding within windows.py::round_offsets() and round_lengths() should be optional
Use case:
given a floating point window, for example, derived from a polygon intersected with a raster
```
Window(col_off=1.9999, row_off=1.9999, width=3.001, height=3.001)
```

We want to be able to derive the outermost pixel indices that contain that polygon by default:
```
Window(col_off=1, row_off=1, width=4, height=4)
```
We can opt-in to rounding if that is desirable for a given use case (e.g., to snap to closest rather than outermost index).

Right now, `round_offsets()` and `round_lengths()` use internal rounding prior to applying `ceil` or `floor` to a given value:
https://github.com/mapbox/rasterio/blob/master/rasterio/windows.py#L601-L603

Seems like we have 2 options:
1. make the `pixel_precision` parameter optional, default `None`.  Only apply rounding if provided.
2. add a pair of new functions `snap_offsets()` and `snap_lengths()` that apply only `ceil` or `floor` and not internal rounding.




Discussion:
I'm wondering now if `precision` parameter on a raster's  `.window()` shouldn't be opt-in by default.   That is, `precision=None` (instead of `precision=6`) by default, which would then result in no `epsilon` being applied later in the processing chain.

@brendan-ward I've been fighting against floating point issues throughout Rasterio and not always in a good way. What you've noticed is a problem we need to solve now. I'm game to see precision default to `None` here (and `eps`, `0.0`). Shall I take this one on? 
@sgillies I can run with this so long as it doesn't need to happen super fast.
Status: I've been making slow but incremental progress on this.  I've been overhauling our window tests and leveraging `hypothesis` more heavily.  I wanted to get our tests really dialed in before I started changing implementation details.
@brendan-ward I'm inclined toward taking the first option.

Resolving patch:
diff --git a/rasterio/transform.py b/rasterio/transform.py
--- a/rasterio/transform.py
+++ b/rasterio/transform.py
@@ -46,7 +46,7 @@ def xy(self, row, col, offset="center"):
         """
         return xy(self.transform, row, col, offset=offset)
 
-    def index(self, x, y, op=math.floor, precision=6):
+    def index(self, x, y, op=math.floor, precision=None):
         """
         Returns the (row, col) index of the pixel containing (x, y) given a
         coordinate reference system.
@@ -64,7 +64,7 @@ def index(self, x, y, op=math.floor, precision=6):
         op : function, optional (default: math.floor)
             Function to convert fractional pixels to whole numbers (floor,
             ceiling, round)
-        precision : int, optional (default: 6)
+        precision : int, optional (default: None)
             Decimal places of precision in indexing, as in `round()`.
 
         Returns
@@ -188,7 +188,7 @@ def xy(transform, rows, cols, offset='center'):
     return xs, ys
 
 
-def rowcol(transform, xs, ys, op=math.floor, precision=6):
+def rowcol(transform, xs, ys, op=math.floor, precision=None):
     """
     Returns the rows and cols of the pixels containing (x, y) given a
     coordinate reference system.
@@ -208,7 +208,7 @@ def rowcol(transform, xs, ys, op=math.floor, precision=6):
     op : function
         Function to convert fractional pixels to whole numbers (floor, ceiling,
         round)
-    precision : int
+    precision : int, optional
         Decimal places of precision in indexing, as in `round()`.
 
     Returns
@@ -228,7 +228,11 @@ def rowcol(transform, xs, ys, op=math.floor, precision=6):
         ys = [ys]
         single_y = True
 
-    eps = 10.0 ** -precision * (1.0 - 2.0 * op(0.1))
+    if precision is None:
+        eps = 0.0
+    else:
+        eps = 10.0 ** -precision * (1.0 - 2.0 * op(0.1))
+
     invtransform = ~transform
 
     rows = []
diff --git a/rasterio/windows.py b/rasterio/windows.py
--- a/rasterio/windows.py
+++ b/rasterio/windows.py
@@ -43,7 +43,7 @@ class WindowMethodsMixin(object):
     properties: `transform`, `height` and `width`
     """
 
-    def window(self, left, bottom, right, top, precision=6):
+    def window(self, left, bottom, right, top, precision=None):
         """Get the window corresponding to the bounding coordinates.
 
         The resulting window is not cropped to the row and column
@@ -252,7 +252,7 @@ def intersects(range1, range2):
 
 
 def from_bounds(left, bottom, right, top, transform=None,
-                height=None, width=None, precision=6):
+                height=None, width=None, precision=None):
     """Get the window corresponding to the bounding coordinates.
 
     Parameters
@@ -475,9 +475,6 @@ def validate_length_value(instance, attribute, value):
         raise ValueError("Number of columns or rows must be non-negative")
 
 
-_default = attr.Factory(lambda x: 0.0 if x is None else float(x))
-
-
 @attr.s(slots=True)
 class Window(object):
     """Windows are rectangular subsets of rasters.
@@ -496,10 +493,10 @@ class Window(object):
     this is a bit confusing in the new float precision world and the
     attributes have been changed. The originals are deprecated.
     """
-    col_off = attr.ib(default=_default)
-    row_off = attr.ib(default=_default)
-    width = attr.ib(default=_default, validator=validate_length_value)
-    height = attr.ib(default=_default, validator=validate_length_value)
+    col_off = attr.ib()
+    row_off = attr.ib()
+    width = attr.ib(validator=validate_length_value)
+    height = attr.ib(validator=validate_length_value)
 
     def __repr__(self):
         """Return a nicely formatted representation string"""
@@ -638,7 +635,7 @@ def from_slices(cls, rows, cols, height=-1, width=-1, boundless=False):
         return cls(col_off=col_off, row_off=row_off, width=num_cols,
                    height=num_rows)
 
-    def round_lengths(self, op='floor', pixel_precision=3):
+    def round_lengths(self, op='floor', pixel_precision=None):
         """Return a copy with width and height rounded.
 
         Lengths are rounded to the nearest whole number. The offsets
@@ -648,7 +645,7 @@ def round_lengths(self, op='floor', pixel_precision=3):
         ----------
         op: str
             'ceil' or 'floor'
-        pixel_precision: int
+        pixel_precision: int, optional (default: None)
             Number of places of rounding precision.
 
         Returns
@@ -659,13 +656,16 @@ def round_lengths(self, op='floor', pixel_precision=3):
         if not operator:
             raise WindowError("operator must be 'ceil' or 'floor'")
         else:
-            return Window(self.col_off, self.row_off,
-                          operator(round(self.width, pixel_precision)),
-                          operator(round(self.height, pixel_precision)))
+            return Window(
+                self.col_off, self.row_off,
+                operator(round(self.width, pixel_precision) if
+                         pixel_precision is not None else self.width),
+                operator(round(self.height, pixel_precision) if
+                         pixel_precision is not None else self.height))
 
     round_shape = round_lengths
 
-    def round_offsets(self, op='floor', pixel_precision=3):
+    def round_offsets(self, op='floor', pixel_precision=None):
         """Return a copy with column and row offsets rounded.
 
         Offsets are rounded to the nearest whole number. The lengths
@@ -673,9 +673,9 @@ def round_offsets(self, op='floor', pixel_precision=3):
 
         Parameters
         ----------
-        op: str
+        op : str
             'ceil' or 'floor'
-        pixel_precision: int
+        pixel_precision : int, optional (default: None)
             Number of places of rounding precision.
 
         Returns
@@ -686,9 +686,12 @@ def round_offsets(self, op='floor', pixel_precision=3):
         if not operator:
             raise WindowError("operator must be 'ceil' or 'floor'")
         else:
-            return Window(operator(round(self.col_off, pixel_precision)),
-                          operator(round(self.row_off, pixel_precision)),
-                          self.width, self.height)
+            return Window(
+                operator(round(self.col_off, pixel_precision) if
+                         pixel_precision is not None else self.col_off),
+                operator(round(self.row_off, pixel_precision) if
+                         pixel_precision is not None else self.row_off),
+                self.width, self.height)
 
     def crop(self, height, width):
         """Return a copy cropped to height and width"""


Test patch:
diff --git a/tests/test_features.py b/tests/test_features.py
--- a/tests/test_features.py
+++ b/tests/test_features.py
@@ -124,7 +124,7 @@ def test_geometry_mask_invalid_shape(basic_geometry):
 
 
 def test_geometry_mask_no_transform(basic_geometry):
-    with pytest.raises(TypeError) as exc_info:
+    with pytest.raises(TypeError):
         geometry_mask(
             [basic_geometry],
             out_shape=DEFAULT_SHAPE,
@@ -150,8 +150,6 @@ def test_geometry_window_rotation(rotated_image_file, rotation_geometry):
         assert window.flatten() == (898, 439, 467, 399)
 
 
-@pytest.mark.xfail(reason="https://github.com/mapbox/rasterio/issues/1139")
-# This test is failing due to https://github.com/mapbox/rasterio/issues/1139
 def test_geometry_window_pixel_precision(basic_image_file):
     """Window offsets should be floor, width and height ceiling"""
 
@@ -216,7 +214,7 @@ def test_geometry_window_large_shapes(basic_image_file):
 
 def test_geometry_window_no_overlap(path_rgb_byte_tif, basic_geometry):
     """Geometries that do not overlap raster raises WindowError"""
-    
+
     with rasterio.open(path_rgb_byte_tif) as src:
         with pytest.raises(WindowError):
             geometry_window(src, [basic_geometry], north_up=False)
@@ -436,9 +434,9 @@ def test_rasterize_geomcollection_no_hole():
 
     geomcollection = {'type': 'GeometryCollection', 'geometries': [
         {'type': 'Polygon',
-        'coordinates': (((0, 0), (0, 5), (5, 5), (5, 0), (0, 0)),)},
+            'coordinates': (((0, 0), (0, 5), (5, 5), (5, 0), (0, 0)),)},
         {'type': 'Polygon',
-        'coordinates': (((2, 2), (2, 7), (7, 7), (7, 2), (2, 2)),)}
+            'coordinates': (((2, 2), (2, 7), (7, 7), (7, 2), (2, 2)),)}
     ]}
 
     expected = rasterize(geomcollection['geometries'], out_shape=DEFAULT_SHAPE)
@@ -731,7 +729,7 @@ def test_rasterize_internal_driver_manager(basic_geometry):
     assert rasterize([basic_geometry], out_shape=DEFAULT_SHAPE).sum() == 4
 
 
-def test_rasterize_geo_interface(geojson_polygon):
+def test_rasterize_geo_interface_2(geojson_polygon):
     """Objects that implement the geo interface should rasterize properly"""
 
     class GeoObj:
@@ -742,7 +740,6 @@ def __geo_interface__(self):
     assert rasterize([GeoObj()], out_shape=DEFAULT_SHAPE).sum() == 4
 
 
-
 def test_shapes(basic_image):
     """Test creation of shapes from pixel values."""
     results = list(shapes(basic_image))
diff --git a/tests/test_windows.py b/tests/test_windows.py
--- a/tests/test_windows.py
+++ b/tests/test_windows.py
@@ -26,7 +26,6 @@
 I_LEN = integers(min_value=0, max_value=1.0e+7)
 
 
-
 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
 
 
@@ -34,7 +33,6 @@ def assert_window_almost_equals(a, b):
     assert np.allclose(a.flatten(), b.flatten(), rtol=1e-3, atol=1e-4)
 
 
-
 def test_window_repr():
     assert str(Window(0, 1, 4, 2)) == ('Window(col_off=0, row_off=1, width=4, '
                                        'height=2)')
@@ -42,7 +40,7 @@ def test_window_repr():
 
 @given(col_off=F_OFF, row_off=F_OFF, width=F_LEN, height=F_LEN)
 def test_window_class(col_off, row_off, width, height):
-    """Floating point inputs should not be rounded, and 0 values should not 
+    """Floating point inputs should not be rounded, and 0 values should not
     raise errors"""
 
     window = Window(col_off, row_off, width, height)
@@ -120,7 +118,6 @@ def test_window_fromslices(col_off, row_off, col_stop, row_stop):
     cols = (col_off, col_stop)
     expected = (col_off, row_off, col_stop - col_off, row_stop - row_off)
 
-
     assert np.allclose(
         Window.from_slices(rows=slice(*rows), cols=slice(*cols)).flatten(),
         expected
@@ -555,3 +552,13 @@ def test_round_window_boundless(path_alpha_tif):
         assert rounded_window.height % height_shape == 0
         assert rounded_window.col_off % width_shape == 0
         assert rounded_window.width % width_shape == 0
+
+
+def test_round_lengths_no_op_error():
+    with pytest.raises(WindowError):
+        Window(0, 0, 1, 1).round_lengths(op='lolwut')
+
+
+def test_round_offsets_no_op_error():
+    with pytest.raises(WindowError):
+        Window(0, 0, 1, 1).round_offsets(op='lolwut')
diff --git a/tests/test_windows_mixins.py b/tests/test_windows_mixins.py
new file mode 100644
--- /dev/null
+++ b/tests/test_windows_mixins.py
@@ -0,0 +1,95 @@
+from hypothesis import given
+from hypothesis.strategies import composite, floats, integers
+
+from rasterio import windows
+from rasterio.coords import BoundingBox
+
+
+class FakeDataset(windows.WindowMethodsMixin):
+    """A test double to support testing of the mixin class"""
+
+    def __init__(self, **kwargs):
+        self.transform = kwargs.get('transform')
+        self.height = kwargs.get('height')
+        self.width = kwargs.get('width')
+
+    @property
+    def bounds(self):
+        a, b, c, d, e, f, _, _, _ = self.transform
+        return BoundingBox(c, f + e * self.height, c + a * self.width, f)
+
+    def __repr__(self):
+        return "<FakeDataset: transform={}, height={}, width={}>".format(self.transform, self.height, self.width)
+
+
+@composite
+def dataset_utm(draw):
+    """Generate a fake UTM dataset with an origin, a resolution, and a finite size"""
+    x = draw(floats(min_value=-1e6, max_value=1e+6, allow_nan=False, allow_infinity=False))
+    y = draw(floats(min_value=-1e6, max_value=1e+6, allow_nan=False, allow_infinity=False))
+    res = draw(floats(min_value=0.1, max_value=30, allow_nan=False, allow_infinity=False))
+    h = draw(integers(min_value=1, max_value=1000))
+    w = draw(integers(min_value=1, max_value=1000))
+    return FakeDataset(
+        transform=windows.Affine.identity() * windows.Affine.translation(x, y) * windows.Affine.scale(res, -res),
+        height=h, width=w)
+
+
+@composite
+def dataset_utm_north_down(draw):
+    """Generate a fake UTM dataset with an origin, a resolution, and a finite size"""
+    x = draw(floats(min_value=-1e6, max_value=1e+6, allow_nan=False, allow_infinity=False))
+    y = draw(floats(min_value=-1e6, max_value=1e+6, allow_nan=False, allow_infinity=False))
+    res = draw(floats(min_value=0.1, max_value=30, allow_nan=False, allow_infinity=False))
+    h = draw(integers(min_value=1, max_value=1000))
+    w = draw(integers(min_value=1, max_value=1000))
+    return FakeDataset(
+        transform=windows.Affine.identity() * windows.Affine.translation(x, y) * windows.Affine.scale(res),
+        height=h, width=w)
+
+
+def assert_windows_almost_equal(a, b):
+    assert round(a.col_off, 7) == round(b.col_off, 7)
+    assert round(a.row_off, 7) == round(b.row_off, 7)
+    assert round(a.width, 7) == round(b.width, 7)
+    assert round(a.height, 7) == round(b.height, 7)
+
+
+@given(dataset=dataset_utm())
+def test_window_rt(dataset):
+    """Get correct window for full dataset extent"""
+    left, top = dataset.transform * (0, 0)
+    right, bottom = dataset.transform * (dataset.width, dataset.height)
+    assert_windows_almost_equal(
+        dataset.window(left, bottom, right, top),
+        windows.Window(0, 0, dataset.width, dataset.height))
+
+
+@given(dataset=dataset_utm_north_down())
+def test_window_rt_north_down(dataset):
+    """Get correct window for full dataset extent"""
+    left, top = dataset.transform * (0, 0)
+    right, bottom = dataset.transform * (dataset.width, dataset.height)
+    assert_windows_almost_equal(
+        dataset.window(left, bottom, right, top),
+        windows.Window(0, 0, dataset.width, dataset.height))
+
+
+@given(dataset=dataset_utm())
+def test_window_transform_rt(dataset):
+    assert dataset.window_transform(windows.Window(0, 0, dataset.width, dataset.height)) == dataset.transform
+
+
+@given(dataset=dataset_utm_north_down())
+def test_window_transform_rt_north_down(dataset):
+    assert dataset.window_transform(windows.Window(0, 0, dataset.width, dataset.height)) == dataset.transform
+
+
+@given(dataset=dataset_utm())
+def test_window_bounds_rt(dataset):
+    assert dataset.window_bounds(windows.Window(0, 0, dataset.width, dataset.height)) == dataset.bounds
+
+
+@given(dataset=dataset_utm_north_down())
+def test_window_bounds_rt_north_down(dataset):
+    assert dataset.window_bounds(windows.Window(0, 0, dataset.width, dataset.height)) == dataset.bounds