Coverage for python / lsst / pipe / tasks / prettyPictureMaker / _functors / _gamut_fixer.py: 18%

68 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__ = ("GamutFixer",) 

25 

26import skimage 

27import numpy as np 

28import logging 

29 

30 

31from lsst.cpputils import fixGamutOK 

32from lsst.pipe.tasks.prettyPictureMaker.types import LABImage, RGBImage 

33from lsst.pex.config.configurableActions import ConfigurableAction 

34from lsst.pex.config import ChoiceField, Field 

35from lsst.rubinoxide import rgb 

36from scipy.ndimage import label, find_objects, binary_dilation 

37 

38 

39def heal_gamut( 

40 lab_image: LABImage, 

41 mask: np.ndarray[tuple[int, int], np.bool], 

42 xyz_whitepoint: tuple[float, float], 

43 max_size: int = 500, 

44 dilation_iterations: int = 3, 

45) -> RGBImage: 

46 """Heal out-of-gamut regions in a Lab image using diffusion-based inpainting. 

47 

48 Parameters 

49 ---------- 

50 lab_image : `LABImage` 

51 A NxMx3 array in the Lab colorspace. Modified in-place by healing out-of-gamut regions. 

52 For each region, a copy is made before modification, then written back. 

53 mask : `numpy.ndarray` of `bool` 

54 A boolean mask indicating which pixels are out of gamut. 

55 xyz_whitepoint : `tuple` of `float`, `float` 

56 Sets the white point of the xyz colorspace in xy coordinates. 

57 max_size : `int`, optional 

58 Maximum size of regions to heal. Larger regions are skipped. Default is 500. 

59 dilation_iterations : `int`, optional 

60 Number of iterations for mask dilation. Default is 3. 

61 

62 Returns 

63 ------- 

64 result : `RGBImage` 

65 The healed image converted to RGB colorspace. 

66 

67 Raises 

68 ------ 

69 ValueError 

70 Raised if the shapes of lab_image and mask are incompatible. 

71 

72 Notes 

73 ----- 

74 The healing algorithm works by: 

75 

76 1. Labeling connected regions in the mask 

77 2. For each region smaller than max_size: 

78 

79 - Dilating the mask to create an annulus around the region 

80 - Computing average a,b color values from the annulus 

81 - Using rgb.inpaint_mask to interpolate the L, a, and b channels 

82 - Filling the masked region with the interpolated values 

83 

84 3. Regions larger than max_size are skipped. 

85 """ 

86 # Need to split all the regions of the mask 

87 labels = label(binary_dilation(mask, iterations=3))[0] 

88 places = find_objects(labels) 

89 # then grow the slices by 20% of the max size 

90 new_places = [] 

91 for place in places: 

92 size = int(3 * np.min([sl.stop - sl.start for sl in place])) 

93 new_y = slice(np.max((0, place[0].start - size)), np.min((mask.shape[0], place[0].stop + size)), None) 

94 new_x = slice(np.max((0, place[1].start - size)), np.min((mask.shape[1], place[1].stop + size)), None) 

95 new_places.append((new_y, new_x)) 

96 # for each slice, dilate the mask by n-pixels, and then diff the mask to make anulus 

97 # get the color data in the anulus 

98 # set the values in the mask for the a,b channels to the color data 

99 # use the rgb diffusion to fix the lum channel 

100 # assign the fixed image back into the 

101 for (place_y, place_x), (old_y, old_x) in zip(new_places, places): 

102 sub_labels = labels[old_y, old_x] 

103 if np.sum(sub_labels > 0) >= max_size: 

104 continue 

105 label_number = np.max(sub_labels) 

106 # copy to ensure contiguous array, this is faster than operating on view 

107 sub_mask = labels[place_y, place_x] == label_number 

108 sub_lab = np.copy(lab_image[place_y, place_x]) 

109 new_lum = rgb.inpaint_mask( 

110 np.ascontiguousarray(sub_lab[..., 0]), 

111 sub_mask, 

112 iterations=62, 

113 radius=26, 

114 radius_center=5, 

115 ) 

116 new_a = rgb.inpaint_mask( 

117 np.ascontiguousarray(sub_lab[..., 1]), 

118 sub_mask, 

119 iterations=62, 

120 radius=26, 

121 radius_center=5, 

122 ) 

123 new_b = rgb.inpaint_mask( 

124 np.ascontiguousarray(sub_lab[..., 2]), 

125 sub_mask, 

126 iterations=62, 

127 radius=26, 

128 radius_center=5, 

129 ) 

130 sub_lab[..., 0] = new_lum 

131 sub_lab[..., 1] = new_a 

132 sub_lab[..., 2] = new_b 

133 

134 lab_image[place_y, place_x] = sub_lab 

135 

136 return rgb.Oklab_to_RGB(np.ascontiguousarray(lab_image), xyz_whitepoint) 

137 

138 

139class GamutFixer(ConfigurableAction): 

140 """Fix out-of-gamut colors in images by remapping them back into the RGB gamut. 

141 

142 This class provides multiple methods for handling colors that fall outside 

143 the standard RGB color gamut, including inpainting, mapping functions, 

144 and diffusion-based healing. 

145 """ 

146 

147 gamutMethod = ChoiceField[str]( 

148 doc="If doRemapGamut is True this determines the method", 

149 default="inpaint", 

150 allowed={ 

151 "mapping": "Use a mapping function", 

152 "inpaint": "Use surrounding pixels to determine likely value", 

153 "heal": "Heal regions with reverse diffusion", 

154 "none": "Don't fix out of gamut colors", 

155 }, 

156 ) 

157 max_size = Field[int](doc="Maximum number of contiguous pixels that will be fixed", default=10000) 

158 

159 def __call__(self, Lab: LABImage, xyz_whitepoint: tuple[float, float]) -> RGBImage: 

160 """Remap colors that fall outside an RGB color gamut back into it. 

161 

162 When gamutMethod is 'heal', regions larger than the configured max_size 

163 are skipped. 

164 

165 Parameters 

166 ---------- 

167 Lab : `LABImage` 

168 A NxMx3 array in the Lab colorspace containing the image data. 

169 xyz_whitepoint : `tuple` of `float`, `float` 

170 Sets the white point of the xyz colorspace in xy coordinates. 

171 

172 Returns 

173 ------- 

174 result : `RGBImage` 

175 The image with out-of-gamut colors remapped to RGB colorspace. 

176 

177 Raises 

178 ------ 

179 ValueError 

180 Raise if the gamut method is not one of the supported methods. 

181 """ 

182 rgb_prime = rgb.Oklab_to_RGB(np.ascontiguousarray(Lab), xyz_whitepoint) 

183 

184 if self.gamutMethod == "none": 

185 return rgb_prime 

186 

187 # Determine if there are any out of bounds pixels 

188 outOfBounds = np.bitwise_or( 

189 np.bitwise_or(rgb_prime[:, :, 0] > 1, rgb_prime[:, :, 0] < 0), 

190 np.bitwise_or(rgb_prime[:, :, 1] > 1, rgb_prime[:, :, 1] < 0), 

191 ) 

192 outOfBounds = np.bitwise_or( 

193 outOfBounds, np.bitwise_or(rgb_prime[:, :, 2] > 1, rgb_prime[:, :, 2] < 0) 

194 ) 

195 

196 # If all pixels are in bounds, return immediately. 

197 if not np.any(outOfBounds): 

198 logging.info("There are no out of gamut pixels.") 

199 return rgb_prime 

200 

201 logging.info("There are out of gamut pixels, remapping colors") 

202 match self.gamutMethod: 

203 case "inpaint": 

204 logging.debug("Running inpaint") 

205 labels, num_features = label(outOfBounds) 

206 label_counts = np.bincount(labels.ravel())[0:] 

207 for index, amount in zip(range(num_features), label_counts): 

208 if amount > self.max_size: 

209 logging.debug(f"Eliminating {amount} pixels") 

210 # ignore areas that are too large 

211 outOfBounds[labels == index] = 0 

212 

213 results = skimage.restoration.inpaint_biharmonic(rgb_prime, outOfBounds, channel_axis=-1) 

214 case "mapping": 

215 results = fixGamutOK(Lab[outOfBounds]) 

216 Lab[outOfBounds] = results 

217 results = rgb.Oklab_to_RGB(np.ascontiguousarray(Lab), xyz_whitepoint) 

218 case "heal": 

219 results = heal_gamut(Lab, outOfBounds, xyz_whitepoint) 

220 case _: 

221 raise ValueError(f"gamut correction {self.gamutMethod} is not supported") 

222 

223 logging.debug(f"The total number of remapped pixels is: {np.sum(outOfBounds)}") 

224 return results