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
_localContrast.py
Go to the documentation of this file.
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__ = ("localContrast",)
25
26import numpy as np
27from numpy.typing import NDArray
28import cv2
29from numba import njit, prange
30from numba.typed.typedlist import List
31from collections.abc import Sequence
32from itertools import cycle
33
34
35@njit(fastmath=True, parallel=True, error_model="numpy", nogil=True)
36def r(
37 img: NDArray, out: NDArray, g: float, sigma: float, shadows: float, highlights: float, clarity: float
38) -> NDArray:
39 """
40 Apply a post-processing effect to an image using the specified parameters.
41
42 Parameters:
43 img : `NDArray`
44 The input image array of shape (n_images, height, width).
45 out : `NDArray`
46 The output image array where the result will be stored. Should have the same shape as `img`.
47 g : `float`
48 A parameter for gamma correction.
49 sigma : `float`
50 Parameter that defines the scale at which a change should be considered an edge.
51 shadows : `float`
52 Shadow adjustment factor.
53 highlights `float`
54 Highlight adjustment factor. Negative values INCREASE highlights.
55 clarity : `float`
56 Clarity adjustment factor.
57
58 Returns:
59 result : `NDArray`
60 The processed image array with the same shape as `out`.
61 """
62
63 h_s = (highlights, shadows)
64
65 # Iterate over each pixel in the image
66 for i in prange(out.shape[0]):
67 # Get the current image slice
68 imgI = img[i]
69 # Get the corresponding output slice
70 outI = out[i]
71
72 # Iterate over each pixel in the image
73 for j in prange(out.shape[1]):
74 # Calculate the contrast adjusted by gamma correction
75 c = imgI[j] - g
76 # Determine the sign of the contrast adjustment
77 s = np.sign(c)
78
79 # Compute the transformation term t based on the signed contrast
80 t = s * c / (2.0 * sigma)
81 # Clamp t to be within [0, 1]
82 t = max(0, min(t, 1))
83
84 t2 = t * t
85 # Complement of t
86 mt = 1.0 - t
87
88 # Determine the index based on the sign of c (either 0 or 1)
89 index = np.uint8(np.bool_(1 + s))
90
91 # Compute the final pixel value using the transformation and
92 # additional terms for shadows/highlights and clarity
93 val = g + s * sigma * 2 * mt * t + t2 * (s * sigma + s * sigma * h_s[index])
94 val = val + clarity * c * np.exp(-(c * c) / (2.0 * sigma * sigma / 3.0))
95
96 # Assign the computed value to the output image
97 outI[j] = val
98
99 return out
100
101
103 img: NDArray, padY: list[int], padX: list[int], out: List[NDArray] | None
104) -> Sequence[NDArray]:
105 """
106 Create a Gaussian Pyramid from an input image.
107
108 Parameters:
109 img : `NDArray`
110 The input image, which will be processed to create the pyramid.
111 padY : `list` of `int`
112 List containing padding sizes along the Y-axis for each level of the pyramid.
113 padX : `list` of `int`
114 List containing padding sizes along the X-axis for each level of the pyramid.
115 out `numba.typed.typedlist.List` of `NDarray` or `None`
116 Optional list to store the output images of the pyramid levels.
117 If None, a new list is created.
118
119 Returns:
120 pyramid : `Sequence` of `NDArray`
121 A sequence of images representing the Gaussian Pyramid.
122
123 Notes:
124 - The function creates a padded version of the input image and then
125 reduces its size using `cv2.pyrDown` to generate each level of the
126 pyramid.
127 - If 'out' is provided, it will be used to store the pyramid levels;
128 otherwise, a new list is dynamically created.
129 - Padding is applied only if specified by non-zero values in `padY` and
130 `padX`.
131 """
132 # Initialize the output pyramid list if not provided
133 if out is None:
134 pyramid = List()
135 else:
136 pyramid = out
137
138 # Apply padding only if needed, ensuring the type matches the input image
139 if padY[0] or padX[0]:
140 paddedImage = cv2.copyMakeBorder(
141 img, *(0, padY[0]), *(0, padX[0]), cv2.BORDER_REPLICATE, None if out is None else pyramid[0], None
142 ).astype(img.dtype)
143 else:
144 paddedImage = img
145
146 # Store the first level of the pyramid (padded image)
147 if out is None:
148 pyramid.append(paddedImage)
149 else:
150 # This might not be sound all the time, copy might be needed!
151 # Update the first level in the provided list
152 pyramid[0] = paddedImage
153
154 # Generate each subsequent level of the Gaussian Pyramid
155 for i in range(1, len(padY)):
156 if padY[i] or padX[i]:
157 paddedImage = cv2.copyMakeBorder(
158 paddedImage, *(0, padY[i]), *(0, padX[i]), cv2.BORDER_REPLICATE, None, None
159 ).astype(img.dtype)
160 # Downsample the image
161 paddedImage = cv2.pyrDown(paddedImage, None if out is None else pyramid[i])
162
163 # Append to the list if not provided externally
164 if out is None:
165 pyramid.append(paddedImage)
166 return pyramid
167
168
170 img: NDArray,
171 padY: list[int],
172 padX: list[int],
173 gaussOut: List[NDArray] | None,
174 lapOut: List[NDArray] | None,
175 upscratch: List[NDArray] | None = None,
176) -> Sequence[NDArray]:
177 """
178 Create a Laplacian pyramid from the input image.
179
180 This function constructs a Laplacian pyramid from the input image. It first
181 generates a Gaussian pyramid and then, for each level (except the last),
182 subtracts the upsampled version of the next lower level from the current
183 level to obtain the Laplacian levels. If `lapOut` is None, it creates a
184 new list to store the Laplacian pyramid; otherwise, it uses the provided
185 `lapOut`.
186
187 Parameters
188 ----------
189 img : `NDArray`
190 The input image as a numpy array.
191 padY : `list` of `int`
192 List of padding sizes for rows (vertical padding).
193 padX : `list` of `int`
194 List of padding sizes for columns (horizontal padding).
195 gaussOut : `numba.typed.typedlist.List` of `NDArray` or None
196 Preallocated storage for the output of the Gaussian pyramid function.
197 If `None` new storage is allocated.
198 lapOut : `numba.typed.typedlist.List` of `NDArray` or None
199 Preallocated for the output Laplacian pyramid. If None, a new
200 `numba.typed.typedlist.List` is created.
201 upscratch : `numba.typed.typedlist.List` of `NDarray`, optional
202 List to store intermediate results of pyramids (default is None).
203
204 Returns
205 -------
206 results : `Sequence` of `NDArray`
207 The Laplacian pyramid as a sequence of numpy arrays.
208
209 """
210 pyramid = makeGaussianPyramid(img, padY, padX, gaussOut)
211 if lapOut is None:
212 lapPyramid = List()
213 else:
214 lapPyramid = lapOut
215 for i in range(len(pyramid) - 1):
216 upsampled = cv2.pyrUp(pyramid[i + 1], None if upscratch is None else upscratch[i + 1])
217 if padY[i + 1] or padX[i + 1]:
218 upsampled = upsampled[
219 : upsampled.shape[0] - 2 * padY[i + 1], : upsampled.shape[1] - 2 * padX[i + 1]
220 ]
221 if lapOut is None:
222 lapPyramid.append(pyramid[i] - upsampled)
223 else:
224 cv2.subtract(pyramid[i], upsampled, dst=lapPyramid[i])
225 if lapOut is None:
226 lapPyramid.append(pyramid[-1])
227 else:
228 lapPyramid[-1][:, :] = pyramid[-1]
229 return lapPyramid
230
231
232@njit(fastmath=True, parallel=True, error_model="numpy", nogil=True)
234 out: List[NDArray],
235 pyramid: List[NDArray],
236 gamma: NDArray,
237 pyramidVectorsBottom: List[NDArray],
238 pyramidVectorsTop: List[NDArray],
239):
240 """
241 Computes the output by interpolating between basis vectors at each pixel in
242 a Gaussian pyramid.
243
244 The function iterates over each pixel in the Gaussian pyramids
245 and interpolates between the corresponding basis vectors from
246 `pyramidVectorsBottom` and `pyramidVectorsTop`. If a pixel value is outside
247 the range defined by gamma, it skips interpolation.
248
249 Parameters:
250 -----------
251 out : `numba.typed.typedlist.List` of `np.ndarray`
252 A list of numpy arrays representing the output image pyramids.
253 pyramid : `numba.typed.typedlist.List` of `np.ndarray`
254 A list of numpy arrays representing the Gaussian pyramids.
255 gamma : `np.ndarray`
256 A numpy array containing the range for pixel values to be considered in
257 the interpolation.
258 pyramidVectorsBottom : `numba.typed.typedlist.List` of `np.ndarray`
259 A list of numpy arrays representing the basis vectors at the bottom
260 level of each pyramid layer.
261 pyramidVectorsTop : `numba.typed.typedlist.List` of `np.ndarray`
262 A list of numpy arrays representing the basis vectors at the top level
263 of each pyramid layer.
264
265 """
266 # loop over each pixel in the gaussian pyramid
267 # gammaDiff = gamma[1] - gamma[0]
268 for level in prange(0, len(pyramid) - 1):
269 yshape = pyramid[level].shape[0]
270 xshape = pyramid[level].shape[1]
271 plevel = pyramid[level]
272 outlevel = out[level]
273 basisBottom = pyramidVectorsBottom[level]
274 basisTop = pyramidVectorsTop[level]
275 for y in prange(yshape):
276 plevelY = plevel[y]
277 outLevelY = outlevel[y]
278 basisBottomY = basisBottom[y]
279 basisTopY = basisTop[y]
280 for x in prange(xshape):
281 val = plevelY[x]
282 if not (val >= gamma[0] and val <= gamma[1]):
283 continue
284 a = (plevelY[x] - gamma[0]) / (gamma[1] - gamma[0])
285 outLevelY[x] = (1 - a) * basisBottomY[x] + a * basisTopY[x]
286
287
288def levelPadder(numb: int, levels: int) -> list[int]:
289 """Determine if each level of transform will need to be padded by
290 one to make the level divisible by two.
291
292 Parameters
293 ----------
294 numb : int
295 The size of the input dimension
296 levels : int
297 The number of times the dimensions will be reduced by a factor of two
298
299 Returns
300 -------
301 padds : list of int
302 A list where the entries are either zero or one depending on if the
303 size will need padded to be a power of two.
304
305 """
306 pads = []
307 if numb % 2 != 0:
308 pads.append(1)
309 numb += 1
310 else:
311 pads.append(0)
312 for _ in range(levels):
313 numb /= 2
314 if numb % 2 != 0:
315 pads.append(1)
316 numb += 1
317 else:
318 pads.append(0)
319 return pads
320
321
323 image: NDArray,
324 sigma: float,
325 highlights: float = -0.9,
326 shadows: float = 0.4,
327 clarity: float = 0.15,
328 maxLevel: int | None = None,
329 numGamma: int = 20,
330) -> NDArray:
331 """Enhance the local contrast of an input image.
332
333 Parameters
334 ----------
335 image : `NDArray`
336 Two dimensional numpy array representing the image to have contrast
337 increased.
338 sigma : `float`
339 The scale over which edges are considered real and not noise.
340 highlights : `float`
341 A parameter that controls how highlights are enhansed or reduced,
342 contrary to intuition, negative values increase highlights.
343 shadows : `float`
344 A parameter that controls how shadows are deepened.
345 clarity : `float`
346 A parameter that relates to the contrast between highlights and
347 shadow.
348 maxLevel : `int` or `None`
349 The maximum number of image pyramid levels to enhanse the contrast over.
350 Each level has a spatial scale of roughly 2^(level) pixles.
351 numGamma : `int`
352 This is an optimization parameter. This algorithm divides up contrast
353 space into a certain numbers over which the expensive computation
354 is done. Contrast values in the image which fall between two of these
355 values are interpolated to get the outcome. The higher the numGamma,
356 the smoother the image is post contrast enhancement, though above
357 some number there is no decerable difference.
358
359 Returns
360 -------
361 image : `NDArray`
362 Two dimensional numpy array of the input image with increased local
363 contrast.
364
365 Raises
366 ------
367 ValueError
368 Raised if the max level to enhance to is greater than the image
369 supports.
370
371 Notes
372 -----
373 This function, and it's supporting functions, spiritually implement the
374 algorithm outlined at
375 https://people.csail.mit.edu/sparis/publi/2011/siggraph/
376 titled "Local Laplacian Filters: Edge-aware Image Processing with Laplacian
377 Pyramid". This is not a 1:1 implementation, it's optimized for the
378 python language and runtime performance. Most notably it transforms only
379 certain levels and linearly interpolates to find other values. This
380 implementation is inspired by the ony done in the darktable image editor:
381 https://www.darktable.org/2017/11/local-laplacian-pyramids/. None of the
382 code is in common, nor is the implementation 1:1, but reading the original
383 paper and the darktable implementation gives more info about this function.
384 Specifically some variable names follow the paper/other implementation,
385 and may be confusing when viewed without that context.
386
387 """
388 # ensure the supplied values are floats, and not ints
389 highlights = float(highlights)
390 shadows = float(shadows)
391 clarity = float(clarity)
392
393 # Determine the maximum level over which the image will be inhanced
394 # and the amount of padding that will be needed to be added to the
395 # image.
396 maxImageLevel = int(np.min(np.log2(image.shape)))
397 if maxLevel is None:
398 maxLevel = maxImageLevel
399 if maxImageLevel < maxLevel:
400 raise ValueError(
401 f"The supplied max level {maxLevel} is is greater than the max of the image: {maxImageLevel}"
402 )
403 support = 1 << (maxLevel - 1)
404 padY_amounts = levelPadder(image.shape[0] + support, maxLevel)
405 padX_amounts = levelPadder(image.shape[1] + support, maxLevel)
406 imagePadded = cv2.copyMakeBorder(
407 image, *(0, support), *(0, support), cv2.BORDER_REPLICATE, None, None
408 ).astype(image.dtype)
409
410 # build a list of intensities
411 gamma = np.linspace(image.min(), image.max(), numGamma)
412
413 # make gaussian pyramid
414 pyramid = makeGaussianPyramid(imagePadded, padY_amounts, padX_amounts, None)
415
416 finalPyramid = List()
417 for sample in pyramid[:-1]:
418 finalPyramid.append(np.zeros_like(sample))
419 finalPyramid.append(pyramid[-1])
420
421 # make a working array for gaussian pyramid in Lap
422 # make two working arrays for laplace as the true value is interpolated
423 # between the endpoints.
424 # This prevents needing re-allocations which can be time consuming.
425 tmpGauss = List()
426 tmpLap1 = List()
427 tmpLap2 = List()
428 upscratch = List()
429 for i, sample in enumerate(pyramid):
430 tmpGauss.append(np.empty_like(sample))
431 tmpLap1.append(np.empty_like(sample))
432 tmpLap2.append(np.empty_like(sample))
433 if i == 0:
434 upscratch.append(np.empty((0, 0), dtype=image.dtype))
435 continue
436 upscratch.append(np.empty((sample.shape[0] * 2, sample.shape[1] * 2), dtype=image.dtype))
437 # cycle between the endpoints, because there is no reason to recalculate both
438 # endpoints as only one changes for each bin.
439 cycler = iter(cycle((tmpLap1, tmpLap2)))
440 # allocate temporary arrays to use for each bin
441 outCycle = iter(cycle((np.copy(imagePadded), np.copy(imagePadded))))
442 prevImg = r(
443 imagePadded, next(outCycle), gamma[0], sigma, shadows=shadows, highlights=highlights, clarity=clarity
444 )
445 prevLapPyr = makeLapPyramid(
446 prevImg, padY_amounts, padX_amounts, tmpGauss, next(cycler), upscratch=upscratch
447 )
448
449 for value in range(1, len(gamma) - 1):
450 pyramidVectors = List()
451 pyramidVectors.append(prevLapPyr)
452 newImg = r(
453 imagePadded,
454 next(outCycle),
455 gamma[value],
456 sigma,
457 shadows=shadows,
458 highlights=highlights,
459 clarity=clarity,
460 )
461 prevLapPyr = makeLapPyramid(
462 newImg, padY_amounts, padX_amounts, tmpGauss, next(cycler), upscratch=upscratch
463 )
464 pyramidVectors.append(prevLapPyr)
465
467 finalPyramid,
468 pyramid,
469 np.array((gamma[value - 1], gamma[value])),
470 pyramidVectors[0],
471 pyramidVectors[1],
472 )
473 del pyramidVectors
474
475 # time to reconstruct
476 output = finalPyramid[-1]
477 for i in range(-2, -1 * len(finalPyramid) - 1, -1):
478 upsampled = cv2.pyrUp(output)
479 upsampled = upsampled[
480 : upsampled.shape[0] - 2 * padY_amounts[i + 1], : upsampled.shape[1] - 2 * padX_amounts[i + 1]
481 ]
482 output = finalPyramid[i] + upsampled
483 return output[:-support, :-support]
int min
int max
Sequence[NDArray] makeLapPyramid(NDArray img, list[int] padY, list[int] padX, List[NDArray]|None gaussOut, List[NDArray]|None lapOut, List[NDArray]|None upscratch=None)
Sequence[NDArray] makeGaussianPyramid(NDArray img, list[int] padY, list[int] padX, List[NDArray]|None out)
NDArray localContrast(NDArray image, float sigma, float highlights=-0.9, float shadows=0.4, float clarity=0.15, int|None maxLevel=None, int numGamma=20)
_calculateOutput(List[NDArray] out, List[NDArray] pyramid, NDArray gamma, List[NDArray] pyramidVectorsBottom, List[NDArray] pyramidVectorsTop)