diff --git a/geetools/ee_image.py b/geetools/ee_image.py index cda57a2c..6f27d431 100644 --- a/geetools/ee_image.py +++ b/geetools/ee_image.py @@ -21,7 +21,7 @@ from xee.ext import REQUEST_BYTE_LIMIT from .accessors import register_class_accessor -from .utils import format_class_info, plot_data +from .utils import format_bits_info, format_class_info, plot_data @register_class_accessor(ee.Image, "geetools") @@ -1678,6 +1678,117 @@ def fromList(images: ee.List | list[ee.Image]) -> ee.Image: ic = ee.ImageCollection.fromImages(images) return ic.toBands().rename(bandNames) + def bitsToBands(self, bits_info: dict, band: str | int = 0) -> ee.Image: + """Convert a band encoded in bits into binary bands. + + Args: + bits_info: bits encodings information (client-side only). + band: name of the bit band. Defaults to first band. + + Returns: + an image with one band per encoded bit. + + Example: + .. code-block:: python + + import ee, geetools + + ee.Initialize() + + ls = ee.Image("LANDSAT/LC09/C02/T1_L2/LC09_232090_20220508") + bits = { + '7': 'Water', + '8-9': { + '1': 'Low', + '2': 'Medium', + '3': 'High' + } + } + decoded = ls.geetools.bitsToBands(bits, 'QA_PIXEL') + """ + bitband = self._obj.select([band]) + bits_info = format_bits_info(bits_info) + masks = [] + for positions, values in bits_info.items(): + start, end = positions.split("-") + start, end = ee.Image(int(start)), ee.Image(int(end)).add(1) + decoded = bitband.rightShift(end).leftShift(end).bitwiseXor(bitband).rightShift(start) + for position, val in values.items(): + mask = ee.Image(int(position)).eq(decoded).rename(val) + masks.append(mask) + return ImageAccessor.fromList(masks) + + def bitsMaskAll( + self, bits_info: dict, classes: Optional[list] = None, band: str | int = 0 + ) -> ee.Image: + """Create a mask using the bits information. + + Args: + bits_info: bits encodings information (client-side only). + classes: name of the classes to use for the mask. If None it will use all classes in bits_info. + band: name of the bit band. Defaults to first band. + + Returns: + an image with one band named 'all_mask', with 1s where ALL classes are present and 0s where not. + + Example: + .. code-block:: python + + import ee, geetools + + ee.Initialize() + + ls = ee.Image("LANDSAT/LC09/C02/T1_L2/LC09_232090_20220508") + bits = { + '5': 'Snow', + '8-9': { + '1': 'Low', + '2': 'Medium', + '3': 'High' + } + } + # Detect confusion between snow and medium probability clouds + mask = ls.geetools.bitsMaskAll(bits, ['Snow', 'Medium'], 'QA_PIXEL') + """ + masks = self.bitsToBands(bits_info, band) + masks = masks.select(classes) if classes else masks + return masks.reduce(ee.Reducer.allNonZero()).rename("all_mask") + + def bitsMaskAny( + self, bits_info: dict, classes: Optional[list] = None, band: str | int = 0 + ) -> ee.Image: + """Create a mask using the bits information. + + Args: + bits_info: bits encodings information (client-side only). + classes: name of the classes to use for the mask. If None it will use all classes in bits_info. + band: name of the bit band. Defaults to first band. + + Returns: + an image with one band named 'any_mask', with 1s where ANY of the classes is present and 0s where not. + + Example: + .. code-block:: python + + import ee, geetools + + ee.Initialize() + + ls = ee.Image("LANDSAT/LC09/C02/T1_L2/LC09_232090_20220508") + bits = { + '7': 'Water', + '8-9': { + '1': 'Low', + '2': 'Medium', + '3': 'High' + } + } + mask = ls.geetools.bitsMaskAny(bits, ['Water', 'High'], 'QA_PIXEL') + """ + masks = self.bitsToBands(bits_info, band) + masks = masks.select(classes) if classes else masks + return masks.reduce(ee.Reducer.anyNonZero()).rename("any_mask") + def classToBands(self, class_info: dict, band: str | int = 0) -> ee.Image: """Convert each class into a separate binary mask. diff --git a/geetools/utils.py b/geetools/utils.py index 5ed84904..5a2e8876 100644 --- a/geetools/utils.py +++ b/geetools/utils.py @@ -275,6 +275,82 @@ def format_bandname(name: str, replacement: str = "_") -> str: return str([name.replace(char, replacement) for char in banned][0]) +def format_bits_info(bits_info: dict) -> dict: + """Format the bits information to match the expected. + + Args: + bits_info: the bits information. + + Example: + .. code-block:: python + + from geetools.utils import format_bitmask + + bitmask = { + '0': 'shadows', + '1-2': { + '0': 'no clouds', + '1': 'high clouds', + '2': 'mid clouds', + '3': 'low clouds' + } + } + bitmask = format_bitmask(bitmask) + """ + final_bit_info = {} + classes = [] + for bit, info in bits_info.items(): + parts = bit.split("-") + start = parts[0].strip() + end = parts[1].strip() if len(parts) > 1 else start + try: + start, end = int(start), int(end) + except ValueError: + raise ValueError( + f"start and end bits must be numeric. Found start: '{start}' end: '{end}' in '{bit}'" + ) + formatted_key = f"{start}-{end}" + nbits = end - start + 1 + # bits info can be a string or dict + if isinstance(info, str): + if nbits > 1: + raise ValueError( + f"Cannot use a single information value for bits '{formatted_key}'. Use a dict instead." + ) + if len(info) == 0: + raise ValueError(f"Value for '{bit}' must contain at least one character.") + if info in classes: + raise ValueError( + f"Bits information cannot contain duplicated names. '{info}' is duplicated." + ) + classes.append(info) + formatted_info = {"1": format_bandname(info)} + elif isinstance(info, dict): + formatted_info = {} + for pos, value in info.items(): + if value in classes: + raise ValueError( + f"Bits information cannot contain duplicated names. '{value}' is duplicated." + ) + classes.append(value) + value = format_bandname(value) + if len(value) == 0: + raise ValueError( + f"Value for '{bit}' in position '{pos}' must contain at least one character." + ) + try: + pos = int(pos) + except ValueError: + raise ValueError(f"Value '{pos}' not allowed as bit information in '{bit}'.") + if pos >= 2**nbits: + raise ValueError(f"Value '{pos}' is out of range ('{bit}').") + formatted_info[str(pos)] = value + else: + raise ValueError(f"Type {type(info)} not allowed as bit information. Found {info}.") + final_bit_info[formatted_key] = formatted_info + return final_bit_info + + def format_class_info(class_info: dict) -> dict: """Format the class information. diff --git a/tests/conftest.py b/tests/conftest.py index 7d7262ab..9bd837f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -279,6 +279,14 @@ def jaxa_rainfall(): @pytest.fixture +def l9_cloudy_image(): + """A Landsat 9 image that contains clouds, shadows, water and snow. + + This image is located in Argentina and Chile (Patagonia). + """ + return ee.Image("LANDSAT/LC09/C02/T1_L2/LC09_232090_20220508") + + def s2_class_image(): """A Sentinel 2 image that contains clouds, shadows, water and snow. diff --git a/tests/test_Image.py b/tests/test_Image.py index 61d9a126..32328cb1 100644 --- a/tests/test_Image.py +++ b/tests/test_Image.py @@ -618,6 +618,64 @@ def image(self): return image.select(["B4", "B3", "B2"]) +class TestBitsToBands: + """Test the ``bitsToBands`` method.""" + + def test_bits_to_bands(self, l9_cloudy_image, ee_image_regression): + """Test ``bitsToBands``.""" + aoi = ee.Geometry.Polygon( + [ + [ + [-71.7044111602349, -42.808681632054565], + [-71.7044111602349, -43.07107570728516], + [-71.32950271296927, -43.07107570728516], + [-71.32950271296927, -42.808681632054565], + ] + ] + ) + bits = {"7": "Water", "8-9": {"1": "Low", "2": "Medium", "3": "High"}} + decoded = l9_cloudy_image.geetools.bitsToBands(bits, "QA_PIXEL") + ee_image_regression.check(decoded, scale=30, region=aoi) + + +class TestBitsMask: + """Test the ``bitsMask`` methods (all and any).""" + + def test_bits_mask_all(self, l9_cloudy_image, ee_image_regression): + aoi = ee.Geometry.Polygon( + [ + [ + [-71.7044111602349, -42.808681632054565], + [-71.7044111602349, -43.07107570728516], + [-71.32950271296927, -43.07107570728516], + [-71.32950271296927, -42.808681632054565], + ] + ] + ) + bits = {"5": "Snow", "8-9": {"1": "Low", "2": "Medium", "3": "High"}} + # Detect mask confusion between snow and clouds (medium probability) + decoded = l9_cloudy_image.geetools.bitsMaskAll(bits, ["Snow", "Medium"], "QA_PIXEL") + viz = {"min": 0, "max": 1, "palette": ["blue", "red"]} + ee_image_regression.check(decoded, scale=30, viz_params=viz, region=aoi) + + def test_bits_mask_any(self, l9_cloudy_image, ee_image_regression): + aoi = ee.Geometry.Polygon( + [ + [ + [-71.7044111602349, -42.808681632054565], + [-71.7044111602349, -43.07107570728516], + [-71.32950271296927, -43.07107570728516], + [-71.32950271296927, -42.808681632054565], + ] + ] + ) + bits = {"5": "Snow", "8-9": {"1": "Low", "2": "Medium", "3": "High"}} + # Get a mask for masking out unneeded pixels + decoded = l9_cloudy_image.geetools.bitsMaskAny(bits, ["Snow", "High", "Medium"], "QA_PIXEL") + viz = {"min": 0, "max": 1, "palette": ["blue", "red"]} + ee_image_regression.check(decoded, scale=30, viz_params=viz, region=aoi) + + class TestClassToBands: """Test the ``classToBands`` method.""" diff --git a/tests/test_Image/serialized_test_bits_to_bands.yml b/tests/test_Image/serialized_test_bits_to_bands.yml new file mode 100644 index 00000000..8be8e88d --- /dev/null +++ b/tests/test_Image/serialized_test_bits_to_bands.yml @@ -0,0 +1,227 @@ +result: '0' +values: + '0': + functionInvocationValue: + arguments: + geometry: + functionInvocationValue: + arguments: + coordinates: + constantValue: + - - - -71.7044111602349 + - -42.808681632054565 + - - -71.7044111602349 + - -43.07107570728516 + - - -71.32950271296927 + - -43.07107570728516 + - - -71.32950271296927 + - -42.808681632054565 + evenOdd: + constantValue: true + functionName: GeometryConstructors.Polygon + input: + functionInvocationValue: + arguments: + input: + functionInvocationValue: + arguments: + collection: + functionInvocationValue: + arguments: + images: + valueReference: '1' + functionName: ImageCollection.fromImages + functionName: ImageCollection.toBands + names: + functionInvocationValue: + arguments: + list: + functionInvocationValue: + arguments: + baseAlgorithm: + functionDefinitionValue: + argumentNames: + - _MAPPING_VAR_0_0 + body: '8' + dropNulls: + constantValue: false + list: + valueReference: '1' + functionName: List.map + functionName: List.flatten + functionName: Image.rename + scale: + constantValue: 30 + functionName: Image.clipToBoundsAndScale + '1': + arrayValue: + values: + - functionInvocationValue: + arguments: + input: + functionInvocationValue: + arguments: + image1: + valueReference: '2' + image2: + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + image1: + valueReference: '3' + image2: + valueReference: '4' + functionName: Image.rightShift + image2: + valueReference: '4' + functionName: Image.leftShift + image2: + valueReference: '3' + functionName: Image.bitwiseXor + image2: + valueReference: '5' + functionName: Image.rightShift + functionName: Image.eq + names: + constantValue: + - Water + functionName: Image.rename + - functionInvocationValue: + arguments: + input: + functionInvocationValue: + arguments: + image1: + valueReference: '2' + image2: + valueReference: '6' + functionName: Image.eq + names: + constantValue: + - Low + functionName: Image.rename + - functionInvocationValue: + arguments: + input: + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + value: + constantValue: 2 + functionName: Image.constant + image2: + valueReference: '6' + functionName: Image.eq + names: + constantValue: + - Medium + functionName: Image.rename + - functionInvocationValue: + arguments: + input: + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + value: + constantValue: 3 + functionName: Image.constant + image2: + valueReference: '6' + functionName: Image.eq + names: + constantValue: + - High + functionName: Image.rename + '2': + functionInvocationValue: + arguments: + value: + constantValue: 1 + functionName: Image.constant + '3': + functionInvocationValue: + arguments: + bandSelectors: + constantValue: + - QA_PIXEL + input: + functionInvocationValue: + arguments: + id: + constantValue: LANDSAT/LC09/C02/T1_L2/LC09_232090_20220508 + functionName: Image.load + functionName: Image.select + '4': + functionInvocationValue: + arguments: + image1: + valueReference: '5' + image2: + valueReference: '2' + functionName: Image.add + '5': + functionInvocationValue: + arguments: + value: + constantValue: 7 + functionName: Image.constant + '6': + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + image1: + valueReference: '3' + image2: + valueReference: '7' + functionName: Image.rightShift + image2: + valueReference: '7' + functionName: Image.leftShift + image2: + valueReference: '3' + functionName: Image.bitwiseXor + image2: + functionInvocationValue: + arguments: + value: + constantValue: 8 + functionName: Image.constant + functionName: Image.rightShift + '7': + functionInvocationValue: + arguments: + image1: + functionInvocationValue: + arguments: + value: + constantValue: 9 + functionName: Image.constant + image2: + valueReference: '2' + functionName: Image.add + '8': + functionInvocationValue: + arguments: + image: + argumentReference: _MAPPING_VAR_0_0 + functionName: Image.bandNames diff --git a/tests/test_Image/test_bits_mask_all.png b/tests/test_Image/test_bits_mask_all.png new file mode 100644 index 00000000..68b91237 Binary files /dev/null and b/tests/test_Image/test_bits_mask_all.png differ diff --git a/tests/test_Image/test_bits_mask_any.png b/tests/test_Image/test_bits_mask_any.png new file mode 100644 index 00000000..62068a48 Binary files /dev/null and b/tests/test_Image/test_bits_mask_any.png differ diff --git a/tests/test_Image/test_bits_to_bands.png b/tests/test_Image/test_bits_to_bands.png new file mode 100644 index 00000000..298e55ba Binary files /dev/null and b/tests/test_Image/test_bits_to_bands.png differ