Repository: opendatacube/datacube-core

Issue:
Numeric precision issues in `compute_reproject_roi` when pixel size is small
### Expected behaviour

If I call `compute_reproject_roi(geobox, geobox[roi])` I expect it to return result with `src_roi == roi` every time. It does not when `geobox` has (1) tiny pixels and (2) oddly sized `alignment`. This often happens when working with data in `EPSG:4326` projection, as pixel sizes can be quite tiny, this data will also often have odd `alignment` (pixel edges do not align to 0, and pixel centers do no align to 0 either).

### Actual behaviour

Computed `src_roi` includes one extra row and hence subpixel shift is expected to occur.

### Steps to reproduce the behaviour

```python
from affine import Affine
import numpy as np
from datacube.utils.geometry.tools import compute_reproject_roi
from datacube.utils.geometry import GeoBox

geobox = GeoBox(3000, 3000, 
                Affine(0.00027778, 0.0, 148.72673054908861,
                       0.0, -0.00027778, -34.98825802556622), "EPSG:4326")
display(geobox.resolution, geobox.alignment)
src_roi = np.s_[2800:2810, 10:30]
rr = compute_reproject_roi(geobox, geobox[src_roi])
display(rr)
assert rr.is_st is True
assert rr.roi_src == src_roi
assert rr.roi_dst == np.s_[0:10, 0:20]
```


Discussion:
@petewa :arrow_up: 

Resolving patch:
diff --git a/datacube/utils/geometry/tools.py b/datacube/utils/geometry/tools.py
--- a/datacube/utils/geometry/tools.py
+++ b/datacube/utils/geometry/tools.py
@@ -435,7 +435,7 @@ def compute_axis_overlap(Ns: int, Nd: int, s: float, t: float) -> Tuple[slice, s
     return (src, dst)
 
 
-def box_overlap(src_shape, dst_shape, ST):
+def box_overlap(src_shape, dst_shape, ST, tol):
     """
     Given two image planes whose coordinate systems are related via scale and
     translation only, find overlapping regions within both.
@@ -444,11 +444,23 @@ def box_overlap(src_shape, dst_shape, ST):
     :param dst_shape: Shape of destination image plane
     :param        ST: Affine transform with only scale/translation,
                       direction is: Xsrc = ST*Xdst
+    :param       tol: Sub-pixel translation tolerance that's scaled by resolution.
     """
+    from datacube.utils.math import is_almost_int
+
     (sx, _, tx,
      _, sy, ty,
      *_) = ST
 
+    def _round_to_near_int(x, tol):
+        tol = min(0.5, tol)
+        if is_almost_int(x, tol):
+            x = round(x)
+        return x
+
+    ty = _round_to_near_int(ty, tol)
+    tx = _round_to_near_int(tx, tol)
+
     s0, d0 = compute_axis_overlap(src_shape[0], dst_shape[0], sy, ty)
     s1, d1 = compute_axis_overlap(src_shape[1], dst_shape[1], sx, tx)
     return (s0, s1), (d0, d1)
@@ -557,7 +569,7 @@ def to_roi(*args):
     return to_roi(yy, xx)
 
 
-def compute_reproject_roi(src, dst, padding=None, align=None):
+def compute_reproject_roi(src, dst, tol=0.05, padding=None, align=None):
     """Given two GeoBoxes find the region within the source GeoBox that overlaps
     with the destination GeoBox, and also compute the scale factor (>1 means
     shrink). Scale is chosen such that if you apply it to the source image
@@ -589,6 +601,8 @@ def compute_reproject_roi(src, dst, padding=None, align=None):
     - No padding beyond sub-pixel alignment if Scale+Translation
     - 1 pixel source padding in all other cases
 
+    :param tol: Sub-pixel translation tolerance as a percentage of resolution.
+
     :returns: SimpleNamespace with following fields:
      .roi_src    : (slice, slice)
      .roi_dst    : (slice, slice)
@@ -621,7 +635,7 @@ def compute_roi(src, dst, tr, pts_per_side, padding, align):
         is_st = is_affine_st(tr.linear)
 
         if tight_ok and is_st:
-            roi_src, roi_dst = box_overlap(src.shape, dst.shape, tr.back.linear)
+            roi_src, roi_dst = box_overlap(src.shape, dst.shape, tr.back.linear, tol)
         else:
             padding = 1 if padding is None else padding
             roi_src, roi_dst = compute_roi(src, dst, tr, 2, padding, align)


Test patch:
diff --git a/tests/test_geometry.py b/tests/test_geometry.py
--- a/tests/test_geometry.py
+++ b/tests/test_geometry.py
@@ -263,13 +263,13 @@ def test_ops():
 
     # test sides
     box = geometry.box(1, 2, 11, 22, epsg4326)
-    ll = list(geometry.sides(box))
-    assert all(l.crs is epsg4326 for l in ll)
-    assert len(ll) == 4
-    assert ll[0] == geometry.line([(1, 2), (1, 22)], epsg4326)
-    assert ll[1] == geometry.line([(1, 22), (11, 22)], epsg4326)
-    assert ll[2] == geometry.line([(11, 22), (11, 2)], epsg4326)
-    assert ll[3] == geometry.line([(11, 2), (1, 2)], epsg4326)
+    lines = list(geometry.sides(box))
+    assert all(line.crs is epsg4326 for line in lines)
+    assert len(lines) == 4
+    assert lines[0] == geometry.line([(1, 2), (1, 22)], epsg4326)
+    assert lines[1] == geometry.line([(1, 22), (11, 22)], epsg4326)
+    assert lines[2] == geometry.line([(11, 22), (11, 2)], epsg4326)
+    assert lines[3] == geometry.line([(11, 2), (1, 2)], epsg4326)
 
 
 def test_geom_split():
@@ -739,7 +739,7 @@ def test_geobox_xr_coords():
 
     cc = gbox.xr_coords(with_crs=True)
     assert list(cc) == ['y', 'x', 'spatial_ref']
-    assert cc['spatial_ref'].shape is ()
+    assert cc['spatial_ref'].shape == ()
     assert cc['spatial_ref'].attrs['spatial_ref'] == gbox.crs.wkt
     assert isinstance(cc['spatial_ref'].attrs['grid_mapping_name'], str)
 
@@ -751,7 +751,7 @@ def test_geobox_xr_coords():
     gbox = GeoBox(w, h, A, 'epsg:4326')
     cc = gbox.xr_coords(with_crs=True)
     assert list(cc) == ['latitude', 'longitude', 'spatial_ref']
-    assert cc['spatial_ref'].shape is ()
+    assert cc['spatial_ref'].shape == ()
     assert cc['spatial_ref'].attrs['spatial_ref'] == gbox.crs.wkt
     assert isinstance(cc['spatial_ref'].attrs['grid_mapping_name'], str)
 
@@ -1358,6 +1358,24 @@ def test_compute_reproject_roi_issue647():
     assert roi_is_empty(rr.roi_dst)
 
 
+def test_compute_reproject_roi_issue1047():
+    """ `compute_reproject_roi(geobox, geobox[roi])` sometimes returns
+    `src_roi != roi`, when `geobox` has (1) tiny pixels and (2) oddly
+    sized `alignment`.
+
+    Test this issue is resolved.
+    """
+    geobox = GeoBox(3000, 3000,
+                    Affine(0.00027778, 0.0, 148.72673054908861,
+                           0.0, -0.00027778, -34.98825802556622), "EPSG:4326")
+    src_roi = np.s_[2800:2810, 10:30]
+    rr = compute_reproject_roi(geobox, geobox[src_roi])
+
+    assert rr.is_st is True
+    assert rr.roi_src == src_roi
+    assert rr.roi_dst == np.s_[0:10, 0:20]
+
+
 def test_window_from_slice():
     from numpy import s_