From 68fc4b9b2ac4027b40888b62d010c844729d2b1a Mon Sep 17 00:00:00 2001 From: Thomas Mansencal Date: Sat, 29 Mar 2025 12:12:44 +1300 Subject: [PATCH 1/2] Make *OpenImageIO* required and *ImageIO* optional. --- ...tinuous-integration-quality-unit-tests.yml | 7 -- colour/examples/io/examples_fichet2021.py | 4 +- colour/io/fichet2021.py | 33 +++---- colour/io/image.py | 96 +++++++----------- colour/io/tests/test_fichet2021.py | 10 -- colour/io/tests/test_image.py | 98 +++++++++---------- colour/utilities/__init__.py | 4 +- colour/utilities/requirements.py | 68 ++++++------- docs/colour.utilities.rst | 2 +- docs/requirements.txt | 1 + pyproject.toml | 3 +- requirements.txt | 1 + 12 files changed, 141 insertions(+), 186 deletions(-) diff --git a/.github/workflows/continuous-integration-quality-unit-tests.yml b/.github/workflows/continuous-integration-quality-unit-tests.yml index c38c73e41..dd81a3376 100644 --- a/.github/workflows/continuous-integration-quality-unit-tests.yml +++ b/.github/workflows/continuous-integration-quality-unit-tests.yml @@ -51,13 +51,6 @@ jobs: uv sync --all-extras --no-dev uv run python -c "import imageio;imageio.plugins.freeimage.download()" shell: bash - - name: Install OpenImageIO (macOs) - if: matrix.os == 'macOS-latest' && matrix.python-version == '3.13' - run: | - brew install openimageio - ln -s /opt/homebrew/Cellar/openimageio/*/lib/python*/site-packages/OpenImageIO/OpenImageIO*.so ./.venv/lib/python${{ matrix.python-version }}/site-packages/OpenImageIO.so - uv run python -c "import OpenImageIO;print(OpenImageIO.__version__)" - shell: bash - name: Pre-Commit (All Files) run: | uv run pre-commit run --all-files diff --git a/colour/examples/io/examples_fichet2021.py b/colour/examples/io/examples_fichet2021.py index 69d73eae8..93ce2409a 100644 --- a/colour/examples/io/examples_fichet2021.py +++ b/colour/examples/io/examples_fichet2021.py @@ -7,9 +7,9 @@ import tempfile import colour -from colour.utilities import is_openimageio_installed, message_box +from colour.utilities import is_imageio_installed, message_box -if is_openimageio_installed(): +if is_imageio_installed(): ROOT_RESOURCES = os.path.join( os.path.dirname(__file__), "..", "..", "io", "tests", "resources" ) diff --git a/colour/io/fichet2021.py b/colour/io/fichet2021.py index 717a561f3..0b7ba067b 100644 --- a/colour/io/fichet2021.py +++ b/colour/io/fichet2021.py @@ -20,6 +20,10 @@ from dataclasses import dataclass, field import numpy as np +from OpenImageIO import ImageBuf # pyright: ignore +from OpenImageIO import ImageBufAlgo # pyright: ignore +from OpenImageIO import ImageInput # pyright: ignore +from OpenImageIO import TypeDesc # pyright: ignore from colour.colorimetry import ( MSDS_CMFS, @@ -50,7 +54,6 @@ from colour.utilities import ( as_float_array, interval, - required, usage_warning, validate_method, ) @@ -317,7 +320,6 @@ class Specification_Fichet2021: attributes: Tuple = field(default_factory=lambda: ()) @staticmethod - @required("OpenImageIO") def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: """ Create a *Fichet et al. (2021)* spectral image specification from given @@ -350,8 +352,6 @@ def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: True """ - from OpenImageIO import ImageInput # pyright: ignore - path = str(path) components = defaultdict(dict) @@ -365,7 +365,8 @@ def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: rf"^T\.*{PATTERN_FICHET2021}\.*{PATTERN_FICHET2021}$" ) - image_specification = ImageInput.open(path).spec() + image_input = ImageInput.open(path) + image_specification = image_input.spec() channels = image_specification.channelnames for i, channel in enumerate(channels): @@ -405,6 +406,8 @@ def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: for attribute in image_specification.extra_attribs ] + image_input.close() + return Specification_Fichet2021( path, components, @@ -422,7 +425,6 @@ def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: @typing.overload -@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = ..., @@ -431,7 +433,6 @@ def read_spectral_image_Fichet2021( @typing.overload -@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = ..., @@ -441,7 +442,6 @@ def read_spectral_image_Fichet2021( @typing.overload -@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"], @@ -449,7 +449,6 @@ def read_spectral_image_Fichet2021( ) -> ComponentsFichet2021: ... -@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = "float32", @@ -509,14 +508,14 @@ def read_spectral_image_Fichet2021( True """ - from OpenImageIO import ImageInput # pyright: ignore - path = str(path) bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] specification = Specification_Fichet2021.from_spectral_image(path) - image = ImageInput.open(path).read_image(bit_depth_specification.openimageio) + image_input = ImageInput.open(path) + image = image_input.read_image(bit_depth_specification.openimageio) + image_input.close() components = {} for component, wavelengths_indexes in specification.components.items(): @@ -607,7 +606,6 @@ def sds_and_msds_to_components_Fichet2021( return {component: (wavelengths, values)} -@required("OpenImageIO") def components_to_sRGB_Fichet2021( components: ComponentsFichet2021, specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, @@ -666,8 +664,6 @@ def components_to_sRGB_Fichet2021( EV """ - from OpenImageIO import TypeDesc # pyright: ignore - component = components.get("S0", components.get("T")) if component is None: @@ -744,7 +740,6 @@ def components_to_sRGB_Fichet2021( return RGB, attributes -@required("OpenImageIO") def write_spectral_image_Fichet2021( components: Sequence[SpectralDistribution | MultiSpectralDistributions] | SpectralDistribution @@ -810,8 +805,6 @@ def write_spectral_image_Fichet2021( True """ - from OpenImageIO import ImageBuf, ImageBufAlgo # pyright: ignore - path = str(path) if isinstance( @@ -870,6 +863,4 @@ def write_spectral_image_Fichet2021( image_buffer.specmod(), [*specification.attributes, *attributes] ) - image_buffer.write(path) - - return True + return image_buffer.write(path) diff --git a/colour/io/image.py b/colour/io/image.py index ba1a448bf..9f6702b67 100644 --- a/colour/io/image.py +++ b/colour/io/image.py @@ -11,6 +11,14 @@ from dataclasses import dataclass, field import numpy as np +from OpenImageIO import DOUBLE # pyright: ignore +from OpenImageIO import FLOAT # pyright: ignore +from OpenImageIO import HALF # pyright: ignore +from OpenImageIO import UINT8 # pyright: ignore +from OpenImageIO import UINT16 # pyright: ignore +from OpenImageIO import ImageInput # pyright: ignore +from OpenImageIO import ImageOutput # pyright: ignore +from OpenImageIO import ImageSpec # pyright: ignore if typing.TYPE_CHECKING: from colour.hints import ( @@ -32,7 +40,7 @@ as_int_array, attest, filter_kwargs, - is_openimageio_installed, + is_imageio_installed, optional, required, tstack, @@ -109,41 +117,19 @@ class Image_Specification_Attribute: ) -if is_openimageio_installed(): # pragma: no cover - from OpenImageIO import ImageSpec # pyright: ignore - from OpenImageIO import DOUBLE, FLOAT, HALF, UINT8, UINT16 # pyright: ignore - - MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( - { - "uint8": Image_Specification_BitDepth("uint8", np.uint8, UINT8), - "uint16": Image_Specification_BitDepth("uint16", np.uint16, UINT16), - "float16": Image_Specification_BitDepth("float16", np.float16, HALF), - "float32": Image_Specification_BitDepth("float32", np.float32, FLOAT), - "float64": Image_Specification_BitDepth("float64", np.float64, DOUBLE), - } - ) - if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover - MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( - "float128", np.float128, DOUBLE - ) -else: # pragma: no cover - - class ImageSpec: - attribute: Any - - MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( - { - "uint8": Image_Specification_BitDepth("uint8", np.uint8, None), - "uint16": Image_Specification_BitDepth("uint16", np.uint16, None), - "float16": Image_Specification_BitDepth("float16", np.float16, None), - "float32": Image_Specification_BitDepth("float32", np.float32, None), - "float64": Image_Specification_BitDepth("float64", np.float64, None), - } +MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( + { + "uint8": Image_Specification_BitDepth("uint8", np.uint8, UINT8), + "uint16": Image_Specification_BitDepth("uint16", np.uint16, UINT16), + "float16": Image_Specification_BitDepth("float16", np.float16, HALF), + "float32": Image_Specification_BitDepth("float32", np.float32, FLOAT), + "float64": Image_Specification_BitDepth("float64", np.float64, DOUBLE), + } +) +if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover + MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( + "float128", np.float128, DOUBLE ) - if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover - MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( - "float128", np.float128, None - ) def add_attributes_to_image_specification_OpenImageIO( @@ -194,7 +180,6 @@ def add_attributes_to_image_specification_OpenImageIO( return image_specification -@required("OpenImageIO") def image_specification_OpenImageIO( width: int, height: int, @@ -330,7 +315,6 @@ def convert_bit_depth( @typing.overload -@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal[ @@ -342,7 +326,6 @@ def read_image_OpenImageIO( @typing.overload -@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal[ @@ -355,7 +338,6 @@ def read_image_OpenImageIO( @typing.overload -@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal["uint8", "uint16", "float16", "float32", "float64", "float128"], @@ -364,7 +346,6 @@ def read_image_OpenImageIO( ) -> NDArrayReal: ... -@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal[ @@ -411,8 +392,6 @@ def read_image_OpenImageIO( >>> image = read_image_OpenImageIO(path) # doctest: +SKIP """ - from OpenImageIO import ImageInput # pyright: ignore - path = str(path) kwargs = handle_arguments_deprecation( @@ -454,6 +433,7 @@ def read_image_OpenImageIO( return image +@required("Imageio") def read_image_Imageio( path: str | PathLike, bit_depth: Literal[ @@ -586,14 +566,14 @@ def read_image( dtype('float32') """ - method = validate_method(method, tuple(READ_IMAGE_METHODS)) - - if method == "openimageio" and not is_openimageio_installed(): # pragma: no cover + if method.lower() == "imageio" and not is_imageio_installed(): # pragma: no cover usage_warning( - '"OpenImageIO" related API features are not available, ' - 'switching to "Imageio"!' + '"Imageio" related API features are not available, ' + 'switching to "OpenImageIO"!' ) - method = "Imageio" + method = "openimageio" + + method = validate_method(method, tuple(READ_IMAGE_METHODS)) function = READ_IMAGE_METHODS[method] @@ -603,7 +583,6 @@ def read_image( return function(path, bit_depth, **kwargs) -@required("OpenImageIO") def write_image_OpenImageIO( image: ArrayLike, path: str | PathLike, @@ -666,7 +645,7 @@ def write_image_OpenImageIO( Writing an "ACES" compliant "EXR" file: - >>> if is_openimageio_installed(): # doctest: +SKIP + >>> if is_imageio_installed(): # doctest: +SKIP ... from OpenImageIO import TypeDesc ... ... chromaticities = ( @@ -689,8 +668,6 @@ def write_image_OpenImageIO( ... write_image_OpenImageIO(image, path, attributes=attributes) """ # noqa: D405, D407, D410, D411 - from OpenImageIO import ImageOutput # pyright: ignore - image = as_float_array(image) path = str(path) @@ -722,13 +699,14 @@ def write_image_OpenImageIO( image_output = ImageOutput.create(path) image_output.open(path, image_specification) - image_output.write_image(image) + success = image_output.write_image(image) image_output.close() - return True + return success +@required("Imageio") def write_image_Imageio( image: ArrayLike, path: str | PathLike, @@ -903,14 +881,14 @@ def write_image( True """ # noqa: D405, D407, D410, D411, D414 - method = validate_method(method, tuple(WRITE_IMAGE_METHODS)) - - if method == "openimageio" and not is_openimageio_installed(): # pragma: no cover + if method.lower() == "imageio" and not is_imageio_installed(): # pragma: no cover usage_warning( - '"OpenImageIO" related API features are not available, ' + '"Imageio" related API features are not available, ' 'switching to "Imageio"!' ) - method = "Imageio" + method = "openimageio" + + method = validate_method(method, tuple(WRITE_IMAGE_METHODS)) function = WRITE_IMAGE_METHODS[method] diff --git a/colour/io/tests/test_fichet2021.py b/colour/io/tests/test_fichet2021.py index 3752eb722..a7ad81b8c 100644 --- a/colour/io/tests/test_fichet2021.py +++ b/colour/io/tests/test_fichet2021.py @@ -29,7 +29,6 @@ match_groups_to_nm, sds_and_msds_to_components_Fichet2021, ) -from colour.utilities import is_openimageio_installed __author__ = "Colour Developers" __copyright__ = "Copyright 2013 Colour Developers" @@ -180,9 +179,6 @@ def test_components_to_sRGB_Fichet2021(self) -> None: definition. """ - if not is_openimageio_installed(): - return - specification = Specification_Fichet2021(is_emissive=True) components = sds_and_msds_to_components_Fichet2021( SDS_ILLUMINANTS["D65"], specification @@ -465,9 +461,6 @@ def test_read_spectral_image_Fichet2021(self) -> None: definition. """ - if not is_openimageio_installed(): - return - _test_spectral_image_D65(os.path.join(ROOT_RESOURCES, "D65.exr")) _test_spectral_image_Ohta1997(os.path.join(ROOT_RESOURCES, "Ohta1997.exr")) @@ -499,9 +492,6 @@ def test_write_spectral_image_Fichet2021(self) -> None: definition. """ - if not is_openimageio_installed(): - return - path = os.path.join(self._temporary_directory, "D65.exr") specification = Specification_Fichet2021(is_emissive=True) write_spectral_image_Fichet2021( diff --git a/colour/io/tests/test_image.py b/colour/io/tests/test_image.py index eb71da0ba..642dd665f 100644 --- a/colour/io/tests/test_image.py +++ b/colour/io/tests/test_image.py @@ -9,6 +9,7 @@ import numpy as np import pytest +from OpenImageIO import HALF, TypeDesc # pyright: ignore from colour.constants import TOLERANCE_ABSOLUTE_TESTS from colour.io import ( @@ -23,7 +24,7 @@ write_image_Imageio, write_image_OpenImageIO, ) -from colour.utilities import attest, full, is_openimageio_installed +from colour.utilities import attest, full __author__ = "Colour Developers" __copyright__ = "Copyright 2013 Colour Developers" @@ -60,11 +61,6 @@ def test_image_specification_OpenImageIO(self) -> None: # pragma: no cover definition. """ - if not is_openimageio_installed(): - return - - from OpenImageIO import HALF # pyright: ignore - compression = Image_Specification_Attribute("Compression", "none") specification = image_specification_OpenImageIO( 1920, 1080, 3, "float16", [compression] @@ -275,9 +271,6 @@ class TestReadImageOpenImageIO: def test_read_image_OpenImageIO(self) -> None: # pragma: no cover """Test :func:`colour.io.image.read_image_OpenImageIO` definition.""" - if not is_openimageio_installed(): - return - image = read_image_OpenImageIO( os.path.join(ROOT_RESOURCES, "CMS_Test_Pattern.exr"), additional_data=False, @@ -362,11 +355,6 @@ def teardown_method(self) -> None: def test_write_image_OpenImageIO(self) -> None: # pragma: no cover """Test :func:`colour.io.image.write_image_OpenImageIO` definition.""" - if not is_openimageio_installed(): - return - - from OpenImageIO import TypeDesc # pyright: ignore - path = os.path.join(self._temporary_directory, "8-bit.png") RGB = full((1, 1, 3), 255, np.uint8) write_image_OpenImageIO(RGB, path, bit_depth="uint8") @@ -380,27 +368,30 @@ def test_write_image_OpenImageIO(self) -> None: # pragma: no cover np.testing.assert_equal(np.squeeze(RGB), image) source_path = os.path.join(ROOT_RESOURCES, "Overflowing_Gradient.png") + source_image = read_image_OpenImageIO(source_path, bit_depth="uint8") target_path = os.path.join( self._temporary_directory, "Overflowing_Gradient.png" ) RGB = np.arange(0, 256, 1, dtype=np.uint8)[None] * 2 write_image_OpenImageIO(RGB, target_path, bit_depth="uint8") - image = read_image_OpenImageIO(source_path, bit_depth="uint8") - np.testing.assert_equal(np.squeeze(RGB), image) + target_image = read_image_OpenImageIO(source_path, bit_depth="uint8") + np.testing.assert_equal(source_image, target_image) + np.testing.assert_equal(np.squeeze(RGB), target_image) source_path = os.path.join(ROOT_RESOURCES, "CMS_Test_Pattern.exr") - target_path = os.path.join(self._temporary_directory, "CMS_Test_Pattern.exr") - image = read_image_OpenImageIO( + source_image = read_image_OpenImageIO( source_path, additional_data=False, ) - write_image_OpenImageIO(image, target_path) - image = read_image_OpenImageIO( + target_path = os.path.join(self._temporary_directory, "CMS_Test_Pattern.exr") + write_image_OpenImageIO(source_image, target_path) + target_image = read_image_OpenImageIO( target_path, additional_data=False, ) - assert image.shape == (1267, 1274, 3) - assert image.dtype is np.dtype("float32") + np.testing.assert_equal(source_image, target_image) + assert target_image.shape == (1267, 1274, 3) + assert target_image.dtype is np.dtype("float32") chromaticities = ( 0.73470, @@ -419,8 +410,8 @@ def test_write_image_OpenImageIO(self) -> None: # pragma: no cover ), Image_Specification_Attribute("compression", "none"), ] - write_image_OpenImageIO(image, target_path, attributes=write_attributes) - image, read_attributes = read_image_OpenImageIO( + write_image_OpenImageIO(target_image, target_path, attributes=write_attributes) + target_image, read_attributes = read_image_OpenImageIO( target_path, additional_data=True ) for write_attribute in write_attributes: @@ -517,35 +508,41 @@ def test_write_image_Imageio(self) -> None: """Test :func:`colour.io.image.write_image_Imageio` definition.""" source_path = os.path.join(ROOT_RESOURCES, "Overflowing_Gradient.png") + source_image = read_image_Imageio(source_path, bit_depth="uint8") target_path = os.path.join( self._temporary_directory, "Overflowing_Gradient.png" ) RGB = np.arange(0, 256, 1, dtype=np.uint8)[None] * 2 write_image_Imageio(RGB, target_path, bit_depth="uint8") - image = read_image_Imageio(source_path, bit_depth="uint8") - np.testing.assert_equal(np.squeeze(RGB), image) + target_image = read_image_Imageio(target_path, bit_depth="uint8") + np.testing.assert_equal(np.squeeze(RGB), target_image) + np.testing.assert_equal(source_image, target_image) - source_path = os.path.join(ROOT_RESOURCES, "CMS_Test_Pattern.exr") - target_path = os.path.join(self._temporary_directory, "CMS_Test_Pattern.exr") - image = read_image_Imageio(source_path) - write_image_Imageio(image, target_path) - image = read_image_Imageio(target_path) - assert image.shape == (1267, 1274, 3) - assert image.dtype is np.dtype("float32") - - # NOTE: Those unit tests are breaking unpredictably on Linux, skipping - # for now. + # NOTE: Those unit tests are breaking on Linux, skipping for now. if platform.system() != "Linux": # pragma: no cover + source_path = os.path.join(ROOT_RESOURCES, "CMS_Test_Pattern.exr") + source_image = read_image_Imageio(source_path) + target_path = os.path.join( + self._temporary_directory, "CMS_Test_Pattern.exr" + ) + write_image_Imageio(source_image, target_path) + target_image = read_image_Imageio(target_path) + np.testing.assert_allclose( + source_image, target_image, atol=TOLERANCE_ABSOLUTE_TESTS + ) + assert target_image.shape == (1267, 1274, 3) + assert target_image.dtype is np.dtype("float32") + target_path = os.path.join(self._temporary_directory, "Full_White.exr") - image = full((32, 16, 3), 1e6, dtype=np.float16) - write_image_Imageio(image, target_path) - image = read_image_Imageio(target_path) - assert np.max(image) == np.inf + target_image = full((32, 16, 3), 1e6, dtype=np.float16) + write_image_Imageio(target_image, target_path) + target_image = read_image_Imageio(target_path) + assert np.max(target_image) == np.inf - image = full((32, 16, 3), 1e6) - write_image_Imageio(image, target_path) - image = read_image_Imageio(target_path) - assert np.max(image) == 1e6 + target_image = full((32, 16, 3), 1e6) + write_image_Imageio(target_image, target_path) + target_image = read_image_Imageio(target_path) + assert np.max(target_image) == 1e6 class TestReadImage: @@ -582,12 +579,15 @@ def test_write_image(self) -> None: """Test :func:`colour.io.image.write_image` definition.""" source_path = os.path.join(ROOT_RESOURCES, "CMS_Test_Pattern.exr") + source_image = read_image(source_path) target_path = os.path.join(self._temporary_directory, "CMS_Test_Pattern.exr") - image = read_image(source_path) - write_image(image, target_path) - image = read_image(target_path) - assert image.shape == (1267, 1274, 3) - assert image.dtype is np.dtype("float32") + write_image(source_image, target_path) + target_image = read_image(target_path) + np.testing.assert_allclose( + source_image, target_image, atol=TOLERANCE_ABSOLUTE_TESTS + ) + assert target_image.shape == (1267, 1274, 3) + assert target_image.dtype is np.dtype("float32") class TestAs3ChannelsImage: diff --git a/colour/utilities/__init__.py b/colour/utilities/__init__.py index ba4eb9012..3347deac3 100644 --- a/colour/utilities/__init__.py +++ b/colour/utilities/__init__.py @@ -40,10 +40,10 @@ ) from .requirements import ( is_ctlrender_installed, + is_imageio_installed, is_matplotlib_installed, is_networkx_installed, is_opencolorio_installed, - is_openimageio_installed, is_pandas_installed, is_pydot_installed, is_tqdm_installed, @@ -181,10 +181,10 @@ ] __all__ += [ "is_ctlrender_installed", + "is_imageio_installed", "is_matplotlib_installed", "is_networkx_installed", "is_opencolorio_installed", - "is_openimageio_installed", "is_pandas_installed", "is_pydot_installed", "is_tqdm_installed", diff --git a/colour/utilities/requirements.py b/colour/utilities/requirements.py index 756d5044c..d31aef649 100644 --- a/colour/utilities/requirements.py +++ b/colour/utilities/requirements.py @@ -30,10 +30,10 @@ __all__ = [ "is_ctlrender_installed", + "is_imageio_installed", "is_matplotlib_installed", "is_networkx_installed", "is_opencolorio_installed", - "is_openimageio_installed", "is_pandas_installed", "is_pydot_installed", "is_tqdm_installed", @@ -88,32 +88,32 @@ def is_ctlrender_installed(raise_exception: bool = False) -> bool: return True -def is_matplotlib_installed(raise_exception: bool = False) -> bool: +def is_imageio_installed(raise_exception: bool = False) -> bool: """ - Return whether *Matplotlib* is installed and available. + Return whether *Imageio* is installed and available. Parameters ---------- raise_exception - Whether to raise an exception if *Matplotlib* is unavailable. + Whether to raise an exception if *Imageio* is unavailable. Returns ------- :class:`bool` - Whether *Matplotlib* is installed. + Whether *Imageio* is installed. Raises ------ :class:`ImportError` - If *Matplotlib* is not installed. + If *Imageio* is not installed. """ try: # pragma: no cover - import matplotlib as mpl # noqa: F401 + import imageio # noqa: F401 except ImportError as exception: # pragma: no cover if raise_exception: error = ( - '"Matplotlib" related API features are not available: ' + '"Imageio" related API features are not available: ' f'"{exception}".\nSee the installation guide for more information: ' "https://www.colour-science.org/installation-guide/" ) @@ -125,34 +125,33 @@ def is_matplotlib_installed(raise_exception: bool = False) -> bool: return True -def is_networkx_installed(raise_exception: bool = False) -> bool: +def is_matplotlib_installed(raise_exception: bool = False) -> bool: """ - Return whether *NetworkX* is installed and available. + Return whether *Matplotlib* is installed and available. Parameters ---------- raise_exception - Whether to raise an exception if *NetworkX* is unavailable. + Whether to raise an exception if *Matplotlib* is unavailable. Returns ------- :class:`bool` - Whether *NetworkX* is installed. + Whether *Matplotlib* is installed. Raises ------ :class:`ImportError` - If *NetworkX* is not installed. + If *Matplotlib* is not installed. """ try: # pragma: no cover - import networkx as nx # noqa: F401 + import matplotlib as mpl # noqa: F401 except ImportError as exception: # pragma: no cover if raise_exception: error = ( - '"NetworkX" related API features, e.g., the automatic colour ' - f'conversion graph, are not available: "{exception}".\nPlease refer ' - "to the installation guide for more information: " + '"Matplotlib" related API features are not available: ' + f'"{exception}".\nSee the installation guide for more information: ' "https://www.colour-science.org/installation-guide/" ) @@ -163,33 +162,34 @@ def is_networkx_installed(raise_exception: bool = False) -> bool: return True -def is_opencolorio_installed(raise_exception: bool = False) -> bool: +def is_networkx_installed(raise_exception: bool = False) -> bool: """ - Return whether *OpenColorIO* is installed and available. + Return whether *NetworkX* is installed and available. Parameters ---------- raise_exception - Whether to raise an exception if *OpenColorIO* is unavailable. + Whether to raise an exception if *NetworkX* is unavailable. Returns ------- :class:`bool` - Whether *OpenColorIO* is installed. + Whether *NetworkX* is installed. Raises ------ :class:`ImportError` - If *OpenColorIO* is not installed. + If *NetworkX* is not installed. """ try: # pragma: no cover - import PyOpenColorIO # noqa: F401 + import networkx as nx # noqa: F401 except ImportError as exception: # pragma: no cover if raise_exception: error = ( - '"OpenColorIO" related API features are not available: ' - f'"{exception}".\nSee the installation guide for more information: ' + '"NetworkX" related API features, e.g., the automatic colour ' + f'conversion graph, are not available: "{exception}".\nPlease refer ' + "to the installation guide for more information: " "https://www.colour-science.org/installation-guide/" ) @@ -200,32 +200,32 @@ def is_opencolorio_installed(raise_exception: bool = False) -> bool: return True -def is_openimageio_installed(raise_exception: bool = False) -> bool: +def is_opencolorio_installed(raise_exception: bool = False) -> bool: """ - Return whether *OpenImageIO* is installed and available. + Return whether *OpenColorIO* is installed and available. Parameters ---------- raise_exception - Whether to raise an exception if *OpenImageIO* is unavailable. + Whether to raise an exception if *OpenColorIO* is unavailable. Returns ------- :class:`bool` - Whether *OpenImageIO* is installed. + Whether *OpenColorIO* is installed. Raises ------ :class:`ImportError` - If *OpenImageIO* is not installed. + If *OpenColorIO* is not installed. """ try: # pragma: no cover - import OpenImageIO # noqa: F401 + import PyOpenColorIO # noqa: F401 except ImportError as exception: # pragma: no cover if raise_exception: error = ( - '"OpenImageIO" related API features are not available: ' + '"OpenColorIO" related API features are not available: ' f'"{exception}".\nSee the installation guide for more information: ' "https://www.colour-science.org/installation-guide/" ) @@ -438,10 +438,10 @@ def is_xxhash_installed(raise_exception: bool = False) -> bool: REQUIREMENTS_TO_CALLABLE: CanonicalMapping = CanonicalMapping( { "ctlrender": is_ctlrender_installed, + "Imageio": is_imageio_installed, "Matplotlib": is_matplotlib_installed, "NetworkX": is_networkx_installed, "OpenColorIO": is_opencolorio_installed, - "OpenImageIO": is_openimageio_installed, "Pandas": is_pandas_installed, "Pydot": is_pydot_installed, "tqdm": is_tqdm_installed, @@ -457,10 +457,10 @@ def is_xxhash_installed(raise_exception: bool = False) -> bool: def required( *requirements: Literal[ "ctlrender", + "Imageio", "Matplotlib", "NetworkX", "OpenColorIO", - "OpenImageIO", "Pandas", "Pydot", "tqdm", diff --git a/docs/colour.utilities.rst b/docs/colour.utilities.rst index 065dfa9b0..3ee11bd53 100644 --- a/docs/colour.utilities.rst +++ b/docs/colour.utilities.rst @@ -192,10 +192,10 @@ Requirements :toctree: generated/ is_ctlrender_installed + is_imageio_installed is_matplotlib_installed is_networkx_installed is_opencolorio_installed - is_openimageio_installed is_pandas_installed is_pydot_installed is_tqdm_installed diff --git a/docs/requirements.txt b/docs/requirements.txt index 95909bac4..747c65f00 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -23,6 +23,7 @@ matplotlib==3.10.0 networkx==3.4.2 numpy==2.2.0 opencolorio==2.4.1 +openimageio==3.0.4.0 packaging==24.2 pandas==2.2.3 pillow==11.0.0 diff --git a/pyproject.toml b/pyproject.toml index 177536eb1..968658b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,14 +45,15 @@ classifiers = [ "Topic :: Software Development", ] dependencies = [ - "imageio>=2,<3", "numpy>=1.24,<3", + "openimageio>=3,<4", "scipy>=1.10,<2", "typing-extensions>=4,<5", ] [project.optional-dependencies] optional = [ + "imageio>=2,<3", "matplotlib>=3.7", "networkx>=3,<4", "opencolorio>=2,<3", diff --git a/requirements.txt b/requirements.txt index b07d482a1..b013517ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -100,6 +100,7 @@ notebook==7.3.1 notebook-shim==0.2.4 numpy==2.2.0 opencolorio==2.4.1 +openimageio==3.0.4.0 overrides==7.7.0 packaging==24.2 pandas==2.2.3 From 6ff475eabadefc8bb9d68c52103a33514436ea3e Mon Sep 17 00:00:00 2001 From: Thomas Mansencal Date: Sun, 11 May 2025 19:56:56 +1200 Subject: [PATCH 2/2] Make *OpenImageIO* optional again. --- colour/io/fichet2021.py | 20 ++++++++-- colour/io/image.py | 64 ++++++++++++++++++++++---------- colour/io/tests/test_image.py | 5 ++- colour/utilities/__init__.py | 2 + colour/utilities/requirements.py | 40 ++++++++++++++++++++ docs/colour.utilities.rst | 1 + pyproject.toml | 2 +- 7 files changed, 108 insertions(+), 26 deletions(-) diff --git a/colour/io/fichet2021.py b/colour/io/fichet2021.py index 0b7ba067b..bc43cc320 100644 --- a/colour/io/fichet2021.py +++ b/colour/io/fichet2021.py @@ -20,10 +20,6 @@ from dataclasses import dataclass, field import numpy as np -from OpenImageIO import ImageBuf # pyright: ignore -from OpenImageIO import ImageBufAlgo # pyright: ignore -from OpenImageIO import ImageInput # pyright: ignore -from OpenImageIO import TypeDesc # pyright: ignore from colour.colorimetry import ( MSDS_CMFS, @@ -54,6 +50,7 @@ from colour.utilities import ( as_float_array, interval, + required, usage_warning, validate_method, ) @@ -320,6 +317,7 @@ class Specification_Fichet2021: attributes: Tuple = field(default_factory=lambda: ()) @staticmethod + @required("OpenImageIO") def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: """ Create a *Fichet et al. (2021)* spectral image specification from given @@ -352,6 +350,8 @@ def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: True """ + from OpenImageIO import ImageInput # pyright: ignore + path = str(path) components = defaultdict(dict) @@ -425,6 +425,7 @@ def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: @typing.overload +@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = ..., @@ -433,6 +434,7 @@ def read_spectral_image_Fichet2021( @typing.overload +@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = ..., @@ -442,6 +444,7 @@ def read_spectral_image_Fichet2021( @typing.overload +@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"], @@ -449,6 +452,7 @@ def read_spectral_image_Fichet2021( ) -> ComponentsFichet2021: ... +@required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = "float32", @@ -508,6 +512,8 @@ def read_spectral_image_Fichet2021( True """ + from OpenImageIO import ImageInput # pyright: ignore + path = str(path) bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] @@ -606,6 +612,7 @@ def sds_and_msds_to_components_Fichet2021( return {component: (wavelengths, values)} +@required("OpenImageIO") def components_to_sRGB_Fichet2021( components: ComponentsFichet2021, specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, @@ -664,6 +671,8 @@ def components_to_sRGB_Fichet2021( EV """ + from OpenImageIO import TypeDesc # pyright: ignore + component = components.get("S0", components.get("T")) if component is None: @@ -740,6 +749,7 @@ def components_to_sRGB_Fichet2021( return RGB, attributes +@required("OpenImageIO") def write_spectral_image_Fichet2021( components: Sequence[SpectralDistribution | MultiSpectralDistributions] | SpectralDistribution @@ -805,6 +815,8 @@ def write_spectral_image_Fichet2021( True """ + from OpenImageIO import ImageBuf, ImageBufAlgo # pyright: ignore + path = str(path) if isinstance( diff --git a/colour/io/image.py b/colour/io/image.py index 9f6702b67..719e68100 100644 --- a/colour/io/image.py +++ b/colour/io/image.py @@ -11,14 +11,6 @@ from dataclasses import dataclass, field import numpy as np -from OpenImageIO import DOUBLE # pyright: ignore -from OpenImageIO import FLOAT # pyright: ignore -from OpenImageIO import HALF # pyright: ignore -from OpenImageIO import UINT8 # pyright: ignore -from OpenImageIO import UINT16 # pyright: ignore -from OpenImageIO import ImageInput # pyright: ignore -from OpenImageIO import ImageOutput # pyright: ignore -from OpenImageIO import ImageSpec # pyright: ignore if typing.TYPE_CHECKING: from colour.hints import ( @@ -41,6 +33,7 @@ attest, filter_kwargs, is_imageio_installed, + is_openimageio_installed, optional, required, tstack, @@ -117,19 +110,41 @@ class Image_Specification_Attribute: ) -MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( - { - "uint8": Image_Specification_BitDepth("uint8", np.uint8, UINT8), - "uint16": Image_Specification_BitDepth("uint16", np.uint16, UINT16), - "float16": Image_Specification_BitDepth("float16", np.float16, HALF), - "float32": Image_Specification_BitDepth("float32", np.float32, FLOAT), - "float64": Image_Specification_BitDepth("float64", np.float64, DOUBLE), - } -) -if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover - MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( - "float128", np.float128, DOUBLE +if is_openimageio_installed(): # pragma: no cover + from OpenImageIO import ImageSpec # pyright: ignore + from OpenImageIO import DOUBLE, FLOAT, HALF, UINT8, UINT16 # pyright: ignore + + MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( + { + "uint8": Image_Specification_BitDepth("uint8", np.uint8, UINT8), + "uint16": Image_Specification_BitDepth("uint16", np.uint16, UINT16), + "float16": Image_Specification_BitDepth("float16", np.float16, HALF), + "float32": Image_Specification_BitDepth("float32", np.float32, FLOAT), + "float64": Image_Specification_BitDepth("float64", np.float64, DOUBLE), + } + ) + if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover + MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( + "float128", np.float128, DOUBLE + ) +else: # pragma: no cover + + class ImageSpec: + attribute: Any + + MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( + { + "uint8": Image_Specification_BitDepth("uint8", np.uint8, None), + "uint16": Image_Specification_BitDepth("uint16", np.uint16, None), + "float16": Image_Specification_BitDepth("float16", np.float16, None), + "float32": Image_Specification_BitDepth("float32", np.float32, None), + "float64": Image_Specification_BitDepth("float64", np.float64, None), + } ) + if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover + MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( + "float128", np.float128, None + ) def add_attributes_to_image_specification_OpenImageIO( @@ -315,6 +330,7 @@ def convert_bit_depth( @typing.overload +@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal[ @@ -326,6 +342,7 @@ def read_image_OpenImageIO( @typing.overload +@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal[ @@ -338,6 +355,7 @@ def read_image_OpenImageIO( @typing.overload +@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal["uint8", "uint16", "float16", "float32", "float64", "float128"], @@ -346,6 +364,7 @@ def read_image_OpenImageIO( ) -> NDArrayReal: ... +@required("OpenImageIO") def read_image_OpenImageIO( path: str | PathLike, bit_depth: Literal[ @@ -392,6 +411,8 @@ def read_image_OpenImageIO( >>> image = read_image_OpenImageIO(path) # doctest: +SKIP """ + from OpenImageIO import ImageInput # pyright: ignore + path = str(path) kwargs = handle_arguments_deprecation( @@ -583,6 +604,7 @@ def read_image( return function(path, bit_depth, **kwargs) +@required("OpenImageIO") def write_image_OpenImageIO( image: ArrayLike, path: str | PathLike, @@ -668,6 +690,8 @@ def write_image_OpenImageIO( ... write_image_OpenImageIO(image, path, attributes=attributes) """ # noqa: D405, D407, D410, D411 + from OpenImageIO import ImageOutput # pyright: ignore + image = as_float_array(image) path = str(path) diff --git a/colour/io/tests/test_image.py b/colour/io/tests/test_image.py index 642dd665f..d16df514c 100644 --- a/colour/io/tests/test_image.py +++ b/colour/io/tests/test_image.py @@ -9,7 +9,6 @@ import numpy as np import pytest -from OpenImageIO import HALF, TypeDesc # pyright: ignore from colour.constants import TOLERANCE_ABSOLUTE_TESTS from colour.io import ( @@ -61,6 +60,8 @@ def test_image_specification_OpenImageIO(self) -> None: # pragma: no cover definition. """ + from OpenImageIO import HALF # pyright: ignore + compression = Image_Specification_Attribute("Compression", "none") specification = image_specification_OpenImageIO( 1920, 1080, 3, "float16", [compression] @@ -355,6 +356,8 @@ def teardown_method(self) -> None: def test_write_image_OpenImageIO(self) -> None: # pragma: no cover """Test :func:`colour.io.image.write_image_OpenImageIO` definition.""" + from OpenImageIO import TypeDesc # pyright: ignore + path = os.path.join(self._temporary_directory, "8-bit.png") RGB = full((1, 1, 3), 255, np.uint8) write_image_OpenImageIO(RGB, path, bit_depth="uint8") diff --git a/colour/utilities/__init__.py b/colour/utilities/__init__.py index 3347deac3..a73024cc0 100644 --- a/colour/utilities/__init__.py +++ b/colour/utilities/__init__.py @@ -41,6 +41,7 @@ from .requirements import ( is_ctlrender_installed, is_imageio_installed, + is_openimageio_installed, is_matplotlib_installed, is_networkx_installed, is_opencolorio_installed, @@ -182,6 +183,7 @@ __all__ += [ "is_ctlrender_installed", "is_imageio_installed", + "is_openimageio_installed", "is_matplotlib_installed", "is_networkx_installed", "is_opencolorio_installed", diff --git a/colour/utilities/requirements.py b/colour/utilities/requirements.py index d31aef649..9131a2ea9 100644 --- a/colour/utilities/requirements.py +++ b/colour/utilities/requirements.py @@ -31,6 +31,7 @@ __all__ = [ "is_ctlrender_installed", "is_imageio_installed", + "is_openimageio_installed", "is_matplotlib_installed", "is_networkx_installed", "is_opencolorio_installed", @@ -125,6 +126,43 @@ def is_imageio_installed(raise_exception: bool = False) -> bool: return True +def is_openimageio_installed(raise_exception: bool = False) -> bool: + """ + Return whether *OpenImageIO* is installed and available. + + Parameters + ---------- + raise_exception + Whether to raise an exception if *OpenImageIO* is unavailable. + + Returns + ------- + :class:`bool` + Whether *OpenImageIO* is installed. + + Raises + ------ + :class:`ImportError` + If *OpenImageIO* is not installed. + """ + + try: # pragma: no cover + import OpenImageIO # noqa: F401 + except ImportError as exception: # pragma: no cover + if raise_exception: + error = ( + '"OpenImageIO" related API features are not available: ' + f'"{exception}".\nSee the installation guide for more information: ' + "https://www.colour-science.org/installation-guide/" + ) + + raise ImportError(error) from exception + + return False + else: + return True + + def is_matplotlib_installed(raise_exception: bool = False) -> bool: """ Return whether *Matplotlib* is installed and available. @@ -439,6 +477,7 @@ def is_xxhash_installed(raise_exception: bool = False) -> bool: { "ctlrender": is_ctlrender_installed, "Imageio": is_imageio_installed, + "OpenImageIO": is_openimageio_installed, "Matplotlib": is_matplotlib_installed, "NetworkX": is_networkx_installed, "OpenColorIO": is_opencolorio_installed, @@ -458,6 +497,7 @@ def required( *requirements: Literal[ "ctlrender", "Imageio", + "OpenImageIO", "Matplotlib", "NetworkX", "OpenColorIO", diff --git a/docs/colour.utilities.rst b/docs/colour.utilities.rst index 3ee11bd53..4bb007837 100644 --- a/docs/colour.utilities.rst +++ b/docs/colour.utilities.rst @@ -193,6 +193,7 @@ Requirements is_ctlrender_installed is_imageio_installed + is_openimageio_installed is_matplotlib_installed is_networkx_installed is_opencolorio_installed diff --git a/pyproject.toml b/pyproject.toml index 968658b93..185fdd5f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ classifiers = [ ] dependencies = [ "numpy>=1.24,<3", - "openimageio>=3,<4", "scipy>=1.10,<2", "typing-extensions>=4,<5", ] @@ -57,6 +56,7 @@ optional = [ "matplotlib>=3.7", "networkx>=3,<4", "opencolorio>=2,<3", + "openimageio>=3,<4", "pandas>=2,<3", "pydot>=3,<4", "tqdm>=4,<5",