1414 * You should have received a copy of the GNU General Public License
1515 * along with Pixelitor. If not, see <http://www.gnu.org/licenses/>.
1616 */
17+
1718package pixelitor .filters ;
1819
20+ import pixelitor .filters .gui .EnumParam ;
1921import pixelitor .filters .gui .GroupedRangeParam ;
2022import pixelitor .filters .gui .IntChoiceParam ;
2123import pixelitor .filters .gui .RangeParam ;
2224import pixelitor .filters .lookup .RGBLookup ;
25+ import pixelitor .filters .util .ColorSpace ;
26+ import pixelitor .utils .ColorSpaces ;
2327import pixelitor .utils .Dithering ;
2428import pixelitor .utils .ImageUtils ;
2529
3034import static pixelitor .utils .Texts .i18n ;
3135
3236/**
33- * Posterize filter
37+ * The " Posterize" filter.
3438 */
3539public class Posterize extends ParametrizedFilter {
3640 public static final String NAME = i18n ("posterize" );
3741
3842 @ Serial
3943 private static final long serialVersionUID = 4448706459360371642L ;
4044
41- private final RangeParam redLevels = new RangeParam (i18n ("red" ), 1 , 2 , 8 );
42- private final RangeParam greenLevels = new RangeParam (i18n ("green" ), 1 , 2 , 8 );
43- private final RangeParam blueLevels = new RangeParam (i18n ("blue" ), 1 , 2 , 8 );
45+ private final EnumParam <ColorSpace > colorSpace = ColorSpace .asParam ();
46+
47+ private final RangeParam levels1 = new RangeParam (i18n ("red" ), 1 , 2 , 8 );
48+ private final RangeParam levels2 = new RangeParam (i18n ("green" ), 1 , 2 , 8 );
49+ private final RangeParam levels3 = new RangeParam (i18n ("blue" ), 1 , 2 , 8 );
50+
51+ private final GroupedRangeParam levelsParam = new GroupedRangeParam ("Levels" ,
52+ new RangeParam []{
53+ levels1 ,
54+ levels2 ,
55+ levels3
56+ }, true );
4457
4558 private final RangeParam ditheringAmountParam = new RangeParam ("Dithering Amount (%)" , 0 , 0 , 100 );
4659 private final IntChoiceParam ditheringMethodParam = Dithering .createDitheringChoices ();
@@ -51,34 +64,171 @@ public Posterize() {
5164 ditheringAmountParam .setupEnableOtherIfNotZero (ditheringMethodParam );
5265
5366 initParams (
54- new GroupedRangeParam ("Levels" ,
55- new RangeParam []{
56- redLevels ,
57- greenLevels ,
58- blueLevels
59- }, true ),
67+ colorSpace ,
68+ levelsParam ,
6069 ditheringAmountParam ,
6170 ditheringMethodParam
6271 );
72+
73+ colorSpace .addOnChangeTask (this ::updateSliders );
74+ }
75+
76+ private void updateSliders () {
77+ switch (colorSpace .getSelected ()) {
78+ case SRGB -> {
79+ levels1 .setName (i18n ("red" ));
80+ levels2 .setName (i18n ("green" ));
81+ levels3 .setName (i18n ("blue" ));
82+ }
83+ case OKLAB -> {
84+ levels1 .setName ("Green-Red (a)" );
85+ levels2 .setName ("Blue-Yellow (b)" );
86+ levels3 .setName ("Lightness" );
87+ }
88+ }
89+ levelsParam .updateGUIAppearance ();
6390 }
6491
6592 @ Override
6693 public BufferedImage transform (BufferedImage src , BufferedImage dest ) {
67- RGBLookup posterizerLookup = RGBLookup .createForPosterize (
68- redLevels .getValue (), greenLevels .getValue (), blueLevels .getValue ());
94+ return switch (colorSpace .getSelected ()) {
95+ case SRGB -> posterizeSrgb (src , dest );
96+ case OKLAB -> posterizeOklab (src , dest );
97+ };
98+ }
99+
100+ private BufferedImage posterizeSrgb (BufferedImage src , BufferedImage dest ) {
101+ RGBLookup srgbLookup = RGBLookup .createForPosterize (
102+ levels1 .getValue (), levels2 .getValue (), levels3 .getValue ());
69103
70- boolean ditheringEnabled = ditheringAmountParam .getValue () != 0 ;
104+ boolean ditheringEnabled = ditheringAmountParam .isNotZero () ;
71105 if (ditheringEnabled ) {
72- return posterizeAndDither (src , dest , posterizerLookup );
106+ return posterizeSrgbWithDithering (src , dest , srgbLookup );
73107 }
74108
75- // simple case
76- BufferedImageOp filterOp = posterizerLookup .asFastLookupOp ();
109+ // simple case for sRGB without dithering
110+ BufferedImageOp filterOp = srgbLookup .asFastLookupOp ();
77111 filterOp .filter (src , dest );
78112 return dest ;
79113 }
80114
81- private BufferedImage posterizeAndDither (BufferedImage src , BufferedImage dest , RGBLookup rgbLookup ) {
115+ private BufferedImage posterizeOklab (BufferedImage src , BufferedImage dest ) {
116+ int aLevels = levels1 .getValue ();
117+ int bLevels = levels2 .getValue ();
118+ int lLevels = levels3 .getValue ();
119+
120+ boolean ditheringEnabled = ditheringAmountParam .isNotZero ();
121+ double diffusionStrength = ditheringAmountParam .getPercentage ();
122+ int ditheringMethod = ditheringMethodParam .getValue ();
123+
124+ int width = src .getWidth ();
125+
126+ int [] srcPixels = ImageUtils .getPixels (src );
127+ int [] destPixels = ImageUtils .getPixels (dest );
128+ int numPixels = srcPixels .length ;
129+ int [] inputPixels ;
130+
131+ // Pre-pass to find the actual min/max range of a and b channels for this image.
132+ // This adapts the quantization to the image's content, avoiding the perceptual
133+ // artifacts caused by a fixed, symmetric range.
134+ float minA = Float .MAX_VALUE , maxA = Float .MIN_VALUE ;
135+ float minB = Float .MAX_VALUE , maxB = Float .MIN_VALUE ;
136+
137+ for (int srcPixel : srcPixels ) {
138+ float [] oklab = ColorSpaces .srgbToOklab (srcPixel );
139+ float a = oklab [1 ];
140+ float b = oklab [2 ];
141+
142+ if (a < minA ) {
143+ minA = a ;
144+ }
145+ if (a > maxA ) {
146+ maxA = a ;
147+ }
148+ if (b < minB ) {
149+ minB = b ;
150+ }
151+ if (b > maxB ) {
152+ maxB = b ;
153+ }
154+ }
155+
156+ if (ditheringEnabled ) {
157+ // use a copy of the image for dithering to diffuse errors
158+ inputPixels = ImageUtils .getPixels (ImageUtils .copyImage (src ));
159+ } else {
160+ // no dithering, process source pixels directly
161+ inputPixels = srcPixels ;
162+ }
163+
164+ for (int i = 0 ; i < numPixels ; i ++) {
165+ int inRGB = inputPixels [i ];
166+ // always get the alpha from the original, unmodified source image
167+ int alpha = srcPixels [i ] & 0xFF000000 ;
168+
169+ float [] oklab = ColorSpaces .srgbToOklab (inRGB );
170+
171+ // quantize L, a, b channels
172+ float quantizedL = quantizeFloat (oklab [0 ], lLevels , 0.0f , 1.0f );
173+ // use the calculated dynamic range
174+ float quantizedA = quantizeFloat (oklab [1 ], aLevels , minA , maxA );
175+ float quantizedB = quantizeFloat (oklab [2 ], bLevels , minB , maxB );
176+
177+ float [] quantizedOklab = {quantizedL , quantizedA , quantizedB };
178+
179+ int outRGB = ColorSpaces .oklabToSrgb (quantizedOklab );
180+
181+ if (ditheringEnabled ) {
182+ int r = (inRGB >>> 16 ) & 0xFF ;
183+ int g = (inRGB >>> 8 ) & 0xFF ;
184+ int b = inRGB & 0xFF ;
185+
186+ int outR = (outRGB >>> 16 ) & 0xFF ;
187+ int outG = (outRGB >>> 8 ) & 0xFF ;
188+ int outB = outRGB & 0xFF ;
189+
190+ double errorR = (r - outR ) * diffusionStrength ;
191+ double errorG = (g - outG ) * diffusionStrength ;
192+ double errorB = (b - outB ) * diffusionStrength ;
193+ Dithering .ditherRGB (ditheringMethod , inputPixels , i , width , numPixels , errorR , errorG , errorB );
194+ }
195+
196+ destPixels [i ] = alpha | (outRGB & 0x00FFFFFF );
197+ }
198+
199+ return dest ;
200+ }
201+
202+ /**
203+ * Quantizes a float value to a specified number of levels within a given range.
204+ */
205+ private static float quantizeFloat (float value , int numLevels , float min , float max ) {
206+ if (numLevels <= 1 ) {
207+ return min + (max - min ) / 2.0f ;
208+ }
209+ float range = max - min ;
210+
211+ // prevent division by zero.
212+ if (range == 0.0f ) {
213+ return min ;
214+ }
215+
216+ // normalize value to 0-1 range
217+ float normalizedValue = (value - min ) / range ;
218+ // clamp to handle out-of-gamut values and ensure it's < 1.0 for level calculation
219+ normalizedValue = Math .max (0.0f , Math .min (normalizedValue , 0.999999f ));
220+
221+ // find the discrete level, an integer from 0 to numLevels-1
222+ int level = (int ) (normalizedValue * numLevels );
223+
224+ // map the discrete level back to a value in the 0-1 range
225+ float quantizedNormalized = (float ) level / (numLevels - 1 );
226+
227+ // scale back to the original min-max range
228+ return min + quantizedNormalized * range ;
229+ }
230+
231+ private BufferedImage posterizeSrgbWithDithering (BufferedImage src , BufferedImage dest , RGBLookup rgbLookup ) {
82232 double diffusionStrength = ditheringAmountParam .getPercentage ();
83233 int ditheringMethod = ditheringMethodParam .getValue ();
84234
@@ -125,4 +275,4 @@ public boolean isAnimatable() {
125275 public boolean supportsGray () {
126276 return false ;
127277 }
128- }
278+ }
0 commit comments