├── 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 | 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 | 6 | 7 | 9 | 10 | 11 | 15 | 16 | 17 | 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 | [![Latest version on Maven Central](https://img.shields.io/maven-central/v/dev.kdrag0n/colorkt)](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 | --------------------------------------------------------------------------------