From 6df657be01e9df5c4eb729b4c89ed2cc5439a643 Mon Sep 17 00:00:00 2001 From: DatLag Date: Mon, 13 Feb 2023 23:20:17 +0100 Subject: [PATCH] Initial Kotlin Multiplatform Support --- kotlin/lib/androidMain/kotlin/ExtendTheme.kt | 32 + kotlin/lib/commonMain/kotlin/blend/Blend.kt | 72 ++ .../commonMain/kotlin/common/BiFunction.kt | 10 + .../lib/commonMain/kotlin/common/Function.kt | 17 + .../commonMain/kotlin/contrast/Contrast.kt | 194 ++++++ .../kotlin/dislike/DislikeAnalyzer.kt | 36 + .../kotlin/dynamiccolor/DynamicColor.kt | 611 +++++++++++++++++ .../dynamiccolor/ToneDeltaConstraints.kt | 21 + .../kotlin/dynamiccolor/TonePolarity.kt | 8 + kotlin/lib/commonMain/kotlin/hct/Cam16.kt | 343 ++++++++++ kotlin/lib/commonMain/kotlin/hct/Hct.kt | 140 ++++ kotlin/lib/commonMain/kotlin/hct/HctSolver.kt | 633 ++++++++++++++++++ .../kotlin/hct/ViewingConditions.kt | 123 ++++ .../commonMain/kotlin/palettes/CorePalette.kt | 59 ++ .../kotlin/palettes/TonalPalette.kt | 63 ++ .../kotlin/quantize/PointProvider.kt | 8 + .../kotlin/quantize/PointProviderLab.kt | 39 ++ .../commonMain/kotlin/quantize/Quantizer.kt | 6 + .../kotlin/quantize/QuantizerCelebi.kt | 34 + .../kotlin/quantize/QuantizerMap.kt | 17 + .../kotlin/quantize/QuantizerResult.kt | 4 + .../kotlin/quantize/QuantizerWsmeans.kt | 180 +++++ .../commonMain/kotlin/quantize/QuantizerWu.kt | 347 ++++++++++ .../commonMain/kotlin/scheme/DynamicScheme.kt | 45 ++ kotlin/lib/commonMain/kotlin/scheme/Scheme.kt | 525 +++++++++++++++ .../commonMain/kotlin/scheme/SchemeContent.kt | 42 ++ .../kotlin/scheme/SchemeExpressive.kt | 32 + .../kotlin/scheme/SchemeFidelity.kt | 36 + .../kotlin/scheme/SchemeMonochrome.kt | 18 + .../commonMain/kotlin/scheme/SchemeNeutral.kt | 18 + .../kotlin/scheme/SchemeTonalSpot.kt | 20 + .../commonMain/kotlin/scheme/SchemeVibrant.kt | 28 + .../lib/commonMain/kotlin/scheme/Variant.kt | 12 + kotlin/lib/commonMain/kotlin/score/Score.kt | 134 ++++ .../kotlin/temperature/TemperaturCache.kt | 294 ++++++++ kotlin/lib/commonMain/kotlin/theme/Theme.kt | 56 ++ .../lib/commonMain/kotlin/utils/ColorUtils.kt | 247 +++++++ .../lib/commonMain/kotlin/utils/MathUtils.kt | 124 ++++ .../lib/commonMain/kotlin/utils/ThemeUtils.kt | 94 +++ kotlin/lib/jvmMain/kotlin/ExtendTheme.kt | 29 + 40 files changed, 4751 insertions(+) create mode 100644 kotlin/lib/androidMain/kotlin/ExtendTheme.kt create mode 100644 kotlin/lib/commonMain/kotlin/blend/Blend.kt create mode 100644 kotlin/lib/commonMain/kotlin/common/BiFunction.kt create mode 100644 kotlin/lib/commonMain/kotlin/common/Function.kt create mode 100644 kotlin/lib/commonMain/kotlin/contrast/Contrast.kt create mode 100644 kotlin/lib/commonMain/kotlin/dislike/DislikeAnalyzer.kt create mode 100644 kotlin/lib/commonMain/kotlin/dynamiccolor/DynamicColor.kt create mode 100644 kotlin/lib/commonMain/kotlin/dynamiccolor/ToneDeltaConstraints.kt create mode 100644 kotlin/lib/commonMain/kotlin/dynamiccolor/TonePolarity.kt create mode 100644 kotlin/lib/commonMain/kotlin/hct/Cam16.kt create mode 100644 kotlin/lib/commonMain/kotlin/hct/Hct.kt create mode 100644 kotlin/lib/commonMain/kotlin/hct/HctSolver.kt create mode 100644 kotlin/lib/commonMain/kotlin/hct/ViewingConditions.kt create mode 100644 kotlin/lib/commonMain/kotlin/palettes/CorePalette.kt create mode 100644 kotlin/lib/commonMain/kotlin/palettes/TonalPalette.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/PointProvider.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/PointProviderLab.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/Quantizer.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/QuantizerCelebi.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/QuantizerMap.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/QuantizerResult.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/QuantizerWsmeans.kt create mode 100644 kotlin/lib/commonMain/kotlin/quantize/QuantizerWu.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/DynamicScheme.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/Scheme.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/SchemeContent.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/SchemeExpressive.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/SchemeFidelity.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/SchemeMonochrome.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/SchemeNeutral.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/SchemeTonalSpot.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/SchemeVibrant.kt create mode 100644 kotlin/lib/commonMain/kotlin/scheme/Variant.kt create mode 100644 kotlin/lib/commonMain/kotlin/score/Score.kt create mode 100644 kotlin/lib/commonMain/kotlin/temperature/TemperaturCache.kt create mode 100644 kotlin/lib/commonMain/kotlin/theme/Theme.kt create mode 100644 kotlin/lib/commonMain/kotlin/utils/ColorUtils.kt create mode 100644 kotlin/lib/commonMain/kotlin/utils/MathUtils.kt create mode 100644 kotlin/lib/commonMain/kotlin/utils/ThemeUtils.kt create mode 100644 kotlin/lib/jvmMain/kotlin/ExtendTheme.kt diff --git a/kotlin/lib/androidMain/kotlin/ExtendTheme.kt b/kotlin/lib/androidMain/kotlin/ExtendTheme.kt new file mode 100644 index 0000000..394ae86 --- /dev/null +++ b/kotlin/lib/androidMain/kotlin/ExtendTheme.kt @@ -0,0 +1,32 @@ +import android.graphics.Bitmap +import quantize.QuantizerCelebi +import score.Score +import theme.CustomColor +import theme.Theme +import utils.ThemeUtils + +// Slow but copyPixelsToBuffer didn't work as expected +fun ThemeUtils.themeFromImage(image: Bitmap, vararg customColors: CustomColor = emptyArray()): Theme { + val pixelColors: MutableList = mutableListOf() + + for (y in 0 until image.height) { + for (x in 0 until image.width) { + val pixel = image.getPixel(x, y) + var argb = 0 + argb += pixel and 0xff shl 24 + argb += pixel and 0xff + argb += pixel and 0xff shl 8 + argb += pixel and 0xff shl 16 + + pixelColors.add(argb) + } + } + + val result = QuantizerCelebi.quantize(pixelColors.toIntArray(), 128) + val ranked = Score.score(result) + val top = ranked[0] + + return themeFromSourceColor(top, *customColors) +} + +fun Bitmap.createTheme(vararg customColors: CustomColor = emptyArray()) = ThemeUtils.themeFromImage(this, *customColors) diff --git a/kotlin/lib/commonMain/kotlin/blend/Blend.kt b/kotlin/lib/commonMain/kotlin/blend/Blend.kt new file mode 100644 index 0000000..2c3e03b --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/blend/Blend.kt @@ -0,0 +1,72 @@ +package blend + +import hct.Cam16 +import hct.Hct +import utils.ColorUtils.lstarFromArgb +import utils.MathUtils.differenceDegrees +import utils.MathUtils.rotationDirection +import utils.MathUtils.sanitizeDegreesDouble + +/** Functions for blending in HCT and CAM16. */ +object Blend { + /** + * Blend the design color's HCT hue towards the key color's HCT hue, in a way that leaves the + * original color recognizable and recognizably shifted towards the key color. + * + * @param designColor ARGB representation of an arbitrary color. + * @param sourceColor ARGB representation of the main theme color. + * @return The design color with a hue shifted towards the system's color, a slightly + * warmer/cooler variant of the design color's hue. + */ + fun harmonize(designColor: Int, sourceColor: Int): Int { + val fromHct = Hct.fromInt(designColor) + val toHct = Hct.fromInt(sourceColor) + val differenceDegrees = differenceDegrees(fromHct.hue, toHct.hue) + val rotationDegrees = (differenceDegrees * 0.5).coerceAtMost(15.0) + val outputHue = sanitizeDegreesDouble( + fromHct.hue + + rotationDegrees * rotationDirection(fromHct.hue, toHct.hue) + ) + return Hct.from(outputHue, fromHct.chroma, fromHct.tone).toInt() + } + + /** + * Blends hue from one color into another. The chroma and tone of the original color are + * maintained. + * + * @param from ARGB representation of color + * @param to ARGB representation of color + * @param amount how much blending to perform; 0.0 >= and <= 1.0 + * @return from, with a hue blended towards to. Chroma and tone are constant. + */ + fun hctHue(from: Int, to: Int, amount: Double): Int { + val ucs = cam16Ucs(from, to, amount) + val ucsCam = Cam16.fromInt(ucs) + val fromCam = Cam16.fromInt(from) + val blended = Hct.from(ucsCam.hue, fromCam.chroma, lstarFromArgb(from)) + return blended.toInt() + } + + /** + * Blend in CAM16-UCS space. + * + * @param from ARGB representation of color + * @param to ARGB representation of color + * @param amount how much blending to perform; 0.0 >= and <= 1.0 + * @return from, blended towards to. Hue, chroma, and tone will change. + */ + fun cam16Ucs(from: Int, to: Int, amount: Double): Int { + val fromCam = Cam16.fromInt(from) + val toCam = Cam16.fromInt(to) + val fromJ = fromCam.jstar + val fromA = fromCam.astar + val fromB = fromCam.bstar + val toJ = toCam.jstar + val toA = toCam.astar + val toB = toCam.bstar + val jstar = fromJ + (toJ - fromJ) * amount + val astar = fromA + (toA - fromA) * amount + val bstar = fromB + (toB - fromB) * amount + return Cam16.fromUcs(jstar, astar, bstar).toInt() + } +} diff --git a/kotlin/lib/commonMain/kotlin/common/BiFunction.kt b/kotlin/lib/commonMain/kotlin/common/BiFunction.kt new file mode 100644 index 0000000..b3a7610 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/common/BiFunction.kt @@ -0,0 +1,10 @@ +package common + +internal fun interface BiFunction { + + fun apply(t: T, u: U): R + + fun andThen(after: Function): BiFunction { + return BiFunction { t: T, u: U -> after.apply(apply(t, u)) } + } +} diff --git a/kotlin/lib/commonMain/kotlin/common/Function.kt b/kotlin/lib/commonMain/kotlin/common/Function.kt new file mode 100644 index 0000000..edf4e3f --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/common/Function.kt @@ -0,0 +1,17 @@ +package common + +internal fun interface Function { + fun apply(t: T): R + + fun compose(before: Function): Function { + return Function { v: V -> apply(before.apply(v)) } + } + + fun andThen(after: Function): Function { + return Function { t: T -> after.apply(apply(t)) } + } + + fun identity(): Function { + return Function { t: T -> t } + } +} diff --git a/kotlin/lib/commonMain/kotlin/contrast/Contrast.kt b/kotlin/lib/commonMain/kotlin/contrast/Contrast.kt new file mode 100644 index 0000000..896d72f --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/contrast/Contrast.kt @@ -0,0 +1,194 @@ +package contrast + +import utils.ColorUtils.lstarFromY +import utils.ColorUtils.yFromLstar +import kotlin.math.abs +import kotlin.math.max + +/** + * Color science for contrast utilities. + * + *

Utility methods for calculating contrast given two colors, or calculating a color given one + * color and a contrast ratio. + * + *

Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y + * becomes HCT's tone and L*a*b*'s' L*. + */ +object Contrast { + // The minimum contrast ratio of two colors. + // Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1. + const val RATIO_MIN = 1.0 + + // The maximum contrast ratio of two colors. + // Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100. + // If lighter == 100, darker = 0, ratio == 21. + const val RATIO_MAX = 21.0 + const val RATIO_30 = 3.0 + const val RATIO_45 = 4.5 + const val RATIO_70 = 7.0 + + // Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio + // with the color can be calculated. However, that luminance may not contrast as desired, i.e. the + // contrast ratio of the input color and the returned luminance may not reach the contrast ratio + // asked for. + // + // When the desired contrast ratio and the result contrast ratio differ by more than this amount, + // an error value should be returned, or the method should be documented as 'unsafe', meaning, + // it will return a valid luminance but that luminance may not meet the requested contrast ratio. + // + // 0.04 selected because it ensures the resulting ratio rounds to the same tenth. + private const val CONTRAST_RATIO_EPSILON = 0.04 + + // Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as + // perceptual accurate color spaces. + // + // To be displayed, they must gamut map to a "display space", one that has a defined limit on the + // number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB. + // Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must + // choose how to sacrifice accuracy in hue, saturation, and/or lightness. + // + // A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue, + // thus maintaining aesthetic intent, and reduce chroma until the color is in gamut. + // + // HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness, + // if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB + // color with, for example, 47.892 lightness, but not 47.891. + // + // To allow for this inherent incompatibility between perceptually accurate color spaces and + // display color spaces, methods that take a contrast ratio and luminance, and return a luminance + // that reaches that contrast ratio for the input luminance, purposefully darken/lighten their + // result such that the desired contrast ratio will be reached even if inaccuracy is introduced. + // + // 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough + // guarantee that as long as a percetual color space gamut maps lightness such that the resulting + // lightness rounds to the same as the requested, the desired contrast ratio will be reached. + private const val LUMINANCE_GAMUT_MAP_TOLERANCE = 0.4 + + /** + * Contrast ratio is a measure of legibility, its used to compare the lightness of two colors. + * This method is used commonly in industry due to its use by WCAG. + * + * + * To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness, + * also known as relative luminance. + * + * + * The equation is ratio = lighter Y + 5 / darker Y + 5. + */ + fun ratioOfYs(y1: Double, y2: Double): Double { + val lighter: Double = max(y1, y2) + val darker = if (lighter == y2) y1 else y2 + return (lighter + 5.0) / (darker + 5.0) + } + + /** + * Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perpectual + * luminance. + * + * + * Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is + * linear to number of photons, not to perception of lightness. Perceptual luminance, L* in + * L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're + * accurate to the eye. + * + * + * Y and L* are pure functions of each other, so it possible to use perceptually accurate color + * spaces, and measure contrast, and measure contrast in a much more understandable way: instead + * of a ratio, a linear difference. This allows a designer to determine what they need to adjust a + * color's lightness to in order to reach their desired contrast, instead of guessing & checking + * with hex codes. + */ + fun ratioOfTones(t1: Double, t2: Double): Double { + return ratioOfYs(yFromLstar(t1), yFromLstar(t2)) + } + + /** + * Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns -1 + * if ratio cannot be achieved. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + fun lighter(tone: Double, ratio: Double): Double { + if (tone < 0.0 || tone > 100.0) { + return -1.0 + } + // Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y. + val darkY = yFromLstar(tone) + val lightY = ratio * (darkY + 5.0) - 5.0 + if (lightY < 0.0 || lightY > 100.0) { + return -1.0 + } + val realContrast = ratioOfYs(lightY, darkY) + val delta = abs(realContrast - ratio) + if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { + return -1.0 + } + val returnValue = lstarFromY(lightY) + LUMINANCE_GAMUT_MAP_TOLERANCE + // NOMUTANTS--important validation step; functions it is calling may change implementation. + return if (returnValue < 0 || returnValue > 100) { + -1.0 + } else returnValue + } + + /** + * Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved. + * + * + * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in + * bounds return value may not reach the desired ratio. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + fun lighterUnsafe(tone: Double, ratio: Double): Double { + val lighterSafe = lighter(tone, ratio) + return if (lighterSafe < 0.0) 100.0 else lighterSafe + } + + /** + * Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns -1 + * if ratio cannot be achieved. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + fun darker(tone: Double, ratio: Double): Double { + if (tone < 0.0 || tone > 100.0) { + return -1.0 + } + // Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y. + val lightY = yFromLstar(tone) + val darkY = (lightY + 5.0) / ratio - 5.0 + if (darkY < 0.0 || darkY > 100.0) { + return -1.0 + } + val realContrast = ratioOfYs(lightY, darkY) + val delta = abs(realContrast - ratio) + if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { + return -1.0 + } + + // For information on 0.4 constant, see comment in lighter(tone, ratio). + val returnValue = lstarFromY(darkY) - LUMINANCE_GAMUT_MAP_TOLERANCE + // NOMUTANTS--important validation step; functions it is calling may change implementation. + return if (returnValue < 0 || returnValue > 100) { + -1.0 + } else returnValue + } + + /** + * Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved. + * + * + * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in + * bounds return value may not reach the desired ratio. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + fun darkerUnsafe(tone: Double, ratio: Double): Double { + val darkerSafe = darker(tone, ratio) + return max(0.0, darkerSafe) + } +} diff --git a/kotlin/lib/commonMain/kotlin/dislike/DislikeAnalyzer.kt b/kotlin/lib/commonMain/kotlin/dislike/DislikeAnalyzer.kt new file mode 100644 index 0000000..bb778b7 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/dislike/DislikeAnalyzer.kt @@ -0,0 +1,36 @@ +package dislike + +import hct.Hct +import kotlin.math.roundToInt + +/** + * Check and/or fix universally disliked colors. + * + *

Color science studies of color preference indicate universal distaste for dark yellow-greens, + * and also show this is correlated to distate for biological waste and rotting food. + * + *

See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook of Color + * Psychology (2015). + */ +object DislikeAnalyzer { + + /** + * Returns true if color is disliked. + * + * + * Disliked is defined as a dark yellow-green that is not neutral. + */ + fun isDisliked(hct: Hct): Boolean { + val huePasses = hct.hue.roundToInt() >= 90.0 && hct.hue.roundToInt() <= 111.0 + val chromaPasses = hct.chroma.roundToInt() > 16.0 + val tonePasses = hct.tone.roundToInt() < 70.0 + return huePasses && chromaPasses && tonePasses + } + + /** If color is disliked, lighten it to make it likable. */ + fun fixIfDisliked(hct: Hct): Hct { + return if (isDisliked(hct)) { + Hct.from(hct.hue, hct.chroma, 70.0) + } else hct + } +} diff --git a/kotlin/lib/commonMain/kotlin/dynamiccolor/DynamicColor.kt b/kotlin/lib/commonMain/kotlin/dynamiccolor/DynamicColor.kt new file mode 100644 index 0000000..0423338 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/dynamiccolor/DynamicColor.kt @@ -0,0 +1,611 @@ +package dynamiccolor + +import common.BiFunction +import common.Function +import contrast.Contrast +import contrast.Contrast.darkerUnsafe +import contrast.Contrast.lighterUnsafe +import contrast.Contrast.ratioOfTones +import hct.Hct +import palettes.TonalPalette +import scheme.DynamicScheme +import utils.MathUtils.clampDouble +import utils.MathUtils.clampInt +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * A color that adjusts itself based on UI state, represented by DynamicScheme. + * + *

This color automatically adjusts to accommodate a desired contrast level, or other adjustments + * such as differing in light mode versus dark mode, or what the theme is, or what the color that + * produced the theme is, etc. + * + *

Colors without backgrounds do not change tone when contrast changes. Colors with backgrounds + * become closer to their background as contrast lowers, and further when contrast increases. + * + *

Prefer the static constructors. They provide a much more simple interface, such as requiring + * just a hexcode, or just a hexcode and a background. + * + *

Ultimately, each component necessary for calculating a color, adjusting it for a desired + * contrast level, and ensuring it has a certain lightness/tone difference from another color, is + * provided by a function that takes a DynamicScheme and returns a value. This ensures ultimate + * flexibility, any desired behavior of a color for any design system, but it usually unnecessary. + * See the default constructor for more information. + */ +// Prevent lint for Function.apply not being available on Android before API level 14 (4.0.1). +// "AndroidJdkLibsChecker" for Function, "NewApi" for Function.apply(). +// A java_library Bazel rule with an Android constraint cannot skip these warnings without this +// annotation; another solution would be to create an android_library rule and supply +// AndroidManifest with an SDK set higher than 14. +internal class DynamicColor( + hue: Function, + chroma: Function, + tone: Function, + opacity: Function?, + background: Function?, + toneMinContrast: Function, + toneMaxContrast: Function, + toneDeltaConstraint: Function? +) { + val hue: Function + val chroma: Function + val tone: Function + val opacity: Function? + val background: Function? + val toneMinContrast: Function + val toneMaxContrast: Function + val toneDeltaConstraint: Function? + private val hctCache = HashMap() + + /** + * The base constructor for DynamicColor. + * + * + * Functional arguments allow overriding without risks that come with subclasses. _Strongly_ + * prefer using one of the static convenience constructors. This class is arguably too flexible to + * ensure it can support any scenario. + * + * + * For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with + * its background is principled and matches a11y guidance. That does not mean it's the desired + * approach for _every_ design system, and every color pairing, always, in every case. + * + * @param hue given DynamicScheme, return the hue in HCT of the output color. + * @param chroma given DynamicScheme, return chroma in HCT of the output color. + * @param tone given DynamicScheme, return tone in HCT of the output color. + * @param background given DynamicScheme, return the DynamicColor that is the background of this + * DynamicColor. When this is provided, automated adjustments to lower and raise contrast are + * made. + * @param toneMinContrast given DynamicScheme, return tone in HCT/L* in L*a*b* this color should + * be at minimum contrast. See toneMinContrastDefault for the default behavior, and strongly + * consider using it unless you have strong opinions on a11y. The static constructors use it. + * @param toneMaxContrast given DynamicScheme, return tone in HCT/L* in L*a*b* this color should + * be at maximum contrast. See toneMaxContrastDefault for the default behavior, and strongly + * consider using it unless you have strong opinions on a11y. The static constructors use it. + * @param toneDeltaConstraint given DynamicScheme, return a ToneDeltaConstraint instance that + * describes a requirement that this DynamicColor must always have some difference in tone/L* + * from another DynamicColor.

+ * Unlikely to be useful unless a design system has some distortions where colors that don't + * have a background/foreground relationship must have _some_ difference in tone, yet, not + * enough difference to create meaningful contrast. + */ + init { + this.hue = hue + this.chroma = chroma + this.tone = tone + this.opacity = opacity + this.background = background + this.toneMinContrast = toneMinContrast + this.toneMaxContrast = toneMaxContrast + this.toneDeltaConstraint = toneDeltaConstraint + } + + fun getArgb(scheme: DynamicScheme): Int { + val argb = getHct(scheme).toInt() + if (opacity == null) { + return argb + } + val percentage: Double = opacity.apply(scheme) + val alpha = clampInt(0, 255, (percentage * 255).roundToInt()) + return argb and 0x00ffffff or (alpha shl 24) + } + + fun getHct(scheme: DynamicScheme): Hct { + val cachedAnswer = hctCache[scheme] + if (cachedAnswer != null) { + return cachedAnswer + } + // This is crucial for aesthetics: we aren't simply the taking the standard color + // and changing its tone for contrast. Rather, we find the tone for contrast, then + // use the specified chroma from the palette to construct a new color. + // + // For example, this enables colors with standard tone of T90, which has limited chroma, to + // "recover" intended chroma as contrast increases. + val answer = Hct.from(hue.apply(scheme), chroma.apply(scheme), getTone(scheme)) + // NOMUTANTS--trivial test with onerous dependency injection requirement. + if (hctCache.size > 4) { + hctCache.clear() + } + // NOMUTANTS--trivial test with onerous dependency injection requirement. + hctCache[scheme] = answer + return answer + } + + /** Returns the tone in HCT, ranging from 0 to 100, of the resolved color given scheme. */ + fun getTone(scheme: DynamicScheme): Double { + var answer: Double = tone.apply(scheme) + val decreasingContrast = scheme.contrastLevel < 0.0 + if (scheme.contrastLevel != 0.0) { + val startTone: Double = tone.apply(scheme) + val endTone: Double = + if (decreasingContrast) toneMinContrast.apply(scheme) else toneMaxContrast.apply(scheme) + val delta = (endTone - startTone) * abs(scheme.contrastLevel) + answer = delta + startTone + } + val bgDynamicColor: DynamicColor? = background?.apply(scheme) + var minRatio = Contrast.RATIO_MIN + var maxRatio = Contrast.RATIO_MAX + if (bgDynamicColor != null) { + val bgHasBg = bgDynamicColor.background != null && bgDynamicColor.background.apply(scheme) == null + val standardRatio = ratioOfTones(tone.apply(scheme), bgDynamicColor.tone.apply(scheme)) + if (decreasingContrast) { + val minContrastRatio = ratioOfTones( + toneMinContrast.apply(scheme), bgDynamicColor.toneMinContrast.apply(scheme) + ) + minRatio = if (bgHasBg) 1.0 else minContrastRatio + maxRatio = standardRatio + } else { + val maxContrastRatio = ratioOfTones( + toneMaxContrast.apply(scheme), bgDynamicColor.toneMaxContrast.apply(scheme) + ) + minRatio = if (!bgHasBg) 1.0 else min(maxContrastRatio, standardRatio) + maxRatio = if (!bgHasBg) 21.0 else max(maxContrastRatio, standardRatio) + } + } + val finalMinRatio = minRatio + val finalMaxRatio = maxRatio + val finalAnswer = answer + answer = calculateDynamicTone( + scheme, + tone, + { dynamicColor -> dynamicColor.getTone(scheme) }, + { _, _ -> finalAnswer }, + if (bgDynamicColor != null) { _ -> bgDynamicColor } else null, + toneDeltaConstraint, + { finalMinRatio }, + { finalMaxRatio }) + return answer + } + + companion object { + /** + * Create a DynamicColor from a hex code. + * + * + * Result has no background; thus no support for increasing/decreasing contrast for a11y. + */ + fun fromArgb(argb: Int): DynamicColor { + val hct = Hct.fromInt(argb) + val palette = TonalPalette.fromInt(argb) + return fromPalette({ palette }) { hct.tone } + } + + /** + * Create a DynamicColor from just a hex code. + * + * + * Result has no background; thus cannot support increasing/decreasing contrast for a11y. + * + * @param argb A hex code. + * @param tone Function that provides a tone given DynamicScheme. Useful for adjusting for dark + * vs. light mode. + */ + fun fromArgb(argb: Int, tone: Function): DynamicColor { + return fromPalette({ + TonalPalette.fromInt( + argb + ) + }, tone) + } + + /** + * Create a DynamicColor. + * + * + * If you don't understand HCT fully, or your design team doesn't, but wants support for + * automated contrast adjustment, this method is _extremely_ useful: you can take a standard + * design system expressed as hex codes, create DynamicColors corresponding to each color, and + * then wire up backgrounds. + * + * + * If the design system uses the same hex code on multiple backgrounds, define that in multiple + * DynamicColors so that the background is accurate for each one. If you define a DynamicColor + * with one background, and actually use it on another, DynamicColor can't guarantee contrast. For + * example, if you use a color on both black and white, increasing the contrast on one necessarily + * decreases contrast of the other. + * + * @param argb A hex code. + * @param tone Function that provides a tone given DynamicScheme. (useful for dark vs. light mode) + * @param background Function that provides background DynamicColor given DynamicScheme. Useful + * for contrast, given a background, colors can adjust to increase/decrease contrast. + */ + fun fromArgb( + argb: Int, + tone: Function, + background: Function? + ): DynamicColor { + return fromPalette({ + TonalPalette.fromInt( + argb + ) + }, tone, background) + } + + /** + * Create a DynamicColor from: + * + * @param argb A hex code. + * @param tone Function that provides a tone given DynamicScheme. (useful for dark vs. light mode) + * @param background Function that provides background DynamicColor given DynamicScheme. Useful + * for contrast, given a background, colors can adjust to increase/decrease contrast. + * @param toneDeltaConstraint Function that provides a ToneDeltaConstraint given DynamicScheme. + * Useful for ensuring lightness difference between colors that don't _require_ contrast or + * have a formal background/foreground relationship. + */ + fun fromArgb( + argb: Int, + tone: Function, + background: Function?, + toneDeltaConstraint: Function? + ): DynamicColor { + return fromPalette( + { + TonalPalette.fromInt( + argb + ) + }, tone, background, toneDeltaConstraint + ) + } + + /** + * Create a DynamicColor. + * + * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is + * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing + * a tonal palette, when contrast adjustments are made, intended chroma can be preserved. For + * example, at T/L* 90, there is a significant limit to the amount of chroma. There is no + * colorful red, a red that light is pink. By preserving the _intended_ chroma if lightness + * lowers for contrast adjustments, the intended chroma is restored. + * @param tone Function that provides a tone given DynamicScheme. (useful for dark vs. light mode) + */ + fun fromPalette( + palette: Function, tone: Function + ): DynamicColor { + return fromPalette(palette, tone, null, null) + } + + /** + * Create a DynamicColor. + * + * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is + * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing + * a tonal palette, when contrast adjustments are made, intended chroma can be preserved. For + * example, at T/L* 90, there is a significant limit to the amount of chroma. There is no + * colorful red, a red that light is pink. By preserving the _intended_ chroma if lightness + * lowers for contrast adjustments, the intended chroma is restored. + * @param tone Function that provides a tone given DynamicScheme. (useful for dark vs. light mode) + * @param background Function that provides background DynamicColor given DynamicScheme. Useful + * for contrast, given a background, colors can adjust to increase/decrease contrast. + */ + fun fromPalette( + palette: Function, + tone: Function, + background: Function? + ): DynamicColor { + return fromPalette(palette, tone, background, null) + } + + /** + * Create a DynamicColor. + * + * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is + * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing + * a tonal palette, when contrast adjustments are made, intended chroma can be preserved. For + * example, at T/L* 90, there is a significant limit to the amount of chroma. There is no + * colorful red, a red that light is pink. By preserving the _intended_ chroma if lightness + * lowers for contrast adjustments, the intended chroma is restored. + * @param tone Function that provides a tone given DynamicScheme. (useful for dark vs. light mode) + * @param background Function that provides background DynamicColor given DynamicScheme. Useful + * for contrast, given a background, colors can adjust to increase/decrease contrast. + * @param toneDeltaConstraint Function that provides a ToneDeltaConstraint given DynamicScheme. + * Useful for ensuring lightness difference between colors that don't _require_ contrast or + * have a formal background/foreground relationship. + */ + fun fromPalette( + palette: Function, + tone: Function, + background: Function?, + toneDeltaConstraint: Function? + ): DynamicColor { + return DynamicColor( + { scheme -> palette.apply(scheme).hue }, + { scheme -> palette.apply(scheme).chroma }, + tone, + null, + background, + { scheme -> + toneMinContrastDefault( + tone, + background, + scheme, + toneDeltaConstraint + ) + }, + { scheme -> + toneMaxContrastDefault( + tone, + background, + scheme, + toneDeltaConstraint + ) + }, + toneDeltaConstraint + ) + } + + /** + * The default algorithm for calculating the tone of a color at minimum contrast.

+ * If the original contrast ratio was >= 7.0, reach contrast 4.5.

+ * If the original contrast ratio was >= 3.0, reach contrast 3.0.

+ * If the original contrast ratio was < 3.0, reach that ratio. + */ + fun toneMinContrastDefault( + tone: Function, + background: Function?, + scheme: DynamicScheme, + toneDeltaConstraint: Function? + ): Double { + return calculateDynamicTone( + scheme, + tone, + { c -> c.toneMinContrast.apply(scheme) }, + { stdRatio: Double, bgTone: Double -> + var answer: Double = tone.apply(scheme) + if (stdRatio >= Contrast.RATIO_70) { + answer = contrastingTone( + bgTone, + Contrast.RATIO_45 + ) + } else if (stdRatio >= Contrast.RATIO_30) { + answer = contrastingTone( + bgTone, + Contrast.RATIO_30 + ) + } else { + val backgroundHasBackground = + background != null && background.apply(scheme).background != null + && background.apply(scheme).background?.apply(scheme) != null + if (backgroundHasBackground) { + answer = contrastingTone(bgTone, stdRatio) + } + } + answer + }, + background, + toneDeltaConstraint, + null, + { standardRatio -> standardRatio }) + } + + /** + * The default algorithm for calculating the tone of a color at maximum contrast.

+ * If the color's background has a background, reach contrast 7.0.

+ * If it doesn't, maintain the original contrast ratio.

+ * + * + * This ensures text on surfaces maintains its original, often detrimentally excessive, + * contrast ratio. But, text on buttons can soften to not have excessive contrast. + * + * + * Historically, digital design uses pure whites and black for text and surfaces. It's too much + * of a jump at this point in history to introduce a dynamic contrast system _and_ insist that + * text always had excessive contrast and should reach 7.0, it would deterimentally affect desire + * to understand and use dynamic contrast. + */ + fun toneMaxContrastDefault( + tone: Function, + background: Function?, + scheme: DynamicScheme, + toneDeltaConstraint: Function? + ): Double { + return calculateDynamicTone( + scheme, + tone, + Function { c -> c.toneMaxContrast.apply(scheme) }, + { stdRatio: Double, bgTone: Double -> + val backgroundHasBackground = + background != null && background.apply(scheme).background != null + && background.apply(scheme).background?.apply(scheme) != null + if (backgroundHasBackground) { + return@calculateDynamicTone contrastingTone( + bgTone, + Contrast.RATIO_70 + ) + } else { + return@calculateDynamicTone contrastingTone( + bgTone, + max(Contrast.RATIO_70, stdRatio) + ) + } + }, + background, + toneDeltaConstraint, + null, + null + ) + } + + /** + * Core method for calculating a tone for under dynamic contrast. + * + * + * It enforces important properties:

+ * #1. Desired contrast ratio is reached.

+ * As contrast increases from standard to max, the tones involved should always be at least the + * standard ratio. For example, if a button is T90, and button text is T0, and the button is T0 at + * max contrast, the button text cannot simply linearly interpolate from T0 to T100, or at some + * point they'll both be at the same tone. + * + * + * #2. Enable light foregrounds on midtones.

+ * The eye prefers light foregrounds on T50 to T60, possibly up to T70, but, contrast ratio 4.5 + * can't be reached with T100 unless the foreground is T50. Contrast ratio 4.5 is crucial, it + * represents 'readable text', i.e. text smaller than ~40 dp / 1/4". So, if a tone is between T50 + * and T60, it is proactively changed to T49 to enable light foregrounds. + * + * + * #3. Ensure tone delta with another color.

+ * In design systems, there may be colors that don't have a pure background/foreground + * relationship, but, do require different tones for visual differentiation. ToneDeltaConstraint + * models this requirement, and DynamicColor enforces it. + */ + fun calculateDynamicTone( + scheme: DynamicScheme, + toneStandard: Function, + toneToJudge: Function, + desiredTone: BiFunction, + background: Function?, + toneDeltaConstraint: Function?, + minRatio: Function?, + maxRatio: Function? + ): Double { + // Start with the tone with no adjustment for contrast. + // If there is no background, don't perform any adjustment, return immediately. + val toneStd: Double = toneStandard.apply(scheme) + var answer = toneStd + val bgDynamic: DynamicColor = (if (background == null) null else background.apply(scheme)) ?: return answer + val bgToneStd: Double = bgDynamic.tone.apply(scheme) + val stdRatio = ratioOfTones(toneStd, bgToneStd) + + // If there is a background, determine its tone after contrast adjustment. + // Then, calculate the foreground tone that ensures the caller's desired contrast ratio is met. + val bgTone: Double = toneToJudge.apply(bgDynamic) + val myDesiredTone = desiredTone.apply(stdRatio, bgTone) + val currentRatio = ratioOfTones(bgTone, myDesiredTone) + val minRatioRealized = + if (minRatio == null) Contrast.RATIO_MIN else if (minRatio.apply(stdRatio) == null) Contrast.RATIO_MIN else minRatio.apply( + stdRatio + ) + val maxRatioRealized = + if (maxRatio == null) Contrast.RATIO_MAX else if (maxRatio.apply(stdRatio) == null) Contrast.RATIO_MAX else maxRatio.apply( + stdRatio + ) + val desiredRatio = clampDouble(minRatioRealized, maxRatioRealized, currentRatio) + answer = if (desiredRatio == currentRatio) { + myDesiredTone + } else { + contrastingTone(bgTone, desiredRatio) + } + + // If the background has no background, adjust the foreground tone to ensure that + // it is dark enough to have a light foreground. + if (bgDynamic.background == null) { + answer = enableLightForeground(answer) + } + + // If the caller has specified a constraint where it must have a certain tone distance from + // another color, enforce that constraint. + answer = ensureToneDelta(answer, toneStd, scheme, toneDeltaConstraint, toneToJudge) + return answer + } + + fun ensureToneDelta( + tone: Double, + toneStandard: Double, + scheme: DynamicScheme, + toneDeltaConstraint: Function?, + toneToDistanceFrom: Function + ): Double { + val constraint: ToneDeltaConstraint = + (if (toneDeltaConstraint == null) null else toneDeltaConstraint.apply(scheme)) + ?: return tone + val requiredDelta = constraint.delta + val keepAwayTone: Double = toneToDistanceFrom.apply(constraint.keepAway) + val delta = abs(tone - keepAwayTone) + return if (delta >= requiredDelta) { + tone + } else when (constraint.keepAwayPolarity) { + TonePolarity.DARKER -> clampDouble(0.0, 100.0, keepAwayTone + requiredDelta) + TonePolarity.LIGHTER -> clampDouble(0.0, 100.0, keepAwayTone - requiredDelta) + TonePolarity.NO_PREFERENCE -> { + val keepAwayToneStandard: Double = constraint.keepAway.tone.apply(scheme) + val preferLighten = toneStandard > keepAwayToneStandard + val alterAmount = abs(delta - requiredDelta) + val lighten = if (preferLighten) tone + alterAmount <= 100.0 else tone < alterAmount + if (lighten) tone + alterAmount else tone - alterAmount + } + } + return tone + } + + /** + * Given a background tone, find a foreground tone, while ensuring they reach a contrast ratio + * that is as close to ratio as possible. + */ + fun contrastingTone(bgTone: Double, ratio: Double): Double { + val lighterTone = lighterUnsafe(bgTone, ratio) + val darkerTone = darkerUnsafe(bgTone, ratio) + val lighterRatio = ratioOfTones(lighterTone, bgTone) + val darkerRatio = ratioOfTones(darkerTone, bgTone) + val preferLighter = tonePrefersLightForeground(bgTone) + return if (preferLighter) { + // "Neglible difference" handles an edge case where the initial contrast ratio is high + // (ex. 13.0), and the ratio passed to the function is that high ratio, and both the lighter + // and darker ratio fails to pass that ratio. + // + // This was observed with Tonal Spot's On Primary Container turning black momentarily between + // high and max contrast in light mode. PC's standard tone was T90, OPC's was T10, it was + // light mode, and the contrast level was 0.6568521221032331. + val negligibleDifference = + abs(lighterRatio - darkerRatio) < 0.1 && lighterRatio < ratio && darkerRatio < ratio + if (lighterRatio >= ratio || lighterRatio >= darkerRatio || negligibleDifference) { + lighterTone + } else { + darkerTone + } + } else { + if (darkerRatio >= ratio || darkerRatio >= lighterRatio) darkerTone else lighterTone + } + } + + /** + * Adjust a tone down such that white has 4.5 contrast, if the tone is reasonably close to + * supporting it. + */ + fun enableLightForeground(tone: Double): Double { + return if (tonePrefersLightForeground(tone) && !toneAllowsLightForeground(tone)) { + 49.0 + } else tone + } + + /** + * People prefer white foregrounds on ~T60-70. Observed over time, and also by Andrew Somers + * during research for APCA. + * + * + * T60 used as to create the smallest discontinuity possible when skipping down to T49 in order + * to ensure light foregrounds. + */ + fun tonePrefersLightForeground(tone: Double): Boolean { + return tone.roundToInt() <= 60 + } + + /** Tones less than ~T50 always permit white at 4.5 contrast. */ + fun toneAllowsLightForeground(tone: Double): Boolean { + return tone.roundToInt() <= 49 + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/dynamiccolor/ToneDeltaConstraints.kt b/kotlin/lib/commonMain/kotlin/dynamiccolor/ToneDeltaConstraints.kt new file mode 100644 index 0000000..a426f28 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/dynamiccolor/ToneDeltaConstraints.kt @@ -0,0 +1,21 @@ +package dynamiccolor + +/** + * Documents a constraint between two DynamicColors, in which their tones must have a certain + * distance from each other. + */ +internal class ToneDeltaConstraint(val delta: Double, keepAway: DynamicColor, keepAwayPolarity: TonePolarity) { + val keepAway: DynamicColor + val keepAwayPolarity: TonePolarity + + /** + * @param delta the difference in tone required + * @param keepAway the color to distance in tone from + * @param keepAwayPolarity whether the color to keep away from must be lighter, darker, or no + * preference, in which case it should + */ + init { + this.keepAway = keepAway + this.keepAwayPolarity = keepAwayPolarity + } +} diff --git a/kotlin/lib/commonMain/kotlin/dynamiccolor/TonePolarity.kt b/kotlin/lib/commonMain/kotlin/dynamiccolor/TonePolarity.kt new file mode 100644 index 0000000..70058e4 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/dynamiccolor/TonePolarity.kt @@ -0,0 +1,8 @@ +package dynamiccolor + +/** Describes the relationship in lightness between two colors. */ +enum class TonePolarity { + DARKER, + LIGHTER, + NO_PREFERENCE +} diff --git a/kotlin/lib/commonMain/kotlin/hct/Cam16.kt b/kotlin/lib/commonMain/kotlin/hct/Cam16.kt new file mode 100644 index 0000000..6028826 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/hct/Cam16.kt @@ -0,0 +1,343 @@ +package hct + +import utils.ColorUtils +import utils.MathUtils +import kotlin.math.* + +/** + * CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex + * code and viewing conditions. + * + *

CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, + * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and should be used when + * measuring distances between colors. + * + *

In traditional color spaces, a color can be identified solely by the observer's measurement of + * the color. Color appearance models such as CAM16 also use information about the environment where + * the color was observed, known as the viewing conditions. + * + *

For example, white under the traditional assumption of a midday sun white point is accurately + * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) + */ +class Cam16 +/** + * All of the CAM16 dimensions can be calculated from 3 of the dimensions, in the following + * combinations: - {j or q} and {c, m, or s} and hue - jstar, astar, bstar Prefer using a static + * method that constructs from 3 of those dimensions. This constructor is intended for those + * methods to use to return all possible dimensions. + * + * @param hue for example, red, orange, yellow, green, etc. + * @param chroma informally, colorfulness / color intensity. like saturation in HSL, except + * perceptually accurate. + * @param j lightness + * @param q brightness; ratio of lightness to white point's lightness + * @param m colorfulness + * @param s saturation; ratio of chroma to white point's chroma + * @param jstar CAM16-UCS J coordinate + * @param astar CAM16-UCS a coordinate + * @param bstar CAM16-UCS b coordinate + */ private constructor( + /** Hue in CAM16 */ + // CAM16 color dimensions, see getters for documentation. + val hue: Double, + /** Chroma in CAM16 */ + val chroma: Double, + /** Lightness in CAM16 */ + val j: Double, + /** + * Brightness in CAM16. + * + * + * Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper is + * much brighter viewed in sunlight than in indoor light, but it is the lightest object under any + * lighting. + */ + val q: Double, + /** + * Colorfulness in CAM16. + * + * + * Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much + * more colorful outside than inside, but it has the same chroma in both environments. + */ + val m: Double, + /** + * Saturation in CAM16. + * + * + * Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness + * relative to the color's own brightness, where chroma is colorfulness relative to white. + */ + val s: Double, + /** Lightness coordinate in CAM16-UCS */ + // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. + val jstar: Double, + /** a* coordinate in CAM16-UCS */ + val astar: Double, + /** b* coordinate in CAM16-UCS */ + val bstar: Double +) { + + // Avoid allocations during conversion by pre-allocating an array. + private val tempArray = doubleArrayOf(0.0, 0.0, 0.0) + + /** + * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, + * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and is used to measure + * distances between colors. + */ + fun distance(other: Cam16): Double { + val dJ = jstar - other.jstar + val dA = astar - other.astar + val dB = bstar - other.bstar + val dEPrime = sqrt(dJ * dJ + dA * dA + dB * dB) + return 1.41 * dEPrime.pow(0.63) + } + + /** + * ARGB representation of the color. Assumes the color was viewed in default viewing conditions, + * which are near-identical to the default viewing conditions for sRGB. + */ + fun toInt(): Int { + return viewed(ViewingConditions.DEFAULT) + } + + /** + * ARGB representation of the color, in defined viewing conditions. + * + * @param viewingConditions Information about the environment where the color will be viewed. + * @return ARGB representation of color + */ + fun viewed(viewingConditions: ViewingConditions): Int { + val xyz = xyzInViewingConditions(viewingConditions, tempArray) + return ColorUtils.argbFromXyz(xyz[0], xyz[1], xyz[2]) + } + + fun xyzInViewingConditions(viewingConditions: ViewingConditions, returnArray: DoubleArray?): DoubleArray { + val alpha = if (chroma == 0.0 || j == 0.0) 0.0 else chroma / sqrt( + j / 100.0 + ) + val t = (alpha / (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73)).pow(1.0 / 0.9) + val hRad = MathUtils.toRadians(hue) + val eHue = 0.25 * (cos(hRad + 2.0) + 3.8) + val ac: Double = (viewingConditions.aw + * (j / 100.0).pow(1.0 / viewingConditions.c / viewingConditions.z)) + val p1: Double = eHue * (50000.0 / 13.0) * viewingConditions.nc * viewingConditions.ncb + val p2: Double = ac / viewingConditions.nbb + val hSin = sin(hRad) + val hCos = cos(hRad) + val gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin) + val a = gamma * hCos + val b = gamma * hSin + val rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 + val gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 + val bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 + val rCBase: Double = max(0.0, 27.13 * abs(rA) / (400.0 - abs(rA))) + val rC: Double = sign(rA) * (100.0 / viewingConditions.fl) * rCBase.pow(1.0 / 0.42) + val gCBase: Double = max(0.0, 27.13 * abs(gA) / (400.0 - abs(gA))) + val gC: Double = sign(gA) * (100.0 / viewingConditions.fl) * gCBase.pow(1.0 / 0.42) + val bCBase: Double = max(0.0, 27.13 * abs(bA) / (400.0 - abs(bA))) + val bC: Double = sign(bA) * (100.0 / viewingConditions.fl) * bCBase.pow(1.0 / 0.42) + val rF: Double = rC / viewingConditions.rgbD[0] + val gF: Double = gC / viewingConditions.rgbD[1] + val bF: Double = bC / viewingConditions.rgbD[2] + val matrix = CAM16RGB_TO_XYZ + val x = rF * matrix[0][0] + gF * matrix[0][1] + bF * matrix[0][2] + val y = rF * matrix[1][0] + gF * matrix[1][1] + bF * matrix[1][2] + val z = rF * matrix[2][0] + gF * matrix[2][1] + bF * matrix[2][2] + return if (returnArray != null) { + returnArray[0] = x + returnArray[1] = y + returnArray[2] = z + returnArray + } else { + doubleArrayOf(x, y, z) + } + } + + companion object { + // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. + val XYZ_TO_CAM16RGB = arrayOf( + doubleArrayOf(0.401288, 0.650173, -0.051461), + doubleArrayOf(-0.250268, 1.204414, 0.045854), + doubleArrayOf(-0.002079, 0.048952, 0.953127) + ) + + // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. + val CAM16RGB_TO_XYZ = arrayOf( + doubleArrayOf(1.8620678, -1.0112547, 0.14918678), + doubleArrayOf(0.38752654, 0.62144744, -0.00897398), + doubleArrayOf(-0.01584150, -0.03412294, 1.0499644) + ) + + /** + * Create a CAM16 color from a color, assuming the color was viewed in default viewing conditions. + * + * @param argb ARGB representation of a color. + */ + fun fromInt(argb: Int): Cam16 { + return fromIntInViewingConditions(argb, ViewingConditions.DEFAULT) + } + + /** + * Create a CAM16 color from a color in defined viewing conditions. + * + * @param argb ARGB representation of a color. + * @param viewingConditions Information about the environment where the color was observed. + */ + // The RGB => XYZ conversion matrix elements are derived scientific constants. While the values + // may differ at runtime due to floating point imprecision, keeping the values the same, and + // accurate, across implementations takes precedence. + fun fromIntInViewingConditions(argb: Int, viewingConditions: ViewingConditions): Cam16 { + // Transform ARGB int to XYZ + val red = argb and 0x00ff0000 shr 16 + val green = argb and 0x0000ff00 shr 8 + val blue = argb and 0x000000ff + val redL = ColorUtils.linearized(red) + val greenL = ColorUtils.linearized(green) + val blueL = ColorUtils.linearized(blue) + val x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL + val y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL + val z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL + return fromXyzInViewingConditions(x, y, z, viewingConditions) + } + + fun fromXyzInViewingConditions( + x: Double, y: Double, z: Double, viewingConditions: ViewingConditions + ): Cam16 { + // Transform XYZ to 'cone'/'rgb' responses + val matrix = XYZ_TO_CAM16RGB + val rT = x * matrix[0][0] + y * matrix[0][1] + z * matrix[0][2] + val gT = x * matrix[1][0] + y * matrix[1][1] + z * matrix[1][2] + val bT = x * matrix[2][0] + y * matrix[2][1] + z * matrix[2][2] + + // Discount illuminant + val rD: Double = viewingConditions.rgbD[0] * rT + val gD: Double = viewingConditions.rgbD[1] * gT + val bD: Double = viewingConditions.rgbD[2] * bT + + // Chromatic adaptation + val rAF = (viewingConditions.fl * abs(rD) / 100.0).pow(0.42) + val gAF = (viewingConditions.fl * abs(gD) / 100.0).pow(0.42) + val bAF = (viewingConditions.fl * abs(bD) / 100.0).pow(0.42) + val rA = sign(rD) * 400.0 * rAF / (rAF + 27.13) + val gA = sign(gD) * 400.0 * gAF / (gAF + 27.13) + val bA = sign(bD) * 400.0 * bAF / (bAF + 27.13) + + // redness-greenness + val a = (11.0 * rA + -12.0 * gA + bA) / 11.0 + // yellowness-blueness + val b = (rA + gA - 2.0 * bA) / 9.0 + + // auxiliary components + val u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0 + val p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0 + + // hue + val atan2 = atan2(b, a) + val atanDegrees = MathUtils.toDegrees(atan2) + val hue = + if (atanDegrees < 0) atanDegrees + 360.0 else if (atanDegrees >= 360) atanDegrees - 360.0 else atanDegrees + val hueRadians = MathUtils.toRadians(hue) + + // achromatic response to color + val ac: Double = p2 * viewingConditions.nbb + + // CAM16 lightness and brightness + val j = (100.0 + * (ac / viewingConditions.aw).pow(viewingConditions.c * viewingConditions.z)) + val q: Double = ((4.0 + / viewingConditions.c) * sqrt(j / 100.0) + * (viewingConditions.aw + 4.0) + * viewingConditions.flRoot) + + // CAM16 chroma, colorfulness, and saturation. + val huePrime = if (hue < 20.14) hue + 360 else hue + val eHue = 0.25 * (cos(MathUtils.toRadians(huePrime) + 2.0) + 3.8) + val p1: Double = 50000.0 / 13.0 * eHue * viewingConditions.nc * viewingConditions.ncb + val t = p1 * hypot(a, b) / (u + 0.305) + val alpha = (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73) * t.pow(0.9) + // CAM16 chroma, colorfulness, saturation + val c = alpha * sqrt(j / 100.0) + val m: Double = c * viewingConditions.flRoot + val s = 50.0 * sqrt(alpha * viewingConditions.c / (viewingConditions.aw + 4.0)) + + // CAM16-UCS components + val jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j) + val mstar = 1.0 / 0.0228 * ln1p(0.0228 * m) + val astar = mstar * cos(hueRadians) + val bstar = mstar * sin(hueRadians) + return Cam16(hue, c, j, q, m, s, jstar, astar, bstar) + } + + /** + * @param j CAM16 lightness + * @param c CAM16 chroma + * @param h CAM16 hue + */ + fun fromJch(j: Double, c: Double, h: Double): Cam16 { + return fromJchInViewingConditions(j, c, h, ViewingConditions.DEFAULT) + } + + /** + * @param j CAM16 lightness + * @param c CAM16 chroma + * @param h CAM16 hue + * @param viewingConditions Information about the environment where the color was observed. + */ + private fun fromJchInViewingConditions( + j: Double, c: Double, h: Double, viewingConditions: ViewingConditions + ): Cam16 { + val q: Double = ((4.0 + / viewingConditions.c) * sqrt(j / 100.0) + * (viewingConditions.aw + 4.0) + * viewingConditions.flRoot) + val m: Double = c * viewingConditions.flRoot + val alpha = c / sqrt(j / 100.0) + val s = 50.0 * sqrt(alpha * viewingConditions.c / (viewingConditions.aw + 4.0)) + val hueRadians = MathUtils.toRadians(h) + val jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j) + val mstar = 1.0 / 0.0228 * ln1p(0.0228 * m) + val astar = mstar * cos(hueRadians) + val bstar = mstar * sin(hueRadians) + return Cam16(h, c, j, q, m, s, jstar, astar, bstar) + } + + /** + * Create a CAM16 color from CAM16-UCS coordinates. + * + * @param jstar CAM16-UCS lightness. + * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y + * axis. + * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X + * axis. + */ + fun fromUcs(jstar: Double, astar: Double, bstar: Double): Cam16 { + return fromUcsInViewingConditions(jstar, astar, bstar, ViewingConditions.DEFAULT) + } + + /** + * Create a CAM16 color from CAM16-UCS coordinates in defined viewing conditions. + * + * @param jstar CAM16-UCS lightness. + * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y + * axis. + * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X + * axis. + * @param viewingConditions Information about the environment where the color was observed. + */ + fun fromUcsInViewingConditions( + jstar: Double, astar: Double, bstar: Double, viewingConditions: ViewingConditions + ): Cam16 { + val m = hypot(astar, bstar) + val m2 = expm1(m * 0.0228) / 0.0228 + val c: Double = m2 / viewingConditions.flRoot + var h = atan2(bstar, astar) * (180.0 / PI) + if (h < 0.0) { + h += 360.0 + } + val j = jstar / (1.0 - (jstar - 100.0) * 0.007) + return fromJchInViewingConditions(j, c, h, viewingConditions) + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/hct/Hct.kt b/kotlin/lib/commonMain/kotlin/hct/Hct.kt new file mode 100644 index 0000000..14a6e50 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/hct/Hct.kt @@ -0,0 +1,140 @@ +package hct + +import utils.ColorUtils.lstarFromArgb +import utils.ColorUtils.lstarFromY + +/** + * A color system built using CAM16 hue and chroma, and L* from L*a*b*. + * + *

Using L* creates a link between the color system, contrast, and thus accessibility. Contrast + * ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can + * be calculated from Y. + * + *

Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. + * + *

Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A + * difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 + * guarantees a contrast ratio >= 4.5. + */ + +/** + * HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color + * measurement system that can also accurately render what colors will appear as in different + * lighting environments. + */ +class Hct private constructor(argb: Int) { + var hue = 0.0 + private set + var chroma = 0.0 + private set + var tone = 0.0 + private set + var argb = 0 + private set + + init { + setInternalState(argb) + } + + fun toInt(): Int { + return argb + } + + /** + * Set the hue of this color. Chroma may decrease because chroma has a different maximum for any + * given hue and tone. + * + * @param newHue 0 <= newHue < 360; invalid values are corrected. + */ + fun setHue(newHue: Double) { + setInternalState(HctSolver.solveToInt(newHue, chroma, tone)) + } + + /** + * Set the chroma of this color. Chroma may decrease because chroma has a different maximum for + * any given hue and tone. + * + * @param newChroma 0 <= newChroma < ? + */ + fun setChroma(newChroma: Double) { + setInternalState(HctSolver.solveToInt(hue, newChroma, tone)) + } + + /** + * Set the tone of this color. Chroma may decrease because chroma has a different maximum for any + * given hue and tone. + * + * @param newTone 0 <= newTone <= 100; invalid valids are corrected. + */ + fun setTone(newTone: Double) { + setInternalState(HctSolver.solveToInt(hue, chroma, newTone)) + } + + /** + * Translate a color into different ViewingConditions. + * + * + * Colors change appearance. They look different with lights on versus off, the same color, as + * in hex code, on white looks different when on black. This is called color relativity, most + * famously explicated by Josef Albers in Interaction of Color. + * + * + * In color science, color appearance models can account for this and calculate the appearance + * of a color in different settings. HCT is based on CAM16, a color appearance model, and uses it + * to make these calculations. + * + * + * See ViewingConditions.make for parameters affecting color appearance. + */ + fun inViewingConditions(vc: ViewingConditions?): Hct { + // 1. Use CAM16 to find XYZ coordinates of color in specified VC. + val cam16 = Cam16.fromInt(toInt()) + val viewedInVc = cam16.xyzInViewingConditions(vc!!, null) + + // 2. Create CAM16 of those XYZ coordinates in default VC. + val recastInVc = Cam16.fromXyzInViewingConditions( + viewedInVc[0], viewedInVc[1], viewedInVc[2], ViewingConditions.DEFAULT + ) + + // 3. Create HCT from: + // - CAM16 using default VC with XYZ coordinates in specified VC. + // - L* converted from Y in XYZ coordinates in specified VC. + return from( + recastInVc.hue, recastInVc.chroma, lstarFromY(viewedInVc[1]) + ) + } + + private fun setInternalState(argb: Int) { + this.argb = argb + val cam = Cam16.fromInt(argb) + hue = cam.hue + chroma = cam.chroma + tone = lstarFromArgb(argb) + } + + companion object { + /** + * Create an HCT color from hue, chroma, and tone. + * + * @param hue 0 <= hue < 360; invalid values are corrected. + * @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than + * the requested chroma. Chroma has a different maximum for any given hue and tone. + * @param tone 0 <= tone <= 100; invalid values are corrected. + * @return HCT representation of a color in default viewing conditions. + */ + fun from(hue: Double, chroma: Double, tone: Double): Hct { + val argb: Int = HctSolver.solveToInt(hue, chroma, tone) + return Hct(argb) + } + + /** + * Create an HCT color from a color. + * + * @param argb ARGB representation of a color. + * @return HCT representation of a color in default viewing conditions + */ + fun fromInt(argb: Int): Hct { + return Hct(argb) + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/hct/HctSolver.kt b/kotlin/lib/commonMain/kotlin/hct/HctSolver.kt new file mode 100644 index 0000000..0c02e31 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/hct/HctSolver.kt @@ -0,0 +1,633 @@ +package hct + +import utils.ColorUtils +import utils.MathUtils +import kotlin.math.* + +/** A class that solves the HCT equation. */ +object HctSolver { + val SCALED_DISCOUNT_FROM_LINRGB = arrayOf( + doubleArrayOf( + 0.001200833568784504, 0.002389694492170889, 0.0002795742885861124 + ), doubleArrayOf( + 0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398 + ), doubleArrayOf( + 0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076 + ) + ) + val LINRGB_FROM_SCALED_DISCOUNT = arrayOf( + doubleArrayOf( + 1373.2198709594231, -1100.4251190754821, -7.278681089101213 + ), doubleArrayOf( + -271.815969077903, 559.6580465940733, -32.46047482791194 + ), doubleArrayOf( + 1.9622899599665666, -57.173814538844006, 308.7233197812385 + ) + ) + val Y_FROM_LINRGB = doubleArrayOf(0.2126, 0.7152, 0.0722) + val CRITICAL_PLANES = doubleArrayOf( + 0.015176349177441876, + 0.045529047532325624, + 0.07588174588720938, + 0.10623444424209313, + 0.13658714259697685, + 0.16693984095186062, + 0.19729253930674434, + 0.2276452376616281, + 0.2579979360165119, + 0.28835063437139563, + 0.3188300904430532, + 0.350925934958123, + 0.3848314933096426, + 0.42057480301049466, + 0.458183274052838, + 0.4976837250274023, + 0.5391024159806381, + 0.5824650784040898, + 0.6277969426914107, + 0.6751227633498623, + 0.7244668422128921, + 0.775853049866786, + 0.829304845476233, + 0.8848452951698498, + 0.942497089126609, + 1.0022825574869039, + 1.0642236851973577, + 1.1283421258858297, + 1.1946592148522128, + 1.2631959812511864, + 1.3339731595349034, + 1.407011200216447, + 1.4823302800086415, + 1.5599503113873272, + 1.6398909516233677, + 1.7221716113234105, + 1.8068114625156377, + 1.8938294463134073, + 1.9832442801866852, + 2.075074464868551, + 2.1693382909216234, + 2.2660538449872063, + 2.36523901573795, + 2.4669114995532007, + 2.5710888059345764, + 2.6777882626779785, + 2.7870270208169257, + 2.898822059350997, + 3.0131901897720907, + 3.1301480604002863, + 3.2497121605402226, + 3.3718988244681087, + 3.4967242352587946, + 3.624204428461639, + 3.754355295633311, + 3.887192587735158, + 4.022731918402185, + 4.160988767090289, + 4.301978482107941, + 4.445716283538092, + 4.592217266055746, + 4.741496401646282, + 4.893568542229298, + 5.048448422192488, + 5.20615066083972, + 5.3666897647573375, + 5.5300801301023865, + 5.696336044816294, + 5.865471690767354, + 6.037501145825082, + 6.212438385869475, + 6.390297286737924, + 6.571091626112461, + 6.7548350853498045, + 6.941541251256611, + 7.131223617812143, + 7.323895587840543, + 7.5195704746346665, + 7.7182615035334345, + 7.919981813454504, + 8.124744458384042, + 8.332562408825165, + 8.543448553206703, + 8.757415699253682, + 8.974476575321063, + 9.194643831691977, + 9.417930041841839, + 9.644347703669503, + 9.873909240696694, + 10.106627003236781, + 10.342513269534024, + 10.58158024687427, + 10.8238400726681, + 11.069304815507364, + 11.317986476196008, + 11.569896988756009, + 11.825048221409341, + 12.083451977536606, + 12.345119996613247, + 12.610063955123938, + 12.878295467455942, + 13.149826086772048, + 13.42466730586372, + 13.702830557985108, + 13.984327217668513, + 14.269168601521828, + 14.55736596900856, + 14.848930523210871, + 15.143873411576273, + 15.44220572664832, + 15.743938506781891, + 16.04908273684337, + 16.35764934889634, + 16.66964922287304, + 16.985093187232053, + 17.30399201960269, + 17.62635644741625, + 17.95219714852476, + 18.281524751807332, + 18.614349837764564, + 18.95068293910138, + 19.290534541298456, + 19.633915083172692, + 19.98083495742689, + 20.331304511189067, + 20.685334046541502, + 21.042933821039977, + 21.404114048223256, + 21.76888489811322, + 22.137256497705877, + 22.50923893145328, + 22.884842241736916, + 23.264076429332462, + 23.6469514538663, + 24.033477234264016, + 24.42366364919083, + 24.817520537484558, + 25.21505769858089, + 25.61628489293138, + 26.021211842414342, + 26.429848230738664, + 26.842203703840827, + 27.258287870275353, + 27.678110301598522, + 28.10168053274597, + 28.529008062403893, + 28.96010235337422, + 29.39497283293396, + 29.83362889318845, + 30.276079891419332, + 30.722335150426627, + 31.172403958865512, + 31.62629557157785, + 32.08401920991837, + 32.54558406207592, + 33.010999283389665, + 33.4802739966603, + 33.953417292456834, + 34.430438229418264, + 34.911345834551085, + 35.39614910352207, + 35.88485700094671, + 36.37747846067349, + 36.87402238606382, + 37.37449765026789, + 37.87891309649659, + 38.38727753828926, + 38.89959975977785, + 39.41588851594697, + 39.93615253289054, + 40.460400508064545, + 40.98864111053629, + 41.520882981230194, + 42.05713473317016, + 42.597404951718396, + 43.141702194811224, + 43.6900349931913, + 44.24241185063697, + 44.798841244188324, + 45.35933162437017, + 45.92389141541209, + 46.49252901546552, + 47.065252796817916, + 47.64207110610409, + 48.22299226451468, + 48.808024568002054, + 49.3971762874833, + 49.9904556690408, + 50.587870934119984, + 51.189430279724725, + 51.79514187861014, + 52.40501387947288, + 53.0190544071392, + 53.637271562750364, + 54.259673423945976, + 54.88626804504493, + 55.517063457223934, + 56.15206766869424, + 56.79128866487574, + 57.43473440856916, + 58.08241284012621, + 58.734331877617365, + 59.39049941699807, + 60.05092333227251, + 60.715611475655585, + 61.38457167773311, + 62.057811747619894, + 62.7353394731159, + 63.417162620860914, + 64.10328893648692, + 64.79372614476921, + 65.48848194977529, + 66.18756403501224, + 66.89098006357258, + 67.59873767827808, + 68.31084450182222, + 69.02730813691093, + 69.74813616640164, + 70.47333615344107, + 71.20291564160104, + 71.93688215501312, + 72.67524319850172, + 73.41800625771542, + 74.16517879925733, + 74.9167682708136, + 75.67278210128072, + 76.43322770089146, + 77.1981124613393, + 77.96744375590167, + 78.74122893956174, + 79.51947534912904, + 80.30219030335869, + 81.08938110306934, + 81.88105503125999, + 82.67721935322541, + 83.4778813166706, + 84.28304815182372, + 85.09272707154808, + 85.90692527145302, + 86.72564993000343, + 87.54890820862819, + 88.3767072518277, + 89.2090541872801, + 90.04595612594655, + 90.88742016217518, + 91.73345337380438, + 92.58406282226491, + 93.43925555268066, + 94.29903859396902, + 95.16341895893969, + 96.03240364439274, + 96.9059996312159, + 97.78421388448044, + 98.6670533535366, + 99.55452497210776 + ) + + /** + * Sanitizes a small enough angle in radians. + * + * @param angle An angle in radians; must not deviate too much from 0. + * @return A coterminal angle between 0 and 2pi. + */ + fun sanitizeRadians(angle: Double): Double { + return (angle + PI * 8) % (PI * 2) + } + + /** + * Delinearizes an RGB component, returning a floating-point number. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @return 0.0 <= output <= 255.0, color channel converted to regular RGB space + */ + fun trueDelinearized(rgbComponent: Double): Double { + val normalized = rgbComponent / 100.0 + val delinearized = if (normalized <= 0.0031308) { + normalized * 12.92 + } else { + 1.055 * normalized.pow(1.0 / 2.4) - 0.055 + } + return delinearized * 255.0 + } + + fun chromaticAdaptation(component: Double): Double { + val af = abs(component).pow(0.42) + return MathUtils.signum(component) * 400.0 * af / (af + 27.13) + } + + /** + * Returns the hue of a linear RGB color in CAM16. + * + * @param linrgb The linear RGB coordinates of a color. + * @return The hue of the color in CAM16, in radians. + */ + fun hueOf(linrgb: DoubleArray?): Double { + val scaledDiscount = MathUtils.matrixMultiply(linrgb!!, SCALED_DISCOUNT_FROM_LINRGB) + val rA = chromaticAdaptation(scaledDiscount[0]) + val gA = chromaticAdaptation(scaledDiscount[1]) + val bA = chromaticAdaptation(scaledDiscount[2]) + // redness-greenness + val a = (11.0 * rA + -12.0 * gA + bA) / 11.0 + // yellowness-blueness + val b = (rA + gA - 2.0 * bA) / 9.0 + return atan2(b, a) + } + + fun areInCyclicOrder(a: Double, b: Double, c: Double): Boolean { + val deltaAB = sanitizeRadians(b - a) + val deltaAC = sanitizeRadians(c - a) + return deltaAB < deltaAC + } + + /** + * Solves the lerp equation. + * + * @param source The starting number. + * @param mid The number in the middle. + * @param target The ending number. + * @return A number t such that lerp(source, target, t) = mid. + */ + fun intercept(source: Double, mid: Double, target: Double): Double { + return (mid - source) / (target - source) + } + + fun lerpPoint(source: DoubleArray, t: Double, target: DoubleArray): DoubleArray { + return doubleArrayOf( + source[0] + (target[0] - source[0]) * t, + source[1] + (target[1] - source[1]) * t, + source[2] + (target[2] - source[2]) * t + ) + } + + /** + * Intersects a segment with a plane. + * + * @param source The coordinates of point A. + * @param coordinate The R-, G-, or B-coordinate of the plane. + * @param target The coordinates of point B. + * @param axis The axis the plane is perpendicular with. (0: R, 1: G, 2: B) + * @return The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or + * B=coordinate + */ + fun setCoordinate(source: DoubleArray, coordinate: Double, target: DoubleArray, axis: Int): DoubleArray { + val t = intercept(source[axis], coordinate, target[axis]) + return lerpPoint(source, t, target) + } + + fun isBounded(x: Double): Boolean { + return x in 0.0..100.0 + } + + /** + * Returns the nth possible vertex of the polygonal intersection. + * + * @param y The Y value of the plane. + * @param n The zero-based index of the point. 0 <= n <= 11. + * @return The nth possible vertex of the polygonal intersection of the y plane and the RGB cube, + * in linear RGB coordinates, if it exists. If this possible vertex lies outside of the cube, + * [-1.0, -1.0, -1.0] is returned. + */ + fun nthVertex(y: Double, n: Int): DoubleArray { + val kR = Y_FROM_LINRGB[0] + val kG = Y_FROM_LINRGB[1] + val kB = Y_FROM_LINRGB[2] + val coordA = if (n % 4 <= 1) 0.0 else 100.0 + val coordB = if (n % 2 == 0) 0.0 else 100.0 + return if (n < 4) { + val r = (y - coordA * kG - coordB * kB) / kR + if (isBounded(r)) { + doubleArrayOf(r, coordA, coordB) + } else { + doubleArrayOf(-1.0, -1.0, -1.0) + } + } else if (n < 8) { + val g = (y - coordB * kR - coordA * kB) / kG + if (isBounded(g)) { + doubleArrayOf(coordB, g, coordA) + } else { + doubleArrayOf(-1.0, -1.0, -1.0) + } + } else { + val b = (y - coordA * kR - coordB * kG) / kB + if (isBounded(b)) { + doubleArrayOf(coordA, coordB, b) + } else { + doubleArrayOf(-1.0, -1.0, -1.0) + } + } + } + + /** + * Finds the segment containing the desired color. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @return A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the + * segment containing the desired color. + */ + fun bisectToSegment(y: Double, targetHue: Double): Array { + var left = doubleArrayOf(-1.0, -1.0, -1.0) + var right = left + var leftHue = 0.0 + var rightHue = 0.0 + var initialized = false + var uncut = true + for (n in 0..11) { + val mid = nthVertex(y, n) + if (mid[0] < 0) { + continue + } + val midHue = hueOf(mid) + if (!initialized) { + left = mid + right = mid + leftHue = midHue + rightHue = midHue + initialized = true + continue + } + if (uncut || areInCyclicOrder(leftHue, midHue, rightHue)) { + uncut = false + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid + rightHue = midHue + } else { + left = mid + leftHue = midHue + } + } + } + return arrayOf(left, right) + } + + fun midpoint(a: DoubleArray, b: DoubleArray): DoubleArray { + return doubleArrayOf( + (a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2 + ) + } + + fun criticalPlaneBelow(x: Double): Int { + return floor(x - 0.5).toInt() + } + + fun criticalPlaneAbove(x: Double): Int { + return ceil(x - 0.5).toInt() + } + + /** + * Finds a color with the given Y and hue on the boundary of the cube. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @return The desired color, in linear RGB coordinates. + */ + fun bisectToLimit(y: Double, targetHue: Double): DoubleArray { + val segment = bisectToSegment(y, targetHue) + var left = segment[0] + var leftHue = hueOf(left) + var right = segment[1] + for (axis in 0..2) { + if (left[axis] != right[axis]) { + var lPlane: Int + var rPlane: Int + if (left[axis] < right[axis]) { + lPlane = criticalPlaneBelow(trueDelinearized(left[axis])) + rPlane = criticalPlaneAbove(trueDelinearized(right[axis])) + } else { + lPlane = criticalPlaneAbove(trueDelinearized(left[axis])) + rPlane = criticalPlaneBelow(trueDelinearized(right[axis])) + } + for (i in 0..7) { + if (abs(rPlane - lPlane) <= 1) { + break + } else { + val mPlane = floor((lPlane + rPlane) / 2.0).toInt() + val midPlaneCoordinate = CRITICAL_PLANES[mPlane] + val mid = setCoordinate(left, midPlaneCoordinate, right, axis) + val midHue = hueOf(mid) + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid + rPlane = mPlane + } else { + left = mid + leftHue = midHue + lPlane = mPlane + } + } + } + } + } + return midpoint(left, right) + } + + fun inverseChromaticAdaptation(adapted: Double): Double { + val adaptedAbs = abs(adapted) + val base = 0.0.coerceAtLeast(27.13 * adaptedAbs / (400.0 - adaptedAbs)) + return MathUtils.signum(adapted) * base.pow(1.0 / 0.42) + } + + /** + * Finds a color with the given hue, chroma, and Y. + * + * @param hueRadians The desired hue in radians. + * @param chroma The desired chroma. + * @param y The desired Y. + * @return The desired color as a hexadecimal integer, if found; 0 otherwise. + */ + fun findResultByJ(hueRadians: Double, chroma: Double, y: Double): Int { + // Initial estimate of j. + var j = sqrt(y) * 11.0 + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + val viewingConditions = ViewingConditions.DEFAULT + val tInnerCoeff = 1 / (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73) + val eHue = 0.25 * (cos(hueRadians + 2.0) + 3.8) + val p1 = eHue * (50000.0 / 13.0) * viewingConditions.nc * viewingConditions.ncb + val hSin = sin(hueRadians) + val hCos = cos(hueRadians) + for (iterationRound in 0..4) { + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + val jNormalized = j / 100.0 + val alpha = if (chroma == 0.0 || j == 0.0) 0.0 else chroma / sqrt(jNormalized) + val t = (alpha * tInnerCoeff).pow(1.0 / 0.9) + val ac = (viewingConditions.aw + * jNormalized.pow(1.0 / viewingConditions.c / viewingConditions.z)) + val p2 = ac / viewingConditions.nbb + val gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin) + val a = gamma * hCos + val b = gamma * hSin + val rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 + val gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 + val bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 + val rCScaled = inverseChromaticAdaptation(rA) + val gCScaled = inverseChromaticAdaptation(gA) + val bCScaled = inverseChromaticAdaptation(bA) + val linrgb = + MathUtils.matrixMultiply(doubleArrayOf(rCScaled, gCScaled, bCScaled), LINRGB_FROM_SCALED_DISCOUNT) + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + if (linrgb[0] < 0 || linrgb[1] < 0 || linrgb[2] < 0) { + return 0 + } + val kR = Y_FROM_LINRGB[0] + val kG = Y_FROM_LINRGB[1] + val kB = Y_FROM_LINRGB[2] + val fnj = kR * linrgb[0] + kG * linrgb[1] + kB * linrgb[2] + if (fnj <= 0) { + return 0 + } + if (iterationRound == 4 || abs(fnj - y) < 0.002) { + return if (linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01) { + 0 + } else ColorUtils.argbFromLinrgb(linrgb) + } + // Iterates with Newton method, + // Using 2 * fn(j) / j as the approximation of fn'(j) + j = j - (fnj - y) * j / (2 * fnj) + } + return 0 + } + + /** + * Finds an sRGB color with the given hue, chroma, and L*, if possible. + * + * @param hueDegrees The desired hue, in degrees. + * @param chroma The desired chroma. + * @param lstar The desired L*. + * @return A hexadecimal representing the sRGB color. The color has sufficiently close hue, + * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be + * sufficiently close, and chroma will be maximized. + */ + fun solveToInt(hueDegrees: Double, chroma: Double, lstar: Double): Int { + var hueDegree = hueDegrees + if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { + return ColorUtils.argbFromLstar(lstar) + } + hueDegree = MathUtils.sanitizeDegreesDouble(hueDegree) + val hueRadians = hueDegree / 180 * PI + val y = ColorUtils.yFromLstar(lstar) + val exactAnswer = findResultByJ(hueRadians, chroma, y) + if (exactAnswer != 0) { + return exactAnswer + } + val linrgb = bisectToLimit(y, hueRadians) + return ColorUtils.argbFromLinrgb(linrgb) + } + + /** + * Finds an sRGB color with the given hue, chroma, and L*, if possible. + * + * @param hueDegrees The desired hue, in degrees. + * @param chroma The desired chroma. + * @param lstar The desired L*. + * @return An CAM16 object representing the sRGB color. The color has sufficiently close hue, + * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be + * sufficiently close, and chroma will be maximized. + */ + fun solveToCam(hueDegrees: Double, chroma: Double, lstar: Double): Cam16 { + return Cam16.fromInt(solveToInt(hueDegrees, chroma, lstar)) + } +} diff --git a/kotlin/lib/commonMain/kotlin/hct/ViewingConditions.kt b/kotlin/lib/commonMain/kotlin/hct/ViewingConditions.kt new file mode 100644 index 0000000..1624704 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/hct/ViewingConditions.kt @@ -0,0 +1,123 @@ +package hct + +import utils.ColorUtils +import utils.MathUtils +import kotlin.math.* + +/** + * In traditional color spaces, a color can be identified solely by the observer's measurement of + * the color. Color appearance models such as CAM16 also use information about the environment where + * the color was observed, known as the viewing conditions. + * + *

For example, white under the traditional assumption of a midday sun white point is accurately + * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) + * + *

This class caches intermediate values of the CAM16 conversion process that depend only on + * viewing conditions, enabling speed ups. + */ +class ViewingConditions +/** + * Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand + * for technical color science terminology, this class would not benefit from documenting them + * individually. A brief overview is available in the CAM16 specification, and a complete overview + * requires a color science textbook, such as Fairchild's Color Appearance Models. + */ private constructor( + val n: Double, + val aw: Double, + val nbb: Double, + val ncb: Double, + val c: Double, + val nc: Double, + val rgbD: DoubleArray, + val fl: Double, + val flRoot: Double, + val z: Double +) { + + companion object { + /** sRGB-like viewing conditions. */ + val DEFAULT = defaultWithBackgroundLstar(50.0) + + /** + * Create ViewingConditions from a simple, physically relevant, set of parameters. + * + * @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day + * afternoon + * @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in + * the room where the color is viewed. Can be calculated from lux by multiplying lux by + * 0.0586. default = 11.72, or 200 lux. + * @param backgroundLstar The lightness of the area surrounding the color. measured by L* in + * L*a*b*. default = 50.0 + * @param surround A general description of the lighting surrounding the color. 0 is pitch dark, + * like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at + * night. 2.0 means there is no difference between the lighting on the color and around it. + * default = 2.0 + * @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting, + * such as knowing an apple is still red in green light. default = false, the eye does not + * perform this process on self-luminous objects like displays. + */ + fun make( + whitePoint: DoubleArray, + adaptingLuminance: Double, + backgroundLstar: Double, + surround: Double, + discountingIlluminant: Boolean + ): ViewingConditions { + // A background of pure black is non-physical and leads to infinities that represent the idea + // that any color viewed in pure black can't be seen. + var backgroundLstar = backgroundLstar + backgroundLstar = max(0.1, backgroundLstar) + // Transform white point XYZ to 'cone'/'rgb' responses + val matrix = Cam16.XYZ_TO_CAM16RGB + val rW = whitePoint[0] * matrix[0][0] + whitePoint[1] * matrix[0][1] + whitePoint[2] * matrix[0][2] + val gW = whitePoint[0] * matrix[1][0] + whitePoint[1] * matrix[1][1] + whitePoint[2] * matrix[1][2] + val bW = whitePoint[0] * matrix[2][0] + whitePoint[1] * matrix[2][1] + whitePoint[2] * matrix[2][2] + val f = 0.8 + surround / 10.0 + val c = if (f >= 0.9) MathUtils.lerp(0.59, 0.69, (f - 0.9) * 10.0) else MathUtils.lerp( + 0.525, 0.59, + (f - 0.8) * 10.0 + ) + var d = + if (discountingIlluminant) 1.0 else f * (1.0 - 1.0 / 3.6 * exp((-adaptingLuminance - 42.0) / 92.0)) + d = MathUtils.clampDouble(0.0, 1.0, d) + val rgbD = doubleArrayOf( + d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, d * (100.0 / bW) + 1.0 - d + ) + val k = 1.0 / (5.0 * adaptingLuminance + 1.0) + val k4 = k * k * k * k + val k4F = 1.0 - k4 + val fl = k4 * adaptingLuminance + 0.1 * k4F * k4F * cbrt(5.0 * adaptingLuminance) + val n = ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1] + val z = 1.48 + sqrt(n) + val nbb = 0.725 / n.pow(0.2) + val rgbAFactors = doubleArrayOf( + (fl * rgbD[0] * rW / 100.0).pow(0.42), + (fl * rgbD[1] * gW / 100.0).pow(0.42), + (fl * rgbD[2] * bW / 100.0).pow(0.42) + ) + val rgbA = doubleArrayOf( + 400.0 * rgbAFactors[0] / (rgbAFactors[0] + 27.13), + 400.0 * rgbAFactors[1] / (rgbAFactors[1] + 27.13), + 400.0 * rgbAFactors[2] / (rgbAFactors[2] + 27.13) + ) + val aw = (2.0 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]) * nbb + return ViewingConditions(n, aw, nbb, nbb, c, f, rgbD, fl, fl.pow(0.25), z) + } + + /** + * Create sRGB-like viewing conditions with a custom background lstar. + * + * + * Default viewing conditions have a lstar of 50, midgray. + */ + fun defaultWithBackgroundLstar(lstar: Double): ViewingConditions { + return make( + ColorUtils.whitePointD65(), + 200.0 / PI * ColorUtils.yFromLstar(50.0) / 100f, + lstar, + 2.0, + false + ) + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/palettes/CorePalette.kt b/kotlin/lib/commonMain/kotlin/palettes/CorePalette.kt new file mode 100644 index 0000000..2f37017 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/palettes/CorePalette.kt @@ -0,0 +1,59 @@ +package palettes + +import hct.Hct +import kotlin.math.min +import kotlin.math.max + +/** + * An intermediate concept between the key color for a UI theme, and a full color scheme. 5 sets of + * tones are generated, all except one use the same hue as the key color, and all vary in chroma. + */ +class CorePalette private constructor(argb: Int, isContent: Boolean) { + var a1: TonalPalette + var a2: TonalPalette + var a3: TonalPalette + var n1: TonalPalette + var n2: TonalPalette + var error: TonalPalette + + init { + val hct = Hct.fromInt(argb) + val hue = hct.hue + val chroma = hct.chroma + + if (isContent) { + a1 = TonalPalette.fromHueAndChroma(hue, chroma) + a2 = TonalPalette.fromHueAndChroma(hue, chroma / 3.0) + a3 = TonalPalette.fromHueAndChroma(hue + 60.0, chroma / 2.0) + n1 = TonalPalette.fromHueAndChroma(hue, min(chroma / 12.0, 4.0)) + n2 = TonalPalette.fromHueAndChroma(hue, min(chroma / 6.0, 8.0)) + } else { + a1 = TonalPalette.fromHueAndChroma(hue, max(48.0, chroma)) + a2 = TonalPalette.fromHueAndChroma(hue, 16.0) + a3 = TonalPalette.fromHueAndChroma(hue + 60.0, 24.0) + n1 = TonalPalette.fromHueAndChroma(hue, 4.0) + n2 = TonalPalette.fromHueAndChroma(hue, 8.0) + } + error = TonalPalette.fromHueAndChroma(25.0, 84.0) + } + + companion object { + /** + * Create key tones from a color. + * + * @param argb ARGB representation of a color + */ + fun of(argb: Int): CorePalette { + return CorePalette(argb, false) + } + + /** + * Create content key tones from a color. + * + * @param argb ARGB representation of a color + */ + fun contentOf(argb: Int): CorePalette { + return CorePalette(argb, true) + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/palettes/TonalPalette.kt b/kotlin/lib/commonMain/kotlin/palettes/TonalPalette.kt new file mode 100644 index 0000000..7749c53 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/palettes/TonalPalette.kt @@ -0,0 +1,63 @@ +package palettes + +import hct.Hct + +/** + * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. + */ +class TonalPalette private constructor(var hue: Double, var chroma: Double) { + var cache: MutableMap = HashMap() + + /** + * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. + * + * @param tone HCT tone, measured from 0 to 100. + * @return ARGB representation of a color with that tone. + */ + // AndroidJdkLibsChecker is higher priority than ComputeIfAbsentUseValue (b/119581923) + fun tone(tone: Int): Int { + var color = cache[tone] + if (color == null) { + color = Hct.from(hue, chroma, tone.toDouble()).toInt() + cache[tone] = color + } + return color + } + + fun getHct(tone: Double): Hct { + return Hct.from(hue, chroma, tone) + } + + companion object { + /** + * Create tones using the HCT hue and chroma from a color. + * + * @param argb ARGB representation of a color + * @return Tones matching that color's hue and chroma. + */ + fun fromInt(argb: Int): TonalPalette { + return fromHct(Hct.fromInt(argb)) + } + + /** + * Create tones using a HCT color. + * + * @param hct HCT representation of a color. + * @return Tones matching that color's hue and chroma. + */ + fun fromHct(hct: Hct): TonalPalette { + return fromHueAndChroma(hct.hue, hct.chroma) + } + + /** + * Create tones from a defined HCT hue and chroma. + * + * @param hue HCT hue + * @param chroma HCT chroma + * @return Tones matching hue and chroma. + */ + fun fromHueAndChroma(hue: Double, chroma: Double): TonalPalette { + return TonalPalette(hue, chroma) + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/quantize/PointProvider.kt b/kotlin/lib/commonMain/kotlin/quantize/PointProvider.kt new file mode 100644 index 0000000..bf09c84 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/PointProvider.kt @@ -0,0 +1,8 @@ +package quantize + +/** An interface to allow use of different color spaces by quantizers. */ +interface PointProvider { + fun fromInt(argb: Int): DoubleArray? + fun toInt(point: DoubleArray?): Int + fun distance(a: DoubleArray?, b: DoubleArray?): Double +} diff --git a/kotlin/lib/commonMain/kotlin/quantize/PointProviderLab.kt b/kotlin/lib/commonMain/kotlin/quantize/PointProviderLab.kt new file mode 100644 index 0000000..a1919ac --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/PointProviderLab.kt @@ -0,0 +1,39 @@ +package quantize + +import utils.ColorUtils.argbFromLab +import utils.ColorUtils.labFromArgb + +/** + * Provides conversions needed for K-Means quantization. Converting input to points, and converting + * the final state of the K-Means algorithm to colors. + */ +class PointProviderLab : PointProvider { + /** + * Convert a color represented in ARGB to a 3-element array of L*a*b* coordinates of the color. + */ + override fun fromInt(argb: Int): DoubleArray { + val lab = labFromArgb(argb) + return doubleArrayOf(lab[0], lab[1], lab[2]) + } + + /** Convert a 3-element array to a color represented in ARGB. */ + override fun toInt(lab: DoubleArray?): Int { + return argbFromLab(lab!![0], lab[1], lab[2]) + } + + /** + * Standard CIE 1976 delta E formula also takes the square root, unneeded here. This method is + * used by quantization algorithms to compare distance, and the relative ordering is the same, + * with or without a square root. + * + * + * This relatively minor optimization is helpful because this method is called at least once + * for each pixel in an image. + */ + override fun distance(one: DoubleArray?, two: DoubleArray?): Double { + val dL = one!![0] - two!![0] + val dA = one[1] - two[1] + val dB = one[2] - two[2] + return dL * dL + dA * dA + dB * dB + } +} diff --git a/kotlin/lib/commonMain/kotlin/quantize/Quantizer.kt b/kotlin/lib/commonMain/kotlin/quantize/Quantizer.kt new file mode 100644 index 0000000..1210214 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/Quantizer.kt @@ -0,0 +1,6 @@ +package quantize + + +internal interface Quantizer { + fun quantize(pixels: IntArray?, maxColors: Int): QuantizerResult +} diff --git a/kotlin/lib/commonMain/kotlin/quantize/QuantizerCelebi.kt b/kotlin/lib/commonMain/kotlin/quantize/QuantizerCelebi.kt new file mode 100644 index 0000000..70cff4d --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/QuantizerCelebi.kt @@ -0,0 +1,34 @@ +package quantize + +/** + * An image quantizer that improves on the quality of a standard K-Means algorithm by setting the + * K-Means initial state to the output of a Wu quantizer, instead of random centroids. Improves on + * speed by several optimizations, as implemented in Wsmeans, or Weighted Square Means, K-Means with + * those optimizations. + * + *

This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving + * the Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 + */ +object QuantizerCelebi { + /** + * Reduce the number of colors needed to represented the input, minimizing the difference between + * the original image and the recolored image. + * + * @param pixels Colors in ARGB format. + * @param maxColors The number of colors to divide the image into. A lower number of colors may be + * returned. + * @return Map with keys of colors in ARGB format, and values of number of pixels in the original + * image that correspond to the color in the quantized image. + */ + fun quantize(pixels: IntArray, maxColors: Int): Map { + val wu = QuantizerWu() + val wuResult: QuantizerResult = wu.quantize(pixels, maxColors) + val wuClustersAsObjects = wuResult.colorToCount.keys + var index = 0 + val wuClusters = IntArray(wuClustersAsObjects.size) + for (argb in wuClustersAsObjects) { + wuClusters[index++] = argb + } + return QuantizerWsmeans.quantize(pixels, wuClusters, maxColors) + } +} diff --git a/kotlin/lib/commonMain/kotlin/quantize/QuantizerMap.kt b/kotlin/lib/commonMain/kotlin/quantize/QuantizerMap.kt new file mode 100644 index 0000000..78071cd --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/QuantizerMap.kt @@ -0,0 +1,17 @@ +package quantize + +/** Creates a dictionary with keys of colors, and values of count of the color */ +class QuantizerMap : Quantizer { + var colorToCount: Map? = null + + override fun quantize(pixels: IntArray?, colorCount: Int): QuantizerResult { + val pixelByCount: MutableMap = LinkedHashMap() + for (pixel in pixels!!) { + val currentPixelCount = pixelByCount[pixel] + val newPixelCount = if (currentPixelCount == null) 1 else currentPixelCount + 1 + pixelByCount[pixel] = newPixelCount + } + colorToCount = pixelByCount + return QuantizerResult(pixelByCount) + } +} diff --git a/kotlin/lib/commonMain/kotlin/quantize/QuantizerResult.kt b/kotlin/lib/commonMain/kotlin/quantize/QuantizerResult.kt new file mode 100644 index 0000000..2792cf6 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/QuantizerResult.kt @@ -0,0 +1,4 @@ +package quantize + +/** Represents result of a quantizer run */ +class QuantizerResult internal constructor(val colorToCount: Map) diff --git a/kotlin/lib/commonMain/kotlin/quantize/QuantizerWsmeans.kt b/kotlin/lib/commonMain/kotlin/quantize/QuantizerWsmeans.kt new file mode 100644 index 0000000..d11c8b7 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/QuantizerWsmeans.kt @@ -0,0 +1,180 @@ +package quantize + +import kotlin.math.min +import kotlin.Array +import kotlin.math.abs +import kotlin.math.sqrt +import kotlin.random.Random + +/** + * An image quantizer that improves on the speed of a standard K-Means algorithm by implementing + * several optimizations, including deduping identical pixels and a triangle inequality rule that + * reduces the number of comparisons needed to identify which cluster a point should be moved to. + * + *

Wsmeans stands for Weighted Square Means. + * + *

This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving + * the Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 + */ +object QuantizerWsmeans { + private const val MAX_ITERATIONS = 10 + private const val MIN_MOVEMENT_DISTANCE = 3.0 + + /** + * Reduce the number of colors needed to represented the input, minimizing the difference between + * the original image and the recolored image. + * + * @param inputPixels Colors in ARGB format. + * @param startingClusters Defines the initial state of the quantizer. Passing an empty array is + * fine, the implementation will create its own initial state that leads to reproducible + * results for the same inputs. Passing an array that is the result of Wu quantization leads + * to higher quality results. + * @param maxColors The number of colors to divide the image into. A lower number of colors may be + * returned. + * @return Map with keys of colors in ARGB format, values of how many of the input pixels belong + * to the color. + */ + fun quantize( + inputPixels: IntArray, startingClusters: IntArray, maxColors: Int + ): Map { + // Uses a seeded random number generator to ensure consistent results. + val random = Random(0x42688) + val pixelToCount: MutableMap = LinkedHashMap() + val points = arrayOfNulls(inputPixels.size) + val pixels = IntArray(inputPixels.size) + val pointProvider: PointProvider = PointProviderLab() + var pointCount = 0 + for (i in inputPixels.indices) { + val inputPixel = inputPixels[i] + val pixelCount = pixelToCount[inputPixel] + if (pixelCount == null) { + points[pointCount] = pointProvider.fromInt(inputPixel) + pixels[pointCount] = inputPixel + pointCount++ + pixelToCount[inputPixel] = 1 + } else { + pixelToCount[inputPixel] = pixelCount + 1 + } + } + val counts = IntArray(pointCount) + for (i in 0 until pointCount) { + val pixel = pixels[i] + val count = pixelToCount[pixel]!! + counts[i] = count + } + var clusterCount: Int = min(maxColors, pointCount) + if (startingClusters.size != 0) { + clusterCount = min(clusterCount, startingClusters.size) + } + val clusters = arrayOfNulls(clusterCount) + var clustersCreated = 0 + for (i in startingClusters.indices) { + clusters[i] = pointProvider.fromInt(startingClusters[i]) + clustersCreated++ + } + val clusterIndices = IntArray(pointCount) + for (i in 0 until pointCount) { + clusterIndices[i] = random.nextInt(clusterCount) + } + val indexMatrix = arrayOfNulls(clusterCount) + for (i in 0 until clusterCount) { + indexMatrix[i] = IntArray(clusterCount) + } + val distanceToIndexMatrix: Array> = Array(clusterCount) { + Array(clusterCount) { Distance() } + } + val pixelCountSums = IntArray(clusterCount) + for (iteration in 0 until MAX_ITERATIONS) { + for (i in 0 until clusterCount) { + for (j in i + 1 until clusterCount) { + val distance = pointProvider.distance(clusters[i], clusters[j]) + distanceToIndexMatrix[j][i]!!.distance = distance + distanceToIndexMatrix[j][i]!!.index = i + distanceToIndexMatrix[i][j]!!.distance = distance + distanceToIndexMatrix[i][j]!!.index = j + } + distanceToIndexMatrix[i].sortBy { it } + for (j in 0 until clusterCount) { + indexMatrix[i]!![j] = distanceToIndexMatrix[i][j]!!.index + } + } + var pointsMoved = 0 + for (i in 0 until pointCount) { + val point = points[i] + val previousClusterIndex = clusterIndices[i] + val previousCluster = clusters[previousClusterIndex] + val previousDistance = pointProvider.distance(point, previousCluster) + var minimumDistance = previousDistance + var newClusterIndex = -1 + for (j in 0 until clusterCount) { + if (distanceToIndexMatrix[previousClusterIndex][j]!!.distance >= 4 * previousDistance) { + continue + } + val distance = pointProvider.distance(point, clusters[j]) + if (distance < minimumDistance) { + minimumDistance = distance + newClusterIndex = j + } + } + if (newClusterIndex != -1) { + val distanceChange = abs(sqrt(minimumDistance) - sqrt(previousDistance)) + if (distanceChange > MIN_MOVEMENT_DISTANCE) { + pointsMoved++ + clusterIndices[i] = newClusterIndex + } + } + } + if (pointsMoved == 0 && iteration != 0) { + break + } + val componentASums = DoubleArray(clusterCount) + val componentBSums = DoubleArray(clusterCount) + val componentCSums = DoubleArray(clusterCount) + pixelCountSums.fill(0) + for (i in 0 until pointCount) { + val clusterIndex = clusterIndices[i] + val point = points[i] + val count = counts[i] + pixelCountSums[clusterIndex] += count + componentASums[clusterIndex] += point!![0] * count + componentBSums[clusterIndex] += point[1] * count + componentCSums[clusterIndex] += point[2] * count + } + for (i in 0 until clusterCount) { + val count = pixelCountSums[i] + if (count == 0) { + clusters[i] = doubleArrayOf(0.0, 0.0, 0.0) + continue + } + val a = componentASums[i] / count + val b = componentBSums[i] / count + val c = componentCSums[i] / count + clusters[i]!![0] = a + clusters[i]!![1] = b + clusters[i]!![2] = c + } + } + val argbToPopulation: MutableMap = LinkedHashMap() + for (i in 0 until clusterCount) { + val count = pixelCountSums[i] + if (count == 0) { + continue + } + val possibleNewCluster = pointProvider.toInt(clusters[i]) + if (argbToPopulation.containsKey(possibleNewCluster)) { + continue + } + argbToPopulation[possibleNewCluster] = count + } + return argbToPopulation + } + + private class Distance internal constructor() : Comparable { + var index: Int = -1 + var distance: Double = -1.0 + + override operator fun compareTo(other: Distance?): Int { + return if (other != null) distance.compareTo(other.distance) else 0 + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/quantize/QuantizerWu.kt b/kotlin/lib/commonMain/kotlin/quantize/QuantizerWu.kt new file mode 100644 index 0000000..73b1d5c --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/quantize/QuantizerWu.kt @@ -0,0 +1,347 @@ +package quantize + +import utils.ColorUtils.blueFromArgb +import utils.ColorUtils.greenFromArgb +import utils.ColorUtils.redFromArgb + +/** + * An image quantizer that divides the image's pixels into clusters by recursively cutting an RGB + * cube, based on the weight of pixels in each area of the cube. + * + *

The algorithm was described by Xiaolin Wu in Graphic Gems II, published in 1991. + */ +class QuantizerWu : Quantizer { + lateinit var weights: IntArray + lateinit var momentsR: IntArray + lateinit var momentsG: IntArray + lateinit var momentsB: IntArray + lateinit var moments: DoubleArray + lateinit var cubes: Array + + override fun quantize(pixels: IntArray?, colorCount: Int): QuantizerResult { + val mapResult = QuantizerMap().quantize(pixels, colorCount) + constructHistogram(mapResult.colorToCount) + createMoments() + val createBoxesResult = createBoxes(colorCount) + val colors = createResult(createBoxesResult.resultCount) + val resultMap: MutableMap = LinkedHashMap() + for (color in colors) { + resultMap[color] = 0 + } + return QuantizerResult(resultMap) + } + + fun constructHistogram(pixels: Map) { + weights = IntArray(TOTAL_SIZE) + momentsR = IntArray(TOTAL_SIZE) + momentsG = IntArray(TOTAL_SIZE) + momentsB = IntArray(TOTAL_SIZE) + moments = DoubleArray(TOTAL_SIZE) + for ((pixel, count) in pixels) { + val red = redFromArgb(pixel) + val green = greenFromArgb(pixel) + val blue = blueFromArgb(pixel) + val bitsToRemove = 8 - INDEX_BITS + val iR = (red shr bitsToRemove) + 1 + val iG = (green shr bitsToRemove) + 1 + val iB = (blue shr bitsToRemove) + 1 + val index = getIndex(iR, iG, iB) + weights[index] += count + momentsR[index] += red * count + momentsG[index] += green * count + momentsB[index] += blue * count + moments[index] += (count * (red * red + green * green + blue * blue)).toDouble() + } + } + + fun createMoments() { + for (r in 1 until INDEX_COUNT) { + val area = IntArray(INDEX_COUNT) + val areaR = IntArray(INDEX_COUNT) + val areaG = IntArray(INDEX_COUNT) + val areaB = IntArray(INDEX_COUNT) + val area2 = DoubleArray(INDEX_COUNT) + for (g in 1 until INDEX_COUNT) { + var line = 0 + var lineR = 0 + var lineG = 0 + var lineB = 0 + var line2 = 0.0 + for (b in 1 until INDEX_COUNT) { + val index = getIndex(r, g, b) + line += weights[index] + lineR += momentsR[index] + lineG += momentsG[index] + lineB += momentsB[index] + line2 += moments[index] + area[b] += line + areaR[b] += lineR + areaG[b] += lineG + areaB[b] += lineB + area2[b] += line2 + val previousIndex = getIndex(r - 1, g, b) + weights[index] = weights[previousIndex] + area[b] + momentsR[index] = momentsR[previousIndex] + areaR[b] + momentsG[index] = momentsG[previousIndex] + areaG[b] + momentsB[index] = momentsB[previousIndex] + areaB[b] + moments[index] = moments[previousIndex] + area2[b] + } + } + } + } + + fun createBoxes(maxColorCount: Int): CreateBoxesResult { + cubes = arrayOfNulls(maxColorCount) + for (i in 0 until maxColorCount) { + cubes[i] = Box() + } + val volumeVariance = DoubleArray(maxColorCount) + val firstBox = cubes[0] + firstBox!!.r1 = INDEX_COUNT - 1 + firstBox.g1 = INDEX_COUNT - 1 + firstBox.b1 = INDEX_COUNT - 1 + var generatedColorCount = maxColorCount + var next = 0 + var i = 1 + while (i < maxColorCount) { + if (cut(cubes[next], cubes[i])) { + volumeVariance[next] = if (cubes[next]!!.vol > 1) variance(cubes[next]) else 0.0 + volumeVariance[i] = if (cubes[i]!!.vol > 1) variance(cubes[i]) else 0.0 + } else { + volumeVariance[next] = 0.0 + i-- + } + next = 0 + var temp = volumeVariance[0] + for (j in 1..i) { + if (volumeVariance[j] > temp) { + temp = volumeVariance[j] + next = j + } + } + if (temp <= 0.0) { + generatedColorCount = i + 1 + break + } + i++ + } + return CreateBoxesResult(maxColorCount, generatedColorCount) + } + + fun createResult(colorCount: Int): List { + val colors: MutableList = ArrayList() + for (i in 0 until colorCount) { + val cube = cubes[i] + val weight = volume(cube, weights) + if (weight > 0) { + val r = volume(cube, momentsR) / weight + val g = volume(cube, momentsG) / weight + val b = volume(cube, momentsB) / weight + val color = 255 shl 24 or (r and 0x0ff shl 16) or (g and 0x0ff shl 8) or (b and 0x0ff) + colors.add(color) + } + } + return colors + } + + fun variance(cube: Box?): Double { + val dr = volume(cube, momentsR) + val dg = volume(cube, momentsG) + val db = volume(cube, momentsB) + val xx = ((((moments[getIndex(cube!!.r1, cube.g1, cube.b1)] + - moments[getIndex(cube.r1, cube.g1, cube.b0)] + - moments[getIndex(cube.r1, cube.g0, cube.b1)]) + + moments[getIndex(cube.r1, cube.g0, cube.b0)] + - moments[getIndex(cube.r0, cube.g1, cube.b1)] + ) + moments[getIndex(cube.r0, cube.g1, cube.b0)] + + moments[getIndex(cube.r0, cube.g0, cube.b1)]) + - moments[getIndex(cube.r0, cube.g0, cube.b0)]) + val hypotenuse = dr * dr + dg * dg + db * db + val volume = volume(cube, weights) + return xx - hypotenuse / volume.toDouble() + } + + fun cut(one: Box?, two: Box?): Boolean { + val wholeR = volume(one, momentsR) + val wholeG = volume(one, momentsG) + val wholeB = volume(one, momentsB) + val wholeW = volume(one, weights) + val maxRResult = maximize(one, Direction.RED, one!!.r0 + 1, one.r1, wholeR, wholeG, wholeB, wholeW) + val maxGResult = maximize(one, Direction.GREEN, one.g0 + 1, one.g1, wholeR, wholeG, wholeB, wholeW) + val maxBResult = maximize(one, Direction.BLUE, one.b0 + 1, one.b1, wholeR, wholeG, wholeB, wholeW) + val cutDirection: Direction + val maxR = maxRResult.maximum + val maxG = maxGResult.maximum + val maxB = maxBResult.maximum + cutDirection = if (maxR >= maxG && maxR >= maxB) { + if (maxRResult.cutLocation < 0) { + return false + } + Direction.RED + } else if (maxG >= maxR && maxG >= maxB) { + Direction.GREEN + } else { + Direction.BLUE + } + two!!.r1 = one.r1 + two.g1 = one.g1 + two.b1 = one.b1 + when (cutDirection) { + Direction.RED -> { + one.r1 = maxRResult.cutLocation + two.r0 = one.r1 + two.g0 = one.g0 + two.b0 = one.b0 + } + + Direction.GREEN -> { + one.g1 = maxGResult.cutLocation + two.r0 = one.r0 + two.g0 = one.g1 + two.b0 = one.b0 + } + + Direction.BLUE -> { + one.b1 = maxBResult.cutLocation + two.r0 = one.r0 + two.g0 = one.g0 + two.b0 = one.b1 + } + } + one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0) + two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0) + return true + } + + fun maximize( + cube: Box?, + direction: Direction, + first: Int, + last: Int, + wholeR: Int, + wholeG: Int, + wholeB: Int, + wholeW: Int + ): MaximizeResult { + val bottomR = bottom(cube, direction, momentsR) + val bottomG = bottom(cube, direction, momentsG) + val bottomB = bottom(cube, direction, momentsB) + val bottomW = bottom(cube, direction, weights) + var max = 0.0 + var cut = -1 + var halfR: Int + var halfG: Int + var halfB: Int + var halfW: Int + for (i in first until last) { + halfR = bottomR + top(cube, direction, i, momentsR) + halfG = bottomG + top(cube, direction, i, momentsG) + halfB = bottomB + top(cube, direction, i, momentsB) + halfW = bottomW + top(cube, direction, i, weights) + if (halfW == 0) { + continue + } + var tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB).toDouble() + var tempDenominator = halfW.toDouble() + var temp = tempNumerator / tempDenominator + halfR = wholeR - halfR + halfG = wholeG - halfG + halfB = wholeB - halfB + halfW = wholeW - halfW + if (halfW == 0) { + continue + } + tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB).toDouble() + tempDenominator = halfW.toDouble() + temp += tempNumerator / tempDenominator + if (temp > max) { + max = temp + cut = i + } + } + return MaximizeResult(cut, max) + } + + enum class Direction { + RED, + GREEN, + BLUE + } + + class MaximizeResult internal constructor(// < 0 if cut impossible + var cutLocation: Int, var maximum: Double + ) + + class CreateBoxesResult internal constructor(var requestedCount: Int, var resultCount: Int) + class Box { + var r0 = 0 + var r1 = 0 + var g0 = 0 + var g1 = 0 + var b0 = 0 + var b1 = 0 + var vol = 0 + } + + companion object { + // A histogram of all the input colors is constructed. It has the shape of a + // cube. The cube would be too large if it contained all 16 million colors: + // historical best practice is to use 5 bits of the 8 in each channel, + // reducing the histogram to a volume of ~32,000. + private const val INDEX_BITS = 5 + private const val INDEX_COUNT = 33 // ((1 << INDEX_BITS) + 1) + private const val TOTAL_SIZE = 35937 // INDEX_COUNT * INDEX_COUNT * INDEX_COUNT + fun getIndex(r: Int, g: Int, b: Int): Int { + return (r shl INDEX_BITS * 2) + (r shl INDEX_BITS + 1) + r + (g shl INDEX_BITS) + g + b + } + + fun volume(cube: Box?, moment: IntArray): Int { + return ((((moment[getIndex(cube!!.r1, cube.g1, cube.b1)] + - moment[getIndex(cube.r1, cube.g1, cube.b0)] + - moment[getIndex(cube.r1, cube.g0, cube.b1)]) + + moment[getIndex(cube.r1, cube.g0, cube.b0)] + - moment[getIndex(cube.r0, cube.g1, cube.b1)] + ) + moment[getIndex(cube.r0, cube.g1, cube.b0)] + + moment[getIndex(cube.r0, cube.g0, cube.b1)]) + - moment[getIndex(cube.r0, cube.g0, cube.b0)]) + } + + fun bottom(cube: Box?, direction: Direction, moment: IntArray): Int { + return when (direction) { + Direction.RED -> ((-moment[getIndex(cube!!.r0, cube.g1, cube.b1)] + + moment[getIndex(cube.r0, cube.g1, cube.b0)] + + moment[getIndex(cube.r0, cube.g0, cube.b1)]) + - moment[getIndex(cube.r0, cube.g0, cube.b0)]) + + Direction.GREEN -> ((-moment[getIndex(cube!!.r1, cube.g0, cube.b1)] + + moment[getIndex(cube.r1, cube.g0, cube.b0)] + + moment[getIndex(cube.r0, cube.g0, cube.b1)]) + - moment[getIndex(cube.r0, cube.g0, cube.b0)]) + + Direction.BLUE -> ((-moment[getIndex(cube!!.r1, cube.g1, cube.b0)] + + moment[getIndex(cube.r1, cube.g0, cube.b0)] + + moment[getIndex(cube.r0, cube.g1, cube.b0)]) + - moment[getIndex(cube.r0, cube.g0, cube.b0)]) + } + } + + fun top(cube: Box?, direction: Direction, position: Int, moment: IntArray): Int { + return when (direction) { + Direction.RED -> ((moment[getIndex(position, cube!!.g1, cube.b1)] + - moment[getIndex(position, cube.g1, cube.b0)] + - moment[getIndex(position, cube.g0, cube.b1)]) + + moment[getIndex(position, cube.g0, cube.b0)]) + + Direction.GREEN -> ((moment[getIndex(cube!!.r1, position, cube.b1)] + - moment[getIndex(cube.r1, position, cube.b0)] + - moment[getIndex(cube.r0, position, cube.b1)]) + + moment[getIndex(cube.r0, position, cube.b0)]) + + Direction.BLUE -> ((moment[getIndex(cube!!.r1, cube.g1, position)] + - moment[getIndex(cube.r1, cube.g0, position)] + - moment[getIndex(cube.r0, cube.g1, position)]) + + moment[getIndex(cube.r0, cube.g0, position)]) + } + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/scheme/DynamicScheme.kt b/kotlin/lib/commonMain/kotlin/scheme/DynamicScheme.kt new file mode 100644 index 0000000..37591a5 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/DynamicScheme.kt @@ -0,0 +1,45 @@ +package scheme + +import hct.Hct +import palettes.TonalPalette +import utils.MathUtils.sanitizeDegreesDouble + +/** + * Provides important settings for creating colors dynamically, and 6 color palettes. Requires: 1. A + * color. (source color) 2. A theme. (Variant) 3. Whether or not its dark mode. 4. Contrast level. + * (-1 to 1, currently contrast ratio 3.0 and 7.0) + */ +open class DynamicScheme( + val sourceColorHct: Hct, + val variant: Variant, + val isDark: Boolean, + val contrastLevel: Double, + val primaryPalette: TonalPalette, + val secondaryPalette: TonalPalette, + val tertiaryPalette: TonalPalette, + val neutralPalette: TonalPalette, + val neutralVariantPalette: TonalPalette +) { + val sourceColorArgb: Int = sourceColorHct.toInt() + val errorPalette: TonalPalette = TonalPalette.fromHueAndChroma(25.0, 84.0) + + companion object { + fun getRotatedHue(sourceColorHct: Hct, hues: DoubleArray, rotations: DoubleArray): Double { + val sourceHue = sourceColorHct.hue + if (rotations.size == 1) { + return sanitizeDegreesDouble(sourceHue + rotations[0]) + } + val size = hues.size + for (i in 0..size - 2) { + val thisHue = hues[i] + val nextHue = hues[i + 1] + if (thisHue < sourceHue && sourceHue < nextHue) { + return sanitizeDegreesDouble(sourceHue + rotations[i]) + } + } + // If this statement executes, something is wrong, there should have been a rotation + // found using the arrays. + return sourceHue + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/scheme/Scheme.kt b/kotlin/lib/commonMain/kotlin/scheme/Scheme.kt new file mode 100644 index 0000000..048fd0b --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/Scheme.kt @@ -0,0 +1,525 @@ +package scheme + +import palettes.CorePalette + +/** Represents a Material color scheme, a mapping of color roles to colors. */ +class Scheme { + var primary = 0 + var onPrimary = 0 + var primaryContainer = 0 + var onPrimaryContainer = 0 + var secondary = 0 + var onSecondary = 0 + var secondaryContainer = 0 + var onSecondaryContainer = 0 + var tertiary = 0 + var onTertiary = 0 + var tertiaryContainer = 0 + var onTertiaryContainer = 0 + var error = 0 + var onError = 0 + var errorContainer = 0 + var onErrorContainer = 0 + var background = 0 + var onBackground = 0 + var surface = 0 + var onSurface = 0 + var surfaceVariant = 0 + var onSurfaceVariant = 0 + var outline = 0 + var outlineVariant = 0 + var shadow = 0 + var scrim = 0 + var inverseSurface = 0 + var inverseOnSurface = 0 + var inversePrimary = 0 + + constructor() + constructor( + primary: Int, + onPrimary: Int, + primaryContainer: Int, + onPrimaryContainer: Int, + secondary: Int, + onSecondary: Int, + secondaryContainer: Int, + onSecondaryContainer: Int, + tertiary: Int, + onTertiary: Int, + tertiaryContainer: Int, + onTertiaryContainer: Int, + error: Int, + onError: Int, + errorContainer: Int, + onErrorContainer: Int, + background: Int, + onBackground: Int, + surface: Int, + onSurface: Int, + surfaceVariant: Int, + onSurfaceVariant: Int, + outline: Int, + outlineVariant: Int, + shadow: Int, + scrim: Int, + inverseSurface: Int, + inverseOnSurface: Int, + inversePrimary: Int + ) : super() { + this.primary = primary + this.onPrimary = onPrimary + this.primaryContainer = primaryContainer + this.onPrimaryContainer = onPrimaryContainer + this.secondary = secondary + this.onSecondary = onSecondary + this.secondaryContainer = secondaryContainer + this.onSecondaryContainer = onSecondaryContainer + this.tertiary = tertiary + this.onTertiary = onTertiary + this.tertiaryContainer = tertiaryContainer + this.onTertiaryContainer = onTertiaryContainer + this.error = error + this.onError = onError + this.errorContainer = errorContainer + this.onErrorContainer = onErrorContainer + this.background = background + this.onBackground = onBackground + this.surface = surface + this.onSurface = onSurface + this.surfaceVariant = surfaceVariant + this.onSurfaceVariant = onSurfaceVariant + this.outline = outline + this.outlineVariant = outlineVariant + this.shadow = shadow + this.scrim = scrim + this.inverseSurface = inverseSurface + this.inverseOnSurface = inverseOnSurface + this.inversePrimary = inversePrimary + } + + fun withPrimary(primary: Int): Scheme { + this.primary = primary + return this + } + + fun withOnPrimary(onPrimary: Int): Scheme { + this.onPrimary = onPrimary + return this + } + + fun withPrimaryContainer(primaryContainer: Int): Scheme { + this.primaryContainer = primaryContainer + return this + } + + fun withOnPrimaryContainer(onPrimaryContainer: Int): Scheme { + this.onPrimaryContainer = onPrimaryContainer + return this + } + + fun withSecondary(secondary: Int): Scheme { + this.secondary = secondary + return this + } + + fun withOnSecondary(onSecondary: Int): Scheme { + this.onSecondary = onSecondary + return this + } + + fun withSecondaryContainer(secondaryContainer: Int): Scheme { + this.secondaryContainer = secondaryContainer + return this + } + + fun withOnSecondaryContainer(onSecondaryContainer: Int): Scheme { + this.onSecondaryContainer = onSecondaryContainer + return this + } + + fun withTertiary(tertiary: Int): Scheme { + this.tertiary = tertiary + return this + } + + fun withOnTertiary(onTertiary: Int): Scheme { + this.onTertiary = onTertiary + return this + } + + fun withTertiaryContainer(tertiaryContainer: Int): Scheme { + this.tertiaryContainer = tertiaryContainer + return this + } + + fun withOnTertiaryContainer(onTertiaryContainer: Int): Scheme { + this.onTertiaryContainer = onTertiaryContainer + return this + } + + fun withError(error: Int): Scheme { + this.error = error + return this + } + + fun withOnError(onError: Int): Scheme { + this.onError = onError + return this + } + + fun withErrorContainer(errorContainer: Int): Scheme { + this.errorContainer = errorContainer + return this + } + + fun withOnErrorContainer(onErrorContainer: Int): Scheme { + this.onErrorContainer = onErrorContainer + return this + } + + fun withBackground(background: Int): Scheme { + this.background = background + return this + } + + fun withOnBackground(onBackground: Int): Scheme { + this.onBackground = onBackground + return this + } + + fun withSurface(surface: Int): Scheme { + this.surface = surface + return this + } + + fun withOnSurface(onSurface: Int): Scheme { + this.onSurface = onSurface + return this + } + + fun withSurfaceVariant(surfaceVariant: Int): Scheme { + this.surfaceVariant = surfaceVariant + return this + } + + fun withOnSurfaceVariant(onSurfaceVariant: Int): Scheme { + this.onSurfaceVariant = onSurfaceVariant + return this + } + + fun withOutline(outline: Int): Scheme { + this.outline = outline + return this + } + + fun withOutlineVariant(outlineVariant: Int): Scheme { + this.outlineVariant = outlineVariant + return this + } + + fun withShadow(shadow: Int): Scheme { + this.shadow = shadow + return this + } + + fun withScrim(scrim: Int): Scheme { + this.scrim = scrim + return this + } + + fun withInverseSurface(inverseSurface: Int): Scheme { + this.inverseSurface = inverseSurface + return this + } + + fun withInverseOnSurface(inverseOnSurface: Int): Scheme { + this.inverseOnSurface = inverseOnSurface + return this + } + + fun withInversePrimary(inversePrimary: Int): Scheme { + this.inversePrimary = inversePrimary + return this + } + + override fun toString(): String { + return ("Scheme{" + + "primary=" + + primary + + ", onPrimary=" + + onPrimary + + ", primaryContainer=" + + primaryContainer + + ", onPrimaryContainer=" + + onPrimaryContainer + + ", secondary=" + + secondary + + ", onSecondary=" + + onSecondary + + ", secondaryContainer=" + + secondaryContainer + + ", onSecondaryContainer=" + + onSecondaryContainer + + ", tertiary=" + + tertiary + + ", onTertiary=" + + onTertiary + + ", tertiaryContainer=" + + tertiaryContainer + + ", onTertiaryContainer=" + + onTertiaryContainer + + ", error=" + + error + + ", onError=" + + onError + + ", errorContainer=" + + errorContainer + + ", onErrorContainer=" + + onErrorContainer + + ", background=" + + background + + ", onBackground=" + + onBackground + + ", surface=" + + surface + + ", onSurface=" + + onSurface + + ", surfaceVariant=" + + surfaceVariant + + ", onSurfaceVariant=" + + onSurfaceVariant + + ", outline=" + + outline + + ", outlineVariant=" + + outlineVariant + + ", shadow=" + + shadow + + ", scrim=" + + scrim + + ", inverseSurface=" + + inverseSurface + + ", inverseOnSurface=" + + inverseOnSurface + + ", inversePrimary=" + + inversePrimary + + '}') + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is Scheme) { + return false + } + if (!super.equals(other)) { + return false + } + val scheme = other + if (primary != scheme.primary) { + return false + } + if (onPrimary != scheme.onPrimary) { + return false + } + if (primaryContainer != scheme.primaryContainer) { + return false + } + if (onPrimaryContainer != scheme.onPrimaryContainer) { + return false + } + if (secondary != scheme.secondary) { + return false + } + if (onSecondary != scheme.onSecondary) { + return false + } + if (secondaryContainer != scheme.secondaryContainer) { + return false + } + if (onSecondaryContainer != scheme.onSecondaryContainer) { + return false + } + if (tertiary != scheme.tertiary) { + return false + } + if (onTertiary != scheme.onTertiary) { + return false + } + if (tertiaryContainer != scheme.tertiaryContainer) { + return false + } + if (onTertiaryContainer != scheme.onTertiaryContainer) { + return false + } + if (error != scheme.error) { + return false + } + if (onError != scheme.onError) { + return false + } + if (errorContainer != scheme.errorContainer) { + return false + } + if (onErrorContainer != scheme.onErrorContainer) { + return false + } + if (background != scheme.background) { + return false + } + if (onBackground != scheme.onBackground) { + return false + } + if (surface != scheme.surface) { + return false + } + if (onSurface != scheme.onSurface) { + return false + } + if (surfaceVariant != scheme.surfaceVariant) { + return false + } + if (onSurfaceVariant != scheme.onSurfaceVariant) { + return false + } + if (outline != scheme.outline) { + return false + } + if (outlineVariant != scheme.outlineVariant) { + return false + } + if (shadow != scheme.shadow) { + return false + } + if (scrim != scheme.scrim) { + return false + } + if (inverseSurface != scheme.inverseSurface) { + return false + } + if (inverseOnSurface != scheme.inverseOnSurface) { + return false + } + return if (inversePrimary != scheme.inversePrimary) { + false + } else true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + primary + result = 31 * result + onPrimary + result = 31 * result + primaryContainer + result = 31 * result + onPrimaryContainer + result = 31 * result + secondary + result = 31 * result + onSecondary + result = 31 * result + secondaryContainer + result = 31 * result + onSecondaryContainer + result = 31 * result + tertiary + result = 31 * result + onTertiary + result = 31 * result + tertiaryContainer + result = 31 * result + onTertiaryContainer + result = 31 * result + error + result = 31 * result + onError + result = 31 * result + errorContainer + result = 31 * result + onErrorContainer + result = 31 * result + background + result = 31 * result + onBackground + result = 31 * result + surface + result = 31 * result + onSurface + result = 31 * result + surfaceVariant + result = 31 * result + onSurfaceVariant + result = 31 * result + outline + result = 31 * result + outlineVariant + result = 31 * result + shadow + result = 31 * result + scrim + result = 31 * result + inverseSurface + result = 31 * result + inverseOnSurface + result = 31 * result + inversePrimary + return result + } + + companion object { + fun light(argb: Int): Scheme { + return lightFromCorePalette(CorePalette.of(argb)) + } + + fun dark(argb: Int): Scheme { + return darkFromCorePalette(CorePalette.of(argb)) + } + + fun lightContent(argb: Int): Scheme { + return lightFromCorePalette(CorePalette.contentOf(argb)) + } + + fun darkContent(argb: Int): Scheme { + return darkFromCorePalette(CorePalette.contentOf(argb)) + } + + private fun lightFromCorePalette(core: CorePalette): Scheme { + return Scheme() + .withPrimary(core.a1.tone(40)) + .withOnPrimary(core.a1.tone(100)) + .withPrimaryContainer(core.a1.tone(90)) + .withOnPrimaryContainer(core.a1.tone(10)) + .withSecondary(core.a2.tone(40)) + .withOnSecondary(core.a2.tone(100)) + .withSecondaryContainer(core.a2.tone(90)) + .withOnSecondaryContainer(core.a2.tone(10)) + .withTertiary(core.a3.tone(40)) + .withOnTertiary(core.a3.tone(100)) + .withTertiaryContainer(core.a3.tone(90)) + .withOnTertiaryContainer(core.a3.tone(10)) + .withError(core.error.tone(40)) + .withOnError(core.error.tone(100)) + .withErrorContainer(core.error.tone(90)) + .withOnErrorContainer(core.error.tone(10)) + .withBackground(core.n1.tone(99)) + .withOnBackground(core.n1.tone(10)) + .withSurface(core.n1.tone(99)) + .withOnSurface(core.n1.tone(10)) + .withSurfaceVariant(core.n2.tone(90)) + .withOnSurfaceVariant(core.n2.tone(30)) + .withOutline(core.n2.tone(50)) + .withOutlineVariant(core.n2.tone(80)) + .withShadow(core.n1.tone(0)) + .withScrim(core.n1.tone(0)) + .withInverseSurface(core.n1.tone(20)) + .withInverseOnSurface(core.n1.tone(95)) + .withInversePrimary(core.a1.tone(80)) + } + + private fun darkFromCorePalette(core: CorePalette): Scheme { + return Scheme() + .withPrimary(core.a1.tone(80)) + .withOnPrimary(core.a1.tone(20)) + .withPrimaryContainer(core.a1.tone(30)) + .withOnPrimaryContainer(core.a1.tone(90)) + .withSecondary(core.a2.tone(80)) + .withOnSecondary(core.a2.tone(20)) + .withSecondaryContainer(core.a2.tone(30)) + .withOnSecondaryContainer(core.a2.tone(90)) + .withTertiary(core.a3.tone(80)) + .withOnTertiary(core.a3.tone(20)) + .withTertiaryContainer(core.a3.tone(30)) + .withOnTertiaryContainer(core.a3.tone(90)) + .withError(core.error.tone(80)) + .withOnError(core.error.tone(20)) + .withErrorContainer(core.error.tone(30)) + .withOnErrorContainer(core.error.tone(80)) + .withBackground(core.n1.tone(10)) + .withOnBackground(core.n1.tone(90)) + .withSurface(core.n1.tone(10)) + .withOnSurface(core.n1.tone(90)) + .withSurfaceVariant(core.n2.tone(30)) + .withOnSurfaceVariant(core.n2.tone(80)) + .withOutline(core.n2.tone(60)) + .withOutlineVariant(core.n2.tone(30)) + .withShadow(core.n1.tone(0)) + .withScrim(core.n1.tone(0)) + .withInverseSurface(core.n1.tone(90)) + .withInverseOnSurface(core.n1.tone(20)) + .withInversePrimary(core.a1.tone(40)) + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/scheme/SchemeContent.kt b/kotlin/lib/commonMain/kotlin/scheme/SchemeContent.kt new file mode 100644 index 0000000..12dd27b --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/SchemeContent.kt @@ -0,0 +1,42 @@ +package scheme + +import dislike.DislikeAnalyzer.fixIfDisliked +import hct.Hct +import palettes.TonalPalette +import temperature.TemperatureCache +import kotlin.math.max + +/** + * A scheme that places the source color in Scheme.primaryContainer. + * + *

Primary Container is the source color, adjusted for color relativity. It maintains constant + * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in + * dark mode. + * + *

Tertiary Container is an analogous color, specifically, the analog of a color wheel divided + * into 6, and the precise analog is the one found by increasing hue. This is a scientifically + * grounded equivalent to rotating hue clockwise by 60 degrees. It also maintains constant + * appearance. + */ +class SchemeContent(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : + DynamicScheme( + sourceColorHct, + Variant.CONTENT, + isDark, + contrastLevel, + TonalPalette.fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma), + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + max(sourceColorHct.chroma - 32.0, sourceColorHct.chroma * 0.5) + ), + TonalPalette.fromHct( + fixIfDisliked( + TemperatureCache(sourceColorHct) + .getAnalogousColors( /* count= */3, /* divisions= */6)[2] + ) + ), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma / 8.0), + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, sourceColorHct.chroma / 8.0 + 4.0 + ) + ) diff --git a/kotlin/lib/commonMain/kotlin/scheme/SchemeExpressive.kt b/kotlin/lib/commonMain/kotlin/scheme/SchemeExpressive.kt new file mode 100644 index 0000000..7f403cb --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/SchemeExpressive.kt @@ -0,0 +1,32 @@ +package scheme + +import hct.Hct +import palettes.TonalPalette +import utils.MathUtils.sanitizeDegreesDouble + +/** A playful theme - the source color's hue does not appear in the theme. */ +class SchemeExpressive(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : + DynamicScheme( + sourceColorHct, + Variant.EXPRESSIVE, + isDark, + contrastLevel, + TonalPalette.fromHueAndChroma( + sanitizeDegreesDouble(sourceColorHct.hue + 120.0), 40.0 + ), + TonalPalette.fromHueAndChroma( + getRotatedHue(sourceColorHct, HUES, SECONDARY_ROTATIONS), 24.0 + ), + TonalPalette.fromHueAndChroma( + getRotatedHue(sourceColorHct, HUES, TERTIARY_ROTATIONS), 32.0 + ), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 8.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 12.0) + ) { + companion object { + // NOMUTANTS--arbitrary increments/decrements, correctly, still passes tests. + private val HUES = doubleArrayOf(0.0, 21.0, 51.0, 121.0, 151.0, 191.0, 271.0, 321.0, 360.0) + private val SECONDARY_ROTATIONS = doubleArrayOf(45.0, 95.0, 45.0, 20.0, 45.0, 90.0, 45.0, 45.0, 45.0) + private val TERTIARY_ROTATIONS = doubleArrayOf(120.0, 120.0, 20.0, 45.0, 20.0, 15.0, 20.0, 120.0, 120.0) + } +} diff --git a/kotlin/lib/commonMain/kotlin/scheme/SchemeFidelity.kt b/kotlin/lib/commonMain/kotlin/scheme/SchemeFidelity.kt new file mode 100644 index 0000000..b6accf4 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/SchemeFidelity.kt @@ -0,0 +1,36 @@ +package scheme + +import dislike.DislikeAnalyzer.fixIfDisliked +import hct.Hct +import palettes.TonalPalette +import temperature.TemperatureCache + +/** + * A scheme that places the source color in Scheme.primaryContainer. + * + *

Primary Container is the source color, adjusted for color relativity. It maintains constant + * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in + * dark mode. + * + *

Tertiary Container is the complement to the source color, using TemperatureCache. It also + * maintains constant appearance. + */ +class SchemeFidelity(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : + DynamicScheme( + sourceColorHct, + Variant.FIDELITY, + isDark, + contrastLevel, + TonalPalette.fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma), + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + (sourceColorHct.chroma - 32.0).coerceAtLeast(sourceColorHct.chroma * 0.5) + ), + TonalPalette.fromHct( + fixIfDisliked(TemperatureCache(sourceColorHct).complement!!) + ), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma / 8.0), + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, sourceColorHct.chroma / 8.0 + 4.0 + ) + ) diff --git a/kotlin/lib/commonMain/kotlin/scheme/SchemeMonochrome.kt b/kotlin/lib/commonMain/kotlin/scheme/SchemeMonochrome.kt new file mode 100644 index 0000000..f449f8c --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/SchemeMonochrome.kt @@ -0,0 +1,18 @@ +package scheme + +import hct.Hct +import palettes.TonalPalette + +/** A monochrome theme, colors are purely black / white / gray. */ +class SchemeMonochrome(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : + DynamicScheme( + sourceColorHct, + Variant.MONOCHROME, + isDark, + contrastLevel, + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + ) diff --git a/kotlin/lib/commonMain/kotlin/scheme/SchemeNeutral.kt b/kotlin/lib/commonMain/kotlin/scheme/SchemeNeutral.kt new file mode 100644 index 0000000..dfe783a --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/SchemeNeutral.kt @@ -0,0 +1,18 @@ +package scheme + +import hct.Hct +import palettes.TonalPalette + +/** A theme that's slightly more chromatic than monochrome, which is purely black / white / gray. */ +class SchemeNeutral(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : + DynamicScheme( + sourceColorHct, + Variant.NEUTRAL, + isDark, + contrastLevel, + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 12.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 8.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 16.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 2.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 2.0) + ) diff --git a/kotlin/lib/commonMain/kotlin/scheme/SchemeTonalSpot.kt b/kotlin/lib/commonMain/kotlin/scheme/SchemeTonalSpot.kt new file mode 100644 index 0000000..303b7ce --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/SchemeTonalSpot.kt @@ -0,0 +1,20 @@ +package scheme + +import hct.Hct +import palettes.TonalPalette +import utils.MathUtils.sanitizeDegreesDouble + +/** A calm theme, sedated colors that aren't particularly chromatic. */ +class SchemeTonalSpot(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( + sourceColorHct, + Variant.TONAL_SPOT, + isDark, + contrastLevel, + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 40.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 16.0), + TonalPalette.fromHueAndChroma( + sanitizeDegreesDouble(sourceColorHct.hue + 60.0), 24.0 + ), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 6.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 8.0) +) diff --git a/kotlin/lib/commonMain/kotlin/scheme/SchemeVibrant.kt b/kotlin/lib/commonMain/kotlin/scheme/SchemeVibrant.kt new file mode 100644 index 0000000..7f33718 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/SchemeVibrant.kt @@ -0,0 +1,28 @@ +package scheme + +import hct.Hct +import palettes.TonalPalette + +/** A loud theme, colorfulness is maximum for Primary palette, increased for others. */ +class SchemeVibrant(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : + DynamicScheme( + sourceColorHct, + Variant.VIBRANT, + isDark, + contrastLevel, + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 200.0), + TonalPalette.fromHueAndChroma( + getRotatedHue(sourceColorHct, HUES, SECONDARY_ROTATIONS), 24.0 + ), + TonalPalette.fromHueAndChroma( + getRotatedHue(sourceColorHct, HUES, TERTIARY_ROTATIONS), 32.0 + ), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 8.0), + TonalPalette.fromHueAndChroma(sourceColorHct.hue, 12.0) + ) { + companion object { + private val HUES = doubleArrayOf(0.0, 41.0, 61.0, 101.0, 131.0, 181.0, 251.0, 301.0, 360.0) + private val SECONDARY_ROTATIONS = doubleArrayOf(18.0, 15.0, 10.0, 12.0, 15.0, 18.0, 15.0, 12.0, 12.0) + private val TERTIARY_ROTATIONS = doubleArrayOf(35.0, 30.0, 20.0, 25.0, 30.0, 35.0, 30.0, 25.0, 25.0) + } +} diff --git a/kotlin/lib/commonMain/kotlin/scheme/Variant.kt b/kotlin/lib/commonMain/kotlin/scheme/Variant.kt new file mode 100644 index 0000000..55861c1 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/scheme/Variant.kt @@ -0,0 +1,12 @@ +package scheme + +/** Themes for Dynamic Color. */ +enum class Variant { + MONOCHROME, + NEUTRAL, + TONAL_SPOT, + VIBRANT, + EXPRESSIVE, + FIDELITY, + CONTENT +} diff --git a/kotlin/lib/commonMain/kotlin/score/Score.kt b/kotlin/lib/commonMain/kotlin/score/Score.kt new file mode 100644 index 0000000..c821e99 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/score/Score.kt @@ -0,0 +1,134 @@ +package score + +import hct.Cam16 +import utils.ColorUtils.lstarFromArgb +import utils.MathUtils.differenceDegrees +import utils.MathUtils.sanitizeDegreesInt +import kotlin.math.roundToInt + +/** + * Given a large set of colors, remove colors that are unsuitable for a UI theme, and rank the rest + * based on suitability. + * + *

Enables use of a high cluster count for image quantization, thus ensuring colors aren't + * muddied, while curating the high cluster count to a much smaller number of appropriate choices. + */ +object Score { + private const val CUTOFF_CHROMA = 15.0 + private const val CUTOFF_EXCITED_PROPORTION = 0.01 + private const val CUTOFF_TONE = 10.0 + private const val TARGET_CHROMA = 48.0 + private const val WEIGHT_PROPORTION = 0.7 + private const val WEIGHT_CHROMA_ABOVE = 0.3 + private const val WEIGHT_CHROMA_BELOW = 0.1 + + /** + * Given a map with keys of colors and values of how often the color appears, rank the colors + * based on suitability for being used for a UI theme. + * + * @param colorsToPopulation map with keys of colors and values of how often the color appears, + * usually from a source image. + * @return Colors sorted by suitability for a UI theme. The most suitable color is the first item, + * the least suitable is the last. There will always be at least one color returned. If all + * the input colors were not suitable for a theme, a default fallback color will be provided, + * Google Blue. + */ + fun score(colorsToPopulation: Map): List { + // Determine the total count of all colors. + var populationSum = 0.0 + for ((_, value) in colorsToPopulation) { + populationSum += value.toDouble() + } + + // Turn the count of each color into a proportion by dividing by the total + // count. Also, fill a cache of CAM16 colors representing each color, and + // record the proportion of colors for each CAM16 hue. + val colorsToCam: MutableMap = HashMap() + val hueProportions = DoubleArray(361) + for ((color, value) in colorsToPopulation) { + val population = value.toDouble() + val proportion = population / populationSum + val cam = Cam16.fromInt(color) + colorsToCam[color] = cam + val hue = cam.hue.roundToInt() + hueProportions[hue] += proportion + } + + // Determine the proportion of the colors around each color, by summing the + // proportions around each color's hue. + val colorsToExcitedProportion: MutableMap = HashMap() + for ((color, cam) in colorsToCam) { + val hue = cam.hue.roundToInt() + var excitedProportion = 0.0 + for (j in hue - 15 until hue + 15) { + val neighborHue = sanitizeDegreesInt(j) + excitedProportion += hueProportions[neighborHue] + } + colorsToExcitedProportion[color] = excitedProportion + } + + // Score the colors by their proportion, as well as how chromatic they are. + val colorsToScore: MutableMap = HashMap() + for ((color, cam) in colorsToCam) { + val proportion = colorsToExcitedProportion[color]!! + val proportionScore = proportion * 100.0 * WEIGHT_PROPORTION + val chromaWeight = if (cam.chroma < TARGET_CHROMA) WEIGHT_CHROMA_BELOW else WEIGHT_CHROMA_ABOVE + val chromaScore = (cam.chroma - TARGET_CHROMA) * chromaWeight + val score = proportionScore + chromaScore + colorsToScore[color] = score + } + + // Remove colors that are unsuitable, ex. very dark or unchromatic colors. + // Also, remove colors that are very similar in hue. + val filteredColors = filter(colorsToExcitedProportion, colorsToCam) + val filteredColorsToScore: MutableMap = HashMap() + for (color in filteredColors) { + filteredColorsToScore[color] = colorsToScore[color] + } + + // Ensure the list of colors returned is sorted such that the first in the + // list is the most suitable, and the last is the least suitable. + val entryList: List> = ArrayList>(filteredColorsToScore.entries).sortedWith(ScoredComparator()) + val colorsByScoreDescending: MutableList = ArrayList() + for ((color) in entryList) { + val cam = colorsToCam[color] + var duplicateHue = false + for (alreadyChosenColor in colorsByScoreDescending) { + val alreadyChosenCam = colorsToCam[alreadyChosenColor] + if (differenceDegrees(cam!!.hue, alreadyChosenCam!!.hue) < 15) { + duplicateHue = true + break + } + } + if (duplicateHue) { + continue + } + colorsByScoreDescending.add(color) + } + + // Ensure that at least one color is returned. + if (colorsByScoreDescending.isEmpty()) { + colorsByScoreDescending.add(-0xbd7a0c) // Google Blue + } + return colorsByScoreDescending + } + + private fun filter( + colorsToExcitedProportion: Map, colorsToCam: Map + ): List { + val filtered: MutableList = ArrayList() + for ((color, cam) in colorsToCam) { + val proportion = colorsToExcitedProportion[color]!! + if (cam.chroma >= CUTOFF_CHROMA && lstarFromArgb(color) >= CUTOFF_TONE && proportion >= CUTOFF_EXCITED_PROPORTION) { + filtered.add(color) + } + } + return filtered + } + + internal class ScoredComparator : Comparator?> { + override fun compare(entry1: Map.Entry?, entry2: Map.Entry?): Int { + return -entry1?.value?.compareTo(entry2?.value!!)!! + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/temperature/TemperaturCache.kt b/kotlin/lib/commonMain/kotlin/temperature/TemperaturCache.kt new file mode 100644 index 0000000..781d61f --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/temperature/TemperaturCache.kt @@ -0,0 +1,294 @@ +package temperature + +import hct.Hct +import utils.ColorUtils.labFromArgb +import utils.MathUtils +import utils.MathUtils.sanitizeDegreesDouble +import utils.MathUtils.sanitizeDegreesInt +import kotlin.collections.ArrayList +import kotlin.collections.HashMap +import kotlin.math.* + +/** + * Design utilities using color temperature theory. + * + *

Analogous colors, complementary color, and cache to efficiently, lazily, generate data for + * calculations when needed. + */ +class TemperatureCache(private val input: Hct) { + private var precomputedComplement: Hct? = null + private var precomputedHctsByTemp: List? = null + private var precomputedHctsByHue: List? = null + private var precomputedTempsByHct: Map? = null + + val complement: Hct? + /** + * A color that complements the input color aesthetically. + * + * + * In art, this is usually described as being across the color wheel. History of this shows + * intent as a color that is just as cool-warm as the input color is warm-cool. + */ + get() { + if (precomputedComplement != null) { + return precomputedComplement + } + val coldestHue = coldest.hue + val coldestTemp = tempsByHct!![coldest]!! + val warmestHue = warmest.hue + val warmestTemp = tempsByHct!![warmest]!! + val range = warmestTemp - coldestTemp + val startHueIsColdestToWarmest = isBetween(input.hue, coldestHue, warmestHue) + val startHue = if (startHueIsColdestToWarmest) warmestHue else coldestHue + val endHue = if (startHueIsColdestToWarmest) coldestHue else warmestHue + val directionOfRotation = 1.0 + var smallestError = 1000.0 + var answer: Hct? = hctsByHue!![input.hue.roundToInt()] + val complementRelativeTemp = 1.0 - getRelativeTemperature(input) + // Find the color in the other section, closest to the inverse percentile + // of the input color. This is the complement. + var hueAddend = 0.0 + while (hueAddend <= 360.0) { + val hue = sanitizeDegreesDouble( + startHue + directionOfRotation * hueAddend + ) + if (!isBetween(hue, startHue, endHue)) { + hueAddend += 1.0 + continue + } + val possibleAnswer = hctsByHue!![hue.roundToInt()] + val relativeTemp = (tempsByHct!![possibleAnswer]!! - coldestTemp) / range + val error = abs(complementRelativeTemp - relativeTemp) + if (error < smallestError) { + smallestError = error + answer = possibleAnswer + } + hueAddend += 1.0 + } + precomputedComplement = answer + return precomputedComplement + } + val analogousColors: List + /** + * 5 colors that pair well with the input color. + * + * + * The colors are equidistant in temperature and adjacent in hue. + */ + get() = getAnalogousColors(5, 12) + + /** + * A set of colors with differing hues, equidistant in temperature. + * + * + * In art, this is usually described as a set of 5 colors on a color wheel divided into 12 + * sections. This method allows provision of either of those values. + * + * + * Behavior is undefined when count or divisions is 0. When divisions < count, colors repeat. + * + * @param count The number of colors to return, includes the input color. + * @param divisions The number of divisions on the color wheel. + */ + fun getAnalogousColors(count: Int, divisions: Int): List { + // The starting hue is the hue of the input color. + val startHue = input.hue.roundToInt().toInt() + val startHct = hctsByHue!![startHue] + var lastTemp = getRelativeTemperature(startHct) + val allColors: MutableList = ArrayList() + allColors.add(startHct) + var absoluteTotalTempDelta = 0.0 + for (i in 0..359) { + val hue = sanitizeDegreesInt(startHue + i) + val hct = hctsByHue!![hue] + val temp = getRelativeTemperature(hct) + val tempDelta = abs(temp - lastTemp) + lastTemp = temp + absoluteTotalTempDelta += tempDelta + } + var hueAddend = 1 + val tempStep = absoluteTotalTempDelta / divisions.toDouble() + var totalTempDelta = 0.0 + lastTemp = getRelativeTemperature(startHct) + while (allColors.size < divisions) { + val hue = sanitizeDegreesInt(startHue + hueAddend) + val hct = hctsByHue!![hue] + val temp = getRelativeTemperature(hct) + val tempDelta = abs(temp - lastTemp) + totalTempDelta += tempDelta + var desiredTotalTempDeltaForIndex = allColors.size * tempStep + var indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex + var indexAddend = 1 + // Keep adding this hue to the answers until its temperature is + // insufficient. This ensures consistent behavior when there aren't + // `divisions` discrete steps between 0 and 360 in hue with `tempStep` + // delta in temperature between them. + // + // For example, white and black have no analogues: there are no other + // colors at T100/T0. Therefore, they should just be added to the array + // as answers. + while (indexSatisfied && allColors.size < divisions) { + allColors.add(hct) + desiredTotalTempDeltaForIndex = (allColors.size + indexAddend) * tempStep + indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex + indexAddend++ + } + lastTemp = temp + hueAddend++ + if (hueAddend > 360) { + while (allColors.size < divisions) { + allColors.add(hct) + } + break + } + } + val answers: MutableList = ArrayList() + answers.add(input) + val ccwCount = floor((count.toDouble() - 1.0) / 2.0).toInt() + for (i in 1 until ccwCount + 1) { + var index = 0 - i + while (index < 0) { + index += allColors.size + } + if (index >= allColors.size) { + index %= allColors.size + } + answers.add(0, allColors[index]) + } + val cwCount = count - ccwCount - 1 + for (i in 1 until cwCount + 1) { + var index = i + while (index < 0) { + index += allColors.size + } + if (index >= allColors.size) { + index %= allColors.size + } + answers.add(allColors[index]) + } + return answers + } + + /** + * Temperature relative to all colors with the same chroma and tone. + * + * @param hct HCT to find the relative temperature of. + * @return Value on a scale from 0 to 1. + */ + fun getRelativeTemperature(hct: Hct): Double { + val range = tempsByHct!![warmest]!! - tempsByHct!![coldest]!! + val differenceFromColdest = tempsByHct!![hct]!! - tempsByHct!![coldest]!! + // Handle when there's no difference in temperature between warmest and + // coldest: for example, at T100, only one color is available, white. + return if (range == 0.0) { + 0.5 + } else differenceFromColdest / range + } + + private val coldest: Hct + /** Coldest color with same chroma and tone as input. */ + private get() = hctsByTemp!![0] + private val hctsByHue: List? + /** + * HCTs for all colors with the same chroma/tone as the input. + * + * + * Sorted by hue, ex. index 0 is hue 0. + */ + private get() { + if (precomputedHctsByHue != null) { + return precomputedHctsByHue + } + val hcts: MutableList = ArrayList() + var hue = 0.0 + while (hue <= 360.0) { + val colorAtHue = Hct.from(hue, input.chroma, input.tone) + hcts.add(colorAtHue) + hue += 1.0 + } + precomputedHctsByHue = hcts.toList() + return precomputedHctsByHue + } + private val hctsByTemp: List? + /** + * HCTs for all colors with the same chroma/tone as the input. + * + * + * Sorted from coldest first to warmest last. + */ + private get() { + if (precomputedHctsByTemp != null) { + return precomputedHctsByTemp + } + val hcts: MutableList = ArrayList( + hctsByHue ?: emptyList() + ) + hcts.add(input) + hcts.sortBy { arg -> + tempsByHct!![arg] + } + precomputedHctsByTemp = hcts + return precomputedHctsByTemp + } + private val tempsByHct: Map? + /** Keys of HCTs in getHctsByTemp, values of raw temperature. */ + private get() { + if (precomputedTempsByHct != null) { + return precomputedTempsByHct + } + val allHcts: MutableList = ArrayList( + hctsByHue ?: emptyList() + ) + allHcts.add(input) + val temperaturesByHct: MutableMap = HashMap() + for (hct in allHcts) { + temperaturesByHct[hct] = rawTemperature(hct) + } + precomputedTempsByHct = temperaturesByHct + return precomputedTempsByHct + } + private val warmest: Hct + /** Warmest color with same chroma and tone as input. */ + private get() = hctsByTemp!![hctsByTemp!!.size - 1] + + companion object { + /** + * Value representing cool-warm factor of a color. Values below 0 are considered cool, above, + * warm. + * + * + * Color science has researched emotion and harmony, which art uses to select colors. Warm-cool + * is the foundation of analogous and complementary colors. See: - Li-Chen Ou's Chapter 19 in + * Handbook of Color Psychology (2015). - Josef Albers' Interaction of Color chapters 19 and 21. + * + * + * Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space. + * Return value has these properties:

+ * - Values below 0 are cool, above 0 are warm.

+ * - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130.

+ * - Upper bound: 8.61. Chroma is infinite. Assuming max of Lab chroma 130. + */ + fun rawTemperature(color: Hct): Double { + val lab = labFromArgb(color.toInt()) + val hue = sanitizeDegreesDouble( + MathUtils.toDegrees( + atan2( + lab[2], lab[1] + ) + ) + ) + val chroma = hypot(lab[1], lab[2]) + return (-0.5 + + (0.02 + * chroma.pow(1.07) + * cos(MathUtils.toRadians(sanitizeDegreesDouble(hue - 50.0))))) + } + + /** Determines if an angle is between two other angles, rotating clockwise. */ + private fun isBetween(angle: Double, a: Double, b: Double): Boolean { + return if (a < b) { + angle in a..b + } else a <= angle || angle <= b + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/theme/Theme.kt b/kotlin/lib/commonMain/kotlin/theme/Theme.kt new file mode 100644 index 0000000..3ab27ff --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/theme/Theme.kt @@ -0,0 +1,56 @@ +package theme + +import palettes.TonalPalette +import scheme.Scheme + +data class Theme( + val source: Int, + val schemes: Schemes, + val palettes: Palettes, + val customColors: Collection = emptyList() +) { + constructor( + source: Int, + schemes: Schemes, + palettes: Palettes, + customColors: Array + ) : this(source, schemes, palettes, customColors.toList()) + + fun custom(name: String): CustomColorGroup { + return customColors.firstOrNull { it.color.name == name } ?: customColors.first { it.color.name.equals(name, true) } + } +} + +data class Schemes( + val light: Scheme, + val dark: Scheme +) + +data class Palettes( + val primary: TonalPalette, + val secondary: TonalPalette, + val tertiary: TonalPalette, + val neutral: TonalPalette, + val neutralVariant: TonalPalette, + val error: TonalPalette, +) + +data class CustomColor( + val value: Int, + val name: String, + val blend: Boolean +) + +data class ColorGroup( + val color: Int, + val onColor: Int, + val colorContainer: Int, + val onColorContainer: Int +) + +data class CustomColorGroup( + val color: CustomColor, + val value: Int, + val light: ColorGroup, + val dark: ColorGroup +) diff --git a/kotlin/lib/commonMain/kotlin/utils/ColorUtils.kt b/kotlin/lib/commonMain/kotlin/utils/ColorUtils.kt new file mode 100644 index 0000000..21a5589 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/utils/ColorUtils.kt @@ -0,0 +1,247 @@ +package utils + +import kotlin.math.pow +import kotlin.math.roundToInt + +/** + * Color science utilities. + * + *

Utility methods for color science constants and color space conversions that aren't HCT or + * CAM16. + */ +object ColorUtils { + val SRGB_TO_XYZ = arrayOf( + doubleArrayOf(0.41233895, 0.35762064, 0.18051042), + doubleArrayOf(0.2126, 0.7152, 0.0722), + doubleArrayOf(0.01932141, 0.11916382, 0.95034478) + ) + val XYZ_TO_SRGB = arrayOf( + doubleArrayOf( + 3.2413774792388685, -1.5376652402851851, -0.49885366846268053 + ), doubleArrayOf( + -0.9691452513005321, 1.8758853451067872, 0.04156585616912061 + ), doubleArrayOf( + 0.05562093689691305, -0.20395524564742123, 1.0571799111220335 + ) + ) + val WHITE_POINT_D65 = doubleArrayOf(95.047, 100.0, 108.883) + + /** Converts a color from RGB components to ARGB format. */ + fun argbFromRgb(red: Int, green: Int, blue: Int): Int { + return 255 shl 24 or (red and 255 shl 16) or (green and 255 shl 8) or (blue and 255) + } + + /** Converts a color from linear RGB components to ARGB format. */ + fun argbFromLinrgb(linrgb: DoubleArray): Int { + val r = delinearized(linrgb[0]) + val g = delinearized(linrgb[1]) + val b = delinearized(linrgb[2]) + return argbFromRgb(r, g, b) + } + + /** Returns the alpha component of a color in ARGB format. */ + fun alphaFromArgb(argb: Int): Int { + return argb shr 24 and 255 + } + + /** Returns the red component of a color in ARGB format. */ + fun redFromArgb(argb: Int): Int { + return argb shr 16 and 255 + } + + /** Returns the green component of a color in ARGB format. */ + fun greenFromArgb(argb: Int): Int { + return argb shr 8 and 255 + } + + /** Returns the blue component of a color in ARGB format. */ + fun blueFromArgb(argb: Int): Int { + return argb and 255 + } + + /** Returns whether a color in ARGB format is opaque. */ + fun isOpaque(argb: Int): Boolean { + return alphaFromArgb(argb) >= 255 + } + + /** Converts a color from ARGB to XYZ. */ + fun argbFromXyz(x: Double, y: Double, z: Double): Int { + val matrix = XYZ_TO_SRGB + val linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z + val linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z + val linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z + val r = delinearized(linearR) + val g = delinearized(linearG) + val b = delinearized(linearB) + return argbFromRgb(r, g, b) + } + + /** Converts a color from XYZ to ARGB. */ + fun xyzFromArgb(argb: Int): DoubleArray { + val r = linearized(redFromArgb(argb)) + val g = linearized(greenFromArgb(argb)) + val b = linearized(blueFromArgb(argb)) + return MathUtils.matrixMultiply(doubleArrayOf(r, g, b), SRGB_TO_XYZ) + } + + /** Converts a color represented in Lab color space into an ARGB integer. */ + fun argbFromLab(l: Double, a: Double, b: Double): Int { + val whitePoint = WHITE_POINT_D65 + val fy = (l + 16.0) / 116.0 + val fx = a / 500.0 + fy + val fz = fy - b / 200.0 + val xNormalized = labInvf(fx) + val yNormalized = labInvf(fy) + val zNormalized = labInvf(fz) + val x = xNormalized * whitePoint[0] + val y = yNormalized * whitePoint[1] + val z = zNormalized * whitePoint[2] + return argbFromXyz(x, y, z) + } + + /** + * Converts a color from ARGB representation to L*a*b* representation. + * + * @param argb the ARGB representation of a color + * @return a Lab object representing the color + */ + fun labFromArgb(argb: Int): DoubleArray { + val linearR = linearized(redFromArgb(argb)) + val linearG = linearized(greenFromArgb(argb)) + val linearB = linearized(blueFromArgb(argb)) + val matrix = SRGB_TO_XYZ + val x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB + val y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB + val z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB + val whitePoint = WHITE_POINT_D65 + val xNormalized = x / whitePoint[0] + val yNormalized = y / whitePoint[1] + val zNormalized = z / whitePoint[2] + val fx = labF(xNormalized) + val fy = labF(yNormalized) + val fz = labF(zNormalized) + val l = 116.0 * fy - 16 + val a = 500.0 * (fx - fy) + val b = 200.0 * (fy - fz) + return doubleArrayOf(l, a, b) + } + + /** + * Converts an L* value to an ARGB representation. + * + * @param lstar L* in L*a*b* + * @return ARGB representation of grayscale color with lightness matching L* + */ + fun argbFromLstar(lstar: Double): Int { + val y = yFromLstar(lstar) + val component = delinearized(y) + return argbFromRgb(component, component, component) + } + + /** + * Computes the L* value of a color in ARGB representation. + * + * @param argb ARGB representation of a color + * @return L*, from L*a*b*, coordinate of the color + */ + fun lstarFromArgb(argb: Int): Double { + val y = xyzFromArgb(argb)[1] + return 116.0 * labF(y / 100.0) - 16.0 + } + + /** + * Converts an L* value to a Y value. + * + * + * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. + * + * + * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a + * logarithmic scale. + * + * @param lstar L* in L*a*b* + * @return Y in XYZ + */ + fun yFromLstar(lstar: Double): Double { + return 100.0 * labInvf((lstar + 16.0) / 116.0) + } + + /** + * Converts a Y value to an L* value. + * + * + * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. + * + * + * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a + * logarithmic scale. + * + * @param y Y in XYZ + * @return L* in L*a*b* + */ + fun lstarFromY(y: Double): Double { + return labF(y / 100.0) * 116.0 - 16.0 + } + + /** + * Linearizes an RGB component. + * + * @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel + * @return 0.0 <= output <= 100.0, color channel converted to linear RGB space + */ + fun linearized(rgbComponent: Int): Double { + val normalized = rgbComponent / 255.0 + return if (normalized <= 0.040449936) { + normalized / 12.92 * 100.0 + } else { + ((normalized + 0.055) / 1.055).pow(2.4) * 100.0 + } + } + + /** + * Delinearizes an RGB component. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @return 0 <= output <= 255, color channel converted to regular RGB space + */ + fun delinearized(rgbComponent: Double): Int { + val normalized = rgbComponent / 100.0 + var delinearized = 0.0 + delinearized = if (normalized <= 0.0031308) { + normalized * 12.92 + } else { + 1.055 * normalized.pow(1.0 / 2.4) - 0.055 + } + return MathUtils.clampInt(0, 255, (delinearized * 255.0).roundToInt()) + } + + /** + * Returns the standard white point; white on a sunny day. + * + * @return The white point + */ + fun whitePointD65(): DoubleArray { + return WHITE_POINT_D65 + } + + fun labF(t: Double): Double { + val e = 216.0 / 24389.0 + val kappa = 24389.0 / 27.0 + return if (t > e) { + t.pow(1.0 / 3.0) + } else { + (kappa * t + 16) / 116 + } + } + + fun labInvf(ft: Double): Double { + val e = 216.0 / 24389.0 + val kappa = 24389.0 / 27.0 + val ft3 = ft * ft * ft + return if (ft3 > e) { + ft3 + } else { + (116 * ft - 16) / kappa + } + } +} diff --git a/kotlin/lib/commonMain/kotlin/utils/MathUtils.kt b/kotlin/lib/commonMain/kotlin/utils/MathUtils.kt new file mode 100644 index 0000000..faca87e --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/utils/MathUtils.kt @@ -0,0 +1,124 @@ +package utils + +import kotlin.math.abs + +/** Utility methods for mathematical operations. */ +object MathUtils { + /** + * The signum function. + * + * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0 + */ + fun signum(num: Double): Int { + return if (num < 0) { + -1 + } else if (num == 0.0) { + 0 + } else { + 1 + } + } + + /** + * The linear interpolation function. + * + * @return start if amount = 0 and stop if amount = 1 + */ + fun lerp(start: Double, stop: Double, amount: Double): Double { + return (1.0 - amount) * start + amount * stop + } + + /** + * Clamps an integer between two integers. + * + * @return input when min <= input <= max, and either min or max otherwise. + */ + fun clampInt(min: Int, max: Int, input: Int): Int { + if (input < min) { + return min + } else if (input > max) { + return max + } + return input + } + + /** + * Clamps an integer between two floating-point numbers. + * + * @return input when min <= input <= max, and either min or max otherwise. + */ + fun clampDouble(min: Double, max: Double, input: Double): Double { + if (input < min) { + return min + } else if (input > max) { + return max + } + return input + } + + /** + * Sanitizes a degree measure as an integer. + * + * @return a degree measure between 0 (inclusive) and 360 (exclusive). + */ + fun sanitizeDegreesInt(degrees: Int): Int { + var degrees = degrees + degrees = degrees % 360 + if (degrees < 0) { + degrees = degrees + 360 + } + return degrees + } + + /** + * Sanitizes a degree measure as a floating-point number. + * + * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). + */ + fun sanitizeDegreesDouble(degrees: Double): Double { + var degrees = degrees + degrees = degrees % 360.0 + if (degrees < 0) { + degrees = degrees + 360.0 + } + return degrees + } + + /** + * Sign of direction change needed to travel from one angle to another. + * + * + * For angles that are 180 degrees apart from each other, both directions have the same travel + * distance, so either direction is shortest. The value 1.0 is returned in this case. + * + * @param from The angle travel starts from, in degrees. + * @param to The angle travel ends at, in degrees. + * @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads + * to the shortest travel distance. + */ + fun rotationDirection(from: Double, to: Double): Double { + val increasingDifference = sanitizeDegreesDouble(to - from) + return if (increasingDifference <= 180.0) 1.0 else -1.0 + } + + /** Distance of two points on a circle, represented using degrees. */ + fun differenceDegrees(a: Double, b: Double): Double { + return 180.0 - abs(abs(a - b) - 180.0) + } + + /** Multiplies a 1x3 row vector with a 3x3 matrix. */ + fun matrixMultiply(row: DoubleArray, matrix: Array): DoubleArray { + val a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2] + val b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2] + val c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2] + return doubleArrayOf(a, b, c) + } + + fun toDegrees(angrad: Double): Double { + return angrad * 57.29577951308232 + } + + fun toRadians(angdeg: Double): Double { + return angdeg * 0.017453292519943295 + } +} diff --git a/kotlin/lib/commonMain/kotlin/utils/ThemeUtils.kt b/kotlin/lib/commonMain/kotlin/utils/ThemeUtils.kt new file mode 100644 index 0000000..3367728 --- /dev/null +++ b/kotlin/lib/commonMain/kotlin/utils/ThemeUtils.kt @@ -0,0 +1,94 @@ +package utils + +import blend.Blend +import palettes.CorePalette +import quantize.QuantizerCelebi +import scheme.Scheme +import score.Score +import theme.* + +object ThemeUtils { + + fun themeFromSourceColor(source: Int, vararg customColors: CustomColor = emptyArray()): Theme { + val palette = CorePalette.of(source) + return Theme( + source = source, + schemes = Schemes( + Scheme.light(source), + Scheme.dark(source) + ), + palettes = Palettes( + palette.a1, + palette.a2, + palette.a3, + palette.n1, + palette.n2, + palette.error + ), + customColors = customColors.map { customColor(source, it) } + ) + } + + private fun customColor(source: Int, color: CustomColor): CustomColorGroup { + val from = color.value + val value = if (color.blend) { + Blend.harmonize(from, source) + } else from + val palette = CorePalette.of(value) + val tones = palette.a1 + + return CustomColorGroup( + color = color, + value = value, + light = ColorGroup( + color = tones.tone(40), + onColor = tones.tone(100), + colorContainer = tones.tone(90), + onColorContainer = tones.tone(10) + ), + dark = ColorGroup( + color = tones.tone(80), + onColor = tones.tone(20), + colorContainer = tones.tone(30), + onColorContainer = tones.tone(90) + ) + ) + } + + internal fun byteArrayToTheme(pixels: ByteArray, hasAlpha: Boolean, vararg customColors: CustomColor): Theme { + val pixelColors: MutableList = mutableListOf() + + if (hasAlpha) { + var pixel = 0 + while (pixel + 3 < pixels.size) { + var argb = 0 + argb += pixels[pixel].toInt() and 0xff shl 24 + argb += pixels[pixel + 1].toInt() and 0xff + argb += pixels[pixel + 2].toInt() and 0xff shl 8 + argb += pixels[pixel + 3].toInt() and 0xff shl 16 + + pixelColors.add(argb) + pixel += 4 + } + } else { + var pixel = 0 + while (pixel + 2 < pixels.size) { + var argb = 0 + argb += -16777216 + argb += pixels[pixel].toInt() and 0xff + argb += pixels[pixel + 1].toInt() and 0xff shl 8 + argb += pixels[pixel + 2].toInt() and 0xff shl 16 + + pixelColors.add(argb) + pixel += 3 + } + } + + val result = QuantizerCelebi.quantize(pixelColors.toIntArray(), 128) + val ranked = Score.score(result) + val top = ranked[0] + + return themeFromSourceColor(top, *customColors) + } + +} diff --git a/kotlin/lib/jvmMain/kotlin/ExtendTheme.kt b/kotlin/lib/jvmMain/kotlin/ExtendTheme.kt new file mode 100644 index 0000000..85cd226 --- /dev/null +++ b/kotlin/lib/jvmMain/kotlin/ExtendTheme.kt @@ -0,0 +1,29 @@ +import theme.CustomColor +import theme.Theme +import utils.ThemeUtils +import java.awt.Image +import java.awt.image.BufferedImage +import java.awt.image.DataBufferByte + +fun Image.toBufferedImage(): BufferedImage { + if (this is BufferedImage) { + return this + } + val bufferedImage = BufferedImage(this.getWidth(null), this.getHeight(null), BufferedImage.TYPE_INT_ARGB) + + val graphics2D = bufferedImage.createGraphics() + graphics2D.drawImage(this, 0, 0, null) + graphics2D.dispose() + + return bufferedImage +} + +fun ThemeUtils.themeFromImage(image: Image, vararg customColors: CustomColor = emptyArray()): Theme { + val img = image.toBufferedImage() + val pixels = (img.raster.dataBuffer as DataBufferByte).data + val hasAlphaChannel = img.alphaRaster != null + + return byteArrayToTheme(pixels, hasAlphaChannel, *customColors) +} + +fun Image.createTheme(vararg customColors: CustomColor = emptyArray()) = ThemeUtils.themeFromImage(this, *customColors)