├── settings.gradle.kts
├── .github
├── FUNDING.yml
└── workflows
│ ├── test.yml
│ └── publish.yml
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── src
├── jvmMain
│ └── kotlin
│ │ └── dev
│ │ └── kdrag0n
│ │ └── colorkt
│ │ └── util
│ │ └── math
│ │ └── MathExtJvm.kt
├── nativeMain
│ └── kotlin
│ │ └── dev
│ │ └── kdrag0n
│ │ └── colorkt
│ │ └── util
│ │ └── math
│ │ └── MathExtNative.kt
├── jsMain
│ └── kotlin
│ │ └── dev
│ │ └── kdrag0n
│ │ └── colorkt
│ │ └── util
│ │ └── math
│ │ └── MathExtJs.kt
├── commonMain
│ └── kotlin
│ │ └── dev
│ │ └── kdrag0n
│ │ └── colorkt
│ │ ├── Color.kt
│ │ ├── conversion
│ │ ├── UnsupportedConversionException.kt
│ │ ├── DefaultConversions.kt
│ │ └── ConversionGraph.kt
│ │ ├── util
│ │ └── math
│ │ │ └── MathExt.kt
│ │ ├── rgb
│ │ ├── Rgb.kt
│ │ ├── Srgb.kt
│ │ └── LinearSrgb.kt
│ │ ├── ucs
│ │ ├── lab
│ │ │ ├── Lab.kt
│ │ │ ├── Srlab2.kt
│ │ │ ├── CieLab.kt
│ │ │ └── Oklab.kt
│ │ └── lch
│ │ │ ├── Lch.kt
│ │ │ ├── Oklch.kt
│ │ │ ├── Srlch2.kt
│ │ │ └── CieLch.kt
│ │ ├── data
│ │ └── Illuminants.kt
│ │ ├── tristimulus
│ │ ├── CieXyzAbs.kt
│ │ └── CieXyz.kt
│ │ ├── gamut
│ │ ├── LchGamut.kt
│ │ └── OklabGamut.kt
│ │ └── cam
│ │ └── Zcam.kt
└── commonTest
│ └── kotlin
│ └── dev
│ └── kdrag0n
│ └── colorkt
│ └── tests
│ ├── Comparison.kt
│ ├── CieLabTests.kt
│ ├── SrgbTests.kt
│ ├── ConversionTests.kt
│ ├── XyzTests.kt
│ ├── OklabTests.kt
│ ├── Srlab2Tests.kt
│ ├── GamutTests.kt
│ └── ZcamTests.kt
├── .idea
├── codeStyles
│ ├── codeStyleConfig.xml
│ └── Project.xml
├── vcs.xml
├── .gitignore
├── misc.xml
└── libraries-with-intellij-classes.xml
├── Android.bp
├── gradle.properties
├── LICENSE
├── .gitignore
├── gradlew.bat
├── README.md
└── gradlew
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "colorkt"
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | liberapay: kdrag0n
2 | patreon: kdrag0n
3 | custom: "https://paypal.me/kdrag0ndonate"
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ArrowOS/android_external_colorkt/arrow-12.0/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/src/jvmMain/kotlin/dev/kdrag0n/colorkt/util/math/MathExtJvm.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.util.math
2 |
3 | @JvmSynthetic
4 | internal actual fun cbrt(x: Double) = Math.cbrt(x)
5 |
--------------------------------------------------------------------------------
/src/nativeMain/kotlin/dev/kdrag0n/colorkt/util/math/MathExtNative.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.util.math
2 |
3 | internal actual fun cbrt(x: Double) = platform.posix.cbrt(x)
4 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Datasource local storage ignored files
5 | /dataSources/
6 | /dataSources.local.xml
7 | # Editor-based HTTP Client requests
8 | /httpRequests/
9 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/src/jsMain/kotlin/dev/kdrag0n/colorkt/util/math/MathExtJs.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.util.math
2 |
3 | import kotlin.math.pow
4 |
5 | internal actual fun cbrt(x: Double) = when {
6 | x > 0 -> x.pow(1.0 / 3.0)
7 | x < 0 -> -(-x).pow(1.0 / 3.0)
8 | else -> 0.0
9 | }
10 |
--------------------------------------------------------------------------------
/Android.bp:
--------------------------------------------------------------------------------
1 | java_library {
2 | name: "colorkt",
3 | srcs: [
4 | "src/commonMain/**/*.kt",
5 | // Build JVM parts for Android
6 | "src/jvmMain/**/*.kt",
7 | ],
8 | kotlincflags: [
9 | // Enable expect and actual keywords for building JVM parts
10 | "-Xmulti-platform",
11 | ],
12 | }
13 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 | kotlin.mpp.enableGranularSourceSetsMetadata=true
3 | kotlin.native.enableDependencyPropagation=false
4 | kotlin.js.generate.executable.default=false
5 | kotlin.mpp.stability.nowarn=true
6 |
7 | # Workaround for Dokka issue: https://github.com/Kotlin/dokka/issues/1405
8 | org.gradle.jvmargs=-XX:MaxMetaspaceSize=512m
9 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/Color.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt
2 |
3 | /**
4 | * Common interface for all colors.
5 | *
6 | * This makes no assumptions about the color itself, but implementations are expected to register conversion paths in
7 | * the global [dev.kdrag0n.colorkt.conversion.ConversionGraph] when possible.
8 | */
9 | public interface Color
10 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/conversion/UnsupportedConversionException.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.conversion
2 |
3 | /**
4 | * Exception thrown when there is no automatic conversation path in
5 | * [dev.kdrag0n.colorkt.util.conversion.ConversionGraph] for a specific pair of colors.
6 | */
7 | public class UnsupportedConversionException : RuntimeException {
8 | public constructor() : super()
9 | public constructor(message: String) : super(message)
10 | }
11 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test library
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | publish:
11 | name: Run tests
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Check out code
15 | uses: actions/checkout@v2
16 |
17 | - name: Set up Java
18 | uses: actions/setup-java@v2
19 | with:
20 | distribution: adopt
21 | java-version: 11
22 |
23 | - name: Build and test
24 | run: ./gradlew jvmTest
25 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/Comparison.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import kotlin.math.abs
4 | import kotlin.test.assertTrue
5 |
6 | private const val TEST_EPSILON = 0.001
7 |
8 | fun Double.approx(x: Double, epsilon: Double = TEST_EPSILON) = abs(this - x) <= epsilon
9 |
10 | fun assertApprox(actual: Double, expected: Double, comment: String? = null, epsilon: Double = TEST_EPSILON) {
11 | val msgBase = "Expected $expected, got $actual"
12 | val msg = if (comment != null) "$msgBase ($comment)" else msgBase
13 | assertTrue(actual.approx(expected, epsilon), msg)
14 | }
15 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/util/math/MathExt.kt:
--------------------------------------------------------------------------------
1 | // These simple math functions should always be inlined for performance
2 | @file:Suppress("NOTHING_TO_INLINE")
3 |
4 | package dev.kdrag0n.colorkt.util.math
5 |
6 | import kotlin.jvm.JvmSynthetic
7 | import kotlin.math.PI
8 |
9 | @JvmSynthetic
10 | internal inline fun cube(x: Double) = x * x * x
11 | @JvmSynthetic
12 | internal inline fun square(x: Double) = x * x
13 |
14 | // Use native cbrt where possible, otherwise simulate it with pow
15 | @JvmSynthetic
16 | internal expect fun cbrt(x: Double): Double
17 |
18 | @JvmSynthetic
19 | internal fun Double.toRadians() = this * PI / 180.0
20 | @JvmSynthetic
21 | internal fun Double.toDegrees() = this * 180.0 / PI
22 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/rgb/Rgb.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.rgb
2 |
3 | import dev.kdrag0n.colorkt.Color
4 |
5 | /**
6 | * Common interface for color spaces that express color with the following 3 components:
7 | * - R: amount of red color
8 | * - G: amount of green color
9 | * - B: amount of blue color
10 | *
11 | * Implementations of this are usually device color spaces, used for final output colors.
12 | */
13 | public interface Rgb : Color {
14 | /**
15 | * Red color component.
16 | */
17 | public val r: Double
18 |
19 | /**
20 | * Green color component.
21 | */
22 | public val g: Double
23 |
24 | /**
25 | * Blue color component.
26 | */
27 | public val b: Double
28 | }
29 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/CieLabTests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.rgb.LinearSrgb.Companion.toLinear
4 | import dev.kdrag0n.colorkt.rgb.Srgb
5 | import dev.kdrag0n.colorkt.tristimulus.CieXyz.Companion.toXyz
6 | import dev.kdrag0n.colorkt.ucs.lab.CieLab.Companion.toCieLab
7 | import dev.kdrag0n.colorkt.ucs.lch.CieLch.Companion.toCieLch
8 | import kotlin.test.Test
9 |
10 | class CieLabTests {
11 | @Test
12 | fun neutralSrgb() {
13 | for (v in 0..255) {
14 | val srgb = Srgb(v, v, v)
15 | val lch = srgb.toLinear().toXyz().toCieLab().toCieLch()
16 | val inverted = lch.toCieLab().toXyz().toLinearSrgb().toSrgb()
17 | assertApprox(inverted.r, srgb.r)
18 | assertApprox(inverted.g, srgb.g)
19 | assertApprox(inverted.b, srgb.b)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lab/Lab.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lab
2 |
3 | import dev.kdrag0n.colorkt.Color
4 |
5 | /**
6 | * Common interface for color spaces that express color with the following 3 components:
7 | * - L: perceived lightness
8 | * - a: amount of green/red color
9 | * - b: amount of blue/yellow color
10 | *
11 | * Implementations of this are usually uniform color spaces.
12 | *
13 | * It may be helpful to convert these colors to polar [dev.kdrag0n.colorkt.ucs.lch.Lch] representations
14 | * for easier manipulation.
15 | */
16 | public interface Lab : Color {
17 | /**
18 | * Perceived lightness component.
19 | */
20 | public val L: Double
21 |
22 | /**
23 | * Green/red color component.
24 | */
25 | public val a: Double
26 |
27 | /**
28 | * Blue/yellow color component.
29 | */
30 | public val b: Double
31 | }
32 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/conversion/DefaultConversions.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.conversion
2 |
3 | import dev.kdrag0n.colorkt.rgb.LinearSrgb
4 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
5 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs
6 | import dev.kdrag0n.colorkt.ucs.lab.CieLab
7 | import dev.kdrag0n.colorkt.ucs.lab.Oklab
8 | import dev.kdrag0n.colorkt.ucs.lab.Srlab2
9 | import dev.kdrag0n.colorkt.ucs.lch.CieLch
10 | import dev.kdrag0n.colorkt.ucs.lch.Oklch
11 | import dev.kdrag0n.colorkt.ucs.lch.Srlch2
12 | import kotlin.jvm.JvmSynthetic
13 |
14 | @JvmSynthetic
15 | internal fun registerAllColors() {
16 | // RGB
17 | LinearSrgb.register()
18 |
19 | // Tristimulus
20 | CieXyz.register()
21 | CieXyzAbs.register()
22 |
23 | // UCS Lab
24 | CieLab.register()
25 | Oklab.register()
26 | Srlab2.register()
27 |
28 | // UCS polar
29 | CieLch.register()
30 | Oklch.register()
31 | Srlch2.register()
32 | }
33 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish release
2 |
3 | on:
4 | release:
5 | types: [released]
6 |
7 | jobs:
8 | publish:
9 | name: Build and publish
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Check out code
13 | uses: actions/checkout@v2
14 |
15 | - name: Set up Java
16 | uses: actions/setup-java@v2
17 | with:
18 | distribution: adopt
19 | java-version: 11
20 |
21 | - name: Build code
22 | run: ./gradlew assemble
23 |
24 | - name: Publish to Maven Central
25 | run: ./gradlew publishAllPublicationsToSonatypeRepository --max-workers 1 closeAndReleaseSonatypeStagingRepository
26 | env:
27 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }}
28 | OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }}
29 | SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
30 | SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
31 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
32 | SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }}
33 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/SrgbTests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.rgb.LinearSrgb.Companion.toLinear
4 | import dev.kdrag0n.colorkt.rgb.Srgb
5 | import dev.kdrag0n.colorkt.tristimulus.CieXyz.Companion.toXyz
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 |
9 | class SrgbTests {
10 | @Test
11 | fun srgbHexRoundTrip() {
12 | listOf("#ff0000", "#00ff00", "#0000ff").forEach { sample ->
13 | val parsed = Srgb(sample)
14 | val encoded = parsed.toHex()
15 | assertEquals(sample, encoded)
16 | }
17 | }
18 |
19 | @Test
20 | fun srgbIntToXyz() {
21 | val srgb = Srgb(0xf3a177)
22 | val xyz = srgb.toLinear().toXyz()
23 | xyz.apply {
24 | assertApprox(x, 0.53032247)
25 | assertApprox(y, 0.45876334)
26 | assertApprox(z, 0.23510203)
27 | }
28 |
29 | val inverted = xyz.toLinearSrgb().toSrgb()
30 | assertApprox(inverted.r, srgb.r)
31 | assertApprox(inverted.g, srgb.g)
32 | assertApprox(inverted.b, srgb.b)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Danny Lin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/ConversionTests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.Color
4 | import dev.kdrag0n.colorkt.ucs.lab.Oklab
5 | import dev.kdrag0n.colorkt.ucs.lab.Oklab.Companion.toOklab
6 | import dev.kdrag0n.colorkt.ucs.lch.CieLch
7 | import dev.kdrag0n.colorkt.ucs.lch.Oklch
8 | import dev.kdrag0n.colorkt.ucs.lch.Oklch.Companion.toOklch
9 | import dev.kdrag0n.colorkt.conversion.ConversionGraph.convert
10 | import dev.kdrag0n.colorkt.conversion.UnsupportedConversionException
11 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
12 | import kotlin.test.Test
13 | import kotlin.test.assertEquals
14 | import kotlin.test.assertFailsWith
15 |
16 | class ConversionTests {
17 | @Test
18 | fun longConversion() {
19 | val jzczhz = CieLch(50.0, 20.0, 1.0)
20 | val autoOklch = jzczhz.convert()
21 | val manualOklch = jzczhz.toCieLab().toXyz().toOklab().toOklch()
22 | assertEquals(autoOklch, manualOklch)
23 | }
24 |
25 | @Test
26 | fun nopConversion() {
27 | val color = Oklab(0.5, 0.3, 0.5)
28 | assertEquals(color, color.convert())
29 | }
30 |
31 | @Test
32 | fun unsupportedConversion() {
33 | assertFailsWith {
34 | UnknownColor().convert()
35 | }
36 | }
37 | }
38 |
39 | private class UnknownColor : Color
40 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/XyzTests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.data.Illuminants
4 | import dev.kdrag0n.colorkt.rgb.LinearSrgb.Companion.toLinear
5 | import dev.kdrag0n.colorkt.rgb.Srgb
6 | import dev.kdrag0n.colorkt.tristimulus.CieXyz.Companion.toXyz
7 | import dev.kdrag0n.colorkt.ucs.lab.CieLab.Companion.toCieLab
8 | import dev.kdrag0n.colorkt.ucs.lch.CieLch.Companion.toCieLch
9 | import kotlin.test.Test
10 | import kotlin.test.assertEquals
11 | import kotlin.test.assertTrue
12 |
13 | class XyzTests {
14 | @Test
15 | fun neutralSrgbChroma() {
16 | var maxChroma = 0.0
17 | var avgChroma = 0.0
18 | for (v in 0..255) {
19 | val lch = Srgb(v, v, v).toLinear().toXyz().toCieLab().toCieLch()
20 | println(lch)
21 |
22 | // Testing against another reference white would break this
23 | assertEquals(lch.referenceWhite, Illuminants.D65)
24 |
25 | avgChroma += lch.chroma
26 | if (lch.chroma > maxChroma) {
27 | maxChroma = lch.chroma
28 | }
29 | }
30 | avgChroma /= 256
31 |
32 | println("max = $maxChroma")
33 | println("avg = $avgChroma")
34 | assertTrue(maxChroma <= 7.108895957933346e-14, "Max neutral chroma = $maxChroma")
35 | assertTrue(avgChroma <= 1.3133161994387564e-14, "Average neutral chroma = $avgChroma")
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/OklabTests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
4 | import dev.kdrag0n.colorkt.ucs.lab.Oklab
5 | import dev.kdrag0n.colorkt.conversion.ConversionGraph.convert
6 | import kotlin.test.Test
7 |
8 | class OklabTests {
9 | @Test
10 | fun oklabXyz1() = testOklabXyz(
11 | xyz = CieXyz(0.950, 1.000, 1.089),
12 | expected = Oklab(1.000, 0.000, 0.000),
13 | )
14 |
15 | @Test
16 | fun oklabXyz2() = testOklabXyz(
17 | xyz = CieXyz(1.000, 0.000, 0.000),
18 | expected = Oklab(0.450, 1.236, -0.019),
19 | )
20 |
21 | @Test
22 | fun oklabXyz3() = testOklabXyz(
23 | xyz = CieXyz(0.000, 1.000, 0.000),
24 | expected = Oklab(0.922, -0.671, 0.263),
25 | )
26 |
27 | @Test
28 | fun oklabXyz4() = testOklabXyz(
29 | xyz = CieXyz(0.000, 0.000, 1.000),
30 | expected = Oklab(0.153, -1.415, -0.449),
31 | )
32 |
33 | private fun testOklabXyz(
34 | xyz: CieXyz,
35 | expected: Oklab,
36 | ) {
37 | val lab = xyz.convert()
38 | assertApprox(lab.L, expected.L)
39 | assertApprox(lab.a, expected.a)
40 | assertApprox(lab.b, expected.b)
41 |
42 | val inverted = lab.toXyz()
43 | assertApprox(inverted.x, xyz.x)
44 | assertApprox(inverted.y, xyz.y)
45 | assertApprox(inverted.z, xyz.z)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lch/Lch.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lch
2 |
3 | import dev.kdrag0n.colorkt.Color
4 | import dev.kdrag0n.colorkt.util.math.toDegrees
5 | import dev.kdrag0n.colorkt.util.math.toRadians
6 | import dev.kdrag0n.colorkt.ucs.lab.Lab
7 | import dev.kdrag0n.colorkt.util.math.square
8 | import kotlin.jvm.JvmSynthetic
9 | import kotlin.math.atan2
10 | import kotlin.math.cos
11 | import kotlin.math.sin
12 | import kotlin.math.sqrt
13 |
14 | /**
15 | * Common interface for the polar representation of [dev.kdrag0n.colorkt.ucs.lab.Lab] color spaces.
16 | *
17 | * This represents Lab colors with the following 3 components:
18 | * - L: perceived lightness
19 | * - C: chroma (amount of color)
20 | * - h: hue angle, in degrees (which color, e.g. green/blue)
21 | *
22 | * @see dev.kdrag0n.colorkt.ucs.lab.Lab
23 | */
24 | public interface Lch : Color {
25 | /**
26 | * Perceived lightness component.
27 | */
28 | public val lightness: Double
29 |
30 | /**
31 | * Chroma component (amount of color).
32 | */
33 | public val chroma: Double
34 |
35 | /**
36 | * Hue angle component in degrees (which color, e.g. green/blue).
37 | */
38 | public val hue: Double
39 | }
40 |
41 | @JvmSynthetic
42 | internal fun Lab.calcLchC() = sqrt(square(a) + square(b))
43 | @JvmSynthetic
44 | internal fun Lab.calcLchH(): Double {
45 | val hDeg = atan2(b, a).toDegrees()
46 | return if (hDeg < 0) hDeg + 360 else hDeg
47 | }
48 |
49 | @JvmSynthetic
50 | internal fun Lch.calcLabA() = chroma * cos(hue.toRadians())
51 | @JvmSynthetic
52 | internal fun Lch.calcLabB() = chroma * sin(hue.toRadians())
53 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lch/Oklch.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lch
2 |
3 | import dev.kdrag0n.colorkt.ucs.lab.Oklab
4 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
5 | import kotlin.jvm.JvmName
6 | import kotlin.jvm.JvmStatic
7 | import kotlin.jvm.JvmSynthetic
8 |
9 | /**
10 | * Polar (LCh) representation of [dev.kdrag0n.colorkt.ucs.lab.Oklab].
11 | *
12 | * @see dev.kdrag0n.colorkt.ucs.lch.Lch
13 | */
14 | public data class Oklch(
15 | override val lightness: Double,
16 | override val chroma: Double,
17 | override val hue: Double,
18 | ) : Lch {
19 | /**
20 | * Convert this color to the Cartesian (Lab) representation of Oklab.
21 | *
22 | * @see dev.kdrag0n.colorkt.ucs.lab.Lab
23 | * @return Color represented as Oklab
24 | */
25 | public fun toOklab(): Oklab = Oklab(
26 | L = lightness,
27 | a = calcLabA(),
28 | b = calcLabB(),
29 | )
30 |
31 | public companion object {
32 | @JvmSynthetic
33 | internal fun register() {
34 | ConversionGraph.add { it.toOklch() }
35 | ConversionGraph.add { it.toOklab() }
36 | }
37 |
38 | /**
39 | * Convert this color to the polar (LCh) representation of Oklab.
40 | *
41 | * @see dev.kdrag0n.colorkt.ucs.lch.Lch
42 | * @return Color represented as OkLCh
43 | */
44 | @JvmStatic
45 | @JvmName("fromOklab")
46 | public fun Oklab.toOklch(): Oklch = Oklch(
47 | lightness = L,
48 | chroma = calcLchC(),
49 | hue = calcLchH(),
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lch/Srlch2.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lch
2 |
3 | import dev.kdrag0n.colorkt.ucs.lab.Srlab2
4 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
5 | import kotlin.jvm.JvmName
6 | import kotlin.jvm.JvmStatic
7 | import kotlin.jvm.JvmSynthetic
8 |
9 | /**
10 | * Polar (LCh) representation of [dev.kdrag0n.colorkt.ucs.lab.Srlab2].
11 | *
12 | * @see dev.kdrag0n.colorkt.ucs.lch.Lch
13 | */
14 | public data class Srlch2(
15 | override val lightness: Double,
16 | override val chroma: Double,
17 | override val hue: Double,
18 | ) : Lch {
19 | /**
20 | * Convert this color to the Cartesian (Lab) representation of SRLAB2.
21 | *
22 | * @see dev.kdrag0n.colorkt.ucs.lab.Lab
23 | * @return Color represented as SRLAB2
24 | */
25 | public fun toSrlab2(): Srlab2 = Srlab2(
26 | L = lightness,
27 | a = calcLabA(),
28 | b = calcLabB(),
29 | )
30 |
31 | public companion object {
32 | @JvmSynthetic
33 | internal fun register() {
34 | ConversionGraph.add { it.toSrlch2() }
35 | ConversionGraph.add { it.toSrlab2() }
36 | }
37 |
38 | /**
39 | * Convert this color to the polar (LCh) representation of SRLAB2.
40 | *
41 | * @see dev.kdrag0n.colorkt.ucs.lch.Lch
42 | * @return Color represented as SRLCh2
43 | */
44 | @JvmStatic
45 | @JvmName("fromSrlab2")
46 | public fun Srlab2.toSrlch2(): Srlch2 = Srlch2(
47 | lightness = L,
48 | chroma = calcLchC(),
49 | hue = calcLchH(),
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lch/CieLch.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lch
2 |
3 | import dev.kdrag0n.colorkt.data.Illuminants
4 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
5 | import dev.kdrag0n.colorkt.ucs.lab.CieLab
6 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
7 | import kotlin.jvm.JvmName
8 | import kotlin.jvm.JvmOverloads
9 | import kotlin.jvm.JvmStatic
10 | import kotlin.jvm.JvmSynthetic
11 |
12 | /**
13 | * Polar (LCh) representation of [dev.kdrag0n.colorkt.ucs.lab.CieLab].
14 | *
15 | * @see dev.kdrag0n.colorkt.ucs.lch.Lch
16 | */
17 | public data class CieLch @JvmOverloads constructor(
18 | override val lightness: Double,
19 | override val chroma: Double,
20 | override val hue: Double,
21 |
22 | /**
23 | * Reference white for CIELAB calculations. This affects the converted color.
24 | */
25 | val referenceWhite: CieXyz = Illuminants.D65,
26 | ) : Lch {
27 | /**
28 | * Convert this color to the Cartesian (Lab) representation of CIELAB.
29 | *
30 | * @see dev.kdrag0n.colorkt.ucs.lab.Lab
31 | * @return Color represented as CIELAB
32 | */
33 | public fun toCieLab(): CieLab = CieLab(
34 | L = lightness,
35 | a = calcLabA(),
36 | b = calcLabB(),
37 | referenceWhite = referenceWhite,
38 | )
39 |
40 | public companion object {
41 | @JvmSynthetic
42 | internal fun register() {
43 | ConversionGraph.add { it.toCieLch() }
44 | ConversionGraph.add { it.toCieLab() }
45 | }
46 |
47 | /**
48 | * Convert this color to the polar (LCh) representation of CIELAB.
49 | *
50 | * @see dev.kdrag0n.colorkt.ucs.lch.Lch
51 | * @return Color represented as CIELCh
52 | */
53 | @JvmStatic
54 | @JvmName("fromCieLab")
55 | public fun CieLab.toCieLch(): CieLch = CieLch(
56 | lightness = L,
57 | chroma = calcLchC(),
58 | hue = calcLchH(),
59 | referenceWhite = referenceWhite,
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/Srlab2Tests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.data.Illuminants
4 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
5 | import dev.kdrag0n.colorkt.tristimulus.CieXyz.Companion.toXyz
6 | import dev.kdrag0n.colorkt.ucs.lab.Srlab2
7 | import dev.kdrag0n.colorkt.ucs.lab.Srlab2.Companion.toSrlab2
8 | import dev.kdrag0n.colorkt.ucs.lch.Srlch2.Companion.toSrlch2
9 | import kotlin.test.Test
10 |
11 | class Srlab2Tests {
12 | @Test
13 | fun colorioSample1() = xyzSrlabTest(
14 | xyz = CieXyz(0.1, 0.2, 0.3),
15 | srlab2 = Srlab2(51.232467008097125, -47.92421786981609, -14.255014329381225),
16 | )
17 | @Test
18 | fun colorioSample2() = xyzSrlabTest(
19 | xyz = CieXyz(0.8, 0.9, 0.1),
20 | srlab2 = Srlab2(96.26425102758816, -30.92082858867076, 103.76703583290106),
21 | )
22 | @Test
23 | fun colorioSample3() = xyzSrlabTest(
24 | xyz = Illuminants.D65,
25 | srlab2 = Srlab2(99.99977248346777, -0.004069281557519844, -0.00039226988315022027),
26 | )
27 | @Test
28 | fun colorioSample4() = xyzSrlabTest(
29 | xyz = CieXyz(0.005, 0.006, 0.004),
30 | srlab2 = Srlab2(5.423523417804045, -2.6161648383355214, 3.6349016311770272),
31 | )
32 |
33 | private fun xyzSrlabTest(xyz: CieXyz, srlab2: Srlab2) {
34 | val actual = xyz.toLinearSrgb().toSrlab2()
35 | // Needs larger epsilon due to less accurate matrices
36 | assertApprox(actual.L, srlab2.L, epsilon = 0.1)
37 | assertApprox(actual.a, srlab2.a, epsilon = 0.1)
38 | assertApprox(actual.b, srlab2.b, epsilon = 0.1)
39 |
40 | // Invert
41 | val inverted = actual.toLinearSrgb().toXyz()
42 | assertApprox(inverted.x, xyz.x)
43 | assertApprox(inverted.y, xyz.y)
44 | assertApprox(inverted.z, xyz.z)
45 |
46 | // Test LCh inversion
47 | val lch = actual.toSrlch2()
48 | val lchInverted = lch.toSrlab2()
49 | assertApprox(lchInverted.L, actual.L)
50 | assertApprox(lchInverted.a, actual.a)
51 | assertApprox(lchInverted.b, actual.b)
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/rgb/Srgb.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.rgb
2 |
3 | import kotlin.math.roundToInt
4 |
5 | /**
6 | * A color in the standard sRGB color space.
7 | * This is the most common device color space, usually used for final output colors.
8 | *
9 | * @see Wikipedia
10 | */
11 | public data class Srgb(
12 | override val r: Double,
13 | override val g: Double,
14 | override val b: Double,
15 | ) : Rgb {
16 | // Convenient constructors for quantized values
17 |
18 | /**
19 | * Create a color from 8-bit integer sRGB components.
20 | */
21 | public constructor(
22 | r: Int,
23 | g: Int,
24 | b: Int,
25 | ) : this(
26 | r = r.toDouble() / 255.0,
27 | g = g.toDouble() / 255.0,
28 | b = b.toDouble() / 255.0,
29 | )
30 |
31 | /**
32 | * Create a color from a packed (A)RGB8 integer.
33 | */
34 | public constructor(color: Int) : this(
35 | r = (color shr 16) and 0xff,
36 | g = (color shr 8) and 0xff,
37 | b = color and 0xff,
38 | )
39 |
40 | /**
41 | * Create a color from a hex color code (e.g. #FA00FA).
42 | * Hex codes with and without leading hash (#) symbols are supported.
43 | */
44 | public constructor(color: String) : this(color.removePrefix("#").toInt(16))
45 |
46 | /**
47 | * Convert this color to an 8-bit packed RGB integer (32 bits total)
48 | *
49 | * This is equivalent to the integer value of hex color codes (e.g. #FA00FA).
50 | *
51 | * @return color as 32-bit integer in RGB8 format
52 | */
53 | public fun toRgb8(): Int = (quantize8(r) shl 16) or (quantize8(g) shl 8) or quantize8(b)
54 |
55 | /**
56 | * Convert this color to an 8-bit hex color code (e.g. #FA00FA).
57 | *
58 | * @return color as RGB8 hex code
59 | */
60 | public fun toHex(): String = "#" + toRgb8().toString(16).padStart(6, padChar = '0')
61 |
62 | private companion object {
63 | // Clamp out-of-bounds values
64 | private fun quantize8(n: Double) = (n * 255.0).roundToInt().coerceIn(0, 255)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/data/Illuminants.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.data
2 |
3 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
4 | import kotlin.jvm.JvmField
5 |
6 | /**
7 | * Standard reference illuminants, typically used as reference white points.
8 | */
9 | public object Illuminants {
10 | /**
11 | * ASTM variant of CIE Standard Illuminant D65. ~6500K color temperature; approximates average daylight in Europe.
12 | * This uses the XYZ values defined in the ASTM E‑308 document.
13 | *
14 | * @see Wikipedia: Illuminant D65
15 | */
16 | @JvmField
17 | public val D65: CieXyz = CieXyz(
18 | x = 0.95047,
19 | y = 1.0,
20 | z = 1.08883,
21 | )
22 |
23 | /**
24 | * sRGB variant of CIE Standard Illuminant D65. ~6500K color temperature; approximates average daylight in Europe.
25 | * This uses the white point chromaticities defined in the sRGB specification.
26 | *
27 | * @see Wikipedia: sRGB
28 | */
29 | @JvmField
30 | public val D65_SRGB: CieXyz = CieXyz(
31 | x = xyToX(0.3127, 0.3290),
32 | y = 1.0,
33 | z = xyToZ(0.3127, 0.3290),
34 | )
35 |
36 | /**
37 | * Raw precise variant of CIE Standard Illuminant D65. ~6500K color temperature; approximates average daylight in Europe.
38 | * This uses XYZ values calculated from raw 1nm SPD data, combined with the CIE 1931 2-degree
39 | * standard observer.
40 | *
41 | * @see RIT - Useful Color Data
42 | */
43 | @JvmField
44 | public val D65_CIE: CieXyz = CieXyz(
45 | x = 0.9504705586542832,
46 | y = 1.0,
47 | z = 1.088828736395884,
48 | )
49 |
50 | /**
51 | * CIE Standard Illuminant D50. ~5000K color temperature.
52 | */
53 | @JvmField
54 | public val D50: CieXyz = CieXyz(
55 | x = xyToX(0.3457, 0.3585),
56 | y = 1.0,
57 | z = xyToZ(0.3457, 0.3585),
58 | )
59 |
60 | private fun xyToX(x: Double, y: Double) = x / y
61 | private fun xyToZ(x: Double, y: Double) = (1.0 - x - y) / y
62 | }
63 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/tristimulus/CieXyzAbs.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tristimulus
2 |
3 | import dev.kdrag0n.colorkt.Color
4 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
5 | import kotlin.jvm.JvmName
6 | import kotlin.jvm.JvmOverloads
7 | import kotlin.jvm.JvmStatic
8 | import kotlin.jvm.JvmSynthetic
9 |
10 | /**
11 | * A color in the CIE XYZ tristimulus color space, with absolute luminance.
12 | * This is often used as an intermediate color space for uniform color spaces and color appearance models.
13 | *
14 | * @see dev.kdrag0n.colorkt.tristimulus.CieXyz
15 | */
16 | public data class CieXyzAbs(
17 | /**
18 | * X component: mix of the non-negative CIE RGB curves.
19 | */
20 | val x: Double,
21 |
22 | /**
23 | * Y component: absolute luminance.
24 | */
25 | val y: Double,
26 |
27 | /**
28 | * Z component: approximately equal to blue from CIE RGB.
29 | */
30 | val z: Double,
31 | ) : Color {
32 | /**
33 | * Convert an absolute XYZ color to relative XYZ, using the specified reference white luminance.
34 | *
35 | * @return Color in relative XYZ
36 | */
37 | @JvmOverloads
38 | public fun toRel(luminance: Double = DEFAULT_SDR_WHITE_LUMINANCE): CieXyz = CieXyz(
39 | x = x / luminance,
40 | y = y / luminance,
41 | z = z / luminance,
42 | )
43 |
44 | public companion object {
45 | /**
46 | * Default absolute luminance used to convert SDR colors to absolute XYZ.
47 | * This effectively models the color being displayed on a display with a brightness of 200 nits (cd/m^2).
48 | */
49 | public const val DEFAULT_SDR_WHITE_LUMINANCE: Double = 200.0 // cd/m^2
50 |
51 | @JvmSynthetic
52 | internal fun register() {
53 | ConversionGraph.add { it.toAbs() }
54 | ConversionGraph.add { it.toRel() }
55 | }
56 |
57 | /**
58 | * Convert a relative XYZ color to absolute XYZ, using the specified reference white luminance.
59 | *
60 | * @return Color in absolute XYZ
61 | */
62 | @JvmStatic
63 | @JvmOverloads
64 | @JvmName("fromRel")
65 | public fun CieXyz.toAbs(luminance: Double = DEFAULT_SDR_WHITE_LUMINANCE): CieXyzAbs = CieXyzAbs(
66 | x = x * luminance,
67 | y = y * luminance,
68 | z = z * luminance,
69 | )
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled class file
2 | *.class
3 |
4 | # Log file
5 | *.log
6 |
7 | # BlueJ files
8 | *.ctxt
9 |
10 | # Mobile Tools for Java (J2ME)
11 | .mtj.tmp/
12 |
13 | # Package Files #
14 | *.jar
15 | *.war
16 | *.nar
17 | *.ear
18 | *.zip
19 | *.tar.gz
20 | *.rar
21 |
22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
23 | hs_err_pid*
24 |
25 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
26 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
27 |
28 | # User-specific stuff
29 | .idea/**/workspace.xml
30 | .idea/**/tasks.xml
31 | .idea/**/usage.statistics.xml
32 | .idea/**/dictionaries
33 | .idea/**/shelf
34 |
35 | # AWS User-specific
36 | .idea/**/aws.xml
37 |
38 | # Generated files
39 | .idea/**/contentModel.xml
40 |
41 | # Sensitive or high-churn files
42 | .idea/**/dataSources/
43 | .idea/**/dataSources.ids
44 | .idea/**/dataSources.local.xml
45 | .idea/**/sqlDataSources.xml
46 | .idea/**/dynamic.xml
47 | .idea/**/uiDesigner.xml
48 | .idea/**/dbnavigator.xml
49 |
50 | # Gradle
51 | .idea/**/gradle.xml
52 | .idea/**/libraries
53 |
54 | # Gradle and Maven with auto-import
55 | # When using Gradle or Maven with auto-import, you should exclude module files,
56 | # since they will be recreated, and may cause churn. Uncomment if using
57 | # auto-import.
58 | .idea/artifacts
59 | .idea/compiler.xml
60 | .idea/jarRepositories.xml
61 | .idea/modules.xml
62 | .idea/*.iml
63 | .idea/modules
64 | *.iml
65 | *.ipr
66 |
67 | # CMake
68 | cmake-build-*/
69 |
70 | # Mongo Explorer plugin
71 | .idea/**/mongoSettings.xml
72 |
73 | # File-based project format
74 | *.iws
75 |
76 | # IntelliJ
77 | out/
78 |
79 | # mpeltonen/sbt-idea plugin
80 | .idea_modules/
81 |
82 | # JIRA plugin
83 | atlassian-ide-plugin.xml
84 |
85 | # Cursive Clojure plugin
86 | .idea/replstate.xml
87 |
88 | # Crashlytics plugin (for Android Studio and IntelliJ)
89 | com_crashlytics_export_strings.xml
90 | crashlytics.properties
91 | crashlytics-build.properties
92 | fabric.properties
93 |
94 | # Editor-based Rest Client
95 | .idea/httpRequests
96 |
97 | # Android studio 3.1+ serialized cache file
98 | .idea/caches/build_file_checksums.ser
99 |
100 | .gradle
101 | **/build/
102 | !src/**/build/
103 |
104 | # Ignore Gradle GUI config
105 | gradle-app.setting
106 |
107 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
108 | !gradle-wrapper.jar
109 |
110 | # Cache of project
111 | .gradletasknamecache
112 |
113 | # Maven publishing
114 | local.properties
115 |
116 | # Kotlin/JS build
117 | /kotlin-js-store/
118 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/rgb/LinearSrgb.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.rgb
2 |
3 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
4 | import kotlin.jvm.JvmName
5 | import kotlin.jvm.JvmStatic
6 | import kotlin.jvm.JvmSynthetic
7 | import kotlin.math.pow
8 |
9 | /**
10 | * Linear representation of [dev.kdrag0n.colorkt.rgb.Srgb].
11 | * This is useful as an intermediate color space for conversions.
12 | *
13 | * The sRGB non-linearity and its inverse are applied accurately, including the linear part of the piecewise function.
14 | *
15 | * @see Wikipedia
16 | */
17 | public data class LinearSrgb(
18 | override val r: Double,
19 | override val g: Double,
20 | override val b: Double,
21 | ) : Rgb {
22 | /**
23 | * Convert this color to standard sRGB.
24 | * This delinearizes the sRGB components.
25 | *
26 | * @see dev.kdrag0n.colorkt.rgb.Srgb
27 | * @return Color in standard sRGB
28 | */
29 | public fun toSrgb(): Srgb {
30 | return Srgb(
31 | r = f(r),
32 | g = f(g),
33 | b = f(b),
34 | )
35 | }
36 |
37 | /**
38 | * Check whether this color is within the sRGB gamut.
39 | * This will return false if any component is either NaN or is not within the 0-1 range.
40 | *
41 | * @return true if color is in gamut, false otherwise
42 | */
43 | public fun isInGamut(): Boolean = r in 0.0..1.0 && g in 0.0..1.0 && b in 0.0..1.0
44 |
45 | public companion object {
46 | @JvmSynthetic
47 | internal fun register() {
48 | ConversionGraph.add { it.toLinear() }
49 | ConversionGraph.add { it.toSrgb() }
50 | }
51 |
52 | // Linear -> sRGB
53 | private fun f(x: Double) = if (x >= 0.0031308) {
54 | 1.055 * x.pow(1.0 / 2.4) - 0.055
55 | } else {
56 | 12.92 * x
57 | }
58 |
59 | // sRGB -> linear
60 | private fun fInv(x: Double) = if (x >= 0.04045) {
61 | ((x + 0.055) / 1.055).pow(2.4)
62 | } else {
63 | x / 12.92
64 | }
65 |
66 | /**
67 | * Convert this color to linear sRGB.
68 | * This linearizes the sRGB components.
69 | *
70 | * @see dev.kdrag0n.colorkt.rgb.LinearSrgb
71 | * @return Color in linear sRGB
72 | */
73 | @JvmStatic
74 | @JvmName("fromSrgb")
75 | public fun Srgb.toLinear(): LinearSrgb {
76 | return LinearSrgb(
77 | r = fInv(r),
78 | g = fInv(g),
79 | b = fInv(b),
80 | )
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lab/Srlab2.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lab
2 |
3 | import dev.kdrag0n.colorkt.rgb.LinearSrgb
4 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
5 | import dev.kdrag0n.colorkt.util.math.cbrt
6 | import dev.kdrag0n.colorkt.util.math.cube
7 | import kotlin.jvm.JvmName
8 | import kotlin.jvm.JvmStatic
9 | import kotlin.jvm.JvmSynthetic
10 |
11 | /**
12 | * A color in the SRLAB2 uniform color space, which represents colors in [dev.kdrag0n.colorkt.ucs.lab.Lab] form.
13 | * This color space is an improvement upon CIELAB using transformations from CIECAM02.
14 | *
15 | * Linear sRGB is used as the intermediate color space.
16 | *
17 | * @see SRLAB2 – an alternative to CIE-L*a*b*
18 | */
19 | public data class Srlab2(
20 | override val L: Double,
21 | override val a: Double,
22 | override val b: Double,
23 | ) : Lab {
24 | /**
25 | * Convert this color to the linear sRGB color space.
26 | *
27 | * @see dev.kdrag0n.colorkt.rgb.LinearSrgb
28 | * @return Color in linear sRGB
29 | */
30 | public fun toLinearSrgb(): LinearSrgb {
31 | val x = fInv(0.01 * L + 0.000904127 * a + 0.000456344 * b)
32 | val y = fInv(0.01 * L - 0.000533159 * a - 0.000269178 * b)
33 | val z = fInv(0.01 * L - 0.005800000 * b)
34 |
35 | return LinearSrgb(
36 | r = 5.435679 * x - 4.599131 * y + 0.163593 * z,
37 | g = -1.168090 * x + 2.327977 * y - 0.159798 * z,
38 | b = 0.037840 * x - 0.198564 * y + 1.160644 * z,
39 | )
40 | }
41 |
42 | public companion object {
43 | @JvmSynthetic
44 | internal fun register() {
45 | ConversionGraph.add { it.toSrlab2() }
46 | ConversionGraph.add { it.toLinearSrgb() }
47 | }
48 |
49 | private fun f(x: Double) = if (x <= 216.0 / 24389.0) {
50 | x * 24389.0 / 2700.0
51 | } else {
52 | 1.16 * cbrt(x) - 0.16
53 | }
54 |
55 | private fun fInv(x: Double) = if (x <= 0.08) {
56 | x * 2700.0 / 24389.0
57 | } else {
58 | cube((x + 0.16) / 1.16)
59 | }
60 |
61 | /**
62 | * Convert this color to the SRLAB2 uniform color space.
63 | *
64 | * @see dev.kdrag0n.colorkt.ucs.lab.Srlab2
65 | * @return Color in SRLAB2 UCS
66 | */
67 | @JvmStatic
68 | @JvmName("fromLinearSrgb")
69 | public fun LinearSrgb.toSrlab2(): Srlab2 {
70 | val x2 = f(0.320530 * r + 0.636920 * g + 0.042560 * b)
71 | val y2 = f(0.161987 * r + 0.756636 * g + 0.081376 * b)
72 | val z2 = f(0.017228 * r + 0.108660 * g + 0.874112 * b)
73 |
74 | return Srlab2(
75 | L = 37.0950 * x2 + 62.9054 * y2 - 0.0008 * z2,
76 | a = 663.4684 * x2 - 750.5078 * y2 + 87.0328 * z2,
77 | b = 63.9569 * x2 + 108.4576 * y2 - 172.4152 * z2,
78 | )
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/.idea/libraries-with-intellij-classes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
64 |
65 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lab/CieLab.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lab
2 |
3 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
4 | import dev.kdrag0n.colorkt.data.Illuminants
5 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
6 | import dev.kdrag0n.colorkt.util.math.cbrt
7 | import dev.kdrag0n.colorkt.util.math.cube
8 | import kotlin.jvm.JvmName
9 | import kotlin.jvm.JvmOverloads
10 | import kotlin.jvm.JvmStatic
11 | import kotlin.jvm.JvmSynthetic
12 |
13 | /**
14 | * A color in the CIE L*a*b* uniform color space, which represents colors in [dev.kdrag0n.colorkt.ucs.lab.Lab] form.
15 | * This is the most well-known uniform color space, but more modern alternatives such as
16 | * [dev.kdrag0n.colorkt.ucs.lab.Oklab] tend to be more perceptually uniform.
17 | *
18 | * Note that this implementation uses a white point of D65, like sRGB.
19 | * It does not implement CIELAB D50.
20 | *
21 | * @see Wikipedia
22 | */
23 | public data class CieLab @JvmOverloads constructor(
24 | override val L: Double,
25 | override val a: Double,
26 | override val b: Double,
27 |
28 | /**
29 | * Reference white for CIELAB calculations. This affects the converted color.
30 | */
31 | val referenceWhite: CieXyz = Illuminants.D65,
32 | ) : Lab {
33 | /**
34 | * Convert this color to the CIE XYZ color space.
35 | *
36 | * @see dev.kdrag0n.colorkt.tristimulus.CieXyz
37 | * @return Color in XYZ
38 | */
39 | public fun toXyz(): CieXyz {
40 | val lp = (L + 16.0) / 116.0
41 |
42 | return CieXyz(
43 | x = referenceWhite.x * fInv(lp + (a / 500.0)),
44 | y = referenceWhite.y * fInv(lp),
45 | z = referenceWhite.z * fInv(lp - (b / 200.0)),
46 | )
47 | }
48 |
49 | public companion object {
50 | @JvmSynthetic
51 | internal fun register() {
52 | ConversionGraph.add { it.toCieLab() }
53 | ConversionGraph.add { it.toXyz() }
54 | }
55 |
56 | private fun f(x: Double) = if (x > 216.0/24389.0) {
57 | cbrt(x)
58 | } else {
59 | x / (108.0/841.0) + 4.0/29.0
60 | }
61 |
62 | private fun fInv(x: Double) = if (x > 6.0/29.0) {
63 | cube(x)
64 | } else {
65 | (108.0/841.0) * (x - 4.0/29.0)
66 | }
67 |
68 | /**
69 | * Convert this color to the CIE L*a*b* uniform color space.
70 | *
71 | * @see dev.kdrag0n.colorkt.ucs.lab.CieLab
72 | * @return Color in CIE L*a*b* UCS
73 | */
74 | @JvmStatic
75 | @JvmOverloads
76 | @JvmName("fromXyz")
77 | public fun CieXyz.toCieLab(refWhite: CieXyz = Illuminants.D65): CieLab {
78 | return CieLab(
79 | L = 116.0 * f(y / refWhite.y) - 16.0,
80 | a = 500.0 * (f(x / refWhite.x) - f(y / refWhite.y)),
81 | b = 200.0 * (f(y / refWhite.y) - f(z / refWhite.z)),
82 | referenceWhite = refWhite,
83 | )
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/tristimulus/CieXyz.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tristimulus
2 |
3 | import dev.kdrag0n.colorkt.Color
4 | import dev.kdrag0n.colorkt.rgb.LinearSrgb
5 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
6 | import kotlin.jvm.JvmName
7 | import kotlin.jvm.JvmStatic
8 | import kotlin.jvm.JvmSynthetic
9 |
10 | /**
11 | * A color in the CIE XYZ tristimulus color space.
12 | * This is often used as an intermediate color space for uniform color spaces and color appearance models.
13 | *
14 | * Note that this is *not* a uniform color space; see [dev.kdrag0n.colorkt.ucs.lab.Lab] for that.
15 | *
16 | * @see Wikipedia
17 | */
18 | public data class CieXyz(
19 | /**
20 | * X component: mix of the non-negative CIE RGB curves.
21 | */
22 | val x: Double,
23 |
24 | /**
25 | * Y component: relative luminance.
26 | */
27 | val y: Double,
28 |
29 | /**
30 | * Z component: approximately equal to blue from CIE RGB.
31 | */
32 | val z: Double,
33 | ) : Color {
34 | /**
35 | * Convert this color to the linear sRGB color space.
36 | *
37 | * @see dev.kdrag0n.colorkt.rgb.LinearSrgb
38 | * @return Color in linear sRGB
39 | */
40 | public fun toLinearSrgb(): LinearSrgb {
41 | // See LinearSrgb.toXyz for info about the source of this matrix.
42 | return LinearSrgb(
43 | r = 3.2404541621141045 * x + -1.5371385127977162 * y + -0.4985314095560159 * z,
44 | g = -0.969266030505187 * x + 1.8760108454466944 * y + 0.04155601753034983 * z,
45 | b = 0.05564343095911474 * x + -0.2040259135167538 * y + 1.0572251882231787 * z,
46 | )
47 | }
48 |
49 | public companion object {
50 | @JvmSynthetic
51 | internal fun register() {
52 | ConversionGraph.add { it.toXyz() }
53 | ConversionGraph.add { it.toLinearSrgb() }
54 | }
55 |
56 | /**
57 | * Convert a linear sRGB color (D65 white point) to the CIE XYZ color space.
58 | *
59 | * @return Color in XYZ
60 | */
61 | @JvmStatic
62 | @JvmName("fromLinearSrgb")
63 | public fun LinearSrgb.toXyz(): CieXyz {
64 | // This matrix (along with the inverse above) has been optimized to minimize chroma in CIELCh
65 | // when converting neutral sRGB colors to CIELAB. The maximum chroma for sRGB neutral colors 0-255 is
66 | // 5.978733960281817e-14.
67 | //
68 | // Calculated with https://github.com/facelessuser/coloraide/blob/master/tools/calc_xyz_transform.py
69 | // Using D65 xy chromaticities from the sRGB spec: x = 0.3127, y = 0.3290
70 | // Always keep in sync with Illuminants.D65.
71 | return CieXyz(
72 | x = 0.41245643908969226 * r + 0.357576077643909 * g + 0.18043748326639897 * b,
73 | y = 0.21267285140562256 * r + 0.715152155287818 * g + 0.07217499330655959 * b,
74 | z = 0.019333895582329303 * r + 0.11919202588130297 * g + 0.950304078536368 * b,
75 | )
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/conversion/ConversionGraph.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.conversion
2 |
3 | import dev.kdrag0n.colorkt.Color
4 | import kotlin.jvm.JvmStatic
5 | import kotlin.reflect.KClass
6 |
7 | internal typealias ColorType = KClass
8 |
9 | /**
10 | * One-way converter from color type F to T.
11 | */
12 | public fun interface ColorConverter {
13 | /**
14 | * Convert [color] from type F to T.
15 | */
16 | public fun convert(color: F): T
17 | }
18 |
19 | /**
20 | * Global color conversion graph, used for automatic conversions between different color spaces.
21 | */
22 | public object ConversionGraph {
23 | // Adjacency list: [vertex] = edges
24 | private val graph = mutableMapOf>()
25 | private val pathCache = HashMap, List>>()
26 |
27 | init {
28 | // All first-party color spaces should be registered in order for conversions to work properly
29 | registerAllColors()
30 | }
31 |
32 | /**
33 | * Add a conversion from color type F to T, specified as generic types.
34 | * This is a convenient wrapper for [add].
35 | */
36 | @JvmStatic
37 | public inline fun add(
38 | crossinline converter: (F) -> T,
39 | ): Unit = add(F::class, T::class) { converter(it as F) }
40 |
41 | /**
42 | * Add a one-way conversion from color type [from] to [to].
43 | * You should also add a matching reverse conversion, i.e. from [to] to [from].
44 | */
45 | @JvmStatic
46 | public fun add(
47 | from: ColorType,
48 | to: ColorType,
49 | converter: ColorConverter,
50 | ) {
51 | val node = ConversionEdge(from, to, converter)
52 |
53 | graph[from]?.let { it += node }
54 | ?: graph.put(from, hashSetOf(node))
55 | graph[to]?.let { it += node }
56 | ?: graph.put(to, hashSetOf(node))
57 | }
58 |
59 | private fun findPath(from: ColorType, to: ColorType): List>? {
60 | val visited = HashSet()
61 | val pathQueue = ArrayDeque(listOf(
62 | // Initial path: from node
63 | listOf(ConversionEdge(from, from) { it }),
64 | ))
65 |
66 | while (pathQueue.isNotEmpty()) {
67 | // Get the first path from the queue
68 | val path = pathQueue.removeFirst()
69 | // Get the last node from the path to visit
70 | val node = path.last()
71 |
72 | if (node.to == to) {
73 | return path.drop(1).map { it.converter }
74 | } else if (node !in visited) {
75 | visited += node
76 | val neighbors = graph[node.to] ?: continue
77 | pathQueue.addAll(neighbors.map { path + it })
78 | }
79 | }
80 |
81 | // No paths found
82 | return null
83 | }
84 |
85 | /**
86 | * Convert this color to color space [T].
87 | * @throws UnsupportedConversionException if no automatic conversion path exists
88 | * @return color as [T]
89 | */
90 | public inline fun Color.convert(): T = this as? T
91 | ?: convert(this, T::class) as T?
92 | ?: throw UnsupportedConversionException("No conversion path from ${this::class} to ${T::class}")
93 |
94 | /**
95 | * Convert [fromColor] to color space [toType].
96 | * @throws UnsupportedConversionException if no automatic conversion path exists
97 | * @return color as [toType]
98 | */
99 | @JvmStatic
100 | public fun convert(fromColor: Color, toType: ColorType): Color? {
101 | val pathKey = fromColor::class to toType
102 | val path = pathCache[pathKey]
103 | ?: findPath(fromColor::class, toType)?.also { pathCache[pathKey] = it }
104 | ?: return null
105 |
106 | return path.fold(fromColor) { color, converter ->
107 | converter.convert(color)
108 | }
109 | }
110 |
111 | private data class ConversionEdge(
112 | val from: ColorType,
113 | val to: ColorType,
114 | val converter: ColorConverter,
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/GamutTests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.cam.Zcam
4 | import dev.kdrag0n.colorkt.cam.Zcam.Companion.toZcam
5 | import dev.kdrag0n.colorkt.data.Illuminants
6 | import dev.kdrag0n.colorkt.gamut.LchGamut.clipToLinearSrgb
7 | import dev.kdrag0n.colorkt.gamut.OklabGamut.clipToLinearSrgb
8 | import dev.kdrag0n.colorkt.rgb.LinearSrgb.Companion.toLinear
9 | import dev.kdrag0n.colorkt.rgb.Srgb
10 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs
11 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs.Companion.DEFAULT_SDR_WHITE_LUMINANCE
12 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs.Companion.toAbs
13 | import dev.kdrag0n.colorkt.ucs.lab.CieLab
14 | import dev.kdrag0n.colorkt.ucs.lch.Oklch
15 | import dev.kdrag0n.colorkt.conversion.ConversionGraph.convert
16 | import dev.kdrag0n.colorkt.gamut.LchGamut
17 | import dev.kdrag0n.colorkt.gamut.OklabGamut
18 | import dev.kdrag0n.colorkt.rgb.LinearSrgb
19 | import kotlin.test.Test
20 | import kotlin.test.assertFalse
21 | import kotlin.test.assertTrue
22 |
23 | private const val EPSILON = 0.001
24 |
25 | class GamutTests {
26 | private val cond = Zcam.ViewingConditions(
27 | surroundFactor = Zcam.ViewingConditions.SURROUND_AVERAGE,
28 | adaptingLuminance = 0.4 * DEFAULT_SDR_WHITE_LUMINANCE,
29 | backgroundLuminance = CieLab(50.0, 0.0, 0.0).toXyz().toAbs().y,
30 | referenceWhite = Illuminants.D65.toAbs(),
31 | )
32 |
33 | private val srgbRgbkw = listOf(
34 | 0xff0000,
35 | 0x00ff00,
36 | 0x0000ff,
37 | 0x000000,
38 | 0xffffff,
39 | ).map { Srgb(it) }
40 |
41 | @Test
42 | fun oklabClipRgbkw() {
43 | for (src in srgbRgbkw) {
44 | val srcLinear = src.toLinear()
45 | val lch = src.convert()
46 |
47 | // Boost the chroma and clip lightness
48 | val clipped = lch.copy(chroma = lch.chroma * 5).toOklab().clipToLinearSrgb()
49 |
50 | // Make sure it's the same
51 | assertApprox(clipped.r, srcLinear.r)
52 | assertApprox(clipped.g, srcLinear.g)
53 | assertApprox(clipped.b, srcLinear.b)
54 |
55 | // Now test all the methods and make sure they're reasonable: not NaN, 0, or out-of-gamut
56 | OklabGamut.ClipMethod.values().forEach { method ->
57 | val clippedM = lch.copy(chroma = lch.chroma * 5).toOklab().clipToLinearSrgb(method)
58 | assertInGamut(clippedM)
59 | }
60 | }
61 | }
62 |
63 | @Test
64 | fun zcamClipRgbkw() {
65 | for (src in srgbRgbkw) {
66 | val srcLinear = src.toLinear()
67 | val zcam = src.convert().toZcam(cond, include2D = false)
68 |
69 | // Boost the chroma
70 | val clipped = zcam.copy(chroma = zcam.chroma * 5).clipToLinearSrgb()
71 |
72 | // Now check
73 | assertApprox(clipped.r, srcLinear.r)
74 | assertApprox(clipped.g, srcLinear.g)
75 | assertApprox(clipped.b, srcLinear.b)
76 |
77 | // Now test all the methods and make sure they're reasonable: not NaN, 0, or out-of-gamut
78 | LchGamut.ClipMethod.values().forEach { method ->
79 | val clippedM = zcam.copy(chroma = zcam.chroma * 5).clipToLinearSrgb(method)
80 | assertInGamut(clippedM)
81 | }
82 | }
83 | }
84 |
85 | @Test
86 | fun zcamClipZeroChroma() {
87 | val zcam = Zcam(
88 | lightness = 50.0,
89 | chroma = 0.0,
90 | hue = 180.0,
91 | viewingConditions = cond,
92 | )
93 |
94 | val clipped = zcam.clipToLinearSrgb()
95 | assertInGamut(clipped)
96 | }
97 |
98 | @Test
99 | fun zcamClipNegativeLightness() {
100 | val zcam = Zcam(
101 | lightness = -10.0,
102 | chroma = 0.0,
103 | hue = 0.0,
104 | viewingConditions = cond,
105 | )
106 |
107 | val clipped = zcam.clipToLinearSrgb()
108 | assertInGamut(clipped)
109 | }
110 |
111 | private fun assertInGamut(rgb: LinearSrgb) {
112 | assertInGamut(rgb.r)
113 | assertInGamut(rgb.g)
114 | assertInGamut(rgb.b)
115 | }
116 |
117 | private fun assertInGamut(component: Double) {
118 | assertTrue(component in (0.0 - EPSILON)..(1.0 + EPSILON), "$component is out of gamut")
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/ucs/lab/Oklab.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.ucs.lab
2 |
3 | import dev.kdrag0n.colorkt.rgb.LinearSrgb
4 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
5 | import dev.kdrag0n.colorkt.conversion.ConversionGraph
6 | import dev.kdrag0n.colorkt.util.math.cbrt
7 | import dev.kdrag0n.colorkt.util.math.cube
8 | import kotlin.jvm.JvmName
9 | import kotlin.jvm.JvmStatic
10 | import kotlin.jvm.JvmSynthetic
11 |
12 | /**
13 | * A color in the Oklab uniform color space, which represents colors in [dev.kdrag0n.colorkt.ucs.lab.Lab] form.
14 | * This color space is designed for overall uniformity and does not assume viewing conditions.
15 | *
16 | * Note that this implementation uses a white point of D65, like sRGB.
17 | * Linear sRGB is used as the intermediate color space.
18 | *
19 | * @see A perceptual color space for image processing
20 | */
21 | public data class Oklab(
22 | override val L: Double,
23 | override val a: Double,
24 | override val b: Double,
25 | ) : Lab {
26 | /**
27 | * Convert this color to the linear sRGB color space.
28 | *
29 | * @see dev.kdrag0n.colorkt.rgb.LinearSrgb
30 | * @return Color in linear sRGB
31 | */
32 | public fun toLinearSrgb(): LinearSrgb {
33 | val l = labToL()
34 | val m = labToM()
35 | val s = labToS()
36 |
37 | return LinearSrgb(
38 | r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
39 | g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
40 | b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
41 | )
42 | }
43 | /**
44 | * Convert this color to the CIE XYZ color space.
45 | *
46 | * @see dev.kdrag0n.colorkt.tristimulus.CieXyz
47 | * @return Color in XYZ
48 | */
49 | public fun toXyz(): CieXyz {
50 | val l = labToL()
51 | val m = labToM()
52 | val s = labToS()
53 |
54 | return CieXyz(
55 | x = +1.2270138511 * l - 0.5577999807 * m + 0.2812561490 * s,
56 | y = -0.0405801784 * l + 1.1122568696 * m - 0.0716766787 * s,
57 | z = -0.0763812845 * l - 0.4214819784 * m + 1.5861632204 * s,
58 | )
59 | }
60 |
61 | // Avoid arrays to minimize garbage
62 | private fun labToL() = cube(L + 0.3963377774 * a + 0.2158037573 * b)
63 | private fun labToM() = cube(L - 0.1055613458 * a - 0.0638541728 * b)
64 | private fun labToS() = cube(L - 0.0894841775 * a - 1.2914855480 * b)
65 |
66 | public companion object {
67 | @JvmSynthetic
68 | internal fun register() {
69 | ConversionGraph.add { it.toOklab() }
70 | ConversionGraph.add { it.toLinearSrgb() }
71 |
72 | ConversionGraph.add { it.toOklab() }
73 | ConversionGraph.add { it.toXyz() }
74 | }
75 |
76 | private fun lmsToOklab(l: Double, m: Double, s: Double): Oklab {
77 | val lp = cbrt(l)
78 | val mp = cbrt(m)
79 | val sp = cbrt(s)
80 |
81 | return Oklab(
82 | L = 0.2104542553 * lp + 0.7936177850 * mp - 0.0040720468 * sp,
83 | a = 1.9779984951 * lp - 2.4285922050 * mp + 0.4505937099 * sp,
84 | b = 0.0259040371 * lp + 0.7827717662 * mp - 0.8086757660 * sp,
85 | )
86 | }
87 |
88 | /**
89 | * Convert this color to the Oklab uniform color space.
90 | *
91 | * @see dev.kdrag0n.colorkt.ucs.lab.Oklab
92 | * @return Color in Oklab UCS
93 | */
94 | @JvmStatic
95 | @JvmName("fromLinearSrgb")
96 | public fun LinearSrgb.toOklab(): Oklab = lmsToOklab(
97 | l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
98 | m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
99 | s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b,
100 | )
101 |
102 | /**
103 | * Convert this color to the Oklab uniform color space.
104 | *
105 | * @see dev.kdrag0n.colorkt.ucs.lab.Oklab
106 | * @return Color in Oklab UCS
107 | */
108 | @JvmStatic
109 | @JvmName("fromXyz")
110 | public fun CieXyz.toOklab(): Oklab = lmsToOklab(
111 | l = 0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z,
112 | m = 0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z,
113 | s = 0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z,
114 | )
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Color.kt
2 |
3 | Color.kt is a modern color science library for Kotlin Multiplatform and Java. It includes modern perceptually-uniform color spaces and color appearance models, such as [Oklab](https://bottosson.github.io/posts/oklab/) and [ZCAM](https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640).
4 |
5 | [**API documentation**](https://javadoc.io/doc/dev.kdrag0n/colorkt)
6 |
7 | ## Features
8 |
9 | - Perceptually-uniform color spaces, each with LCh (lightness, chroma, hue) representations
10 | - [Oklab](https://bottosson.github.io/posts/oklab/)
11 | - [CIELAB](https://en.wikipedia.org/wiki/CIELAB_color_space)
12 | - [SRLAB2](https://www.magnetkern.de/srlab2.html)
13 | - Color appearance models
14 | - [ZCAM](https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640)
15 | - Hue-preserving gamut mapping
16 | - Preserve lightness, reduce chroma (default)
17 | - Project towards neutral 50% gray
18 | - Adaptive lightness and chroma preservation
19 | - [CIE 1931 XYZ](https://en.wikipedia.org/wiki/CIE_1931_color_space) interchange color space
20 | - Relative luminance
21 | - Absolute luminance in nits (cd/m²) for HDR color spaces
22 | - Automatic conversion graph
23 | - Gamma-correct sRGB encoding and decoding
24 | - Support for custom color spaces
25 | - Idiomatic Java API
26 |
27 | ## Usage
28 |
29 | [](https://search.maven.org/artifact/dev.kdrag0n/colorkt)
30 |
31 | Add this library as a dependency and replace `VERSION` with the latest version above:
32 |
33 | ```groovy
34 | repositories {
35 | mavenCentral()
36 | }
37 |
38 | dependencies {
39 | implementation 'dev.kdrag0n:colorkt:VERSION'
40 | }
41 | ```
42 |
43 | If you're using the Kotlin Gradle DSL:
44 |
45 | ```kotlin
46 | repositories {
47 | mavenCentral()
48 | }
49 |
50 | dependencies {
51 | implementation("dev.kdrag0n:colorkt:VERSION")
52 | }
53 | ```
54 |
55 | ## Examples
56 |
57 | ### Increase colorfulness with CIELAB
58 |
59 | Convert a hex sRGB color code to CIELAB, increase the chroma (colorfulness), and convert it back to sRGB as a hex color code:
60 |
61 | ```kotlin
62 | val cielab = Srgb("#9b392f").convert()
63 | val lch = cielab.convert()
64 | val boosted1 = lch.copy(C = lch.C * 2).convert().toHex()
65 | ```
66 |
67 | Do the same thing in Oklab:
68 |
69 | ```kotlin
70 | val oklab = Srgb("#9b392f").convert()
71 | val lch = oklab.convert()
72 | val boosted2 = lch.copy(C = lch.C * 2).convert().toHex()
73 | ```
74 |
75 | ### Advanced color appearance model usage
76 |
77 | Model a color using ZCAM:
78 |
79 | ```kotlin
80 | // Brightness of the display
81 | val luminance = 200.0 // nits (cd/m²)
82 |
83 | // Conditions under which the color will be viewed
84 | val cond = Zcam.ViewingConditions(
85 | surroundFactor = Zcam.ViewingConditions.SURROUND_AVERAGE,
86 | adaptingLuminance = 0.4 * luminance,
87 | // Mid-gray background at 50% luminance
88 | backgroundLuminance = CieLab(50.0, 0.0, 0.0).toXyz().y * luminance,
89 | // D65 is the only supported white point
90 | referenceWhite = Illuminants.D65.toAbs(luminance),
91 | )
92 |
93 | // Color to convert
94 | val src = Srgb("#533b69")
95 | // Use ZCAM to get perceptual color appearance attributes
96 | val zcam = src.toLinear().toXyz().toAbs(luminance).toZcam(cond)
97 | ```
98 |
99 | Increase the chroma (colorfulness):
100 |
101 | ```kotlin
102 | val colorful = zcam.copy(chroma = zcam.chroma * 2)
103 | ```
104 |
105 | Convert the color back to sRGB, while preserving hue and avoiding ugly results caused by hard clipping:
106 |
107 | ```kotlin
108 | val srgb = colorful.clipToLinearSrgb()
109 | ```
110 |
111 | Finally, print the new hex color code:
112 |
113 | ```kotlin
114 | println(srgb.toHex())
115 | ```
116 |
117 | ## Automatic conversion
118 |
119 | Color.kt makes it easy to convert between any two color spaces by automatically finding the shortest path in the color conversion graph. This simplifies long conversions, which can occur frequently when working with different color spaces. For example, converting from CIELCh to Oklab LCh is usually done like this:
120 |
121 | ```kotlin
122 | val oklab = cielch.toCieLab().toXyz().toLinearSrgb().toOklab().toOklch()
123 | ```
124 |
125 | With automatic conversion:
126 |
127 | ```kotlin
128 | val oklab = cielch.convert()
129 | ```
130 |
131 | However, keep in mind that there is a performance cost associated with automatic conversion because it requires searching the graph. Conversion paths are cached after the first use, but it is still less efficient than manual conversion; prefer manual, explicit conversions in performance-critical code.
132 |
133 | ## Custom color spaces
134 |
135 | Color.kt includes several color spaces that should cover most use cases, but you can also add your own if necessary. Simply create a class that implements the Color interface, and implement some conversions to make the color space useful:
136 |
137 | ```kotlin
138 | data class GrayColor(val brightness: Double) : Color {
139 | fun toLinearSrgb() = LinearSrgb(brightness, brightness, brightness)
140 |
141 | companion object {
142 | // sRGB luminosity function from https://en.wikipedia.org/wiki/Relative_luminance
143 | fun LinearSrgb.toGray() = GrayColor(0.2126 * r + 0.7251 * g + 0.0722 * b)
144 | }
145 | }
146 | ```
147 |
148 | Optionally, add your new color space to the automatic conversion graph for convenient usage:
149 |
150 | ```kotlin
151 | ConversionGraph.add { it.toGray() }
152 | ConversionGraph.add { it.toLinearSrgb() }
153 | ```
154 |
155 | If you implement a new color space this way, please consider contributing it with a [pull request](https://github.com/kdrag0n/colorkt/compare) so that everyone can benefit from it!
156 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/gamut/LchGamut.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.gamut
2 |
3 | import dev.kdrag0n.colorkt.cam.Zcam
4 | import dev.kdrag0n.colorkt.rgb.LinearSrgb
5 | import kotlin.jvm.JvmOverloads
6 | import kotlin.jvm.JvmStatic
7 | import kotlin.math.abs
8 |
9 | private fun interface LchFactory {
10 | fun getColor(lightness: Double, chroma: Double, hue: Double): LinearSrgb
11 | }
12 |
13 | /**
14 | * sRGB gamut clipping using binary search to find the edge of the gamut for a specific color.
15 | *
16 | * Out-of-gamut colors are mapped using gamut intersection in a 2D plane, and hue is always preserved. Lightness and
17 | * chroma are changed depending on the clip method; see [ClipMethod] for details.
18 | *
19 | * [OklabGamut] has the same goal, but this is a generalized solution that works with any color space or color
20 | * appearance model with brightness/lightness, chroma/colorfulness, and hue attributes. It's not as fast as
21 | * [OklabGamut] and lacks methods that use the lightness of the maximum chroma in the hue plane, but is much more
22 | * flexible.
23 | *
24 | * Currently, only ZCAM is supported, but the underlying algorithm implementation may be exposed in the future as it is
25 | * portable to other color spaces and appearance models.
26 | */
27 | public object LchGamut {
28 | // Epsilon for color spaces where lightness ranges from 0 to 100
29 | private const val EPSILON_100 = 0.001
30 |
31 | private fun evalLine(slope: Double, intercept: Double, x: Double) =
32 | slope * x + intercept
33 |
34 | private fun clip(
35 | // Target point
36 | l1: Double,
37 | c1: Double,
38 | hue: Double,
39 | // Projection point within gamut
40 | l0: Double,
41 | // Color space parameters
42 | epsilon: Double,
43 | maxLightness: Double,
44 | factory: LchFactory,
45 | ): LinearSrgb {
46 | var result = factory.getColor(l1, c1, hue)
47 |
48 | return when {
49 | result.isInGamut() -> result
50 | // Avoid searching black and white for performance
51 | l1 <= epsilon -> LinearSrgb(0.0, 0.0, 0.0)
52 | l1 >= maxLightness - epsilon -> LinearSrgb(1.0, 1.0, 1.0)
53 |
54 | // Clip with gamut intersection
55 | else -> {
56 | // Chroma is always 0 so the reference point is guaranteed to be within gamut
57 | val c0 = 0.0
58 |
59 | // Create a line - x=C, y=L - intersecting a hue plane
60 | // In theory, we could have a divide-by-zero error here if c1=0. However, that's not a problem because
61 | // all colors with chroma = 0 should be in gamut, so this loop never runs. Even if this loop somehow
62 | // ends up running for such a color, it would just result in a slow search that doesn't converge because
63 | // the NaN causes isInGamut() to return false.
64 | val slope = (l1 - l0) / (c1 - c0)
65 | val intercept = l0 - slope * c0
66 |
67 | var lo = 0.0
68 | var hi = c1
69 |
70 | while (abs(hi - lo) > epsilon) {
71 | val midC = (lo + hi) / 2
72 | val midL = evalLine(slope, intercept, midC)
73 |
74 | result = factory.getColor(midL, midC, hue)
75 |
76 | if (!result.isInGamut()) {
77 | // If this color isn't in gamut, pivot left to get an in-gamut color.
78 | hi = midC
79 | } else {
80 | // If this color is in gamut, test a point to the right that should be just outside the gamut.
81 | // If the test point is *not* in gamut, we know that this color is right at the edge of the
82 | // gamut.
83 | val midC2 = midC + epsilon
84 | val midL2 = evalLine(slope, intercept, midC2)
85 |
86 | val ptOutside = factory.getColor(midL2, midC2, hue)
87 | if (ptOutside.isInGamut()) {
88 | lo = midC
89 | } else {
90 | break
91 | }
92 | }
93 | }
94 |
95 | result
96 | }
97 | }
98 | }
99 |
100 | /**
101 | * Convert this ZCAM color to linear sRGB, and clip it to sRGB gamut boundaries if it's not already within gamut.
102 | *
103 | * Out-of-gamut colors are mapped using gamut intersection in a 2D plane, and hue is always preserved. Lightness and
104 | * chroma are changed depending on the clip method; see [ClipMethod] for details.
105 | *
106 | * @return clipped color in linear sRGB
107 | */
108 | @JvmStatic
109 | @JvmOverloads
110 | public fun Zcam.clipToLinearSrgb(
111 | /**
112 | * Gamut clipping method to use. Different methods preserve different attributes and make different trade-offs.
113 | * @see [ClipMethod]
114 | */
115 | method: ClipMethod = ClipMethod.PRESERVE_LIGHTNESS,
116 | /**
117 | * For adaptive clipping methods only: the extent to which lightness should be preserved rather than chroma.
118 | * Larger numbers will preserve chroma more than lightness, and vice versa.
119 | *
120 | * This value is ignored when using other (non-adaptive) clipping methods.
121 | */
122 | alpha: Double = 0.05,
123 | ): LinearSrgb {
124 | val l0 = when (method) {
125 | ClipMethod.PRESERVE_LIGHTNESS -> lightness
126 | ClipMethod.PROJECT_TO_MID -> 50.0
127 | ClipMethod.ADAPTIVE_TOWARDS_MID -> OklabGamut.calcAdaptiveMidL(
128 | L = lightness / 100.0,
129 | C = chroma / 100.0,
130 | alpha = alpha,
131 | ) * 100.0
132 | }
133 |
134 | return clip(
135 | l1 = lightness,
136 | c1 = chroma,
137 | hue = hue,
138 | l0 = l0,
139 | epsilon = EPSILON_100,
140 | maxLightness = 100.0
141 | ) { l, c, h ->
142 | Zcam(
143 | lightness = l,
144 | chroma = c,
145 | hue = h,
146 | viewingConditions = viewingConditions,
147 | ).toXyzAbs(
148 | luminanceSource = Zcam.LuminanceSource.LIGHTNESS,
149 | chromaSource = Zcam.ChromaSource.CHROMA,
150 | ).toRel(viewingConditions.referenceWhite.y).toLinearSrgb()
151 | }
152 | }
153 |
154 | public enum class ClipMethod {
155 | /**
156 | * Preserve the target lightness (e.g. for contrast) by reducing chroma.
157 | */
158 | PRESERVE_LIGHTNESS,
159 |
160 | /**
161 | * Project towards neutral 50% gray.
162 | */
163 | PROJECT_TO_MID,
164 |
165 | /**
166 | * A mix of lightness-preserving chroma reduction and projecting towards neutral 50% gray.
167 | */
168 | ADAPTIVE_TOWARDS_MID,
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/src/commonTest/kotlin/dev/kdrag0n/colorkt/tests/ZcamTests.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.tests
2 |
3 | import dev.kdrag0n.colorkt.cam.Zcam
4 | import dev.kdrag0n.colorkt.cam.Zcam.Companion.toZcam
5 | import dev.kdrag0n.colorkt.data.Illuminants
6 | import dev.kdrag0n.colorkt.rgb.LinearSrgb.Companion.toLinear
7 | import dev.kdrag0n.colorkt.rgb.Srgb
8 | import dev.kdrag0n.colorkt.tristimulus.CieXyz
9 | import dev.kdrag0n.colorkt.tristimulus.CieXyz.Companion.toXyz
10 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs
11 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs.Companion.toAbs
12 | import dev.kdrag0n.colorkt.ucs.lab.CieLab
13 | import kotlin.math.abs
14 | import kotlin.test.Test
15 | import kotlin.test.assertEquals
16 | import kotlin.test.assertTrue
17 |
18 | class ZcamTests {
19 | private val defaultCond = Zcam.ViewingConditions(
20 | surroundFactor = Zcam.ViewingConditions.SURROUND_AVERAGE,
21 | adaptingLuminance = 0.4 * CieXyzAbs.DEFAULT_SDR_WHITE_LUMINANCE,
22 | backgroundLuminance = CieLab(50.0, 0.0, 0.0).toXyz().toAbs().y,
23 | referenceWhite = Illuminants.D65.toAbs(),
24 | )
25 |
26 | @Test
27 | fun zcamExample1() = testExample(
28 | referenceWhite = CieXyzAbs(256.0, 264.0, 202.0),
29 | sampleD65 = CieXyzAbs(182.25997236, 206.57412429, 231.18612283),
30 | surround = Zcam.ViewingConditions.SURROUND_AVERAGE,
31 | L_a = 264.0,
32 | Y_b = 100.0,
33 |
34 | hz = 196.3524,
35 | Qz = 321.3464,
36 | Jz = 92.2520,
37 | Mz = 10.5252,
38 | Cz = 3.0216,
39 | Sz = 19.1314,
40 | Vz = 34.7022,
41 | Kz = 25.2994,
42 | Wz = 91.6837,
43 | )
44 |
45 | @Test
46 | fun zcamExample2() = testExample(
47 | referenceWhite = CieXyzAbs(256.0, 264.0, 202.0),
48 | sampleD65 = CieXyzAbs(91.33742436, 97.68591995, 169.79324781),
49 | surround = Zcam.ViewingConditions.SURROUND_AVERAGE,
50 | L_a = 264.0,
51 | Y_b = 100.0,
52 |
53 | hz = 250.6422,
54 | Qz = 248.0394,
55 | Jz = 71.2071,
56 | Mz = 23.8744,
57 | Cz = 6.8539,
58 | Sz = 32.7963,
59 | Vz = 18.2796,
60 | Kz = 40.4621,
61 | Wz = 70.4026,
62 | )
63 |
64 | @Test
65 | fun zcamExample3() = testExample(
66 | referenceWhite = CieXyzAbs(256.0, 264.0, 202.0),
67 | sampleD65 = CieXyzAbs(77.59404245, 80.98983072, 85.36972501),
68 | surround = Zcam.ViewingConditions.SURROUND_DIM,
69 | L_a = 264.0,
70 | Y_b = 100.0,
71 | // Paper has the wrong L_a and Y_b
72 | /*
73 | L_a = 150.0,
74 | Y_b = 60.0,
75 | */
76 |
77 | hz = 58.7532,
78 | Qz = 196.7686,
79 | Jz = 68.8890,
80 | Mz = 2.7918,
81 | Cz = 0.9774,
82 | Sz = 12.5916,
83 | Vz = 11.0371,
84 | Kz = 44.4143,
85 | Wz = 68.8737,
86 | )
87 |
88 | @Test
89 | fun zcamExample4() = testExample(
90 | referenceWhite = CieXyzAbs(2103.0, 2259.0, 1401.0),
91 | sampleD65 = CieXyzAbs(910.69546926, 1107.07247243, 804.10072127),
92 | surround = Zcam.ViewingConditions.SURROUND_DARK,
93 | L_a = 359.0,
94 | Y_b = 16.0,
95 |
96 | hz = 123.9464,
97 | Qz = 114.7431,
98 | Jz = 82.6445,
99 | Mz = 18.1655,
100 | Cz = 13.0838,
101 | Sz = 44.7277,
102 | Vz = 34.4874,
103 | Kz = 26.8778,
104 | Wz = 78.2653,
105 | )
106 |
107 | @Test
108 | fun zcamExample5() = testExample(
109 | referenceWhite = CieXyzAbs(2103.0, 2259.0, 1401.0),
110 | sampleD65 = CieXyzAbs(94.13640377, 65.93948718, 45.16280809),
111 | surround = Zcam.ViewingConditions.SURROUND_DARK,
112 | L_a = 359.0,
113 | Y_b = 16.0,
114 |
115 | // Paper says 389.7720 because it unconditionally adds 360 degrees
116 | hz = 29.7720,
117 | Qz = 45.8363,
118 | Jz = 33.0139,
119 | Mz = 26.9446,
120 | Cz = 19.4070,
121 | Sz = 86.1882,
122 | Vz = 43.6447,
123 | Kz = 47.9942,
124 | Wz = 30.2593,
125 | )
126 |
127 | @Test
128 | fun zcamAliases() {
129 | val zcam = Srgb(0xff00ff).toLinear().toXyz().toAbs().toZcam(defaultCond)
130 | zcam.apply {
131 | assertEquals(Qz, brightness)
132 | assertEquals(Jz, lightness)
133 | assertEquals(Mz, colorfulness)
134 | assertEquals(Cz, chroma)
135 | assertEquals(hz, hue)
136 | assertEquals(Sz, saturation)
137 | assertEquals(Vz, vividness)
138 | assertEquals(Kz, blackness)
139 | assertEquals(Wz, whiteness)
140 | }
141 | }
142 |
143 | @Test
144 | fun viewingConditionParams() {
145 | defaultCond.apply {
146 | assertEquals(surroundFactor, Zcam.ViewingConditions.SURROUND_AVERAGE)
147 | assertEquals(referenceWhite, Illuminants.D65.toAbs())
148 | assertEquals(backgroundLuminance, CieLab(50.0, 0.0, 0.0).toXyz().toAbs().y)
149 | assertEquals(adaptingLuminance, CieXyz(0.0, 0.4, 0.0).toAbs().y)
150 | }
151 | }
152 |
153 | private fun testExample(
154 | sampleD65: CieXyzAbs,
155 | referenceWhite: CieXyzAbs,
156 | surround: Double,
157 | L_a: Double,
158 | Y_b: Double,
159 | hz: Double,
160 | Qz: Double,
161 | Jz: Double,
162 | Mz: Double,
163 | Cz: Double,
164 | Sz: Double,
165 | Vz: Double,
166 | Kz: Double,
167 | Wz: Double,
168 | ) {
169 | val cond = Zcam.ViewingConditions(
170 | surroundFactor = surround,
171 | adaptingLuminance = L_a,
172 | backgroundLuminance = Y_b,
173 | referenceWhite = referenceWhite,
174 | )
175 |
176 | val zcam = sampleD65.toZcam(cond)
177 | println(zcam)
178 |
179 | // TODO: assert for more exact values once the paper authors provide a clarification
180 | assertSimilar(zcam.hz, hz)
181 | assertSimilar(zcam.Qz, Qz)
182 | assertSimilar(zcam.Jz, Jz)
183 | assertSimilar(zcam.Mz, Mz)
184 | assertSimilar(zcam.Cz, Cz)
185 | assertSimilar(zcam.Sz, Sz)
186 | assertSimilar(zcam.Vz, Vz)
187 | assertSimilar(zcam.Kz, Kz, epsilon = 0.8)
188 | assertSimilar(zcam.Wz, Wz)
189 |
190 | // Now invert it using all combinations of methods
191 | val invertedResults = Zcam.LuminanceSource.values()
192 | .flatMap { ls -> Zcam.ChromaSource.values().map { cs -> ls to cs } }
193 | .map { (ls, cs) -> Triple(ls, cs, zcam.toXyzAbs(ls, cs)) }
194 | invertedResults.forEach { (ls, cs, inverted) ->
195 | val comment = "Inverted with $ls, $cs"
196 | println("$ls $cs $inverted")
197 | assertApprox(inverted.y, sampleD65.y, comment)
198 | assertApprox(inverted.x, sampleD65.x, comment)
199 | assertApprox(inverted.z, sampleD65.z, comment)
200 | }
201 | }
202 | }
203 |
204 | private fun assertSimilar(actual: Double, expected: Double, epsilon: Double = 0.1) {
205 | assertTrue(abs(actual - expected) <= epsilon, "Expected $expected, got $actual")
206 | }
207 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/gamut/OklabGamut.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.gamut
2 |
3 | import dev.kdrag0n.colorkt.rgb.LinearSrgb
4 | import dev.kdrag0n.colorkt.ucs.lab.Oklab
5 | import dev.kdrag0n.colorkt.ucs.lab.Oklab.Companion.toOklab
6 | import dev.kdrag0n.colorkt.util.math.cbrt
7 | import dev.kdrag0n.colorkt.util.math.cube
8 | import dev.kdrag0n.colorkt.util.math.square
9 | import kotlin.jvm.JvmOverloads
10 | import kotlin.jvm.JvmStatic
11 | import kotlin.jvm.JvmSynthetic
12 | import kotlin.math.*
13 |
14 | /**
15 | * sRGB gamut clipping using Oklab.
16 | *
17 | * Out-of-gamut colors are mapped using gamut intersection in a 2D plane, and hue is always preserved. Lightness and
18 | * chroma are changed depending on the clip method; see [ClipMethod] for details.
19 | *
20 | * [LchGamut] has the same goal, but this is a numerical solution created specifically for Oklab. As a result, this runs
21 | * faster and has more clip methods (based on the lightness of the maximum chroma in the hue plane), but is otherwise
22 | * the same.
23 | *
24 | * Ported from the original C++ implementation:
25 | *
26 | * Copyright (c) 2021 Björn Ottosson
27 | *
28 | * Permission is hereby granted, free of charge, to any person obtaining a copy of
29 | * this software and associated documentation files (the "Software"), to deal in
30 | * the Software without restriction, including without limitation the rights to
31 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
32 | * of the Software, and to permit persons to whom the Software is furnished to do
33 | * so, subject to the following conditions:
34 | *
35 | * The above copyright notice and this permission notice shall be included in all
36 | * copies or substantial portions of the Software.
37 | *
38 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
39 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
40 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
41 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
42 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
43 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
44 | * SOFTWARE.
45 | *
46 | * @see sRGB gamut clipping
47 | */
48 | // Renaming variables hurts the readability of math code
49 | @Suppress("LocalVariableName")
50 | public object OklabGamut {
51 | private const val CLIP_EPSILON = 0.00001
52 |
53 | // Finds the maximum saturation possible for a given hue that fits in sRGB
54 | // Saturation here is defined as S = C/L
55 | // a and b must be normalized so a^2 + b^2 == 1
56 | private fun computeMaxSaturation(a: Double, b: Double): Double {
57 | // Max saturation will be when one of r, g or b goes below zero.
58 |
59 | // Select different coefficients depending on which component goes below zero first
60 | val coeff = when {
61 | -1.88170328 * a - 0.80936493 * b > 1 -> SaturationCoefficients.RED
62 | 1.81444104 * a - 1.19445276 * b > 1 -> SaturationCoefficients.GREEN
63 | else -> SaturationCoefficients.BLUE
64 | }
65 |
66 | // Approximate max saturation using a polynomial:
67 | val S = coeff.k0 + coeff.k1 * a + coeff.k2 * b + coeff.k3 * a * a + coeff.k4 * a * b
68 |
69 | // Do one step Halley's method to get closer
70 | // this gives an error less than 10e6, except for some blue hues where the dS/dh is close to infinite
71 | // this should be sufficient for most applications, otherwise do two/three steps
72 |
73 | val k_l = +0.3963377774 * a + 0.2158037573 * b
74 | val k_m = -0.1055613458 * a - 0.0638541728 * b
75 | val k_s = -0.0894841775 * a - 1.2914855480 * b
76 |
77 | run {
78 | val l_ = 1 + S * k_l
79 | val m_ = 1 + S * k_m
80 | val s_ = 1 + S * k_s
81 |
82 | val l = cube(l_)
83 | val m = cube(m_)
84 | val s = cube(s_)
85 |
86 | val l_dS = 3 * k_l * square(l_)
87 | val m_dS = 3 * k_m * square(m_)
88 | val s_dS = 3 * k_s * square(s_)
89 |
90 | val l_dS2 = 6 * square(k_l) * l_
91 | val m_dS2 = 6 * square(k_m) * m_
92 | val s_dS2 = 6 * square(k_s) * s_
93 |
94 | val f = coeff.wl * l + coeff.wm * m + coeff.ws * s
95 | val f1 = coeff.wl * l_dS + coeff.wm * m_dS + coeff.ws * s_dS
96 | val f2 = coeff.wl * l_dS2 + coeff.wm * m_dS2 + coeff.ws * s_dS2
97 |
98 | return S - f * f1 / (f1*f1 - 0.5 * f * f2)
99 | }
100 | }
101 |
102 | // finds L_cusp and C_cusp for a given hue
103 | // a and b must be normalized so a^2 + b^2 == 1
104 | private fun findCusp(a: Double, b: Double): LC {
105 | // First, find the maximum saturation (saturation S = C/L)
106 | val S_cusp = computeMaxSaturation(a, b)
107 |
108 | // Convert to linear sRGB to find the first point where at least one of r,g or b >= 1:
109 | val rgb_at_max = Oklab(1.0, S_cusp * a, S_cusp * b).toLinearSrgb()
110 | val L_cusp = cbrt(1.0 / max(max(rgb_at_max.r, rgb_at_max.g), rgb_at_max.b))
111 | val C_cusp = L_cusp * S_cusp
112 |
113 | return LC(L_cusp, C_cusp)
114 | }
115 |
116 | private fun halleyTerm(
117 | l: Double, m: Double, s: Double,
118 | ldt: Double, mdt: Double, sdt: Double,
119 | ldt2: Double, mdt2: Double, sdt2: Double,
120 | coeff1: Double, coeff2: Double, coeff3: Double,
121 | ): Double {
122 | val n = coeff1 * l + coeff2 * m + coeff3 * s - 1
123 | val n1 = coeff1 * ldt + coeff2 * mdt + coeff3 * sdt
124 | val n2 = coeff1 * ldt2 + coeff2 * mdt2 + coeff3 * sdt2
125 |
126 | val u_n = n1 / (n1 * n1 - 0.5 * n * n2)
127 | val t_n = -n * u_n
128 |
129 | return if (u_n >= 0) t_n else Double.MAX_VALUE
130 | }
131 |
132 | // Finds intersection of the line defined by
133 | // L = L0 * (1 - t) + t * L1
134 | // C = t * C1
135 | // a and b must be normalized so a^2 + b^2 == 1
136 | private fun findGamutIntersection(
137 | cusp: LC,
138 | a: Double, b: Double,
139 | l1: Double, c1: Double,
140 | l0: Double,
141 | ): Double {
142 | // Find the intersection for upper and lower half separately
143 | if (((l1 - l0) * cusp.C - (cusp.L - l0) * c1) <= 0) {
144 | // Lower half
145 | return cusp.C * l0 / (c1 * cusp.L + cusp.C * (l0 - l1))
146 | }
147 |
148 | // Upper half
149 |
150 | // First intersect with triangle
151 | val t = cusp.C * (l0 - 1) / (c1 * (cusp.L - 1) + cusp.C * (l0 - l1))
152 |
153 | // Then one step Halley's method
154 | run {
155 | val dL = l1 - l0
156 | val dC = c1
157 |
158 | val k_l = +0.3963377774 * a + 0.2158037573 * b
159 | val k_m = -0.1055613458 * a - 0.0638541728 * b
160 | val k_s = -0.0894841775 * a - 1.2914855480 * b
161 |
162 | val l_dt = dL + dC * k_l
163 | val m_dt = dL + dC * k_m
164 | val s_dt = dL + dC * k_s
165 |
166 | // If higher accuracy is required, 2 or 3 iterations of the following block can be used:
167 | run {
168 | val L = l0 * (1.0 - t) + t * l1
169 | val C = t * c1
170 |
171 | val l_ = L + C * k_l
172 | val m_ = L + C * k_m
173 | val s_ = L + C * k_s
174 |
175 | val l = cube(l_)
176 | val m = cube(m_)
177 | val s = cube(s_)
178 |
179 | val ldt = 3 * l_dt * square(l_)
180 | val mdt = 3 * m_dt * square(m_)
181 | val sdt = 3 * s_dt * square(s_)
182 |
183 | val ldt2 = 6 * square(l_dt) * l_
184 | val mdt2 = 6 * square(m_dt) * m_
185 | val sdt2 = 6 * square(s_dt) * s_
186 |
187 | val t_r = halleyTerm(
188 | l, m, s, ldt, mdt, sdt, ldt2, mdt2, sdt2,
189 | 4.0767416621, -3.3077115913, 0.2309699292,
190 | )
191 | val t_g = halleyTerm(
192 | l, m, s, ldt, mdt, sdt, ldt2, mdt2, sdt2,
193 | -1.2681437731, 2.6097574011, -0.3413193965,
194 | )
195 | val t_b = halleyTerm(
196 | l, m, s, ldt, mdt, sdt, ldt2, mdt2, sdt2,
197 | -0.0041960863, -0.7034186147, 1.7076147010,
198 | )
199 |
200 | return t + min(t_r, min(t_g, t_b))
201 | }
202 | }
203 | }
204 |
205 | @JvmSynthetic
206 | internal fun calcAdaptiveMidL(L: Double, C: Double, alpha: Double): Double {
207 | val Ld = L - 0.5
208 | val e1 = 0.5 + abs(Ld) + alpha * C
209 | return 0.5*(1.0 + sign(Ld)*(e1 - sqrt(e1*e1 - 2.0 *abs(Ld))))
210 | }
211 |
212 | private fun clip(
213 | rgb: LinearSrgb,
214 | method: ClipMethod,
215 | alpha: Double,
216 | lab: Oklab,
217 | ): LinearSrgb {
218 | if (rgb.isInGamut()) {
219 | return rgb
220 | }
221 |
222 | val L = lab.L
223 | val C = max(CLIP_EPSILON, sqrt(lab.a * lab.a + lab.b * lab.b))
224 | val a_ = lab.a / C
225 | val b_ = lab.b / C
226 |
227 | val cusp = findCusp(a_, b_)
228 |
229 | val l0 = when (method) {
230 | // l0 = target L
231 | ClipMethod.PRESERVE_LIGHTNESS -> L.coerceIn(0.0, 1.0)
232 |
233 | // l0 = 0.5 (mid grayscale)
234 | ClipMethod.PROJECT_TO_MID -> 0.5
235 | // l0 = L_cusp
236 | ClipMethod.PROJECT_TO_LCUSP -> cusp.L
237 |
238 | // Adaptive l0 towards l0=0.5
239 | ClipMethod.ADAPTIVE_TOWARDS_MID -> calcAdaptiveMidL(L, C, alpha)
240 | // Adaptive l0 towards l0=L_cusp
241 | ClipMethod.ADAPTIVE_TOWARDS_LCUSP -> {
242 | val Ld = L - cusp.L
243 | val k = 2.0 * (if (Ld > 0) 1.0 - cusp.L else cusp.L)
244 |
245 | val e1 = 0.5*k + abs(Ld) + alpha * C/k
246 | cusp.L + 0.5 * (sign(Ld) * (e1 - sqrt(e1 * e1 - 2.0 * k * abs(Ld))))
247 | }
248 | }
249 |
250 | val t = findGamutIntersection(cusp, a_, b_, L, C, l0)
251 | val L_clipped = l0 * (1 - t) + t * L
252 | val C_clipped = t * C
253 |
254 | return Oklab(L_clipped, C_clipped * a_, C_clipped * b_).toLinearSrgb()
255 | }
256 |
257 | /**
258 | * Gamut clipping method to use.
259 | * Hue is always preserved, regardless of the method chosen here.
260 | */
261 | public enum class ClipMethod {
262 | /**
263 | * Preserve the target lightness (e.g. for contrast) by reducing chroma.
264 | */
265 | PRESERVE_LIGHTNESS,
266 |
267 | /**
268 | * Project towards neutral 50% gray.
269 | */
270 | PROJECT_TO_MID,
271 | /**
272 | * Project towards neutral gray at the lightness with the most possible chroma for this hue.
273 | */
274 | PROJECT_TO_LCUSP,
275 |
276 | /**
277 | * A mix of lightness-preserving chroma reduction and projecting towards neutral 50% gray.
278 | */
279 | ADAPTIVE_TOWARDS_MID,
280 | /**
281 | * A mix of lightness-preserving chroma reduction and projecting towards neutral gray with max chroma.
282 | */
283 | ADAPTIVE_TOWARDS_LCUSP,
284 | }
285 |
286 | private data class LC(
287 | val L: Double,
288 | val C: Double,
289 | )
290 |
291 | private enum class SaturationCoefficients(
292 | val k0: Double,
293 | val k1: Double,
294 | val k2: Double,
295 | val k3: Double,
296 | val k4: Double,
297 | val wl: Double,
298 | val wm: Double,
299 | val ws: Double,
300 | ) {
301 | RED(
302 | k0 = +1.19086277,
303 | k1 = +1.76576728,
304 | k2 = +0.59662641,
305 | k3 = +0.75515197,
306 | k4 = +0.56771245,
307 | wl = +4.0767416621,
308 | wm = -3.3077115913,
309 | ws = +0.2309699292,
310 | ),
311 |
312 | GREEN(
313 | k0 = +0.73956515,
314 | k1 = -0.45954404,
315 | k2 = +0.08285427,
316 | k3 = +0.12541070,
317 | k4 = +0.14503204,
318 | wl = -1.2681437731,
319 | wm = +2.6097574011,
320 | ws = -0.3413193965,
321 | ),
322 |
323 | BLUE(
324 | k0 = +1.35733652,
325 | k1 = -0.00915799,
326 | k2 = -1.15130210,
327 | k3 = -0.50559606,
328 | k4 = +0.00692167,
329 | wl = -0.0041960863,
330 | wm = -0.7034186147,
331 | ws = +1.7076147010,
332 | ),
333 | }
334 |
335 | /**
336 | * Convert this Oklab color to linear sRGB, and clip it to sRGB gamut boundaries if it's not already within gamut.
337 | *
338 | * Out-of-gamut colors are mapped using gamut intersection in a 2D plane, and hue is always preserved. Lightness and
339 | * chroma are changed depending on the clip method; see [ClipMethod] for details.
340 | *
341 | * @see sRGB gamut clipping
342 | * @return clipped color in linear sRGB
343 | */
344 | @JvmStatic
345 | @JvmOverloads
346 | public fun Oklab.clipToLinearSrgb(
347 | /**
348 | * Gamut clipping method to use. Different methods preserve different attributes and make different trade-offs.
349 | * @see [ClipMethod]
350 | */
351 | method: ClipMethod = ClipMethod.PRESERVE_LIGHTNESS,
352 | /**
353 | * For adaptive clipping methods only: the extent to which lightness should be preserved rather than chroma.
354 | * Larger numbers will preserve chroma more than lightness, and vice versa.
355 | *
356 | * This value is ignored when using other (non-adaptive) clipping methods.
357 | */
358 | alpha: Double = 0.05,
359 | ): LinearSrgb = clip(
360 | rgb = toLinearSrgb(),
361 | method = method,
362 | alpha = alpha,
363 | lab = this,
364 | )
365 | }
366 |
--------------------------------------------------------------------------------
/src/commonMain/kotlin/dev/kdrag0n/colorkt/cam/Zcam.kt:
--------------------------------------------------------------------------------
1 | package dev.kdrag0n.colorkt.cam
2 |
3 | import dev.kdrag0n.colorkt.Color
4 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs
5 | import dev.kdrag0n.colorkt.ucs.lch.Lch
6 | import dev.kdrag0n.colorkt.util.math.cbrt
7 | import dev.kdrag0n.colorkt.util.math.square
8 | import dev.kdrag0n.colorkt.util.math.toDegrees
9 | import dev.kdrag0n.colorkt.util.math.toRadians
10 | import kotlin.jvm.*
11 | import kotlin.math.*
12 |
13 | /**
14 | * A color modeled by the ZCAM color appearance model, which provides a variety of perceptual color attributes.
15 | * This color appearance model is designed with HDR in mind so it only accepts *absolute* CIE XYZ values scaled by the
16 | * absolute luminance of the modeled display, unlike SDR color spaces that accept relative luminance.
17 | *
18 | * Most attributes are optional in the constructor because don't need to be present together. All ZCAM colors must have:
19 | * - brightness OR lightness
20 | * - colorfulness OR chroma OR saturation OR vividness OR blackness OR whiteness
21 | * - hue
22 | * - viewing conditions
23 | *
24 | * @see ZCAM, a colour appearance model based on a high dynamic range uniform colour space
25 | */
26 | // Math code looks better with underscores, and we want to match the paper
27 | @Suppress("LocalVariableName", "PrivatePropertyName", "PropertyName")
28 | public data class Zcam(
29 | // 1D
30 | /** Absolute brightness. **/
31 | val brightness: Double = Double.NaN,
32 | /** Brightness relative to the reference white, from 0 to 100. **/
33 | override val lightness: Double = Double.NaN,
34 | /** Absolute colorfulness. **/
35 | val colorfulness: Double = Double.NaN,
36 | /** Colorfulness relative to the reference white. **/
37 | override val chroma: Double = Double.NaN,
38 | /** Hue from 0 to 360 degrees. **/
39 | override val hue: Double,
40 | /* hue composition is not supported */
41 |
42 | // 2D
43 | /** Chroma relative to lightness. 2D attribute. **/
44 | val saturation: Double = Double.NaN,
45 | /** Distance from neutral black. 2D attribute. **/
46 | val vividness: Double = Double.NaN,
47 | /** Amount of black. 2D attribute. **/
48 | val blackness: Double = Double.NaN,
49 | /** Amount of white. 2D attribute. **/
50 | val whiteness: Double = Double.NaN,
51 |
52 | /** Viewing conditions used to model this color. **/
53 | val viewingConditions: ViewingConditions,
54 | ) : Color, Lch {
55 | // Aliases to match the paper
56 | /** Alias for [brightness]. **/
57 | val Qz: Double get() = brightness
58 | /** Alias for [lightness]. **/
59 | val Jz: Double get() = lightness
60 | /** Alias for [colorfulness]. **/
61 | val Mz: Double get() = colorfulness
62 | /** Alias for [chroma]. **/
63 | val Cz: Double get() = chroma
64 | /** Alias for [hue]. **/
65 | val hz: Double get() = hue
66 | /** Alias for [saturation]. **/
67 | val Sz: Double get() = saturation
68 | /** Alias for [vividness]. **/
69 | val Vz: Double get() = vividness
70 | /** Alias for [blackness]. **/
71 | val Kz: Double get() = blackness
72 | /** Alias for [whiteness]. **/
73 | val Wz: Double get() = whiteness
74 |
75 | /**
76 | * Convert this color to the CIE XYZ color space, with absolute luminance.
77 | *
78 | * @see dev.kdrag0n.colorkt.tristimulus.CieXyzAbs
79 | * @return Color in absolute XYZ
80 | */
81 | public fun toXyzAbs(
82 | luminanceSource: LuminanceSource,
83 | chromaSource: ChromaSource,
84 | ): CieXyzAbs {
85 | val cond = viewingConditions
86 | val Qz_w = cond.Qz_w
87 |
88 | /* Step 1 */
89 | // Achromatic response
90 | val Iz = when (luminanceSource) {
91 | LuminanceSource.BRIGHTNESS -> Qz / cond.Iz_coeff
92 | LuminanceSource.LIGHTNESS -> (Jz * Qz_w) / (cond.Iz_coeff * 100.0)
93 | }.pow(cond.Qz_denom / (1.6 * cond.surroundFactor))
94 |
95 | /* Step 2 */
96 | // Chroma
97 | val Cz = when (chromaSource) {
98 | ChromaSource.CHROMA -> Cz
99 | ChromaSource.COLORFULNESS -> Double.NaN // not used
100 | ChromaSource.SATURATION -> (Qz * square(Sz)) / (100.0 * Qz_w * cond.Sz_denom)
101 | ChromaSource.VIVIDNESS -> sqrt((square(Vz) - square(Jz - 58)) / 3.4)
102 | ChromaSource.BLACKNESS -> sqrt((square((100 - Kz) / 0.8) - square(Jz)) / 8)
103 | ChromaSource.WHITENESS -> sqrt(square(100.0 - Wz) - square(100.0 - Jz))
104 | }
105 |
106 | /* Step 3 is missing because hue composition is not supported */
107 |
108 | /* Step 4 */
109 | // ... and back to colorfulness
110 | val Mz = when (chromaSource) {
111 | ChromaSource.COLORFULNESS -> Mz
112 | else -> (Cz * Qz_w) / 100
113 | }
114 | val ez = hpToEz(hz)
115 | val Cz_p = ((Mz * cond.Mz_denom) /
116 | // Paper specifies pow(1.3514) but this extra precision is necessary for accurate inversion
117 | (100.0 * ez.pow(0.068) * cond.ez_coeff)).pow(1.0 / 0.37 / 2)
118 | val hzRad = hz.toRadians()
119 | val az = Cz_p * cos(hzRad)
120 | val bz = Cz_p * sin(hzRad)
121 |
122 | /* Step 5 */
123 | val I = Iz + EPSILON
124 |
125 | val r = pq(I + 0.2772100865*az + 0.1160946323*bz)
126 | val g = pq(I)
127 | val b = pq(I + 0.0425858012*az + -0.7538445799*bz)
128 |
129 | val xp = 1.9242264358*r + -1.0047923126*g + 0.0376514040*b
130 | val yp = 0.3503167621*r + 0.7264811939*g + -0.0653844229*b
131 | val z = -0.0909828110*r + -0.3127282905*g + 1.5227665613*b
132 |
133 | val x = (xp + (B - 1)*z) / B
134 | val y = (yp + (G - 1)*x) / G
135 |
136 | return CieXyzAbs(x, y, z)
137 | }
138 |
139 | /**
140 | * ZCAM attributes that can be used to calculate luminance in the inverse model.
141 | */
142 | public enum class LuminanceSource {
143 | /**
144 | * Use the brightness attribute to calculate luminance in the inverse model.
145 | * Lightness will be ignored.
146 | */
147 | BRIGHTNESS,
148 | /**
149 | * Use the lightness attribute to calculate luminance in the inverse model.
150 | * Brightness will be ignored.
151 | */
152 | LIGHTNESS,
153 | }
154 |
155 | /**
156 | * ZCAM attributes that can be used to calculate chroma (colorfulness) in the inverse model.
157 | */
158 | public enum class ChromaSource {
159 | /**
160 | * Use the chroma attribute to calculate luminance in the inverse model.
161 | * Colorfulness, saturation, vividness, blackness, and whiteness will be ignored.
162 | */
163 | CHROMA,
164 | /**
165 | * Use the colorfulness attribute to calculate luminance in the inverse model.
166 | * Chroma, saturation, vividness, blackness, and whiteness will be ignored.
167 | */
168 | COLORFULNESS,
169 | /**
170 | * Use the saturation attribute to calculate luminance in the inverse model.
171 | * Chroma, colorfulness, vividness, blackness, and whiteness will be ignored.
172 | */
173 | SATURATION,
174 | /**
175 | * Use the vividness attribute to calculate luminance in the inverse model.
176 | * Chroma, colorfulness, saturation, blackness, and whiteness will be ignored.
177 | */
178 | VIVIDNESS,
179 | /**
180 | * Use the blackness attribute to calculate luminance in the inverse model.
181 | * Chroma, colorfulness, saturation, vividness, and whiteness will be ignored.
182 | */
183 | BLACKNESS,
184 | /**
185 | * Use the whiteness attribute to calculate luminance in the inverse model.
186 | * Chroma, colorfulness, saturation, vividness, and blackness will be ignored.
187 | */
188 | WHITENESS,
189 | }
190 |
191 | /**
192 | * The conditions under which a color modeled by ZCAM will be viewed. This is defined by the luminance of the
193 | * adapting field, luminance of the background, and surround factor.
194 | *
195 | * For performance, viewing conditions should be created once and reused for all ZCAM conversions unless they have
196 | * changed. Creating an instance of ViewingConditions performs calculations that are reused throughout the ZCAM
197 | * model.
198 | */
199 | public data class ViewingConditions(
200 | /**
201 | * Surround factor, which models the surround field (distant background).
202 | */
203 | val surroundFactor: Double,
204 |
205 | /**
206 | * Absolute luminance of the adapting field. This can be calculated as L_w * [backgroundLuminance] / 100 where
207 | * L_w is the luminance of [referenceWhite], but it is a user-controlled parameter for flexibility.
208 | */
209 | val adaptingLuminance: Double,
210 | /**
211 | * Absolute luminance of the background.
212 | */
213 | val backgroundLuminance: Double,
214 |
215 | /**
216 | * Reference white point in absolute XYZ.
217 | */
218 | val referenceWhite: CieXyzAbs,
219 | ) {
220 | @JvmSynthetic @JvmField internal val Iz_coeff: Double
221 | @JvmSynthetic @JvmField internal val ez_coeff: Double
222 | @JvmSynthetic @JvmField internal val Qz_denom: Double
223 | @JvmSynthetic @JvmField internal val Sz_coeff: Double
224 | @JvmSynthetic @JvmField internal val Sz_denom: Double
225 | @JvmSynthetic @JvmField internal val Mz_denom: Double
226 | @JvmSynthetic @JvmField internal val Qz_w: Double
227 |
228 | init {
229 | val F_b = sqrt(backgroundLuminance / referenceWhite.y)
230 | val F_l = 0.171 * cbrt(adaptingLuminance) * (1.0 - exp(-48.0 / 9.0 * adaptingLuminance))
231 |
232 | Iz_coeff = 2700.0 * surroundFactor.pow(2.2) * sqrt(F_b) * F_l.pow(0.2)
233 | ez_coeff = F_l.pow(0.2)
234 | Qz_denom = F_b.pow(0.12)
235 | Sz_coeff = F_l.pow(0.6)
236 | Sz_denom = F_l.pow(1.2)
237 |
238 | val Iz_w = xyzToIzazbz(referenceWhite)[0]
239 | Mz_denom = Iz_w.pow(0.78) * F_b.pow(0.1)
240 |
241 | // Depends on coefficients computed above
242 | Qz_w = izToQz(Iz_w, this)
243 | }
244 |
245 | public companion object {
246 | /**
247 | * Surround factor for dark viewing conditions.
248 | */
249 | public const val SURROUND_DARK: Double = 0.525
250 | /**
251 | * Surround factor for dim viewing conditions.
252 | */
253 | public const val SURROUND_DIM: Double = 0.59
254 | /**
255 | * Surround factor for average viewing conditions.
256 | */
257 | public const val SURROUND_AVERAGE: Double = 0.69
258 | }
259 | }
260 |
261 | public companion object {
262 | // Constants
263 | private const val B = 1.15
264 | private const val G = 0.66
265 | private const val C1 = 3424.0 / 4096
266 | private const val C2 = 2413.0 / 128
267 | private const val C3 = 2392.0 / 128
268 | private const val ETA = 2610.0 / 16384
269 | private const val RHO = 1.7 * 2523.0 / 32
270 | private const val EPSILON = 3.7035226210190005e-11
271 |
272 | // Transfer function and inverse
273 | private fun pq(x: Double): Double {
274 | val num = C1 - x.pow(1.0/RHO)
275 | val denom = C3*x.pow(1.0/RHO) - C2
276 |
277 | return 10000.0 * (num / denom).pow(1.0/ETA)
278 | }
279 | private fun pqInv(x: Double): Double {
280 | val num = C1 + C2*(x / 10000).pow(ETA)
281 | val denom = 1.0 + C3*(x / 10000).pow(ETA)
282 |
283 | return (num / denom).pow(RHO)
284 | }
285 |
286 | // Intermediate conversion, also used in ViewingConditions
287 | private fun xyzToIzazbz(xyz: CieXyzAbs): DoubleArray {
288 | // This equation (#4) is wrong in the paper; below is the correct version.
289 | // It can be derived from the inverse model (supplementary paper) or the original Jzazbz paper.
290 | val xp = B*xyz.x - (B-1)*xyz.z
291 | val yp = G*xyz.y - (G-1)*xyz.x
292 |
293 | val rp = pqInv(0.41478972*xp + 0.579999*yp + 0.0146480*xyz.z)
294 | val gp = pqInv(-0.2015100*xp + 1.120649*yp + 0.0531008*xyz.z)
295 | val bp = pqInv(-0.0166008*xp + 0.264800*yp + 0.6684799*xyz.z)
296 |
297 | val az = 3.524000*rp + -4.066708*gp + 0.542708*bp
298 | val bz = 0.199076*rp + 1.096799*gp + -1.295875*bp
299 | val Iz = gp - EPSILON
300 |
301 | return doubleArrayOf(Iz, az, bz)
302 | }
303 |
304 | // Shared between forward and inverse models
305 | private fun hpToEz(hp: Double) = 1.015 + cos((89.038 + hp).toRadians())
306 | private fun izToQz(Iz: Double, cond: ViewingConditions) =
307 | cond.Iz_coeff * Iz.pow((1.6 * cond.surroundFactor) / cond.Qz_denom)
308 |
309 | /**
310 | * Get the perceptual appearance attributes of this color using the [Zcam] color appearance model.
311 | * Input colors must be relative to a reference white of D65, absolute luminance notwithstanding.
312 | *
313 | * @return [Zcam] attributes
314 | */
315 | @JvmStatic
316 | @JvmOverloads
317 | @JvmName("fromXyzAbs")
318 | public fun CieXyzAbs.toZcam(
319 | /**
320 | * Conditions under which the color will be viewed.
321 | */
322 | cond: ViewingConditions,
323 | /**
324 | * Whether to calculate 2D color attributes (attributes that depend on the result of multiple 1D
325 | * attributes). This includes saturation (Sz), vividness (Vz), blackness (Kz), and whiteness (Wz).
326 | *
327 | * These attributes are unnecessary in most cases, so you can set this to false and speed up the
328 | * calculations.
329 | */
330 | include2D: Boolean = true,
331 | ): Zcam {
332 | /* Step 2 */
333 | // Raw responses (similar to Jzazbz)
334 | val (Iz, az, bz) = xyzToIzazbz(this)
335 |
336 | /* Step 3 */
337 | // Hue angle
338 | val hzRaw = atan2(bz, az).toDegrees()
339 | val hz = if (hzRaw < 0) hzRaw + 360 else hzRaw
340 |
341 | /* Step 4 */
342 | // Eccentricity factor
343 | val ez = hpToEz(hz)
344 |
345 | /* Step 5 */
346 | // Brightness
347 | val Qz = izToQz(Iz, cond)
348 | val Qz_w = cond.Qz_w
349 |
350 | // Lightness
351 | val Jz = 100.0 * (Qz / Qz_w)
352 |
353 | // Colorfulness
354 | val Mz = 100.0 * (square(az) + square(bz)).pow(0.37) *
355 | ((ez.pow(0.068) * cond.ez_coeff) / cond.Mz_denom)
356 |
357 | // Chroma
358 | val Cz = 100.0 * (Mz / Qz_w)
359 |
360 | /* Step 6 */
361 | // Saturation
362 | val Sz = if (include2D) 100.0 * cond.Sz_coeff * sqrt(Mz / Qz) else Double.NaN
363 |
364 | // Vividness, blackness, whiteness
365 | val Vz = if (include2D) sqrt(square(Jz - 58) + 3.4 * square(Cz)) else Double.NaN
366 | val Kz = if (include2D) 100.0 - 0.8 * sqrt(square(Jz) + 8.0 * square(Cz)) else Double.NaN
367 | val Wz = if (include2D) 100.0 - sqrt(square(100.0 - Jz) + square(Cz)) else Double.NaN
368 |
369 | return Zcam(
370 | brightness = Qz,
371 | lightness = Jz,
372 | colorfulness = Mz,
373 | chroma = Cz,
374 | hue = hz,
375 |
376 | saturation = Sz,
377 | vividness = Vz,
378 | blackness = Kz,
379 | whiteness = Wz,
380 |
381 | viewingConditions = cond,
382 | )
383 | }
384 | }
385 | }
386 |
--------------------------------------------------------------------------------