diff --git a/aces/idt/core/common.py b/aces/idt/core/common.py index c5ce5e2..b3edf7e 100644 --- a/aces/idt/core/common.py +++ b/aces/idt/core/common.py @@ -33,6 +33,7 @@ SDS_COLOURCHECKERS, SDS_ILLUMINANTS, SpectralDistribution, + sd_blackbody, sd_to_aces_relative_exposure_values, ) from colour.algebra import euclidean_distance, vecmul @@ -41,6 +42,8 @@ optimisation_factory_rawtoaces_v1, whitepoint_preserving_matrix, ) +from colour.colorimetry import sd_CIE_illuminant_D_series +from colour.temperature import CCT_to_xy_CIE_D if typing.TYPE_CHECKING: from colour.hints import ( @@ -120,7 +123,9 @@ def get_sds_colour_checker(colour_checker_name: str) -> Tuple[SpectralDistributi return tuple(SDS_COLOURCHECKERS[colour_checker_name].values()) -def get_sds_illuminant(illuminant_name: str) -> SpectralDistribution: +def get_sds_illuminant( + illuminant_name: str, temperature: float | None = None +) -> SpectralDistribution: """ Return the *ACES* reference illuminant spectral distribution, for the given illuminant name. @@ -128,13 +133,58 @@ def get_sds_illuminant(illuminant_name: str) -> SpectralDistribution: Parameters ---------- illuminant_name - Name of the illuminant. + Name of the illuminant. Can be a standard illuminant name from + SDS_ILLUMINANTS or a special type like "Blackbody", "Custom", or "Daylight". + temperature + Temperature in Kelvin for Blackbody or Daylight illuminants. Required when + illuminant_name is "Blackbody" or "Daylight". Default is *None*. Returns ------- :class:`SpectralDistribution` + Illuminant spectral distribution. + + Raises + ------ + ValueError + If illuminant_name is "Blackbody" or "Daylight" and temperature is not provided. + If illuminant_name is "Custom" (not yet implemented). + + Notes + ----- + - For "Blackbody" illuminants, the `sd_blackbody` function is used to + generate a spectral distribution at the specified temperature using + Planck's law. + - For "Daylight" illuminants, the CIE D Series illuminant is generated + using the specified correlated colour temperature (CCT). A correction + factor of 1.4388/1.4380 is applied to the CCT as per CIE 015:2004 + recommendation to account for the difference between historical and + modern Planck's constant values. This ensures generated illuminants + match internationally agreed CIE D Series xy chromaticity coordinates. + - "Custom" illuminants are not yet implemented and will raise a ValueError. + + References + ---------- + :cite:`CIETC1-482004` - CIE 015:2004: Colorimetry, 3rd Edition """ + if illuminant_name == "Blackbody": + if temperature is None: + msg = 'Temperature parameter is required for "Blackbody" illuminant.' + raise ValueError(msg) + return sd_blackbody(temperature) + + if illuminant_name == "Daylight": + if temperature is None: + msg = 'Temperature parameter is required for "Daylight" illuminant.' + raise ValueError(msg) + # Apply the correction factor as per CIE 015:2004 recommendation + CCT_corrected = temperature * 1.4388 / 1.4380 + xy = CCT_to_xy_CIE_D(CCT_corrected) + return sd_CIE_illuminant_D_series(xy) + if illuminant_name == "Custom": + msg = '"Custom" illuminant type is not yet implemented.' + raise ValueError(msg) return SDS_ILLUMINANTS[illuminant_name] diff --git a/aces/idt/core/constants.py b/aces/idt/core/constants.py index cd3a431..06f9ffa 100644 --- a/aces/idt/core/constants.py +++ b/aces/idt/core/constants.py @@ -312,6 +312,15 @@ class ProjectSettingsMetadataConstants: ui_category=UICategories.STANDARD, ) + ILLUMINANT_CUSTOM_TEMPERATURE = Metadata( + name="illuminant_custom_temperature", + default_value=6500, + description="The temperature in Kelvin for Blackbody or Daylight illuminants", + display_name="Illuminant Temperature (K)", + ui_type=UITypes.INT_FIELD, + ui_category=UICategories.ADVANCED, + ) + ADDITIONAL_CAMERA_SETTINGS = Metadata( name="additional_camera_settings", default_value="", @@ -488,6 +497,7 @@ class ProjectSettingsMetadataConstants: ACES_USER_NAME, ISO, TEMPERATURE, + ILLUMINANT_CUSTOM_TEMPERATURE, ADDITIONAL_CAMERA_SETTINGS, LIGHTING_SETUP_DESCRIPTION, DEBAYERING_PLATFORM, diff --git a/aces/idt/framework/project_settings.py b/aces/idt/framework/project_settings.py index 7955455..1a55711 100644 --- a/aces/idt/framework/project_settings.py +++ b/aces/idt/framework/project_settings.py @@ -85,6 +85,10 @@ def __init__(self, **kwargs: Dict) -> None: IDTProjectSettings.temperature.metadata.name, IDTProjectSettings.temperature.metadata.default_value, ) + self._illuminant_custom_temperature = kwargs.get( + IDTProjectSettings.illuminant_custom_temperature.metadata.name, + IDTProjectSettings.illuminant_custom_temperature.metadata.default_value, + ) self._additional_camera_settings = kwargs.get( IDTProjectSettings.additional_camera_settings.metadata.name, IDTProjectSettings.additional_camera_settings.metadata.default_value, @@ -291,6 +295,30 @@ def temperature(self) -> int: return self._temperature + @metadata_property(metadata=MetadataConstants.ILLUMINANT_CUSTOM_TEMPERATURE) + def illuminant_custom_temperature(self) -> int: + """ + Getter property for the illuminant custom temperature in Kelvin degrees. + + This property is used when the illuminant is set to "Blackbody" or + "Daylight". It specifies the correlated colour temperature (CCT) in + Kelvin for generating the spectral distribution. + + Returns + ------- + :class:`int` + Illuminant custom temperature in Kelvin degrees. + + Notes + ----- + - For Blackbody illuminants, this temperature is used directly with + Planck's law to generate the spectral power distribution. + - For Daylight illuminants, this temperature is used to generate a + CIE D Series illuminant at the specified CCT. + """ + + return self._illuminant_custom_temperature + @metadata_property(metadata=MetadataConstants.ADDITIONAL_CAMERA_SETTINGS) def additional_camera_settings(self) -> str: """ @@ -638,9 +666,16 @@ def get_reference_colour_checker_samples(self) -> NDArrayFloat: Reference colour checker samples. """ + # Pass illuminant_custom_temperature if illuminant is "Blackbody" or "Daylight" + temperature = ( + self.illuminant_custom_temperature + if self.illuminant in ("Blackbody", "Daylight") + else None + ) + return generate_reference_colour_checker( get_sds_colour_checker(self.reference_colour_checker), - get_sds_illuminant(self.illuminant), + get_sds_illuminant(self.illuminant, temperature=temperature), ) def get_optimization_factory(self) -> callable: diff --git a/apps/idt_calculator_camera.py b/apps/idt_calculator_camera.py index 663cd7e..901a78f 100644 --- a/apps/idt_calculator_camera.py +++ b/apps/idt_calculator_camera.py @@ -1141,6 +1141,7 @@ def toggle_modal(n_clicks, is_open): State(_uid("encoding-transfer-function-field"), "value"), State(_uid("rgb-display-colourspace-select"), "value"), State(_uid("illuminant-select"), "value"), + State(_uid("cct-field"), "value"), State(_uid("illuminant-datatable"), "data"), State(_uid("chromatic-adaptation-transform-select"), "value"), State(_uid("optimisation-space-select"), "value"), @@ -1170,6 +1171,7 @@ def compute_idt_camera( encoding_transfer_function, RGB_display_colourspace, illuminant_name, + illuminant_custom_temperature, illuminant_data, chromatic_adaptation_transform, optimisation_space, @@ -1217,6 +1219,9 @@ def compute_idt_camera( *RGB* display colourspace. illuminant_name : str Name of the illuminant. + illuminant_custom_temperature : float + Custom correlated colour temperature (CCT) in Kelvin for Blackbody + or Daylight illuminants. illuminant_data : list List of wavelength dicts of illuminant data. chromatic_adaptation_transform : str @@ -1249,6 +1254,7 @@ def compute_idt_camera( 'Computing "IDT" with "%s" using parameters:\n' '\tRGB Display Colourspace : "%s"\n' '\tIlluminant Name : "%s"\n' + '\tIlluminant Custom Temperature : "%s"\n' '\tIlluminant Data : "%s"\n' '\tChromatic Adaptation Transform : "%s"\n' '\tOptimisation Space : "%s"\n' @@ -1261,6 +1267,7 @@ def compute_idt_camera( generator_name, RGB_display_colourspace, illuminant_name, + illuminant_custom_temperature, illuminant_data, chromatic_adaptation_transform, optimisation_space, @@ -1359,6 +1366,7 @@ def compute_idt_camera( encoding_colourspace=encoding_colourspace, encoding_transfer_function=encoding_transfer_function, illuminant=illuminant_name, + illuminant_custom_temperature=float(illuminant_custom_temperature), ) _IDT_GENERATOR_APPLICATION = IDTGeneratorApplication( generator_name, project_settings @@ -1368,12 +1376,16 @@ def compute_idt_camera( _HASH_IDT_ARCHIVE = hash_file(_PATH_UPLOADED_IDT_ARCHIVE) LOGGER.debug('"Archive hash: "%s"', _HASH_IDT_ARCHIVE) - if _CACHE_DATA_ARCHIVE_TO_SAMPLES.get(_HASH_IDT_ARCHIVE) is None: + # Include illuminant and temperature in cache key since they affect + # reference samples + cache_key = f"{_HASH_IDT_ARCHIVE}_{illuminant_name}_{illuminant_custom_temperature}" + + if _CACHE_DATA_ARCHIVE_TO_SAMPLES.get(cache_key) is None: _IDT_GENERATOR_APPLICATION.extract(_PATH_UPLOADED_IDT_ARCHIVE) os.remove(_PATH_UPLOADED_IDT_ARCHIVE) _IDT_GENERATOR_APPLICATION.validate_project_settings() _IDT_GENERATOR_APPLICATION.generator.sample() - _CACHE_DATA_ARCHIVE_TO_SAMPLES[_HASH_IDT_ARCHIVE] = ( + _CACHE_DATA_ARCHIVE_TO_SAMPLES[cache_key] = ( _IDT_GENERATOR_APPLICATION.project_settings.data, _IDT_GENERATOR_APPLICATION.generator.samples_analysis, _IDT_GENERATOR_APPLICATION.generator.baseline_exposure, @@ -1383,7 +1395,7 @@ def compute_idt_camera( _IDT_GENERATOR_APPLICATION.project_settings.data, _IDT_GENERATOR_APPLICATION.generator._samples_analysis, # noqa: SLF001 _IDT_GENERATOR_APPLICATION.generator._baseline_exposure, # noqa: SLF001 - ) = _CACHE_DATA_ARCHIVE_TO_SAMPLES[_HASH_IDT_ARCHIVE] + ) = _CACHE_DATA_ARCHIVE_TO_SAMPLES[cache_key] generator = _IDT_GENERATOR_APPLICATION.generator project_settings = _IDT_GENERATOR_APPLICATION.project_settings diff --git a/tests/resources/example_from_folder.json b/tests/resources/example_from_folder.json index 3a7500d..648948b 100644 --- a/tests/resources/example_from_folder.json +++ b/tests/resources/example_from_folder.json @@ -29,6 +29,7 @@ "cleanup": true, "reference_colour_checker": "ISO 17321-1", "illuminant": "D60", + "illuminant_custom_temperature": 6500, "file_type": "", "ev_weights": [], "optimization_kwargs": {} diff --git a/tests/resources/synthetic_001/test_project.json b/tests/resources/synthetic_001/test_project.json index 465d57b..76cf3d9 100644 --- a/tests/resources/synthetic_001/test_project.json +++ b/tests/resources/synthetic_001/test_project.json @@ -7,6 +7,7 @@ "camera_model": "", "iso": 800, "temperature": 6000, + "illuminant_custom_temperature": 6500, "additional_camera_settings": "", "lighting_setup_description": "", "debayering_platform": "", diff --git a/tests/test_application.py b/tests/test_application.py index 3bf677f..1b69121 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -102,6 +102,7 @@ def test__str__(self) -> None: Flatten_Clf : False Grey_Card_Reference : [ 0.18 0.18 0.18] Illuminant : D60 +Illuminant_Custom_Temperature : 6500 Illuminant_Interpolator : Linear Include_Exposure_Factor_In_Clf : False Include_White_Balance_In_Clf : False diff --git a/tests/test_common.py b/tests/test_common.py index 3218f7e..f111d16 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -13,6 +13,7 @@ find_clipped_exposures, find_similar_rows, generate_reference_colour_checker, + get_sds_illuminant, ) from tests.test_utils import TestIDTBase @@ -28,6 +29,7 @@ "TestCalculateCameraNpmAndPrimariesWp", "TestFindSimilarRows", "TestFindClippedExposures", + "TestGetSdsIlluminant", ] @@ -360,3 +362,87 @@ def test_scenario_6(self) -> None: ) expected_ev_keys = [-6.0, -5.0, -4.0, 4.0, 5.0, 6.0] self.assertEqual(removed_evs, expected_ev_keys) + + +class TestGetSdsIlluminant: + """ + Define :func:`aces.idt.core.common.get_sds_illuminant` definition unit tests + methods. + """ + + def test_get_sds_illuminant_valid_name(self) -> None: + """ + Test :func:`aces.idt.core.common.get_sds_illuminant` with a valid + illuminant name. + """ + + # Test with D60, the default ACES illuminant + illuminant = get_sds_illuminant("D60") + assert illuminant is not None + assert hasattr(illuminant, "wavelengths") + assert hasattr(illuminant, "values") + + def test_get_sds_illuminant_blackbody_without_temperature(self) -> None: + """ + Test :func:`aces.idt.core.common.get_sds_illuminant` with 'Blackbody' + illuminant name without providing a temperature parameter. + + This should raise a ValueError since temperature is required for + Blackbody illuminants. + """ + + import pytest + + with pytest.raises(ValueError, match="Temperature parameter is required"): + get_sds_illuminant("Blackbody") + + def test_get_sds_illuminant_blackbody_with_temperature(self) -> None: + """ + Test :func:`aces.idt.core.common.get_sds_illuminant` with 'Blackbody' + illuminant name and a valid temperature parameter. + + This test verifies that the Blackbody illuminant functionality works + correctly when a temperature is provided. + """ + + # Test with common blackbody temperatures + for temperature in [5500, 6500, 3200]: + illuminant = get_sds_illuminant("Blackbody", temperature=temperature) + assert illuminant is not None + assert hasattr(illuminant, "wavelengths") + assert hasattr(illuminant, "values") + # Verify the illuminant name includes the temperature + assert str(temperature) in illuminant.name + + def test_get_sds_illuminant_daylight_without_temperature(self) -> None: + """ + Test :func:`aces.idt.core.common.get_sds_illuminant` with 'Daylight' + illuminant name without providing a temperature parameter. + + This should raise a ValueError since temperature is required for + Daylight illuminants. + """ + + import pytest + + with pytest.raises(ValueError, match="Temperature parameter is required"): + get_sds_illuminant("Daylight") + + def test_get_sds_illuminant_daylight_with_temperature(self) -> None: + """ + Test :func:`aces.idt.core.common.get_sds_illuminant` with 'Daylight' + illuminant name and a valid temperature parameter. + + This test verifies that the Daylight (CIE D Series) illuminant + functionality works correctly when a temperature is provided. + """ + + # Test with common daylight temperatures + for temperature in [5500, 6500, 7500]: + illuminant = get_sds_illuminant("Daylight", temperature=temperature) + assert illuminant is not None + assert hasattr(illuminant, "wavelengths") + assert hasattr(illuminant, "values") + # Daylight illuminants should have spectral data + assert len(illuminant.wavelengths) > 0 + assert len(illuminant.values) > 0