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..bc43cc320 100644 --- a/colour/io/fichet2021.py +++ b/colour/io/fichet2021.py @@ -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, @@ -516,7 +519,9 @@ def read_spectral_image_Fichet2021( 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(): @@ -870,6 +875,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..719e68100 100644 --- a/colour/io/image.py +++ b/colour/io/image.py @@ -32,6 +32,7 @@ as_int_array, attest, filter_kwargs, + is_imageio_installed, is_openimageio_installed, optional, required, @@ -194,7 +195,6 @@ def add_attributes_to_image_specification_OpenImageIO( return image_specification -@required("OpenImageIO") def image_specification_OpenImageIO( width: int, height: int, @@ -454,6 +454,7 @@ def read_image_OpenImageIO( return image +@required("Imageio") def read_image_Imageio( path: str | PathLike, bit_depth: Literal[ @@ -586,14 +587,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] @@ -666,7 +667,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 = ( @@ -722,13 +723,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 +905,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..d16df514c 100644 --- a/colour/io/tests/test_image.py +++ b/colour/io/tests/test_image.py @@ -23,7 +23,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,9 +60,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") @@ -275,9 +272,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,9 +356,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") @@ -380,27 +371,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 +413,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 +511,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 +582,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..a73024cc0 100644 --- a/colour/utilities/__init__.py +++ b/colour/utilities/__init__.py @@ -40,10 +40,11 @@ ) from .requirements import ( is_ctlrender_installed, + is_imageio_installed, + is_openimageio_installed, is_matplotlib_installed, is_networkx_installed, is_opencolorio_installed, - is_openimageio_installed, is_pandas_installed, is_pydot_installed, is_tqdm_installed, @@ -181,10 +182,11 @@ ] __all__ += [ "is_ctlrender_installed", + "is_imageio_installed", + "is_openimageio_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..9131a2ea9 100644 --- a/colour/utilities/requirements.py +++ b/colour/utilities/requirements.py @@ -30,10 +30,11 @@ __all__ = [ "is_ctlrender_installed", + "is_imageio_installed", + "is_openimageio_installed", "is_matplotlib_installed", "is_networkx_installed", "is_opencolorio_installed", - "is_openimageio_installed", "is_pandas_installed", "is_pydot_installed", "is_tqdm_installed", @@ -88,32 +89,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 +126,33 @@ def is_matplotlib_installed(raise_exception: bool = False) -> bool: return True -def is_networkx_installed(raise_exception: bool = False) -> bool: +def is_openimageio_installed(raise_exception: bool = False) -> bool: """ - Return whether *NetworkX* is installed and available. + Return whether *OpenImageIO* is installed and available. Parameters ---------- raise_exception - Whether to raise an exception if *NetworkX* is unavailable. + Whether to raise an exception if *OpenImageIO* is unavailable. Returns ------- :class:`bool` - Whether *NetworkX* is installed. + Whether *OpenImageIO* is installed. Raises ------ :class:`ImportError` - If *NetworkX* is not installed. + If *OpenImageIO* is not installed. """ try: # pragma: no cover - import networkx as nx # noqa: F401 + import OpenImageIO # 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: " + '"OpenImageIO" related API features are not available: ' + f'"{exception}".\nSee the installation guide for more information: ' "https://www.colour-science.org/installation-guide/" ) @@ -163,32 +163,32 @@ def is_networkx_installed(raise_exception: bool = False) -> bool: return True -def is_opencolorio_installed(raise_exception: bool = False) -> bool: +def is_matplotlib_installed(raise_exception: bool = False) -> bool: """ - Return whether *OpenColorIO* is installed and available. + Return whether *Matplotlib* is installed and available. Parameters ---------- raise_exception - Whether to raise an exception if *OpenColorIO* is unavailable. + Whether to raise an exception if *Matplotlib* is unavailable. Returns ------- :class:`bool` - Whether *OpenColorIO* is installed. + Whether *Matplotlib* is installed. Raises ------ :class:`ImportError` - If *OpenColorIO* is not installed. + If *Matplotlib* is not installed. """ try: # pragma: no cover - import PyOpenColorIO # noqa: F401 + import matplotlib as mpl # noqa: F401 except ImportError as exception: # pragma: no cover if raise_exception: error = ( - '"OpenColorIO" related API features are not available: ' + '"Matplotlib" related API features are not available: ' f'"{exception}".\nSee the installation guide for more information: ' "https://www.colour-science.org/installation-guide/" ) @@ -200,32 +200,70 @@ def is_opencolorio_installed(raise_exception: bool = False) -> bool: return True -def is_openimageio_installed(raise_exception: bool = False) -> bool: +def is_networkx_installed(raise_exception: bool = False) -> bool: """ - Return whether *OpenImageIO* is installed and available. + Return whether *NetworkX* is installed and available. Parameters ---------- raise_exception - Whether to raise an exception if *OpenImageIO* is unavailable. + Whether to raise an exception if *NetworkX* is unavailable. Returns ------- :class:`bool` - Whether *OpenImageIO* is installed. + Whether *NetworkX* is installed. Raises ------ :class:`ImportError` - If *OpenImageIO* is not installed. + If *NetworkX* is not installed. """ try: # pragma: no cover - import OpenImageIO # noqa: F401 + import networkx as nx # noqa: F401 except ImportError as exception: # pragma: no cover if raise_exception: error = ( - '"OpenImageIO" related API features are not available: ' + '"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/" + ) + + raise ImportError(error) from exception + + return False + else: + return True + + +def is_opencolorio_installed(raise_exception: bool = False) -> bool: + """ + Return whether *OpenColorIO* is installed and available. + + Parameters + ---------- + raise_exception + Whether to raise an exception if *OpenColorIO* is unavailable. + + Returns + ------- + :class:`bool` + Whether *OpenColorIO* is installed. + + Raises + ------ + :class:`ImportError` + If *OpenColorIO* is not installed. + """ + + try: # pragma: no cover + import PyOpenColorIO # 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: ' "https://www.colour-science.org/installation-guide/" ) @@ -438,10 +476,11 @@ def is_xxhash_installed(raise_exception: bool = False) -> bool: REQUIREMENTS_TO_CALLABLE: CanonicalMapping = CanonicalMapping( { "ctlrender": is_ctlrender_installed, + "Imageio": is_imageio_installed, + "OpenImageIO": is_openimageio_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 +496,11 @@ def is_xxhash_installed(raise_exception: bool = False) -> bool: def required( *requirements: Literal[ "ctlrender", + "Imageio", + "OpenImageIO", "Matplotlib", "NetworkX", "OpenColorIO", - "OpenImageIO", "Pandas", "Pydot", "tqdm", diff --git a/docs/colour.utilities.rst b/docs/colour.utilities.rst index 065dfa9b0..4bb007837 100644 --- a/docs/colour.utilities.rst +++ b/docs/colour.utilities.rst @@ -192,10 +192,11 @@ Requirements :toctree: generated/ is_ctlrender_installed + is_imageio_installed + is_openimageio_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..185fdd5f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ classifiers = [ "Topic :: Software Development", ] dependencies = [ - "imageio>=2,<3", "numpy>=1.24,<3", "scipy>=1.10,<2", "typing-extensions>=4,<5", @@ -53,9 +52,11 @@ dependencies = [ [project.optional-dependencies] optional = [ + "imageio>=2,<3", "matplotlib>=3.7", "networkx>=3,<4", "opencolorio>=2,<3", + "openimageio>=3,<4", "pandas>=2,<3", "pydot>=3,<4", "tqdm>=4,<5", 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