Repository: gammapy/gammapy

Issue:
Stacking of Map cutouts is erroneous
** Bug description **

If we have 2 maps which are themselves cutouts (eg; maps obtained from our `MapMaker` process), and then we perform a cutout on them

`map1 = dataset1.counts.cutout(dataset1.counts._geom.center_skydir, width=2.0*u.deg)`
`map2 = dataset2.counts.coutout(dataset2.counts._geom.center_skydir, width=2.0*u.deg)`

and then want to stack `map1` and `map2` onto a large target Geom, 

```
counts = Map.from_geom(geom)
cutout = dataset1.counts.cutout(dataset1.counts._geom.center_skydir, width=2.0*u.deg)
counts.stack(cutout)
```

 just stacks in one corner according to the pixel index, forgetting the geometries.

** Reason **

I discussed this privately with @adonath and the reason is that in
https://github.com/gammapy/gammapy/blob/master/gammapy/maps/wcsnd.py#L789 we only check for the alignment of the WCS and not the actual position. So when stacking a cutout from a cutout into the initial geom, the WCS projections are aligned, but the slices are not valid anymore. The slices from the smallest cutout are only valid for the bigger cutout and not the initial parent geometry.

This is a reminder issue to fix before 1.0 release


Discussion:
Just for the record. Here is already a possibly fix: https://github.com/gammapy/gammapy/compare/master...adonath:aligned_wcs_geom?expand=1 and this fix will also allow to make `datasets.stack_reduce()` work for cutout `MapDataset` objects.
So instead of relying on storing the parent slices, one could possibly just re-compute the slices created in `.cutout()`. However the fix hasn't been tested yet...

As I already started to work on the cutout slices I'll sign the issue to me. 

Resolving patch:
diff --git a/gammapy/maps/wcs.py b/gammapy/maps/wcs.py
--- a/gammapy/maps/wcs.py
+++ b/gammapy/maps/wcs.py
@@ -6,6 +6,7 @@
 from astropy.coordinates import Angle, SkyCoord
 from astropy.io import fits
 from astropy.nddata import Cutout2D
+from astropy.nddata.utils import overlap_slices
 from astropy.convolution import Tophat2DKernel
 from astropy.wcs import WCS
 from astropy.wcs.utils import (
@@ -118,8 +119,6 @@ class WcsGeom(Geom):
         Reference pixel coordinate in each image plane.
     axes : list
         Axes for non-spatial dimensions
-    cutout_info : dict
-        Dict with cutout info, if the `WcsGeom` was created by `WcsGeom.cutout()`
     """
 
     _slice_spatial_axes = slice(0, 2)
@@ -127,7 +126,7 @@ class WcsGeom(Geom):
     is_hpx = False
     is_region = False
 
-    def __init__(self, wcs, npix, cdelt=None, crpix=None, axes=None, cutout_info=None):
+    def __init__(self, wcs, npix, cdelt=None, crpix=None, axes=None):
         self._wcs = wcs
         self._frame = wcs_to_celestial_frame(wcs).name
         self._projection = wcs.wcs.ctype[0][5:]
@@ -146,7 +145,6 @@ def __init__(self, wcs, npix, cdelt=None, crpix=None, axes=None, cutout_info=Non
             crpix = tuple(1.0 + (np.array(self._npix) - 1.0) / 2.0)
 
         self._crpix = crpix
-        self._cutout_info = cutout_info
 
         # define cached methods
         self.get_coord = lru_cache()(self.get_coord)
@@ -211,10 +209,32 @@ def frame(self):
         """
         return self._frame
 
-    @property
-    def cutout_info(self):
-        """Cutout info dict."""
-        return self._cutout_info
+    def cutout_slices(self, geom, mode="partial"):
+        """Compute cutout slices.
+
+        Parameters
+        ----------
+        geom : `WcsGeom`
+            Parent geometry
+        mode : {"trim", "partial", "strict"}
+            Cutout slices mode.
+
+        Returns
+        -------
+        slices : dict
+            Dictionary containing "parent-slices" and "cutout-slices".
+        """
+        position = geom.to_image().coord_to_pix(self.center_skydir)
+        slices = overlap_slices(
+            large_array_shape=geom.data_shape[-2:],
+            small_array_shape=self.data_shape[-2:],
+            position=position[::-1],
+            mode=mode
+        )
+        return {
+            "parent-slices": slices[0],
+            "cutout-slices": slices[1],
+        }
 
     @property
     def projection(self):
@@ -433,6 +453,12 @@ def create(
         wcs.wcs.datfix()
         return cls(wcs, npix, cdelt=binsz, axes=axes)
 
+    @property
+    def footprint(self):
+        """Footprint of the geometry"""
+        coords = self.wcs.calc_footprint()
+        return SkyCoord(coords, frame=self.frame, unit="deg")
+
     @classmethod
     def from_header(cls, header, hdu_bands=None, format="gadf"):
         """Create a WCS geometry object from a FITS header.
@@ -472,20 +498,7 @@ def from_header(cls, header, hdu_bands=None, format="gadf"):
             npix = (header["NAXIS1"], header["NAXIS2"])
             cdelt = None
 
-        if "PSLICE1" in header:
-            cutout_info = {}
-            cutout_info["parent-slices"] = (
-                str_to_slice(header["PSLICE2"]),
-                str_to_slice(header["PSLICE1"]),
-            )
-            cutout_info["cutout-slices"] = (
-                str_to_slice(header["CSLICE2"]),
-                str_to_slice(header["CSLICE1"]),
-            )
-        else:
-            cutout_info = None
-
-        return cls(wcs, npix, cdelt=cdelt, axes=axes, cutout_info=cutout_info)
+        return cls(wcs, npix, cdelt=cdelt, axes=axes)
 
     def _make_bands_cols(self):
 
@@ -527,17 +540,8 @@ def to_header(self):
         shape = "{},{}".format(np.max(self.npix[0]), np.max(self.npix[1]))
         for ax in self.axes:
             shape += f",{ax.nbin}"
-        header["WCSSHAPE"] = f"({shape})"
-
-        if self.cutout_info is not None:
-            slices_parent = self.cutout_info["parent-slices"]
-            slices_cutout = self.cutout_info["cutout-slices"]
-
-            header["PSLICE1"] = (slice_to_str(slices_parent[1]), "Parent slice")
-            header["PSLICE2"] = (slice_to_str(slices_parent[0]), "Parent slice")
-            header["CSLICE1"] = (slice_to_str(slices_cutout[1]), "Cutout slice")
-            header["CSLICE2"] = (slice_to_str(slices_cutout[0]), "Cutout slice")
 
+        header["WCSSHAPE"] = f"({shape})"
         return header
 
     def get_idx(self, idx=None, flat=False):
@@ -678,7 +682,7 @@ def to_image(self):
         npix = (np.max(self._npix[0]), np.max(self._npix[1]))
         cdelt = (np.max(self._cdelt[0]), np.max(self._cdelt[1]))
         return self.__class__(
-            self._wcs, npix, cdelt=cdelt, cutout_info=self.cutout_info
+            self._wcs, npix, cdelt=cdelt
         )
 
     def to_cube(self, axes):
@@ -690,7 +694,6 @@ def to_cube(self, axes):
             npix,
             cdelt=cdelt,
             axes=axes,
-            cutout_info=self.cutout_info,
         )
 
     def _pad_spatial(self, pad_width):
@@ -861,15 +864,7 @@ def cutout(self, position, width, mode="trim"):
             size=width[::-1] * u.deg,
             mode=mode,
         )
-
-        cutout_info = {
-            "parent-slices": c2d.slices_original,
-            "cutout-slices": c2d.slices_cutout,
-        }
-
-        return self._init_copy(
-            wcs=c2d.wcs, npix=c2d.shape[::-1], cutout_info=cutout_info
-        )
+        return self._init_copy(wcs=c2d.wcs, npix=c2d.shape[::-1])
 
     def boundary_mask(self, width):
         """Create a mask applying binary erosion with a given width from geom edges
diff --git a/gammapy/maps/wcsnd.py b/gammapy/maps/wcsnd.py
--- a/gammapy/maps/wcsnd.py
+++ b/gammapy/maps/wcsnd.py
@@ -761,11 +761,12 @@ def cutout(self, position, width, mode="trim"):
             Cutout map
         """
         geom_cutout = self.geom.cutout(position=position, width=width, mode=mode)
+        cutout_info = geom_cutout.cutout_slices(self.geom, mode=mode)
 
-        slices = geom_cutout.cutout_info["parent-slices"]
+        slices = cutout_info["parent-slices"]
         parent_slices = Ellipsis, slices[0], slices[1]
 
-        slices = geom_cutout.cutout_info["cutout-slices"]
+        slices = cutout_info["cutout-slices"]
         cutout_slices = Ellipsis, slices[0], slices[1]
 
         data = np.zeros(shape=geom_cutout.data_shape, dtype=self.data.dtype)
@@ -787,10 +788,12 @@ def stack(self, other, weights=None):
         if self.geom == other.geom:
             parent_slices, cutout_slices = None, None
         elif self.geom.is_aligned(other.geom):
-            slices = other.geom.cutout_info["parent-slices"]
+            cutout_slices = other.geom.cutout_slices(self.geom)
+
+            slices = cutout_slices["parent-slices"]
             parent_slices = Ellipsis, slices[0], slices[1]
 
-            slices = other.geom.cutout_info["cutout-slices"]
+            slices = cutout_slices["cutout-slices"]
             cutout_slices = Ellipsis, slices[0], slices[1]
         else:
             raise ValueError(


Test patch:
diff --git a/gammapy/maps/tests/test_wcs.py b/gammapy/maps/tests/test_wcs.py
--- a/gammapy/maps/tests/test_wcs.py
+++ b/gammapy/maps/tests/test_wcs.py
@@ -241,24 +241,14 @@ def test_cutout_info():
     geom = WcsGeom.create(skydir=(0, 0), npix=10)
     position = SkyCoord(0, 0, unit="deg")
     cutout_geom = geom.cutout(position=position, width="2 deg")
-    assert cutout_geom.cutout_info["parent-slices"][0].start == 3
-    assert cutout_geom.cutout_info["parent-slices"][1].start == 3
 
-    assert cutout_geom.cutout_info["cutout-slices"][0].start == 0
-    assert cutout_geom.cutout_info["cutout-slices"][1].start == 0
+    cutout_info = cutout_geom.cutout_slices(geom)
 
-    header = cutout_geom.to_header()
-    assert "PSLICE1" in header
-    assert "PSLICE2" in header
-    assert "CSLICE1" in header
-    assert "CSLICE2" in header
+    assert cutout_info["parent-slices"][0].start == 3
+    assert cutout_info["parent-slices"][1].start == 3
 
-    geom = WcsGeom.from_header(header)
-    assert geom.cutout_info["parent-slices"][0].start == 3
-    assert geom.cutout_info["parent-slices"][1].start == 3
-
-    assert geom.cutout_info["cutout-slices"][0].start == 0
-    assert geom.cutout_info["cutout-slices"][1].start == 0
+    assert cutout_info["cutout-slices"][0].start == 0
+    assert cutout_info["cutout-slices"][1].start == 0
 
 
 def test_wcsgeom_get_coord():
diff --git a/gammapy/maps/tests/test_wcsnd.py b/gammapy/maps/tests/test_wcsnd.py
--- a/gammapy/maps/tests/test_wcsnd.py
+++ b/gammapy/maps/tests/test_wcsnd.py
@@ -856,3 +856,18 @@ def test_memory_usage():
     assert geom.data_nbytes().unit == u.MB
     assert_allclose(geom.data_nbytes(dtype="float32").value, 1.0368)
     assert_allclose(geom.data_nbytes(dtype="b").value, 0.2592)
+
+
+def test_double_cutout():
+    # regression test for https://github.com/gammapy/gammapy/issues/3368
+    m = Map.create(width="10 deg")
+    m.data = np.arange(10_000, dtype="float")
+
+    position = SkyCoord("1d", "1d")
+    m_c = m.cutout(position=position, width="3 deg")
+    m_cc = m_c.cutout(position=position, width="2 deg")
+
+    m_new = Map.create(width="10 deg")
+    m_new.stack(m_cc)
+    m_c_new = m_new.cutout(position=position, width="2 deg")
+    np.testing.assert_allclose(m_c_new.data, m_cc.data)