Skip to content

preprocessing module

Module for preprocessing Earth Observation data using Google Earth Engine.

MeanCentering

Mean-centers each band of an Earth Engine image.

The transformation is computed as:

$$ X_{centered} = X - \mu $$

Where:

  • $X$: original pixel value
  • $\mu$: mean of the band computed over the given region

Parameters:

Name Type Description Default
image ee.Image

Input multi-band image to center.

required
region ee.Geometry

Geometry over which statistics will be computed.

required
scale int

Spatial resolution in meters. Defaults to 100.

100
max_pixels int

Max pixels allowed in computation. Defaults to 1e9.

1000000000

Exceptions:

Type Description
TypeError

If image or region is not an ee.Image or ee.Geometry.

Source code in geeagri/preprocessing.py
class MeanCentering:
    r"""
    Mean-centers each band of an Earth Engine image.

    The transformation is computed as:

    $$
    X_{centered} = X - \mu
    $$

    Where:

    - $X$: original pixel value
    - $\mu$: mean of the band computed over the given region

    Args:
        image (ee.Image): Input multi-band image to center.
        region (ee.Geometry): Geometry over which statistics will be computed.
        scale (int, optional): Spatial resolution in meters. Defaults to 100.
        max_pixels (int, optional): Max pixels allowed in computation. Defaults to 1e9.

    Raises:
        TypeError: If image or region is not an ee.Image or ee.Geometry.
    """

    def __init__(
        self,
        image: ee.Image,
        region: ee.Geometry,
        scale: int = 100,
        max_pixels: int = int(1e9),
    ):
        if not isinstance(image, ee.Image):
            raise TypeError("Expected 'image' to be of type ee.Image.")
        if not isinstance(region, ee.Geometry):
            raise TypeError("Expected 'region' to be of type ee.Geometry.")

        self.image = image
        self.region = region
        self.scale = scale
        self.max_pixels = max_pixels

    def transform(self) -> ee.Image:
        """
        Applies mean-centering to each band of the image.

        Returns:
            ee.Image: The centered image with mean of each band subtracted.

        Raises:
            ValueError: If mean computation returns None or missing values.
        """
        means = self.image.reduceRegion(
            reducer=ee.Reducer.mean(),
            geometry=self.region,
            scale=self.scale,
            bestEffort=True,
            maxPixels=self.max_pixels,
        )

        if means is None:
            raise ValueError("Mean computation failed — no valid pixels in the region.")

        bands = self.image.bandNames()

        def center_band(band):
            band = ee.String(band)
            mean = ee.Number(means.get(band))
            if mean is None:
                raise ValueError(f"Mean value not found for band: {band.getInfo()}")
            return self.image.select(band).subtract(mean).rename(band)

        centered = bands.map(center_band)
        return ee.ImageCollection(centered).toBands().rename(bands)

transform(self)

Applies mean-centering to each band of the image.

Returns:

Type Description
ee.Image

The centered image with mean of each band subtracted.

Exceptions:

Type Description
ValueError

If mean computation returns None or missing values.

Source code in geeagri/preprocessing.py
def transform(self) -> ee.Image:
    """
    Applies mean-centering to each band of the image.

    Returns:
        ee.Image: The centered image with mean of each band subtracted.

    Raises:
        ValueError: If mean computation returns None or missing values.
    """
    means = self.image.reduceRegion(
        reducer=ee.Reducer.mean(),
        geometry=self.region,
        scale=self.scale,
        bestEffort=True,
        maxPixels=self.max_pixels,
    )

    if means is None:
        raise ValueError("Mean computation failed — no valid pixels in the region.")

    bands = self.image.bandNames()

    def center_band(band):
        band = ee.String(band)
        mean = ee.Number(means.get(band))
        if mean is None:
            raise ValueError(f"Mean value not found for band: {band.getInfo()}")
        return self.image.select(band).subtract(mean).rename(band)

    centered = bands.map(center_band)
    return ee.ImageCollection(centered).toBands().rename(bands)

MinMaxScaler

Applies min-max normalization to each band of an Earth Engine image.

The transformation is computed as:

$$ X_\text{scaled} = \frac{X - \min}{\max - \min} $$

After clamping, $X_\text{scaled} \in [0, 1]$.

Where:

  • $\min$, $\max$: band-wise minimum and maximum values over the region.

Parameters:

Name Type Description Default
image ee.Image

The input multi-band image.

required
region ee.Geometry

The region over which to compute min and max.

required
scale int

The spatial resolution in meters. Defaults to 100.

100
max_pixels int

Max pixels allowed during reduction. Defaults to 1e9.

1000000000

Exceptions:

Type Description
TypeError

If image is not an ee.Image or region is not an ee.Geometry.

Source code in geeagri/preprocessing.py
class MinMaxScaler:
    r"""
    Applies min-max normalization to each band of an Earth Engine image.

    The transformation is computed as:

    $$
    X_\\text{scaled} = \\frac{X - \\min}{\\max - \\min}
    $$

    After clamping, $X_\\text{scaled} \\in [0, 1]$.

    Where:

    - $\min$, $\max$: band-wise minimum and maximum values over the region.

    Args:
        image (ee.Image): The input multi-band image.
        region (ee.Geometry): The region over which to compute min and max.
        scale (int, optional): The spatial resolution in meters. Defaults to 100.
        max_pixels (int, optional): Max pixels allowed during reduction. Defaults to 1e9.

    Raises:
        TypeError: If `image` is not an `ee.Image` or `region` is not an `ee.Geometry`.
    """

    def __init__(
        self,
        image: ee.Image,
        region: ee.Geometry,
        scale: int = 100,
        max_pixels: int = int(1e9),
    ):
        if not isinstance(image, ee.Image):
            raise TypeError("Expected 'image' to be of type ee.Image.")
        if not isinstance(region, ee.Geometry):
            raise TypeError("Expected 'region' to be of type ee.Geometry.")

        self.image = image
        self.region = region
        self.scale = scale
        self.max_pixels = max_pixels

    def transform(self) -> ee.Image:
        """
        Applies min-max scaling to each band, producing values in the range [0, 1].

        Returns:
            ee.Image: A scaled image with band values clamped between 0 and 1.

        Raises:
            ValueError: If min or max statistics are unavailable or reduction fails.
        """
        stats = self.image.reduceRegion(
            reducer=ee.Reducer.minMax(),
            geometry=self.region,
            scale=self.scale,
            bestEffort=True,
            maxPixels=self.max_pixels,
        )

        if stats is None:
            raise ValueError(
                "MinMax reduction failed — possibly no valid pixels in region."
            )

        bands = self.image.bandNames()

        def scale_band(band):
            band = ee.String(band)
            min_val = ee.Number(stats.get(band.cat("_min")))
            max_val = ee.Number(stats.get(band.cat("_max")))
            if min_val is None or max_val is None:
                raise ValueError(f"Missing min/max for band: {band.getInfo()}")
            scaled = (
                self.image.select(band)
                .subtract(min_val)
                .divide(max_val.subtract(min_val))
            )
            return scaled.clamp(0, 1).rename(band)

        scaled = bands.map(scale_band)
        return ee.ImageCollection(scaled).toBands().rename(bands)

transform(self)

Applies min-max scaling to each band, producing values in the range [0, 1].

Returns:

Type Description
ee.Image

A scaled image with band values clamped between 0 and 1.

Exceptions:

Type Description
ValueError

If min or max statistics are unavailable or reduction fails.

Source code in geeagri/preprocessing.py
def transform(self) -> ee.Image:
    """
    Applies min-max scaling to each band, producing values in the range [0, 1].

    Returns:
        ee.Image: A scaled image with band values clamped between 0 and 1.

    Raises:
        ValueError: If min or max statistics are unavailable or reduction fails.
    """
    stats = self.image.reduceRegion(
        reducer=ee.Reducer.minMax(),
        geometry=self.region,
        scale=self.scale,
        bestEffort=True,
        maxPixels=self.max_pixels,
    )

    if stats is None:
        raise ValueError(
            "MinMax reduction failed — possibly no valid pixels in region."
        )

    bands = self.image.bandNames()

    def scale_band(band):
        band = ee.String(band)
        min_val = ee.Number(stats.get(band.cat("_min")))
        max_val = ee.Number(stats.get(band.cat("_max")))
        if min_val is None or max_val is None:
            raise ValueError(f"Missing min/max for band: {band.getInfo()}")
        scaled = (
            self.image.select(band)
            .subtract(min_val)
            .divide(max_val.subtract(min_val))
        )
        return scaled.clamp(0, 1).rename(band)

    scaled = bands.map(scale_band)
    return ee.ImageCollection(scaled).toBands().rename(bands)

RobustScaler

Applies robust scaling to each band of an Earth Engine image using percentiles, which reduces the influence of outliers compared to min-max scaling.

The transformation is computed as:

$$ X_\text{scaled} = \frac{X - P_{\text{lower}}}{P_{\text{upper}} - P_{\text{lower}}} $$

After clamping, $X_\text{scaled} \in [0, 1]$.

Where:

  • $X$: original pixel value
  • $P_{\text{lower}}$: lower percentile value (e.g., 25th percentile)
  • $P_{\text{upper}}$: upper percentile value (e.g., 75th percentile)

This method is particularly useful when the image contains outliers or skewed distributions.

Parameters:

Name Type Description Default
image ee.Image

The input multi-band image.

required
region ee.Geometry

Geometry over which percentiles are computed.

required
scale int

Spatial resolution in meters for computation.

100
lower int

Lower percentile to use (default: 25).

25
upper int

Upper percentile to use (default: 75).

75
max_pixels int

Maximum number of pixels allowed for region reduction.

1000000000

Exceptions:

Type Description
TypeError

If image is not an ee.Image or region is not an ee.Geometry.

Source code in geeagri/preprocessing.py
class RobustScaler:
    r"""
    Applies robust scaling to each band of an Earth Engine image using percentiles,
    which reduces the influence of outliers compared to min-max scaling.

    The transformation is computed as:

    $$
    X_\\text{scaled} = \\frac{X - P_{\\text{lower}}}{P_{\\text{upper}} - P_{\\text{lower}}}
    $$

    After clamping, $X_\\text{scaled} \\in [0, 1]$.

    Where:

    - $X$: original pixel value
    - $P_{\\text{lower}}$: lower percentile value (e.g., 25th percentile)
    - $P_{\\text{upper}}$: upper percentile value (e.g., 75th percentile)

    This method is particularly useful when the image contains outliers or skewed distributions.

    Args:
        image (ee.Image): The input multi-band image.
        region (ee.Geometry): Geometry over which percentiles are computed.
        scale (int): Spatial resolution in meters for computation.
        lower (int): Lower percentile to use (default: 25).
        upper (int): Upper percentile to use (default: 75).
        max_pixels (int): Maximum number of pixels allowed for region reduction.

    Raises:
        TypeError: If `image` is not an `ee.Image` or `region` is not an `ee.Geometry`.
    """

    def __init__(
        self,
        image: ee.Image,
        region: ee.Geometry,
        scale: int = 100,
        lower: int = 25,
        upper: int = 75,
        max_pixels: int = int(1e9),
    ):
        if not isinstance(image, ee.Image):
            raise TypeError("Expected 'image' to be of type ee.Image.")
        if not isinstance(region, ee.Geometry):
            raise TypeError("Expected 'region' to be of type ee.Geometry.")
        if not (0 <= lower < upper <= 100):
            raise ValueError("Percentiles must satisfy 0 <= lower < upper <= 100.")

        self.image = image
        self.region = region
        self.scale = scale
        self.lower = lower
        self.upper = upper
        self.max_pixels = max_pixels

    def transform(self) -> ee.Image:
        """
        Applies percentile-based scaling to each band in the image.
        Values are scaled to the [0, 1] range and clamped.

        Returns:
            ee.Image: The scaled image with values between 0 and 1.

        Raises:
            ValueError: If percentile reduction fails.
        """
        bands = self.image.bandNames()
        percentiles = self.image.reduceRegion(
            reducer=ee.Reducer.percentile([self.lower, self.upper]),
            geometry=self.region,
            scale=self.scale,
            bestEffort=True,
            maxPixels=self.max_pixels,
        )

        if percentiles is None:
            raise ValueError("Percentile computation failed.")

        def scale_band(band):
            band = ee.String(band)
            p_min = ee.Number(percentiles.get(band.cat(f"_p{self.lower}")))
            p_max = ee.Number(percentiles.get(band.cat(f"_p{self.upper}")))
            if p_min is None or p_max is None:
                raise ValueError(
                    f"Missing percentile values for band: {band.getInfo()}"
                )

            scaled = (
                self.image.select(band).subtract(p_min).divide(p_max.subtract(p_min))
            )
            return scaled.clamp(0, 1).rename(band)

        scaled = bands.map(scale_band)
        return ee.ImageCollection(scaled).toBands().rename(bands)

transform(self)

Applies percentile-based scaling to each band in the image. Values are scaled to the [0, 1] range and clamped.

Returns:

Type Description
ee.Image

The scaled image with values between 0 and 1.

Exceptions:

Type Description
ValueError

If percentile reduction fails.

Source code in geeagri/preprocessing.py
def transform(self) -> ee.Image:
    """
    Applies percentile-based scaling to each band in the image.
    Values are scaled to the [0, 1] range and clamped.

    Returns:
        ee.Image: The scaled image with values between 0 and 1.

    Raises:
        ValueError: If percentile reduction fails.
    """
    bands = self.image.bandNames()
    percentiles = self.image.reduceRegion(
        reducer=ee.Reducer.percentile([self.lower, self.upper]),
        geometry=self.region,
        scale=self.scale,
        bestEffort=True,
        maxPixels=self.max_pixels,
    )

    if percentiles is None:
        raise ValueError("Percentile computation failed.")

    def scale_band(band):
        band = ee.String(band)
        p_min = ee.Number(percentiles.get(band.cat(f"_p{self.lower}")))
        p_max = ee.Number(percentiles.get(band.cat(f"_p{self.upper}")))
        if p_min is None or p_max is None:
            raise ValueError(
                f"Missing percentile values for band: {band.getInfo()}"
            )

        scaled = (
            self.image.select(band).subtract(p_min).divide(p_max.subtract(p_min))
        )
        return scaled.clamp(0, 1).rename(band)

    scaled = bands.map(scale_band)
    return ee.ImageCollection(scaled).toBands().rename(bands)

Sentinel2CloudMask

A utility class for creating cloud- and shadow-masked Sentinel-2 image collections.

This class uses Sentinel-2 Level-2A Surface Reflectance (SR) data in combination with Sentinel-2 Cloud Probability (s2cloudless) data to generate a cloud-free ImageCollection.

Attributes:

Name Type Description
region ee.Geometry

The region of interest for filtering the ImageCollection.

start_date str

Start date (inclusive) in 'YYYY-MM-DD' format.

end_date str

End date (exclusive) in 'YYYY-MM-DD' format.

cloud_filter int

Maximum scene-level cloudiness allowed (%).

cloud_prob_threshold int

Cloud probability threshold (values above are considered clouds).

nir_dark_threshold float

NIR reflectance threshold (values below considered potential shadows).

shadow_proj_dist int

Maximum distance (km) to search for shadows from clouds.

buffer int

Buffer distance (m) to dilate cloud/shadow masks.

Source code in geeagri/preprocessing.py
class Sentinel2CloudMask:
    """A utility class for creating cloud- and shadow-masked Sentinel-2 image collections.

    This class uses Sentinel-2 Level-2A Surface Reflectance (SR) data in combination
    with Sentinel-2 Cloud Probability (s2cloudless) data to generate a
    cloud-free ImageCollection.

    Attributes:
        region (ee.Geometry): The region of interest for filtering the ImageCollection.
        start_date (str): Start date (inclusive) in 'YYYY-MM-DD' format.
        end_date (str): End date (exclusive) in 'YYYY-MM-DD' format.
        cloud_filter (int): Maximum scene-level cloudiness allowed (%).
        cloud_prob_threshold (int): Cloud probability threshold (values above are considered clouds).
        nir_dark_threshold (float): NIR reflectance threshold (values below considered potential shadows).
        shadow_proj_dist (int): Maximum distance (km) to search for shadows from clouds.
        buffer (int): Buffer distance (m) to dilate cloud/shadow masks.
    """

    def __init__(
        self,
        region,
        start_date,
        end_date,
        cloud_filter=60,
        cloud_prob_threshold=50,
        nir_dark_threshold=0.15,
        shadow_proj_dist=1,
        buffer=50,
    ):

        if not isinstance(region, ee.Geometry):
            raise ValueError("`region` must be an instance of ee.Geometry.")

        self.region = region
        self.start_date = start_date
        self.end_date = end_date
        self.cloud_filter = cloud_filter
        self.cloud_prob_threshold = cloud_prob_threshold
        self.nir_dark_threshold = nir_dark_threshold
        self.shadow_proj_dist = shadow_proj_dist
        self.buffer = buffer

    def get_cloud_collection(self):
        """Retrieve Sentinel-2 images joined with s2cloudless cloud probability.

        Returns:
            ee.ImageCollection: Sentinel-2 SR images with a property containing
            the matching s2cloudless image.
        """
        s2_sr = (
            ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")
            .filterBounds(self.region)
            .filterDate(self.start_date, self.end_date)
            .filter(ee.Filter.lte("CLOUDY_PIXEL_PERCENTAGE", self.cloud_filter))
        )

        s2_cloud_prob = (
            ee.ImageCollection("COPERNICUS/S2_CLOUD_PROBABILITY")
            .filterBounds(self.region)
            .filterDate(self.start_date, self.end_date)
        )

        joined = ee.ImageCollection(
            ee.Join.saveFirst("cloud_prob").apply(
                primary=s2_sr,
                secondary=s2_cloud_prob,
                condition=ee.Filter.equals(
                    leftField="system:index", rightField="system:index"
                ),
            )
        )

        return joined

    def _add_cloud_bands(self, image):
        """Add cloud probability and binary cloud mask bands.

        Args:
            image (ee.Image): Sentinel-2 image.

        Returns:
            ee.Image: Image with added `cloud_prob` and `clouds` bands.
        """
        cloud_prob = ee.Image(image.get("cloud_prob")).select("probability")
        is_cloud = cloud_prob.gt(self.cloud_prob_threshold).rename("clouds")

        return image.addBands([cloud_prob.rename("cloud_prob"), is_cloud])

    def _add_shadow_bands(self, image):
        """Add potential shadow bands to the image.

        Args:
            image (ee.Image): Sentinel-2 image with cloud mask.

        Returns:
            ee.Image: Image with added `dark_pixels`, `cloud_transform`, and `shadows` bands.
        """
        not_water = image.select("SCL").neq(6)

        scale_factor = 1e4
        dark_pixels = (
            image.select("B8")
            .lt(self.nir_dark_threshold * scale_factor)
            .multiply(not_water)
            .rename("dark_pixels")
        )

        shadow_azimuth = ee.Number(90).subtract(
            ee.Number(image.get("MEAN_SOLAR_AZIMUTH_ANGLE"))
        )

        cloud_proj = (
            image.select("clouds")
            .directionalDistanceTransform(shadow_azimuth, self.shadow_proj_dist * 10)
            .reproject(crs=image.select(0).projection(), scale=100)
            .select("distance")
            .mask()
            .rename("cloud_transform")
        )

        shadows = cloud_proj.multiply(dark_pixels).rename("shadows")

        return image.addBands([dark_pixels, cloud_proj, shadows])

    def _add_cloud_shadow_mask(self, image):
        """Create combined cloud + shadow mask.

        Args:
            image (ee.Image): Sentinel-2 image.

        Returns:
            ee.Image: Image with an added `cloudmask` band.
        """
        image = self._add_cloud_bands(image)
        image = self._add_shadow_bands(image)

        cloud_shadow_mask = image.select("clouds").add(image.select("shadows")).gt(0)

        cloud_shadow_mask = (
            cloud_shadow_mask.focal_min(2)
            .focal_max(self.buffer * 2 / 20)
            .reproject(crs=image.select(0).projection(), scale=20)
            .rename("cloudmask")
        )

        return image.addBands(cloud_shadow_mask)

    def _apply_cloud_shadow_mask(self, image):
        """Apply cloud/shadow mask to reflectance bands.

        Args:
            image (ee.Image): Sentinel-2 image with `cloudmask` band.

        Returns:
            ee.Image: Cloud/shadow-masked image (reflectance bands only).
        """
        not_cloud_shadow = image.select("cloudmask").Not()
        return image.select("B.*").updateMask(not_cloud_shadow)

    def get_cloudfree_collection(self):
        """Generate cloud-free Sentinel-2 ImageCollection.

        Returns:
            ee.ImageCollection: Cloud- and shadow-masked Sentinel-2 SR collection.
        """
        cloud_collection = self.get_cloud_collection()
        return cloud_collection.map(self._add_cloud_shadow_mask).map(
            self._apply_cloud_shadow_mask
        )

get_cloud_collection(self)

Retrieve Sentinel-2 images joined with s2cloudless cloud probability.

Returns:

Type Description
ee.ImageCollection

Sentinel-2 SR images with a property containing the matching s2cloudless image.

Source code in geeagri/preprocessing.py
def get_cloud_collection(self):
    """Retrieve Sentinel-2 images joined with s2cloudless cloud probability.

    Returns:
        ee.ImageCollection: Sentinel-2 SR images with a property containing
        the matching s2cloudless image.
    """
    s2_sr = (
        ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")
        .filterBounds(self.region)
        .filterDate(self.start_date, self.end_date)
        .filter(ee.Filter.lte("CLOUDY_PIXEL_PERCENTAGE", self.cloud_filter))
    )

    s2_cloud_prob = (
        ee.ImageCollection("COPERNICUS/S2_CLOUD_PROBABILITY")
        .filterBounds(self.region)
        .filterDate(self.start_date, self.end_date)
    )

    joined = ee.ImageCollection(
        ee.Join.saveFirst("cloud_prob").apply(
            primary=s2_sr,
            secondary=s2_cloud_prob,
            condition=ee.Filter.equals(
                leftField="system:index", rightField="system:index"
            ),
        )
    )

    return joined

get_cloudfree_collection(self)

Generate cloud-free Sentinel-2 ImageCollection.

Returns:

Type Description
ee.ImageCollection

Cloud- and shadow-masked Sentinel-2 SR collection.

Source code in geeagri/preprocessing.py
def get_cloudfree_collection(self):
    """Generate cloud-free Sentinel-2 ImageCollection.

    Returns:
        ee.ImageCollection: Cloud- and shadow-masked Sentinel-2 SR collection.
    """
    cloud_collection = self.get_cloud_collection()
    return cloud_collection.map(self._add_cloud_shadow_mask).map(
        self._apply_cloud_shadow_mask
    )

StandardScaler

Standardizes each band of an Earth Engine image using z-score normalization.

The transformation is computed as:

$$ X_\text{standardized} = \frac{X - \mu}{\sigma} $$

Where:

  • $X$: original pixel value
  • $\mu$: mean of the band over the specified region
  • $\sigma$: standard deviation of the band over the specified region

This transformation results in a standardized image where each band has zero mean and unit variance (approximately), assuming normally distributed values.

Parameters:

Name Type Description Default
image ee.Image

The input multi-band image to be standardized.

required
region ee.Geometry

The geographic region over which to compute the statistics.

required
scale int

Spatial resolution (in meters) to use for region reduction. Defaults to 100.

100
max_pixels int

Maximum number of pixels allowed in reduction. Defaults to 1e9.

1000000000

Exceptions:

Type Description
TypeError

If image is not an ee.Image or region is not an ee.Geometry.

Source code in geeagri/preprocessing.py
class StandardScaler:
    r"""
    Standardizes each band of an Earth Engine image using z-score normalization.

    The transformation is computed as:

    $$
    X_\\text{standardized} = \\frac{X - \\mu}{\\sigma}
    $$

    Where:

    - $X$: original pixel value
    - $\mu$: mean of the band over the specified region
    - $\sigma$: standard deviation of the band over the specified region

    This transformation results in a standardized image where each band has
    zero mean and unit variance (approximately), assuming normally distributed values.

    Args:
        image (ee.Image): The input multi-band image to be standardized.
        region (ee.Geometry): The geographic region over which to compute the statistics.
        scale (int, optional): Spatial resolution (in meters) to use for region reduction. Defaults to 100.
        max_pixels (int, optional): Maximum number of pixels allowed in reduction. Defaults to 1e9.

    Raises:
        TypeError: If `image` is not an `ee.Image` or `region` is not an `ee.Geometry`.
    """

    def __init__(
        self,
        image: ee.Image,
        region: ee.Geometry,
        scale: int = 100,
        max_pixels: int = int(1e9),
    ):
        if not isinstance(image, ee.Image):
            raise TypeError("Expected 'image' to be of type ee.Image.")
        if not isinstance(region, ee.Geometry):
            raise TypeError("Expected 'region' to be of type ee.Geometry.")

        self.image = image
        self.region = region
        self.scale = scale
        self.max_pixels = max_pixels

    def transform(self) -> ee.Image:
        """
        Applies z-score normalization to each band.

        Returns:
            ee.Image: Standardized image with zero mean and unit variance.

        Raises:
            ValueError: If statistics could not be computed.
        """
        means = self.image.reduceRegion(
            reducer=ee.Reducer.mean(),
            geometry=self.region,
            scale=self.scale,
            bestEffort=True,
            maxPixels=self.max_pixels,
        )
        stds = self.image.reduceRegion(
            reducer=ee.Reducer.stdDev(),
            geometry=self.region,
            scale=self.scale,
            bestEffort=True,
            maxPixels=self.max_pixels,
        )

        if means is None or stds is None:
            raise ValueError(
                "Statistic computation failed — check if region has valid pixels."
            )

        bands = self.image.bandNames()

        def scale_band(band):
            band = ee.String(band)
            mean = ee.Number(means.get(band))
            std = ee.Number(stds.get(band))
            if mean is None or std is None:
                raise ValueError(f"Missing stats for band: {band.getInfo()}")
            return self.image.select(band).subtract(mean).divide(std).rename(band)

        scaled = bands.map(scale_band)
        return ee.ImageCollection(scaled).toBands().rename(bands)

transform(self)

Applies z-score normalization to each band.

Returns:

Type Description
ee.Image

Standardized image with zero mean and unit variance.

Exceptions:

Type Description
ValueError

If statistics could not be computed.

Source code in geeagri/preprocessing.py
def transform(self) -> ee.Image:
    """
    Applies z-score normalization to each band.

    Returns:
        ee.Image: Standardized image with zero mean and unit variance.

    Raises:
        ValueError: If statistics could not be computed.
    """
    means = self.image.reduceRegion(
        reducer=ee.Reducer.mean(),
        geometry=self.region,
        scale=self.scale,
        bestEffort=True,
        maxPixels=self.max_pixels,
    )
    stds = self.image.reduceRegion(
        reducer=ee.Reducer.stdDev(),
        geometry=self.region,
        scale=self.scale,
        bestEffort=True,
        maxPixels=self.max_pixels,
    )

    if means is None or stds is None:
        raise ValueError(
            "Statistic computation failed — check if region has valid pixels."
        )

    bands = self.image.bandNames()

    def scale_band(band):
        band = ee.String(band)
        mean = ee.Number(means.get(band))
        std = ee.Number(stds.get(band))
        if mean is None or std is None:
            raise ValueError(f"Missing stats for band: {band.getInfo()}")
        return self.image.select(band).subtract(mean).divide(std).rename(band)

    scaled = bands.map(scale_band)
    return ee.ImageCollection(scaled).toBands().rename(bands)