From c7ef5f69bc887f399db3b7bedb679461ad8bf9d5 Mon Sep 17 00:00:00 2001 From: Simon Ochsenreither Date: Fri, 9 Jun 2023 18:20:29 +0200 Subject: [PATCH] add `OklabColor` implementation/tests --- README.md | 3 +- src/main/java/co/lors/Color.java | 9 ++- src/main/java/co/lors/OklabColor.java | 94 +++++++++++++++++++++++ src/main/java/co/lors/RgbColor.java | 42 ++++++++-- src/test/java/co/lors/ColorTest.java | 21 +++++ src/test/java/co/lors/OklabColorTest.java | 54 +++++++++++++ src/test/java/co/lors/RgbColorTest.java | 40 ++++++++++ 7 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 src/main/java/co/lors/OklabColor.java create mode 100644 src/test/java/co/lors/ColorTest.java create mode 100644 src/test/java/co/lors/OklabColorTest.java diff --git a/README.md b/README.md index 1ac1ac7..5b5c7ef 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ This library is written in Java, and runs on the JVM (≥ 17). ## Status - RGB: done -- Oklab: in progress, see [topic/oklab](https://github.com/co-lors/colors-jvm/tree/topic/oklab) +- Oklab: done +- Oklch: next up ## Usage diff --git a/src/main/java/co/lors/Color.java b/src/main/java/co/lors/Color.java index c6eb01f..6d8c7f0 100644 --- a/src/main/java/co/lors/Color.java +++ b/src/main/java/co/lors/Color.java @@ -1,18 +1,23 @@ package co.lors; -public sealed interface Color permits RgbColor { +public sealed interface Color permits RgbColor, OklabColor { static Color parse(String value) { if (value.startsWith("#")) { return RgbColor.fromHex(value); } else if (value.startsWith("rgb")) { return RgbColor.fromCss(value); + } else if (value.startsWith("oklab")) { + return OklabColor.fromCss(value); } - // todo: handle more colorspaces throw new UnsupportedOperationException("Colorspace of " + value + " is unsupported!"); } String toCss(); + RgbColor toRgb(); + + OklabColor toOklab(); + } diff --git a/src/main/java/co/lors/OklabColor.java b/src/main/java/co/lors/OklabColor.java new file mode 100644 index 0000000..ba18e70 --- /dev/null +++ b/src/main/java/co/lors/OklabColor.java @@ -0,0 +1,94 @@ +package co.lors; + +/** + * A type representing an Oklab color with optional transparency. + * + * It uses the Oklab colorspace, + * which uses the D65 white point. + * Compared to sRGB, it can express a wider range of colors. + */ +public record OklabColor(float lightness, float aAxis, float bAxis, float alpha) implements Color { + + public OklabColor { + checkUnitRange("lightness", lightness); + checkAxisRange("aAxis", aAxis); + checkAxisRange("bAxis", bAxis); + checkUnitRange("alpha", alpha); + } + + public static OklabColor of(float lightness, float aAxis, float bAxis, float alpha) { + return new OklabColor(lightness, aAxis, bAxis, alpha); + } + + public static OklabColor of(float lightness, float aAxis, float bAxis) { + return new OklabColor(lightness, aAxis, bAxis, 1.0f); + } + + public static OklabColor fromCss(String value) { + String[] colors = value.substring(7, value.length() - 1).split(" / |, |,| "); + float lightness = Float.parseFloat(colors[0].trim()); + float aAxis = Float.parseFloat(colors[1].trim()); + float bAxis = Float.parseFloat(colors[2].trim()); + float alpha = 1.0f; + if (colors.length > 3) { + alpha = Float.parseFloat(colors[3].trim()); + } + return OklabColor.of(lightness, aAxis, bAxis, alpha); + } + + @Override + public String toCss() { + String alphaString = alpha == 0.0f ? "" : " / " + alpha; + return "oklab(" + lightness + " " + aAxis + " " + bAxis + alphaString + ")"; + } + + @Override + public RgbColor toRgb() { + double l = lightness + 0.3963377774 * aAxis + 0.2158037573 * bAxis; + double m = lightness - 0.1055613458 * aAxis - 0.0638541728 * bAxis; + double s = lightness - 0.0894841775 * aAxis - 1.2914855480 * bAxis; + + double l3 = l * l * l; + double m3 = m * m * m; + double s3 = s * s * s; + + double red = +4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3; + double green = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3; + double blue = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3; + return RgbColor.of(scaleToByte(red), scaleToByte(green), scaleToByte(blue), (int) (alpha * 255)); + } + + @Override + public OklabColor toOklab() { + return this; + } + + private static double gamma(double value) { + return value >= 0.0031308 + ? 1.055 * Math.pow(value, 1 / 2.4) - 0.055 + : 12.92 * value; + } + + private static int scaleToByte(double value) { + return Math.max(0, Math.min(255, (int) (Math.round(gamma(value) * 255)))); + } + + private static void checkUnitRange(String name, float value) { + if (value < 0) { + throw new IllegalArgumentException(name + " cannot be less than 0, but was " + value); + } + if (value > 1) { + throw new IllegalArgumentException(name + " cannot be greater than 1, but was " + value); + } + } + + private static void checkAxisRange(String name, float value) { + if (value < -0.4f) { + throw new IllegalArgumentException(name + " cannot be less than -0.4, but was " + value); + } + if (value > 0.4f) { + throw new IllegalArgumentException(name + " cannot be greater than 0.4, but was " + value); + } + } + +} diff --git a/src/main/java/co/lors/RgbColor.java b/src/main/java/co/lors/RgbColor.java index 6127cff..fc265ff 100644 --- a/src/main/java/co/lors/RgbColor.java +++ b/src/main/java/co/lors/RgbColor.java @@ -41,11 +41,11 @@ public static RgbColor fromHex(String value) { if (value.length() == 3 || value.length() == 6 || value.length() == 8) { value = '#' + value; } - int intval = Integer.decode(value); + long intval = Long.decode(value); if (value.length() == 4) { // color shorthand? - int red = intval & 0xF00; - int green = intval & 0x0F0; - int blue = intval & 0x00F; + int red = (int) (intval & 0xF00); + int green = (int) (intval & 0x0F0); + int blue = (int) (intval & 0x00F); intval = red << 20 | red << 16; intval |= green << 16 | green << 12; intval |= blue << 12 | blue << 8; @@ -53,7 +53,7 @@ public static RgbColor fromHex(String value) { } else if (value.length() == 7) { // alpha missing? intval = (intval << 8) | 0xFF; } - return new RgbColor(intval); + return new RgbColor((int) intval); } /** `RRGGBBAA` */ @@ -74,7 +74,7 @@ public String toHexWithHash() { /** `rgb(r, g, b, a)` */ public static RgbColor fromCss(String value) { - String[] colors = value.substring(4, value.length() - 1).split(","); + String[] colors = value.substring(4, value.length() - 1).split(" / |, |,| "); int r = Integer.parseInt(colors[0].trim()); int g = Integer.parseInt(colors[1].trim()); int b = Integer.parseInt(colors[2].trim()); @@ -93,6 +93,36 @@ public String toCss() { return "rgb(" + red() + ", " + green() + ", " + blue() + ", " + df.format(alphaScaled()) + ")"; } + @Override + public RgbColor toRgb() { + return this; + } + + @Override + public OklabColor toOklab() { + double r = gammaInv(red() / 255.0); + double g = gammaInv(green() / 255.0); + double b = gammaInv(blue() / 255.0); + + double l3 = 0.4121656120 * r + 0.5362752080 * g + 0.0514575653 * b; + double m3 = 0.2118591070 * r + 0.6807189584 * g + 0.1074065790 * b; + double s3 = 0.0883097947 * r + 0.2818474174 * g + 0.6302613616 * b; + + double l = Math.cbrt(l3); + double m = Math.cbrt(m3); + double s = Math.cbrt(s3); + + return new OklabColor( + (float) (0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s), + (float) (1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s), + (float) (0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s), + (float) alphaScaled()); + } + + private static double gammaInv(double r) { + return (r > 0.04045) ? Math.pow((r + 0.055) / 1.055, 2.4) : (r / 12.92); + } + public int red() { return (value >> 24) & 0xFF; } diff --git a/src/test/java/co/lors/ColorTest.java b/src/test/java/co/lors/ColorTest.java new file mode 100644 index 0000000..b523bf2 --- /dev/null +++ b/src/test/java/co/lors/ColorTest.java @@ -0,0 +1,21 @@ +package co.lors; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ColorTest { + + @Test + void parse() { + assertEquals(RgbColor.of(255, 204, 153), Color.parse("#FFCC99")); + assertEquals(RgbColor.of(255, 204, 153, 34), Color.parse("#FFCC9922")); + + assertEquals(RgbColor.of(255, 204, 153), Color.parse("rgb(255, 204, 153)")); + assertEquals(RgbColor.of(255, 204, 153, 34), Color.parse("rgb(255, 204, 153 / 0.133)")); + + assertEquals(OklabColor.of(0.700f, 0.200f, 0.000f), Color.parse("oklab(0.7 0.2 0.0)")); + assertEquals(OklabColor.of(0.700f, 0.200f, 0.000f, 1.0f), Color.parse("oklab(0.7 0.2 0.0 / 1.0)")); + } + +} \ No newline at end of file diff --git a/src/test/java/co/lors/OklabColorTest.java b/src/test/java/co/lors/OklabColorTest.java new file mode 100644 index 0000000..e291677 --- /dev/null +++ b/src/test/java/co/lors/OklabColorTest.java @@ -0,0 +1,54 @@ +package co.lors; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class OklabColorTest { + + public static final OklabColor COLOR1 = OklabColor.of(0.700f, 0.200f, 0.000f, 1.0f); + public static final OklabColor COLOR2 = OklabColor.of(0.500f, 0.100f, 0.100f, 1.0f); + + @Test + void fromCss() { + assertEquals(COLOR1, OklabColor.fromCss("oklab(0.7 0.2 0.0 / 1.0)")); + assertEquals(COLOR2, OklabColor.fromCss("oklab(0.5 0.1 0.1 / 1.0)")); + } + + @Test + void toCss() { + assertEquals(COLOR1.toCss(), "oklab(0.7 0.2 0.0 / 1.0)"); + assertEquals(COLOR2.toCss(), "oklab(0.5 0.1 0.1 / 1.0)"); + } + + @Test + void toRgb() { + assertEquals(COLOR1.toRgb(), RgbColor.of(251, 92, 153, 255)); + assertEquals(COLOR2.toRgb(), RgbColor.of(161, 66, 3, 255)); + } + + @Test + void lightness() { + assertEquals(COLOR1.lightness(), 0.7f); + assertEquals(COLOR2.lightness(), 0.5f); + } + + @Test + void aAxis() { + assertEquals(COLOR1.aAxis(), 0.2f); + assertEquals(COLOR2.aAxis(), 0.1f); + } + + @Test + void bAxis() { + assertEquals(COLOR1.bAxis(), 0.0f); + assertEquals(COLOR2.bAxis(), 0.1f); + } + + @Test + void alpha() { + assertEquals(COLOR1.alpha(), 1.0f); + assertEquals(COLOR2.alpha(), 1.0f); + } + +} \ No newline at end of file diff --git a/src/test/java/co/lors/RgbColorTest.java b/src/test/java/co/lors/RgbColorTest.java index e3f8651..1547b93 100644 --- a/src/test/java/co/lors/RgbColorTest.java +++ b/src/test/java/co/lors/RgbColorTest.java @@ -163,6 +163,46 @@ public void toHexHash5NoAlpha() { @Test public void fromCss() { + assertEquals(RgbColor.BLACK, RgbColor.fromCss("rgb(0 0 0 1.0)")); + assertEquals(RgbColor.WHITE, RgbColor.fromCss("rgb(255 255, 255 1.0)")); + assertEquals(RgbColor.RED, RgbColor.fromCss("rgb(255 0 0 1.0)")); + assertEquals(RgbColor.GREEN, RgbColor.fromCss("rgb(0 255 0 1.0)")); + assertEquals(RgbColor.BLUE, RgbColor.fromCss("rgb(0 0 255 1.0)")); + + assertEquals(RgbColor.of(0, 0, 0, 0), RgbColor.fromCss("rgb(0 0 0 0.0)")); + assertEquals(RgbColor.of(0, 0, 0, 1), RgbColor.fromCss("rgb(0 0 0 0.004)")); + assertEquals(RgbColor.of(0, 0, 0, 2), RgbColor.fromCss("rgb(0 0 0 0.008)")); + assertEquals(RgbColor.of(0, 0, 0, 11), RgbColor.fromCss("rgb(0 0 0 0.043)")); + assertEquals(RgbColor.of(0, 0, 0, 23), RgbColor.fromCss("rgb(0 0 0 0.09)")); + assertEquals(RgbColor.of(0, 0, 0, 42), RgbColor.fromCss("rgb(0 0 0 0.165)")); + assertEquals(RgbColor.of(0, 0, 0, 59), RgbColor.fromCss("rgb(0 0 0 0.231)")); + assertEquals(RgbColor.of(0, 0, 0, 113), RgbColor.fromCss("rgb(0 0 0 0.443)")); + assertEquals(RgbColor.of(0, 0, 0, 127), RgbColor.fromCss("rgb(0 0 0 0.498)")); + assertEquals(RgbColor.of(0, 0, 0, 128), RgbColor.fromCss("rgb(0 0 0 0.502)")); + assertEquals(RgbColor.of(0, 0, 0, 129), RgbColor.fromCss("rgb(0 0 0 0.506)")); + assertEquals(RgbColor.of(0, 0, 0, 253), RgbColor.fromCss("rgb(0 0 0 0.992)")); + assertEquals(RgbColor.of(0, 0, 0, 254), RgbColor.fromCss("rgb(0 0 0 0.996)")); + assertEquals(RgbColor.of(0, 0, 0, 255), RgbColor.fromCss("rgb(0 0 0 1.0)")); + + // values without rounding to max 3 fractional digits + assertEquals(RgbColor.of(0, 0, 0, 0), RgbColor.fromCss("rgb(0 0 0 0.0)")); + assertEquals(RgbColor.of(0, 0, 0, 1), RgbColor.fromCss("rgb(0 0 0 0.00392156862745098)")); + assertEquals(RgbColor.of(0, 0, 0, 2), RgbColor.fromCss("rgb(0 0 0 0.00784313725490196)")); + assertEquals(RgbColor.of(0, 0, 0, 11), RgbColor.fromCss("rgb(0 0 0 0.043137254901960784)")); + assertEquals(RgbColor.of(0, 0, 0, 23), RgbColor.fromCss("rgb(0 0 0 0.09019607843137255)")); + assertEquals(RgbColor.of(0, 0, 0, 42), RgbColor.fromCss("rgb(0 0 0 0.16470588235294117)")); + assertEquals(RgbColor.of(0, 0, 0, 59), RgbColor.fromCss("rgb(0 0 0 0.23137254901960785)")); + assertEquals(RgbColor.of(0, 0, 0, 113), RgbColor.fromCss("rgb(0 0 0 0.44313725490196076)")); + assertEquals(RgbColor.of(0, 0, 0, 127), RgbColor.fromCss("rgb(0 0 0 0.4980392156862745)")); + assertEquals(RgbColor.of(0, 0, 0, 128), RgbColor.fromCss("rgb(0 0 0 0.5019607843137255)")); + assertEquals(RgbColor.of(0, 0, 0, 129), RgbColor.fromCss("rgb(0 0 0 0.5058823529411764)")); + assertEquals(RgbColor.of(0, 0, 0, 253), RgbColor.fromCss("rgb(0 0 0 0.9921568627450981)")); + assertEquals(RgbColor.of(0, 0, 0, 254), RgbColor.fromCss("rgb(0 0 0 0.996078431372549)")); + assertEquals(RgbColor.of(0, 0, 0, 255), RgbColor.fromCss("rgb(0 0 0 1.0)")); + } + + @Test + public void fromCssLegacyCommaSyntax() { assertEquals(RgbColor.BLACK, RgbColor.fromCss("rgb(0, 0, 0, 1.0)")); assertEquals(RgbColor.WHITE, RgbColor.fromCss("rgb(255, 255, 255, 1.0)")); assertEquals(RgbColor.RED, RgbColor.fromCss("rgb(255, 0, 0, 1.0)"));