Skip to content

Commit 5bdfd97

Browse files
committed
Oklab in "Posterize"
1 parent 8e17b22 commit 5bdfd97

7 files changed

Lines changed: 232 additions & 43 deletions

File tree

src/main/java/pixelitor/filters/Posterize.java

Lines changed: 168 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
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+
1718
package pixelitor.filters;
1819

20+
import pixelitor.filters.gui.EnumParam;
1921
import pixelitor.filters.gui.GroupedRangeParam;
2022
import pixelitor.filters.gui.IntChoiceParam;
2123
import pixelitor.filters.gui.RangeParam;
2224
import pixelitor.filters.lookup.RGBLookup;
25+
import pixelitor.filters.util.ColorSpace;
26+
import pixelitor.utils.ColorSpaces;
2327
import pixelitor.utils.Dithering;
2428
import pixelitor.utils.ImageUtils;
2529

@@ -30,17 +34,26 @@
3034
import static pixelitor.utils.Texts.i18n;
3135

3236
/**
33-
* Posterize filter
37+
* The "Posterize" filter.
3438
*/
3539
public 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+
}

src/main/java/pixelitor/filters/gui/GroupedRangeParam.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import static java.util.stream.Collectors.joining;
3232
import static pixelitor.filters.gui.RandomizeMode.ALLOW_RANDOMIZE;
33+
import static pixelitor.gui.utils.SliderSpinner.LabelPosition.NONE;
3334

3435
/**
3536
* Two or more {@link RangeParam} objects that are grouped visually,
@@ -83,6 +84,11 @@ public GroupedRangeParam(String name, RangeParam[] children, boolean linked) {
8384
super(name, ALLOW_RANDOMIZE);
8485
this.children = children;
8586

87+
for (RangeParam child : children) {
88+
child.setAddResetButton(true);
89+
child.setLabelPosition(NONE);
90+
}
91+
8692
linkedModel = new JToggleButton.ToggleButtonModel();
8793

8894
linkedByDefault = linked;
@@ -99,6 +105,17 @@ public JComponent createGUI() {
99105
return gui;
100106
}
101107

108+
public void updateGUIAppearance() {
109+
if (paramGUI != null) {
110+
for (RangeParam child : children) {
111+
child.updateGUIAppearance(true);
112+
}
113+
GroupedRangeParamGUI gui = (GroupedRangeParamGUI) paramGUI;
114+
gui.revalidate();
115+
gui.repaint();
116+
}
117+
}
118+
102119
private static RangeParam[] createChildren(String[] names,
103120
int min, int def, int max) {
104121
RangeParam[] children = new RangeParam[names.length];

src/main/java/pixelitor/filters/gui/GroupedRangeParamGUI.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import java.awt.GridBagLayout;
2626

2727
import static javax.swing.BorderFactory.createTitledBorder;
28-
import static pixelitor.gui.utils.SliderSpinner.LabelPosition.NONE;
2928
import static pixelitor.utils.Texts.i18n;
3029

3130
/**
@@ -57,13 +56,15 @@ public GroupedRangeParamGUI(GroupedRangeParam model) {
5756
private void addSliderSpinners() {
5857
for (int i = 0; i < numChildren; i++) {
5958
RangeParam param = model.getRangeParam(i);
60-
// doesn't call param.createGUI() because we don't want another border
61-
sliders[i] = new SliderSpinner(param, NONE, true);
59+
sliders[i] = (SliderSpinner) param.createGUI();
6260
sliders[i].setupTicks();
6361

6462
String name = param.getName();
6563
sliders[i].setName(name);
66-
gbh.addLabelAndControl(name + ":", sliders[i], i);
64+
65+
JLabel label = new JLabel(name + ":", SwingConstants.RIGHT);
66+
sliders[i].setLabel(label);
67+
gbh.addTwoControls(label, sliders[i], i);
6768
}
6869
}
6970

src/main/java/pixelitor/filters/gui/RangeParam.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ public class RangeParam extends AbstractFilterParam implements BoundedRangeModel
5151
private double value;
5252

5353
private boolean adjusting;
54-
private final boolean addResetButton;
55-
private final SliderSpinner.LabelPosition labelPosition;
54+
private boolean addResetButton;
55+
private SliderSpinner.LabelPosition labelPosition;
5656

5757
private ChangeEvent changeEvent = null;
5858
private final EventListenerList listenerList = new EventListenerList();
@@ -156,9 +156,9 @@ public void scaledLinkWith(RangeParam other, double multiplier) {
156156
other.getValueAsDouble() / multiplier));
157157
}
158158

159-
public void updateGUIAppearance() {
159+
public void updateGUIAppearance(boolean revalidate) {
160160
if (paramGUI != null) {
161-
((SliderSpinner) paramGUI).updateAppearance();
161+
((SliderSpinner) paramGUI).updateAppearance(revalidate);
162162
}
163163
}
164164

@@ -258,6 +258,10 @@ public boolean isZero() {
258258
return getValue() == 0;
259259
}
260260

261+
public boolean isNotZero() {
262+
return getValue() != 0;
263+
}
264+
261265
@Override
262266
public int getValue() {
263267
return (int) value;
@@ -424,6 +428,14 @@ public void setDefaultValue(double newDefault) {
424428
defaultValue = Math.clamp(newDefault, minValue, maxValue);
425429
}
426430

431+
public void setAddResetButton(boolean addResetButton) {
432+
this.addResetButton = addResetButton;
433+
}
434+
435+
public void setLabelPosition(SliderSpinner.LabelPosition labelPosition) {
436+
this.labelPosition = labelPosition;
437+
}
438+
427439
@Override
428440
public boolean isAnimatable() {
429441
return true;

0 commit comments

Comments
 (0)