Coverage for python / lsst / pipe / tasks / prettyPictureMaker / _functors / _exposure_fusion.py: 16%
55 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 08:45 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 08:45 +0000
1# This file is part of pipe_tasks.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22from __future__ import annotations
24__all__ = ("ExposureBracketer",)
26import numpy as np
27import cv2
29from lsst.pipe.tasks.prettyPictureMaker.types import FloatImagePlane
30from lsst.pex.config.configurableActions import ConfigurableAction
31from lsst.pex.config import ListField
33from .._localContrast import levelPadder, makeGaussianPyramid, makeLapPyramid
36def _fuseExposureLum(
37 images: list[FloatImagePlane], sigma: float = 0.1, maxLevel: int | None = 3
38) -> FloatImagePlane:
39 """Fuse multiple exposure images using Laplacian pyramid blending.
41 Parameters
42 ----------
43 images : `list` of `FloatImagePlane`
44 List of exposure images to fuse. Each image should be a
45 `FloatImagePlane` with pixel values typically in the range [0, 1].
46 sigma : `float`, optional
47 Controls the exposure weighting sensitivity. Lower values make the
48 weighting more sensitive to deviations from the reference exposure
49 (0.7). Default is 0.1.
50 maxLevel : `int` | `None`, optional
51 Maximum pyramid level to use for blending. If None, automatically
52 determined based on image dimensions. Default is 3.
54 Returns
55 -------
56 result : `FloatImagePlane`
57 Fused image with balanced exposure.
59 Raises
60 ------
61 ValueError
62 Raised if maxLevel is greater than the maximum allowed level based on
63 image dimensions.
65 Notes
66 -----
67 This function implements exposure fusion using a Laplacian pyramid
68 approach. The algorithm works as follows:
70 1. Compute exposure weights for each image based on how close pixel
71 values are to the reference exposure (0.7). Values above 1.0 receive
72 reduced weight.
73 2. Build Gaussian pyramids from the weights and Laplacian pyramids from
74 the padded images.
75 3. Blend the Laplacian pyramids using the Gaussian pyramid weights at
76 each level.
77 4. Reconstruct the final image from the blended pyramid.
79 The fusion preserves local contrast while balancing exposure across
80 the input images.
81 """
82 weights = np.zeros((len(images), *images[0].shape[:2]))
83 for i, image in enumerate(images):
84 exposure = np.exp(-((image[:, :] - 0.7) ** 2) / (2 * sigma))
85 # dont weight at all values greater than 1
86 exposure[image > 1] *= 0.5
88 weights[i, :, :] = exposure
89 norm = np.sum(weights, axis=0)
90 np.divide(weights, norm, out=weights)
92 # loop over each image again to build pyramids
93 g_pyr = []
94 l_pyr = []
95 maxImageLevel = int(np.min(np.log2(images[0].shape[:2])))
96 if maxLevel is None:
97 maxLevel = maxImageLevel
98 if maxImageLevel < maxLevel:
99 raise ValueError(
100 f"The supplied max level {maxLevel} is greater than the max of the image: {maxImageLevel}"
101 )
102 support = 1 << (maxLevel - 1)
103 padY_amounts = levelPadder(image.shape[0] + support, maxLevel)
104 padX_amounts = levelPadder(image.shape[1] + support, maxLevel)
105 for image, weight in zip(images, weights):
106 imagePadded = cv2.copyMakeBorder(
107 image, *(0, support), *(0, support), cv2.BORDER_REFLECT, None, None
108 ).astype(image.dtype)
109 weightPadded = cv2.copyMakeBorder(
110 weight, *(0, support), *(0, support), cv2.BORDER_REFLECT, None, None
111 ).astype(image.dtype)
113 g_pyr.append(list(makeGaussianPyramid(weightPadded, padY_amounts, padX_amounts, None)))
114 l_pyr.append(list(makeLapPyramid(imagePadded, padY_amounts, padX_amounts, None, None)))
116 # time to blend
117 blended = []
118 for level in range(len(padY_amounts)):
119 accumulate = np.zeros_like(l_pyr[0][level])
120 for img in range(len(g_pyr)):
121 accumulate[:, :] += l_pyr[img][level][:, :] * g_pyr[img][level]
122 blended.append(accumulate)
124 # time to reconstruct
125 output = blended[-1]
126 for i in range(-2, -1 * len(blended) - 1, -1):
127 upsampled = cv2.pyrUp(output)
128 upsampled = upsampled[
129 : upsampled.shape[0] - 2 * padY_amounts[i + 1], : upsampled.shape[1] - 2 * padX_amounts[i + 1]
130 ]
131 output = blended[i] + upsampled
132 return output[:-support, :-support]
135class ExposureBracketer(ConfigurableAction):
136 exposureBrackets = ListField[float](
137 doc=(
138 "Exposure scaling factors used in creating multiple exposures with different scalings which will "
139 "then be fused into a final image"
140 ),
141 optional=True,
142 default=[1.25, 1, 0.75],
143 )
145 def __call__(self, intensities: FloatImagePlane) -> FloatImagePlane:
146 """Apply exposure bracketing and fusion to an image.
148 Parameters
149 ----------
150 intensities : `FloatImagePlane`
151 Input image to process. This FloatImagePlane contains the pixel
153 Returns
154 -------
155 result : `FloatImagePlane`
156 Raise if processed image with exposure bracketing applied. If multiple
157 brackets are configured, returns the fused result. If a single
158 bracket or no brackets are configured, returns the scaled image.
160 Notes
161 -----
162 When multiple exposure brackets are configured (default [1.25, 1, 0.75]):
163 The input image is divided by each bracket factor to create a stack
164 of differently exposed images, which are then fused using
165 _fuseExposureLum to produce a final image with balanced exposure.
167 When a single bracket is configured: The input image is divided by
168 that bracket factor and returned directly without fusion.
170 When no brackets are configured (exposureBrackets is None): The
171 input image is returned unchanged.
172 """
173 if self.exposureBrackets is None:
174 return intensities
175 stack = []
176 for bracket in self.exposureBrackets:
177 stack.append(intensities / bracket)
179 if len(stack) == 1:
180 intensities = stack[0]
181 else:
182 intensities = _fuseExposureLum(stack)
184 return intensities