LSST Applications g0f08755f38+c89d42e150,g1635faa6d4+b6cf076a36,g1653933729+a8ce1bb630,g1a0ca8cf93+4c08b13bf7,g28da252d5a+f33f8200ef,g29321ee8c0+0187be18b1,g2bbee38e9b+9634bc57db,g2bc492864f+9634bc57db,g2cdde0e794+c2c89b37c4,g3156d2b45e+41e33cbcdc,g347aa1857d+9634bc57db,g35bb328faa+a8ce1bb630,g3a166c0a6a+9634bc57db,g3e281a1b8c+9f2c4e2fc3,g414038480c+077ccc18e7,g41af890bb2+e740673f1a,g5fbc88fb19+17cd334064,g7642f7d749+c89d42e150,g781aacb6e4+a8ce1bb630,g80478fca09+f8b2ab54e1,g82479be7b0+e2bd23ab8b,g858d7b2824+c89d42e150,g9125e01d80+a8ce1bb630,g9726552aa6+10f999ec6a,ga5288a1d22+065360aec4,gacf8899fa4+9553554aa7,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gbd46683f8f+ac57cbb13d,gc28159a63d+9634bc57db,gcf0d15dbbd+e37acf7834,gda3e153d99+c89d42e150,gda6a2b7d83+e37acf7834,gdaeeff99f8+1711a396fd,ge2409df99d+cb1e6652d6,ge79ae78c31+9634bc57db,gf0baf85859+147a0692ba,gf3967379c6+02b11634a5,w.2024.45
LSST Data Management Base Package
Loading...
Searching...
No Matches
_colorMapper.py
Go to the documentation of this file.
1__all__ = ("mapUpperBounds", "latLum", "colorConstantSat", "lsstRGB", "mapUpperBounds")
2
3import logging
4import numpy as np
5import skimage
6import colour
7from scipy.special import erf
8from scipy.interpolate import pchip_interpolate
9
10from ._localContrast import localContrast
11from lsst.cpputils import fixGamutOK
12
13from numpy.typing import NDArray
14from typing import Callable, Mapping
15
16
18 values,
19 stretch: float = 400,
20 max: float = 85,
21 A: float = 1,
22 b0: float = 0.0,
23 minimum: float = 0,
24 floor: float = 0.00,
25 Q: float = 0.7,
26 doDenoise: bool = False,
27) -> NDArray:
28 """
29 Scale the input luminosity values to maximize the dynamic range visible.
30
31 Parameters
32 ----------
33 values : `NDArray`
34 The input image luminosity data of of.
35 stretch : `float`, optional
36 A parameter for the arcsinh function.
37 max : `float`, optional
38 Maximum value for intensity scaling.
39 A : `float`, optional
40 Linear scaling factor for the transformed intensities.
41 b0 : `float` optional
42 Offset term added to the arcsinh transformation.
43 minimum : `float`
44 Threshold below which pixel values are set to zero.
45 floor : `float`
46 A value added to each pixel in arcsinh transform, this ensures values in
47 the arcsinh transform will be no smaller than the supplied value.
48 Q : `float`
49 Another parameter for the arcsinh function and scaling factor for
50 softening.
51 doDenoise : `bool`
52 Denoise the image if desired.
53
54 Returns:
55 luminance : `NDArray`
56 The stretched luminosity data.
57 """
58
59 # De-noise the input image using wavelet de-noising.
60 if doDenoise:
61 values = skimage.restoration.denoise_wavelet(values)
62 values = abs(values)
63
64 # Scale values from 0-1 to 0-100 as various algorithm expect that range.
65 values *= 100
66
67 # Find what fraction of 100 the brightest pixel is. This is then used to
68 # re-normalize after all the non-linear scaling such that the brightest part
69 # of the image corresponds to the same absolute brightness.
70 maxRatio = values.max() / 100
71
72 # Calculate the slope for arcsinh transformation based on Q and stretch
73 # parameters.
74 slope = 0.1 * 100 / np.arcsinh(0.1 * Q)
75
76 # Apply the modified luminosity transformation using arcsinh function.
77 soften = Q / stretch
78 intensities = A * np.arcsinh((abs(values) * soften + floor) * slope) + b0
79
80 intensities /= intensities.max() / maxRatio
81 intensities *= 100
82
83 # Apply a specific tone cure to the luminocity defined by the below interpolant.
84 # This is calculated on the median of the image to smooth out pixel to pixel
85 # variations that are most likely due to noise. The sharpness of the image
86 # is preserved as we only apply this filter to the luminocity data.
87 # filtered = medfilt2d(intensities, 3)
88
89 control_points = (
90 [0,
91 0.5,
92 2,
93 5,
94 13.725490196078432,
95 25,
96 30,
97 55.294117647058826,
98 73.72549019607844,
99 98,
100 100],
101 [0,
102 10,
103 15,
104 20,
105 25.686274509803921,
106 40,
107 50,
108 80.35294117647058,
109 94.11764705882352,
110 98,
111 100]
112 )
113 scaled = pchip_interpolate(*control_points, intensities)
114 scaled[scaled == 0] = 1e-7
115 intensities = scaled
116 intensities[intensities > max] = max
117
118 # If values end up near 100 it's best to "bend" them a little to help
119 # the out of gamut fixer to appropriately handle luminosity and chroma
120 # issues. This is an empirically derived formula that returns
121 # scaling factors. For most of the domain it will return a value that
122 # is close to 1. Right near the upper part of the domain, it
123 # returns values slightly below 1 such that it scales a value of 100
124 # to a value near 95.
125 intensities *= (-1 * erf(-1 * (1 / intensities * 210))) ** 20
126 intensities[np.isnan(intensities)] = 0
127
128 # Reset the input array.
129 values /= 100
130
131 # Rescale the output array.
132 intensities /= 100
133
134 return intensities
135
136
138 img: NDArray, quant: float = 0.9, absMax: float | None = None, scaleBoundFactor: float | None = None
139) -> NDArray:
140 """Bound images to a range between zero and one.
141
142 Some images supplied aren't properly bounded with a maximum value of 1.
143 Either the images exceed the bounds of 1, or that no value seems to close,
144 implying indeterminate maximum value. This function determines an
145 appropriate maximum either by taking the value supplied in the absMax
146 argument or by scaling the maximum across all channels with the
147 supplied quant variable.
148
149 Parameters
150 ----------
151 img : `NDArray` like
152 Must have dimensions of y,x,3 where the channels are in RGB order
153 quant : `float`
154 Value to scale the maximum pixel value, in any channel, by to
155 determine the maximum flux allowable in all channels. Ignored
156 if absMax isn't None.
157 absMax : `float` or `None`
158 If this value is not None, use it as the maximum pixel value
159 for all channels, unless scaleBoundFactor is set in which case
160 it is only the maximum if the value determined from the image
161 and quant is larger than scaleBoundFactor times absMax. This is
162 to prevent individual frames in a mosaic from being scaled too
163 faint if absMax is too large for one region.
164 scaleBoundFactor : `float` or `None`
165 Factor used to compare absMax and the emperically determined
166 maximim. if emperical_max is less than scaleBoundFactor*absMax
167 then the emperical_max is used instead of absMax, even if it
168 is set. Set to None to skip this comparison.
169
170 Returns
171 -------
172 image : `NDArray`
173 The result of the remapping process
174 """
175 if np.max(img) == 1:
176 return img
177
178 r = img[:, :, 0]
179 g = img[:, :, 1]
180 b = img[:, :, 2]
181
182 turnover = np.max(np.vstack((r, g, b)))
183
184 # If scaleBoundFactor is not none and absMax is not None, check that the
185 # determined turnover is not less than the supplied absMax times the
186 # scaleBoundFactor. This fixes patches that may have max values much less
187 # than others for some processing reason.
188 if absMax is not None:
189 if scaleBoundFactor is not None and turnover < scaleBoundFactor * absMax:
190 scale = turnover * quant
191 else:
192 scale = absMax
193 else:
194 scale = turnover * quant
195
196 image = np.empty(img.shape)
197 image[:, :, 0] = r / scale
198 image[:, :, 1] = g / scale
199 image[:, :, 2] = b / scale
200
201 # Clip values that exceed the bound to ensure all values are within [0, absMax]
202 np.clip(image, 0, 1, out=image)
203
204 return image
205
206
208 oldLum: NDArray, luminance: NDArray, a: NDArray, b: NDArray, saturation: float = 1, maxChroma: float = 50
209) -> tuple[NDArray, NDArray]:
210 """
211 Adjusts the color saturation while keeping the hue constant.
212
213 This function adjusts the chromaticity (a, b) of colors to maintain a
214 consistent saturation level, based on their original luminance. It uses
215 the CIELAB color space representation and the `luminance` is the new target
216 luminance for all colors.
217
218 Parameters
219 ----------
220 oldLum : `NDArray`
221 Luminance values of the original colors.
222 luminance : `NDArray`
223 Target luminance values for the transformed colors.
224 a : `NDArray`
225 Chromaticity parameter 'a' corresponding to green-red axis in CIELAB.
226 b : `NDArray`
227 Chromaticity parameter 'b' corresponding to blue-yellow axis in CIELAB.
228 saturation : `float`, optional
229 Desired saturation level for the output colors. Defaults to 1.
230 maxChroma : `float`, optional
231 Maximum chroma value allowed for any color. Defaults to 50.
232
233 Returns
234 -------
235 new_a : NDArray
236 New a values representing the adjusted chromaticity.
237 new_b : NDArray
238 New b values representing the adjusted chromaticity.
239 """
240 # Calculate the square of the chroma, which is the distance from origin in
241 # the a-b plane.
242 chroma1_2 = a**2 + b**2
243 chroma1 = np.sqrt(chroma1_2)
244
245 # Calculate the hue angle, taking the absolute value to ensure non-negative
246 # angle representation.
247 chromaMask = chroma1 == 0
248 chroma1[chromaMask] = 1
249 sinHue = b / chroma1
250 cosHue = a / chroma1
251 sinHue[chromaMask] = 0
252 cosHue[chromaMask] = 0
253
254 # Compute a divisor for saturation calculation, adding 1 to avoid division
255 # by zero.
256 div = chroma1_2 + oldLum**2
257 div[div <= 0] = 1
258
259 # Calculate the square of the new chroma based on desired saturation
260 sat_original_2 = chroma1_2 / div
261 chroma2_2 = saturation * sat_original_2 * luminance**2 / (1 - sat_original_2)
262
263 # Cap the chroma to avoid excessive values that are visually unrealistic
264 chroma2_2[chroma2_2 > maxChroma**2] = maxChroma**2
265
266 # Compute new 'a' values using the square root of adjusted chroma and
267 # considering hue direction.
268 chroma2 = np.sqrt(chroma2_2)
269 new_a = chroma2 * cosHue
270
271 # Compute new 'b' values by scaling 'new_a' with the tangent of the sin
272 # angle.
273 new_b = chroma2 * sinHue
274
275 return new_a, new_b
276
277
279 Lab: NDArray,
280 colourspace: str = "Display P3",
281) -> None:
282 """Remap colors that fall outside an RGB color gamut back into it.
283
284 This function modifies the input Lab array in-place for memory reasons.
285
286 Parameters
287 ----------
288 Lab : `NDArray`
289 A NxMX3 array that contains data in the Lab colorspace.
290 colourspace : `str`
291 The target colourspace to map outlying pixels into. This must
292 correspond to an RGB colourspace understood by the colour-science
293 python package
294 """
295 # Convert back into the CIE XYZ colourspace.
296 xyz_prime = colour.Oklab_to_XYZ(Lab)
297
298 # And then back to the specified RGB colourspace.
299 rgb_prime = colour.XYZ_to_RGB(xyz_prime, colourspace=colourspace)
300
301 # Determine if there are any out of bounds pixels
302 outOfBounds = np.bitwise_or(
303 np.bitwise_or(rgb_prime[:, :, 0] > 1, rgb_prime[:, :, 1] > 1), rgb_prime[:, :, 2] > 1
304 )
305
306 # If all pixels are in bounds, return immediately.
307 if not np.any(outOfBounds):
308 logging.info("There are no out of gamut pixels.")
309 return
310
311 logging.info("There are out of gamut pixels, remapping colors")
312 results = fixGamutOK(Lab[outOfBounds])
313 logging.debug(f"The total number of remapped pixels is: {np.sum(outOfBounds)}")
314 Lab[outOfBounds] = results
315 return
316
317
319 rArray: NDArray,
320 gArray: NDArray,
321 bArray: NDArray,
322 doLocalContrast: bool = True,
323 scaleLum: Callable[..., NDArray] | None = latLum,
324 scaleLumKWargs: Mapping | None = None,
325 scaleColor: Callable[..., tuple[NDArray, NDArray]] | None = colorConstantSat,
326 scaleColorKWargs: Mapping | None = None,
327 remapBounds: Callable | None = mapUpperBounds,
328 remapBoundsKwargs: Mapping | None = None,
329 cieWhitePoint: tuple[float, float] = (0.28, 0.28),
330 sigma: float = 30,
331 highlights: float = -0.9,
332 shadows: float = 0.5,
333 clarity: float = 0.1,
334 maxLevel: int | None = None,
335 psf: NDArray | None = None,
336) -> NDArray:
337 """Enhance the lightness and color preserving hue using perceptual methods.
338
339 Parameters
340 ----------
341 rArray : `NDArray`
342 The array used as the red channel
343 gArray : `NDArray`
344 The array used as the green channel
345 bArray : `NDArray`
346 The array used as the blue channel
347 doLocalContrast: `bool`
348 Apply local contrast enhancement algorithms to the luminance channel.
349 scaleLum : `Callable` or `None`
350 This is a callable that's passed the luminance values as well as
351 any defined scaleLumKWargs, and should return a scaled luminance array
352 the same shape as the input. Set to None for no scaling.
353 scaleLumKWargs : `Mapping` or `None`
354 Key word arguments that passed to the scaleLum function.
355 scaleColor : `Callable` or `None`
356 This is a callable that's passed the original luminance, the remapped
357 luminance values, the a values for each pixel, and the b values for
358 each pixel. The function is also passed any parameters defined in
359 scaleColorKWargs. This function is responsible for scaling chroma
360 values. This should return two arrays corresponding to the scaled a and
361 b values. Set to None for no modification.
362 scaleColorKWargs : `Mapping` or `None`
363 Key word arguments passed to the scaleColor function.
364 remapBounds : `Callable` or `None`
365 This is a callable that should remaps the input arrays such that each of
366 them fall within a zero to one range. This callable is given the
367 initial image as well as any parameters defined in the remapBoundsKwargs
368 parameter. Set to None for no remapping.
369 remapBoundsKwargs : `Mapping` or None
370 cieWhitePoint : `tuple` of `float`, `float`
371 This is the white point of the input of the input arrays in CIE XY
372 coordinates. Altering this affects the relative balance of colors
373 in the input image, and therefore also the output image.
374 sigma : `float`
375 The scale over which local contrast considers edges real and not noise.
376 highlights : `float`
377 A parameter that controls how local contrast enhances or reduces
378 highlights. Contrary to intuition, negative values increase highlights.
379 shadows : `float`
380 A parameter that controls how local contrast will deepen or reduce
381 shadows.
382 clarity : `float`
383 A parameter that relates to the local contrast between highlights and
384 shadow.
385 maxLevel : `int` or `None`
386 The maximum number of image pyramid levels to enhance the local contrast
387 over. Each level has a spatial scale of roughly 2^(level) pixels.
388 psf : `NDArray` or `None`
389 If this parameter is an image of a PSF kernel the luminance channel is
390 deconvolved with it. Set to None to skip deconvolution.
391
392 Returns
393 -------
394 result : `NDArray`
395 The brightness and color calibrated image.
396
397 Raises
398 ------
399 ValueError
400 Raised if the shapes of the input array don't match
401 """
402 if rArray.shape != gArray.shape or rArray.shape != bArray.shape:
403 raise ValueError("The shapes of all the input arrays must be the same")
404
405 # Construct a new image array in the proper byte ordering.
406 img = np.empty((*rArray.shape, 3))
407 img[:, :, 0] = rArray
408 img[:, :, 1] = gArray
409 img[:, :, 2] = bArray
410
411 # The image might contain pixels less than zero due to noise. The options
412 # for handling this are to either set them to zero, which creates weird
413 # holes in the scaled output image, throw an exception and have the user
414 # handle it, which they might not have to proper understanding to, or take
415 # the abs. Here the code uses the later, though this may have the effect of
416 # raising the floor of the image a bit, this isn't really a bad thing as
417 # it makes the background a grey color rather that pitch black which
418 # can cause perceptual contrast issues.
419 img = abs(img)
420
421 # If there are nan's in the image there is no real option other than to
422 # set them to zero or throw.
423 img[np.isnan(img)] = 0
424
425 # remap the bounds of the image if there is a function to do so.
426 if remapBounds is not None:
427 img = remapBounds(img, **(remapBoundsKwargs or {}))
428
429 # Convert the starting image into the OK L*a*b* color space.
430 # https://en.wikipedia.org/wiki/Oklab_color_space
431
432 Lab = colour.XYZ_to_Oklab(
433 colour.RGB_to_XYZ(
434 img,
435 colourspace="CIE RGB",
436 illuminant=np.array(cieWhitePoint),
437 chromatic_adaptation_transform="bradford",
438 )
439 )
440
441 # make an alias for the luminance channel.
442 lum = Lab[:, :, 0]
443 # Determine the ratio of the maximum of this image to the possible maximum
444 # which is 1 in the OKLab colorspace model.
445 maxRatio = lum.max()
446
447 # Enhance the contrast of the input image before mapping.
448 if doLocalContrast:
449 newLum = localContrast(lum, sigma, highlights, shadows, clarity=clarity, maxLevel=maxLevel)
450 # Sometimes at the faint end the shadows can be driven a bit negative.
451 # Take the abs to avoid black clipping issues.
452 newLum = abs(newLum)
453 # because contrast enhancement can change the maximum value, linearly
454 # rescale the image such that the maximum is at the same ratio as the
455 # original maximum.
456 newLum /= newLum.max() / maxRatio
457 else:
458 newLum = lum
459
460 # Scale the luminance channel if possible.
461 if scaleLum is not None:
462 lRemapped = scaleLum(newLum, **(scaleLumKWargs or {}))
463 else:
464 lRemapped = newLum
465
466 if psf is not None:
467 # This algorithm requires values between 0 and 1, but luminance is
468 # between 0 and 100, so scale the input to and output of the
469 # deconvolution algorithm.
470 lRemapped = skimage.restoration.richardson_lucy(lRemapped, psf=psf, clip=False, num_iter=2)
471
472 if scaleColor is not None:
473 new_a, new_b = scaleColor(lum, lRemapped, Lab[:, :, 1], Lab[:, :, 2], **(scaleColorKWargs or {}))
474 # Replace the color information with the new scaled color information.
475 Lab[:, :, 1] = new_a
476 Lab[:, :, 2] = new_b
477
478 # Replace the luminance information with the new scaled luminance information
479 Lab[:, :, 0] = lRemapped
480
481 # Fix any colors that fall outside of the RGB colour gamut.
483
484 # Transform back to RGB coordinates
485 result = colour.XYZ_to_RGB(colour.Oklab_to_XYZ(Lab), colourspace="Display P3")
486
487 # explicitly cut at 1 even though the mapping above was to map colors
488 # appropriately because the Z matrix transform can produce values above
489 # 1 and is a known feature of the transform.
490 result[result > 1] = 1
491 result[result < 0] = 0
492 return result
None fixOutOfGamutColors(NDArray Lab, str colourspace="Display P3")
NDArray latLum(values, float stretch=400, float max=85, float A=1, float b0=0.0, float minimum=0, float floor=0.00, float Q=0.7, bool doDenoise=False)
NDArray mapUpperBounds(NDArray img, float quant=0.9, float|None absMax=None, float|None scaleBoundFactor=None)
tuple[NDArray, NDArray] colorConstantSat(NDArray oldLum, NDArray luminance, NDArray a, NDArray b, float saturation=1, float maxChroma=50)
NDArray lsstRGB(NDArray rArray, NDArray gArray, NDArray bArray, bool doLocalContrast=True, Callable[..., NDArray]|None scaleLum=latLum, Mapping|None scaleLumKWargs=None, Callable[..., tuple[NDArray, NDArray]]|None scaleColor=colorConstantSat, Mapping|None scaleColorKWargs=None, Callable|None remapBounds=mapUpperBounds, Mapping|None remapBoundsKwargs=None, tuple[float, float] cieWhitePoint=(0.28, 0.28), float sigma=30, float highlights=-0.9, float shadows=0.5, float clarity=0.1, int|None maxLevel=None, NDArray|None psf=None)