Skip to content

Commit 69e0e46

Browse files
authored
Merge pull request #316 from cmayet/fix/detfoo-mask-duplicate-loading
fix: prevent duplicate mask loading with associated_bands
2 parents e0c11ab + 33a1690 commit 69e0e46

3 files changed

Lines changed: 136 additions & 1 deletion

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- FIX: Fix subsetting SAR data with a window (cannot be done directly in the ESA SNAP Read Operator)
77
- FIX: Fix `KeyError` when loading a Landsat Collection-2 band from a STAC item whose asset name differs from the STAC common name (e.g. `nir08` instead of `nir`) [#307](https://github.com/sertit/eoreader/issues/307) - by @gaoflow
88
- FIX: Handle `TypeError` when passing `resampling` to `load()` on Sentinel-2 - by @SAY-5
9+
- FIX: Fix masks loaded N times when N associated bands are requested [#314](https://github.com/sertit/eoreader/issues/314) by @cmayet
910

1011
## 0.24.0 (2026-05-05)
1112

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Test for duplicate mask loading bug fix in S2Product._load_masks.
2+
3+
This test verifies that when loading a mask with multiple associated bands,
4+
the mask file is only opened/processed ONCE per associated band, not N times
5+
(where N is the number of associated bands).
6+
7+
Bug context:
8+
In `_load_masks`, when iterating over `associated_bands[band]`, the code was
9+
appending `band` to `bands_to_load` once per associated band when the mask file
10+
wasn't on disk. For example, with `associated_bands = {DETFOO: [RED, GREEN, BLUE]}`,
11+
`bands_to_load` would become `[DETFOO, DETFOO, DETFOO]`, causing each associated
12+
band mask to be opened 3 times.
13+
14+
Fix:
15+
The fix ensures `band` is only appended to `bands_to_load` once, by checking
16+
`if band not in bands_to_load` before appending.
17+
"""
18+
19+
from unittest.mock import MagicMock, patch
20+
21+
import pytest
22+
23+
from eoreader.bands import S2MaskBandNames
24+
from eoreader.keywords import ASSOCIATED_BANDS
25+
from eoreader.products.optical.s2_product import S2Product
26+
27+
28+
class TestS2LoadMasksNoDuplicateLoading:
29+
"""Test that S2Product._load_masks doesn't load the same mask multiple times."""
30+
31+
@pytest.fixture
32+
def mock_s2_product(self):
33+
"""Create a mock S2Product instance with necessary attributes mocked."""
34+
with patch.object(S2Product, "__init__", lambda self, *args, **kwargs: None):
35+
prod = S2Product.__new__(S2Product)
36+
37+
# Set required attributes
38+
prod._processing_baseline = 4.0
39+
40+
# Mock _sanitized_associated_bands to return our test data
41+
prod._sanitized_associated_bands = MagicMock()
42+
43+
# Mock _get_band_key to return a predictable key
44+
def get_band_key(band, assoc_band, **kw):
45+
band_name = band.name if hasattr(band, "name") else str(band)
46+
assoc_name = (
47+
assoc_band.name
48+
if hasattr(assoc_band, "name") and assoc_band
49+
else str(assoc_band)
50+
if assoc_band
51+
else "None"
52+
)
53+
return f"{band_name}_{assoc_name}"
54+
55+
prod._get_band_key = MagicMock(side_effect=get_band_key)
56+
57+
# Mock get_band_path to return a path that doesn't exist (forces loading)
58+
mock_path = MagicMock()
59+
mock_path.is_file.return_value = False
60+
prod.get_band_path = MagicMock(return_value=mock_path)
61+
62+
# Mock _open_masks to track what it receives
63+
prod._open_masks = MagicMock(return_value={})
64+
65+
yield prod
66+
67+
def test_load_masks_no_duplicates_with_multiple_associated_bands(
68+
self, mock_s2_product
69+
):
70+
"""Verify _open_masks receives bands_to_load without duplicates.
71+
72+
When a mask (e.g., DETFOO) has multiple associated bands (e.g., [RED, GREEN, BLUE]),
73+
the band should appear only once in bands_to_load passed to _open_masks.
74+
"""
75+
prod = mock_s2_product
76+
77+
# DETFOO mask with 3 associated bands
78+
associated_bands = {S2MaskBandNames.DETFOO: ["RED", "GREEN", "BLUE"]}
79+
80+
# Mock returns the same associated_bands that we pass as input
81+
prod._sanitized_associated_bands.return_value = associated_bands
82+
83+
# Call the actual _load_masks method
84+
prod._load_masks(
85+
bands=[S2MaskBandNames.DETFOO],
86+
pixel_size=10,
87+
size=None,
88+
**{ASSOCIATED_BANDS: associated_bands},
89+
)
90+
91+
# Verify _open_masks was called
92+
assert prod._open_masks.called, "_open_masks should have been called"
93+
94+
# Get the bands_to_load argument passed to _open_masks
95+
call_args = prod._open_masks.call_args
96+
bands_to_load = call_args[0][0] # First positional argument
97+
98+
# THE KEY ASSERTION: DETFOO should appear exactly once, not 3 times
99+
detfoo_count = bands_to_load.count(S2MaskBandNames.DETFOO)
100+
assert detfoo_count == 1, (
101+
f"S2MaskBandNames.DETFOO should appear exactly once in bands_to_load, "
102+
f"but appears {detfoo_count} times. bands_to_load = {bands_to_load}"
103+
)
104+
105+
def test_load_masks_multiple_masks_with_associated_bands(self, mock_s2_product):
106+
"""Test with multiple masks, each having multiple associated bands."""
107+
prod = mock_s2_product
108+
109+
# DETFOO with 3 associated bands, SATURA with 2 associated bands
110+
associated_bands = {
111+
S2MaskBandNames.DETFOO: ["RED", "GREEN", "BLUE"],
112+
S2MaskBandNames.SATURA: ["RED", "GREEN"],
113+
}
114+
115+
# Mock returns the same associated_bands that we pass as input
116+
prod._sanitized_associated_bands.return_value = associated_bands
117+
118+
# Call the actual _load_masks method
119+
prod._load_masks(
120+
bands=[S2MaskBandNames.DETFOO, S2MaskBandNames.SATURA],
121+
pixel_size=10,
122+
size=None,
123+
**{ASSOCIATED_BANDS: associated_bands},
124+
)
125+
126+
# Get the bands_to_load argument
127+
call_args = prod._open_masks.call_args
128+
bands_to_load = call_args[0][0]
129+
130+
# Each mask should appear exactly once
131+
assert bands_to_load.count(S2MaskBandNames.DETFOO) == 1
132+
assert bands_to_load.count(S2MaskBandNames.SATURA) == 1
133+
assert len(bands_to_load) == 2

eoreader/products/optical/s2_product.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,8 @@ def _load_masks(
894894
if mask_path.is_file():
895895
band_dict[key] = utils.read(mask_path)
896896
else:
897-
bands_to_load.append(band)
897+
if band not in bands_to_load:
898+
bands_to_load.append(band)
898899
associated_bands_to_load[band].append(associated_band)
899900

900901
# Then load other bands that haven't been loaded before

0 commit comments

Comments
 (0)