Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion geetools/ee_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.

Expand Down
76 changes: 76 additions & 0 deletions geetools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
58 changes: 58 additions & 0 deletions tests/test_Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading
Loading