diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9ca8bde..d88ada4 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] os: ["ubuntu-24.04", "ubuntu-latest"] steps: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2eef6e1..922ac27 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,25 @@ Changelog ######### + +2025.8.3 - 2025-08-25 +--------------------- + +* packaging + + * add `Python :: 3.13` into `pyproject.toml` + +* core + + * minimize, fix and make `image_operations.blend_modes.*py` --> `image_operations.blend_functions.py` compatible with `image_operations.compositing.py` + * tests for `image_operations.blend_functions.py` are in `tests/image_operations/test_compositing.py`; rework the `tests/image_operations/test_compositing.py` altogether + * added tests for `image_operations.dtype_scale.py` and `image_operations.linear_normalization.py` + +* CI/CD + + * test with `python=3.13` + + 2025.8.2 - 2025-08-14 --------------------- diff --git a/mapchete_eo/__init__.py b/mapchete_eo/__init__.py index 4b34e00..07287ea 100644 --- a/mapchete_eo/__init__.py +++ b/mapchete_eo/__init__.py @@ -1 +1 @@ -__version__ = "2025.8.2" +__version__ = "2025.8.3" diff --git a/mapchete_eo/image_operations/blend_functions.py b/mapchete_eo/image_operations/blend_functions.py new file mode 100644 index 0000000..7c69756 --- /dev/null +++ b/mapchete_eo/image_operations/blend_functions.py @@ -0,0 +1,579 @@ +""" + +Original LICENSE: + +MIT License + +Copyright (c) 2016 Florian Roscheck + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation th +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +""" + +import numpy as np + + +def _compose_alpha(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Calculate alpha composition ratio between two images.""" + + comp_alpha = np.minimum(fg[:, :, 3], bg[:, :, 3]) * opacity + new_alpha = fg[:, :, 3] + (1.0 - fg[:, :, 3]) * comp_alpha + np.seterr(divide="ignore", invalid="ignore") + ratio = comp_alpha / new_alpha + ratio[np.isnan(ratio)] = 0.0 + return ratio + + +def normal(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply "normal" blending mode of a layer on an image. + + See Also: + Find more information on `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + """ + + # Extract alpha-channels and apply opacity + fg_alp = np.expand_dims(fg[:, :, 3], 2) # alpha of b, prepared for broadcasting + bg_alp = ( + np.expand_dims(bg[:, :, 3], 2) * opacity + ) # alpha of a, prepared for broadcasting + + # Blend images + with np.errstate(divide="ignore", invalid="ignore"): + img_out = (bg[:, :, :3] * bg_alp + fg[:, :, :3] * fg_alp * (1 - bg_alp)) / ( + bg_alp + fg_alp * (1 - bg_alp) + ) + img_out[np.isnan(img_out)] = 0 # replace NaNs with 0 + + # Blend alpha + cout_alp = bg_alp + fg_alp * (1 - bg_alp) + + # Combine image and alpha + img_out = np.dstack((img_out, cout_alp)) + + np.nan_to_num(img_out, copy=False) + + return img_out + + +def soft_light(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply soft light blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + # The following code does this: + # multiply = fg[:, :, :3]*bg[:, :, :3] + # screen = 1.0 - (1.0-fg[:, :, :3])*(1.0-bg[:, :, :3]) + # comp = (1.0 - fg[:, :, :3]) * multiply + fg[:, :, :3] * screen + # ratio_rs = np.reshape(np.repeat(ratio,3),comp.shape) + # img_out = comp*ratio_rs + fg[:, :, :3] * (1.0-ratio_rs) + + comp = (1.0 - fg[:, :, :3]) * fg[:, :, :3] * bg[:, :, :3] + fg[:, :, :3] * ( + 1.0 - (1.0 - fg[:, :, :3]) * (1.0 - bg[:, :, :3]) + ) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def lighten_only(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply lighten only blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.maximum(fg[:, :, :3], bg[:, :, :3]) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def screen(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply screen blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = 1.0 - (1.0 - fg[:, :, :3]) * (1.0 - bg[:, :, :3]) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def dodge(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply dodge blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.minimum(fg[:, :, :3] / (1.0 - bg[:, :, :3]), 1.0) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def addition(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply addition blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = fg[:, :, :3] + bg[:, :, :3] + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = np.clip(comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs), 0.0, 1.0) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def darken_only(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply darken only blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.minimum(fg[:, :, :3], bg[:, :, :3]) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def multiply(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply multiply blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.clip(bg[:, :, :3] * fg[:, :, :3], 0.0, 1.0) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def hard_light(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply hard light blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.greater(bg[:, :, :3], 0.5) * np.minimum( + 1.0 - ((1.0 - fg[:, :, :3]) * (1.0 - (bg[:, :, :3] - 0.5) * 2.0)), + 1.0, + ) + np.logical_not(np.greater(bg[:, :, :3], 0.5)) * np.minimum( + fg[:, :, :3] * (bg[:, :, :3] * 2.0), 1.0 + ) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def difference(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply difference blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = fg[:, :, :3] - bg[:, :, :3] + comp[comp < 0.0] *= -1.0 + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def subtract(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply subtract blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = fg[:, :, :3] - bg[:, :, :3] + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = np.clip(comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs), 0.0, 1.0) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def grain_extract(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply grain extract blending mode of a layer on an image. + + See Also: + Find more information in the `GIMP Documentation `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.clip(fg[:, :, :3] - bg[:, :, :3] + 0.5, 0.0, 1.0) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def grain_merge(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply grain merge blending mode of a layer on an image. + + See Also: + Find more information in the `GIMP Documentation `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.clip(fg[:, :, :3] + bg[:, :, :3] - 0.5, 0.0, 1.0) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def divide(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply divide blending mode of a layer on an image. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.minimum( + (256.0 / 255.0 * fg[:, :, :3]) / (1.0 / 255.0 + bg[:, :, :3]), + 1.0, + ) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out + + +def overlay(fg: np.ndarray, bg: np.ndarray, opacity: float): + """Apply overlay blending mode of a layer on an image. + + Note: + The implementation of this method was changed in version 2.0.0. Previously, it would be identical to the + soft light blending mode. Now, it resembles the implementation on Wikipedia. You can still use the soft light + blending mode if you are looking for backwards compatibility. + + See Also: + Find more information on + `Wikipedia `__. + + Args: + fg(3-dimensional numpy array of floats (r/g/b/a) in range 0-255.0): Image to be blended upon + bg(3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0): Layer to be blended with image + opacity(float): Desired opacity of layer for blending + disable_type_checks(bool): Whether type checks within the function should be disabled. Disabling the checks may + yield a slight performance improvement, but comes at the cost of user experience. If you are certain that + you are passing in the right arguments, you may set this argument to 'True'. Defaults to 'False'. + + Returns: + 3-dimensional numpy array of floats (r/g/b/a) in range 0.0-255.0: Blended image + + """ + + ratio = _compose_alpha(fg, bg, opacity) + + comp = np.less(fg[:, :, :3], 0.5) * ( + 2 * fg[:, :, :3] * bg[:, :, :3] + ) + np.greater_equal(fg[:, :, :3], 0.5) * ( + 1 - (2 * (1 - fg[:, :, :3]) * (1 - bg[:, :, :3])) + ) + + ratio_rs = np.reshape( + np.repeat(ratio, 3), [comp.shape[0], comp.shape[1], comp.shape[2]] + ) + img_out = comp * ratio_rs + fg[:, :, :3] * (1.0 - ratio_rs) + img_out = np.nan_to_num( + np.dstack((img_out, fg[:, :, 3])) + ) # add alpha channel and replace nans + return img_out diff --git a/mapchete_eo/image_operations/blend_modes/blending_functions.py b/mapchete_eo/image_operations/blend_modes/blending_functions.py deleted file mode 100644 index d4cbbb3..0000000 --- a/mapchete_eo/image_operations/blend_modes/blending_functions.py +++ /dev/null @@ -1,198 +0,0 @@ -""" - -Original LICENSE: - -MIT License - -Copyright (c) 2016 Florian Roscheck - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the -Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE -OR OTHER DEALINGS IN THE SOFTWARE. - -Overview --------- - -.. currentmodule:: blend_modes.blending_functions - -.. autosummary:: - :nosignatures: - - addition - darken_only - difference - divide - dodge - grain_extract - grain_merge - hard_light - lighten_only - multiply - normal - overlay - screen - soft_light - subtract -""" - -import numpy as np -from typing import Callable -from mapchete_eo.image_operations.blend_modes.type_checks import ( - assert_image_format, - assert_opacity, -) - - -class BlendBase: - def __init__( - self, - opacity=1.0, - disable_type_checks=False, - dtype=np.float16, - fcn_name="BlendBase", - ): - self.opacity = opacity - self.disable_type_checks = disable_type_checks - self.dtype = dtype - self.fcn_name = fcn_name - - def _prepare(self, src: np.ndarray, dst: np.ndarray): - if not self.disable_type_checks: - assert_image_format(src, fcn_name=self.fcn_name, arg_name="src") - assert_image_format(dst, fcn_name=self.fcn_name, arg_name="dst") - assert_opacity(self.opacity, fcn_name=self.fcn_name) - if src.dtype != self.dtype: - src = src.astype(self.dtype) - if dst.dtype != self.dtype: - dst = dst.astype(self.dtype) - return src, dst - - def blend(self, src: np.ndarray, dst: np.ndarray, blend_func: Callable): - src, dst = self._prepare(src, dst) - blended = blend_func(src, dst) - result = (blended * self.opacity) + (dst * (1 - self.opacity)) - return np.clip(result, 0, 1).astype(self.dtype) - - -def make_blend_function(blend_func: Callable): - # This function returns a wrapper that uses a shared BlendBase instance - base = BlendBase() - - def func( - src: np.ndarray, - dst: np.ndarray, - opacity: float = 1.0, - disable_type_checks: bool = False, - dtype: np.dtype = np.float16, - ) -> np.ndarray: - # If parameters differ from base, create new BlendBase (rare) - if ( - opacity != base.opacity - or disable_type_checks != base.disable_type_checks - or dtype != base.dtype - ): - base_local = BlendBase(opacity, disable_type_checks, dtype) - return base_local.blend(src, dst, blend_func) - return base.blend(src, dst, blend_func) - - return func - - -normal = make_blend_function(lambda s, d: s) -multiply = make_blend_function(lambda s, d: s * d) -screen = make_blend_function(lambda s, d: 1 - (1 - s) * (1 - d)) -darken_only = make_blend_function(lambda s, d: np.minimum(s, d)) -lighten_only = make_blend_function(lambda s, d: np.maximum(s, d)) -difference = make_blend_function(lambda s, d: np.abs(d - s)) -subtract = make_blend_function(lambda s, d: np.clip(d - s, 0, 1)) - - -def divide_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - with np.errstate(divide="ignore", invalid="ignore"): - res = np.true_divide(d, s) - res[~np.isfinite(res)] = 0 - return np.clip(res, 0, 1) - - -divide = make_blend_function(divide_blend) - - -def grain_extract_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - return np.clip(d - s + 0.5, 0, 1) - - -grain_extract = make_blend_function(grain_extract_blend) - - -def grain_merge_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - return np.clip(d + s - 0.5, 0, 1) - - -grain_merge = make_blend_function(grain_merge_blend) - - -def overlay_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - mask = d <= 0.5 - result = np.empty_like(d) - result[mask] = 2 * s[mask] * d[mask] - result[~mask] = 1 - 2 * (1 - s[~mask]) * (1 - d[~mask]) - return np.clip(result, 0, 1) - - -overlay = make_blend_function(overlay_blend) - - -def hard_light_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - mask = s <= 0.5 - result = np.empty_like(d) - result[mask] = 2 * s[mask] * d[mask] - result[~mask] = 1 - 2 * (1 - s[~mask]) * (1 - d[~mask]) - return np.clip(result, 0, 1) - - -hard_light = make_blend_function(hard_light_blend) - - -def soft_light_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - result = (1 - 2 * s) * d**2 + 2 * s * d - return np.clip(result, 0, 1) - - -soft_light = make_blend_function(soft_light_blend) - - -def dodge_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - with np.errstate(divide="ignore", invalid="ignore"): - res = np.true_divide(d, 1 - s) - res[~np.isfinite(res)] = 1 - return np.clip(res, 0, 1) - - -dodge = make_blend_function(dodge_blend) - - -def burn_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - with np.errstate(divide="ignore", invalid="ignore"): - res = 1 - np.true_divide(1 - d, s) - res[~np.isfinite(res)] = 0 - return np.clip(res, 0, 1) - - -burn = make_blend_function(burn_blend) - - -def addition_blend(s: np.ndarray, d: np.ndarray) -> np.ndarray: - return np.clip(s + d, 0, 1) - - -addition = make_blend_function(addition_blend) diff --git a/mapchete_eo/image_operations/blend_modes/type_checks.py b/mapchete_eo/image_operations/blend_modes/type_checks.py deleted file mode 100644 index 1b91e5b..0000000 --- a/mapchete_eo/image_operations/blend_modes/type_checks.py +++ /dev/null @@ -1,99 +0,0 @@ -"""This module includes functions to check if variable types match expected formats.""" - -import numpy as np - - -def assert_image_format(image, fcn_name: str, arg_name: str, force_alpha: bool = True): - """Assert if image arguments have the expected format. - - Checks: - - Image is a numpy array - - Array is of floating-point type - - Array is 3D (height x width x channels) - - Array has the correct number of layers (3 or 4) - - Args: - image: The image to be checked. - fcn_name (str): Calling function name for display in error messages. - arg_name (str): Relevant argument name for display in error messages. - force_alpha (bool): Whether the image must include an alpha layer. - - Raises: - TypeError: If type or shape are incorrect. - """ - if not isinstance(image, np.ndarray): - raise TypeError( - f"\n[Invalid Type]\n" - f"Function: {fcn_name}\n" - f"Argument: {arg_name}\n" - f"Expected: numpy.ndarray\n" - f"Got: {type(image).__name__}\n" - f'Hint: Pass a numpy.ndarray for "{arg_name}".' - ) - - if image.dtype.kind != "f": - raise TypeError( - f"\n[Invalid Data Type]\n" - f"Function: {fcn_name}\n" - f"Argument: {arg_name}\n" - f'Expected dtype kind: "f" (floating-point)\n' - f'Got dtype kind: "{image.dtype.kind}"\n' - f"Hint: Convert the array to float, e.g., image.astype(float)." - ) - - if len(image.shape) != 3: - raise TypeError( - f"\n[Invalid Dimensions]\n" - f"Function: {fcn_name}\n" - f"Argument: {arg_name}\n" - f"Expected: 3D array (height x width x channels)\n" - f"Got: {len(image.shape)}D array\n" - f"Hint: Ensure the array has three dimensions." - ) - - if force_alpha and image.shape[2] != 4: - raise TypeError( - f"\n[Invalid Channel Count]\n" - f"Function: {fcn_name}\n" - f"Argument: {arg_name}\n" - f"Expected: 4 layers (R, G, B, Alpha)\n" - f"Got: {image.shape[2]} layers\n" - f"Hint: Include all four channels if force_alpha=True." - ) - - -def assert_opacity(opacity, fcn_name: str, arg_name: str = "opacity"): - """Assert if opacity has the expected format. - - Checks: - - Opacity is float or int - - Opacity is within 0.0 <= x <= 1.0 - - Args: - opacity: The opacity value to be checked. - fcn_name (str): Calling function name for display in error messages. - arg_name (str): Argument name for display in error messages. - - Raises: - TypeError: If type is not float or int. - ValueError: If opacity is out of range. - """ - if not isinstance(opacity, (float, int)): - raise TypeError( - f"\n[Invalid Type]\n" - f"Function: {fcn_name}\n" - f"Argument: {arg_name}\n" - f"Expected: float (or int)\n" - f"Got: {type(opacity).__name__}\n" - f"Hint: Pass a float between 0.0 and 1.0." - ) - - if not 0.0 <= opacity <= 1.0: - raise ValueError( - f"\n[Out of Range]\n" - f"Function: {fcn_name}\n" - f"Argument: {arg_name}\n" - f"Expected: value in range 0.0 <= x <= 1.0\n" - f"Got: {opacity}\n" - f"Hint: Clamp or normalize the value to the valid range." - ) diff --git a/mapchete_eo/image_operations/compositing.py b/mapchete_eo/image_operations/compositing.py index 0abe1f2..9cec1a4 100644 --- a/mapchete_eo/image_operations/compositing.py +++ b/mapchete_eo/image_operations/compositing.py @@ -8,7 +8,7 @@ from mapchete import Timer from rasterio.plot import reshape_as_image, reshape_as_raster -from mapchete_eo.image_operations.blend_modes import blending_functions +from mapchete_eo.image_operations import blend_functions logger = logging.getLogger(__name__) @@ -55,14 +55,16 @@ def _expanded_mask(arr: ma.MaskedArray) -> np.ndarray: def _blend_base( bg: np.ndarray, fg: np.ndarray, opacity: float, operation: Callable ) -> ma.MaskedArray: - # generate RGBA output and run compositing + # generate RGBA output and run compositing and normalize by dividing by 255 out_arr = reshape_as_raster( - operation( - reshape_as_image(to_rgba(bg)), - reshape_as_image(to_rgba(fg)), - opacity, - disable_type_checks=True, - ).astype(np.uint8) + ( + operation( + reshape_as_image(to_rgba(bg) / 255), + reshape_as_image(to_rgba(fg) / 255), + opacity, + ) + * 255 + ).astype(np.uint8, copy=False) ) # generate mask from alpha band out_mask = np.where(out_arr[3] == 0, True, False) @@ -70,63 +72,63 @@ def _blend_base( def normal(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.normal) + return _blend_base(bg, fg, opacity, blend_functions.normal) def soft_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.soft_light) + return _blend_base(bg, fg, opacity, blend_functions.soft_light) def lighten_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.lighten_only) + return _blend_base(bg, fg, opacity, blend_functions.lighten_only) def screen(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.screen) + return _blend_base(bg, fg, opacity, blend_functions.screen) def dodge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.dodge) + return _blend_base(bg, fg, opacity, blend_functions.dodge) def addition(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.addition) + return _blend_base(bg, fg, opacity, blend_functions.addition) def darken_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.darken_only) + return _blend_base(bg, fg, opacity, blend_functions.darken_only) def multiply(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.multiply) + return _blend_base(bg, fg, opacity, blend_functions.multiply) def hard_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.hard_light) + return _blend_base(bg, fg, opacity, blend_functions.hard_light) def difference(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.difference) + return _blend_base(bg, fg, opacity, blend_functions.difference) def subtract(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.subtract) + return _blend_base(bg, fg, opacity, blend_functions.subtract) def grain_extract(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.grain_extract) + return _blend_base(bg, fg, opacity, blend_functions.grain_extract) def grain_merge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.grain_merge) + return _blend_base(bg, fg, opacity, blend_functions.grain_merge) def divide(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.divide) + return _blend_base(bg, fg, opacity, blend_functions.divide) def overlay(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray: - return _blend_base(bg, fg, opacity, blending_functions.overlay) + return _blend_base(bg, fg, opacity, blend_functions.overlay) METHODS = { @@ -212,11 +214,14 @@ def fuzzy_alpha_mask( gradient_position=GradientPosition.outside, ) -> np.ndarray: """Return an RGBA array with a fuzzy alpha mask.""" - gradient_position = ( - GradientPosition[gradient_position] - if isinstance(gradient_position, str) - else gradient_position - ) + try: + gradient_position = ( + GradientPosition[gradient_position] + if isinstance(gradient_position, str) + else gradient_position + ) + except KeyError: + raise ValueError(f"unknown gradient_position: {gradient_position}") if arr.shape[0] != 3: raise TypeError("input array must have exactly three bands") diff --git a/mapchete_eo/image_operations/fillnodata.py b/mapchete_eo/image_operations/fillnodata.py index bf064cd..49ecbd7 100644 --- a/mapchete_eo/image_operations/fillnodata.py +++ b/mapchete_eo/image_operations/fillnodata.py @@ -6,7 +6,7 @@ from mapchete import Timer from rasterio.features import rasterize, shapes from rasterio.fill import fillnodata as rio_fillnodata -from scipy.ndimage.filters import convolve +from scipy.ndimage import convolve from shapely.geometry import shape logger = logging.getLogger(__name__) diff --git a/pyproject.toml b/pyproject.toml index f03ea3c..3dfa7f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: GIS", ] dependencies = [ diff --git a/tests/conftest.py b/tests/conftest.py index 3ad94da..d21ace0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,6 @@ from mapchete.path import MPath from mapchete.testing import ProcessFixture from mapchete.tile import BufferedTilePyramid -from PIL import Image from pystac_client import Client from rasterio import Affine from shapely import wkt @@ -767,50 +766,30 @@ def set_cdse_test_env(monkeypatch, request): pytest.fail("CDSE AWS credentials not found in environment") -@pytest.fixture(scope="session") -def testdata_blend_modes_dir(): - return ( - MPath(os.path.dirname(os.path.realpath(__file__))) / "testdata" / "blend_modes" - ) - - -@pytest.fixture(scope="session") -def src_image(testdata_blend_modes_dir): - img_path = testdata_blend_modes_dir / "orig.png" - img = Image.open(img_path).convert("RGBA") - arr = np.array(img) / 255.0 - return arr.astype(np.float16) - - -@pytest.fixture(scope="session") -def dst_images(testdata_blend_modes_dir): - blend_names = [ - "normal", - "multiply", - "screen", - "darken_only", - "lighten_only", - "difference", - "subtract", - "divide", - "grain_extract", - "grain_merge", - "overlay", - "hard_light", - "soft_light", - "dodge", - "burn", - "addition", - ] - - dst_imgs = {} - for fname in os.listdir(testdata_blend_modes_dir): - if fname.endswith(".png"): - key = fname[:-4] # remove .png - # Use only if file matches blend_names exactly (ignore *_50p etc) - if key in blend_names: - img = Image.open(testdata_blend_modes_dir / fname).convert("RGBA") - arr = np.array(img) / 255.0 - dst_imgs[key] = arr.astype(np.float16) - - return dst_imgs +@pytest.fixture +def bg_array() -> np.ndarray: + """Background array (3x3 example)""" + return np.array([[50, 100, 150], [200, 50, 0], [25, 75, 125]], dtype=np.float32) + + +@pytest.fixture +def fg_array() -> np.ndarray: + """Foreground array (3x3 example)""" + return np.array([[100, 50, 200], [0, 150, 255], [50, 100, 25]], dtype=np.float32) + + +@pytest.fixture +def edge_bg_array() -> np.ndarray: + """Edge case array: min/max values""" + return np.array([[0, 255, 128], [255, 0, 128], [128, 128, 128]], dtype=np.float32) + + +@pytest.fixture +def edge_fg_array() -> np.ndarray: + """Edge case array: min/max values""" + return np.array([[255, 0, 128], [0, 255, 128], [128, 128, 128]], dtype=np.float32) + + +@pytest.fixture +def opacities() -> list[float]: + return [0.0, 0.25, 0.5, 0.75, 1.0] diff --git a/tests/image_operations/test_blend_modes.py b/tests/image_operations/test_blend_modes.py deleted file mode 100644 index ec76e4b..0000000 --- a/tests/image_operations/test_blend_modes.py +++ /dev/null @@ -1,302 +0,0 @@ -import pytest -import numpy as np -import mapchete_eo.image_operations.blend_modes.blending_functions as bf -from mapchete_eo.image_operations.blend_modes.blending_functions import BlendBase - - -# List of blend modes (replace with actual blend function names) -blend_names = [ - "normal", - "multiply", - "screen", - "overlay", - "lighten_only", - "darken_only", - # add your modes here... -] - -# Blend modes that allow opacity parameter -blend_modes_with_opacity = { - "normal", - "multiply", - "screen", - "overlay", - # add other modes supporting opacity here -} - -# Blend modes where result can equal dst_image at opacity=1.0 (no difference expected) -blend_modes_allowing_equal = {"lighten_only", "some_other_modes_if_any"} - - -def test_func_behavior_with_blendbase(): - base = BlendBase() - - def test_blend_func(s, d): - return s # just src, opacity applied outside in BlendBase.blend() - - src = np.ones((2, 2, 4), dtype=np.float16) - dst = np.zeros((2, 2, 4), dtype=np.float16) - - def func( - src: np.ndarray, - dst: np.ndarray, - opacity: float = 1.0, - disable_type_checks: bool = False, - dtype: np.dtype = np.float16, - ) -> np.ndarray: - if ( - opacity != base.opacity - or disable_type_checks != base.disable_type_checks - or dtype != base.dtype - ): - base_local = BlendBase(opacity, disable_type_checks, dtype) - - def local_blend_func(s, d): - return s # raw src only - - return base_local.blend(src, dst, local_blend_func) - return base.blend(src, dst, test_blend_func) - - # opacity=1.0 => output == src - result_default = func(src, dst) - assert np.allclose(result_default, src) - assert result_default.dtype == np.float16 - assert result_default.shape == src.shape - - # opacity=0.3 => output == 0.3*src + 0.7*dst = 0.3 * 1 + 0.7 * 0 = 0.3 - result_opacity = func(src, dst, opacity=0.3) - expected = 0.3 * src + 0.7 * dst - assert np.allclose(result_opacity, expected) - assert result_opacity.dtype == np.float16 - assert result_opacity.shape == src.shape - - -def test_make_blend_function_default_path(): - # Create a dummy blend function, e.g. returns src - def dummy_blend(s, d): - return s - - func = bf.make_blend_function(dummy_blend) - - src = np.ones((2, 2, 4), dtype=np.float16) - dst = np.zeros((2, 2, 4), dtype=np.float16) - - # Call with default params to hit the return base.blend() line - result = func(src, dst) - - assert np.allclose(result, src) - assert result.dtype == np.float16 - assert result.shape == src.shape - - -def test_prepare_type_checks_enabled_casting(): - blend = BlendBase() - blend.disable_type_checks = False - blend.fcn_name = "test_blend" - blend.dtype = np.float16 - blend.opacity = 1.0 - - # Valid 3D float64 arrays with 4 channels -> should cast to float16 - src = np.ones((2, 2, 4), dtype=np.float64) - dst = np.zeros((2, 2, 4), dtype=np.float64) - - src_out, dst_out = blend._prepare(src, dst) - - assert src_out.dtype == np.float16 - assert dst_out.dtype == np.float16 - assert src_out.shape == src.shape - assert dst_out.shape == dst.shape - - -def test_prepare_type_checks_enabled_no_cast(): - blend = BlendBase() - blend.disable_type_checks = False - blend.fcn_name = "test_blend" - blend.dtype = np.float16 - blend.opacity = 1.0 - - # Correct dtype and shape: no cast, outputs share memory - src = np.ones((2, 2, 4), dtype=np.float16) - dst = np.zeros((2, 2, 4), dtype=np.float16) - - src_out, dst_out = blend._prepare(src, dst) - - assert src_out.dtype == np.float16 - assert dst_out.dtype == np.float16 - assert np.shares_memory(src, src_out) - assert np.shares_memory(dst, dst_out) - - -def test_prepare_type_checks_disabled_casting(): - blend = BlendBase() - blend.disable_type_checks = True # disables type and shape checks - blend.fcn_name = "test_blend" - blend.dtype = np.float16 - blend.opacity = 1.0 - - # Invalid shape (2D), but no error because checks disabled; dtype cast applied - src = np.ones((2, 2), dtype=np.float64) - dst = np.zeros((2, 2), dtype=np.float64) - - src_out, dst_out = blend._prepare(src, dst) - - assert src_out.dtype == np.float16 - assert dst_out.dtype == np.float16 - assert src_out.shape == src.shape - assert dst_out.shape == dst.shape - - -def test_prepare_type_checks_enabled_invalid_shape_raises(): - blend = BlendBase() - blend.disable_type_checks = False - blend.fcn_name = "test_blend" - blend.dtype = np.float16 - blend.opacity = 1.0 - - # Invalid 2D shape arrays; should raise TypeError due to assert_image_format - src = np.ones((2, 2), dtype=np.float16) - dst = np.zeros((2, 2), dtype=np.float16) - - with pytest.raises(TypeError, match="Expected: 3D array"): - blend._prepare(src, dst) - - -def test_prepare_type_checks_enabled_invalid_channels_raises(): - blend = BlendBase() - blend.disable_type_checks = False - blend.fcn_name = "test_blend" - blend.dtype = np.float16 - blend.opacity = 1.0 - - # 3D shape but with 3 channels instead of 4, should raise on channel count - src = np.ones((2, 2, 3), dtype=np.float16) - dst = np.zeros((2, 2, 3), dtype=np.float16) - - with pytest.raises(TypeError, match="Expected: 4 layers"): - blend._prepare(src, dst) - - -@pytest.mark.parametrize("blend_name", blend_names) -def test_blend_functions_opacity_zero(src_image, dst_images, blend_name): - if blend_name not in dst_images: - pytest.skip(f"No destination image for {blend_name}") - - if blend_name not in blend_modes_with_opacity: - pytest.skip(f"Blend mode {blend_name} does not support opacity parameter") - - dst_image = dst_images[blend_name] - blend_func = getattr(bf, blend_name) - - opacity = 0.0 - result = blend_func( - src_image, - dst_image, - opacity=opacity, - disable_type_checks=True, - dtype=np.float16, - ) - - assert result.shape == src_image.shape - assert result.dtype == np.float16, f"{blend_name} output dtype is not float16" - assert (result >= 0).all() and (result <= 1).all() - - # Fully transparent blend: result should equal destination image - np.testing.assert_allclose(result, dst_image.astype(np.float16), rtol=1e-3) - - -@pytest.mark.parametrize("blend_name", blend_names) -def test_blend_functions_opacity_one(src_image, dst_images, blend_name): - if blend_name not in dst_images: - pytest.skip(f"No destination image for {blend_name}") - - if blend_name not in blend_modes_with_opacity: - pytest.skip(f"Blend mode {blend_name} does not support opacity parameter") - - dst_image = dst_images[blend_name] - blend_func = getattr(bf, blend_name) - - opacity = 1.0 - result = blend_func( - src_image, - dst_image, - opacity=opacity, - disable_type_checks=True, - dtype=np.float16, - ) - - assert result.shape == src_image.shape - assert result.dtype == np.float16, f"{blend_name} output dtype is not float16" - assert (result >= 0).all() and (result <= 1).all() - - # Only assert difference if blend mode expected to differ from dst_image at full opacity - if blend_name not in blend_modes_allowing_equal: - assert not np.allclose( - result, dst_image.astype(np.float16) - ), f"Blend mode {blend_name} with opacity=1.0 result equals dst_image, which is unexpected" - - -@pytest.mark.parametrize("blend_name", blend_names) -@pytest.mark.parametrize("opacity", [0.25, 0.5, 0.75]) -def test_blend_functions_opacity_mid(src_image, dst_images, blend_name, opacity): - if blend_name not in dst_images: - pytest.skip(f"No destination image for {blend_name}") - - if blend_name not in blend_modes_with_opacity: - pytest.skip(f"Blend mode {blend_name} does not support opacity parameter") - - dst_image = dst_images[blend_name] - blend_func = getattr(bf, blend_name) - - result = blend_func( - src_image, - dst_image, - opacity=opacity, - disable_type_checks=True, - dtype=np.float16, - ) - - assert result.shape == src_image.shape - assert result.dtype == np.float16, f"{blend_name} output dtype is not float16" - assert (result >= 0).all() and (result <= 1).all() - - -def test_burn_blend_basic(): - s = np.array([[0.5, 1.0], [0.2, 0.0]], dtype=np.float32) - d = np.array([[0.2, 0.3], [0.9, 0.5]], dtype=np.float32) - result = bf.burn_blend(s, d) - - # output shape and dtype checks - assert result.shape == s.shape - assert result.dtype == s.dtype - - # output range check - assert (result >= 0).all() and (result <= 1).all() - - # no NaNs or Infs in output - assert np.isfinite(result).all() - - # Check known values manually: - # When s=0.5, d=0.2 => 1 - (1 - 0.2)/0.5 = 1 - 0.8/0.5 = 1 - 1.6 = -0.6 clipped to 0 - assert result[0, 0] == 0 - - # When s=1.0, d=0.3 => 1 - (1 - 0.3)/1.0 = 1 - 0.7 = 0.3 - assert np.isclose(result[0, 1], 0.3) - - # When s=0 (division by zero), output should be 0, no NaNs - assert result[1, 1] == 0 - - -def test_burn_blend_all_ones(): - s = np.ones((3, 3), dtype=np.float32) - d = np.ones((3, 3), dtype=np.float32) - result = bf.burn_blend(s, d) - # burn_blend(1,1) == 1 - (1-1)/1 = 1 - assert np.allclose(result, 1) - - -def test_burn_blend_zero_s_and_d(): - s = np.zeros((2, 2), dtype=np.float32) - d = np.zeros((2, 2), dtype=np.float32) - result = bf.burn_blend(s, d) - # division by zero case, all values set to 0 safely - assert np.all(result == 0) diff --git a/tests/image_operations/test_blend_modes_type_checks.py b/tests/image_operations/test_blend_modes_type_checks.py deleted file mode 100644 index da2821d..0000000 --- a/tests/image_operations/test_blend_modes_type_checks.py +++ /dev/null @@ -1,65 +0,0 @@ -import numpy as np -import pytest - -from mapchete_eo.image_operations.blend_modes.type_checks import ( - assert_image_format, - assert_opacity, -) # replace with actual module name - - -def test_valid_image_with_alpha(): - img = np.zeros((10, 10, 4), dtype=float) - assert_image_format(img, "blend_func", "image") # Should not raise - - -def test_valid_image_without_alpha_when_not_forced(): - img = np.zeros((10, 10, 3), dtype=float) - assert_image_format( - img, "blend_func", "image", force_alpha=False - ) # Should not raise - - -def test_image_not_numpy_array(): - with pytest.raises(TypeError, match=r"\[Invalid Type\]"): - assert_image_format("not an array", "blend_func", "image") - - -def test_image_wrong_dtype(): - img = np.zeros((10, 10, 4), dtype=np.uint8) - with pytest.raises(TypeError, match=r"\[Invalid Data Type\]"): - assert_image_format(img, "blend_func", "image") - - -def test_image_wrong_dimensions(): - img = np.zeros((10, 10), dtype=float) # 2D - with pytest.raises(TypeError, match=r"\[Invalid Dimensions\]"): - assert_image_format(img, "blend_func", "image") - - -def test_image_wrong_channel_count_with_force_alpha(): - img = np.zeros((10, 10, 3), dtype=float) # No alpha channel - with pytest.raises(TypeError, match=r"\[Invalid Channel Count\]"): - assert_image_format(img, "blend_func", "image", force_alpha=True) - - -def test_valid_opacity_float(): - assert_opacity(0.5, "blend_func") # Should not raise - - -def test_valid_opacity_int(): - assert_opacity(1, "blend_func") # Should not raise - - -def test_opacity_wrong_type(): - with pytest.raises(TypeError, match=r"\[Invalid Type\]"): - assert_opacity("not a number", "blend_func") - - -def test_opacity_below_range(): - with pytest.raises(ValueError, match=r"\[Out of Range\]"): - assert_opacity(-0.1, "blend_func") - - -def test_opacity_above_range(): - with pytest.raises(ValueError, match=r"\[Out of Range\]"): - assert_opacity(1.1, "blend_func") diff --git a/tests/image_operations/test_compositing.py b/tests/image_operations/test_compositing.py index d90c574..a9b3ec0 100644 --- a/tests/image_operations/test_compositing.py +++ b/tests/image_operations/test_compositing.py @@ -2,68 +2,345 @@ import numpy.ma as ma import pytest -from mapchete_eo.image_operations import compositing +from mapchete_eo.image_operations import compositing, blend_functions + +BLEND_FUNCS = [ + blend_functions.normal, + blend_functions.multiply, + blend_functions.screen, + blend_functions.overlay, + blend_functions.soft_light, + blend_functions.hard_light, + blend_functions.lighten_only, + blend_functions.darken_only, + blend_functions.dodge, + blend_functions.addition, + blend_functions.subtract, + blend_functions.difference, + blend_functions.divide, + blend_functions.grain_extract, + blend_functions.grain_merge, +] + +# --------------------------- +# to_rgba tests +# --------------------------- @pytest.mark.parametrize("bands", range(1, 5)) -def test_to_rgba(bands, test_2d_array): +def test_to_rgba_shapes_and_types(test_2d_array, bands): + # Repeat the 2D array to create 'bands' bands arr = np.repeat(np.expand_dims(test_2d_array, axis=0), bands, axis=0) out = compositing.to_rgba(arr) assert isinstance(out, np.ndarray) - assert not isinstance(out, ma.masked_array) assert out.shape == (4, 256, 256) assert out.dtype == np.float16 - assert out.min() >= 0.0 - assert out.max() <= 255.0 + assert out.min() >= 0 + assert out.max() <= 255 -def test_to_rgba_dtype_error(test_2d_array): +def test_to_rgba_type_error(test_2d_array): + # Should raise TypeError for non-uint8 with pytest.raises(TypeError): compositing.to_rgba(test_2d_array.astype(np.uint16)) +def test_to_rgba_invalid_band_counts(): + # More than 4 bands triggers TypeError + data = np.random.randint(0, 255, (5, 2, 2), dtype=np.uint8) + arr = ma.MaskedArray(data) + with pytest.raises(TypeError): + compositing.to_rgba(arr) + # Zero bands also triggers TypeError + data0 = np.empty((0, 2, 2), dtype=np.uint8) + arr0 = ma.MaskedArray(data0) + with pytest.raises(TypeError): + compositing.to_rgba(arr0) + + +def test_to_rgba_expanded_mask_bool_scalar(): + # Create a 1-band masked array where mask is a scalar np.bool_ + data = np.ones((1, 2, 2), dtype=np.uint8) + arr = ma.MaskedArray(data) + arr.mask = np.bool_(True) # triggers the np.bool_ branch in _expanded_mask + + out = compositing.to_rgba(arr) + # Alpha should be zero everywhere because mask=True + assert np.all(out[3] == 0) + assert out.shape == (4, 2, 2) + + +def test_to_rgba_expanded_mask_bool_array(): + # 3-band masked array with all False mask + data = np.ones((3, 2, 2), dtype=np.uint8) * 100 + arr = ma.MaskedArray(data, mask=False) + out = compositing.to_rgba(arr) + # Alpha should be 255 everywhere + assert np.all(out[3] == 255) + + +def test_to_rgba_mixed_mask_values(): + # 3-band array with mixed masked/unmasked values + data = np.array( + [[[10, 20], [30, 40]], [[50, 60], [70, 80]], [[90, 100], [110, 120]]], + dtype=np.uint8, + ) + mask = np.array( + [ + [[False, True], [False, True]], + [[True, False], [True, False]], + [[False, False], [True, True]], + ] + ) + arr = ma.MaskedArray(data, mask=mask) + out = compositing.to_rgba(arr) + assert out.shape == (4, 2, 2) + # alpha channel should be 255 only where all bands are unmasked + expected_alpha = np.zeros((2, 2), dtype=np.uint8) + assert np.array_equal(out[3], expected_alpha) + + +def test_to_rgba_two_band_array(): + # 2-band array triggers the 2-band branch + data = np.ones((2, 2, 2), dtype=np.uint8) * 100 + arr = ma.MaskedArray(data, mask=False) + out = compositing.to_rgba(arr) + assert out.shape == (4, 2, 2) + # First 3 channels are copies of band0, 4th is band1 + assert np.all(out[0] == out[1]) + assert np.all(out[1] == out[2]) + assert np.all(out[3] == arr[1]) + + +def test_to_rgba_four_band_array(): + # 4-band array triggers the 4-band branch + data = np.ones((4, 2, 2), dtype=np.uint8) * 100 + arr = ma.MaskedArray(data, mask=False) + out = compositing.to_rgba(arr) + assert out.shape == (4, 2, 2) + # Output should equal input data + assert np.all(out == arr.data) + + +def test_to_rgba_expanded_mask_and_non_maskedarray(): + import numpy as np + import numpy.ma as ma + from mapchete_eo.image_operations import compositing + + # ------------------------------- + # Part 1: input is not a MaskedArray + # ------------------------------- + data = np.array([[10, 20], [30, 40]], dtype=np.uint8) # shape (2,2) + + # Pass a plain ndarray (not a MaskedArray) + out = compositing.to_rgba(data[np.newaxis, ...]) # shape (1,2,2) + assert isinstance(out, np.ndarray) + assert out.shape == (4, 2, 2) + # alpha channel should be 255 everywhere because no mask + assert np.all(out[3] == 255) + + # ------------------------------- + # Part 2: MaskedArray with scalar boolean mask + # ------------------------------- + arr_masked = ma.MaskedArray(data, mask=np.bool_(True)).reshape((1, 2, 2)) + out2 = compositing.to_rgba(arr_masked) + assert isinstance(out2, np.ndarray) + assert out2.shape == (4, 2, 2) + # alpha channel should be 0 everywhere + assert np.all(out2[3] == 0) + + # Scalar False case + arr_masked_false = ma.MaskedArray(data, mask=np.bool_(False)).reshape((1, 2, 2)) + out3 = compositing.to_rgba(arr_masked_false) + assert np.all(out3[3] == 255) + + +# --------------------------- +# _blend_base and composite +# --------------------------- + + +@pytest.mark.parametrize("blend_func", BLEND_FUNCS) +def test_blend_base_all_functions(test_3d_array, blend_func): + bg = test_3d_array.astype(np.uint8) + fg = test_3d_array.astype(np.uint8) + out = compositing._blend_base(bg, fg, 0.5, blend_func) + assert isinstance(out, ma.MaskedArray) + assert out.shape == (4, bg.shape[1], bg.shape[2]) + assert out.dtype == np.uint8 + + @pytest.mark.parametrize("method", compositing.METHODS.keys()) -@pytest.mark.parametrize("opacity", [0, 0.5, 1]) -def test_compositing_output_array(test_3d_array, method, opacity): - out = compositing.composite(method, test_3d_array, test_3d_array, opacity=opacity) - assert isinstance(out, ma.masked_array) +def test_composite_dispatch(test_3d_array, method): + out = compositing.composite(method, test_3d_array, test_3d_array) + assert isinstance(out, ma.MaskedArray) assert out.shape == (4, 256, 256) - assert out.dtype == np.uint8 + + +# --------------------------- +# fuzzy_mask tests +# --------------------------- @pytest.mark.parametrize("bands", [1, 3]) -@pytest.mark.parametrize("radius", [0, 5]) +@pytest.mark.parametrize("radius", [0, 3]) @pytest.mark.parametrize("invert", [True, False]) @pytest.mark.parametrize("dilate", [True, False]) -def test_fuzzy_mask(test_2d_array, bands, radius, invert, dilate): +def test_fuzzy_mask_shapes(test_2d_array, bands, radius, invert, dilate): arr = np.repeat(np.expand_dims(test_2d_array, axis=0), bands, axis=0) out = compositing.fuzzy_mask( - arr, fill_value=0, radius=radius, invert=invert, dilate=dilate + arr, fill_value=10, radius=radius, invert=invert, dilate=dilate ) assert isinstance(out, np.ndarray) assert out.shape == (256, 256) assert out.dtype == np.uint8 - assert not np.array_equal(arr, out) -@pytest.mark.parametrize("mask", [np.ones((3, 256, 256), dtype=bool), None]) -@pytest.mark.parametrize("radius", [0, 5]) +def test_fuzzy_mask_invalid_dimensions(): + arr = np.zeros((4, 256, 256), dtype=bool) + with pytest.raises(TypeError): + compositing.fuzzy_mask(arr, fill_value=255) + + +def test_fuzzy_mask_ndim_and_band_branches(): + # ----------------------- + # 2D input → triggers arr.ndim == 2 branch + # ----------------------- + arr_2d = np.zeros((5, 5), dtype=bool) + out_2d = compositing.fuzzy_mask(arr_2d, fill_value=10, invert=False, dilate=False) + assert out_2d.shape == (5, 5) + assert out_2d.dtype == np.uint8 + + # ----------------------- + # 3D input with 1 band → triggers arr.shape[0] == 1 branch + # ----------------------- + arr_3d_1band = np.zeros((1, 4, 4), dtype=bool) + out_1band = compositing.fuzzy_mask( + arr_3d_1band, fill_value=20, invert=False, dilate=False + ) + assert out_1band.shape == (4, 4) + assert out_1band.dtype == np.uint8 + + # ----------------------- + # 3D input with 3 bands → triggers arr.shape[0] == 3 branch + # ----------------------- + arr_3d_3band = np.zeros((3, 3, 3), dtype=bool) + out_3band = compositing.fuzzy_mask( + arr_3d_3band, fill_value=30, invert=False, dilate=False + ) + assert out_3band.shape == (3, 3) + assert out_3band.dtype == np.uint8 + + # ----------------------- + # Invalid number of bands → triggers TypeError + # ----------------------- + arr_invalid = np.zeros((2, 2, 2, 2), dtype=bool) + with pytest.raises(TypeError, match="array must have exactly three dimensions"): + compositing.fuzzy_mask(arr_invalid, fill_value=10) + + # ----------------------- + # Invalid single band number → triggers TypeError + # ----------------------- + arr_invalid_bands = np.zeros((2, 4, 4), dtype=bool) # 2 bands, not 1 or 3 + with pytest.raises(TypeError, match="array must have either one or three bands"): + compositing.fuzzy_mask(arr_invalid_bands, fill_value=10) + + +# --------------------------- +# fuzzy_alpha_mask tests +# --------------------------- + + @pytest.mark.parametrize("gradient_position", list(compositing.GradientPosition)) -def test_fuzzy_alpha_mask(test_3d_array, mask, radius, gradient_position): +def test_fuzzy_alpha_mask_basic(test_3d_array, gradient_position): + mask = np.ones((3, 256, 256), dtype=bool) out = compositing.fuzzy_alpha_mask( - test_3d_array, mask=mask, radius=radius, gradient_position=gradient_position + test_3d_array, mask=mask, radius=0, gradient_position=gradient_position ) assert isinstance(out, np.ndarray) assert out.shape == (4, 256, 256) assert out.dtype == np.uint8 - assert not np.array_equal(test_3d_array, out) -def test_fuzzy_alpha_mask_shape_error(test_3d_array): - with pytest.raises(TypeError): - compositing.fuzzy_alpha_mask(test_3d_array[0]) +def test_fuzzy_alpha_mask_without_mask(test_3d_array): + arr = ma.MaskedArray(test_3d_array, mask=False) + out = compositing.fuzzy_alpha_mask(arr, radius=0) + assert isinstance(out, np.ndarray) + assert out.shape == (4, 256, 256) -def test_fuzzy_alpha_mask_error(test_3d_array): +def test_fuzzy_alpha_mask_invalid_input(): + arr = np.zeros((4, 256, 256), dtype=np.uint8) with pytest.raises(TypeError): - compositing.fuzzy_alpha_mask(test_3d_array.data) + compositing.fuzzy_alpha_mask(arr) + + +def test_fuzzy_alpha_mask_invalid_gradient_position(test_3d_array): + mask = np.ones((3, 256, 256), dtype=bool) + with pytest.raises(ValueError, match="unknown gradient_position"): + compositing.fuzzy_alpha_mask( + test_3d_array, mask=mask, gradient_position="invalid_position" + ) + + +def test_fuzzy_alpha_mask_mask_none_branch(): + # Case 1: arr is a MaskedArray → should use arr.mask + data = np.random.randint(0, 255, (3, 4, 4), dtype=np.uint8) + mask = np.array( + [ + [ + [True, False, True, False], + [False, True, False, True], + [True, True, False, False], + [False, False, True, True], + ], + [ + [False, False, True, True], + [True, True, False, False], + [False, True, True, False], + [True, False, False, True], + ], + [ + [True, False, False, True], + [False, True, True, False], + [True, False, True, False], + [False, True, False, True], + ], + ], + dtype=bool, + ) + arr = ma.MaskedArray(data, mask=mask) + + out = compositing.fuzzy_alpha_mask(arr, mask=None, radius=0, fill_value=10) + assert out.shape == (4, 4, 4) # 3 original bands + 1 alpha + assert out.dtype == np.uint8 + + # Case 2: arr is not a MaskedArray → should raise TypeError + arr_np = np.random.randint(0, 255, (3, 4, 4), dtype=np.uint8) + with pytest.raises( + TypeError, + match="input array must be a numpy MaskedArray or mask must be provided", + ): + compositing.fuzzy_alpha_mask(arr_np, mask=None) + + +# --------------------------- +# Ensure Timer branches are hit +# --------------------------- + + +def test_fuzzy_mask_timer_invoked(monkeypatch): + def fake_timer(): + class T: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + return T() + + monkeypatch.setattr(compositing, "Timer", fake_timer) + arr = np.zeros((1, 2, 2), dtype=bool) + out = compositing.fuzzy_mask(arr, fill_value=1, radius=2) + assert out.shape == (2, 2) diff --git a/tests/image_operations/test_dtype_scale.py b/tests/image_operations/test_dtype_scale.py new file mode 100644 index 0000000..59c624f --- /dev/null +++ b/tests/image_operations/test_dtype_scale.py @@ -0,0 +1,48 @@ +import numpy as np +import numpy.ma as ma +import pytest +from mapchete_eo.image_operations import dtype_scale + + +@pytest.mark.parametrize( + "bands_fixture", ["test_2d_array", "test_3d_array", "test_4d_array"] +) +@pytest.mark.parametrize( + "out_dtype, max_source, max_output, nodata", + [ + (np.uint8, 10000.0, None, None), + (np.uint16, 5000.0, 20000, None), + ("uint8", 10000.0, 100, 0), + ("uint16", 1000.0, None, 1), + ], +) +def test_dtype_scale_parametrized( + request, bands_fixture, out_dtype, max_source, max_output, nodata +): + bands = request.getfixturevalue(bands_fixture) + + result = dtype_scale( + bands, + nodata=nodata, + out_dtype=out_dtype, + max_source_value=max_source, + max_output_value=max_output, + ) + + expected_dtype = np.dtype(out_dtype) + assert isinstance(result, ma.MaskedArray) + assert result.shape == bands.shape + assert result.dtype == expected_dtype + + # Mask should preserve original nodata mask + expected_mask = np.logical_or( + bands.mask, bands == (nodata if nodata is not None else 0) + ) + assert np.array_equal(result.mask, expected_mask) + + # Check that all unmasked values are within [1, max_output] + max_val = max_output if max_output is not None else np.iinfo(expected_dtype).max + unmasked = result.data[~result.mask] + if unmasked.size > 0: + assert unmasked.min() >= 1 + assert unmasked.max() <= max_val diff --git a/tests/image_operations/test_linear_normalization.py b/tests/image_operations/test_linear_normalization.py new file mode 100644 index 0000000..c4887d1 --- /dev/null +++ b/tests/image_operations/test_linear_normalization.py @@ -0,0 +1,63 @@ +import numpy as np +import numpy.ma as ma +import pytest +from rasterio.dtypes import dtype_ranges +from mapchete_eo.image_operations import linear_normalization + + +@pytest.mark.parametrize( + "out_dtype", [np.uint8, "uint8", np.uint16, "uint16", np.float32, "float32"] +) +@pytest.mark.parametrize("use_out_min", [True, False]) +def test_linear_normalization_parametrized(test_3d_array, out_dtype, use_out_min): + """ + Parametrized test for linear_normalization using test_3d_array. + Covers different dtypes and optional out_min. + """ + bands = test_3d_array.copy() + bands_minmax = [(0, 255), (0, 255), (0, 255)] + out_min_val = 5 if use_out_min else None + + result = linear_normalization( + bands, + bands_minmax_values=bands_minmax, + out_dtype=out_dtype, + out_min=out_min_val, + ) + + # type and shape checks + assert isinstance(result, ma.MaskedArray) + assert result.shape == bands.shape + + # dtype resolution + expected_dtype = np.dtype(out_dtype) + assert result.dtype == expected_dtype + + # mask should be preserved + assert np.array_equal(result.mask, bands.mask) + + # output values within expected range + min_val = ( + out_min_val if out_min_val is not None else dtype_ranges[str(expected_dtype)][0] + ) + max_val = dtype_ranges[str(expected_dtype)][1] + assert result.data.min() >= min_val + assert result.data.max() <= max_val + + +def test_linear_normalization_band_length_mismatch(test_3d_array): + """Test ValueError when bands and bands_minmax_values lengths mismatch.""" + bands_minmax = [(0, 150), (10, 160)] # only 2 instead of 3 + with pytest.raises( + ValueError, match="bands and bands_minmax_values must have the same length" + ): + linear_normalization(test_3d_array, bands_minmax_values=bands_minmax) + + +def test_linear_normalization_invalid_dtype(test_3d_array): + """Test KeyError when an invalid out_dtype is provided.""" + bands_minmax = [(0, 150), (10, 160), (20, 170)] + with pytest.raises(KeyError, match="invalid out_dtype"): + linear_normalization( + test_3d_array, bands_minmax_values=bands_minmax, out_dtype="invalid_dtype" + ) diff --git a/tests/testdata/blend_modes/addition.png b/tests/testdata/blend_modes/addition.png deleted file mode 100644 index 3e12e70..0000000 Binary files a/tests/testdata/blend_modes/addition.png and /dev/null differ diff --git a/tests/testdata/blend_modes/darken_only.png b/tests/testdata/blend_modes/darken_only.png deleted file mode 100644 index b375c03..0000000 Binary files a/tests/testdata/blend_modes/darken_only.png and /dev/null differ diff --git a/tests/testdata/blend_modes/difference.png b/tests/testdata/blend_modes/difference.png deleted file mode 100644 index 0f18c30..0000000 Binary files a/tests/testdata/blend_modes/difference.png and /dev/null differ diff --git a/tests/testdata/blend_modes/divide.png b/tests/testdata/blend_modes/divide.png deleted file mode 100644 index 87f14da..0000000 Binary files a/tests/testdata/blend_modes/divide.png and /dev/null differ diff --git a/tests/testdata/blend_modes/dodge.png b/tests/testdata/blend_modes/dodge.png deleted file mode 100644 index d793c16..0000000 Binary files a/tests/testdata/blend_modes/dodge.png and /dev/null differ diff --git a/tests/testdata/blend_modes/grain_extract.png b/tests/testdata/blend_modes/grain_extract.png deleted file mode 100644 index 451be7c..0000000 Binary files a/tests/testdata/blend_modes/grain_extract.png and /dev/null differ diff --git a/tests/testdata/blend_modes/grain_merge.png b/tests/testdata/blend_modes/grain_merge.png deleted file mode 100644 index e091937..0000000 Binary files a/tests/testdata/blend_modes/grain_merge.png and /dev/null differ diff --git a/tests/testdata/blend_modes/hard_light.png b/tests/testdata/blend_modes/hard_light.png deleted file mode 100644 index 128975b..0000000 Binary files a/tests/testdata/blend_modes/hard_light.png and /dev/null differ diff --git a/tests/testdata/blend_modes/layer.png b/tests/testdata/blend_modes/layer.png deleted file mode 100644 index fe12486..0000000 Binary files a/tests/testdata/blend_modes/layer.png and /dev/null differ diff --git a/tests/testdata/blend_modes/layer_50p.png b/tests/testdata/blend_modes/layer_50p.png deleted file mode 100644 index 2ba54c5..0000000 Binary files a/tests/testdata/blend_modes/layer_50p.png and /dev/null differ diff --git a/tests/testdata/blend_modes/lighten_only.png b/tests/testdata/blend_modes/lighten_only.png deleted file mode 100644 index 22fbebf..0000000 Binary files a/tests/testdata/blend_modes/lighten_only.png and /dev/null differ diff --git a/tests/testdata/blend_modes/multiply.png b/tests/testdata/blend_modes/multiply.png deleted file mode 100644 index 0f84021..0000000 Binary files a/tests/testdata/blend_modes/multiply.png and /dev/null differ diff --git a/tests/testdata/blend_modes/normal_100p.png b/tests/testdata/blend_modes/normal_100p.png deleted file mode 100644 index 98f3ea1..0000000 Binary files a/tests/testdata/blend_modes/normal_100p.png and /dev/null differ diff --git a/tests/testdata/blend_modes/normal_50p.png b/tests/testdata/blend_modes/normal_50p.png deleted file mode 100644 index 8ff8e34..0000000 Binary files a/tests/testdata/blend_modes/normal_50p.png and /dev/null differ diff --git a/tests/testdata/blend_modes/orig.png b/tests/testdata/blend_modes/orig.png deleted file mode 100644 index 26b0941..0000000 Binary files a/tests/testdata/blend_modes/orig.png and /dev/null differ diff --git a/tests/testdata/blend_modes/overlay.png b/tests/testdata/blend_modes/overlay.png deleted file mode 100644 index 42835fc..0000000 Binary files a/tests/testdata/blend_modes/overlay.png and /dev/null differ diff --git a/tests/testdata/blend_modes/screen.png b/tests/testdata/blend_modes/screen.png deleted file mode 100644 index 957d123..0000000 Binary files a/tests/testdata/blend_modes/screen.png and /dev/null differ diff --git a/tests/testdata/blend_modes/soft_light.png b/tests/testdata/blend_modes/soft_light.png deleted file mode 100644 index a712936..0000000 Binary files a/tests/testdata/blend_modes/soft_light.png and /dev/null differ diff --git a/tests/testdata/blend_modes/soft_light_50p.png b/tests/testdata/blend_modes/soft_light_50p.png deleted file mode 100644 index 12aef6a..0000000 Binary files a/tests/testdata/blend_modes/soft_light_50p.png and /dev/null differ diff --git a/tests/testdata/blend_modes/subtract.png b/tests/testdata/blend_modes/subtract.png deleted file mode 100644 index 0a3dcb8..0000000 Binary files a/tests/testdata/blend_modes/subtract.png and /dev/null differ