Repository: microsoft/torchgeo

Issue:
Zero area intersections in IntersectionDataset result in unexpected dataset lengths
### Issue

We create an IntersectionDataset like this:
```
train_image_ds = RasterDataset(
    'data/processed/images/',
)
train_mask_ds = RasterDataset(
    'data/processed/masks/',
)
train_mask_ds.is_image = False
train_ds = train_image_ds & train_mask_ds
```

Here both `train_image_ds` and `train_mask_ds` have length of 22, and cover the exact same spatial areas (i.e. there is a 1-to-1 pairing between a tile in `train_image_ds` and a tile in `train_mask_ds`). It looks something like this:

![output](https://user-images.githubusercontent.com/1088078/233478034-195dfb3b-802c-418f-93c7-72a0a53a6c86.svg)


The issue is that `train_ds` unexpectedly has a length of 140. Specifically, the merged index has 140 entries, however only 22 of them (as expected) have an `area > 0`. I'm guessing this is why we filter out intersection areas with area <= 0 in the samplers, but don't remember the details!

I recommend that we filter areas of intersection with area 0 when merging datasets.


Discussion:
Related to #737, #319, #376, etc.

The reason for this issue is that rtree considers two bounding boxes to be overlapping even if the area of overlap is 0.

It isn't hard to add a check for this and remove them from the intersection, or from the sampler. The reason we haven't done this already is that some datasets have 0 area on purpose. We have several point GeoDatasets, including GBIF, iNaturalist, and EDDMapS, and I have plans to add others for air pollution as well. I'm not actively using these datasets, and I'm not even sure if our builtin samplers would be useful for these kinds of datasets, but that's the reason things are the way they are. I would be open to changing this, but would need to think about how else we could use point datasets without 0 area files. Could add a parameter to control this I suppose.
Just clarified the title to emphasize that the problem is that the reported length of the IntersectionDataset does not match the expected length which is confusing to users.
@yichiac has this same problem in his dataset

Resolving patch:
diff --git a/torchgeo/datasets/geo.py b/torchgeo/datasets/geo.py
--- a/torchgeo/datasets/geo.py
+++ b/torchgeo/datasets/geo.py
@@ -966,8 +966,11 @@ def _merge_dataset_indices(self) -> None:
             for hit2 in ds2.index.intersection(hit1.bounds, objects=True):
                 box1 = BoundingBox(*hit1.bounds)
                 box2 = BoundingBox(*hit2.bounds)
-                self.index.insert(i, tuple(box1 & box2))
-                i += 1
+                box3 = box1 & box2
+                # Skip 0 area overlap (unless 0 area dataset)
+                if box3.area > 0 or box1.area == 0 or box2.area == 0:
+                    self.index.insert(i, tuple(box3))
+                    i += 1
 
         if i == 0:
             raise RuntimeError("Datasets have no spatiotemporal intersection")


Test patch:
diff --git a/tests/datasets/test_geo.py b/tests/datasets/test_geo.py
--- a/tests/datasets/test_geo.py
+++ b/tests/datasets/test_geo.py
@@ -543,6 +543,14 @@ def test_different_res_1_23(self) -> None:
         assert len(ds1) == len(ds2) == len(ds3) == len(ds) == 1
         assert isinstance(sample["image"], torch.Tensor)
 
+    def test_point_dataset(self) -> None:
+        ds1 = CustomGeoDataset(BoundingBox(0, 2, 2, 4, 4, 6))
+        ds2 = CustomGeoDataset(BoundingBox(1, 1, 3, 3, 5, 5))
+        ds = IntersectionDataset(ds1, ds2)
+        assert ds1.crs == ds2.crs == ds.crs == CRS.from_epsg(4087)
+        assert ds1.res == ds2.res == ds.res == 1
+        assert len(ds1) == len(ds2) == len(ds) == 1
+
     def test_no_overlap(self) -> None:
         ds1 = CustomGeoDataset(BoundingBox(0, 1, 2, 3, 4, 5))
         ds2 = CustomGeoDataset(BoundingBox(6, 7, 8, 9, 10, 11))
@@ -550,6 +558,13 @@ def test_no_overlap(self) -> None:
         with pytest.raises(RuntimeError, match=msg):
             IntersectionDataset(ds1, ds2)
 
+    def test_grid_overlap(self) -> None:
+        ds1 = CustomGeoDataset(BoundingBox(0, 1, 2, 3, 4, 5))
+        ds2 = CustomGeoDataset(BoundingBox(1, 2, 3, 4, 5, 6))
+        msg = "Datasets have no spatiotemporal intersection"
+        with pytest.raises(RuntimeError, match=msg):
+            IntersectionDataset(ds1, ds2)
+
     def test_invalid_query(self, dataset: IntersectionDataset) -> None:
         query = BoundingBox(-1, -1, -1, -1, -1, -1)
         with pytest.raises(