Repository: rasterio/rasterio

Issue:
rio.transform.xy does not accept all float and int data types when passing a single row/col
Hi all,

Since 1.2.7 release yesterday, `rio.transform.xy` does not accept certain data types anymore for non-Iterable (single point) transformation.

Working example for reproducing the error:
```python
transform = Affine(20.0, 0.0, 502810.0, 0.0, -20.0, 8674030.0)
rows = np.int64(0)
cols = np.int64(0)
offset = 'ul'
rio.transform.xy(transform, rows, cols, offset=offset)
```
Output:
```
Traceback (most recent call last):
  File "<input>", line 5, in <module>
  File "/home/atom/miniconda3/envs/xdem-dev/lib/python3.9/site-packages/rasterio/transform.py", line 177, in xy
    raise TransformError("Invalid inputs")
rasterio.errors.TransformError: Invalid inputs
```

The commit creating this behavior change is here: https://github.com/mapbox/rasterio/commit/22636eac82baf391bc69706baa841a682eef9e78#diff-0c5d56d4190d3603e40c1faab3c11ff7c4c71949948e951cd05d8d5578ebd6fd

Specifically, it's the data type checking lines:
```python
if isinstance(rows, (int, float)) and isinstance(cols, (int, float)):
        return adjusted_transform * (cols, rows)
elif isinstance(rows, Iterable) and isinstance(cols, Iterable):
      xs, ys = zip(*(adjusted_transform * (col, row) for col, row in zip(cols, rows)))
      return list(xs), list(ys)
  else:
      raise TransformError("Invalid inputs")
```

I'm not sure if this the new intended behaviour, or if one should modify to `isinstance(rows, (int, np.integer, float, np.float))` or similar?

And btw, thanks for all the work on this package!


Discussion:
@rhugonnet I'm working on a fix. Something along the lines of:
```python
if isinstance(rows, Iterable) and isinstance(cols, Iterable):
     xs, ys = zip(*(adjusted_transform * (col, row) for col, row in zip(cols, rows)))
     return list(xs), list(ys)
else:
    try:
        return adjusted_transform * (cols, rows)
    except TypeError:
        raise TransformError("Invalid inputs")
```
@rhugonnet sorry about that! I'll schedule a 1.2.8 release.

Resolving patch:
diff --git a/rasterio/__init__.py b/rasterio/__init__.py
--- a/rasterio/__init__.py
+++ b/rasterio/__init__.py
@@ -40,7 +40,7 @@
 import rasterio.path
 
 __all__ = ['band', 'open', 'pad', 'Env']
-__version__ = "1.2.7"
+__version__ = "1.2.8dev"
 __gdal_version__ = gdal_version()
 
 # Rasterio attaches NullHandler to the 'rasterio' logger and its
diff --git a/rasterio/transform.py b/rasterio/transform.py
--- a/rasterio/transform.py
+++ b/rasterio/transform.py
@@ -168,12 +168,15 @@ def xy(transform, rows, cols, offset='center'):
 
     adjusted_transform = transform * Affine.translation(coff, roff)
 
-    if isinstance(rows, (int, float)) and isinstance(cols, (int, float)):
-        return adjusted_transform * (cols, rows)
-    elif isinstance(rows, Iterable) and isinstance(cols, Iterable):
-        xs, ys = zip(*(adjusted_transform * (col, row) for col, row in zip(cols, rows)))
-        return list(xs), list(ys)
-    else:
+    try:
+        if hasattr(rows, "__iter__") and hasattr(cols, "__iter__"):
+            xs, ys = zip(
+                *(adjusted_transform * (col, row) for col, row in zip(cols, rows))
+            )
+            return list(xs), list(ys)
+        else:
+            return adjusted_transform * (cols, rows)
+    except TypeError:
         raise TransformError("Invalid inputs")
 
 
@@ -220,13 +223,16 @@ def rowcol(transform, xs, ys, op=math.floor, precision=None):
 
     invtransform = ~transform
 
-    if isinstance(xs, (int, float)) and isinstance(ys, (int, float)):
-        fcol, frow = invtransform * (xs + eps, ys + eps)
-        return op(frow), op(fcol)
-    elif isinstance(xs, Iterable) and isinstance(ys, Iterable):
-        fcols, frows = zip(*(invtransform * (x + eps, y + eps) for x, y in zip(xs, ys)))
-        return [op(row) for row in frows], [op(col) for col in fcols]
-    else:
+    try:
+        if hasattr(xs, "__iter__") and hasattr(ys, "__iter__"):
+            fcols, frows = zip(
+                *(invtransform * (x + eps, y + eps) for x, y in zip(xs, ys))
+            )
+            return [op(row) for row in frows], [op(col) for col in fcols]
+        else:
+            fcol, frow = invtransform * (xs + eps, ys + eps)
+            return op(frow), op(fcol)
+    except TypeError:
         raise TransformError("Invalid inputs")
 
 


Test patch:
diff --git a/tests/test_transform.py b/tests/test_transform.py
--- a/tests/test_transform.py
+++ b/tests/test_transform.py
@@ -1,5 +1,12 @@
+"""Tests of the transform module."""
+
+from array import array
+
 from affine import Affine
 import pytest
+
+import numpy
+
 import rasterio
 from rasterio import transform
 from rasterio.env import GDALVersion
@@ -132,33 +139,55 @@ def test_from_bounds_two():
     assert [round(v, 7) for v in tr] == [round(v, 7) for v in expected]
 
 
-def test_xy():
-    # TODO: use pytest's parametrize to make separate tests.
-    aff = Affine(300.0379266750948, 0.0, 101985.0,
-                 0.0, -300.041782729805, 2826915.0)
-    ul_x, ul_y = aff * (0, 0)
-    xoff = aff.a
-    yoff = aff.e
-    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(aff, 0, 0, offset='lr') == expected
-    expected = (ul_x + xoff / 2, ul_y + yoff / 2)
-    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')
-
-    # Check list inputs.
-    assert xy(aff, [0], [0], offset='ul') == ([ul_x], [ul_y])
+@pytest.mark.parametrize("aff", [Affine.identity()])
+@pytest.mark.parametrize(
+    "offset, exp_xy",
+    [
+        ("ur", (1.0, 0.0)),
+        ("lr", (1.0, 1.0)),
+        ("ll", (0.0, 1.0)),
+        ("ul", (0.0, 0.0)),
+        ("center", (0.5, 0.5)),
+    ],
+)
+def test_xy_offset(offset, exp_xy, aff):
+    """Check offset keyword arg."""
+    assert xy(aff, 0, 0, offset=offset) == exp_xy
 
 
 def test_bogus_offset():
+    """Raise on invalid offset."""
     with pytest.raises(TransformError):
         xy(None, 1, 0, offset='bogus')
 
 
+@pytest.mark.parametrize("aff", [Affine.identity()])
+@pytest.mark.parametrize(
+    "rows, cols, exp_xy",
+    [
+        (0, 0, (0.5, 0.5)),
+        (0.0, 0.0, (0.5, 0.5)),
+        (numpy.int32(0), numpy.int32(0), (0.5, 0.5)),
+        (numpy.float32(0), numpy.float32(0), (0.5, 0.5)),
+        ([0], [0], ([0.5], [0.5])),
+        (array("d", [0.0]), array("d", [0.0]), ([0.5], [0.5])),
+        ([numpy.int32(0)], [numpy.int32(0)], ([0.5], [0.5])),
+        (numpy.array([0.0]), numpy.array([0.0]), ([0.5], [0.5])),
+    ],
+)
+def test_xy_input(rows, cols, exp_xy, aff):
+    """Handle single and iterable inputs of different numerical types."""
+    assert xy(aff, rows, cols) == exp_xy
+
+
+@pytest.mark.parametrize("aff", [Affine.identity()])
+@pytest.mark.parametrize("rows, cols", [(0, [0]), ("0", "0")])
+def test_invalid_xy_input(rows, cols, aff):
+    """Raise on invalid input."""
+    with pytest.raises(TransformError):
+        xy(aff, rows, cols)
+
+
 def test_guard_transform_gdal_TypeError(path_rgb_byte_tif):
     """As part of the 1.0 migration, guard_transform() should raise a TypeError
     if a GDAL geotransform is encountered"""
@@ -186,8 +215,21 @@ def test_rowcol():
         assert rowcol(aff, left, bottom) == (src.height, 0)
         assert rowcol(aff, 101985.0, 2826915.0) == (0, 0)
 
-        # Check list inputs.
-        assert rowcol(aff, [101985.0 + 400.0], [2826915.0]) == ([0], [1])
+
+@pytest.mark.parametrize(
+    "xs, ys, exp_rowcol",
+    [
+        ([101985.0 + 400.0], [2826915.0], ([0], [1])),
+        (array("d", [101985.0 + 400.0]), array("d", [2826915.0]), ([0], [1])),
+        (numpy.array([101985.0 + 400.0]), numpy.array([2826915.0]), ([0], [1])),
+    ],
+)
+def test_rowcol_input(xs, ys, exp_rowcol):
+    """Handle single and iterable inputs of different numerical types."""
+    with rasterio.open("tests/data/RGB.byte.tif", "r") as src:
+        aff = src.transform
+
+    assert rowcol(aff, xs, ys) == exp_rowcol
 
 
 def test_xy_rowcol_inverse():
@@ -199,6 +241,14 @@ def test_xy_rowcol_inverse():
     assert rows_cols == rowcol(aff, *xy(aff, *rows_cols))
 
 
+@pytest.mark.parametrize("aff", [Affine.identity()])
+@pytest.mark.parametrize("xs, ys", [(0, [0]), ("0", "0")])
+def test_invalid_rowcol_input(xs, ys, aff):
+    """Raise on invalid input."""
+    with pytest.raises(TransformError):
+        rowcol(aff, xs, ys)
+
+
 def test_from_gcps():
     with rasterio.open("tests/data/white-gemini-iv.vrt", 'r') as src:
         aff = transform.from_gcps(src.gcps[0])