Repository: rasterio/rasterio

Issue:
transform function naming and signatures
Picking up after #805 is merged, see initial discussion there

**Currently** we have in the `rasterio.transform` module

```
xy(row, col, transform)
get_index(x, y, transform)
```

There is a need to
- make it clear that these functions are the inverse of each other through naming conventions
- test that they are indeed inverse functions
- keep their signatures roughly in sync
- accept iterables (for performance reasons; function call overhead can be significant) or single values

The **proposed** new design would be

```
# rasterio.transform functions
xy(transform, rows, cols)
rowcol(transform, xs, ys)

# dataset methods
src.index(xs, ys)  # Note: index naming stays here
src.ul(rows, cols)  # deprecated in favor of src.xy(offset='ul')
src.xy(rows, cols)
```

We should be able to do this with a minimum of refactoring and since the functions themselves are a new addition and single values would still be accepted, there should be no backwards compatibility concerns.

cc @geowurster @sgillies 



Discussion:

Resolving patch:
diff --git a/rasterio/io.py b/rasterio/io.py
--- a/rasterio/io.py
+++ b/rasterio/io.py
@@ -11,7 +11,7 @@
 from rasterio._io import (
     DatasetReaderBase, DatasetWriterBase, BufferedDatasetWriterBase)
 from rasterio import windows
-from rasterio.transform import guard_transform, xy, get_index
+from rasterio.transform import guard_transform, xy, rowcol
 
 
 class TransformMethodsMixin(object):
@@ -46,7 +46,7 @@ def xy(self, row, col, offset="center"):
         tuple
             ``(x, y)``
         """
-        return xy(row, col, self.transform, offset=offset)
+        return xy(self.tranform, row, col, offset=offset)
 
     def ul(self, row, col):
         """Returns the coordinates (x, y) of the upper left corner of a
@@ -57,7 +57,7 @@ def ul(self, row, col):
         """
         warnings.warn("ul method is deprecated. Use xy(row, col, offset='ul')",
                       DeprecationWarning)
-        return xy(row, col, self.transform, offset='ul')
+        return xy(self.transform, row, col, offset='ul')
 
     def index(self, x, y, op=math.floor, precision=6):
         """
@@ -85,8 +85,7 @@ def index(self, x, y, op=math.floor, precision=6):
         tuple
             (row index, col index)
         """
-        return get_index(x, y, self.transform, op=op, precision=precision)
-
+        return rowcol(self.transform, x, y, op=op, precision=precision)
 
 
 class WindowMethodsMixin(object):
diff --git a/rasterio/transform.py b/rasterio/transform.py
--- a/rasterio/transform.py
+++ b/rasterio/transform.py
@@ -1,7 +1,8 @@
 """Geospatial transforms"""
 
-
 from __future__ import division
+
+import collections
 import math
 
 from affine import Affine
@@ -62,48 +63,71 @@ def array_bounds(height, width, transform):
     return w, s, e, n
 
 
-def xy(row, col, transform, offset='center'):
-    """Returns the coordinates ``(x, y)`` of a pixel at `row` and `col`.
+def xy(transform, rows, cols, offset='center'):
+    """Returns the x and y coordinates of pixels at `rows` and `cols`.
     The pixel's center is returned by default, but a corner can be returned
     by setting `offset` to one of `ul, ur, ll, lr`.
 
     Parameters
     ----------
-    row : int
-        Pixel row.
-    col : int
-        Pixel column.
     transform : affine.Affine
         Transformation from pixel coordinates to coordinate reference system.
+    rows : list or int
+        Pixel rows.
+    cols : list or int
+        Pixel columns.
     offset : str, optional
         Determines if the returned coordinates are for the center of the
         pixel or for a corner.
 
     Returns
     -------
-    tuple
-        ``(x, y)``
+    xs : list
+        x coordinates in coordinate reference system
+    ys : list
+        y coordinates in coordinate reference system
     """
 
-    cr = (col, row)
+    single_col = False
+    single_row = False
+    if not isinstance(cols, collections.Iterable):
+        cols = [cols]
+        single_col = True
+    if not isinstance(rows, collections.Iterable):
+        rows = [rows]
+        single_row = True
 
     if offset == 'center':
-        return transform * transform.translation(0.5, 0.5) * cr
+        coff, roff = (0.5, 0.5)
     elif offset == 'ul':
-        return transform * cr
+        coff, roff = (0, 0)
     elif offset == 'ur':
-        return transform * transform.translation(1, 0) * cr
+        coff, roff = (1, 0)
     elif offset == 'll':
-        return transform * transform.translation(0, 1) * cr
+        coff, roff = (0, 1)
     elif offset == 'lr':
-        return transform * transform.translation(1, 1) * cr
+        coff, roff = (1, 1)
     else:
         raise ValueError("Invalid offset")
 
+    xs = []
+    ys = []
+    for col, row in zip(cols, rows):
+        x, y = transform * transform.translation(coff, roff) * (col, row)
+        xs.append(x)
+        ys.append(y)
+
+    if single_row:
+        ys = ys[0]
+    if single_col:
+        xs = xs[0]
+
+    return xs, ys
+
 
-def get_index(x, y, transform, op=math.floor, precision=6):
+def rowcol(transform, xs, ys, op=math.floor, precision=6):
     """
-    Returns the (row, col) index of the pixel containing (x, y) given a
+    Returns the rows and cols of the pixels containing (x, y) given a
     coordinate reference system.
 
     Use an epsilon, magnitude determined by the precision parameter
@@ -112,12 +136,12 @@ def get_index(x, y, transform, op=math.floor, precision=6):
 
     Parameters
     ----------
-    x : float
-        x value in coordinate reference system
-    y : float
-        y value in coordinate reference system
     transform : Affine
         Coefficients mapping pixel coordinates to coordinate reference system.
+    xs : list or float
+        x values in coordinate reference system
+    ys : list or float
+        y values in coordinate reference system
     op : function
         Function to convert fractional pixels to whole numbers (floor, ceiling,
         round)
@@ -126,14 +150,34 @@ def get_index(x, y, transform, op=math.floor, precision=6):
 
     Returns
     -------
-    row : int
-        row index
-    col : int
-        col index
+    rows : list of ints
+        list of row indicies
+    cols : list of ints
+        list of column indicies
     """
 
-    eps = 10.0**-precision * (1.0 - 2.0*op(0.1))
-    fcol, frow = ~transform * (x + eps, y - eps)
-    col = int(op(fcol))
-    row = int(op(frow))
-    return row, col
+    single_x = False
+    single_y = False
+    if not isinstance(xs, collections.Iterable):
+        xs = [xs]
+        single_x = True
+    if not isinstance(ys, collections.Iterable):
+        ys = [ys]
+        single_y = True
+
+    eps = 10.0 ** -precision * (1.0 - 2.0 * op(0.1))
+    invtransform = ~transform
+
+    rows = []
+    cols = []
+    for x, y in zip(xs, ys):
+        fcol, frow = invtransform * (x + eps, y - eps)
+        cols.append(int(op(fcol)))
+        rows.append(int(op(frow)))
+
+    if single_x:
+        cols = cols[0]
+    if single_y:
+        rows = rows[0]
+
+    return rows, cols
diff --git a/rasterio/windows.py b/rasterio/windows.py
--- a/rasterio/windows.py
+++ b/rasterio/windows.py
@@ -12,7 +12,7 @@
 from affine import Affine
 import numpy as np
 
-from rasterio.transform import get_index
+from rasterio.transform import rowcol
 
 
 def iter_args(function):
@@ -193,11 +193,11 @@ def from_bounds(left, bottom, right, top, transform,
         corresponding to the bounding coordinates
     """
 
-    window_start = get_index(
-        left, top, transform, op=math.floor, precision=precision)
+    window_start = rowcol(
+        transform, left, top, op=math.floor, precision=precision)
 
-    window_stop = get_index(
-        right, bottom, transform, op=math.ceil, precision=precision)
+    window_stop = rowcol(
+        transform, right, bottom, op=math.ceil, precision=precision)
 
     window = tuple(zip(window_start, window_stop))
 


Test patch:
diff --git a/tests/test_transform.py b/tests/test_transform.py
--- a/tests/test_transform.py
+++ b/tests/test_transform.py
@@ -2,7 +2,7 @@
 import pytest
 import rasterio
 from rasterio import transform
-from rasterio.transform import xy
+from rasterio.transform import xy, rowcol
 
 
 def test_window_transform():
@@ -159,17 +159,17 @@ def test_xy():
     ul_x, ul_y = aff * (0, 0)
     xoff = aff.a
     yoff = aff.e
-    assert xy(0, 0, transform=aff, offset='ul') == (ul_x, ul_y)
-    assert xy(0, 0, transform=aff, offset='ur') == (ul_x + xoff, ul_y)
-    assert xy(0, 0, transform=aff, offset='ll') == (ul_x, ul_y + yoff)
+    assert xy(aff, 0, 0, offset='ul') == (ul_x, ul_y)
+    assert xy(aff, 0, 0, offset='ur') == (ul_x + xoff, ul_y)
+    assert xy(aff, 0, 0, offset='ll') == (ul_x, ul_y + yoff)
     expected = (ul_x + xoff, ul_y + yoff)
-    assert xy(0, 0, transform=aff, offset='lr') == expected
+    assert xy(aff, 0, 0, offset='lr') == expected
     expected = (ul_x + xoff / 2, ul_y + yoff / 2)
-    assert xy(0, 0, transform=aff, offset='center') == expected
-    assert xy(0, 0, transform=aff, offset='lr') == \
-        xy(0, 1, transform=aff, offset='ll') == \
-        xy(1, 1, transform=aff, offset='ul') == \
-        xy(1, 0, transform=aff, offset='ur')
+    assert xy(aff, 0, 0, offset='center') == expected
+    assert xy(aff, 0, 0, offset='lr') == \
+        xy(aff, 0, 1, offset='ll') == \
+        xy(aff, 1, 1, offset='ul') == \
+        xy(aff, 1, 0, offset='ur')
 
 
 def test_guard_transform_gdal_TypeError(path_rgb_byte_tif):
@@ -187,3 +187,24 @@ def test_tastes_like_gdal_identity():
     aff = Affine.identity()
     assert not transform.tastes_like_gdal(aff)
     assert transform.tastes_like_gdal(aff.to_gdal())
+
+
+def test_rowcol():
+    with rasterio.open("tests/data/RGB.byte.tif", 'r') as src:
+        aff = src.transform
+        left, bottom, right, top = src.bounds
+        assert rowcol(aff, left, top) == (0, 0)
+        assert rowcol(aff, right, top) == (0, src.width)
+        assert rowcol(aff, right, bottom) == (src.height, src.width)
+        assert rowcol(aff, left, bottom) == (src.height, 0)
+        assert rowcol(aff, 101985.0, 2826915.0) == (0, 0)
+        assert rowcol(aff, 101985.0+400.0, 2826915.0) == (0, 1)
+
+
+def test_xy_rowcol_inverse():
+    # TODO this is an ideal candiate for
+    # property-based testing with hypothesis
+    aff = Affine.identity()
+    rows_cols = ([0, 0, 10, 10],
+                 [0, 10, 0, 10])
+    assert rows_cols == rowcol(aff, *xy(aff, *rows_cols))