Coverage for python / lsst / pipe / tasks / prettyPictureMaker / _functors / _color_scale.py: 37%
33 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 08:53 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 08:53 +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__ = ("ColorScaler",)
26import numpy as np
28from lsst.pipe.tasks.prettyPictureMaker.types import FloatImagePlane
29from lsst.pex.config.configurableActions import ConfigurableAction
30from lsst.pex.config import Field, ListField
32from .._equalizers import contrast_equalizer
35class ColorScaler(ConfigurableAction):
36 saturation = Field[float](
37 doc=(
38 "The overall saturation factor with the scaled luminance between zero and one. "
39 "A value of one is not recommended as it makes bright pixels very saturated"
40 ),
41 default=0.6,
42 )
43 maxChroma = Field[float](
44 doc=(
45 "The maximum chromaticity in the OKLCh color space, large "
46 "values will cause bright pixels to fall outside the RGB gamut."
47 ),
48 default=0.4,
49 )
50 brightFalloff = Field[float](
51 doc="The rate that saturation scaling should slow with brightness", default=800
52 )
53 equalizer_levels = ListField[float](
54 doc=(
55 "A list of factors to modify the color constrast in a scale dependent way. "
56 "One coefficient for each spatial scale, starting from the largest. "
57 "Values large than 1 increase contrast, while less than 1 decreases"
58 "Only scales upto and including the largest to be modified need specified"
59 "IE [1.1,0.9] modifieds the first two [1.1,1,0.9] modifies the first three."
60 ),
61 optional=True,
62 )
64 def __call__(
65 self, old_lum: FloatImagePlane, new_lum: FloatImagePlane, a: FloatImagePlane, b: FloatImagePlane
66 ) -> tuple[FloatImagePlane, FloatImagePlane]:
67 """
68 Adjusts the color saturation while keeping the hue constant.
70 This function adjusts the chromaticity (a, b) of colors to maintain a
71 consistent saturation level, based on their original luminance. It uses
72 the CIELAB color space representation and the `luminance` is the new target
73 luminance for all colors.
75 Parameters
76 ----------
77 old_lum : `FloatImagePlane`
78 Luminance values of the original colors.
79 new_lum : `FloatImagePlane`
80 Target luminance values for the transformed colors.
81 a : `FloatImagePlane`
82 Chromaticity parameter 'a' corresponding to green-red axis in CIELAB.
83 b : `FloatImagePlane`
84 Chromaticity parameter 'b' corresponding to blue-yellow axis in CIELAB.
86 Returns
87 -------
88 new_a : `FloatImagePlane`
89 New a values representing the adjusted chromaticity.
90 new_b : `FloatImagePlane`
91 New b values representing the adjusted chromaticity.
92 """
93 # Calculate the square of the chroma, which is the distance from origin in
94 # the a-b plane.
95 chroma1_2 = a**2 + b**2
96 chroma1 = np.sqrt(chroma1_2)
98 # Calculate the hue angle, taking the absolute value to ensure non-negative
99 # angle representation.
100 chromaMask = chroma1 == 0
101 chroma1[chromaMask] = 1
102 # Calculate sine and cosine of hue angle from chromaticity coordinates.
103 # In CIELAB a-b plane: sin(hue) = b/chroma, cos(hue) = a/chroma
104 # These are used below to preserve hue while adjusting chroma.
105 sinHue = b / chroma1
106 cosHue = a / chroma1
107 sinHue[chromaMask] = 0
108 cosHue[chromaMask] = 0
110 # Compute a divisor for saturation calculation, adding 1 to avoid division
111 # by zero.
112 div = chroma1_2 + old_lum**2
113 div[div <= 0] = 1
115 # Calculate the square of the new chroma based on desired saturation
116 sat_original_2 = chroma1_2 / div
117 # chroma2_2 = self.saturation * sat_original_2 * new_lum**2 / (1 - sat_original_2)
118 correction_factor = np.arcsinh(abs(new_lum - 1) * self.brightFalloff) / np.arcsinh(self.brightFalloff)
119 chroma2_2 = (
120 self.saturation
121 * correction_factor**2
122 * sat_original_2
123 * new_lum**2
124 / (1 - sat_original_2 * correction_factor**2)
125 )
127 # Compute new 'a' values using the square root of adjusted chroma and
128 # considering hue direction.
129 chroma2 = np.sqrt(chroma2_2)
130 if self.equalizer_levels is not None:
131 chroma2 = contrast_equalizer(chroma2, self.equalizer_levels)
132 # Cap the chroma to avoid excessive values that are visually unrealistic
133 chroma2[chroma2 > self.maxChroma] = self.maxChroma
135 new_a = chroma2 * cosHue
137 # Compute new 'b' values using the root of the adjusted chroma and hue
138 # direction.
139 new_b = chroma2 * sinHue
141 return new_a, new_b