diff --git a/colour_hdri/generation/hdri.py b/colour_hdri/generation/hdri.py index cba3127..c25cc84 100644 --- a/colour_hdri/generation/hdri.py +++ b/colour_hdri/generation/hdri.py @@ -22,6 +22,7 @@ from __future__ import annotations +import gc import typing import numpy as np @@ -30,7 +31,7 @@ if typing.TYPE_CHECKING: from colour.hints import ArrayLike, Callable, NDArrayFloat -from colour.utilities import as_float_array, attest, tsplit, tstack, warning +from colour.utilities import as_float_array, attest, tsplit, tstack, warning, zeros from colour_hdri.exposure import average_luminance from colour_hdri.generation import weighting_function_Debevec1997 @@ -96,10 +97,17 @@ def image_stack_to_HDRI( attest(image_stack.is_valid(), "Image stack is invalid!") - image_c = np.zeros(image_stack[0].data.shape) # pyright: ignore - weight_c = np.zeros(image_stack[0].data.shape) # pyright: ignore + image_c = as_float_array([]) + weight_c = as_float_array([]) for i, image in enumerate(image_stack): + if image.data is None: + image.read_data(image_stack.cctf_decoding) + + if image_c.size == 0: + image_c = zeros(image.data.shape) # pyright: ignore + weight_c = zeros(image.data.shape) # pyright: ignore + L = 1 / average_luminance( image.metadata.f_number, # pyright: ignore image.metadata.exposure_time, # pyright: ignore @@ -114,28 +122,32 @@ def image_stack_to_HDRI( f"colourspace." ) - image_data = np.clip(image.data, EPSILON, 1) # pyright: ignore - weights = np.clip(weighting_function(image_data), EPSILON, 1) + data = np.clip(image.data, EPSILON, 1) # pyright: ignore + weights = np.clip(weighting_function(data), EPSILON, 1) + + # Invoking garbage collection to free memory. + image.data = None + gc.collect() if i == 0: - weights[image_data >= 0.5] = 1 + weights[data >= 0.5] = 1 if i == len(image_stack) - 1: - weights[image_data <= 0.5] = 1 + weights[data <= 0.5] = 1 if camera_response_functions is not None: camera_response_functions = as_float_array(camera_response_functions) samples = np.linspace(0, 1, camera_response_functions.shape[0]) - R, G, B = tsplit(image_data) + R, G, B = tsplit(data) R = np.interp(R, samples, camera_response_functions[..., 0]) G = np.interp(G, samples, camera_response_functions[..., 1]) B = np.interp(B, samples, camera_response_functions[..., 2]) - image_data = tstack([R, G, B]) + data = tstack([R, G, B]) - image_c += weights * image_data / L + image_c += weights * data / L weight_c += weights - del image_data, weights + del data, weights return image_c / weight_c diff --git a/colour_hdri/generation/tests/test_hdri.py b/colour_hdri/generation/tests/test_hdri.py index 74ac33a..1024d94 100644 --- a/colour_hdri/generation/tests/test_hdri.py +++ b/colour_hdri/generation/tests/test_hdri.py @@ -54,7 +54,6 @@ def test_image_stack_to_HDRI(self) -> None: image_stack = ImageStack.from_files(IMAGES_JPG) image_stack.data = RGB_COLOURSPACES["sRGB"].cctf_decoding(image_stack.data) - # Lower precision for unit tests under *travis-ci*. np.testing.assert_allclose( image_stack_to_HDRI(image_stack), np.load( @@ -66,7 +65,6 @@ def test_image_stack_to_HDRI(self) -> None: atol=0.0001, ) - # Lower precision for unit tests under *travis-ci*. image_stack = ImageStack.from_files(IMAGES_JPG) np.testing.assert_allclose( image_stack_to_HDRI( @@ -83,3 +81,20 @@ def test_image_stack_to_HDRI(self) -> None: ), atol=0.0001, ) + + image_stack = ImageStack.from_files(IMAGES_JPG, read_data=False) + np.testing.assert_allclose( + image_stack_to_HDRI( + image_stack, + camera_response_functions=( + camera_response_functions_Debevec1997(image_stack) + ), + ), + np.load( + os.path.join( + ROOT_RESOURCES_GENERATION, + "test_image_stack_to_hdri_crfs.npy", + ) + ), + atol=0.0001, + ) diff --git a/colour_hdri/network/graphs.py b/colour_hdri/network/graphs.py index 0a86c39..660c6bc 100644 --- a/colour_hdri/network/graphs.py +++ b/colour_hdri/network/graphs.py @@ -1013,8 +1013,8 @@ def process(self, **kwargs: Any) -> None: "output", list( zip( - self.nodes["ParallelForMultiprocess"].get_output("results"), # pyright: ignore - self.nodes["GraphPostMergeHDRI"].get_output("output"), # pyright: ignore + self.nodes["ParallelForMultiprocess"].get_output("results"), + self.nodes["GraphPostMergeHDRI"].get_output("output"), strict=False, ) ), diff --git a/colour_hdri/network/nodes.py b/colour_hdri/network/nodes.py index 8f732ca..e8bb793 100644 --- a/colour_hdri/network/nodes.py +++ b/colour_hdri/network/nodes.py @@ -1430,7 +1430,10 @@ def process(self, **kwargs: Any) -> None: # noqa: ARG002 return self.set_output( - "image_stack", ImageStack.from_files(paths, self.get_input("cctf_decoding")) + "image_stack", + ImageStack.from_files( + paths, self.get_input("cctf_decoding"), read_data=False + ), ) self.dirty = False @@ -1508,7 +1511,7 @@ def process(self, **kwargs: Any) -> None: # noqa: ARG002 self.log(f'"{image_path}" image does not exist!') return - median.append(np.median(read_image_OpenImageIO(image_path))) + median.append(np.median(read_image_OpenImageIO(image_path))) # pyright: ignore normalising_factor = np.median(median) diff --git a/colour_hdri/sampling/variance_minimization.py b/colour_hdri/sampling/variance_minimization.py index 90c4059..4152450 100644 --- a/colour_hdri/sampling/variance_minimization.py +++ b/colour_hdri/sampling/variance_minimization.py @@ -265,7 +265,7 @@ def light_probe_sampling_variance_minimization_Viriyothai2009( Light_Specification( (c / np.array(Y.shape))[::-1], np.sum(np.sum(light_probe_c, 0), 0), - c, # pyright: ignore + c, ) ) diff --git a/colour_hdri/utilities/image.py b/colour_hdri/utilities/image.py index bf8f289..839ab97 100644 --- a/colour_hdri/utilities/image.py +++ b/colour_hdri/utilities/image.py @@ -363,11 +363,40 @@ def read_metadata(self) -> Metadata: return metadata +def _luminance_average_key(image: Image) -> NDArrayFloat | None: + """Comparison key function.""" + + metadata = cast(Metadata, image.metadata) + + f_number = metadata.f_number + exposure_time = metadata.exposure_time + iso = metadata.iso + + if f_number is None or exposure_time is None or iso is None: + warning( + f'"{image.path}" exposure data is missing, average ' + f"luminance sorting is inapplicable!" + ) + return None + + return 1 / average_luminance(f_number, exposure_time, iso) + + class ImageStack(MutableSequence[Image]): """ Define a convenient image stack storing a sequence of images for HDRI / radiance images generation. + Parameters + ---------- + cctf_decoding + Decoding colour component transfer function (Decoding CCTF) or + electro-optical transfer function (EOTF / EOCF). + + Attributes + ---------- + - :attr:`~colour_hdri.ImageStack.cctf_decoding` + Methods ------- - :meth:`colour_hdri.ImageStack.__init__` @@ -385,8 +414,44 @@ class ImageStack(MutableSequence[Image]): - :meth:`colour_hdri.ImageStack.clear_metadata` """ - def __init__(self) -> None: + def __init__(self, cctf_decoding: Callable | None = None) -> None: self._data: List = [] + self._cctf_decoding: Callable | None = None + self.cctf_decoding = cctf_decoding + + @property + def cctf_decoding(self) -> Callable | None: + """ + Getter and setter property for the decoding colour component transfer + function (Decoding CCTF) / electro-optical transfer function + (EOTF). + + Parameters + ---------- + value + Decoding colour component transfer function (Decoding CCTF) / + electro-optical transfer function (EOTF). + + Returns + ------- + :py:data:`None` or Callable + Decoding colour component transfer function (Decoding CCTF) / + electro-optical transfer function (EOTF). + """ + + return self._cctf_decoding + + @cctf_decoding.setter + def cctf_decoding(self, value: Callable | None) -> None: + """Setter for the **self.cctf_decoding** property.""" + + if value is not None: + attest( + callable(value), + f'"cctf_decoding" property: "{value}" is not callable!', + ) + + self._cctf_decoding = value def __getitem__(self, index: int | slice) -> Image | List[Image]: # pyright: ignore """ @@ -461,6 +526,11 @@ def __getattr__(self, attribute: str) -> Any: return self.__dict__[attribute] except KeyError as exception: if hasattr(Image, attribute): + if attribute == "data": + for image in self: + if image.data is None: + image.read_data() + value = [getattr(image, attribute) for image in self] if attribute == "data": @@ -533,7 +603,10 @@ def sort(self, key: Callable | None = None) -> None: @staticmethod def from_files( - image_files: Sequence[str], cctf_decoding: Callable | None = None + image_files: Sequence[str], + cctf_decoding: Callable | None = None, + read_data: bool = True, + read_metadata: bool = True, ) -> ImageStack: """ Return a :class:`colour_hdri.ImageStack` instance from given image @@ -546,44 +619,37 @@ def from_files( cctf_decoding Decoding colour component transfer function (Decoding CCTF) or electro-optical transfer function (EOTF / EOCF). + read_data + Whether to read the image data. + read_metadata + Whether to read the image metadata. Returns ------- :class:`colour_hdri.ImageStack` """ - image_stack = ImageStack() + image_stack = ImageStack(cctf_decoding) for image_file in image_files: image = Image(image_file) - image.read_data(cctf_decoding) - image.read_metadata() - image_stack.append(image) - - def luminance_average_key(image: Image) -> NDArrayFloat | None: - """Comparison key function.""" - metadata = cast(Metadata, image.metadata) + if read_data: + image.read_data(image_stack.cctf_decoding) - f_number = metadata.f_number - exposure_time = metadata.exposure_time - iso = metadata.iso + if read_metadata: + image.read_metadata() - if f_number is None or exposure_time is None or iso is None: - warning( - f'"{image.path}" exposure data is missing, average ' - f"luminance sorting is inapplicable!" - ) - return None - return 1 / average_luminance(f_number, exposure_time, iso) + image_stack.append(image) - image_stack.sort(luminance_average_key) + if read_metadata: + image_stack.sort(_luminance_average_key) return image_stack def is_valid(self) -> bool: """ Return whether the image stack is valid, i.e., whether all the image - data and metadata is defined. + metadata is defined. Returns ------- @@ -591,7 +657,7 @@ def is_valid(self) -> bool: Whether the image stack is valid. """ - return all(not (image.data is None or image.metadata is None) for image in self) + return all(image.metadata is not None for image in self) def clear_data(self) -> None: """Clear the image stack image data.""" diff --git a/colour_hdri/utilities/tests/test_image.py b/colour_hdri/utilities/tests/test_image.py index 397c7ad..ebe8ba6 100644 --- a/colour_hdri/utilities/tests/test_image.py +++ b/colour_hdri/utilities/tests/test_image.py @@ -85,6 +85,14 @@ def setup_method(self) -> None: self._image_stack = ImageStack().from_files(self._test_jpg_images) + def test_required_attributes(self) -> None: + """Test the presence of required attributes.""" + + required_attributes = ("cctf_decoding",) + + for attribute in required_attributes: + assert attribute in dir(Image) + def test_required_methods(self) -> None: """Test the presence of required methods."""