From 8c958edca4f58177a8490f2314b6d7a4d60c3e1a Mon Sep 17 00:00:00 2001 From: Adam Davis Date: Mon, 13 Oct 2025 19:04:26 +0100 Subject: [PATCH] Fix a bug and expose functionality where the ui exposes blackbody and daylight with custom temperatures These need to be passed through and handled in the backend to ensure we get the right illuminant We also have to update the cache_Key to invalidate the cache if the lluminant changes other wise we run with the wrong settings --- aces/idt/core/common.py | 54 +++++++++++- aces/idt/core/constants.py | 10 +++ aces/idt/framework/project_settings.py | 37 +++++++- apps/idt_calculator_camera.py | 18 +++- tests/resources/example_from_folder.json | 1 + .../resources/synthetic_001/test_project.json | 1 + tests/test_application.py | 1 + tests/test_common.py | 86 +++++++++++++++++++ 8 files changed, 202 insertions(+), 6 deletions(-) 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