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-22 09:17 +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/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("ExposureBracketer",) 

25 

26import numpy as np 

27import cv2 

28 

29from lsst.pipe.tasks.prettyPictureMaker.types import FloatImagePlane 

30from lsst.pex.config.configurableActions import ConfigurableAction 

31from lsst.pex.config import ListField 

32 

33from .._localContrast import levelPadder, makeGaussianPyramid, makeLapPyramid 

34 

35 

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. 

40 

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. 

53 

54 Returns 

55 ------- 

56 result : `FloatImagePlane` 

57 Fused image with balanced exposure. 

58 

59 Raises 

60 ------ 

61 ValueError 

62 Raised if maxLevel is greater than the maximum allowed level based on 

63 image dimensions. 

64 

65 Notes 

66 ----- 

67 This function implements exposure fusion using a Laplacian pyramid 

68 approach. The algorithm works as follows: 

69 

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. 

78 

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 

87 

88 weights[i, :, :] = exposure 

89 norm = np.sum(weights, axis=0) 

90 np.divide(weights, norm, out=weights) 

91 

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) 

112 

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))) 

115 

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) 

123 

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] 

133 

134 

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 ) 

144 

145 def __call__(self, intensities: FloatImagePlane) -> FloatImagePlane: 

146 """Apply exposure bracketing and fusion to an image. 

147 

148 Parameters 

149 ---------- 

150 intensities : `FloatImagePlane` 

151 Input image to process. This FloatImagePlane contains the pixel 

152 

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. 

159 

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. 

166 

167 When a single bracket is configured: The input image is divided by 

168 that bracket factor and returned directly without fusion. 

169 

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) 

178 

179 if len(stack) == 1: 

180 intensities = stack[0] 

181 else: 

182 intensities = _fuseExposureLum(stack) 

183 

184 return intensities